pathlib-next 0.8.0__tar.gz → 0.8.2__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 (110) hide show
  1. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/CHANGELOG.md +62 -1
  2. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/PKG-INFO +1 -1
  3. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/pyproject.toml +1 -1
  4. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/fspath.py +31 -0
  5. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/uri/schemes/sftp/__init__.py +67 -25
  6. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/uri/schemes/sftp/_asyncssh.py +1 -1
  7. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/uri/schemes/sftp/_paramiko.py +5 -14
  8. pathlib_next-0.8.2/src/pathlib_next/uri/schemes/sftp/_sshconfig.py +34 -0
  9. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/utils/__init__.py +19 -2
  10. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_sftp_asyncssh.py +51 -4
  11. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_smoke.py +17 -8
  12. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_utils.py +61 -0
  13. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/.gitignore +0 -0
  14. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/LICENSE +0 -0
  15. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/README.md +0 -0
  16. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/docs/api/mempath.md +0 -0
  17. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/docs/api/path.md +0 -0
  18. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/docs/api/testing.md +0 -0
  19. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/docs/api/uri.md +0 -0
  20. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/docs/api/utils.md +0 -0
  21. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/docs/benchmarks.md +0 -0
  22. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/docs/changelog.md +0 -0
  23. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/docs/divergences.md +0 -0
  24. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/docs/guides/cli.md +0 -0
  25. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/docs/guides/extending.md +0 -0
  26. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/docs/guides/schemes.md +0 -0
  27. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/docs/index.md +0 -0
  28. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/examples/az_listing.py +0 -0
  29. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/examples/data_and_archive.py +0 -0
  30. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/examples/ftp_listing.py +0 -0
  31. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/examples/github_listing.py +0 -0
  32. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/examples/gitlab_listing.py +0 -0
  33. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/examples/gs_listing.py +0 -0
  34. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/examples/http_listing.py +0 -0
  35. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/examples/local_and_mem.py +0 -0
  36. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/examples/s3_listing.py +0 -0
  37. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/examples/sftp_sync.py +0 -0
  38. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/examples/webdav_roundtrip.py +0 -0
  39. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/mkdocs.yml +0 -0
  40. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/__init__.py +0 -0
  41. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/mempath.py +0 -0
  42. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/path.py +0 -0
  43. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/protocols/__init__.py +0 -0
  44. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/protocols/fs.py +0 -0
  45. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/protocols/io.py +0 -0
  46. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/py.typed +0 -0
  47. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/testing.py +0 -0
  48. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/tools/__init__.py +0 -0
  49. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/tools/uripath.py +0 -0
  50. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/uri/__init__.py +0 -0
  51. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/uri/query.py +0 -0
  52. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/uri/schemes/__init__.py +0 -0
  53. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/uri/schemes/_gitrepo.py +0 -0
  54. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/uri/schemes/archive/__init__.py +0 -0
  55. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/uri/schemes/archive/_base.py +0 -0
  56. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/uri/schemes/archive/tar.py +0 -0
  57. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/uri/schemes/archive/zip.py +0 -0
  58. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/uri/schemes/az.py +0 -0
  59. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/uri/schemes/data.py +0 -0
  60. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/uri/schemes/dav.py +0 -0
  61. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/uri/schemes/file.py +0 -0
  62. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/uri/schemes/ftp.py +0 -0
  63. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/uri/schemes/git/__init__.py +0 -0
  64. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/uri/schemes/git/_base.py +0 -0
  65. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/uri/schemes/git/github.py +0 -0
  66. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/uri/schemes/git/gitlab.py +0 -0
  67. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/uri/schemes/github.py +0 -0
  68. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/uri/schemes/gitlab.py +0 -0
  69. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/uri/schemes/gs.py +0 -0
  70. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/uri/schemes/http.py +0 -0
  71. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/uri/schemes/s3.py +0 -0
  72. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/uri/source.py +0 -0
  73. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/utils/archive.py +0 -0
  74. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/utils/checksum.py +0 -0
  75. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/utils/glob.py +0 -0
  76. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/utils/stat.py +0 -0
  77. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/utils/sync.py +0 -0
  78. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/conftest.py +0 -0
  79. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_archive_uri.py +0 -0
  80. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_az.py +0 -0
  81. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_az_fake.py +0 -0
  82. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_contract.py +0 -0
  83. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_data_uri.py +0 -0
  84. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_dav.py +0 -0
  85. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_ftp.py +0 -0
  86. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_gitrepo.py +0 -0
  87. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_glob.py +0 -0
  88. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_gs.py +0 -0
  89. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_gs_fake.py +0 -0
  90. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_http.py +0 -0
  91. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_http_live.py +0 -0
  92. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_http_parser.py +0 -0
  93. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_local.py +0 -0
  94. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_mempath.py +0 -0
  95. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_parity_io.py +0 -0
  96. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_parity_pure.py +0 -0
  97. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_path_gaps.py +0 -0
  98. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_pathname.py +0 -0
  99. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_plugins.py +0 -0
  100. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_properties.py +0 -0
  101. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_query.py +0 -0
  102. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_s3.py +0 -0
  103. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_sftp.py +0 -0
  104. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_source.py +0 -0
  105. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_sync.py +0 -0
  106. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_uri_parse.py +0 -0
  107. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_uri_path.py +0 -0
  108. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_uripath_tool.py +0 -0
  109. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_walk.py +0 -0
  110. {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_webdav.py +0 -0
@@ -7,6 +7,64 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.8.2] - 2026-07-18
11
+
12
+ ### Fixed
13
+ - **`SftpPath` could not be imported or used with an asyncssh-only install
14
+ (no `paramiko`).** `uri/schemes/sftp/__init__.py` imported `._paramiko`
15
+ eagerly at module load, and `_asyncssh.py` imported the
16
+ `_DEFAULT_SSH_CONFIG` sentinel from `._paramiko`, so merely importing
17
+ `SftpPath` (or the `AsyncsshSftpBackend`) required `paramiko` even when the
18
+ caller only wanted the asyncssh backend from the `sftp-async` extra. The
19
+ paramiko-free bits (the sentinel + config-path normalization) moved to a new
20
+ `_sshconfig` module, and the paramiko `SftpBackend` is now imported lazily
21
+ (via `_probe_paramiko`, mirroring `_probe_asyncssh`) only when actually
22
+ selected. `SftpBackend`/`_DEFAULT_SSH_CONFIG` remain importable from the
23
+ scheme package (PEP 562 `__getattr__`) for backward compatibility.
24
+ `PATHLIB_NEXT_SFTP_BACKEND=paramiko` (or `auto` with neither library) now
25
+ raises a clear `ImportError` naming the missing extra instead of a bare
26
+ `ModuleNotFoundError` at import time. Regression test added
27
+ (`test_sftp_scheme_imports_and_resolves_without_paramiko`, runs in a
28
+ paramiko-masked subprocess).
29
+
30
+ ## [0.8.1] - 2026-07-16
31
+
32
+ ### Fixed
33
+ - **`LocalPath.walk()`/`rm()` raised `TypeError: cannot unpack non-iterable
34
+ DirEntry object` on Python 3.11/3.12.** Those stdlib versions define their
35
+ own `pathlib.Path._scandir()` (returning raw `os.scandir()` `DirEntry`
36
+ objects), which sits ahead of this project's `_scandir()` in `LocalPath`'s
37
+ MRO and silently shadowed it -- breaking the `(name, FileStat|None)`
38
+ contract `walk()`/`glob()`/`rm()` expect. `LocalPath` now defines its own
39
+ `_scandir()` explicitly, reusing each `DirEntry`'s cached `lstat()` so the
40
+ perf win from `_scandir()` unification is preserved. On 3.12+, stdlib
41
+ `pathlib.Path` also defines its own `walk()` ahead of ours in the MRO, and
42
+ that stdlib `walk()` treats `self._scandir()`'s return value as a context
43
+ manager (`with scandir_it:`) -- our own `_scandir()` is a plain generator,
44
+ so stdlib's `walk()` raised `TypeError: 'generator' object does not
45
+ support the context manager protocol` even with the override above.
46
+ `LocalPath` now also overrides `walk()` explicitly, routing to this
47
+ project's own implementation regardless of Python version. Introduced in
48
+ 0.8.0 (`8cdbefa`), exposed on the CI 3.11/3.12 legs.
49
+ - **`Test No-Extras` CI job was red.** `tests/test_smoke.py` unconditionally
50
+ constructed an `http://`/`sftp://` `UriPath` in two tests, requiring
51
+ `requests`/`paramiko` even though the no-extras job installs neither; a
52
+ third test wrongly assumed `S3Path` requires `boto3` to register (it only
53
+ needs `botocore`, imported lazily inside a method). The two hard tests now
54
+ `pytest.importorskip` their extra; the `S3Path` check now probes for
55
+ `botocore`. Introduced in 0.8.0 (`94bd545`/`8cdbefa`), fixed with the
56
+ expected skip count (2) verified in a real no-extras venv.
57
+ - **Importable on a clean Python 3.9 install.** `pathlib_next.utils` used
58
+ `typing.ParamSpec` (3.10+), falling back to `typing_extensions.ParamSpec` and
59
+ then to a bare `typing.TypeVar`. A `TypeVar` has no `.args`, so the
60
+ `*args: K.args` annotations raised `AttributeError: 'TypeVar' object has no
61
+ attribute 'args'` at import time, making `import pathlib_next` fail on 3.9
62
+ whenever `typing_extensions` was absent. Since `typing_extensions` is not a
63
+ runtime dependency, this broke a plain `pip install pathlib_next` on 3.9. The
64
+ final fallback is now a minimal `ParamSpec` shim providing `.args`/`.kwargs`,
65
+ so no runtime dependency is added and 3.10+ keeps using `typing.ParamSpec`
66
+ unchanged.
67
+
10
68
  ## [0.8.0] - 2026-07-13
11
69
 
12
70
  ### Added
@@ -437,7 +495,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
437
495
  - Sync error handling.
438
496
  - Generic Path Protocol based pathlib implementation for URI paths with file access support for sftp, http, file schemes.
439
497
 
440
- [Unreleased]: https://github.com/jose-pr/pathlib_next/compare/v0.7.0...HEAD
498
+ [Unreleased]: https://github.com/jose-pr/pathlib_next/compare/v0.8.2...HEAD
499
+ [0.8.2]: https://github.com/jose-pr/pathlib_next/compare/v0.8.1...v0.8.2
500
+ [0.8.1]: https://github.com/jose-pr/pathlib_next/compare/v0.8.0...v0.8.1
501
+ [0.8.0]: https://github.com/jose-pr/pathlib_next/compare/v0.7.0...v0.8.0
441
502
  [0.7.0]: https://github.com/jose-pr/pathlib_next/compare/v0.6.0...v0.7.0
442
503
  [0.6.0]: https://github.com/jose-pr/pathlib_next/compare/v0.5.0...v0.6.0
443
504
  [0.5.0]: https://github.com/jose-pr/pathlib_next/compare/v0.4.1...v0.5.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pathlib_next
3
- Version: 0.8.0
3
+ Version: 0.8.2
4
4
  Summary: Generic Path Protocol based pathlib
5
5
  Project-URL: Homepage, https://github.com/jose-pr/pathlib_next/
6
6
  Project-URL: Documentation, https://jose-pr.github.io/pathlib_next/
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pathlib_next"
7
- version = "0.8.0"
7
+ version = "0.8.2"
8
8
  authors = [{ name = "Jose A" }]
9
9
  description = "Generic Path Protocol based pathlib"
10
10
  readme = "README.md"
@@ -11,6 +11,7 @@ import types as _types
11
11
  import typing as _ty
12
12
 
13
13
  from . import path as _proto
14
+ from .utils.stat import FileStat as _FileStat
14
15
 
15
16
  # pathlib.Path.stat()/chmod() only accept follow_symlinks= on 3.10+; below
16
17
  # that, LocalPath (which inherits them directly from pathlib.Path via MRO,
@@ -84,6 +85,36 @@ class LocalPath(
84
85
 
85
86
  __slots__ = ()
86
87
 
88
+ def _scandir(self):
89
+ # On 3.11+, `pathlib.Path._scandir()` (stdlib, ahead of ours in the
90
+ # MRO via WindowsPath/PosixPath) shadows `_proto.Path._scandir()`
91
+ # and returns `os.scandir(self)` directly -- an iterator of raw
92
+ # `os.DirEntry`, not this project's `(name, FileStat|None)` tuples.
93
+ # walk()/glob() expect the latter, so re-assert our own contract
94
+ # here regardless of what stdlib does in a given version. DirEntry's
95
+ # own cached lstat (`follow_symlinks=False`, matching walk()'s
96
+ # default) is reused instead of a fresh stat() round trip.
97
+ for entry in _os.scandir(self):
98
+ try:
99
+ stat = _FileStat.from_stat(entry.stat(follow_symlinks=False))
100
+ except OSError:
101
+ stat = None
102
+ yield entry.name, stat
103
+
104
+ def walk(self, top_down=True, on_error=None, follow_symlinks=False):
105
+ # 3.12+ stdlib `pathlib.Path.walk()` sits ahead of ours in the MRO
106
+ # and would otherwise win here. Its implementation calls
107
+ # `self._scandir()` expecting stdlib's own context-manager-capable
108
+ # `os.scandir(self)` return value ("with scandir_it:") -- our
109
+ # `_scandir()` override above is a plain generator, so stdlib's
110
+ # `walk()` breaks on it (`TypeError: 'generator' object does not
111
+ # support the context manager protocol`). Route explicitly to our
112
+ # own `walk()` (which drives `_scandir()` correctly) regardless of
113
+ # which one the MRO would otherwise resolve to.
114
+ return _proto.Path.walk(
115
+ self, top_down=top_down, on_error=on_error, follow_symlinks=follow_symlinks
116
+ )
117
+
87
118
  def stat(self, *, follow_symlinks=True):
88
119
  # pathlib.Path.stat() (next in MRO via WindowsPath/PosixPath) only
89
120
  # accepts follow_symlinks= on 3.10+; below that, lstat() is the
@@ -32,7 +32,9 @@ class BaseSftpBackend(object):
32
32
  def client(self, source: Source): ...
33
33
 
34
34
 
35
- from ._paramiko import _DEFAULT_SSH_CONFIG, SftpBackend as SftpBackend # noqa: E402
35
+ # The default-config sentinel is paramiko-free (lives in `_sshconfig`) so
36
+ # importing this scheme never pulls paramiko in just to have the sentinel.
37
+ from ._sshconfig import _DEFAULT_SSH_CONFIG
36
38
 
37
39
 
38
40
  # --- backend selection -----------------------------------------------------
@@ -41,10 +43,18 @@ from ._paramiko import _DEFAULT_SSH_CONFIG, SftpBackend as SftpBackend # noqa:
41
43
  # UriPath backend propagation works, unchanged) > `SftpPath._default_backend_cls`
42
44
  # class attribute > `PATHLIB_NEXT_SFTP_BACKEND` env var > auto-detect
43
45
  # (asyncssh if importable, else paramiko).
46
+ #
47
+ # BOTH backends are imported lazily (`_probe_asyncssh`/`_probe_paramiko`): merely
48
+ # importing this scheme -- which happens for every `sftp:` URL and for
49
+ # `from ...sftp import SftpPath` -- must not require *either* SSH library. In
50
+ # particular an asyncssh-only install (the `sftp-async` extra, no paramiko) must
51
+ # be able to import and use `SftpPath`; eagerly importing `._paramiko` here broke
52
+ # exactly that.
44
53
 
45
54
  _ENV_VAR = "PATHLIB_NEXT_SFTP_BACKEND"
46
- _BACKEND_REGISTRY: "dict[str, type[BaseSftpBackend]]" = {"paramiko": SftpBackend}
55
+ _BACKEND_REGISTRY: "dict[str, type[BaseSftpBackend]]" = {}
47
56
  _asyncssh_probed = False
57
+ _paramiko_probed = False
48
58
  _resolved_backend_cls: "type[BaseSftpBackend] | None" = None
49
59
 
50
60
 
@@ -64,47 +74,79 @@ def _probe_asyncssh() -> None:
64
74
  _BACKEND_REGISTRY["asyncssh"] = AsyncsshSftpBackend
65
75
 
66
76
 
77
+ def _probe_paramiko() -> None:
78
+ # Symmetric with `_probe_asyncssh`: only import paramiko when it is actually
79
+ # needed (paramiko selected, or auto-detect with asyncssh unavailable), so an
80
+ # asyncssh-only install never imports paramiko.
81
+ global _paramiko_probed
82
+ if _paramiko_probed:
83
+ return
84
+ _paramiko_probed = True
85
+ try:
86
+ from ._paramiko import SftpBackend
87
+ except ImportError:
88
+ return
89
+ _BACKEND_REGISTRY["paramiko"] = SftpBackend
90
+
91
+
67
92
  def _resolve_default_backend_cls(reload: bool = False) -> "type[BaseSftpBackend]":
68
93
  global _resolved_backend_cls
69
94
  if not reload and _resolved_backend_cls is not None:
70
95
  return _resolved_backend_cls
71
96
  value = _os.environ.get(_ENV_VAR, "auto")
72
97
  if value == "paramiko":
98
+ _probe_paramiko()
99
+ if "paramiko" not in _BACKEND_REGISTRY:
100
+ raise ImportError(
101
+ f"{_ENV_VAR}=paramiko but the paramiko package is not "
102
+ "installed -- install the 'sftp' extra, or unset "
103
+ f"{_ENV_VAR} to auto-detect (uses asyncssh if available)."
104
+ )
73
105
  cls = _BACKEND_REGISTRY["paramiko"]
74
- else:
106
+ elif value == "asyncssh":
75
107
  _probe_asyncssh()
76
- if value == "auto":
77
- cls = _BACKEND_REGISTRY.get("asyncssh") or _BACKEND_REGISTRY["paramiko"]
78
- elif value == "asyncssh":
79
- if "asyncssh" not in _BACKEND_REGISTRY:
80
- # Fail loud -- a silent fallback to paramiko would hide a
81
- # deployment misconfiguration (asyncssh extra not installed
82
- # where the operator explicitly asked for it).
83
- raise ImportError(
84
- f"{_ENV_VAR}=asyncssh but the asyncssh package is not "
85
- "installed -- install the 'sftp-async' extra, or unset "
86
- f"{_ENV_VAR} to auto-detect (falls back to paramiko)."
87
- )
88
- cls = _BACKEND_REGISTRY["asyncssh"]
89
- else:
90
- raise ValueError(
91
- f"{_ENV_VAR}={value!r} is not a recognized SFTP backend "
92
- f"(expected one of {sorted({'auto', *_BACKEND_REGISTRY})!r})"
108
+ if "asyncssh" not in _BACKEND_REGISTRY:
109
+ # Fail loud -- a silent fallback to paramiko would hide a
110
+ # deployment misconfiguration (asyncssh extra not installed
111
+ # where the operator explicitly asked for it).
112
+ raise ImportError(
113
+ f"{_ENV_VAR}=asyncssh but the asyncssh package is not "
114
+ "installed -- install the 'sftp-async' extra, or unset "
115
+ f"{_ENV_VAR} to auto-detect (falls back to paramiko)."
116
+ )
117
+ cls = _BACKEND_REGISTRY["asyncssh"]
118
+ elif value == "auto":
119
+ _probe_asyncssh()
120
+ if "asyncssh" not in _BACKEND_REGISTRY:
121
+ _probe_paramiko()
122
+ cls = _BACKEND_REGISTRY.get("asyncssh") or _BACKEND_REGISTRY.get("paramiko")
123
+ if cls is None:
124
+ raise ImportError(
125
+ "no SFTP backend available -- install the 'sftp-async' "
126
+ "(asyncssh) or 'sftp' (paramiko) extra."
93
127
  )
128
+ else:
129
+ raise ValueError(
130
+ f"{_ENV_VAR}={value!r} is not a recognized SFTP backend "
131
+ "(expected one of 'auto', 'asyncssh', 'paramiko')"
132
+ )
94
133
  _resolved_backend_cls = cls
95
134
  return cls
96
135
 
97
136
 
98
137
  def __getattr__(name: str):
99
- # PEP 562 lazy module attribute: `from .sftp import AsyncsshSftpBackend`
100
- # (or `sftp.AsyncsshSftpBackend`) only imports asyncssh at the point
101
- # it's actually referenced -- importing `pathlib_next.uri.schemes.sftp`
102
- # itself (which happens for every `sftp:` URL, regardless of which
103
- # backend ends up selected) must not eagerly import asyncssh.
138
+ # PEP 562 lazy module attributes: referencing `AsyncsshSftpBackend`,
139
+ # `SftpBackend` (paramiko), or `_DEFAULT_SSH_CONFIG` via
140
+ # `from .sftp import ...` imports the relevant backend only at that point --
141
+ # importing the scheme module itself pulls in neither SSH library.
104
142
  if name == "AsyncsshSftpBackend":
105
143
  from ._asyncssh import AsyncsshSftpBackend
106
144
 
107
145
  return AsyncsshSftpBackend
146
+ if name == "SftpBackend":
147
+ from ._paramiko import SftpBackend
148
+
149
+ return SftpBackend
108
150
  raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
109
151
 
110
152
 
@@ -15,7 +15,7 @@ import asyncssh as _asyncssh
15
15
  from ... import Source
16
16
  from ....utils.stat import FileStat
17
17
  from . import BaseSftpBackend
18
- from ._paramiko import _DEFAULT_SSH_CONFIG
18
+ from ._sshconfig import _DEFAULT_SSH_CONFIG
19
19
 
20
20
  # --- shared background event loop -------------------------------------
21
21
  # asyncssh is asyncio-only end to end (connect(), every SFTPClient method,
@@ -9,20 +9,11 @@ from .... import utils as _utils
9
9
  from ... import Source
10
10
  from . import BaseSftpBackend
11
11
 
12
-
13
- _DEFAULT_SSH_CONFIG = object()
14
-
15
-
16
- def _normalize_config_paths(
17
- ssh_config: "object",
18
- ) -> "tuple[str, ...] | None":
19
- if ssh_config is _DEFAULT_SSH_CONFIG:
20
- return (str(_pathlib.Path.home() / ".ssh" / "config"),)
21
- if ssh_config is None:
22
- return None
23
- if isinstance(ssh_config, (str, _pathlib.PurePath)):
24
- return (str(ssh_config),)
25
- return tuple(str(path) for path in ssh_config)
12
+ # The sentinel + path normalization are paramiko-free and now live in
13
+ # ``_sshconfig`` so the asyncssh backend and the scheme ``__init__`` can use them
14
+ # without importing paramiko. Re-exported here for backward compatibility (older
15
+ # code did ``from ._paramiko import _DEFAULT_SSH_CONFIG``).
16
+ from ._sshconfig import _DEFAULT_SSH_CONFIG, _normalize_config_paths
26
17
 
27
18
 
28
19
  @_utils.LRU
@@ -0,0 +1,34 @@
1
+ """Backend-agnostic SSH-config helpers (no paramiko/asyncssh import).
2
+
3
+ The default-config sentinel and path normalization live here, separate from
4
+ ``_paramiko.py``, so the asyncssh backend and the scheme's ``__init__`` can
5
+ reference them **without importing paramiko**. Only the actual config *parsing*
6
+ (``_load_ssh_config``/``_lookup_ssh_config`` in ``_paramiko.py``) needs
7
+ ``paramiko.SSHConfig``; the sentinel and the "which files" logic do not.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import pathlib as _pathlib
13
+
14
+ #: Sentinel meaning "use the default SSH config location(s)". A bare ``object()``
15
+ #: so it is distinct from ``None`` (explicitly no config) and from any real path.
16
+ #: Shared by both backends; kept paramiko-free on purpose (see module docstring).
17
+ _DEFAULT_SSH_CONFIG = object()
18
+
19
+
20
+ def _normalize_config_paths(
21
+ ssh_config: "object",
22
+ ) -> "tuple[str, ...] | None":
23
+ """Resolve an ``ssh_config`` argument to a tuple of file paths, or ``None``.
24
+
25
+ ``_DEFAULT_SSH_CONFIG`` -> the user's ``~/.ssh/config``; ``None`` -> no config;
26
+ a str/path -> that one file; an iterable -> those files. No paramiko needed.
27
+ """
28
+ if ssh_config is _DEFAULT_SSH_CONFIG:
29
+ return (str(_pathlib.Path.home() / ".ssh" / "config"),)
30
+ if ssh_config is None:
31
+ return None
32
+ if isinstance(ssh_config, (str, _pathlib.PurePath)):
33
+ return (str(ssh_config),)
34
+ return tuple(str(path) for path in ssh_config)
@@ -6,12 +6,29 @@ from email.utils import parsedate as _parsedate
6
6
  from threading import RLock
7
7
 
8
8
  try:
9
- ParamSpec = _ty.ParamSpec
9
+ ParamSpec = _ty.ParamSpec # 3.10+
10
10
  except AttributeError:
11
11
  try:
12
12
  from typing_extensions import ParamSpec
13
13
  except ImportError:
14
- ParamSpec = _ty.TypeVar
14
+
15
+ class ParamSpec(_ty.TypeVar, _root=True):
16
+ """Minimal `ParamSpec` stand-in for 3.9 without `typing_extensions`.
17
+
18
+ Only the `.args` / `.kwargs` attributes are needed: they appear in
19
+ annotations that must merely *evaluate*, and a plain `TypeVar` has
20
+ neither. `typing_extensions` is not a runtime dependency, so the
21
+ fallback keeps a bare 3.9 install importable.
22
+ """
23
+
24
+ @property
25
+ def args(self):
26
+ return self
27
+
28
+ @property
29
+ def kwargs(self):
30
+ return self
31
+
15
32
 
16
33
  K = ParamSpec("K")
17
34
  V = _ty.TypeVar("V")
@@ -137,6 +137,13 @@ def _reset_backend_resolution(monkeypatch):
137
137
 
138
138
  from pathlib_next.uri.schemes import sftp as sftp_pkg # noqa: E402
139
139
 
140
+ import importlib.util as _importutil # noqa: E402
141
+
142
+ _HAS_PARAMIKO = _importutil.find_spec("paramiko") is not None
143
+ _needs_paramiko = pytest.mark.skipif(
144
+ not _HAS_PARAMIKO, reason="paramiko not installed (asyncssh-only install)"
145
+ )
146
+
140
147
 
141
148
  def test_resolve_default_backend_auto_prefers_asyncssh(monkeypatch):
142
149
  monkeypatch.delenv(sftp_pkg._ENV_VAR, raising=False)
@@ -144,6 +151,7 @@ def test_resolve_default_backend_auto_prefers_asyncssh(monkeypatch):
144
151
  assert cls is backend_mod.AsyncsshSftpBackend
145
152
 
146
153
 
154
+ @_needs_paramiko
147
155
  def test_resolve_default_backend_explicit_paramiko(monkeypatch):
148
156
  monkeypatch.setenv(sftp_pkg._ENV_VAR, "paramiko")
149
157
  cls = sftp_pkg._resolve_default_backend_cls(reload=True)
@@ -164,7 +172,6 @@ def test_resolve_default_backend_invalid_value_raises(monkeypatch):
164
172
 
165
173
  def test_resolve_default_backend_asyncssh_unavailable_raises_importerror(monkeypatch):
166
174
  monkeypatch.setenv(sftp_pkg._ENV_VAR, "asyncssh")
167
- monkeypatch.setitem(sftp_pkg._BACKEND_REGISTRY, "paramiko", sftp_pkg.SftpBackend)
168
175
  monkeypatch.delitem(sftp_pkg._BACKEND_REGISTRY, "asyncssh", raising=False)
169
176
  monkeypatch.setattr(sftp_pkg, "_asyncssh_probed", True) # skip the real probe
170
177
  with pytest.raises(ImportError, match="sftp-async"):
@@ -172,13 +179,16 @@ def test_resolve_default_backend_asyncssh_unavailable_raises_importerror(monkeyp
172
179
 
173
180
 
174
181
  def test_resolve_default_backend_result_is_cached(monkeypatch):
175
- monkeypatch.setenv(sftp_pkg._ENV_VAR, "paramiko")
176
- first = sftp_pkg._resolve_default_backend_cls(reload=True)
182
+ # asyncssh first (always available in this test module), then flip the env
183
+ # and confirm no-reload returns the cached asyncssh result.
177
184
  monkeypatch.setenv(sftp_pkg._ENV_VAR, "asyncssh")
185
+ first = sftp_pkg._resolve_default_backend_cls(reload=True)
186
+ monkeypatch.setenv(sftp_pkg._ENV_VAR, "auto")
178
187
  second = sftp_pkg._resolve_default_backend_cls() # no reload -- cached
179
- assert first is second is sftp_pkg.SftpBackend
188
+ assert first is second is backend_mod.AsyncsshSftpBackend
180
189
 
181
190
 
191
+ @_needs_paramiko
182
192
  def test_default_backend_cls_class_attribute_wins_over_env(monkeypatch):
183
193
  monkeypatch.setenv(sftp_pkg._ENV_VAR, "asyncssh")
184
194
 
@@ -191,6 +201,42 @@ def test_default_backend_cls_class_attribute_wins_over_env(monkeypatch):
191
201
  assert isinstance(backend, sftp_pkg.SftpBackend)
192
202
 
193
203
 
204
+ def test_sftp_scheme_imports_and_resolves_without_paramiko():
205
+ """Regression: an asyncssh-only install (no paramiko) must still import
206
+ SftpPath and auto-resolve to the asyncssh backend.
207
+
208
+ Runs in a subprocess with ``paramiko`` masked (blocked in sys.modules) so the
209
+ guard holds even in a CI env where paramiko happens to be installed. Before
210
+ the fix, ``uri/schemes/sftp/__init__`` imported ``._paramiko`` eagerly, so
211
+ merely importing ``SftpPath`` raised ModuleNotFoundError without paramiko.
212
+ """
213
+ import subprocess
214
+ import sys
215
+ import textwrap
216
+
217
+ script = textwrap.dedent(
218
+ """
219
+ import sys
220
+ # Make `import paramiko` fail, simulating an asyncssh-only install.
221
+ sys.modules["paramiko"] = None
222
+ from pathlib_next.uri.schemes.sftp import SftpPath
223
+ from pathlib_next.uri.schemes import sftp as pkg
224
+ sp = SftpPath("sftp://root@h:22/etc/hosts")
225
+ assert sp.source.host == "h" and sp.path == "/etc/hosts"
226
+ cls = pkg._resolve_default_backend_cls(reload=True)
227
+ assert cls.__name__ == "AsyncsshSftpBackend", cls
228
+ print("OK")
229
+ """
230
+ )
231
+ result = subprocess.run(
232
+ [sys.executable, "-c", script],
233
+ capture_output=True,
234
+ text=True,
235
+ )
236
+ assert result.returncode == 0, result.stderr
237
+ assert "OK" in result.stdout
238
+
239
+
194
240
  def test_explicit_backend_kwarg_wins_over_everything(monkeypatch):
195
241
  monkeypatch.setenv(sftp_pkg._ENV_VAR, "asyncssh")
196
242
  explicit = backend_mod.AsyncsshSftpBackend()
@@ -219,6 +265,7 @@ def test_asyncssh_backend_supports_lchmod_and_hardlink():
219
265
  assert backend.supports_hardlink is True
220
266
 
221
267
 
268
+ @_needs_paramiko
222
269
  def test_paramiko_backend_does_not_support_lchmod_or_hardlink():
223
270
  assert sftp_pkg.SftpBackend.supports_lchmod is False
224
271
  assert sftp_pkg.SftpBackend.supports_hardlink is False
@@ -4,6 +4,8 @@ These are the snippets from README.md's Quick start and examples/example.py
4
4
  that don't touch the network. On Python 3.9 this file is expected to fail
5
5
  until the Python 3.9 compatibility work lands.
6
6
  """
7
+ import pytest
8
+
7
9
  import pathlib_next
8
10
  from pathlib_next import Path, glob
9
11
  from pathlib_next.mempath import MemPath
@@ -27,6 +29,7 @@ def test_readme_local_path():
27
29
 
28
30
 
29
31
  def test_readme_http_path_construct_only():
32
+ pytest.importorskip("requests")
30
33
  http_path = UriPath("http://example.com/data.txt")
31
34
  assert http_path.source.scheme == "http"
32
35
 
@@ -80,6 +83,7 @@ def test_example_uripath_norm():
80
83
 
81
84
 
82
85
  def test_example_uripath_sftp_join():
86
+ pytest.importorskip("paramiko")
83
87
  sftp_root = UriPath("sftp://root@sftpexample/")
84
88
  sftp_root.as_posix()
85
89
  authkeys = sftp_root / "root/.ssh/authorized_keys"
@@ -104,16 +108,21 @@ def test_optional_schemes_presence_or_absence():
104
108
  except ImportError:
105
109
  assert not hasattr(schemes, "HttpPath")
106
110
 
107
- # Check sftp
108
- try:
109
- import paramiko # noqa: F401
110
- assert hasattr(schemes, "SftpPath")
111
- except ImportError:
112
- assert not hasattr(schemes, "SftpPath")
111
+ # Check sftp: as of 0.8.2 importing SftpPath no longer requires an SSH
112
+ # backend (paramiko/asyncssh) -- those are resolved lazily at USE time. The
113
+ # scheme is a UriPath, so its import gate is `uritools` (the `uri` extra);
114
+ # SftpPath is present exactly when that is importable. Using it without any
115
+ # SSH backend installed is what raises (covered by the backend-selection
116
+ # tests), not importing it.
117
+ import importlib.util as _importutil
118
+
119
+ _has_uri = _importutil.find_spec("uritools") is not None
120
+ assert hasattr(schemes, "SftpPath") == _has_uri
113
121
 
114
- # Check s3
122
+ # Check s3 -- S3Path only needs botocore at import time (boto3 itself is
123
+ # a lazy import inside S3Backend.client()), so that's what gates it.
115
124
  try:
116
- import boto3 # noqa: F401
125
+ import botocore # noqa: F401
117
126
  assert hasattr(schemes, "S3Path")
118
127
  except ImportError:
119
128
  assert not hasattr(schemes, "S3Path")
@@ -1,3 +1,8 @@
1
+ import pathlib
2
+ import subprocess
3
+ import sys
4
+ import textwrap
5
+
1
6
  import pytest
2
7
 
3
8
  from pathlib_next import utils
@@ -306,3 +311,59 @@ def test_detect_format_does_not_peek_when_extension_is_conclusive():
306
311
  assert _detect_format("a.tar", _boom) == "tar"
307
312
 
308
313
 
314
+
315
+
316
+ @pytest.mark.skipif(
317
+ sys.version_info >= (3, 10),
318
+ reason="the ParamSpec fallback is only reachable on 3.9 (3.10+ has typing.ParamSpec)",
319
+ )
320
+ def test_paramspec_fallback_importable_without_typing_extensions():
321
+ # Regression: on Python 3.9 `typing.ParamSpec` does not exist, so the module
322
+ # falls back to `typing_extensions.ParamSpec` and, failing that, to a local
323
+ # shim. The old fallback was a bare `TypeVar`, which has no `.args`, so the
324
+ # `*args: K.args` annotations raised `AttributeError: 'TypeVar' object has no
325
+ # attribute 'args'` at import. It went unnoticed because every dev/CI env had
326
+ # `typing_extensions` installed transitively -- but it is not a runtime
327
+ # dependency, so a clean 3.9 install of the package could not import it.
328
+ #
329
+ # 3.9-only by nature, not by convenience: `Generic[K, V]` on 3.9 rejects a
330
+ # plain object ("Parameters to generic types must be types"), so the shim must
331
+ # subclass `TypeVar` -- and `TypeVar` stopped being subclassable in 3.12. The
332
+ # branch is unreachable on 3.10+ anyway, since `typing.ParamSpec` exists there.
333
+ #
334
+ # Runs in a subprocess with `typing_extensions` blocked so the fallback is
335
+ # exercised for real rather than mocked.
336
+ probe = textwrap.dedent(
337
+ """
338
+ import builtins
339
+
340
+ _real_import = builtins.__import__
341
+
342
+ def _blocked(name, *a, **k):
343
+ if name == "typing_extensions":
344
+ raise ImportError("emulating an env without typing_extensions")
345
+ return _real_import(name, *a, **k)
346
+
347
+ builtins.__import__ = _blocked
348
+
349
+ from pathlib_next.utils import LRU, K
350
+
351
+ assert K.args is not None, "ParamSpec fallback lost .args"
352
+ assert K.kwargs is not None, "ParamSpec fallback lost .kwargs"
353
+ lru = LRU(lambda x: x * 2, maxsize=4)
354
+ assert lru(21) == 42
355
+ assert lru(21) == 42
356
+ lru.invalidate(21)
357
+ assert lru(21) == 42
358
+ print("FALLBACK_OK")
359
+ """
360
+ )
361
+ result = subprocess.run(
362
+ [sys.executable, "-c", probe],
363
+ capture_output=True,
364
+ text=True,
365
+ cwd=pathlib.Path(utils.__file__).parents[3],
366
+ )
367
+ assert "FALLBACK_OK" in result.stdout, (
368
+ f"fallback import failed:\nstdout={result.stdout}\nstderr={result.stderr}"
369
+ )
File without changes
File without changes
File without changes
File without changes
File without changes