gantry-sftp
A modern Python SFTP library that does not implement SSH at all.
OpenSSH already exists and is already installed. gantry-sftp runs it as a subprocess, which
hands back a plaintext, framed SFTP byte stream, so there is zero cryptography in this
package, and key exchange, host-key verification, ssh_config and ProxyJump are all things
you already have rather than things this library reimplements.
What is left is the part that is actually about SFTP: a protocol codec, a request scheduler, and an ergonomics layer.
pip install gantry-sftp
from gantry_sftp.sync import connect
with connect("example.com", user="bob") as sftp:
sftp.get("/incoming/data.parquet", "data.parquet")
result = sftp.put("report.csv", "/outgoing/report.csv")
print(result.mechanism, result.atomic) # posix-rename True
That upload is atomic by default: the bytes go to a hidden staging file, are flushed, and are
renamed over the destination, so a consumer polling that directory sees the old file or the new
one and never a half-written one. result says which mechanism it actually got, because every
step of it is an optional server extension.
No event loop is needed for any of that. The core is async, written against anyio so it runs
on asyncio and trio, and gantry_sftp.sync is a blocking facade over the same code rather than
a second implementation of it. If you are writing a script, stay here. If you are writing a
service, drop the .sync and add async / await:
import anyio
from gantry_sftp import connect
async def main():
async with connect("example.com", user="bob") as sftp:
await sftp.get("/incoming/data.parquet", "data.parquet")
anyio.run(main)
What it needs: read this before you install it
That architecture has a price and it is a single sentence: this library does not implement
SSH, so it needs an SSH client. pip install gantry-sftp does not put one there. It is the
same sentence as the reason to use it, so it is here rather than at the bottom.
- Python 3.13+
- An
sshbinary onPATH, meaningopenssh-client. Not a soft dependency, not vendored, and not optional. - A POSIX host, for transfers.
get/put/get_tree/put_tree/sync_treeneed offset-addressed local I/O and raiseNotImplementedErroron Windows, before anything is sent. Everything that only talks to the far end works there. See Requirements for why, and for the full list. - About 16 MiB of memory per concurrent transfer, which is
depth × request sizeand is independent of the file's size: a 40 GB download costs what a 40 MB one does. Lowerdepthfor a smaller container. If you are on Cloud Run, Lambda or Fly, note also that/tmpis memory there, so a staged download counts against your limit twice. See what a transfer costs in memory, which gives the expression and the way to process a file bigger than the container without staging it.
Your machine already satisfies this and your container probably does not, which is the failure worth pre-empting: it passes locally, then fails on first deploy. Check the image you actually deploy rather than trusting a table. The library will check itself, and needs no server to do it:
$ python -m gantry_sftp doctor
gantry-sftp doctor
local
library 0.5.0 (filexfer v3)
ssh executable ssh -- a bare name, so PATH decides at spawn time
ssh version OpenSSH_10.0p2 Debian-7+deb13u4, OpenSSL 3.5.6 7 Apr 2026
transfers supported
ssh config /home/bob/.ssh/config
environment none of the steering variables are set
defaults depth=64 request_timeout=30.0 idle_timeout=60.0
exit 0 (OK)
Put it in the build and the image that cannot work fails its own build instead of a
customer's first transfer. The exit codes are distinct so a RUN can tell the cases apart:
0 usable · 3 no ssh binary · 4 platform cannot transfer · 5 host unreachable.
RUN python -m gantry_sftp doctor
Add it in a Dockerfile with whichever your base image uses:
RUN apt-get update && apt-get install -y --no-install-recommends openssh-client # Debian/Ubuntu
RUN apk add --no-cache openssh-client # Alpine
RUN dnf install -y openssh-clients # RHEL/Fedora
python:3.13-slim and Alpine images generally need one of those; full python:3.13 and the
Airflow images generally already have ssh. Those are guidance, not guarantees. No CI job
here verifies a base image's contents, so the ssh -V check above is the authoritative
answer for your image and the sentence you should trust.
Where this library cannot run at all: scratch, distroless images, and managed runtimes
with no package manager, such as the AWS Lambda Python runtime. There is no ssh to install
and no way to install one, so the answer is a different base image. A Lambda container
image can install openssh-client and works fine. This is stated plainly rather than
hedged, because finding it out after adopting a library is worse than finding it out now.
If ssh is missing, you get a ConnectError whose hint says all of the above. See
when the connection fails.
Documentation
Start with Getting started. After that the guides are shaped by task rather than by module:
| Guide | What is in it |
|---|---|
| Getting started | Install, the ssh prerequisite, your first transfer, and the same code with and without an event loop |
| Transferring files | get / put, atomic publish, resume, content verification, timestamps, permissions, whole trees, previewing one with dry_run, mirroring one with sync_tree, and the incremental-ingest loop |
| Paths, predicates and attributes | SFTPPath, the bytes-versus-Path rule, exists / is_dir / is_file, a working directory, symlinks and chmod |
| Listing and matching | listdir / scandir, streaming a directory you did not size, and the glob dialect |
| Concurrency and byte ranges | Many transfers over one connection, get_many / put_many, concurrency=, open_file, and reading part of a file |
| Connecting and authenticating | Keys, agents, ssh_config, passwords, restricting where a connection may go, which ssh_config settings this library overrides, and what a failure tells you |
| Reconnecting and timeouts | with_reconnect, retrying a refusal that clears, deadlines on every wait, and stopping a transfer cleanly |
| Seeing what it is doing | Structured logs, session counters, the frame dump, credential redaction, and doctor |
| Does this work against my server? | The compatibility battery, what a finding carries, the write probes and how to nominate a directory for them |
| fsspec, pandas and dask | pd.read_parquet("gantry-sftp://…"), and the two things to know before deploying it |
| Tunables, and what things cost | Every knob and its default, round trips per operation, memory per transfer |
| Why this exists | The design argument, the failures it prevents, and where this library is behind |
| Development | The suite, the lanes, and what each one exists to catch |
| The security model | The trust boundary, what is deliberately not defended, and where each control is proved |
examples/ is the other half of the documentation, and it is executed
rather than described: one runnable example per user-facing feature, each of which works with no
arguments by spawning a real sftp-server on a pipe, and every one of them is run by the test
suite. If you would rather read code than prose, start there.
What it does
- Transfers that tell you what happened.
getandputreturn a result object rather than a byte count: which checks ran, which could not, what a resume adopted, whether the timestamps survived. - Atomic publish by default, with the mechanism named in the result, because every step of it is an optional extension and a downgrade you were not told about is worse than a refusal.
- Resume in both directions, opt-in, and labelled with what it actually proves rather than with a claim that something was proven.
- Content verification on a ladder: server-side hashing where it exists, a re-read where it
does not, and a size check that is always available. It reports
unavailablerather than success when a rung could not run. - A zip-slip defence on every recursive download. Server-supplied names are attacker-supplied names, and every one is validated before it reaches your filesystem.
- Bytes end to end, so a filename that is not valid UTF-8 is an ordinary filename rather than
a
UnicodeDecodeError. - Typed errors carrying state, not strings. A
ConnectErrorholds OpenSSH's own stderr verbatim, aTransferErrorholds both paths and the offset it stopped at. - Timeouts on every wait, including the send, so a transfer cannot hang with nothing to escape it.
- A mirror whose skips carry their evidence.
sync_treesends only what changed, and every file comes back with the reason it was or was not sent — because the dangerous operation in a mirror is the one that does nothing, and a wrong skip leaves stale bytes on the far end under a successful result. - One connection, many transfers, multiplexed over a single
sshchild. - A
pathlib-shaped path object, an fsspec filesystem, and a blocking facade: three ways in besides the async session.
Full detail is in the guides above. python -m gantry_sftp doctor reports what your machine can
actually do; benchmarks/README.md is the lane that measures
performance, and it writes its figures to a report rather than to this file.
Status
0.5.0, and beta rather than alpha: the feature set is complete and the API can still change.
While the major version is 0 a breaking change lands in the minor version, so a patch release is
always safe to take. 0.5.0 is a minor release because it adds API and breaks nothing —
nothing was removed, no signature changed, no default moved, so unlike 0.4.0 there is no handler
anywhere that stops catching. It adds transferring an explicit list of files, get_many and
put_many, on both the async and the blocking surface and with results in the order you asked for
them; and a blocking form of with_reconnect, which the async side had and the blocking side did
not. See CHANGELOG.md for both, and for the one thing doctor was telling you
backwards.
The protocol layer is complete: all 27 filexfer v3 packet types, encoded and decoded, each with a
byte-level fixture asserted in both directions, checked against draft-ietf-secsh-filexfer-02
and OpenSSH's own source. The thesis is proven end to end against a real sshd over a
tc netem-shaped link, and against three different server implementations.
CHANGELOG.md has what is in this release and what its known limitations are:
Windows transfers refuse by design, ssh is a system dependency, and two more. Where this library
is behind is also in Why this exists. Both are
written down rather than left for you to find.
How this was built
This library was built with AI assistance. This work is a collaboration between human writing and AI generation. It was directed, reviewed, and accepted by a human author who takes full responsibility for the final result.
Humans and models produce slop in roughly equal measure. Neither one is the reason software is good or bad. What decides that is the verification: what is actually tested, what is measured against a real system instead of recalled, and which claims something would catch if they stopped being true. A careful human and a careful model with the same test suite land in the same place, and so do a careless one of each.
So the rules for this repository are aimed at that, and they are enforced rather than professed. The specific failure mode worth designing against is confident plausibility: a packet layout recalled from memory looks exactly like one read off a wire, and a fallback described in a docstring reads exactly like a fallback somebody tested.
- Byte layouts are validated against the source, never from memory:
draft-ietf-secsh-filexfer-02and OpenSSH's ownPROTOCOLandsftp.h. Every one of the 27 packet types carries a byte-level fixture asserted on encode and decode, because a codec tested only against its own encoder is tested against nothing. - Claims about servers are measured, not remembered. Extension behaviour, status codes, and
the argument order of
SYMLINK(which the reference server reverses relative to the draft) were each settled by asking a real server and keeping the answer. The suite drives the genuine OpenSSHsftp-server, and a matrix lane drives three different implementations, because a fake only ever confirms what its author already believed. - A prevention claim without a test is a rumour. The table in
Why this exists names the test for each row.
Documentation facts are pinned the same way: the memory figure is derived from the shipped
constants rather than typed, the
sshhint is quoted from the code that produces it, and every link in these documents is checked to resolve. - Mutation testing on the codec, because a passing suite proves the tests ran, not that they would have noticed.
None of that makes the code correct. It makes the claims checkable, which is the part you cannot verify by reading a diff, and it is the standard this project should be held to no matter who or what typed it.
Requirements
- Python 3.13+
- A POSIX host. Transfers need offset-addressed local I/O:
getplaces every payload withos.pwriteat the offset its request asked for,putreads withos.preadfrom a worker thread, andpreserve_timesstamps a descriptor rather than a path. All three are Unix-only in CPython, and they are not incidental: writing at an explicit offset is why writes need no ordering and why a shortREADis re-queued rather than restarting the transfer. On Windowsget/get_tree/put/put_tree/sync_treeraiseNotImplementedErrornaming what is missing, before anything is sent and before any local file is touched. Everything that talks only to the far end (connecting,listdir,scandir,walk,stat,realpath,rename,remove,mkdir,rmdir,rmtreeandcheck_file) is platform-independent and works there. A Windows fallback is open work, not a decision against it. - An
sshbinary onPATH(openssh-client). Windows ships one at%SystemRoot%\System32\OpenSSH\ssh.exe. The container story, the install commands and the platforms where this cannot run are on the first screen, under What it needs, rather than repeated here. One copy, so the two cannot drift. openssh-server, only to run the real-server test lane, never at runtime.
Development
The suite runs with no network, no containers and no keys. It drives the genuine OpenSSH
sftp-server over a pipe:
uv sync --all-extras
.venv/bin/python -m pytest
Development covers the rest: the CI matrix, the controlled ssh
environment every security assertion depends on, and the four lanes that run longer than a commit
hook: leak detection, tc netem link shaping, benchmarks, and mutation testing.
Security
Found something? Please report it privately — open a security advisory, or email kevin@oneil.xyz if you would rather not use GitHub.
SECURITY.md has the reporting scope and
the security model has the trust boundary in full. The scope is worth
reading before you start: this
library contains no cryptography and does not implement SSH, so a finding about ciphers or
host-key algorithms belongs to OpenSSH rather than here. What is ours is everything a hostile
server can send us, how we build the ssh argument vector, and where a credential can end up.
License
Apache-2.0.
Release files for gantry-sftp 0.5.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| gantry_sftp-0.5.0.tar.gz | 1.5 MB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| gantry_sftp-0.5.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 1.9 MB
Release files / gantry_sftp-0.5.0.tar.gz
| Download URL | gantry_sftp-0.5.0.tar.gz |
|---|---|
| Size | 1.5 MB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
c31f07d3dfba7e92e108b777bdb363207325487ecf21c984dd18cdfa0e3b5acc
|
|
BLAKE2b-256 checksum How to use checksums |
d69ef9783e1ec9ee8c31d1c8d792f5247090e319793790ab3601bf4ecef82f63
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 17, 2026.
Transparency logRelease files / gantry_sftp-0.5.0-py3-none-any.whl
| Download URL | gantry_sftp-0.5.0-py3-none-any.whl |
|---|---|
| Size | 463.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
8aa3be2ad9c3d64ebc885d152a71128c5d50d7c0f5a89328ddc5b663ce917819
|
|
BLAKE2b-256 checksum How to use checksums |
6cb8cb7ac11305531e52ca289fe5fd5bff3050471bb374e37c3fc84fb460d924
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 17, 2026.
Transparency log