pathlib-next 0.9.1__tar.gz → 0.9.3__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.9.1 → pathlib_next-0.9.3}/CHANGELOG.md +94 -1
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/PKG-INFO +2 -2
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/docs/divergences.md +2 -1
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/docs/guides/extending.md +10 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/pyproject.toml +1 -1
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/AGENTS.md +41 -6
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/fspath.py +9 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/mempath.py +20 -3
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/path.py +45 -8
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/protocols/fs.py +1 -1
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/uri/__init__.py +69 -4
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/archive/_base.py +1 -2
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/az.py +1 -2
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/dav.py +1 -2
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/ftp.py +1 -2
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/gs.py +1 -2
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/s3.py +1 -2
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/sftp/__init__.py +5 -3
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/uri/source.py +1 -3
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/utils/glob.py +1 -1
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/utils/sync.py +1 -1
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_archive_uri.py +15 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_ftp.py +14 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_mempath.py +81 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_parity_io.py +23 -3
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_pathname.py +122 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_properties.py +15 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_s3.py +14 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_sftp.py +139 -3
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_sync.py +1 -1
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_uri_path.py +58 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/.gitignore +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/LICENSE +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/README.md +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/docs/api/mempath.md +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/docs/api/path.md +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/docs/api/testing.md +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/docs/api/uri.md +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/docs/api/utils.md +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/docs/benchmarks.md +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/docs/changelog.md +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/docs/guides/cli.md +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/docs/guides/schemes.md +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/docs/index.md +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/examples/az_listing.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/examples/data_and_archive.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/examples/ftp_listing.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/examples/github_listing.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/examples/gitlab_listing.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/examples/gs_listing.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/examples/http_listing.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/examples/local_and_mem.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/examples/s3_listing.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/examples/sftp_sync.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/examples/webdav_roundtrip.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/mkdocs.yml +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/__init__.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/protocols/__init__.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/protocols/checksum.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/protocols/io.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/py.typed +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/testing.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/tools/__init__.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/tools/uripath.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/uri/query.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/__init__.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/_gitrepo.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/archive/__init__.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/archive/tar.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/archive/zip.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/data.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/file.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/git/__init__.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/git/_base.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/git/github.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/git/gitlab.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/github.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/gitlab.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/http.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/sftp/_asyncssh.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/sftp/_paramiko.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/sftp/_sshconfig.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/utils/__init__.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/utils/archive.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/utils/checksum.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/utils/stat.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/conftest.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_az.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_az_fake.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_checksum.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_contract.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_data_uri.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_dav.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_gitrepo.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_glob.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_gs.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_gs_fake.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_http.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_http_live.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_http_parser.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_local.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_mro_precedence.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_parity_pure.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_path_gaps.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_plugins.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_query.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_sftp_asyncssh.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_smoke.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_source.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_uri_parse.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_uripath_tool.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_utils.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_walk.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_webdav.py +0 -0
|
@@ -5,6 +5,97 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
7
7
|
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.9.3] - 2026-08-16
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **A `str` destination to `rename()`/`symlink_to()` was re-parsed as a URI,
|
|
14
|
+
so part of it was silently discarded.** Every scheme resolved the
|
|
15
|
+
destination by feeding it back through the URI parser
|
|
16
|
+
(`Uri(self.parent, target)` for `rename()`, `type(self)(target)` inside
|
|
17
|
+
`Path.symlink_to()`). That reads a **decoded filesystem path** as URI
|
|
18
|
+
syntax: everything from a `?` or `#` onward became a query/fragment and was
|
|
19
|
+
dropped, `%xx` was percent-decoded, and a relative destination whose first
|
|
20
|
+
segment ended in `:` was read as a *scheme*. Measured against a real SFTP
|
|
21
|
+
server (TrueNAS 26.0.0-BETA.1):
|
|
22
|
+
|
|
23
|
+
| call | file/link actually produced |
|
|
24
|
+
| --- | --- |
|
|
25
|
+
| `rename(".../rn?b.txt")` | `.../rn` |
|
|
26
|
+
| `rename(".../rn%20b.txt")` | `.../rn b.txt` |
|
|
27
|
+
| `symlink_to(".../cache?v=2")` | link points at `.../cache` |
|
|
28
|
+
| `rename("C:/Temp/x.txt")` | `/Temp/x.txt` (`C:` taken as a scheme) |
|
|
29
|
+
|
|
30
|
+
Nothing raised. When something already occupied the truncated name the call
|
|
31
|
+
instead failed with a bare `OSError: Failure`, so the symptom was either
|
|
32
|
+
silent misplacement or an unexplained error depending on what happened to
|
|
33
|
+
be there. Downstream, `pytruenas`'s documented
|
|
34
|
+
`client.path(x).symlink_to(y)` route created a wrong link, and
|
|
35
|
+
`PathSyncer`'s `symlink_mode="preserve"` (which hands `symlink_to()` the
|
|
36
|
+
raw target string `readlink()` returned) mirrored such a link to the wrong
|
|
37
|
+
place.
|
|
38
|
+
|
|
39
|
+
A `str` destination is now taken as an already-decoded path — `?`, `#`,
|
|
40
|
+
`%` and `:` are ordinary filename characters — via the new
|
|
41
|
+
`Uri._from_decoded_path()`, one implementation shared by
|
|
42
|
+
`Uri._rename_target()` (used by `SftpPath`, `FtpPath`, `DavPath`,
|
|
43
|
+
`S3Path`, `GsPath`, `AzPath` and `ArchiveUri`) and by
|
|
44
|
+
`UriPath._symlink_target()`, an override of a new `Path._symlink_target()`
|
|
45
|
+
hook. Relative destinations keep their existing meaning: a `rename()`
|
|
46
|
+
destination is a sibling, a `symlink_to()` target is stored verbatim and
|
|
47
|
+
stays relative. The string is **not** percent-encoded on the way in, so a
|
|
48
|
+
destination that legitimately contains a literal `%20` — or a path object
|
|
49
|
+
built by a consumer that already encoded it — is not encoded twice.
|
|
50
|
+
`readlink()`, `unlink()`, `rmdir()` and `hardlink_to()` never had this
|
|
51
|
+
defect. `copy()`/`move()` are deliberately unchanged: their `str`
|
|
52
|
+
destination is still parsed as a URI, which is what makes a cross-scheme
|
|
53
|
+
`copy("s3://bucket/key")` work. See `docs/divergences.md`.
|
|
54
|
+
|
|
55
|
+
## [0.9.2] - 2026-08-16
|
|
56
|
+
|
|
57
|
+
### Fixed
|
|
58
|
+
- **`MemPath.open("w")` on an existing directory raised nothing and destroyed
|
|
59
|
+
the tree.** `_open()` assigned over whatever was already at the name, so
|
|
60
|
+
`MemPath("dir").write_text(...)` replaced a directory and everything under
|
|
61
|
+
it with a file — silently, in the class the docs present as the reference
|
|
62
|
+
exemplar for extending this library, and the class used as a mock
|
|
63
|
+
filesystem in tests. It now raises `IsADirectoryError`, as `pathlib` does.
|
|
64
|
+
The same guard covers the virtual root for every mode, which used to grow a
|
|
65
|
+
bogus `""` key in the backend on `"w"`/`"a"`.
|
|
66
|
+
- **A `MemPath` routed *through* a file raised `TypeError`.**
|
|
67
|
+
`_parent_container()` walked ancestors with `path not in parent`, which on a
|
|
68
|
+
`bytearray` ancestor evaluates `"seg" not in bytearray`. That `TypeError`
|
|
69
|
+
sails past the `OSError` guard in `Stat._st_mode()`, so even
|
|
70
|
+
`MemPath("file.txt/sub").exists()` crashed instead of returning `False` —
|
|
71
|
+
a routine shape in glob/walk and in `mkdir(parents=True)`. It now raises
|
|
72
|
+
`NotADirectoryError` naming the offending ancestor.
|
|
73
|
+
- **`Pathname` had no `__eq__`/`__hash__`, so subclasses compared by
|
|
74
|
+
identity.** Every pure subclass that didn't hand-write equality — including
|
|
75
|
+
`MemPath`, and any downstream class subclassing `Path` directly — was
|
|
76
|
+
unusable as a dict key or set member, and `is_relative_to()` (which decides
|
|
77
|
+
via `==` against freshly built parents) always returned `False` without
|
|
78
|
+
raising. There is now a default keyed on
|
|
79
|
+
`(type(self), tuple(self.segments))`. `LocalPath`, `PosixPathname` and
|
|
80
|
+
`WindowsPathname` are unaffected — `pathlib.PurePath` precedes `Pathname` in
|
|
81
|
+
their MRO and keeps its own equality — as is `Uri`, which defines one.
|
|
82
|
+
- **`is_relative_to()` normalized a `str` argument by joining it onto
|
|
83
|
+
`self`.** `Pathname` used `cls(self, other)` and `Uri` used
|
|
84
|
+
`Uri(self, _ROOT, other)`, so `Uri("a/b").is_relative_to("a")` compared
|
|
85
|
+
against `"a/b/a"` / `"/a"` and answered `False` while
|
|
86
|
+
`Uri("a/b").is_relative_to(Uri("a"))` answered `True` — the str and object
|
|
87
|
+
forms of the same call disagreed. Both now parse `other` standalone, as
|
|
88
|
+
CPython does. The generic side uses `self.with_segments(other)` so a
|
|
89
|
+
subclass's per-instance state (`MemPath`'s backend) survives the
|
|
90
|
+
normalization. `Uri("http://h/a/b").is_relative_to("/a")` is still `True`.
|
|
91
|
+
- **`LocalPath.chown()` leaked `AttributeError`/`LookupError` on Windows.**
|
|
92
|
+
`shutil.chown` exists there while `os.chown` does not, so an int id raised
|
|
93
|
+
`AttributeError` and a name raised a misleading `LookupError: no such user`
|
|
94
|
+
(with no `pwd` module, every name misses whether or not the user exists).
|
|
95
|
+
It now raises `NotImplementedError`, which `docs/divergences.md` already
|
|
96
|
+
promised and which every other unsupported capability here raises. The
|
|
97
|
+
all-unchanged no-op still succeeds.
|
|
98
|
+
|
|
8
99
|
## [0.9.1] - 2026-08-04
|
|
9
100
|
|
|
10
101
|
### Added
|
|
@@ -732,7 +823,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
732
823
|
- Sync error handling.
|
|
733
824
|
- Generic Path Protocol based pathlib implementation for URI paths with file access support for sftp, http, file schemes.
|
|
734
825
|
|
|
735
|
-
[Unreleased]: https://github.com/jose-pr/pathlib-next/compare/v0.9.
|
|
826
|
+
[Unreleased]: https://github.com/jose-pr/pathlib-next/compare/v0.9.3...HEAD
|
|
827
|
+
[0.9.3]: https://github.com/jose-pr/pathlib-next/compare/v0.9.2...v0.9.3
|
|
828
|
+
[0.9.2]: https://github.com/jose-pr/pathlib-next/compare/v0.9.1...v0.9.2
|
|
736
829
|
[0.9.1]: https://github.com/jose-pr/pathlib-next/compare/v0.9.0...v0.9.1
|
|
737
830
|
[0.8.6]: https://github.com/jose-pr/pathlib-next/compare/v0.8.5...v0.8.6
|
|
738
831
|
[0.8.5]: https://github.com/jose-pr/pathlib-next/compare/v0.8.4...v0.8.5
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: pathlib-next
|
|
3
|
-
Version: 0.9.
|
|
3
|
+
Version: 0.9.3
|
|
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/
|
|
@@ -61,8 +61,9 @@ these operations itself always keeps its own implementation.
|
|
|
61
61
|
| `Path.move(target, ...)` | Not in `pathlib` at all | Our own extension: tries `rename()`, falls back to copy+unlink | N/A -- pure extension, no pathlib method to diverge from. |
|
|
62
62
|
| `Path.rm(recursive=, missing_ok=, ignore_error=)` | Not in `pathlib` (closest: `shutil.rmtree`) | Our own extension. Recursive removal deletes bottom-up and uses non-following stat/listing metadata, so a symlink to a directory is unlinked rather than traversed. | N/A -- pure extension. Non-following recursive deletion avoids deleting through symlinked directory targets and lets backends with metadata-rich listings remove trees without a stat round trip per child. |
|
|
63
63
|
| `Path.symlink_to(target, target_is_directory=False, *, force=False)` | `pathlib.Path.symlink_to(target, target_is_directory=False)` -- raises `FileExistsError` if anything already exists at the link path | Adds a keyword-only `force=`. `force=False` (the default) is stdlib-exact. `force=True` unlinks an existing **non-directory** entry at the link path first, then creates the symlink; an existing *directory* is never removed and the underlying error propagates. Not atomic: no filesystem or transport offers "replace a symlink" as one operation, so the path briefly does not exist between the unlink and the symlink. | Additive extension (an extra optional kwarg, per the parity contract). No backend can offer this atomically, so every consumer was re-implementing the same unlink-then-symlink dance -- it is path semantics, not transport semantics, so it belongs at the `Path` layer where one implementation serves every backend. Backends implement only the `_symlink_to()` primitive (same `_mkdir`/`_open` shape) and get `force=` for free. Because no stdlib version accepts the keyword, `symlink_to` is in `_OPERATION_NAMES` so `LocalPath` honors it too. |
|
|
64
|
-
| `Path.chown(uid=None, gid=None, *, follow_symlinks=True)` | Not in `pathlib` at all -- it has `owner()`/`group()` **readers** but no writer (the stdlib writers are `os.chown`/`shutil.chown`, which are functions over a path, not path methods) | Our own extension. `None` (default) leaves a field unchanged; `-1` is accepted as an alias for `None` (`os.chown`'s own sentinel); an `int` is a uid/gid and a `str` is a user/group name. A call where both fields are unchanged short-circuits without touching the backend. Implemented for `LocalPath`/`FileUri` (via `shutil.chown`, or `os.lchown` for `follow_symlinks=False`) and `SftpPath` (`setstat`); `NotImplementedError` elsewhere. | Ownership was the one permission attribute `stat()` could report (`st_uid`/`st_gid`) that nothing could write back. The valuable part is centralizing the **"unchanged" sentinel** on `Path` (`utils.as_owner()`): `os.chown` spells it `-1`, SFTP omits the field, other middlewares use `None` -- normalizing per-scheme would be three chances to disagree. Backends implement `_chown()` and receive an already-canonical pair. SFTPv3 sends uid/gid as one paired attribute, so `SftpPath` reads the current owner for whichever field is unchanged rather than guessing a value. |
|
|
64
|
+
| `Path.chown(uid=None, gid=None, *, follow_symlinks=True)` | Not in `pathlib` at all -- it has `owner()`/`group()` **readers** but no writer (the stdlib writers are `os.chown`/`shutil.chown`, which are functions over a path, not path methods) | Our own extension. `None` (default) leaves a field unchanged; `-1` is accepted as an alias for `None` (`os.chown`'s own sentinel); an `int` is a uid/gid and a `str` is a user/group name. A call where both fields are unchanged short-circuits without touching the backend. Implemented for `LocalPath`/`FileUri` (via `shutil.chown`, or `os.lchown` for `follow_symlinks=False`) and `SftpPath` (`setstat`); `NotImplementedError` elsewhere -- including `LocalPath` on a platform without `os.chown` (Windows), where `shutil.chown` exists but cannot work. | Ownership was the one permission attribute `stat()` could report (`st_uid`/`st_gid`) that nothing could write back. The valuable part is centralizing the **"unchanged" sentinel** on `Path` (`utils.as_owner()`): `os.chown` spells it `-1`, SFTP omits the field, other middlewares use `None` -- normalizing per-scheme would be three chances to disagree. Backends implement `_chown()` and receive an already-canonical pair. SFTPv3 sends uid/gid as one paired attribute, so `SftpPath` reads the current owner for whichever field is unchanged rather than guessing a value. |
|
|
65
65
|
| `Path.chmod(mode, ...)` accepting a `str` | `mode` must be an `int`; a `str` raises `TypeError` | Additionally accepts a `str`, parsed as **octal**: `"0755"`, `"755"` and `0o755` all mean the same thing. An optional `0o` prefix is allowed; any character outside `[0-7]` raises `ValueError` rather than being coerced. | The string form is how modes are written in `chmod(1)`, Ansible, Dockerfiles and shell scripts, so config-driven callers arrive holding one. Accepted only with an **explicit base 8** (`utils.as_mode()`), never a plain `int()`: `int("0755")` in decimal is 755 == `0o1363`, a different *and valid* mode, so a fallback to decimal would set plausible-but-wrong permissions with nothing raising -- which is exactly why stdlib refuses strings. Parsing in one shared helper is what makes the base non-negotiable across the five backends that implement `chmod` directly. |
|
|
66
|
+
| A `str` destination to `rename()`/`symlink_to()` on a `UriPath` | `pathlib.Path.rename(str)`/`symlink_to(str)` take the string as a path, verbatim; a relative one is resolved against the **cwd** | The string is taken as an already-**decoded path**, never re-parsed as URI syntax: `?`, `#`, `%` and `:` are ordinary filename characters, so `rename("rn?b.txt")` renames to `rn?b.txt`. A relative `rename()` destination resolves against `self.parent` (sibling rename), since a URI has no cwd; a relative `symlink_to()` target is stored verbatim and stays relative, exactly as pathlib does. `copy()`/`move()` are unchanged -- their `str` destination is still parsed as a URI, which is what makes a cross-scheme `copy("s3://bucket/key")` work. | Restores pathlib parity on the two methods whose destination is unambiguously a path on the same host. Re-parsing it as a URI discarded everything from a `?`/`#` onward and percent-decoded the rest -- silently, so a rename landed on a different file and a symlink pointed somewhere else (measured against a real SFTP server, 0.9.3). Percent-encoding the string before parsing was rejected: it double-encodes a name that legitimately contains a literal `%20`, and it puts a copy of the safe set in every consumer. The parse is bypassed instead -- `Uri._from_decoded_path()`, one implementation for every scheme. |
|
|
66
67
|
| `PathSyncer` / `Query` / `Source` | N/A | Our own extensions | N/A -- pure extensions, no pathlib equivalent. |
|
|
67
68
|
| `PathSyncer(follow_symlinks=False).sync()` on a symlink source | N/A (no pathlib equivalent) | Previously always raised `NotImplementedError`. Now controlled by the new `symlink_mode` constructor kwarg (`"preserve"` default, `"reject"` opt-out): `"preserve"` creates a matching symlink on `target` with the same raw, unresolved target string `readlink()` returned (dangling links and relative targets included, never validated/resolved); `"reject"` restores the exact old unconditional-raise behavior. If `target` can't create symlinks at all (every backend except `LocalPath` and `SftpPath`), `"preserve"` also raises `NotImplementedError`, through the same `ignore_error`/`hook()` machinery as every other sync branch, not a silent skip. **This is a default-behavior change, not a pure extension** -- flagged here because existing callers relying on the old unconditional raise (e.g. to detect and skip symlinks) must now pass `symlink_mode="reject"` explicitly. | Faithful one-way tree mirroring needs symlinks preserved as symlinks by default, not silently dropped/erroring -- discovered via a real cross-host sync use case (hostctl). `follow_symlinks=True` (unchanged default) still resolves through symlinks during traversal, so this only affects callers who already opted into `follow_symlinks=False`. **User decision, 2026-07-28.** |
|
|
68
69
|
| `S3Path` directories | N/A (pathlib directories are real filesystem entries) | `is_dir()` is prefix emulation (any object key under `"<path>/"`); `mkdir()` creates a zero-byte `"<path>/"` marker object; `rmdir()` requires no other keys under that prefix (pathlib-parity "must be empty"). If an exact object key and a `"<path>/"` prefix both exist, exact object operations such as `stat()` and `rm(recursive=True)` treat the path as the object first. | S3 has no native directory concept -- this is the same prefix convention the AWS console itself uses for an empty "folder". Exact-object precedence avoids deleting a prefix tree when the addressed path is a real object. |
|
|
@@ -31,6 +31,16 @@ as_uri() # a URI string identifying this path (can be a custom scheme)
|
|
|
31
31
|
relative_to(other) # or raise NotImplementedError if not meaningful
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
+
Equality is **not** on that list: `Pathname` supplies a default `__eq__`/
|
|
35
|
+
`__hash__` keyed on `(type(self), tuple(self.segments))`, so your class is
|
|
36
|
+
usable as a dict key or set member, and the equality-based helpers
|
|
37
|
+
(`is_relative_to()`, `parents` membership) work, without you writing
|
|
38
|
+
anything. Override both together if your type needs a different identity
|
|
39
|
+
-- e.g. case-insensitive segments, or one that also distinguishes the
|
|
40
|
+
backing store two otherwise-identical paths point at. (`LocalPath` and the
|
|
41
|
+
`*Pathname` classes don't use this default: `pathlib.PurePath` precedes
|
|
42
|
+
`Pathname` in their MRO and keeps its own equality.)
|
|
43
|
+
|
|
34
44
|
Optional I/O, implement whichever your resource actually supports -- leave
|
|
35
45
|
the rest as the inherited `@notimplemented` stubs (derived helpers either
|
|
36
46
|
fall back, e.g. `move()` falls back to copy+unlink when `rename()` isn't
|
|
@@ -12,7 +12,7 @@ build-backend = "hatchling.build"
|
|
|
12
12
|
# `import pathlib_next`) -- a hyphen is not legal in a Python identifier.
|
|
13
13
|
# Distribution name and import name differing is ordinary and intended.
|
|
14
14
|
name = "pathlib-next"
|
|
15
|
-
version = "0.9.
|
|
15
|
+
version = "0.9.3"
|
|
16
16
|
authors = [{ name = "Jose A" }]
|
|
17
17
|
description = "Generic Path Protocol based pathlib"
|
|
18
18
|
readme = "README.md"
|
|
@@ -26,6 +26,17 @@ pathlib_next`.
|
|
|
26
26
|
(abstract), `match(pattern, *, case_sensitive=None)`,
|
|
27
27
|
`full_match(pattern, *, case_sensitive=None)`, `as_posix()`,
|
|
28
28
|
`has_glob_pattern()`. `as_uri()` is abstract on `Pathname` itself.
|
|
29
|
+
- `__eq__`/`__hash__` are supplied by default, keyed on
|
|
30
|
+
`(type(self), tuple(self.segments))` — exact type, so a subclass never
|
|
31
|
+
compares equal to its base. Classes mixing in `pathlib.PurePath`
|
|
32
|
+
(`PosixPathname`, `WindowsPathname`, `LocalPath`) keep stdlib's
|
|
33
|
+
equality instead, since `PurePath` precedes `Pathname` in their MRO;
|
|
34
|
+
`Uri` defines its own over `as_uri()`. Override both together if your
|
|
35
|
+
subclass needs a different identity.
|
|
36
|
+
- `is_relative_to(other)` parses a `str` `other` standalone, via
|
|
37
|
+
`self.with_segments(other)` — the same rule as CPython's
|
|
38
|
+
`self.with_segments(other)`, and it preserves per-instance state such
|
|
39
|
+
as `MemPath`'s backend.
|
|
29
40
|
- **`Path(Pathname, Chmod, Stat, BinaryOpen)`** — base class for I/O paths.
|
|
30
41
|
`Path(*args)` (the bare class, not a subclass) always constructs a
|
|
31
42
|
`LocalPath` (`fspath.py`) — the real local filesystem. Adds:
|
|
@@ -62,15 +73,21 @@ pathlib_next`.
|
|
|
62
73
|
equivalent. Removes a file or (with `recursive=True`) a directory tree;
|
|
63
74
|
`ignore_error` (bool or predicate) controls whether an error during the
|
|
64
75
|
walk is swallowed (predicate return `True`) or re-raised.
|
|
65
|
-
- `rename(target)` — not implemented by default.
|
|
76
|
+
- `rename(target)` — not implemented by default. On a `UriPath` a `str`
|
|
77
|
+
`target` is an already-**decoded path**, not URI syntax, and a relative
|
|
78
|
+
one is a sibling rename — see `Uri._rename_target()` under "URIs".
|
|
66
79
|
- `_symlink_to(target, target_is_directory=False)` (not implemented by
|
|
67
80
|
default) / `symlink_to(target, target_is_directory=False, *,
|
|
68
81
|
force=False)` — same primitive/wrapper split as `_mkdir`/`mkdir`: a
|
|
69
82
|
backend implements only `_symlink_to()` and receives an already
|
|
70
|
-
normalized path object
|
|
71
|
-
wrapper, as `copy()`/`move()` do), then reads the raw target string the
|
|
83
|
+
normalized path object, then reads the raw target string the
|
|
72
84
|
way its transport needs (`Uri.path` on the wire, `os.fspath()`
|
|
73
|
-
locally). `
|
|
85
|
+
locally). The `str`→path step is `_symlink_target(target)`, an
|
|
86
|
+
overridable hook: the default is `type(self)(target)`, and `UriPath`
|
|
87
|
+
overrides it so a link target is taken literally instead of being
|
|
88
|
+
re-parsed as a URI (a `?`/`#` in it is a filename character, not a
|
|
89
|
+
delimiter). A relative target is never anchored — it stays relative,
|
|
90
|
+
as in pathlib. `force=` is this library's extension: `False` is
|
|
74
91
|
stdlib-exact, `True` unlinks an existing **non-directory** entry at the
|
|
75
92
|
link path first (never a directory) and is **not** atomic. Listed in
|
|
76
93
|
`_OPERATION_NAMES`, since no stdlib version accepts `force=`.
|
|
@@ -154,7 +171,10 @@ pathlib_next`.
|
|
|
154
171
|
alias for it; an `int` is an id, a `str` is a name. `chown()` normalizes
|
|
155
172
|
via `utils.as_owner()` and short-circuits when nothing would change, so
|
|
156
173
|
`_chown()` always receives a canonical pair and only converts to its own
|
|
157
|
-
wire spelling (`-1` for `os.chown`, an omitted attr for SFTP).
|
|
174
|
+
wire spelling (`-1` for `os.chown`, an omitted attr for SFTP). On a
|
|
175
|
+
platform without `os.chown` (Windows) `LocalPath.chown()` raises
|
|
176
|
+
`NotImplementedError` for any real change; the all-unchanged no-op still
|
|
177
|
+
succeeds because it never reaches the backend.
|
|
158
178
|
- **`io.BinaryOpen`** — `Protocol`. `_open(mode="r", buffering=-1) ->
|
|
159
179
|
io.IOBase` (not implemented by default; must yield a **binary** stream).
|
|
160
180
|
Derives `open(mode="r", buffering=-1, encoding=None, errors=None,
|
|
@@ -204,7 +224,10 @@ extra that depends on it).
|
|
|
204
224
|
`suffix`, `stem`, `parent`. Methods: `as_uri(sanitize=False)` (sanitize
|
|
205
225
|
strips password from userinfo before formatting), `with_source(source)`,
|
|
206
226
|
`with_segments(*segments)`, `with_path(path)`, `with_query(query)`,
|
|
207
|
-
`with_fragment(fragment)`, `is_absolute()`, `is_relative_to(other)
|
|
227
|
+
`with_fragment(fragment)`, `is_absolute()`, `is_relative_to(other)`
|
|
228
|
+
(a `str` `other` is parsed standalone as `Uri(other)`, matching
|
|
229
|
+
`relative_to()`; an `other` with no authority is compatible with any
|
|
230
|
+
`self.source`, so `Uri("http://h/a/b").is_relative_to("/a")` is `True`),
|
|
208
231
|
`relative_to(other, *, walk_up=False)`, `is_local()` (delegates to
|
|
209
232
|
`Source.is_local()` — does a DNS lookup, cached per `Source`),
|
|
210
233
|
`as_posix()` (`user@host:path` / `host:path` form when a source is
|
|
@@ -215,6 +238,18 @@ extra that depends on it).
|
|
|
215
238
|
is the unambiguous accessor for "path on the URI's own host" — same
|
|
216
239
|
`_host_filesystem_path` gate, but never falls back to local-path
|
|
217
240
|
semantics. See `docs/divergences.md`.
|
|
241
|
+
Destination/target normalization (used by every scheme's `rename()` and
|
|
242
|
+
by `symlink_to()`): `_from_decoded_path(path)` builds a same-type URI
|
|
243
|
+
whose `.path` is `path` **verbatim** — an already-decoded path string,
|
|
244
|
+
not URI syntax, so `?`, `#`, `%` and `:` in it are ordinary filename
|
|
245
|
+
characters and only dot segments are normalized.
|
|
246
|
+
`_rename_target(target)` is what `rename()` calls: a `Uri` passes
|
|
247
|
+
through untouched, a `str` goes through `_from_decoded_path()` and, if
|
|
248
|
+
relative, is joined onto `self.parent` (sibling rename — a URI has no
|
|
249
|
+
cwd). `_symlink_target(target)` (overriding `Path`'s) is the same minus
|
|
250
|
+
the parent anchoring, so a relative link target stays relative.
|
|
251
|
+
`copy()`/`move()` deliberately still parse a `str` destination as a URI
|
|
252
|
+
— that is what makes a cross-scheme `copy("s3://bucket/key")` work.
|
|
218
253
|
- **`UriPath(Uri, Path)`** — `Uri` + `Path` (I/O) + scheme dispatch.
|
|
219
254
|
`UriPath(*uris, **options)` (the bare class) parses the URI and returns an
|
|
220
255
|
instance of the concrete subclass registered for its scheme via
|
|
@@ -193,6 +193,15 @@ class LocalPath(
|
|
|
193
193
|
# canonical form Chmod.chown() already normalized to, so the pair
|
|
194
194
|
# passes straight through. os.chown's -1 sentinel never appears
|
|
195
195
|
# here.
|
|
196
|
+
if not hasattr(_os, "chown"):
|
|
197
|
+
# shutil.chown() *exists* on Windows while os.chown does not, so
|
|
198
|
+
# without this the int form leaked `AttributeError: module 'os'
|
|
199
|
+
# has no attribute 'chown'` and the name form leaked
|
|
200
|
+
# `LookupError: no such user` -- actively misleading, since
|
|
201
|
+
# there is no `pwd` module for shutil._get_uid to consult, so
|
|
202
|
+
# every name "misses" whether or not the user exists.
|
|
203
|
+
# docs/divergences.md promises NotImplementedError here.
|
|
204
|
+
raise NotImplementedError("chown()")
|
|
196
205
|
if not follow_symlinks:
|
|
197
206
|
if not hasattr(_os, "lchown"):
|
|
198
207
|
raise NotImplementedError("chown(follow_symlinks=False)")
|
|
@@ -121,11 +121,19 @@ class MemPath(Path):
|
|
|
121
121
|
def _parent_container(self) -> tuple[dict[str, bytearray], str]:
|
|
122
122
|
parent = self.backend
|
|
123
123
|
*ancestors, name = self.normalized
|
|
124
|
-
for path in ancestors:
|
|
124
|
+
for index, path in enumerate(ancestors):
|
|
125
125
|
if path not in parent:
|
|
126
126
|
raise FileNotFoundError(self.parent)
|
|
127
|
-
|
|
128
|
-
|
|
127
|
+
parent = parent[path]
|
|
128
|
+
if not isinstance(parent, dict):
|
|
129
|
+
# An ancestor segment names a file. Without this the next
|
|
130
|
+
# iteration evaluates `"seg" not in bytearray` and raises
|
|
131
|
+
# TypeError, which sails past the OSError guards in
|
|
132
|
+
# stat()/exists()/is_dir() -- so even exists() crashed on a
|
|
133
|
+
# path merely routed through a file. NotADirectoryError is
|
|
134
|
+
# an OSError, which is what stdlib raises and what those
|
|
135
|
+
# guards already swallow.
|
|
136
|
+
raise NotADirectoryError(self.with_segments(*ancestors[: index + 1]))
|
|
129
137
|
|
|
130
138
|
return parent, name
|
|
131
139
|
|
|
@@ -192,6 +200,11 @@ class MemPath(Path):
|
|
|
192
200
|
# mode contract: "r"/"w" are required; "x"/"a" are supported here
|
|
193
201
|
# as an extension. Anything else raises NotImplementedError.
|
|
194
202
|
parent, name = self._parent_container()
|
|
203
|
+
if not name:
|
|
204
|
+
# An empty name is the virtual root, which stat() reports as a
|
|
205
|
+
# directory. Without this guard "w"/"a" created a bogus ""
|
|
206
|
+
# entry in the backend and "r" claimed FileNotFoundError.
|
|
207
|
+
raise IsADirectoryError(self)
|
|
195
208
|
if mode == "r":
|
|
196
209
|
if name not in parent:
|
|
197
210
|
raise FileNotFoundError(self)
|
|
@@ -200,6 +213,10 @@ class MemPath(Path):
|
|
|
200
213
|
raise IsADirectoryError(self)
|
|
201
214
|
return io.BytesIO(content)
|
|
202
215
|
elif mode == "w":
|
|
216
|
+
if isinstance(parent.get(name), dict):
|
|
217
|
+
# Truncating over a directory silently replaced the whole
|
|
218
|
+
# subtree with a file; stdlib raises IsADirectoryError.
|
|
219
|
+
raise IsADirectoryError(self)
|
|
203
220
|
content = bytearray()
|
|
204
221
|
parent[name] = content
|
|
205
222
|
return MemBytesIO(content)
|
|
@@ -187,9 +187,35 @@ class Pathname(FsPathLike, _ty.Generic[_P]):
|
|
|
187
187
|
def is_relative_to(self, other: _ty.Self | str):
|
|
188
188
|
"""Return True if the path is relative to another path or False."""
|
|
189
189
|
cls = type(self)
|
|
190
|
-
|
|
190
|
+
# with_segments(other), NOT cls(self, other): joining `other` under
|
|
191
|
+
# `self` first turned `MemPath("a/b").is_relative_to("a")` into a
|
|
192
|
+
# comparison against "a/b/a" and answered False, while the object
|
|
193
|
+
# form of the same call answered True. CPython parses `other`
|
|
194
|
+
# standalone (`self.with_segments(other)`) and so do we -- via
|
|
195
|
+
# with_segments rather than the bare constructor so per-instance
|
|
196
|
+
# state a subclass carries (MemPath's backend) survives.
|
|
197
|
+
other = other if isinstance(other, cls) else self.with_segments(other)
|
|
191
198
|
return other == self or other in self.parents
|
|
192
199
|
|
|
200
|
+
def __eq__(self, other: object) -> bool:
|
|
201
|
+
"""Compare by (exact type, segments).
|
|
202
|
+
|
|
203
|
+
The ABC previously defined no equality at all, so every pure
|
|
204
|
+
subclass that didn't hand-write one -- including `MemPath`, the
|
|
205
|
+
documented reference exemplar -- compared by identity. That made
|
|
206
|
+
`is_relative_to()` (which decides via `==`) silently return False
|
|
207
|
+
for every subclass, and broke paths as dict keys or set members.
|
|
208
|
+
`_BaseFSPathname`/`LocalPath` are unaffected: `pathlib.PurePath`
|
|
209
|
+
precedes `Pathname` in their MRO and keeps its own `__eq__`, as
|
|
210
|
+
does `Uri`, which defines one.
|
|
211
|
+
"""
|
|
212
|
+
if type(self) is not type(other):
|
|
213
|
+
return NotImplemented
|
|
214
|
+
return tuple(self.segments) == tuple(other.segments)
|
|
215
|
+
|
|
216
|
+
def __hash__(self) -> int:
|
|
217
|
+
return hash((type(self), tuple(self.segments)))
|
|
218
|
+
|
|
193
219
|
def __truediv__(self, key: _ty.Self | str) -> _ty.Self:
|
|
194
220
|
try:
|
|
195
221
|
return type(self)(self, key)
|
|
@@ -262,7 +288,7 @@ class Pathname(FsPathLike, _ty.Generic[_P]):
|
|
|
262
288
|
def has_glob_pattern(self):
|
|
263
289
|
"""Return True if any of the path segments contain glob wildcards."""
|
|
264
290
|
for segment in self.segments:
|
|
265
|
-
if _glob.WILDCARD_PATTERN.search(segment)
|
|
291
|
+
if _glob.WILDCARD_PATTERN.search(segment) is not None:
|
|
266
292
|
return True
|
|
267
293
|
return False
|
|
268
294
|
|
|
@@ -706,6 +732,17 @@ class Path(Pathname, Chmod, Stat, BinaryOpen):
|
|
|
706
732
|
"""
|
|
707
733
|
...
|
|
708
734
|
|
|
735
|
+
def _symlink_target(self, target: "_ty.Self | str") -> "_ty.Self":
|
|
736
|
+
"""Normalize a `symlink_to()` target argument to a path object.
|
|
737
|
+
|
|
738
|
+
A `str` target is the **literal link target** -- whatever it says
|
|
739
|
+
is what gets stored, verbatim and unresolved, exactly as
|
|
740
|
+
`pathlib.Path.symlink_to()` does. Override this wherever
|
|
741
|
+
`type(self)(str)` would reinterpret the string instead of taking
|
|
742
|
+
it literally (`UriPath` does; see `UriPath._symlink_target`).
|
|
743
|
+
"""
|
|
744
|
+
return type(self)(target) if isinstance(target, str) else target
|
|
745
|
+
|
|
709
746
|
def symlink_to(
|
|
710
747
|
self,
|
|
711
748
|
target: "_ty.Self | str",
|
|
@@ -732,10 +769,12 @@ class Path(Pathname, Chmod, Stat, BinaryOpen):
|
|
|
732
769
|
for.
|
|
733
770
|
"""
|
|
734
771
|
# Normalize a str target to a path object, so the primitive only
|
|
735
|
-
# ever handles one type
|
|
736
|
-
#
|
|
737
|
-
|
|
738
|
-
|
|
772
|
+
# ever handles one type. Routed through `_symlink_target()` rather
|
|
773
|
+
# than inlining `type(self)(target)`: for a URI-backed path that
|
|
774
|
+
# constructor re-parses the string as URI syntax, which silently
|
|
775
|
+
# truncated a link target at a "?"/"#" and percent-decoded it (see
|
|
776
|
+
# `UriPath._symlink_target`).
|
|
777
|
+
target = self._symlink_target(target)
|
|
739
778
|
if force:
|
|
740
779
|
try:
|
|
741
780
|
self.unlink(missing_ok=True)
|
|
@@ -854,8 +893,6 @@ class Path(Pathname, Chmod, Stat, BinaryOpen):
|
|
|
854
893
|
if isinstance(target, str):
|
|
855
894
|
target = type(self)(target)
|
|
856
895
|
src = self
|
|
857
|
-
if src is None:
|
|
858
|
-
return
|
|
859
896
|
|
|
860
897
|
if target.exists():
|
|
861
898
|
if overwrite:
|
|
@@ -232,6 +232,50 @@ class Uri(Pathname):
|
|
|
232
232
|
uri._init(source, path, query, fragment, **kwargs)
|
|
233
233
|
return uri
|
|
234
234
|
|
|
235
|
+
def _from_decoded_path(self, path: str, /, **kwargs) -> "_ty.Self":
|
|
236
|
+
"""Build a same-type URI whose `.path` is `path` verbatim.
|
|
237
|
+
|
|
238
|
+
`path` is an **already-decoded path string**, not URI syntax: `?`,
|
|
239
|
+
`#`, `%` and `:` are ordinary filename characters here. Only
|
|
240
|
+
path-level normalization (dot segments, exactly what `_parse_uri`
|
|
241
|
+
applies *after* decoding) is performed -- nothing is split off and
|
|
242
|
+
nothing is percent-decoded.
|
|
243
|
+
|
|
244
|
+
This is what a destination/target argument must go through.
|
|
245
|
+
Feeding such a string back into the URI parser (`Uri(path)`,
|
|
246
|
+
`type(self)(path)`) reads it as syntax: "a?b.txt" silently became
|
|
247
|
+
"a" plus a query, "a#b.txt" became "a" plus a fragment,
|
|
248
|
+
"a%20b.txt" became "a b.txt", and a relative "C:/Temp/x" became
|
|
249
|
+
scheme "c" plus "/Temp/x" -- so the wire call went to a different
|
|
250
|
+
file than the caller named, with no error. Percent-encoding the
|
|
251
|
+
string before parsing would fix the truncation but re-encode an
|
|
252
|
+
already-encoded name (a literal "%20" would come back as a space),
|
|
253
|
+
so the parse is bypassed instead of being fed encoded input.
|
|
254
|
+
"""
|
|
255
|
+
return self._from_parsed_parts(
|
|
256
|
+
_NOSOURCE, _remove_dot_segments(path), None, None, **kwargs
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
def _rename_target(self, target: UriLike) -> "Uri":
|
|
260
|
+
"""Normalize a `rename()`/`replace()` destination to a `Uri`.
|
|
261
|
+
|
|
262
|
+
A `str` is an already-decoded path (see `_from_decoded_path`), and
|
|
263
|
+
a relative one is resolved against `self.parent` -- the documented
|
|
264
|
+
sibling-rename semantics ("rename this to a new name in the same
|
|
265
|
+
directory"), not against `self` itself. A `Uri` (of any scheme
|
|
266
|
+
class) is taken as given; anything else keeps the pre-existing
|
|
267
|
+
`Uri(...)` conversion, which is already lossless for
|
|
268
|
+
`PurePath`/`os.PathLike` (they are percent-encoded on the way in
|
|
269
|
+
and decoded back out).
|
|
270
|
+
"""
|
|
271
|
+
if isinstance(target, Uri):
|
|
272
|
+
return target
|
|
273
|
+
if isinstance(target, str):
|
|
274
|
+
target = self._from_decoded_path(target)
|
|
275
|
+
# target is a Uri by now, so this join re-uses `_load_parts`'
|
|
276
|
+
# existing right-to-left semantics without re-parsing anything.
|
|
277
|
+
return Uri(self.parent, target)
|
|
278
|
+
|
|
235
279
|
@classmethod
|
|
236
280
|
def _format_parsed_parts(
|
|
237
281
|
cls,
|
|
@@ -432,7 +476,16 @@ class Uri(Pathname):
|
|
|
432
476
|
|
|
433
477
|
def is_relative_to(self, other: UriLike):
|
|
434
478
|
"""Return True if the path is relative to another path or False."""
|
|
435
|
-
|
|
479
|
+
# Uri(other), NOT Uri(self, _ROOT, other): anchoring a str `other`
|
|
480
|
+
# at self's root turned `Uri("a/b").is_relative_to("a")` into a
|
|
481
|
+
# comparison against "/a" and answered False, disagreeing with the
|
|
482
|
+
# object form of the same call. `relative_to()` below already
|
|
483
|
+
# parsed a str standalone; this matches it and CPython.
|
|
484
|
+
# The same-authority case still works: a standalone parse leaves
|
|
485
|
+
# `other.source` empty, which the guard below treats as compatible
|
|
486
|
+
# with any `self.source`, and the segment prefix compare is
|
|
487
|
+
# unaffected -- `Uri("http://h/a/b").is_relative_to("/a")` is True.
|
|
488
|
+
other = other if isinstance(other, Uri) else Uri(other)
|
|
436
489
|
if not (
|
|
437
490
|
(other.source == self.source)
|
|
438
491
|
or not (bool(self.source) and bool(other.source))
|
|
@@ -682,6 +735,21 @@ class UriPath(Uri, Path):
|
|
|
682
735
|
inst._init(source, self.path, self.query, self.fragment)
|
|
683
736
|
return inst
|
|
684
737
|
|
|
738
|
+
def _symlink_target(self, target: "UriLike") -> "_ty.Self":
|
|
739
|
+
"""`Path._symlink_target()` for URI-backed paths: a `str` target is
|
|
740
|
+
an already-decoded path, never URI syntax (see
|
|
741
|
+
`Uri._from_decoded_path`).
|
|
742
|
+
|
|
743
|
+
The default `type(self)(target)` ran the link target back through
|
|
744
|
+
the URI parser, so `symlink_to("/mnt/cache?v=2")` created a link
|
|
745
|
+
pointing at `/mnt/cache`. Unlike `_rename_target()` this never
|
|
746
|
+
anchors at `self.parent`: a symlink target is stored as given, so
|
|
747
|
+
a relative one stays relative.
|
|
748
|
+
"""
|
|
749
|
+
if isinstance(target, str):
|
|
750
|
+
return self._from_decoded_path(target)
|
|
751
|
+
return target
|
|
752
|
+
|
|
685
753
|
@_utils.notimplemented
|
|
686
754
|
def _listdir(self) -> "_ty.Iterator[str]": ...
|
|
687
755
|
|
|
@@ -717,6 +785,3 @@ class UriPath(Uri, Path):
|
|
|
717
785
|
def iterdir(self) -> "_ty.Iterator[Self]":
|
|
718
786
|
for name, stat in self._scandir():
|
|
719
787
|
yield self._make_child_relpath(name, stat_hint=stat)
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
_ROOT = Uri("/")
|
|
@@ -283,8 +283,7 @@ class ArchiveUri(UriPath):
|
|
|
283
283
|
# A plain str target is a sibling rename (relative to self's
|
|
284
284
|
# parent), matching sftp.py's/ftp.py's rename() semantics.
|
|
285
285
|
self._require_writable()
|
|
286
|
-
|
|
287
|
-
target = Uri(self.parent, target)
|
|
286
|
+
target = self._rename_target(target)
|
|
288
287
|
old_path = self.path
|
|
289
288
|
new_path = target.path.lstrip("/")
|
|
290
289
|
names = self._names()
|
|
@@ -282,8 +282,7 @@ class AzPath(UriPath):
|
|
|
282
282
|
raise
|
|
283
283
|
|
|
284
284
|
def rename(self, target: "AzPath | Uri | str"):
|
|
285
|
-
|
|
286
|
-
target = Uri(self.parent, target)
|
|
285
|
+
target = self._rename_target(target)
|
|
287
286
|
dest_key = (
|
|
288
287
|
self.with_segments(target).key
|
|
289
288
|
if not isinstance(target, AzPath)
|
|
@@ -212,8 +212,7 @@ class DavPath(HttpPath):
|
|
|
212
212
|
raise
|
|
213
213
|
|
|
214
214
|
def rename(self, target: "DavPath | Uri | str"):
|
|
215
|
-
|
|
216
|
-
target = Uri(self.parent, target)
|
|
215
|
+
target = self._rename_target(target)
|
|
217
216
|
dest = self.with_path(target.path)._wire_uri()
|
|
218
217
|
resp = self.backend.request(
|
|
219
218
|
"MOVE", self._wire_uri(), headers={"Destination": dest, "Overwrite": "F"}
|
|
@@ -228,8 +228,7 @@ class FtpPath(UriPath):
|
|
|
228
228
|
def rename(self, target: "FtpPath | Uri | str"):
|
|
229
229
|
# A plain str target is a sibling rename (relative to self's
|
|
230
230
|
# parent), matching sftp.py's rename() semantics.
|
|
231
|
-
|
|
232
|
-
target = Uri(self.parent, target)
|
|
231
|
+
target = self._rename_target(target)
|
|
233
232
|
self._ftpclient.rename(self.path, target.path)
|
|
234
233
|
|
|
235
234
|
def chmod(self, mode: int | str, *, follow_symlinks: bool = True):
|
|
@@ -247,8 +247,7 @@ class GsPath(UriPath):
|
|
|
247
247
|
raise
|
|
248
248
|
|
|
249
249
|
def rename(self, target: "GsPath | Uri | str"):
|
|
250
|
-
|
|
251
|
-
target = Uri(self.parent, target)
|
|
250
|
+
target = self._rename_target(target)
|
|
252
251
|
dest_key = target.path.lstrip("/")
|
|
253
252
|
source_blob = self._bucket.blob(self.key)
|
|
254
253
|
self._bucket.copy_blob(source_blob, self._bucket, dest_key)
|
|
@@ -254,8 +254,7 @@ class S3Path(UriPath):
|
|
|
254
254
|
raise
|
|
255
255
|
|
|
256
256
|
def rename(self, target: "S3Path | Uri | str"):
|
|
257
|
-
|
|
258
|
-
target = Uri(self.parent, target)
|
|
257
|
+
target = self._rename_target(target)
|
|
259
258
|
dest_key = target.path.lstrip("/")
|
|
260
259
|
self._client.copy_object(
|
|
261
260
|
Bucket=self.bucket,
|
|
@@ -357,9 +357,11 @@ class SftpPath(UriPath):
|
|
|
357
357
|
# "host:" for the sftp wire protocol, which only wants the raw path.
|
|
358
358
|
# A plain str target is resolved relative to self's *parent*
|
|
359
359
|
# (sibling rename -- "rename this file to a new name in the same
|
|
360
|
-
# directory"), not to self itself (which would join it as a child)
|
|
361
|
-
|
|
362
|
-
|
|
360
|
+
# directory"), not to self itself (which would join it as a child)
|
|
361
|
+
# -- and is taken as a literal path rather than re-parsed as a URI,
|
|
362
|
+
# which used to truncate "rn?b.txt" to "rn" on the wire (see
|
|
363
|
+
# `Uri._rename_target`).
|
|
364
|
+
target = self._rename_target(target)
|
|
363
365
|
return self._sftpclient.rename(self.path, target.path)
|
|
364
366
|
|
|
365
367
|
def _symlink_to(
|
|
@@ -306,9 +306,7 @@ class Source(_ty.NamedTuple):
|
|
|
306
306
|
types and treating `host` as local if ANY resolved address is.
|
|
307
307
|
`netimps.resolve()` gained OS-resolver-chain support in 0.2.0 --
|
|
308
308
|
before that it was dnspython-only, which is why this method
|
|
309
|
-
originally kept `socket.gethostbyname()` for this step
|
|
310
|
-
`.agents/findings/processed/2026-07-29_netimps_adoption_survey.md`
|
|
311
|
-
and the companion finding filed against `netimps` itself). The
|
|
309
|
+
originally kept `socket.gethostbyname()` for this step. The
|
|
312
310
|
"is this address MINE" comparison uses `netimps.is_local_address()`,
|
|
313
311
|
which enumerates real network interfaces
|
|
314
312
|
(`netimps.get_interfaces()`) instead of the weaker
|
|
@@ -76,7 +76,7 @@ def glob(
|
|
|
76
76
|
include_hidden = include_hidden or path.is_hidden()
|
|
77
77
|
pattern = compile_pattern(path.name, case_sensitive) if path.name else ANY_PATTERN
|
|
78
78
|
|
|
79
|
-
name_is_pattern = WILDCARD_PATTERN.search(path.name)
|
|
79
|
+
name_is_pattern = WILDCARD_PATTERN.search(path.name) is not None
|
|
80
80
|
wildcard_in_path = name_is_pattern or path.has_glob_pattern()
|
|
81
81
|
parent = next(iter(path.parents), None)
|
|
82
82
|
|
|
@@ -187,7 +187,7 @@ class PathAndStat(object):
|
|
|
187
187
|
return self._stat
|
|
188
188
|
|
|
189
189
|
def exists(self):
|
|
190
|
-
return self.stat
|
|
190
|
+
return self.stat is not None
|
|
191
191
|
|
|
192
192
|
def refresh(self, follow_symlink: bool):
|
|
193
193
|
self._stat = FileStat.from_path(self.path, follow_symlink=follow_symlink)
|