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.
Files changed (114) hide show
  1. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/CHANGELOG.md +94 -1
  2. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/PKG-INFO +2 -2
  3. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/docs/divergences.md +2 -1
  4. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/docs/guides/extending.md +10 -0
  5. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/pyproject.toml +1 -1
  6. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/AGENTS.md +41 -6
  7. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/fspath.py +9 -0
  8. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/mempath.py +20 -3
  9. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/path.py +45 -8
  10. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/protocols/fs.py +1 -1
  11. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/uri/__init__.py +69 -4
  12. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/archive/_base.py +1 -2
  13. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/az.py +1 -2
  14. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/dav.py +1 -2
  15. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/ftp.py +1 -2
  16. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/gs.py +1 -2
  17. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/s3.py +1 -2
  18. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/sftp/__init__.py +5 -3
  19. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/uri/source.py +1 -3
  20. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/utils/glob.py +1 -1
  21. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/utils/sync.py +1 -1
  22. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_archive_uri.py +15 -0
  23. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_ftp.py +14 -0
  24. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_mempath.py +81 -0
  25. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_parity_io.py +23 -3
  26. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_pathname.py +122 -0
  27. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_properties.py +15 -0
  28. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_s3.py +14 -0
  29. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_sftp.py +139 -3
  30. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_sync.py +1 -1
  31. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_uri_path.py +58 -0
  32. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/.gitignore +0 -0
  33. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/LICENSE +0 -0
  34. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/README.md +0 -0
  35. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/docs/api/mempath.md +0 -0
  36. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/docs/api/path.md +0 -0
  37. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/docs/api/testing.md +0 -0
  38. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/docs/api/uri.md +0 -0
  39. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/docs/api/utils.md +0 -0
  40. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/docs/benchmarks.md +0 -0
  41. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/docs/changelog.md +0 -0
  42. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/docs/guides/cli.md +0 -0
  43. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/docs/guides/schemes.md +0 -0
  44. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/docs/index.md +0 -0
  45. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/examples/az_listing.py +0 -0
  46. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/examples/data_and_archive.py +0 -0
  47. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/examples/ftp_listing.py +0 -0
  48. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/examples/github_listing.py +0 -0
  49. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/examples/gitlab_listing.py +0 -0
  50. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/examples/gs_listing.py +0 -0
  51. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/examples/http_listing.py +0 -0
  52. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/examples/local_and_mem.py +0 -0
  53. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/examples/s3_listing.py +0 -0
  54. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/examples/sftp_sync.py +0 -0
  55. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/examples/webdav_roundtrip.py +0 -0
  56. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/mkdocs.yml +0 -0
  57. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/__init__.py +0 -0
  58. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/protocols/__init__.py +0 -0
  59. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/protocols/checksum.py +0 -0
  60. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/protocols/io.py +0 -0
  61. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/py.typed +0 -0
  62. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/testing.py +0 -0
  63. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/tools/__init__.py +0 -0
  64. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/tools/uripath.py +0 -0
  65. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/uri/query.py +0 -0
  66. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/__init__.py +0 -0
  67. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/_gitrepo.py +0 -0
  68. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/archive/__init__.py +0 -0
  69. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/archive/tar.py +0 -0
  70. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/archive/zip.py +0 -0
  71. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/data.py +0 -0
  72. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/file.py +0 -0
  73. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/git/__init__.py +0 -0
  74. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/git/_base.py +0 -0
  75. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/git/github.py +0 -0
  76. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/git/gitlab.py +0 -0
  77. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/github.py +0 -0
  78. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/gitlab.py +0 -0
  79. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/http.py +0 -0
  80. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/sftp/_asyncssh.py +0 -0
  81. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/sftp/_paramiko.py +0 -0
  82. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/sftp/_sshconfig.py +0 -0
  83. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/utils/__init__.py +0 -0
  84. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/utils/archive.py +0 -0
  85. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/utils/checksum.py +0 -0
  86. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/src/pathlib_next/utils/stat.py +0 -0
  87. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/conftest.py +0 -0
  88. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_az.py +0 -0
  89. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_az_fake.py +0 -0
  90. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_checksum.py +0 -0
  91. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_contract.py +0 -0
  92. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_data_uri.py +0 -0
  93. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_dav.py +0 -0
  94. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_gitrepo.py +0 -0
  95. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_glob.py +0 -0
  96. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_gs.py +0 -0
  97. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_gs_fake.py +0 -0
  98. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_http.py +0 -0
  99. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_http_live.py +0 -0
  100. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_http_parser.py +0 -0
  101. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_local.py +0 -0
  102. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_mro_precedence.py +0 -0
  103. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_parity_pure.py +0 -0
  104. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_path_gaps.py +0 -0
  105. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_plugins.py +0 -0
  106. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_query.py +0 -0
  107. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_sftp_asyncssh.py +0 -0
  108. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_smoke.py +0 -0
  109. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_source.py +0 -0
  110. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_uri_parse.py +0 -0
  111. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_uripath_tool.py +0 -0
  112. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_utils.py +0 -0
  113. {pathlib_next-0.9.1 → pathlib_next-0.9.3}/tests/test_walk.py +0 -0
  114. {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.1...HEAD
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.4
1
+ Metadata-Version: 2.5
2
2
  Name: pathlib-next
3
- Version: 0.9.1
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.1"
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 (a `str` target is turned into one by the
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). `force=` is this library's extension: `False` is
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
- else:
128
- parent = parent[path]
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
- other = other if isinstance(other, cls) else cls(self, other)
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) != None:
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 -- same `type(self)(target)` form as
736
- # copy()/move() use for their destination, for consistency.
737
- if isinstance(target, str):
738
- target = type(self)(target)
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:
@@ -58,7 +58,7 @@ class Stat(_ty.Protocol):
58
58
  """
59
59
  Whether this path exists.
60
60
  """
61
- return self._st_mode(follow_symlinks=follow_symlinks) != None
61
+ return self._st_mode(follow_symlinks=follow_symlinks) is not None
62
62
 
63
63
  def is_dir(self):
64
64
  """
@@ -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
- other = other if isinstance(other, Uri) else Uri(self, _ROOT, other)
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
- if not isinstance(target, Uri):
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
- if not isinstance(target, Uri):
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
- if not isinstance(target, Uri):
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
- if not isinstance(target, Uri):
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
- if not isinstance(target, Uri):
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
- if not isinstance(target, Uri):
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
- if not isinstance(target, Uri):
362
- target = Uri(self.parent, target)
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 (see
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) != None
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 != None
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)