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.
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/CHANGELOG.md +62 -1
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/PKG-INFO +1 -1
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/pyproject.toml +1 -1
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/fspath.py +31 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/uri/schemes/sftp/__init__.py +67 -25
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/uri/schemes/sftp/_asyncssh.py +1 -1
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/uri/schemes/sftp/_paramiko.py +5 -14
- pathlib_next-0.8.2/src/pathlib_next/uri/schemes/sftp/_sshconfig.py +34 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/utils/__init__.py +19 -2
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_sftp_asyncssh.py +51 -4
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_smoke.py +17 -8
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_utils.py +61 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/.gitignore +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/LICENSE +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/README.md +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/docs/api/mempath.md +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/docs/api/path.md +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/docs/api/testing.md +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/docs/api/uri.md +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/docs/api/utils.md +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/docs/benchmarks.md +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/docs/changelog.md +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/docs/divergences.md +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/docs/guides/cli.md +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/docs/guides/extending.md +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/docs/guides/schemes.md +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/docs/index.md +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/examples/az_listing.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/examples/data_and_archive.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/examples/ftp_listing.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/examples/github_listing.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/examples/gitlab_listing.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/examples/gs_listing.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/examples/http_listing.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/examples/local_and_mem.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/examples/s3_listing.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/examples/sftp_sync.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/examples/webdav_roundtrip.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/mkdocs.yml +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/__init__.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/mempath.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/path.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/protocols/__init__.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/protocols/fs.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/protocols/io.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/py.typed +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/testing.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/tools/__init__.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/tools/uripath.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/uri/__init__.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/uri/query.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/uri/schemes/__init__.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/uri/schemes/_gitrepo.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/uri/schemes/archive/__init__.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/uri/schemes/archive/_base.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/uri/schemes/archive/tar.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/uri/schemes/archive/zip.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/uri/schemes/az.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/uri/schemes/data.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/uri/schemes/dav.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/uri/schemes/file.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/uri/schemes/ftp.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/uri/schemes/git/__init__.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/uri/schemes/git/_base.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/uri/schemes/git/github.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/uri/schemes/git/gitlab.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/uri/schemes/github.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/uri/schemes/gitlab.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/uri/schemes/gs.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/uri/schemes/http.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/uri/schemes/s3.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/uri/source.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/utils/archive.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/utils/checksum.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/utils/glob.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/utils/stat.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/src/pathlib_next/utils/sync.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/conftest.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_archive_uri.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_az.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_az_fake.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_contract.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_data_uri.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_dav.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_ftp.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_gitrepo.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_glob.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_gs.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_gs_fake.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_http.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_http_live.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_http_parser.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_local.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_mempath.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_parity_io.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_parity_pure.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_path_gaps.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_pathname.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_plugins.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_properties.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_query.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_s3.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_sftp.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_source.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_sync.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_uri_parse.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_uri_path.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_uripath_tool.py +0 -0
- {pathlib_next-0.8.0 → pathlib_next-0.8.2}/tests/test_walk.py +0 -0
- {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.
|
|
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
|
|
@@ -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
|
-
|
|
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]]" = {
|
|
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
|
-
|
|
106
|
+
elif value == "asyncssh":
|
|
75
107
|
_probe_asyncssh()
|
|
76
|
-
if
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|
100
|
-
# (or `
|
|
101
|
-
#
|
|
102
|
-
#
|
|
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 .
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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
|
-
|
|
176
|
-
|
|
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
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|