pathlib-next 0.9.0__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.0 → pathlib_next-0.9.2}/CHANGELOG.md +84 -14
  2. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/PKG-INFO +16 -15
  3. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/README.md +9 -9
  4. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/docs/divergences.md +3 -0
  5. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/docs/guides/extending.md +10 -0
  6. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/mkdocs.yml +3 -3
  7. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/pyproject.toml +19 -6
  8. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/src/pathlib_next/AGENTS.md +45 -4
  9. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/src/pathlib_next/fspath.py +54 -1
  10. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/src/pathlib_next/mempath.py +21 -6
  11. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/src/pathlib_next/path.py +95 -5
  12. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/src/pathlib_next/protocols/fs.py +63 -3
  13. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/src/pathlib_next/testing.py +4 -3
  14. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/src/pathlib_next/uri/__init__.py +15 -5
  15. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/src/pathlib_next/uri/query.py +1 -3
  16. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/archive/_base.py +3 -1
  17. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/archive/tar.py +3 -1
  18. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/archive/zip.py +13 -6
  19. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/az.py +11 -2
  20. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/dav.py +9 -4
  21. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/file.py +14 -2
  22. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/ftp.py +9 -7
  23. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/git/__init__.py +0 -1
  24. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/git/github.py +0 -1
  25. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/git/gitlab.py +0 -1
  26. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/github.py +9 -2
  27. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/gitlab.py +6 -4
  28. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/gs.py +6 -2
  29. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/http.py +109 -90
  30. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/s3.py +3 -1
  31. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/sftp/__init__.py +39 -8
  32. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/sftp/_asyncssh.py +40 -8
  33. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/sftp/_paramiko.py +2 -1
  34. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/src/pathlib_next/uri/source.py +7 -5
  35. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/src/pathlib_next/utils/__init__.py +71 -0
  36. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/src/pathlib_next/utils/archive.py +10 -2
  37. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/src/pathlib_next/utils/glob.py +2 -5
  38. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/src/pathlib_next/utils/sync.py +2 -3
  39. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/tests/conftest.py +29 -20
  40. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/tests/test_archive_uri.py +14 -4
  41. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/tests/test_checksum.py +7 -7
  42. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/tests/test_contract.py +10 -1
  43. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/tests/test_data_uri.py +1 -0
  44. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/tests/test_dav.py +1 -0
  45. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/tests/test_ftp.py +1 -0
  46. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/tests/test_gitrepo.py +9 -2
  47. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/tests/test_glob.py +1 -0
  48. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/tests/test_http.py +9 -3
  49. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/tests/test_http_live.py +1 -0
  50. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/tests/test_http_parser.py +12 -3
  51. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/tests/test_local.py +10 -8
  52. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/tests/test_mempath.py +81 -0
  53. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/tests/test_mro_precedence.py +13 -1
  54. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/tests/test_parity_io.py +184 -4
  55. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/tests/test_parity_pure.py +9 -9
  56. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/tests/test_path_gaps.py +66 -0
  57. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/tests/test_pathname.py +122 -0
  58. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/tests/test_plugins.py +10 -1
  59. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/tests/test_properties.py +66 -13
  60. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/tests/test_s3.py +1 -0
  61. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/tests/test_sftp.py +127 -29
  62. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/tests/test_sftp_asyncssh.py +32 -12
  63. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/tests/test_smoke.py +5 -2
  64. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/tests/test_source.py +2 -4
  65. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/tests/test_sync.py +1 -1
  66. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/tests/test_uri_path.py +0 -1
  67. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/tests/test_utils.py +20 -17
  68. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/tests/test_walk.py +24 -6
  69. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/tests/test_webdav.py +2 -0
  70. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/.gitignore +0 -0
  71. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/LICENSE +0 -0
  72. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/docs/api/mempath.md +0 -0
  73. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/docs/api/path.md +0 -0
  74. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/docs/api/testing.md +0 -0
  75. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/docs/api/uri.md +0 -0
  76. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/docs/api/utils.md +0 -0
  77. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/docs/benchmarks.md +0 -0
  78. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/docs/changelog.md +0 -0
  79. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/docs/guides/cli.md +0 -0
  80. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/docs/guides/schemes.md +0 -0
  81. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/docs/index.md +0 -0
  82. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/examples/az_listing.py +0 -0
  83. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/examples/data_and_archive.py +0 -0
  84. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/examples/ftp_listing.py +0 -0
  85. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/examples/github_listing.py +0 -0
  86. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/examples/gitlab_listing.py +0 -0
  87. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/examples/gs_listing.py +0 -0
  88. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/examples/http_listing.py +0 -0
  89. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/examples/local_and_mem.py +0 -0
  90. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/examples/s3_listing.py +0 -0
  91. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/examples/sftp_sync.py +0 -0
  92. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/examples/webdav_roundtrip.py +0 -0
  93. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/src/pathlib_next/__init__.py +0 -0
  94. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/src/pathlib_next/protocols/__init__.py +0 -0
  95. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/src/pathlib_next/protocols/checksum.py +0 -0
  96. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/src/pathlib_next/protocols/io.py +0 -0
  97. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/src/pathlib_next/py.typed +0 -0
  98. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/src/pathlib_next/tools/__init__.py +0 -0
  99. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/src/pathlib_next/tools/uripath.py +0 -0
  100. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/__init__.py +0 -0
  101. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/_gitrepo.py +0 -0
  102. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/archive/__init__.py +0 -0
  103. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/data.py +0 -0
  104. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/git/_base.py +0 -0
  105. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/sftp/_sshconfig.py +0 -0
  106. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/src/pathlib_next/utils/checksum.py +0 -0
  107. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/src/pathlib_next/utils/stat.py +0 -0
  108. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/tests/test_az.py +0 -0
  109. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/tests/test_az_fake.py +0 -0
  110. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/tests/test_gs.py +0 -0
  111. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/tests/test_gs_fake.py +0 -0
  112. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/tests/test_query.py +0 -0
  113. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/tests/test_uri_parse.py +0 -0
  114. {pathlib_next-0.9.0 → pathlib_next-0.9.2}/tests/test_uripath_tool.py +0 -0
@@ -5,6 +5,74 @@ 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
+
54
+ ## [0.9.1] - 2026-08-04
55
+
56
+ ### Added
57
+ - **`symlink_to(..., force=True)`** — replace an existing entry at the link
58
+ path instead of failing. SFTP has no atomic "replace symlink", so every
59
+ consumer was re-implementing the unlink-then-symlink dance. Implemented at
60
+ the `Path` layer over a new `_symlink_to()` backend primitive (the same
61
+ wrapper/primitive split as `_mkdir`/`mkdir`), so all backends inherit it.
62
+ Removes a **non-directory** entry only, and is documented as non-atomic.
63
+ - **`chown(uid=None, gid=None)`** over a new `_chown()` backend primitive.
64
+ `chown` was the one POSIX permission attribute `stat()` could read but
65
+ nothing could write back. `None` means "leave unchanged"; the normalization
66
+ lives on `Path` so each backend receives an already-canonical pair rather
67
+ than re-deriving the mapping (`os.chown` wants `-1`, SFTP omits the field).
68
+ - **`chmod()` accepts a string octal mode** (`"0755"`), normalized with an
69
+ explicit base-8 parse. A mode string outside `[0-7]` raises rather than
70
+ being coerced — `int("0755")` in *decimal* is a different mode, which is
71
+ exactly the wrong-but-plausible failure this guards.
72
+
73
+ ### Changed
74
+ - Adopted `black`, pinned to the 3.9 floor; `src/` and `tests/` reformatted.
75
+
8
76
  ## [0.9.0] - 2026-07-29
9
77
 
10
78
  ### Added
@@ -710,17 +778,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
710
778
  - Sync error handling.
711
779
  - Generic Path Protocol based pathlib implementation for URI paths with file access support for sftp, http, file schemes.
712
780
 
713
- [Unreleased]: https://github.com/jose-pr/pathlib_next/compare/v0.8.6...HEAD
714
- [0.8.6]: https://github.com/jose-pr/pathlib_next/compare/v0.8.5...v0.8.6
715
- [0.8.5]: https://github.com/jose-pr/pathlib_next/compare/v0.8.4...v0.8.5
716
- [0.8.4]: https://github.com/jose-pr/pathlib_next/compare/v0.8.3...v0.8.4
717
- [0.8.3]: https://github.com/jose-pr/pathlib_next/compare/v0.8.2...v0.8.3
718
- [0.8.2]: https://github.com/jose-pr/pathlib_next/compare/v0.8.1...v0.8.2
719
- [0.8.1]: https://github.com/jose-pr/pathlib_next/compare/v0.8.0...v0.8.1
720
- [0.8.0]: https://github.com/jose-pr/pathlib_next/compare/v0.7.0...v0.8.0
721
- [0.7.0]: https://github.com/jose-pr/pathlib_next/compare/v0.6.0...v0.7.0
722
- [0.6.0]: https://github.com/jose-pr/pathlib_next/compare/v0.5.0...v0.6.0
723
- [0.5.0]: https://github.com/jose-pr/pathlib_next/compare/v0.4.1...v0.5.0
724
- [0.4.1]: https://github.com/jose-pr/pathlib_next/compare/v0.4.0...v0.4.1
725
- [0.4.0]: https://github.com/jose-pr/pathlib_next/releases/tag/v0.4.0
726
- [0.3.5]: https://github.com/jose-pr/pathlib_next/releases/tag/v0.3.5
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
783
+ [0.9.1]: https://github.com/jose-pr/pathlib-next/compare/v0.9.0...v0.9.1
784
+ [0.8.6]: https://github.com/jose-pr/pathlib-next/compare/v0.8.5...v0.8.6
785
+ [0.8.5]: https://github.com/jose-pr/pathlib-next/compare/v0.8.4...v0.8.5
786
+ [0.8.4]: https://github.com/jose-pr/pathlib-next/compare/v0.8.3...v0.8.4
787
+ [0.8.3]: https://github.com/jose-pr/pathlib-next/compare/v0.8.2...v0.8.3
788
+ [0.8.2]: https://github.com/jose-pr/pathlib-next/compare/v0.8.1...v0.8.2
789
+ [0.8.1]: https://github.com/jose-pr/pathlib-next/compare/v0.8.0...v0.8.1
790
+ [0.8.0]: https://github.com/jose-pr/pathlib-next/compare/v0.7.0...v0.8.0
791
+ [0.7.0]: https://github.com/jose-pr/pathlib-next/compare/v0.6.0...v0.7.0
792
+ [0.6.0]: https://github.com/jose-pr/pathlib-next/compare/v0.5.0...v0.6.0
793
+ [0.5.0]: https://github.com/jose-pr/pathlib-next/compare/v0.4.1...v0.5.0
794
+ [0.4.1]: https://github.com/jose-pr/pathlib-next/compare/v0.4.0...v0.4.1
795
+ [0.4.0]: https://github.com/jose-pr/pathlib-next/releases/tag/v0.4.0
796
+ [0.3.5]: https://github.com/jose-pr/pathlib-next/releases/tag/v0.3.5
@@ -1,10 +1,10 @@
1
- Metadata-Version: 2.4
2
- Name: pathlib_next
3
- Version: 0.9.0
1
+ Metadata-Version: 2.5
2
+ Name: pathlib-next
3
+ Version: 0.9.2
4
4
  Summary: Generic Path Protocol based pathlib
5
- Project-URL: Homepage, https://github.com/jose-pr/pathlib_next/
6
- Project-URL: Documentation, https://jose-pr.github.io/pathlib_next/
7
- Project-URL: Issues, https://github.com/jose-pr/pathlib_next/issues
5
+ Project-URL: Homepage, https://github.com/jose-pr/pathlib-next/
6
+ Project-URL: Documentation, https://jose-pr.github.io/pathlib-next/
7
+ Project-URL: Issues, https://github.com/jose-pr/pathlib-next/issues
8
8
  Author: Jose A
9
9
  License-File: LICENSE
10
10
  Classifier: License :: OSI Approved :: MIT License
@@ -16,6 +16,7 @@ Requires-Dist: azure-storage-blob; extra == 'az'
16
16
  Requires-Dist: netimps>=0.2.0; extra == 'az'
17
17
  Requires-Dist: uritools; extra == 'az'
18
18
  Provides-Extra: dev
19
+ Requires-Dist: black; extra == 'dev'
19
20
  Requires-Dist: build; extra == 'dev'
20
21
  Requires-Dist: cheroot; extra == 'dev'
21
22
  Requires-Dist: hatchling; extra == 'dev'
@@ -58,11 +59,11 @@ Description-Content-Type: text/markdown
58
59
 
59
60
  # pathlib_next
60
61
 
61
- [![Version](https://img.shields.io/pypi/v/pathlib_next.svg)](https://pypi.org/project/pathlib_next/)
62
- [![Python versions](https://img.shields.io/pypi/pyversions/pathlib_next.svg)](https://pypi.org/project/pathlib_next/)
62
+ [![Version](https://img.shields.io/pypi/v/pathlib_next.svg)](https://pypi.org/project/pathlib-next/)
63
+ [![Python versions](https://img.shields.io/pypi/pyversions/pathlib_next.svg)](https://pypi.org/project/pathlib-next/)
63
64
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
64
- [![Docs](https://img.shields.io/badge/docs-latest-blue.svg)](https://jose-pr.github.io/pathlib_next/)
65
- [![CI](https://img.shields.io/github/actions/workflow/status/jose-pr/pathlib_next/test.yml)](https://github.com/jose-pr/pathlib_next/actions/workflows/test.yml)
65
+ [![Docs](https://img.shields.io/badge/docs-latest-blue.svg)](https://jose-pr.github.io/pathlib-next/)
66
+ [![CI](https://img.shields.io/github/actions/workflow/status/jose-pr/pathlib_next/test.yml)](https://github.com/jose-pr/pathlib-next/actions/workflows/test.yml)
66
67
 
67
68
  A **robust, extensible pathlib-like base** for any resource addressable as a
68
69
  path or URI. Same method names, signatures, semantics, and exception types as
@@ -70,7 +71,7 @@ path or URI. Same method names, signatures, semantics, and exception types as
70
71
  against `Path`/`UriPath` and it works against your local disk, an in-memory
71
72
  tree, an HTTP index, or an SFTP server. Every intentional divergence from
72
73
  `pathlib`'s behavior is documented, not silent -- see
73
- [`docs/divergences.md`](https://jose-pr.github.io/pathlib_next/divergences/).
74
+ [`docs/divergences.md`](https://jose-pr.github.io/pathlib-next/divergences/).
74
75
 
75
76
  ## Features
76
77
 
@@ -88,7 +89,7 @@ tree, an HTTP index, or an SFTP server. Every intentional divergence from
88
89
 
89
90
  Every scheme shares the same `glob()`, `walk()`, `copy()`/`move()`, `rm()`
90
91
  implementations -- see the full matrix and notes in
91
- [Schemes](https://jose-pr.github.io/pathlib_next/guides/schemes/).
92
+ [Schemes](https://jose-pr.github.io/pathlib-next/guides/schemes/).
92
93
 
93
94
  - **Unified path interface** across local files, in-memory paths, archive
94
95
  members, and `file`/`data`/`ftp`/`http`/`dav`/`sftp`/`s3` URIs.
@@ -100,7 +101,7 @@ implementations -- see the full matrix and notes in
100
101
  authority components.
101
102
  - **Extensible two ways**: subclass `Path` directly for a custom
102
103
  non-URI resource, or subclass `UriPath` for a new URI scheme -- see
103
- [Extending](https://jose-pr.github.io/pathlib_next/guides/extending/).
104
+ [Extending](https://jose-pr.github.io/pathlib-next/guides/extending/).
104
105
 
105
106
  ## Installation
106
107
 
@@ -186,13 +187,13 @@ print(member.read_text())
186
187
  Also built in: `data:` (RFC 2397 inline payloads), `ftp(s):` (stdlib
187
188
  `ftplib`), `dav(s):` (WebDAV, full read/write over HTTP), and `s3:`
188
189
  (`boto3`) -- one example per scheme in
189
- [Schemes](https://jose-pr.github.io/pathlib_next/guides/schemes/).
190
+ [Schemes](https://jose-pr.github.io/pathlib-next/guides/schemes/).
190
191
 
191
192
  ## Extending
192
193
 
193
194
  Two first-class ways to add a new path-addressable resource -- both covered
194
195
  in depth, with worked examples, in
195
- [Extending](https://jose-pr.github.io/pathlib_next/guides/extending/):
196
+ [Extending](https://jose-pr.github.io/pathlib-next/guides/extending/):
196
197
 
197
198
  - Subclass `Path` directly for a custom, non-URI resource (`MemPath` is the
198
199
  reference exemplar).
@@ -1,10 +1,10 @@
1
1
  # pathlib_next
2
2
 
3
- [![Version](https://img.shields.io/pypi/v/pathlib_next.svg)](https://pypi.org/project/pathlib_next/)
4
- [![Python versions](https://img.shields.io/pypi/pyversions/pathlib_next.svg)](https://pypi.org/project/pathlib_next/)
3
+ [![Version](https://img.shields.io/pypi/v/pathlib_next.svg)](https://pypi.org/project/pathlib-next/)
4
+ [![Python versions](https://img.shields.io/pypi/pyversions/pathlib_next.svg)](https://pypi.org/project/pathlib-next/)
5
5
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
- [![Docs](https://img.shields.io/badge/docs-latest-blue.svg)](https://jose-pr.github.io/pathlib_next/)
7
- [![CI](https://img.shields.io/github/actions/workflow/status/jose-pr/pathlib_next/test.yml)](https://github.com/jose-pr/pathlib_next/actions/workflows/test.yml)
6
+ [![Docs](https://img.shields.io/badge/docs-latest-blue.svg)](https://jose-pr.github.io/pathlib-next/)
7
+ [![CI](https://img.shields.io/github/actions/workflow/status/jose-pr/pathlib_next/test.yml)](https://github.com/jose-pr/pathlib-next/actions/workflows/test.yml)
8
8
 
9
9
  A **robust, extensible pathlib-like base** for any resource addressable as a
10
10
  path or URI. Same method names, signatures, semantics, and exception types as
@@ -12,7 +12,7 @@ path or URI. Same method names, signatures, semantics, and exception types as
12
12
  against `Path`/`UriPath` and it works against your local disk, an in-memory
13
13
  tree, an HTTP index, or an SFTP server. Every intentional divergence from
14
14
  `pathlib`'s behavior is documented, not silent -- see
15
- [`docs/divergences.md`](https://jose-pr.github.io/pathlib_next/divergences/).
15
+ [`docs/divergences.md`](https://jose-pr.github.io/pathlib-next/divergences/).
16
16
 
17
17
  ## Features
18
18
 
@@ -30,7 +30,7 @@ tree, an HTTP index, or an SFTP server. Every intentional divergence from
30
30
 
31
31
  Every scheme shares the same `glob()`, `walk()`, `copy()`/`move()`, `rm()`
32
32
  implementations -- see the full matrix and notes in
33
- [Schemes](https://jose-pr.github.io/pathlib_next/guides/schemes/).
33
+ [Schemes](https://jose-pr.github.io/pathlib-next/guides/schemes/).
34
34
 
35
35
  - **Unified path interface** across local files, in-memory paths, archive
36
36
  members, and `file`/`data`/`ftp`/`http`/`dav`/`sftp`/`s3` URIs.
@@ -42,7 +42,7 @@ implementations -- see the full matrix and notes in
42
42
  authority components.
43
43
  - **Extensible two ways**: subclass `Path` directly for a custom
44
44
  non-URI resource, or subclass `UriPath` for a new URI scheme -- see
45
- [Extending](https://jose-pr.github.io/pathlib_next/guides/extending/).
45
+ [Extending](https://jose-pr.github.io/pathlib-next/guides/extending/).
46
46
 
47
47
  ## Installation
48
48
 
@@ -128,13 +128,13 @@ print(member.read_text())
128
128
  Also built in: `data:` (RFC 2397 inline payloads), `ftp(s):` (stdlib
129
129
  `ftplib`), `dav(s):` (WebDAV, full read/write over HTTP), and `s3:`
130
130
  (`boto3`) -- one example per scheme in
131
- [Schemes](https://jose-pr.github.io/pathlib_next/guides/schemes/).
131
+ [Schemes](https://jose-pr.github.io/pathlib-next/guides/schemes/).
132
132
 
133
133
  ## Extending
134
134
 
135
135
  Two first-class ways to add a new path-addressable resource -- both covered
136
136
  in depth, with worked examples, in
137
- [Extending](https://jose-pr.github.io/pathlib_next/guides/extending/):
137
+ [Extending](https://jose-pr.github.io/pathlib-next/guides/extending/):
138
138
 
139
139
  - Subclass `Path` directly for a custom, non-URI resource (`MemPath` is the
140
140
  reference exemplar).
@@ -60,6 +60,9 @@ these operations itself always keeps its own implementation.
60
60
  | `Path.copy(target, ...)` | CPython 3.14 `Path.copy(target, *, follow_symlinks=True, dirs_exist_ok=False, preserve_metadata=False)`; always raises if `target` exists | Ours predates 3.14. Signature: `copy(target, *, overwrite=False, follow_symlinks=True, preserve_metadata=True)`. `overwrite=True` unlinks an existing non-directory target first; `preserve_metadata` defaults to **True** (opposite of 3.14) and only propagates `st_mode`, not timestamps/xattrs | Argument names aligned with 3.14 where cheap; `preserve_metadata=True` default kept for backward compat with this method's pre-existing (pre-3.14-alignment) behavior of always copying the mode bits. Full metadata preservation (timestamps, xattrs) is not implemented. |
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
+ | `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 -- 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
+ | `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. |
63
66
  | `PathSyncer` / `Query` / `Source` | N/A | Our own extensions | N/A -- pure extensions, no pathlib equivalent. |
64
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.** |
65
68
  | `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
@@ -1,8 +1,8 @@
1
1
  site_name: pathlib_next
2
2
  site_description: Generic Path Protocol based pathlib
3
- site_url: https://jose-pr.github.io/pathlib_next/
4
- repo_url: https://github.com/jose-pr/pathlib_next
5
- repo_name: jose-pr/pathlib_next
3
+ site_url: https://jose-pr.github.io/pathlib-next/
4
+ repo_url: https://github.com/jose-pr/pathlib-next
5
+ repo_name: jose-pr/pathlib-next
6
6
  edit_uri: edit/main/docs/
7
7
 
8
8
  theme:
@@ -3,8 +3,16 @@ requires = ["hatchling"]
3
3
  build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
- name = "pathlib_next"
7
- version = "0.9.0"
6
+ # The DISTRIBUTION name, hyphenated to match what PyPI displays and what
7
+ # `pip install pathlib-next` takes. PEP 503 normalises `_` to `-`, so this
8
+ # was already published as `pathlib-next`; spelling it that way here just
9
+ # stops the repo, the docs and the index from disagreeing.
10
+ #
11
+ # The IMPORT name stays `pathlib_next` (`packages` below, and every
12
+ # `import pathlib_next`) -- a hyphen is not legal in a Python identifier.
13
+ # Distribution name and import name differing is ordinary and intended.
14
+ name = "pathlib-next"
15
+ version = "0.9.2"
8
16
  authors = [{ name = "Jose A" }]
9
17
  description = "Generic Path Protocol based pathlib"
10
18
  readme = "README.md"
@@ -27,7 +35,7 @@ sftp-async = ["asyncssh<2.22; python_version<'3.10'", "asyncssh; python_version>
27
35
  s3 = ["boto3", "pathlib_next[uri]"]
28
36
  gs = ["google-cloud-storage", "pathlib_next[uri]"]
29
37
  az = ["azure-storage-blob", "pathlib_next[uri]"]
30
- dev = ['build', 'twine', 'hatchling', 'pytest', 'pytest-cov', 'pyftpdlib', 'wsgidav', 'cheroot', 'moto[s3]', 'hypothesis']
38
+ dev = ['build', 'twine', 'hatchling', 'pytest', 'pytest-cov', 'pyftpdlib', 'wsgidav', 'cheroot', 'moto[s3]', 'hypothesis', 'black']
31
39
  docs = ['mkdocs', 'mkdocs-material', 'mkdocstrings[python]']
32
40
 
33
41
  [project.entry-points."pathlib_next.schemes"]
@@ -54,9 +62,9 @@ git = "pathlib_next.uri.schemes.git:GitPath"
54
62
  "git+gitlab" = "pathlib_next.uri.schemes.git:GitLabGitPath"
55
63
 
56
64
  [project.urls]
57
- Homepage = "https://github.com/jose-pr/pathlib_next/"
58
- Documentation = "https://jose-pr.github.io/pathlib_next/"
59
- Issues = "https://github.com/jose-pr/pathlib_next/issues"
65
+ Homepage = "https://github.com/jose-pr/pathlib-next/"
66
+ Documentation = "https://jose-pr.github.io/pathlib-next/"
67
+ Issues = "https://github.com/jose-pr/pathlib-next/issues"
60
68
 
61
69
 
62
70
  # Ship the consumer-facing docs inside the installed package, so they are
@@ -74,6 +82,11 @@ exclude = ["/.*", "/benchmarks"]
74
82
  packages = ["src/pathlib_next"]
75
83
 
76
84
 
85
+ [tool.black]
86
+ # Pinned to the project's *minimum* supported Python so black never emits
87
+ # syntax the 3.9 floor cannot parse.
88
+ target-version = ["py39"]
89
+
77
90
  [tool.pytest.ini_options]
78
91
  pythonpath = ["src"]
79
92
  testpaths = ["tests"]
@@ -4,7 +4,7 @@ Header-file-style reference for the `pathlib_next` package: every public
4
4
  export with its signature, arguments, contract, and gotchas, so this module
5
5
  can be consumed without reading its source. Kept current with the public
6
6
  API. For the project overview, install extras, and code layout, see the
7
- <https://github.com/jose-pr/pathlib_next>. Any behavioral divergence from `pathlib.Path` is
7
+ <https://github.com/jose-pr/pathlib-next>. Any behavioral divergence from `pathlib.Path` is
8
8
  recorded in `docs/divergences.md` — this file documents the *contract*, not
9
9
  every internal deviation.
10
10
 
@@ -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:
@@ -63,6 +74,17 @@ pathlib_next`.
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
76
  - `rename(target)` — not implemented by default.
77
+ - `_symlink_to(target, target_is_directory=False)` (not implemented by
78
+ default) / `symlink_to(target, target_is_directory=False, *,
79
+ force=False)` — same primitive/wrapper split as `_mkdir`/`mkdir`: a
80
+ backend implements only `_symlink_to()` and receives an already
81
+ normalized path object (a `str` target is turned into one by the
82
+ wrapper, as `copy()`/`move()` do), then reads the raw target string the
83
+ way its transport needs (`Uri.path` on the wire, `os.fspath()`
84
+ locally). `force=` is this library's extension: `False` is
85
+ stdlib-exact, `True` unlinks an existing **non-directory** entry at the
86
+ link path first (never a directory) and is **not** atomic. Listed in
87
+ `_OPERATION_NAMES`, since no stdlib version accepts `force=`.
66
88
  - `copy(target, *, overwrite=False, follow_symlinks=True,
67
89
  preserve_metadata=True, recursive=False, ignore_error=None,
68
90
  progress=None)` — `follow_symlinks`/`preserve_metadata` names match
@@ -130,7 +152,23 @@ pathlib_next`.
130
152
  properties. `exists()`/the `is_*` methods swallow `OSError`/`ValueError`
131
153
  from `stat()` and report `False` rather than propagating (pathlib parity).
132
154
  - **`fs.Chmod`** — `Protocol`. `chmod(mode, *, follow_symlinks=True)` (not
133
- implemented by default); derives `lchmod(mode)`.
155
+ implemented by default); derives `lchmod(mode)`. `mode` may be a `str`,
156
+ parsed as **octal** (`"0755"` == `"755"` == `0o755`); a non-octal digit
157
+ raises `ValueError`. Each backend overrides `chmod()` directly (each has
158
+ real per-scheme logic), so all of them normalize through
159
+ `utils.as_mode()` — the shared helper is what keeps the base from
160
+ drifting between them.
161
+ Also `chown(uid=None, gid=None, *, follow_symlinks=True)` over a
162
+ `_chown(uid, gid, *, follow_symlinks=True)` backend primitive (not
163
+ implemented by default). Extension: `pathlib` has `owner()`/`group()`
164
+ readers but no writer. `None` leaves a field unchanged and `-1` is an
165
+ alias for it; an `int` is an id, a `str` is a name. `chown()` normalizes
166
+ via `utils.as_owner()` and short-circuits when nothing would change, so
167
+ `_chown()` always receives a canonical pair and only converts to its own
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.
134
172
  - **`io.BinaryOpen`** — `Protocol`. `_open(mode="r", buffering=-1) ->
135
173
  io.IOBase` (not implemented by default; must yield a **binary** stream).
136
174
  Derives `open(mode="r", buffering=-1, encoding=None, errors=None,
@@ -180,7 +218,10 @@ extra that depends on it).
180
218
  `suffix`, `stem`, `parent`. Methods: `as_uri(sanitize=False)` (sanitize
181
219
  strips password from userinfo before formatting), `with_source(source)`,
182
220
  `with_segments(*segments)`, `with_path(path)`, `with_query(query)`,
183
- `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`),
184
225
  `relative_to(other, *, walk_up=False)`, `is_local()` (delegates to
185
226
  `Source.is_local()` — does a DNS lookup, cached per `Source`),
186
227
  `as_posix()` (`user@host:path` / `host:path` form when a source is
@@ -249,7 +290,7 @@ extra that depends on it).
249
290
  `single=True`, last value wins).
250
291
 
251
292
  Built-in scheme modules live under `uri/schemes/` — see the table in the
252
- <https://github.com/jose-pr/pathlib_next>. `PATHLIB_NEXT_SFTP_BACKEND` env var (`"paramiko"` /
293
+ <https://github.com/jose-pr/pathlib-next>. `PATHLIB_NEXT_SFTP_BACKEND` env var (`"paramiko"` /
253
294
  `"asyncssh"` / `"auto"`, default `"auto"`) selects the `sftp:` backend;
254
295
  precedence is an explicit class attribute > this env var > auto-detect
255
296
  (prefers asyncssh if importable). `gs:` honors `STORAGE_EMULATOR_HOST` (set
@@ -6,11 +6,13 @@ import os as _os
6
6
  import pathlib as _path
7
7
  import posixpath as _posixpath
8
8
  import re as _re
9
+ import shutil as _shutil
9
10
  import sys as _sys
10
11
  import types as _types
11
12
  import typing as _ty
12
13
 
13
14
  from . import path as _proto
15
+ from . import utils as _utils
14
16
  from .utils.stat import FileStat as _FileStat
15
17
 
16
18
  # pathlib.Path.stat()/chmod() only accept follow_symlinks= on 3.10+; below
@@ -148,6 +150,20 @@ class LocalPath(
148
150
  # available on every supported Python version.
149
151
  return _proto.Path.move(self, target, overwrite=overwrite)
150
152
 
153
+ def _symlink_to(
154
+ self, target: _proto.Path | str, target_is_directory: bool = False
155
+ ) -> None:
156
+ # `symlink_to` is in _OPERATION_NAMES, so the generic
157
+ # `Path.symlink_to()` (which owns `force=`) is what resolves on
158
+ # LocalPath -- it delegates the actual link creation here, and
159
+ # stdlib's own implementation is reached explicitly via super().
160
+ # Unlike every remote scheme, target_is_directory is meaningful
161
+ # here: it is the Windows-only flag pathlib forwards to
162
+ # os.symlink(). stdlib accepts any os.PathLike, so the normalized
163
+ # path object goes straight through -- and a relative target stays
164
+ # relative, exactly as before.
165
+ return super().symlink_to(target, target_is_directory)
166
+
151
167
  def stat(self, *, follow_symlinks=True):
152
168
  # pathlib.Path.stat() (next in MRO via WindowsPath/PosixPath) only
153
169
  # accepts follow_symlinks= on 3.10+; below that, lstat() is the
@@ -156,14 +172,51 @@ class LocalPath(
156
172
  return super().stat(follow_symlinks=follow_symlinks)
157
173
  return super().stat() if follow_symlinks else super().lstat()
158
174
 
159
- def chmod(self, mode, *, follow_symlinks=True):
175
+ def chmod(self, mode: int | str, *, follow_symlinks: bool = True):
160
176
  # Same follow_symlinks= 3.10+ gap as stat() above; lchmod() is the
161
177
  # pre-existing equivalent (raises NotImplementedError itself on
162
178
  # platforms without os.lchmod, e.g. Windows).
179
+ mode = _utils.as_mode(mode)
163
180
  if _HAS_FOLLOW_SYMLINKS:
164
181
  return super().chmod(mode, follow_symlinks=follow_symlinks)
165
182
  return super().chmod(mode) if follow_symlinks else super().lchmod(mode)
166
183
 
184
+ def _chown(
185
+ self,
186
+ uid: int | str | None,
187
+ gid: int | str | None,
188
+ *,
189
+ follow_symlinks: bool = True,
190
+ ) -> None:
191
+ # shutil.chown() is the stdlib spelling that accepts names as well
192
+ # as ids, and it takes None for "leave unchanged" -- the same
193
+ # canonical form Chmod.chown() already normalized to, so the pair
194
+ # passes straight through. os.chown's -1 sentinel never appears
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()")
205
+ if not follow_symlinks:
206
+ if not hasattr(_os, "lchown"):
207
+ raise NotImplementedError("chown(follow_symlinks=False)")
208
+ if isinstance(uid, str) or isinstance(gid, str):
209
+ # os.lchown takes numeric ids only; resolving a name would
210
+ # mean duplicating shutil's lookup, and guessing wrong here
211
+ # writes the wrong owner silently.
212
+ raise NotImplementedError(
213
+ "chown(follow_symlinks=False) requires numeric uid/gid"
214
+ )
215
+ return _os.lchown(
216
+ self, -1 if uid is None else uid, -1 if gid is None else gid
217
+ )
218
+ return _shutil.chown(self, uid, gid)
219
+
167
220
  def glob(
168
221
  self,
169
222
  pattern: str | _proto.FsPathLike,
@@ -91,9 +91,7 @@ class MemPath(Path):
91
91
  # implementation-defined root) and doesn't collapse it, which
92
92
  # broke MemPath("/") (as_posix() == "/") into a bogus "//".
93
93
  posix = self.as_posix().lstrip("/")
94
- self._normalized = (
95
- _posix.normpath("/" + posix).removeprefix("/").split("/")
96
- )
94
+ self._normalized = _posix.normpath("/" + posix).removeprefix("/").split("/")
97
95
  return self._normalized
98
96
 
99
97
  @property
@@ -123,11 +121,19 @@ class MemPath(Path):
123
121
  def _parent_container(self) -> tuple[dict[str, bytearray], str]:
124
122
  parent = self.backend
125
123
  *ancestors, name = self.normalized
126
- for path in ancestors:
124
+ for index, path in enumerate(ancestors):
127
125
  if path not in parent:
128
126
  raise FileNotFoundError(self.parent)
129
- else:
130
- 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]))
131
137
 
132
138
  return parent, name
133
139
 
@@ -194,6 +200,11 @@ class MemPath(Path):
194
200
  # mode contract: "r"/"w" are required; "x"/"a" are supported here
195
201
  # as an extension. Anything else raises NotImplementedError.
196
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)
197
208
  if mode == "r":
198
209
  if name not in parent:
199
210
  raise FileNotFoundError(self)
@@ -202,6 +213,10 @@ class MemPath(Path):
202
213
  raise IsADirectoryError(self)
203
214
  return io.BytesIO(content)
204
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)
205
220
  content = bytearray()
206
221
  parent[name] = content
207
222
  return MemBytesIO(content)