pathlib-next 0.9.1__tar.gz → 0.9.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/CHANGELOG.md +48 -1
  2. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/PKG-INFO +2 -2
  3. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/docs/divergences.md +1 -1
  4. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/docs/guides/extending.md +10 -0
  5. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/pyproject.toml +1 -1
  6. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/AGENTS.md +19 -2
  7. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/fspath.py +9 -0
  8. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/mempath.py +20 -3
  9. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/path.py +28 -4
  10. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/protocols/fs.py +1 -1
  11. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/uri/__init__.py +10 -4
  12. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/uri/source.py +1 -3
  13. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/utils/glob.py +1 -1
  14. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/utils/sync.py +1 -1
  15. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_mempath.py +81 -0
  16. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_parity_io.py +23 -3
  17. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_pathname.py +122 -0
  18. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_properties.py +15 -0
  19. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_sftp.py +10 -3
  20. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_sync.py +1 -1
  21. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/.gitignore +0 -0
  22. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/LICENSE +0 -0
  23. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/README.md +0 -0
  24. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/docs/api/mempath.md +0 -0
  25. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/docs/api/path.md +0 -0
  26. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/docs/api/testing.md +0 -0
  27. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/docs/api/uri.md +0 -0
  28. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/docs/api/utils.md +0 -0
  29. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/docs/benchmarks.md +0 -0
  30. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/docs/changelog.md +0 -0
  31. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/docs/guides/cli.md +0 -0
  32. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/docs/guides/schemes.md +0 -0
  33. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/docs/index.md +0 -0
  34. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/examples/az_listing.py +0 -0
  35. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/examples/data_and_archive.py +0 -0
  36. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/examples/ftp_listing.py +0 -0
  37. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/examples/github_listing.py +0 -0
  38. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/examples/gitlab_listing.py +0 -0
  39. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/examples/gs_listing.py +0 -0
  40. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/examples/http_listing.py +0 -0
  41. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/examples/local_and_mem.py +0 -0
  42. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/examples/s3_listing.py +0 -0
  43. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/examples/sftp_sync.py +0 -0
  44. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/examples/webdav_roundtrip.py +0 -0
  45. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/mkdocs.yml +0 -0
  46. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/__init__.py +0 -0
  47. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/protocols/__init__.py +0 -0
  48. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/protocols/checksum.py +0 -0
  49. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/protocols/io.py +0 -0
  50. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/py.typed +0 -0
  51. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/testing.py +0 -0
  52. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/tools/__init__.py +0 -0
  53. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/tools/uripath.py +0 -0
  54. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/uri/query.py +0 -0
  55. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/__init__.py +0 -0
  56. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/_gitrepo.py +0 -0
  57. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/archive/__init__.py +0 -0
  58. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/archive/_base.py +0 -0
  59. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/archive/tar.py +0 -0
  60. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/archive/zip.py +0 -0
  61. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/az.py +0 -0
  62. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/data.py +0 -0
  63. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/dav.py +0 -0
  64. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/file.py +0 -0
  65. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/ftp.py +0 -0
  66. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/git/__init__.py +0 -0
  67. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/git/_base.py +0 -0
  68. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/git/github.py +0 -0
  69. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/git/gitlab.py +0 -0
  70. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/github.py +0 -0
  71. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/gitlab.py +0 -0
  72. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/gs.py +0 -0
  73. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/http.py +0 -0
  74. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/s3.py +0 -0
  75. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/sftp/__init__.py +0 -0
  76. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/sftp/_asyncssh.py +0 -0
  77. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/sftp/_paramiko.py +0 -0
  78. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/sftp/_sshconfig.py +0 -0
  79. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/utils/__init__.py +0 -0
  80. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/utils/archive.py +0 -0
  81. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/utils/checksum.py +0 -0
  82. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/utils/stat.py +0 -0
  83. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/conftest.py +0 -0
  84. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_archive_uri.py +0 -0
  85. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_az.py +0 -0
  86. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_az_fake.py +0 -0
  87. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_checksum.py +0 -0
  88. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_contract.py +0 -0
  89. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_data_uri.py +0 -0
  90. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_dav.py +0 -0
  91. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_ftp.py +0 -0
  92. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_gitrepo.py +0 -0
  93. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_glob.py +0 -0
  94. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_gs.py +0 -0
  95. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_gs_fake.py +0 -0
  96. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_http.py +0 -0
  97. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_http_live.py +0 -0
  98. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_http_parser.py +0 -0
  99. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_local.py +0 -0
  100. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_mro_precedence.py +0 -0
  101. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_parity_pure.py +0 -0
  102. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_path_gaps.py +0 -0
  103. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_plugins.py +0 -0
  104. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_query.py +0 -0
  105. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_s3.py +0 -0
  106. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_sftp_asyncssh.py +0 -0
  107. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_smoke.py +0 -0
  108. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_source.py +0 -0
  109. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_uri_parse.py +0 -0
  110. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_uri_path.py +0 -0
  111. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_uripath_tool.py +0 -0
  112. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_utils.py +0 -0
  113. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_walk.py +0 -0
  114. {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_webdav.py +0 -0
@@ -5,6 +5,52 @@ 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.2] - 2026-08-16
11
+
12
+ ### Fixed
13
+ - **`MemPath.open("w")` on an existing directory raised nothing and destroyed
14
+ the tree.** `_open()` assigned over whatever was already at the name, so
15
+ `MemPath("dir").write_text(...)` replaced a directory and everything under
16
+ it with a file — silently, in the class the docs present as the reference
17
+ exemplar for extending this library, and the class used as a mock
18
+ filesystem in tests. It now raises `IsADirectoryError`, as `pathlib` does.
19
+ The same guard covers the virtual root for every mode, which used to grow a
20
+ bogus `""` key in the backend on `"w"`/`"a"`.
21
+ - **A `MemPath` routed *through* a file raised `TypeError`.**
22
+ `_parent_container()` walked ancestors with `path not in parent`, which on a
23
+ `bytearray` ancestor evaluates `"seg" not in bytearray`. That `TypeError`
24
+ sails past the `OSError` guard in `Stat._st_mode()`, so even
25
+ `MemPath("file.txt/sub").exists()` crashed instead of returning `False` —
26
+ a routine shape in glob/walk and in `mkdir(parents=True)`. It now raises
27
+ `NotADirectoryError` naming the offending ancestor.
28
+ - **`Pathname` had no `__eq__`/`__hash__`, so subclasses compared by
29
+ identity.** Every pure subclass that didn't hand-write equality — including
30
+ `MemPath`, and any downstream class subclassing `Path` directly — was
31
+ unusable as a dict key or set member, and `is_relative_to()` (which decides
32
+ via `==` against freshly built parents) always returned `False` without
33
+ raising. There is now a default keyed on
34
+ `(type(self), tuple(self.segments))`. `LocalPath`, `PosixPathname` and
35
+ `WindowsPathname` are unaffected — `pathlib.PurePath` precedes `Pathname` in
36
+ their MRO and keeps its own equality — as is `Uri`, which defines one.
37
+ - **`is_relative_to()` normalized a `str` argument by joining it onto
38
+ `self`.** `Pathname` used `cls(self, other)` and `Uri` used
39
+ `Uri(self, _ROOT, other)`, so `Uri("a/b").is_relative_to("a")` compared
40
+ against `"a/b/a"` / `"/a"` and answered `False` while
41
+ `Uri("a/b").is_relative_to(Uri("a"))` answered `True` — the str and object
42
+ forms of the same call disagreed. Both now parse `other` standalone, as
43
+ CPython does. The generic side uses `self.with_segments(other)` so a
44
+ subclass's per-instance state (`MemPath`'s backend) survives the
45
+ normalization. `Uri("http://h/a/b").is_relative_to("/a")` is still `True`.
46
+ - **`LocalPath.chown()` leaked `AttributeError`/`LookupError` on Windows.**
47
+ `shutil.chown` exists there while `os.chown` does not, so an int id raised
48
+ `AttributeError` and a name raised a misleading `LookupError: no such user`
49
+ (with no `pwd` module, every name misses whether or not the user exists).
50
+ It now raises `NotImplementedError`, which `docs/divergences.md` already
51
+ promised and which every other unsupported capability here raises. The
52
+ all-unchanged no-op still succeeds.
53
+
8
54
  ## [0.9.1] - 2026-08-04
9
55
 
10
56
  ### Added
@@ -732,7 +778,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
732
778
  - Sync error handling.
733
779
  - Generic Path Protocol based pathlib implementation for URI paths with file access support for sftp, http, file schemes.
734
780
 
735
- [Unreleased]: https://github.com/jose-pr/pathlib-next/compare/v0.9.1...HEAD
781
+ [Unreleased]: https://github.com/jose-pr/pathlib-next/compare/v0.9.2...HEAD
782
+ [0.9.2]: https://github.com/jose-pr/pathlib-next/compare/v0.9.1...v0.9.2
736
783
  [0.9.1]: https://github.com/jose-pr/pathlib-next/compare/v0.9.0...v0.9.1
737
784
  [0.8.6]: https://github.com/jose-pr/pathlib-next/compare/v0.8.5...v0.8.6
738
785
  [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.2
4
4
  Summary: Generic Path Protocol based pathlib
5
5
  Project-URL: Homepage, https://github.com/jose-pr/pathlib-next/
6
6
  Project-URL: Documentation, https://jose-pr.github.io/pathlib-next/
@@ -61,7 +61,7 @@ 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
66
  | `PathSyncer` / `Query` / `Source` | N/A | Our own extensions | N/A -- pure extensions, no pathlib equivalent. |
67
67
  | `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.** |
@@ -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.2"
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:
@@ -154,7 +165,10 @@ pathlib_next`.
154
165
  alias for it; an `int` is an id, a `str` is a name. `chown()` normalizes
155
166
  via `utils.as_owner()` and short-circuits when nothing would change, so
156
167
  `_chown()` always receives a canonical pair and only converts to its own
157
- wire spelling (`-1` for `os.chown`, an omitted attr for SFTP).
168
+ wire spelling (`-1` for `os.chown`, an omitted attr for SFTP). On a
169
+ platform without `os.chown` (Windows) `LocalPath.chown()` raises
170
+ `NotImplementedError` for any real change; the all-unchanged no-op still
171
+ succeeds because it never reaches the backend.
158
172
  - **`io.BinaryOpen`** — `Protocol`. `_open(mode="r", buffering=-1) ->
159
173
  io.IOBase` (not implemented by default; must yield a **binary** stream).
160
174
  Derives `open(mode="r", buffering=-1, encoding=None, errors=None,
@@ -204,7 +218,10 @@ extra that depends on it).
204
218
  `suffix`, `stem`, `parent`. Methods: `as_uri(sanitize=False)` (sanitize
205
219
  strips password from userinfo before formatting), `with_source(source)`,
206
220
  `with_segments(*segments)`, `with_path(path)`, `with_query(query)`,
207
- `with_fragment(fragment)`, `is_absolute()`, `is_relative_to(other)`,
221
+ `with_fragment(fragment)`, `is_absolute()`, `is_relative_to(other)`
222
+ (a `str` `other` is parsed standalone as `Uri(other)`, matching
223
+ `relative_to()`; an `other` with no authority is compatible with any
224
+ `self.source`, so `Uri("http://h/a/b").is_relative_to("/a")` is `True`),
208
225
  `relative_to(other, *, walk_up=False)`, `is_local()` (delegates to
209
226
  `Source.is_local()` — does a DNS lookup, cached per `Source`),
210
227
  `as_posix()` (`user@host:path` / `host:path` form when a source is
@@ -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
 
@@ -854,8 +880,6 @@ class Path(Pathname, Chmod, Stat, BinaryOpen):
854
880
  if isinstance(target, str):
855
881
  target = type(self)(target)
856
882
  src = self
857
- if src is None:
858
- return
859
883
 
860
884
  if target.exists():
861
885
  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
  """
@@ -432,7 +432,16 @@ class Uri(Pathname):
432
432
 
433
433
  def is_relative_to(self, other: UriLike):
434
434
  """Return True if the path is relative to another path or False."""
435
- other = other if isinstance(other, Uri) else Uri(self, _ROOT, other)
435
+ # Uri(other), NOT Uri(self, _ROOT, other): anchoring a str `other`
436
+ # at self's root turned `Uri("a/b").is_relative_to("a")` into a
437
+ # comparison against "/a" and answered False, disagreeing with the
438
+ # object form of the same call. `relative_to()` below already
439
+ # parsed a str standalone; this matches it and CPython.
440
+ # The same-authority case still works: a standalone parse leaves
441
+ # `other.source` empty, which the guard below treats as compatible
442
+ # with any `self.source`, and the segment prefix compare is
443
+ # unaffected -- `Uri("http://h/a/b").is_relative_to("/a")` is True.
444
+ other = other if isinstance(other, Uri) else Uri(other)
436
445
  if not (
437
446
  (other.source == self.source)
438
447
  or not (bool(self.source) and bool(other.source))
@@ -717,6 +726,3 @@ class UriPath(Uri, Path):
717
726
  def iterdir(self) -> "_ty.Iterator[Self]":
718
727
  for name, stat in self._scandir():
719
728
  yield self._make_child_relpath(name, stat_hint=stat)
720
-
721
-
722
- _ROOT = Uri("/")
@@ -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)
@@ -153,3 +153,84 @@ def test_rmdir_nonempty_raises():
153
153
  (root / "d" / "f.txt").write_text("x")
154
154
  with pytest.raises(FileExistsError):
155
155
  (root / "d").rmdir()
156
+
157
+
158
+ # --- 2026-08-16: open("w") over a directory destroyed the whole subtree ---
159
+
160
+
161
+ def test_open_w_on_directory_raises_and_keeps_the_tree():
162
+ backend = MemPathBackend()
163
+ root = MemPath("/", backend=backend)
164
+ (root / "dir").mkdir()
165
+ (root / "dir" / "child.txt").write_text("hi")
166
+
167
+ with pytest.raises(IsADirectoryError):
168
+ MemPath("dir", backend=backend).write_text("clobber")
169
+
170
+ # The whole point of the fix: the tree survives the refused write.
171
+ assert isinstance(backend["dir"], dict)
172
+ assert (root / "dir" / "child.txt").read_text() == "hi"
173
+
174
+
175
+ def test_open_w_on_root_raises_instead_of_creating_an_empty_key():
176
+ backend = MemPathBackend()
177
+ with pytest.raises(IsADirectoryError):
178
+ MemPath("/", backend=backend).write_text("clobber")
179
+ assert backend == {}
180
+
181
+
182
+ def test_open_a_on_root_raises_instead_of_creating_an_empty_key():
183
+ backend = MemPathBackend()
184
+ with pytest.raises(IsADirectoryError):
185
+ MemPath("/", backend=backend)._open("a")
186
+ assert backend == {}
187
+
188
+
189
+ def test_open_w_still_truncates_an_existing_file():
190
+ # Guard against over-correcting: only directories are refused.
191
+ root = MemPath("/")
192
+ (root / "f.txt").write_text("original")
193
+ (root / "f.txt").write_text("new")
194
+ assert (root / "f.txt").read_text() == "new"
195
+
196
+
197
+ # --- 2026-08-16: a path routed *through* a file raised TypeError ---
198
+
199
+
200
+ def test_exists_through_a_file_segment_is_false():
201
+ backend = MemPathBackend()
202
+ MemPath("file.txt", backend=backend).write_text("x")
203
+ # Used to raise TypeError: a bytes-like object is required, not 'str'.
204
+ assert MemPath("file.txt/sub", backend=backend).exists() is False
205
+ assert MemPath("file.txt/sub/deeper", backend=backend).exists() is False
206
+ assert MemPath("file.txt/sub", backend=backend).is_dir() is False
207
+
208
+
209
+ def test_stat_through_a_file_segment_raises_notadirectoryerror():
210
+ backend = MemPathBackend()
211
+ MemPath("file.txt", backend=backend).write_text("x")
212
+ with pytest.raises(NotADirectoryError):
213
+ MemPath("file.txt/sub", backend=backend).stat()
214
+
215
+
216
+ def test_open_through_a_file_segment_raises_notadirectoryerror():
217
+ backend = MemPathBackend()
218
+ MemPath("file.txt", backend=backend).write_text("x")
219
+ with pytest.raises(NotADirectoryError):
220
+ MemPath("file.txt/sub", backend=backend).read_text()
221
+
222
+
223
+ def test_mkdir_parents_under_a_file_raises_notadirectoryerror():
224
+ backend = MemPathBackend()
225
+ MemPath("file.txt", backend=backend).write_text("x")
226
+ with pytest.raises(NotADirectoryError):
227
+ MemPath("file.txt/sub", backend=backend).mkdir(parents=True)
228
+
229
+
230
+ def test_notadirectoryerror_names_the_offending_ancestor():
231
+ backend = MemPathBackend()
232
+ MemPath("a", backend=backend).mkdir()
233
+ MemPath("a/f.txt", backend=backend).write_text("x")
234
+ with pytest.raises(NotADirectoryError) as excinfo:
235
+ MemPath("a/f.txt/sub", backend=backend).stat()
236
+ assert str(excinfo.value.args[0]) == "a/f.txt"
@@ -248,8 +248,8 @@ def test_copy_progress_hook_monotonic_and_reaches_total(tmp_path):
248
248
  def test_copy_progress_hook_not_called_without_callback_and_no_behavior_change(
249
249
  tmp_path,
250
250
  ):
251
- # No callback => identical behavior/content to plain shutil.copyfileobj
252
- # (Phase 1's "no behavior change" requirement).
251
+ # No callback => identical behavior/content to plain shutil.copyfileobj:
252
+ # the hook must not cost anything when it is not used.
253
253
  root = pathlib_next.LocalPath(tmp_path)
254
254
  data = b"hello world" * 1000
255
255
  (root / "src.bin").write_bytes(data)
@@ -278,7 +278,7 @@ def test_copy_progress_hook_fires_on_path_copy(tmp_path):
278
278
 
279
279
 
280
280
  def test_copy_recursive_progress_hook_reports_per_file_identity(tmp_path):
281
- # Phase 2: recursive copy must report per-file identity alongside byte
281
+ # A recursive copy must report per-file identity alongside byte
282
282
  # progress -- not just an anonymous byte stream.
283
283
  root = pathlib_next.LocalPath(tmp_path)
284
284
  src = root / "src"
@@ -495,3 +495,23 @@ def test_local_chown_no_op_needs_no_privileges(tmp_path):
495
495
  f.chown()
496
496
  f.chown(-1, -1)
497
497
  assert f.read_text() == "x"
498
+
499
+
500
+ @pytest.mark.skipif(hasattr(os, "chown"), reason="platforms without os.chown only")
501
+ @pytest.mark.parametrize("uid", [1000, "someuser"])
502
+ def test_local_chown_unsupported_platform_raises_notimplementederror(tmp_path, uid):
503
+ # shutil.chown() exists on Windows while os.chown does not, so the
504
+ # mutation path used to leak AttributeError for an int id and a
505
+ # misleading "no such user" LookupError for a name (there is no pwd
506
+ # module, so every name misses). docs/divergences.md promises
507
+ # NotImplementedError, which is what every other unsupported capability
508
+ # in this library raises.
509
+ root = pathlib_next.LocalPath(tmp_path)
510
+ f = root / "f.txt"
511
+ f.write_text("x")
512
+ with pytest.raises(NotImplementedError):
513
+ f.chown(uid=uid)
514
+ with pytest.raises(NotImplementedError):
515
+ f.chown(gid=uid)
516
+ with pytest.raises(NotImplementedError):
517
+ f.chown(uid=uid, follow_symlinks=False)
@@ -165,3 +165,125 @@ def test_root_drive_anchor_relative(cls):
165
165
  p = cls("a/b")
166
166
  assert p.root == ""
167
167
  assert p.anchor == ""
168
+
169
+
170
+ # --- 2026-08-16: equality + is_relative_to str/object agreement ---
171
+
172
+
173
+ @pytest.mark.parametrize("cls", IMPLS)
174
+ def test_equality_is_by_value_not_identity(cls):
175
+ # `Pathname` used to define no __eq__ at all, so any subclass that
176
+ # didn't hand-write one (MemPath, and every downstream Track A class)
177
+ # compared by identity.
178
+ assert cls("a/b") == cls("a/b")
179
+ assert cls("a/b") != cls("a/c")
180
+ assert cls("a/b") is not cls("a/b")
181
+
182
+
183
+ @pytest.mark.parametrize("cls", IMPLS)
184
+ def test_hash_matches_equality(cls):
185
+ assert hash(cls("a/b")) == hash(cls("a/b"))
186
+ assert len({cls("a/b"), cls("a/b"), cls("a/c")}) == 2
187
+ assert {cls("a/b"): 1}[cls("a/b")] == 1
188
+
189
+
190
+ @pytest.mark.parametrize("cls", IMPLS)
191
+ def test_equality_against_a_foreign_type_is_false_not_an_error(cls):
192
+ assert (cls("a/b") == object()) is False
193
+ assert (cls("a/b") != object()) is True
194
+
195
+
196
+ @pytest.mark.parametrize("cls", IMPLS)
197
+ @pytest.mark.parametrize("path,other", [("a/b", "a"), ("a/b/c", "a/b"), ("a", "a")])
198
+ def test_is_relative_to_str_and_object_agree_with_stdlib(cls, path, other):
199
+ expected = pathlib.PurePosixPath(path).is_relative_to(other)
200
+ assert expected is True # sanity: the oracle really does say True
201
+ assert cls(path).is_relative_to(cls(other)) is expected
202
+ # The str form used to join `other` onto `self` instead of parsing it
203
+ # standalone, so it answered False where the object form answered True.
204
+ assert cls(path).is_relative_to(other) is expected
205
+
206
+
207
+ @pytest.mark.parametrize("cls", IMPLS)
208
+ @pytest.mark.parametrize("path,other", [("a/b", "b"), ("a/b", "c"), ("ab/c", "a")])
209
+ def test_is_relative_to_negative_str_and_object_agree_with_stdlib(cls, path, other):
210
+ expected = pathlib.PurePosixPath(path).is_relative_to(other)
211
+ assert expected is False
212
+ assert cls(path).is_relative_to(cls(other)) is expected
213
+ assert cls(path).is_relative_to(other) is expected
214
+
215
+
216
+ def test_mempath_is_relative_to_str_keeps_the_backend():
217
+ # with_segments(), not type(self)(other): the bare constructor hands
218
+ # MemPath a fresh empty backend, which is the kind of per-instance
219
+ # state a generic normalization must not drop.
220
+ from pathlib_next.mempath import MemPathBackend
221
+
222
+ backend = MemPathBackend()
223
+ p = MemPath("a/b", backend=backend)
224
+ assert p.is_relative_to("a") is True
225
+ assert p.with_segments("a").backend is backend
226
+
227
+
228
+ def test_generic_pathname_subclass_gets_working_equality():
229
+ """A minimal Track A subclass -- the shape `docs/guides/extending.md`
230
+ documents -- must get equality (and therefore is_relative_to) without
231
+ hand-writing __eq__."""
232
+ from pathlib_next.path import Pathname
233
+
234
+ class Toy(Pathname):
235
+ __slots__ = ("_segments",)
236
+
237
+ def __init__(self, *segments):
238
+ parts = []
239
+ for segment in segments:
240
+ if isinstance(segment, Pathname):
241
+ parts.extend(segment.segments)
242
+ else:
243
+ parts.append(segment)
244
+ self._segments = "/".join(parts).split("/")
245
+
246
+ @property
247
+ def segments(self):
248
+ return self._segments
249
+
250
+ @property
251
+ def parts(self):
252
+ return tuple(self._segments)
253
+
254
+ @property
255
+ def parent(self):
256
+ return self.with_segments(*self._segments[:-1])
257
+
258
+ def with_segments(self, *segments):
259
+ return type(self)(*segments)
260
+
261
+ def relative_to(self, other):
262
+ raise NotImplementedError()
263
+
264
+ def as_uri(self):
265
+ return "toy:" + self.as_posix()
266
+
267
+ assert Toy("a", "b") == Toy("a/b")
268
+ assert Toy("a/b") != Toy("a/c")
269
+ assert len({Toy("a/b"), Toy("a/b")}) == 1
270
+ assert Toy("a/b").is_relative_to(Toy("a")) is True
271
+ assert Toy("a/b").is_relative_to("a") is True
272
+ assert Toy("a/b").is_relative_to("c") is False
273
+
274
+
275
+ def test_localpath_keeps_stdlib_equality_semantics():
276
+ """`pathlib.PurePath` precedes `Pathname` in the fspath MRO, so the new
277
+ default must NOT displace stdlib's (case-folding, cross-subclass)
278
+ equality there."""
279
+ from pathlib_next.fspath import LocalPath, _BaseFSPathname
280
+ from pathlib_next.path import Pathname
281
+
282
+ for cls in (LocalPath, PosixPathname):
283
+ names = [k.__name__ for k in cls.__mro__]
284
+ assert names.index("PurePath") < names.index("Pathname")
285
+ assert cls.__eq__ is not Pathname.__eq__
286
+ assert cls.__eq__ is pathlib.PurePath.__eq__
287
+
288
+ assert issubclass(PosixPathname, _BaseFSPathname)
289
+ assert LocalPath("a/b") == pathlib.Path("a/b")
@@ -161,6 +161,21 @@ def test_is_relative_to_matches_pure_posix_path(segs):
161
161
  u, pp = Uri(path_str), pathlib.PurePosixPath(path_str)
162
162
  u_parent, pp_parent = Uri(parent_str), pathlib.PurePosixPath(parent_str)
163
163
  assert u.is_relative_to(u_parent) == pp.is_relative_to(pp_parent) == True
164
+ # The str form must agree with the object form: it used to anchor
165
+ # `other` at self's root (Uri(self, _ROOT, other)) and answer False for
166
+ # every *relative* self.
167
+ assert u.is_relative_to(parent_str) == pp.is_relative_to(parent_str) == True
168
+
169
+
170
+ @given(segs=nonempty_segments_list)
171
+ @settings(max_examples=200)
172
+ def test_is_relative_to_str_matches_pure_posix_path_for_unrelated_other(segs):
173
+ # The negative direction of the same oracle: a str `other` that is not a
174
+ # prefix must answer False for both, relative and absolute self alike.
175
+ other_str = _posix(["zzz-unrelated"])
176
+ for path_str in (_posix(segs), "/" + _posix(segs)):
177
+ u, pp = Uri(path_str), pathlib.PurePosixPath(path_str)
178
+ assert u.is_relative_to(other_str) == pp.is_relative_to(other_str)
164
179
 
165
180
 
166
181
  # --- uritools oracle: one-pass parse/compose fast paths ---
@@ -315,9 +315,16 @@ def test_sftp_backend_connect_and_client():
315
315
  transport = backend.transport(source)
316
316
  assert transport is mock_transport
317
317
  mock_ssh.set_missing_host_key_policy.assert_called_with("policy")
318
- mock_ssh.connect.assert_called_with(
319
- timeout=10, hostname="host", port=2222, username="user", password="pass"
320
- )
318
+ # Assert the arguments this test is ABOUT, not the whole call: the
319
+ # backend also merges the developer's real ~/.ssh/config, so a machine
320
+ # with an `identityfile` entry adds `key_filename=` and an exact-call
321
+ # assertion fails there while passing on a key-less CI runner.
322
+ connect_kwargs = mock_ssh.connect.call_args.kwargs
323
+ assert connect_kwargs["timeout"] == 10
324
+ assert connect_kwargs["hostname"] == "host"
325
+ assert connect_kwargs["port"] == 2222
326
+ assert connect_kwargs["username"] == "user"
327
+ assert connect_kwargs["password"] == "pass"
321
328
 
322
329
  # Test client()
323
330
  client = backend.client(source)
@@ -547,7 +547,7 @@ def test_sync_symlink_preserve_cross_backend_unsupported_target_raises(
547
547
  tmp_path,
548
548
  ):
549
549
  # SFTP source (supports symlink_to) -> MemPath target (does not):
550
- # the documented Phase 1 behavior is NotImplementedError through
550
+ # the documented behavior is NotImplementedError surfaced through
551
551
  # ignore_error/hook(), not a silent skip or crash.
552
552
  backend = _FakeSymlinkBackend()
553
553
  source = _fake_sftp("sftp://host/link.txt", backend=backend)
File without changes
File without changes
File without changes
File without changes
File without changes