Streaming line reader for large remote log files over SSH.
Project description
ssh-logstream
Streaming line reader for large remote log files over SSH.
ssh-logstream provides a fast, memory-bounded way to iterate over text lines
from a file located on a remote machine via SSH without copying the file
locally and without loading the entire file into memory.
It is designed for large log processing pipelines where remote files may be hundreds of megabytes or gigabytes in size.
Features
-
True streaming over SSH
- Reads remote files incrementally
- No local file copy required
- No full file buffering
-
Bounded memory usage
- Memory stays flat regardless of file size
- Controlled buffering for partial lines
- Safe handling of oversized lines
-
Deterministic file resolution
- Searches within a specified remote folder
- Enforces exactly one match
- Detects ambiguity and missing files
-
Incremental decoding
- Handles chunk boundaries safely
- Configurable encoding and error policy
-
Snapshot and follow modes
- Read full file once
- Or continuously stream appended log data
-
Fully typed
- Includes
py.typedfor static type checking
- Includes
Installation
Install from PyPI:
pip install ssh-logstream
Or install from source:
pip install .
Or in development mode:
pip install -e .[dev]
Python 3.10+ is required.
Quick Start
from ssh_logstream import LineStreamer, SshConfig
ssh_config = SshConfig(
host="example-host",
username="logsync",
private_key_path="~/.ssh/id_ed25519",
)
streamer = LineStreamer(
folder="/var/log/myapp",
filename="app.log",
ssh_config=ssh_config,
)
for line in streamer.stream():
print(line)
This will:
- connect to the remote host via SSH
- locate
app.loginside/var/log/myapp - stream decoded lines one by one
No file download or full buffering occurs.
Configuration
Streaming behavior can be configured using LineStreamerConfig.
from ssh_logstream import LineStreamer, LineStreamerConfig, SshConfig
ssh_config = SshConfig(
host="example-host",
username="logsync",
private_key_path="~/.ssh/id_ed25519",
)
config = LineStreamerConfig(
chunk_size=1 << 20, # 1 MiB SSH read chunks
encoding="utf-8",
errors="replace",
max_line_bytes=32 * (1 << 20), # 32 MiB max line buffer
)
streamer = LineStreamer(
folder="/var/log/myapp",
filename="app.log",
ssh_config=ssh_config,
config=config,
)
for line in streamer.stream():
print(line)
Configuration options
| Option | Description |
|---|---|
chunk_size |
Number of bytes read per chunk from SSH stream |
encoding |
Text encoding used when decoding bytes |
errors |
Error handler for decoding |
max_line_bytes |
Max buffered bytes for a partial line |
Notes
chunk_sizemust be positivemax_line_bytesmust be >=chunk_size- oversized lines are safely handled to prevent memory growth
- BOM-injecting encodings (
utf-16,utf-32,utf-8-sig) are rejected; useutf-16-le,utf-16-be,utf-32-le, orutf-32-beinstead
Remote File Resolution
The library must resolve exactly one file.
The default resolver behaves as follows:
- Search recursively inside the given folder
- If
filenamecontains no path separator, prefer exact basename matches - If no basename match exists, fall back to suffix matching
- If multiple matches exist,
RemoteFileAmbiguityErroris raised - If no match exists,
RemoteFileNotFoundErroris raised
This ensures deterministic behavior.
Example remote folder:
/var/log/myapp/
|-- service-a/app.log
`-- service-b/app.log
Calling:
from ssh_logstream import LineStreamer, SshConfig
ssh_config = SshConfig(
host="example-host",
username="logsync",
private_key_path="~/.ssh/id_ed25519",
)
LineStreamer(
folder="/var/log/myapp",
filename="app.log",
ssh_config=ssh_config,
)
would raise an ambiguity error because two files match.
You can instead target a specific suffix:
from ssh_logstream import LineStreamer, SshConfig
ssh_config = SshConfig(
host="example-host",
username="logsync",
private_key_path="~/.ssh/id_ed25519",
)
LineStreamer(
folder="/var/log/myapp",
filename="service-a/app.log",
ssh_config=ssh_config,
)
SSH Configuration
from ssh_logstream import SshConfig
ssh_config = SshConfig(
host="example-host",
port=22,
username="logsync",
private_key_path="~/.ssh/id_ed25519",
connect_timeout_seconds=30.0,
keepalive_seconds=10,
known_hosts_path="~/.ssh/known_hosts",
allow_unknown_hosts=False,
)
Authentication
- Use private key (recommended)
- Or password (less secure)
- At least one of
private_key_pathorpasswordis required
Host verification
- Unknown hosts are rejected by default
- Set
allow_unknown_hosts=Trueonly in controlled environments
Streaming Modes
Snapshot Mode (default)
- Reads file from beginning to end
- Terminates at EOF
Follow Mode
- Continuously streams appended lines
- Suitable for live logs
- The iterator does not terminate at EOF; the caller is responsible for breaking out of the loop
Public API
from ssh_logstream import (
LineStreamer,
LineStreamerConfig,
SshConfig,
StreamStats,
SshLogStreamError,
ConfigurationError,
SshConnectionError,
RemoteFileNotFoundError,
RemoteFileAmbiguityError,
)
Session stats
After (or during) a stream() call, streamer.stats exposes a StreamStats object:
from ssh_logstream import LineStreamer, SshConfig
ssh_config = SshConfig(
host="example-host",
username="logsync",
private_key_path="~/.ssh/id_ed25519",
)
streamer = LineStreamer(
folder="/var/log/myapp",
filename="app.log",
ssh_config=ssh_config,
)
for line in streamer.stream():
print(line)
stats = streamer.stats
print(f"{stats.lines_emitted} lines / {stats.bytes_read / 1e6:.1f} MB / {stats.chunks_read} chunks")
| Field | Description |
|---|---|
bytes_read |
Raw bytes received from the SSH channel |
chunks_read |
SSH read calls issued |
lines_emitted |
Complete lines yielded, including the final partial line |
Stats reset when a new stream() iterator begins producing values.
Error Handling
from ssh_logstream import (
ConfigurationError,
LineStreamer,
RemoteFileAmbiguityError,
RemoteFileNotFoundError,
SshConfig,
)
try:
ssh_config = SshConfig(
host="example-host",
username="logsync",
private_key_path="~/.ssh/id_ed25519",
)
streamer = LineStreamer(
folder="/var/log/myapp",
filename="app.log",
ssh_config=ssh_config,
)
for line in streamer.stream():
print(line)
except RemoteFileNotFoundError:
print("remote file not found")
except RemoteFileAmbiguityError:
print("remote file resolution was ambiguous")
except ConfigurationError:
print("configuration was invalid")
Line Semantics
- newline characters removed
- CRLF normalized
- final partial line emitted
- empty lines preserved
- oversized lines handled safely
Bounded Memory Behavior
Memory usage depends only on:
chunk_size- current partial line
- decoding state
It does not depend on file size.
This allows streaming arbitrarily large remote files safely.
Project Structure
ssh-logstream/
|-- .github/
| `-- workflows/
| |-- ci.yml
| `-- release.yml
|-- src/
| `-- ssh_logstream/
| |-- __init__.py
| |-- py.typed
| |-- client/
| | `-- line_streamer.py
| |-- config/
| | |-- ssh.py
| | `-- streamer.py
| |-- resolver/
| | `-- remote_file_resolver.py
| |-- transport/
| | `-- ssh_transport.py
| |-- streaming/
| | |-- decoder.py
| | |-- line_buffer.py
| | `-- line_streamer_core.py
| |-- commands/
| | `-- ssh_commands.py
| |-- models/
| | |-- line_record.py
| | |-- resolved_file.py
| | `-- stream_stats.py
| `-- errors/
| `-- exceptions.py
|-- tests/
| |-- unit/
| `-- integration/
|-- scripts/
| |-- benchmark_streaming.py
| |-- benchmark_streaming.example.json
| |-- stress_large_remote_file.example.json
| `-- stress_large_remote_file.py
|-- docs/
| |-- architecture.md
| |-- streaming-model.md
| |-- configuration.md
| |-- api.md
| |-- error-model.md
| `-- development.md
|-- README.md
|-- LICENSE
`-- pyproject.toml
Structure Overview
client/-- public API (LineStreamer)config/-- configuration objects and validationresolver/-- remote file discoverytransport/-- SSH execution and byte streamingstreaming/-- decoding and line assemblycommands/-- remote shell command constructionmodels/-- structured data objectserrors/-- exception hierarchy
This separation ensures:
- clear responsibility boundaries
- maintainable architecture
- easier testing and extension
- compatibility with AI-assisted development
Development
pip install -e .[dev]
pytest
Manual performance tools are also available under scripts/ for on-demand
benchmarking and large-file stress testing, with both real SSH and local
no-SSH modes. Both scripts also accept --config PATH so you can keep saved
benchmark settings in JSON files, and in local mode generated benchmark files
are reused if they already exist.
License
MIT License
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file ssh_logstream-0.1.1.tar.gz.
File metadata
- Download URL: ssh_logstream-0.1.1.tar.gz
- Upload date:
- Size: 23.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
be4ae96a134e1122ac62dbc9b8580ebbf65fb8d63854b79893490073432676ac
|
|
| MD5 |
d1f4b95915f5f843bbb6e536c5fef1c1
|
|
| BLAKE2b-256 |
b118b05a7a32c3ffe4f75b8fb16e963ef7085bb8b46a1ab273d58cfb5c9b6651
|
Provenance
The following attestation bundles were made for ssh_logstream-0.1.1.tar.gz:
Publisher:
release.yml on DreamyStranger/ssh-logstream
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
ssh_logstream-0.1.1.tar.gz -
Subject digest:
be4ae96a134e1122ac62dbc9b8580ebbf65fb8d63854b79893490073432676ac - Sigstore transparency entry: 1149590847
- Sigstore integration time:
-
Permalink:
DreamyStranger/ssh-logstream@85ba607fee1c2b6b7581c1f5e3bad3fa8b5c1d63 -
Branch / Tag:
refs/tags/v0.1.1 - Owner: https://github.com/DreamyStranger
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@85ba607fee1c2b6b7581c1f5e3bad3fa8b5c1d63 -
Trigger Event:
push
-
Statement type:
File details
Details for the file ssh_logstream-0.1.1-py3-none-any.whl.
File metadata
- Download URL: ssh_logstream-0.1.1-py3-none-any.whl
- Upload date:
- Size: 26.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
361ad91e490de45220b8e8f6d8d076c9d2d6dda33f900e5891087522ca3f5c0d
|
|
| MD5 |
a65c42d6b1f1b384f5c6157cf9c0a4ec
|
|
| BLAKE2b-256 |
4623b780a0acd8df9ae3dc9cf6113d32aeb5a87f69810fa80ea261288fc5f6d1
|
Provenance
The following attestation bundles were made for ssh_logstream-0.1.1-py3-none-any.whl:
Publisher:
release.yml on DreamyStranger/ssh-logstream
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
ssh_logstream-0.1.1-py3-none-any.whl -
Subject digest:
361ad91e490de45220b8e8f6d8d076c9d2d6dda33f900e5891087522ca3f5c0d - Sigstore transparency entry: 1149590908
- Sigstore integration time:
-
Permalink:
DreamyStranger/ssh-logstream@85ba607fee1c2b6b7581c1f5e3bad3fa8b5c1d63 -
Branch / Tag:
refs/tags/v0.1.1 - Owner: https://github.com/DreamyStranger
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@85ba607fee1c2b6b7581c1f5e3bad3fa8b5c1d63 -
Trigger Event:
push
-
Statement type: