sftppath 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. sftppath-0.1.0/.gitignore +41 -0
  2. sftppath-0.1.0/CHANGELOG.md +104 -0
  3. sftppath-0.1.0/LICENSE +21 -0
  4. sftppath-0.1.0/PKG-INFO +190 -0
  5. sftppath-0.1.0/README.md +143 -0
  6. sftppath-0.1.0/docs/api/exceptions.md +10 -0
  7. sftppath-0.1.0/docs/api/sftp.md +17 -0
  8. sftppath-0.1.0/docs/api/uri.md +8 -0
  9. sftppath-0.1.0/docs/benchmarks.md +61 -0
  10. sftppath-0.1.0/docs/changelog.md +3 -0
  11. sftppath-0.1.0/docs/divergences.md +24 -0
  12. sftppath-0.1.0/docs/guides/paths.md +74 -0
  13. sftppath-0.1.0/docs/guides/uri.md +62 -0
  14. sftppath-0.1.0/docs/index.md +75 -0
  15. sftppath-0.1.0/examples/sftp_sync.py +60 -0
  16. sftppath-0.1.0/mkdocs.yml +58 -0
  17. sftppath-0.1.0/pyproject.toml +95 -0
  18. sftppath-0.1.0/src/sftppath/AGENTS.md +411 -0
  19. sftppath-0.1.0/src/sftppath/__init__.py +70 -0
  20. sftppath-0.1.0/src/sftppath/_asyncssh/__init__.py +7 -0
  21. sftppath-0.1.0/src/sftppath/_asyncssh/_attrs.py +99 -0
  22. sftppath-0.1.0/src/sftppath/_asyncssh/_backend.py +212 -0
  23. sftppath-0.1.0/src/sftppath/_asyncssh/_cache.py +130 -0
  24. sftppath-0.1.0/src/sftppath/_asyncssh/_client.py +187 -0
  25. sftppath-0.1.0/src/sftppath/_asyncssh/_connect.py +75 -0
  26. sftppath-0.1.0/src/sftppath/_asyncssh/_file.py +113 -0
  27. sftppath-0.1.0/src/sftppath/_asyncssh/_loop.py +164 -0
  28. sftppath-0.1.0/src/sftppath/_asyncssh/_translate.py +98 -0
  29. sftppath-0.1.0/src/sftppath/_asyncssh/_walk.py +442 -0
  30. sftppath-0.1.0/src/sftppath/_backend.py +133 -0
  31. sftppath-0.1.0/src/sftppath/_checkfile.py +216 -0
  32. sftppath-0.1.0/src/sftppath/_errors.py +104 -0
  33. sftppath-0.1.0/src/sftppath/_exit.py +57 -0
  34. sftppath-0.1.0/src/sftppath/_namespace.py +35 -0
  35. sftppath-0.1.0/src/sftppath/_paramiko.py +564 -0
  36. sftppath-0.1.0/src/sftppath/_paramiko_config.py +65 -0
  37. sftppath-0.1.0/src/sftppath/_path.py +732 -0
  38. sftppath-0.1.0/src/sftppath/_rename.py +33 -0
  39. sftppath-0.1.0/src/sftppath/_select.py +128 -0
  40. sftppath-0.1.0/src/sftppath/_sshconfig.py +258 -0
  41. sftppath-0.1.0/src/sftppath/_version.py +11 -0
  42. sftppath-0.1.0/src/sftppath/exceptions.py +28 -0
  43. sftppath-0.1.0/src/sftppath/py.typed +0 -0
  44. sftppath-0.1.0/src/sftppath/uri.py +478 -0
  45. sftppath-0.1.0/tests/capabilities.py +52 -0
  46. sftppath-0.1.0/tests/child.py +45 -0
  47. sftppath-0.1.0/tests/conftest.py +279 -0
  48. sftppath-0.1.0/tests/hermetic.py +261 -0
  49. sftppath-0.1.0/tests/mutation/README.md +66 -0
  50. sftppath-0.1.0/tests/mutation/mutants.py +302 -0
  51. sftppath-0.1.0/tests/mutation/mutation_check.py +16 -0
  52. sftppath-0.1.0/tests/mutation/run.py +224 -0
  53. sftppath-0.1.0/tests/probes/probe_environment_leak.py +12 -0
  54. sftppath-0.1.0/tests/probes/probe_leaks_a_socket.py +12 -0
  55. sftppath-0.1.0/tests/probes/probe_session_environment.py +36 -0
  56. sftppath-0.1.0/tests/server_loops.py +43 -0
  57. sftppath-0.1.0/tests/sftp_loopback.py +310 -0
  58. sftppath-0.1.0/tests/sftp_support.py +80 -0
  59. sftppath-0.1.0/tests/test_contract.py +30 -0
  60. sftppath-0.1.0/tests/test_documentation.py +171 -0
  61. sftppath-0.1.0/tests/test_examples_skip.py +62 -0
  62. sftppath-0.1.0/tests/test_hermetic.py +329 -0
  63. sftppath-0.1.0/tests/test_isolation.py +138 -0
  64. sftppath-0.1.0/tests/test_packaging.py +93 -0
  65. sftppath-0.1.0/tests/test_public_surface.py +168 -0
  66. sftppath-0.1.0/tests/test_sftp.py +2173 -0
  67. sftppath-0.1.0/tests/test_sftp_asyncssh.py +1195 -0
  68. sftppath-0.1.0/tests/test_sftp_attributes.py +633 -0
  69. sftppath-0.1.0/tests/test_sftp_cost.py +271 -0
  70. sftppath-0.1.0/tests/test_sftp_errors.py +449 -0
  71. sftppath-0.1.0/tests/test_sftp_failed_write.py +54 -0
  72. sftppath-0.1.0/tests/test_sftp_native_walk.py +1165 -0
  73. sftppath-0.1.0/tests/test_sftp_paramiko_cache.py +81 -0
  74. sftppath-0.1.0/tests/test_sftp_replace.py +292 -0
  75. sftppath-0.1.0/tests/test_sftp_ssh_config.py +884 -0
  76. sftppath-0.1.0/tests/test_sftp_transport.py +956 -0
  77. sftppath-0.1.0/tests/test_sftp_utime.py +144 -0
  78. sftppath-0.1.0/tests/test_shipped_header.py +455 -0
  79. sftppath-0.1.0/tests/test_sync_sftp.py +147 -0
  80. sftppath-0.1.0/tests/test_sync_sftp_parity.py +656 -0
  81. sftppath-0.1.0/tests/test_uri_config_scope.py +200 -0
  82. sftppath-0.1.0/tests/test_uri_scheme.py +440 -0
  83. sftppath-0.1.0/tests/test_uri_sftp.py +755 -0
  84. sftppath-0.1.0/tests/test_version.py +24 -0
  85. sftppath-0.1.0/tests/waits.py +17 -0
@@ -0,0 +1,41 @@
1
+ # Category rule: nothing dot-prefixed at the repo root gets tracked except the
2
+ # files re-included below (a category beats a list). Root-anchored on purpose;
3
+ # nested dotfiles are governed by the specific rules further down.
4
+ /.*
5
+ !/.gitignore
6
+ !/.gitattributes
7
+ !/.github/
8
+
9
+ # Private agent config. No trailing slash on `.agents` on purpose: it may be a
10
+ # symlink, which git treats as a file, so a directory-only `.agents/` would not
11
+ # match it. These stay even though /.* covers the root: they also catch nested
12
+ # copies. *.local.* is an unshared, machine- or user-specific override and is
13
+ # never committed (it is also excluded from the build in pyproject.toml).
14
+ .agents
15
+ *.local.*
16
+ CLAUDE*
17
+ .claude
18
+
19
+ # Build/dist output and generated docs site
20
+ build/
21
+ dist/
22
+ site/
23
+
24
+ # Python
25
+ __pycache__/
26
+ *.py[cod]
27
+ .pytest_cache/
28
+ .hypothesis/
29
+ .mypy_cache/
30
+ .ruff_cache/
31
+ .coverage*
32
+ htmlcov/
33
+ *.egg-info/
34
+ .venv/
35
+ .pyvenv/
36
+
37
+ # OS/editor
38
+ .DS_Store
39
+ Thumbs.db
40
+ .idea/
41
+ .vscode/
@@ -0,0 +1,104 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2026-10-14
11
+
12
+ The first release of `sftppath`: the SFTP support of `pathlib-next` 0.9.14
13
+ (`pathlib_next.uri.schemes.sftp`) as a distribution of its own, with a path that
14
+ needs no URI. It needs Python 3.9 or newer and `pathapi>=0.1.0,<0.2`.
15
+ Neither SSH library is required to import it: the extras `paramiko`
16
+ (`paramiko>=5.0,<6`) and `asyncssh` (`asyncssh>=2.20.0`, below 3) add one, and
17
+ `uri` (`pathapi-uri>=0.1.0,<0.2`) adds the `sftp:` scheme. With neither SSH library a
18
+ path can still be built, joined and compared, and its first request raises an
19
+ `ImportError` that names both extras; naming `SFTPBackend` or `AsyncSSHBackend`
20
+ without its library raises one that names its extra. Under *Changed*, the
21
+ migration from `pathlib_next.uri.schemes.sftp` comes in this order: the table of
22
+ names, the table of extras, then every change of behaviour with what to do about
23
+ it.
24
+
25
+ It is built on `pathapi`, which was published as `pathlib-next` up to 0.10.0 (import
26
+ name `pathlib_next`); the old names below are the ones before the move.
27
+
28
+ ### Added
29
+
30
+ - `SFTPPath`, a `pathapi.Path` for a directory tree on an SFTP server, built
31
+ from keywords: `SFTPPath("/srv/x", host="h", port=2222, user="u", password="p")`.
32
+ Nothing connects until the first request. `backend=` shares one connection
33
+ between paths built separately; a path joined from another keeps its backend.
34
+ - `SFTPBackend` (paramiko), `AsyncSSHBackend` (asyncssh), `BaseSFTPBackend` and
35
+ `CheckFileSFTPBackend`, each the connections to one server and account. Every
36
+ backend is a context manager, `close()` returns when the connections are
37
+ closed, and may be called twice. `SFTPEndpoint`, `DEFAULT_PORT` and
38
+ `DEFAULT_SSH_CONFIG` are exported too.
39
+ - `sftppath.exceptions`: `SFTPPathError`, a `pathapi.PathError`, with
40
+ `SFTPAuthenticationError` (also `PermissionError`) and `SFTPHostKeyError`
41
+ (also `ConnectionError`).
42
+ - `sftppath.uri`: `SFTPURIPath`, the `sftp:` scheme of `uripath`, found by
43
+ `uripath` through the entry point `sftp` of the group `uripath.schemes`, and
44
+ `SFTPURIBackend`, which builds the backend of each server a URI names. Importing
45
+ `sftppath.uri` without `uripath` raises an `ImportError` that names
46
+ `pip install "sftppath[uri]"`; `import sftppath` never imports `uripath`.
47
+ - `SFTPPath.is_local()`: true for a loopback address, `localhost` and a backend
48
+ with no host, so `PathSyncer(quick_check=True)` applies to a real server.
49
+ - `sftppath.__version__`, read from the installed metadata.
50
+
51
+ ### Changed
52
+
53
+ - The names. `pathlib_next.uri.schemes.sftp` is gone with no alias.
54
+
55
+ | Was | Now |
56
+ | --- | --- |
57
+ | `SftpPath` (a `UriPath`) | `sftppath.SFTPPath` for a path built from keywords; `sftppath.uri.SFTPURIPath` for `URIPath("sftp://...")` |
58
+ | `SftpBackend` | `sftppath.SFTPBackend` |
59
+ | `AsyncsshSftpBackend` | `sftppath.AsyncSSHBackend` |
60
+ | `BaseSftpBackend` | `sftppath.BaseSFTPBackend` |
61
+ | `CheckFileSftpBackend` | `sftppath.CheckFileSFTPBackend` |
62
+ | `SftpAuthenticationError`, `SftpHostKeyError` | `sftppath.SFTPAuthenticationError`, `sftppath.SFTPHostKeyError` (in `sftppath.exceptions`) |
63
+ | `_DEFAULT_SSH_CONFIG` | `sftppath.DEFAULT_SSH_CONFIG` |
64
+ | `PATHLIB_NEXT_SFTP_BACKEND` | `SFTPPATH_BACKEND`; the old variable is not read |
65
+ | the logger `pathlib_next.sftp` | the logger `sftppath` |
66
+ | the entry-point group `pathlib_next.schemes`, entry `sftp` | the group `uripath.schemes`, entry `sftp = "sftppath.uri:SFTPURIPath"` |
67
+
68
+ - What to install where `pathlib-next` had an extra. `pathlib-next` 0.10 has no
69
+ `sftp` or `sftp-async` extra.
70
+
71
+ | Was | Install now |
72
+ | --- | --- |
73
+ | `pathlib-next[sftp]` | `sftppath[paramiko]` |
74
+ | `pathlib-next[sftp-async]` | `sftppath[asyncssh]` |
75
+ | either, for `UriPath("sftp://...")` | add `sftppath[uri]` (or install `uripath` beside it) |
76
+
77
+ - A backend is the connections to one server and account. Was: one backend
78
+ served every host a path named, and `client(source)`, `opts(source)` and
79
+ `transport(source)` took the `Source` of the URI; a backend was built with its
80
+ options only (`SftpBackend(connect_opts, hostkeypolicy, ...)`). Now the host,
81
+ port, user and password are arguments of the backend
82
+ (`SFTPBackend("h", 2222, user="u", password="p", connect_opts=...)`,
83
+ `AsyncSSHBackend("h", ...)`), and `client()`, `opts()` and `transport()` take
84
+ nothing. A `BaseSFTPBackend` subclass overrides `client(self)`. For URI paths,
85
+ give `backend=` an `SFTPURIBackend(SFTPBackend, connect_opts=...)` (the
86
+ options of the backend class; the user, password and port come from the URI)
87
+ or a `BaseSFTPBackend` for the one server. `BaseSftpBackend.default()` and the
88
+ `default(ssh_config=)` classmethods are removed.
89
+ - A relative or empty path is read from the server root. Was: `sftp://host` (no
90
+ path) listed the home directory of the account. Now it is `/`, and
91
+ `SFTPPath("x", host=h)` is `/x`. Name the directory you mean
92
+ (`/home/user`).
93
+ - `SFTPPath.readlink()` returns a path on the same backend, so dot segments of
94
+ the target collapse as they do for `pathlib`. The `SFTPURIPath` keeps the text
95
+ verbatim in `.path`.
96
+ - `SFTPPath.iterdir()` yields children without the attributes the listing
97
+ carried, so `is_dir()` on each costs a request; `walk()`, `glob()` and
98
+ `rm(recursive=True)` still use the listing. The children an `SFTPURIPath`
99
+ yields carry them, as before.
100
+ - An error that has a file name and no second one prints only the first; it
101
+ printed `-> None` after it.
102
+
103
+ [Unreleased]: https://github.com/jose-pr/sftppath/compare/v0.1.0...HEAD
104
+ [0.1.0]: https://github.com/jose-pr/sftppath/releases/tag/v0.1.0
sftppath-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Jose A
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,190 @@
1
+ Metadata-Version: 2.5
2
+ Name: sftppath
3
+ Version: 0.1.0
4
+ Summary: pathlib-style paths for SFTP servers, on paramiko or asyncssh, and the sftp: scheme for uripath
5
+ Project-URL: Homepage, https://github.com/jose-pr/sftppath/
6
+ Project-URL: Documentation, https://jose-pr.github.io/sftppath/
7
+ Project-URL: Issues, https://github.com/jose-pr/sftppath/issues
8
+ Author: Jose A
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.9
22
+ Requires-Dist: pathapi<0.2,>=0.1.0
23
+ Provides-Extra: all
24
+ Requires-Dist: asyncssh<2.22,>=2.20.0; (python_version < '3.10') and extra == 'all'
25
+ Requires-Dist: asyncssh<3,>=2.20.0; (python_version >= '3.10') and extra == 'all'
26
+ Requires-Dist: paramiko<6,>=5.0; extra == 'all'
27
+ Requires-Dist: pathapi-uri<0.2,>=0.1.0; extra == 'all'
28
+ Provides-Extra: asyncssh
29
+ Requires-Dist: asyncssh<2.22,>=2.20.0; (python_version < '3.10') and extra == 'asyncssh'
30
+ Requires-Dist: asyncssh<3,>=2.20.0; (python_version >= '3.10') and extra == 'asyncssh'
31
+ Provides-Extra: dev
32
+ Requires-Dist: black; extra == 'dev'
33
+ Requires-Dist: build; extra == 'dev'
34
+ Requires-Dist: hatchling; extra == 'dev'
35
+ Requires-Dist: pytest; extra == 'dev'
36
+ Requires-Dist: pytest-cov; extra == 'dev'
37
+ Requires-Dist: twine; extra == 'dev'
38
+ Provides-Extra: docs
39
+ Requires-Dist: mkdocs; extra == 'docs'
40
+ Requires-Dist: mkdocs-material; extra == 'docs'
41
+ Requires-Dist: mkdocstrings[python]; extra == 'docs'
42
+ Provides-Extra: paramiko
43
+ Requires-Dist: paramiko<6,>=5.0; extra == 'paramiko'
44
+ Provides-Extra: uri
45
+ Requires-Dist: pathapi-uri<0.2,>=0.1.0; extra == 'uri'
46
+ Description-Content-Type: text/markdown
47
+
48
+ # sftppath
49
+
50
+ [![Version](https://img.shields.io/pypi/v/sftppath.svg)](https://pypi.org/project/sftppath/)
51
+ [![Python versions](https://img.shields.io/pypi/pyversions/sftppath.svg)](https://pypi.org/project/sftppath/)
52
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/jose-pr/sftppath/blob/main/LICENSE)
53
+ [![Docs](https://img.shields.io/badge/docs-latest-blue.svg)](https://jose-pr.github.io/sftppath/)
54
+ [![CI](https://img.shields.io/github/actions/workflow/status/jose-pr/sftppath/test.yml)](https://github.com/jose-pr/sftppath/actions/workflows/test.yml)
55
+
56
+ **`pathlib` for SFTP servers.** `SFTPPath("/srv/data/report.txt", host="files.example.org")`
57
+ has the method names, signatures and exception types of `pathlib.Path`, over
58
+ paramiko or asyncssh, and needs no URI. It builds on
59
+ [`pathapi`](https://github.com/jose-pr/pathapi) alone; with
60
+ [`uripath`](https://github.com/jose-pr/uripath) installed it also provides the
61
+ `sftp:` scheme. It is documented at <https://jose-pr.github.io/sftppath/>.
62
+
63
+ ## Features
64
+
65
+ - **A `pathlib` path on a server**: `open`, `read_text`, `iterdir`, `glob`,
66
+ `stat`, `mkdir`, `rename`, `replace`, `chmod`, `symlink_to`, `readlink` and
67
+ the rest, plus `walk`, `copy`, `move`, `rm` and `PathSyncer` from
68
+ `pathapi`. Nothing connects until the first request.
69
+ - **Two backends, one behaviour**: `SFTPBackend` (paramiko) and `AsyncSSHBackend`
70
+ (asyncssh) raise the same exceptions for the same server answers; the choice is
71
+ a keyword, a subclass attribute or `SFTPPATH_BACKEND`.
72
+ - **Native tree operations**: on asyncssh a recursive `copy()` and `rm()` run as
73
+ concurrent requests on one connection, bounded by `max_concurrency`.
74
+ - **ssh_config**: `HostName`, `Port`, `User`, `IdentityFile`, `ProxyCommand` and
75
+ `Include` apply to the host you name; host keys are verified.
76
+ - **Server-side checksums**: `checksum()` uses the `check-file-handle`
77
+ extension where the server has it, and `PathSyncer` falls back to streaming
78
+ where it does not.
79
+ - **No SSH library at import**: `import sftppath` loads neither library nor
80
+ `uripath`. Errors are `OSError` subclasses, `SFTPAuthenticationError` and
81
+ `SFTPHostKeyError`; no library exception crosses the API.
82
+ - **The `sftp:` scheme** for `uripath`: `URIPath("sftp://user@host/dir")` is an
83
+ `SFTPURIPath` that delegates to an `SFTPPath`.
84
+
85
+ ## Installation
86
+
87
+ ```bash
88
+ pip install "sftppath[paramiko]"
89
+ ```
90
+
91
+ Requires `pathapi`, which pip installs with it, and Python 3.9 or newer.
92
+
93
+ | Install | Adds |
94
+ | --- | --- |
95
+ | `pip install sftppath` | the path and the backend contract; no SSH library |
96
+ | `pip install "sftppath[paramiko]"` | the paramiko backend (`paramiko>=5.0,<6`) |
97
+ | `pip install "sftppath[asyncssh]"` | the asyncssh backend and the native tree operations (`asyncssh>=2.20`) |
98
+ | `pip install "sftppath[uri]"` | `uripath` and with it the `sftp:` scheme |
99
+ | `pip install "sftppath[all]"` | the three above |
100
+
101
+ ## Quick start
102
+
103
+ **A path on a server**: the endpoint goes in as keywords; the user, password and
104
+ port may come from your `~/.ssh/config` instead:
105
+
106
+ <!-- example: skip: connects to a server -->
107
+ ```python
108
+ from sftppath import SFTPPath
109
+
110
+ report = SFTPPath("/srv/data/report.txt", host="files.example.org", user="deploy")
111
+ print(report.read_text())
112
+ for child in report.parent.iterdir():
113
+ print(child.name, child.stat().st_size)
114
+ ```
115
+
116
+ **Share a connection**: a backend is the connections to one server and
117
+ account, and a context manager:
118
+
119
+ <!-- example: skip: connects to a server -->
120
+ ```python
121
+ from sftppath import SFTPBackend, SFTPPath
122
+
123
+ with SFTPBackend("files.example.org", user="deploy", timeout=10) as backend:
124
+ root = SFTPPath("/srv/data", backend=backend)
125
+ (root / "out").mkdir(exist_ok=True)
126
+ (root / "out" / "n.txt").write_text("1")
127
+ root.copy(SFTPPath("/srv/backup", backend=backend), recursive=True)
128
+ ```
129
+
130
+ **Mirror a tree** with `PathSyncer`, onto a local directory:
131
+
132
+ <!-- example: skip: connects to a server -->
133
+ ```python
134
+ from pathapi import LocalPath
135
+ from pathapi.sync import PathSyncer
136
+ from sftppath import SFTPPath
137
+
138
+ remote = SFTPPath("/var/log", host="files.example.org", user="deploy")
139
+ PathSyncer(remove_missing=True).sync(remote, LocalPath("mirror"))
140
+ ```
141
+
142
+ **The scheme**, with `uripath` installed:
143
+
144
+ <!-- example: skip: connects to a server -->
145
+ ```python
146
+ from uripath import URIPath
147
+
148
+ print(URIPath("sftp://deploy@files.example.org/srv/data/report.txt").read_text())
149
+ ```
150
+
151
+ ## API overview
152
+
153
+ | Module | Purpose |
154
+ | --- | --- |
155
+ | `sftppath` | `SFTPPath`, the backends `SFTPBackend`, `AsyncSSHBackend`, `BaseSFTPBackend` and `CheckFileSFTPBackend`, `SFTPEndpoint`, `DEFAULT_PORT`, `DEFAULT_SSH_CONFIG`, the errors and `__version__` |
156
+ | `sftppath.exceptions` | `SFTPPathError`, `SFTPAuthenticationError` and `SFTPHostKeyError` |
157
+ | `sftppath.uri` | `SFTPURIPath` and `SFTPURIBackend`, the `sftp:` scheme of `uripath` (needs the `uri` extra) |
158
+
159
+ The full reference is on the [documentation site](https://jose-pr.github.io/sftppath/);
160
+ an agent-oriented API summary ships inside the package as `sftppath/AGENTS.md`.
161
+
162
+ ## Development
163
+
164
+ ```bash
165
+ pip install -e ".[dev,all]"
166
+ python -m pytest -q -rs
167
+ ```
168
+
169
+ The tests start SSH servers of their own on the loopback interface with
170
+ throwaway keys; they never reach another host. A test for a library or an extra
171
+ that is not installed is skipped, so check the skip list (`-rs`). Contributor
172
+ notes (virtual environments, formatting, CI, releases) are in the repository's
173
+ [`AGENTS.md`](https://github.com/jose-pr/sftppath/blob/main/AGENTS.md). To
174
+ preview the documentation: `pip install -e ".[all,docs]"` then `mkdocs serve`.
175
+
176
+ Changes are recorded in the
177
+ [changelog](https://github.com/jose-pr/sftppath/blob/main/CHANGELOG.md).
178
+
179
+ ### Releasing
180
+
181
+ This project follows [Semantic Versioning](https://semver.org/) and keeps a
182
+ [`CHANGELOG.md`](https://github.com/jose-pr/sftppath/blob/main/CHANGELOG.md). Pushing a tag matching `v*` triggers the release
183
+ workflow: test gate → build (checking the tag names the version built) → a strict
184
+ docs build as a gate → GitHub release → publish. The release workflow never deploys
185
+ the docs site itself: for a final release its last job dispatches the docs workflow
186
+ at the tag, which owns every Pages deploy.
187
+
188
+ ## License
189
+
190
+ MIT — see [LICENSE](https://github.com/jose-pr/sftppath/blob/main/LICENSE).
@@ -0,0 +1,143 @@
1
+ # sftppath
2
+
3
+ [![Version](https://img.shields.io/pypi/v/sftppath.svg)](https://pypi.org/project/sftppath/)
4
+ [![Python versions](https://img.shields.io/pypi/pyversions/sftppath.svg)](https://pypi.org/project/sftppath/)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/jose-pr/sftppath/blob/main/LICENSE)
6
+ [![Docs](https://img.shields.io/badge/docs-latest-blue.svg)](https://jose-pr.github.io/sftppath/)
7
+ [![CI](https://img.shields.io/github/actions/workflow/status/jose-pr/sftppath/test.yml)](https://github.com/jose-pr/sftppath/actions/workflows/test.yml)
8
+
9
+ **`pathlib` for SFTP servers.** `SFTPPath("/srv/data/report.txt", host="files.example.org")`
10
+ has the method names, signatures and exception types of `pathlib.Path`, over
11
+ paramiko or asyncssh, and needs no URI. It builds on
12
+ [`pathapi`](https://github.com/jose-pr/pathapi) alone; with
13
+ [`uripath`](https://github.com/jose-pr/uripath) installed it also provides the
14
+ `sftp:` scheme. It is documented at <https://jose-pr.github.io/sftppath/>.
15
+
16
+ ## Features
17
+
18
+ - **A `pathlib` path on a server**: `open`, `read_text`, `iterdir`, `glob`,
19
+ `stat`, `mkdir`, `rename`, `replace`, `chmod`, `symlink_to`, `readlink` and
20
+ the rest, plus `walk`, `copy`, `move`, `rm` and `PathSyncer` from
21
+ `pathapi`. Nothing connects until the first request.
22
+ - **Two backends, one behaviour**: `SFTPBackend` (paramiko) and `AsyncSSHBackend`
23
+ (asyncssh) raise the same exceptions for the same server answers; the choice is
24
+ a keyword, a subclass attribute or `SFTPPATH_BACKEND`.
25
+ - **Native tree operations**: on asyncssh a recursive `copy()` and `rm()` run as
26
+ concurrent requests on one connection, bounded by `max_concurrency`.
27
+ - **ssh_config**: `HostName`, `Port`, `User`, `IdentityFile`, `ProxyCommand` and
28
+ `Include` apply to the host you name; host keys are verified.
29
+ - **Server-side checksums**: `checksum()` uses the `check-file-handle`
30
+ extension where the server has it, and `PathSyncer` falls back to streaming
31
+ where it does not.
32
+ - **No SSH library at import**: `import sftppath` loads neither library nor
33
+ `uripath`. Errors are `OSError` subclasses, `SFTPAuthenticationError` and
34
+ `SFTPHostKeyError`; no library exception crosses the API.
35
+ - **The `sftp:` scheme** for `uripath`: `URIPath("sftp://user@host/dir")` is an
36
+ `SFTPURIPath` that delegates to an `SFTPPath`.
37
+
38
+ ## Installation
39
+
40
+ ```bash
41
+ pip install "sftppath[paramiko]"
42
+ ```
43
+
44
+ Requires `pathapi`, which pip installs with it, and Python 3.9 or newer.
45
+
46
+ | Install | Adds |
47
+ | --- | --- |
48
+ | `pip install sftppath` | the path and the backend contract; no SSH library |
49
+ | `pip install "sftppath[paramiko]"` | the paramiko backend (`paramiko>=5.0,<6`) |
50
+ | `pip install "sftppath[asyncssh]"` | the asyncssh backend and the native tree operations (`asyncssh>=2.20`) |
51
+ | `pip install "sftppath[uri]"` | `uripath` and with it the `sftp:` scheme |
52
+ | `pip install "sftppath[all]"` | the three above |
53
+
54
+ ## Quick start
55
+
56
+ **A path on a server**: the endpoint goes in as keywords; the user, password and
57
+ port may come from your `~/.ssh/config` instead:
58
+
59
+ <!-- example: skip: connects to a server -->
60
+ ```python
61
+ from sftppath import SFTPPath
62
+
63
+ report = SFTPPath("/srv/data/report.txt", host="files.example.org", user="deploy")
64
+ print(report.read_text())
65
+ for child in report.parent.iterdir():
66
+ print(child.name, child.stat().st_size)
67
+ ```
68
+
69
+ **Share a connection**: a backend is the connections to one server and
70
+ account, and a context manager:
71
+
72
+ <!-- example: skip: connects to a server -->
73
+ ```python
74
+ from sftppath import SFTPBackend, SFTPPath
75
+
76
+ with SFTPBackend("files.example.org", user="deploy", timeout=10) as backend:
77
+ root = SFTPPath("/srv/data", backend=backend)
78
+ (root / "out").mkdir(exist_ok=True)
79
+ (root / "out" / "n.txt").write_text("1")
80
+ root.copy(SFTPPath("/srv/backup", backend=backend), recursive=True)
81
+ ```
82
+
83
+ **Mirror a tree** with `PathSyncer`, onto a local directory:
84
+
85
+ <!-- example: skip: connects to a server -->
86
+ ```python
87
+ from pathapi import LocalPath
88
+ from pathapi.sync import PathSyncer
89
+ from sftppath import SFTPPath
90
+
91
+ remote = SFTPPath("/var/log", host="files.example.org", user="deploy")
92
+ PathSyncer(remove_missing=True).sync(remote, LocalPath("mirror"))
93
+ ```
94
+
95
+ **The scheme**, with `uripath` installed:
96
+
97
+ <!-- example: skip: connects to a server -->
98
+ ```python
99
+ from uripath import URIPath
100
+
101
+ print(URIPath("sftp://deploy@files.example.org/srv/data/report.txt").read_text())
102
+ ```
103
+
104
+ ## API overview
105
+
106
+ | Module | Purpose |
107
+ | --- | --- |
108
+ | `sftppath` | `SFTPPath`, the backends `SFTPBackend`, `AsyncSSHBackend`, `BaseSFTPBackend` and `CheckFileSFTPBackend`, `SFTPEndpoint`, `DEFAULT_PORT`, `DEFAULT_SSH_CONFIG`, the errors and `__version__` |
109
+ | `sftppath.exceptions` | `SFTPPathError`, `SFTPAuthenticationError` and `SFTPHostKeyError` |
110
+ | `sftppath.uri` | `SFTPURIPath` and `SFTPURIBackend`, the `sftp:` scheme of `uripath` (needs the `uri` extra) |
111
+
112
+ The full reference is on the [documentation site](https://jose-pr.github.io/sftppath/);
113
+ an agent-oriented API summary ships inside the package as `sftppath/AGENTS.md`.
114
+
115
+ ## Development
116
+
117
+ ```bash
118
+ pip install -e ".[dev,all]"
119
+ python -m pytest -q -rs
120
+ ```
121
+
122
+ The tests start SSH servers of their own on the loopback interface with
123
+ throwaway keys; they never reach another host. A test for a library or an extra
124
+ that is not installed is skipped, so check the skip list (`-rs`). Contributor
125
+ notes (virtual environments, formatting, CI, releases) are in the repository's
126
+ [`AGENTS.md`](https://github.com/jose-pr/sftppath/blob/main/AGENTS.md). To
127
+ preview the documentation: `pip install -e ".[all,docs]"` then `mkdocs serve`.
128
+
129
+ Changes are recorded in the
130
+ [changelog](https://github.com/jose-pr/sftppath/blob/main/CHANGELOG.md).
131
+
132
+ ### Releasing
133
+
134
+ This project follows [Semantic Versioning](https://semver.org/) and keeps a
135
+ [`CHANGELOG.md`](https://github.com/jose-pr/sftppath/blob/main/CHANGELOG.md). Pushing a tag matching `v*` triggers the release
136
+ workflow: test gate → build (checking the tag names the version built) → a strict
137
+ docs build as a gate → GitHub release → publish. The release workflow never deploys
138
+ the docs site itself: for a final release its last job dispatches the docs workflow
139
+ at the tag, which owns every Pages deploy.
140
+
141
+ ## License
142
+
143
+ MIT — see [LICENSE](https://github.com/jose-pr/sftppath/blob/main/LICENSE).
@@ -0,0 +1,10 @@
1
+ # Exceptions
2
+
3
+ `SFTPPathError` is the base of what this package raises on its own. A caller's
4
+ bad argument is a plain `ValueError` or `TypeError`, and a failed request is the
5
+ `OSError` of the kind `pathlib` raises.
6
+
7
+ ::: sftppath.exceptions
8
+ options:
9
+ show_if_no_docstring: true
10
+ filters: ["!^_"]
@@ -0,0 +1,17 @@
1
+ # SFTP paths
2
+
3
+ ::: sftppath
4
+ options:
5
+ show_if_no_docstring: true
6
+ filters: ["!^_"]
7
+ members:
8
+ - SFTPPath
9
+ - SFTPEndpoint
10
+ - BaseSFTPBackend
11
+ - CheckFileSFTPBackend
12
+ - DEFAULT_PORT
13
+ - DEFAULT_SSH_CONFIG
14
+
15
+ ::: sftppath.SFTPBackend
16
+
17
+ ::: sftppath.AsyncSSHBackend
@@ -0,0 +1,8 @@
1
+ # The `sftp:` scheme
2
+
3
+ Needs `uripath`: `pip install "sftppath[uri]"`.
4
+
5
+ ::: sftppath.uri
6
+ options:
7
+ show_if_no_docstring: true
8
+ filters: ["!^_"]
@@ -0,0 +1,61 @@
1
+ # Benchmarks
2
+
3
+ `sftppath` includes a benchmark harness at `benchmarks/bench.py` that times
4
+ `SFTPPath` over paramiko and over asyncssh, and the `sftp:` scheme of `uripath`
5
+ on the same operations. It needs both SSH libraries and `uripath`:
6
+ `pip install -e ".[dev,all]"`.
7
+
8
+ ## Run It
9
+
10
+ ```bash
11
+ python benchmarks/bench.py # run the benchmarks
12
+ python benchmarks/bench.py --help # options and suites; runs nothing
13
+ ```
14
+
15
+ A suite name after the options runs part of the matrix: `sftp-batch`,
16
+ `sftp-recursive`, `sftp-recursive-copy` or `sftp-recursive-large`.
17
+
18
+ ## Saved Results
19
+
20
+ Every metric is timed over several samples. `--save` writes the run as JSON to
21
+ `benchmarks/results/<name>.json`, with the minimum, median and maximum
22
+ milliseconds per call of each metric plus the interpreter, platform and git
23
+ commit it ran on:
24
+
25
+ ```bash
26
+ python benchmarks/bench.py --save
27
+ python benchmarks/bench.py --save --samples 9
28
+ ```
29
+
30
+ `--samples N` applies one sample count to every case; without it each case uses
31
+ its own (1-5). The result schema, the suites and the committed results are
32
+ described in
33
+ [`benchmarks/README.md`](https://github.com/jose-pr/sftppath/blob/main/benchmarks/README.md).
34
+
35
+ Compare two runs on their medians, and only between runs from the same machine
36
+ and interpreter. A run on a developer machine is a sanity check; performance
37
+ claims in release notes come from CI runs.
38
+
39
+ ## What It Covers
40
+
41
+ - `stat()`, listing (`iterdir()`), `walk()` and `glob()` of a small tree
42
+ - `read_bytes()`, `write_bytes()` of 256 KiB, `mkdir()`, `rename()`, `unlink()`
43
+ and a single-file `copy()`
44
+ - recursive `copy()` and `rm()` of 4-, 9- and 128-file trees, and the asyncssh
45
+ recursive copy at several `max_concurrency` values
46
+ - a cold connect and `stat()` on a new backend
47
+ - each of the above on a paramiko backend and on an asyncssh backend, and for
48
+ both through `SFTPPath` (metric group `sftp`) and through
49
+ `URIPath("sftp://...", backend=SFTPURIBackend(...))` (group `sftp-uri`), so
50
+ the cost of the URI layer is the difference between the two groups
51
+
52
+ The cases run against an in-process asyncssh server on 127.0.0.1, so nothing
53
+ leaves the machine. Without `uripath` the `sftp-uri` group is skipped and the
54
+ run records why.
55
+
56
+ ## Caveats
57
+
58
+ The server and the client share one machine and one interpreter, with no
59
+ network latency, so the numbers show the overhead of a backend and of the path
60
+ layer, not how a remote link behaves. Timings depend on the machine, the
61
+ interpreter and what else is running; compare medians, as described above.
@@ -0,0 +1,3 @@
1
+ # Changelog
2
+
3
+ --8<-- "CHANGELOG.md"
@@ -0,0 +1,24 @@
1
+ # Divergences from `pathlib`
2
+
3
+ `sftppath` targets `pathlib.Path` parity: same method names, signatures,
4
+ semantics and exception types wherever a `pathlib.Path` equivalent exists.
5
+ Extensions (extra optional kwargs, new methods like `rm()` and `copy()`) are
6
+ allowed. Any *behavioral* divergence from `pathlib` on a method that exists in
7
+ both must be listed here -- no silent divergence.
8
+
9
+ The divergences below apply to `SFTPPath` and `SFTPURIPath`. The ones of the
10
+ generic `Path` operations that every path inherits (`touch()` on a non-local
11
+ backend, `rm()` and its policies, `chown()`, `Path.replace()`, a `with` block
12
+ that raised while writing) are on the documentation site of `pathapi`,
13
+ <https://jose-pr.github.io/pathapi/divergences/>.
14
+
15
+ | Method | `pathlib` | `SFTPPath` | Why |
16
+ | --- | --- | --- | --- |
17
+ | A relative path, and the empty path | relative to the current working directory | names are read from the server root: `SFTPPath("x", host=h)` is `/x`, the empty path is `/` | A tree served by a backend has no working directory, and two spellings of one name must be one file (`==`, `copy()` and `move()` ask). |
18
+ | `rename(target)` onto an existing file | POSIX `rename(2)`: replaces | Replaces through `posix-rename@openssh.com` where the server supports it; otherwise raises `FileExistsError`. | Plain SFTPv3 rename refuses an existing target with an uninformative failure. |
19
+ | `rename(str)` | a relative `str` is relative to the current working directory | a `str` is a sibling name (relative to `self.parent`); an absolute one is a path on the same server | There is no working directory; a sibling is what a rename within a directory means. |
20
+ | `replace(target)` onto an existing file | `os.replace` is one atomic call | One `posix-rename@openssh.com` request where the server has it (atomic; a refusal leaves both entries as they were). Without it the target is removed and then this entry renamed, so a failure between the two loses the target; a bare failure from the server is treated as the extension being absent. | Removing first left a window in which the target did not exist on every overwrite. |
21
+ | `chmod(mode, follow_symlinks=False)` | supported where the platform has `lchmod` | `NotImplementedError` on the paramiko backend; supported on asyncssh | paramiko has no call for it. |
22
+ | `hardlink_to(target)` | supported | `NotImplementedError` on the paramiko backend; supported on asyncssh, and only onto a path of the same server | SFTPv3 has no core hard-link operation. |
23
+ | `chown(uid, gid)` | not a `pathlib` method | numeric ids only; a partial change reads the other field first and raises `NotImplementedError` when the server names owners instead of numbering them | SFTPv3 carries the owner as one numeric pair. |
24
+ | `os.fspath()` of an `SFTPURIPath` | the local path | the path on the *server* (`.path`); `host_fspath()` says so | Meant for building a command line that runs on that host. `os.fspath()` of an `SFTPPath` is `NotImplementedError`. |