pathlib-next 0.9.4__tar.gz → 0.9.6__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 (143) hide show
  1. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/CHANGELOG.md +70 -1
  2. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/PKG-INFO +1 -1
  3. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/docs/divergences.md +6 -1
  4. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/docs/guides/schemes.md +11 -3
  5. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/pyproject.toml +1 -1
  6. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/src/pathlib_next/AGENTS.md +47 -13
  7. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/src/pathlib_next/fspath.py +19 -4
  8. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/src/pathlib_next/path.py +65 -5
  9. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/src/pathlib_next/testing.py +7 -2
  10. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/src/pathlib_next/uri/schemes/archive/_base.py +135 -46
  11. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/src/pathlib_next/utils/glob.py +32 -2
  12. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/tests/test_archive_safety.py +204 -8
  13. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/tests/test_glob_parity.py +84 -0
  14. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/tests/test_pure_parity.py +35 -0
  15. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/.gitignore +0 -0
  16. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/AGENTS.md +0 -0
  17. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/LICENSE +0 -0
  18. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/README.md +0 -0
  19. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/docs/api/cli.md +0 -0
  20. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/docs/api/mempath.md +0 -0
  21. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/docs/api/path.md +0 -0
  22. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/docs/api/protocols.md +0 -0
  23. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/docs/api/schemes/archive.md +0 -0
  24. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/docs/api/schemes/ftp.md +0 -0
  25. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/docs/api/schemes/git.md +0 -0
  26. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/docs/api/schemes/http.md +0 -0
  27. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/docs/api/schemes/local.md +0 -0
  28. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/docs/api/schemes/objstore.md +0 -0
  29. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/docs/api/schemes/sftp.md +0 -0
  30. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/docs/api/testing.md +0 -0
  31. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/docs/api/uri.md +0 -0
  32. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/docs/api/utils.md +0 -0
  33. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/docs/benchmarks.md +0 -0
  34. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/docs/changelog.md +0 -0
  35. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/docs/guides/cli.md +0 -0
  36. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/docs/guides/extending.md +0 -0
  37. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/docs/index.md +0 -0
  38. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/examples/az_listing.py +0 -0
  39. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/examples/data_and_archive.py +0 -0
  40. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/examples/ftp_listing.py +0 -0
  41. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/examples/github_listing.py +0 -0
  42. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/examples/gitlab_listing.py +0 -0
  43. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/examples/gs_listing.py +0 -0
  44. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/examples/http_listing.py +0 -0
  45. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/examples/local_and_mem.py +0 -0
  46. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/examples/s3_listing.py +0 -0
  47. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/examples/sftp_sync.py +0 -0
  48. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/examples/webdav_roundtrip.py +0 -0
  49. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/mkdocs.yml +0 -0
  50. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/src/pathlib_next/__init__.py +0 -0
  51. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/src/pathlib_next/mempath.py +0 -0
  52. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/src/pathlib_next/protocols/__init__.py +0 -0
  53. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/src/pathlib_next/protocols/checksum.py +0 -0
  54. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/src/pathlib_next/protocols/fs.py +0 -0
  55. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/src/pathlib_next/protocols/io.py +0 -0
  56. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/src/pathlib_next/py.typed +0 -0
  57. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/src/pathlib_next/tools/__init__.py +0 -0
  58. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/src/pathlib_next/tools/uripath.py +0 -0
  59. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/src/pathlib_next/uri/__init__.py +0 -0
  60. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/src/pathlib_next/uri/query.py +0 -0
  61. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/src/pathlib_next/uri/schemes/__init__.py +0 -0
  62. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/src/pathlib_next/uri/schemes/_gitrepo.py +0 -0
  63. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/src/pathlib_next/uri/schemes/archive/__init__.py +0 -0
  64. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/src/pathlib_next/uri/schemes/archive/tar.py +0 -0
  65. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/src/pathlib_next/uri/schemes/archive/zip.py +0 -0
  66. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/src/pathlib_next/uri/schemes/az.py +0 -0
  67. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/src/pathlib_next/uri/schemes/data.py +0 -0
  68. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/src/pathlib_next/uri/schemes/dav.py +0 -0
  69. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/src/pathlib_next/uri/schemes/file.py +0 -0
  70. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/src/pathlib_next/uri/schemes/ftp.py +0 -0
  71. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/src/pathlib_next/uri/schemes/git/__init__.py +0 -0
  72. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/src/pathlib_next/uri/schemes/git/_base.py +0 -0
  73. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/src/pathlib_next/uri/schemes/git/github.py +0 -0
  74. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/src/pathlib_next/uri/schemes/git/gitlab.py +0 -0
  75. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/src/pathlib_next/uri/schemes/github.py +0 -0
  76. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/src/pathlib_next/uri/schemes/gitlab.py +0 -0
  77. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/src/pathlib_next/uri/schemes/gs.py +0 -0
  78. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/src/pathlib_next/uri/schemes/http.py +0 -0
  79. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/src/pathlib_next/uri/schemes/s3.py +0 -0
  80. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/src/pathlib_next/uri/schemes/sftp/__init__.py +0 -0
  81. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/src/pathlib_next/uri/schemes/sftp/_asyncssh.py +0 -0
  82. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/src/pathlib_next/uri/schemes/sftp/_paramiko.py +0 -0
  83. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/src/pathlib_next/uri/schemes/sftp/_sshconfig.py +0 -0
  84. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/src/pathlib_next/uri/source.py +0 -0
  85. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/src/pathlib_next/utils/__init__.py +0 -0
  86. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/src/pathlib_next/utils/archive.py +0 -0
  87. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/src/pathlib_next/utils/checksum.py +0 -0
  88. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/src/pathlib_next/utils/stat.py +0 -0
  89. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/src/pathlib_next/utils/sync.py +0 -0
  90. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/tests/conftest.py +0 -0
  91. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/tests/test_archive_parity.py +0 -0
  92. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/tests/test_archive_uri.py +0 -0
  93. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/tests/test_az.py +0 -0
  94. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/tests/test_az_fake.py +0 -0
  95. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/tests/test_checksum.py +0 -0
  96. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/tests/test_contract.py +0 -0
  97. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/tests/test_contract_helpers.py +0 -0
  98. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/tests/test_data_uri.py +0 -0
  99. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/tests/test_dav.py +0 -0
  100. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/tests/test_destructive_safety.py +0 -0
  101. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/tests/test_ftp.py +0 -0
  102. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/tests/test_ftp_objstore_parity.py +0 -0
  103. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/tests/test_gitrepo.py +0 -0
  104. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/tests/test_glob.py +0 -0
  105. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/tests/test_gs.py +0 -0
  106. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/tests/test_gs_fake.py +0 -0
  107. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/tests/test_http.py +0 -0
  108. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/tests/test_http_live.py +0 -0
  109. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/tests/test_http_parser.py +0 -0
  110. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/tests/test_httpdav_safety.py +0 -0
  111. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/tests/test_io_parity.py +0 -0
  112. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/tests/test_local.py +0 -0
  113. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/tests/test_low_core.py +0 -0
  114. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/tests/test_low_schemes.py +0 -0
  115. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/tests/test_low_sync.py +0 -0
  116. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/tests/test_mempath.py +0 -0
  117. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/tests/test_mro_precedence.py +0 -0
  118. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/tests/test_parity_io.py +0 -0
  119. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/tests/test_parity_pure.py +0 -0
  120. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/tests/test_path_gaps.py +0 -0
  121. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/tests/test_pathname.py +0 -0
  122. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/tests/test_plugins.py +0 -0
  123. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/tests/test_properties.py +0 -0
  124. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/tests/test_query.py +0 -0
  125. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/tests/test_routing.py +0 -0
  126. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/tests/test_s3.py +0 -0
  127. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/tests/test_sftp.py +0 -0
  128. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/tests/test_sftp_asyncssh.py +0 -0
  129. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/tests/test_sftp_transport.py +0 -0
  130. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/tests/test_smoke.py +0 -0
  131. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/tests/test_source.py +0 -0
  132. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/tests/test_sync.py +0 -0
  133. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/tests/test_sync_safety.py +0 -0
  134. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/tests/test_sync_sftp.py +0 -0
  135. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/tests/test_sync_sftp_parity.py +0 -0
  136. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/tests/test_transport_security.py +0 -0
  137. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/tests/test_uri_core_parity.py +0 -0
  138. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/tests/test_uri_parse.py +0 -0
  139. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/tests/test_uri_path.py +0 -0
  140. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/tests/test_uripath_tool.py +0 -0
  141. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/tests/test_utils.py +0 -0
  142. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/tests/test_walk.py +0 -0
  143. {pathlib_next-0.9.4 → pathlib_next-0.9.6}/tests/test_webdav.py +0 -0
@@ -7,6 +7,73 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.6] - 2026-09-16
11
+
12
+ ### Added
13
+ - **`Path.glob(None)` / `rglob(None)`**: expand the pattern the path itself
14
+ carries (`LocalPath("/etc/*.conf").glob(None)`), splitting at the first
15
+ wildcard. `glob("")` still raises `ValueError` as pathlib does -- `None`
16
+ is the spelling that cannot collide with a real pattern -- so this restores
17
+ what 0.9.4 removed as an explicit, supported form rather than by accident.
18
+ - **`Path.glob(native=)` / `rglob(native=)`**, default `True`: follow the
19
+ running interpreter on the two rules pathlib changed mid-series. A
20
+ trailing `/` is ignored before 3.11 and selects directories only from
21
+ 3.11; a component that merely contains `**` (`a**`) raises `ValueError`
22
+ before 3.13 and is a plain wildcard from 3.13. `native=False` applies one
23
+ rule on every version instead, which is what a caller comparing results
24
+ across backends or interpreters wants.
25
+
26
+ ### Changed
27
+ - **`glob()` now matches the running interpreter exactly**, including on
28
+ Python 3.9-3.12 where it previously applied its own rule for a trailing
29
+ `/` and for `a**`. Measured with a 46-comparison differential sweep
30
+ against `pathlib`: 3.14 was already identical, and 3.9 went from 4
31
+ disagreements to 0. Pass `native=False` for the previous, version-
32
+ independent behaviour; `pathlib_next.testing`'s contract suite does.
33
+
34
+ ### Documentation
35
+ - **Named the replacement for `glob("")`**, which 0.9.4 removed for pathlib
36
+ parity: `pathlib_next.utils.glob.glob(path, recursive=...)` expands a
37
+ pattern the path itself carries, splitting at the first wildcard. The
38
+ 0.9.4 entry withdrew the capability without naming it, and `Path.glob()`'s
39
+ docstring documented the `ValueError` but not the alternative. Reported by
40
+ yaconfiglib, whose `path.glob("", recursive=...)` calls stopped working.
41
+
42
+ ## [0.9.5] - 2026-09-16
43
+
44
+ ### Fixed
45
+ - **Archive member names are normalized**, so the same member is reachable
46
+ however the archive was written and whichever format it is. A leading
47
+ `./` (what `tar -C dir .`, `TarFile.add(arcname=".")` and
48
+ `shutil.make_archive` put on every member), empty segments (`a//b`) and
49
+ interior `.`/`..` (`a/./b`, `a/b/../c`) now resolve to one name for
50
+ listing and lookup alike. Previously only `tar:` stripped a leading `./`:
51
+ a zip written that way listed as empty and none of its members could be
52
+ read under any spelling, while a name such as `d//e.txt` was readable but
53
+ absent from listings.
54
+
55
+ ### Changed
56
+ - **A drive- or backslash-shaped member name is no longer dropped.**
57
+ `C:drive.txt` and `a\b` are ordinary filenames on POSIX, and an archive
58
+ written there may contain them; they were silently absent from every
59
+ listing and unreadable on every platform. The rule they were failing is a
60
+ *destination* rule, and now lives where the joining happens:
61
+ `Path.copy(recursive=True)` refuses a child name that would not stay
62
+ inside its target (`ValueError` through `ignore_error`), which is what
63
+ `PathSyncer` and `utils.unpack_archive()` already did per destination.
64
+ Copying such a member onto a Windows path is still refused; copying it to
65
+ a POSIX path, a `MemPath` or another archive now works.
66
+ - **A member name that escapes the archive root** (`../x`, `/abs`, a
67
+ `..` with nothing left to consume) has no name inside the archive: it was
68
+ already never listed, and is now never readable either. Such a member
69
+ used to be hidden from listings while `read_bytes()` still returned it
70
+ under its raw name. (Writing to one already failed and created nothing;
71
+ that is unchanged.) A `..` that stays inside is resolved rather than
72
+ rejected (`pkg/../ok.txt` reads `ok.txt`), and when two spellings
73
+ normalize to one name the later member wins, as in `zipfile`/`tarfile`.
74
+ - **`Uri`'s RFC 3986 dot-segment removal is documented** as a divergence
75
+ from `pathlib` (`docs/divergences.md`); the behaviour is unchanged.
76
+
10
77
  ## [0.9.4] - 2026-09-16
11
78
 
12
79
  ### Fixed
@@ -1269,7 +1336,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
1269
1336
  - Sync error handling.
1270
1337
  - Generic Path Protocol based pathlib implementation for URI paths with file access support for sftp, http, file schemes.
1271
1338
 
1272
- [Unreleased]: https://github.com/jose-pr/pathlib-next/compare/v0.9.4...HEAD
1339
+ [Unreleased]: https://github.com/jose-pr/pathlib-next/compare/v0.9.6...HEAD
1340
+ [0.9.6]: https://github.com/jose-pr/pathlib-next/compare/v0.9.5...v0.9.6
1341
+ [0.9.5]: https://github.com/jose-pr/pathlib-next/compare/v0.9.4...v0.9.5
1273
1342
  [0.9.4]: https://github.com/jose-pr/pathlib-next/compare/v0.9.3...v0.9.4
1274
1343
  [0.9.3]: https://github.com/jose-pr/pathlib-next/compare/v0.9.2...v0.9.3
1275
1344
  [0.9.2]: https://github.com/jose-pr/pathlib-next/compare/v0.9.1...v0.9.2
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pathlib-next
3
- Version: 0.9.4
3
+ Version: 0.9.6
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/
@@ -55,7 +55,8 @@ these operations itself always keeps its own implementation.
55
55
  | Method | pathlib behavior | Our behavior | Why |
56
56
  | --- | --- | --- | --- |
57
57
  | `Uri("http://h/d/").name` (trailing `/`) | `PurePosixPath("d/").name == "d"` | A trailing `/` is kept: `name` is `""` and `parent` is `http://h/d`. | For HTTP/WebDAV a trailing slash is how a directory URL is spelled; normalizing it away changes which URL is requested. |
58
- | `Path.glob()` / `rglob()` edge cases | Version-dependent pathlib rules | Hidden entries are included by default (pathlib parity; `include_hidden=False` filters them). A trailing `/` selects directories only on every Python version (pathlib ignores it before 3.11). `recurse_symlinks=True` raises `NotImplementedError`: `**` never descends into directory symlinks. A trailing `**` follows the running interpreter (files too on 3.13+). `a**` is a plain wildcard (pathlib before 3.13 raises `ValueError`). | Loop-safe recursion without `st_dev`/`st_ino` (most backends' stats lack them) rules out following links; one trailing-slash rule keeps results identical across backends and versions. |
58
+ | `Path.glob()` / `rglob()` edge cases | Version-dependent pathlib rules | Hidden entries are included by default (pathlib parity; `include_hidden=False` filters them). `recurse_symlinks=True` raises `NotImplementedError`: `**` never descends into directory symlinks. A trailing `**` follows the running interpreter (files too on 3.13+). The two rules pathlib changed mid-series follow the running interpreter by default (`native=True`): a trailing `/` is ignored before 3.11 and selects directories only from 3.11, and `a**` raises `ValueError` before 3.13 and is a plain wildcard from 3.13. `native=False` applies one rule on every version instead -- trailing `/` always selects directories only, `a**` is always a plain wildcard. | Loop-safe recursion without `st_dev`/`st_ino` (most backends' stats lack them) rules out following links. The `native` default keeps `LocalPath` answering exactly what the `pathlib` beside it answers; `native=False` is for a caller that wants one answer across backends and interpreters, which is what the contract suite asserts. |
59
+ | `Path.glob(None)` / `rglob(None)` | `pathlib` has no such form (its pattern is always applied to a directory) | `None` expands the pattern THIS PATH CARRIES: `LocalPath("/etc/*.conf").glob(None)` splits at the first wildcard and globs from there (`utils.glob.glob()`). `glob("")` still raises `ValueError`, as pathlib does. | An extension: a path that is itself a pattern is a common shape for config and CLI inputs, and before 0.9.4 `glob("")` was the accidental spelling for it. `None` cannot collide with a real pattern, so parity is untouched. |
59
60
  | `Uri.query` | N/A (pathlib has no query) | The query is kept exactly as received (percent-encoded) and sent unchanged; `Query(...).decode()`/`to_dict()` decode. `Uri.parts` is `(source, path, query, fragment)`, not path segments (`segments` is). A `%2F` in a path decodes to `/` and is not distinguishable from a separator. | Decoding at parse time and re-encoding changed what reached the server (a signed URL's `%2B` became `+`, an escaped `&` split a value). The decoded-path model cannot keep `%2F` distinct. |
60
61
  | `Path.copy(follow_symlinks=False)` on a symlink | CPython 3.14: copies the link as a link | Same: the link is recreated (not its metadata). Where the source cannot `readlink()` or the target cannot create links, raises `NotImplementedError` instead of copying content. `copy(recursive=True)` into its own subtree raises `OSError(EINVAL)` before creating anything. | Copying the link target's content under the link's permissions produced a 0o777 regular file. |
61
62
  | `SftpPath.rename(target)` onto an existing file | POSIX `rename(2)`: replaces | Replaces through `posix-rename@openssh.com` where the server supports it; otherwise raises `FileExistsError`. | Plain SFTPv3 rename refuses an existing target with an uninformative failure. |
@@ -80,6 +81,10 @@ these operations itself always keeps its own implementation.
80
81
  | `str()`/`repr()` of `github:`/`gitlab:`/`git:` paths | N/A (no pathlib equivalent) | The **whole** userinfo is redacted, not just the part after `:` as for other schemes. `as_uri()` (unsanitized) still returns it. | The token is commonly the bare userinfo (`TOKEN@host`), the one part other schemes keep, so it reached logs and tracebacks. |
81
82
  | `Path.exists()` / `is_*()` on a stat error other than "not found" | pathlib 3.9-3.12 re-raise errors outside ENOENT/ENOTDIR/EBADF/ELOOP (e.g. `PermissionError`); 3.13+ return `False` | Every `OSError`/`ValueError` from `stat()` returns `False` on every Python version, including `LocalPath.exists()` on 3.9-3.12. | One rule across backends and versions, matching current pathlib. |
82
83
  | `PathSyncer` directory → file/symlink type change | N/A (closest: `rsync`, which will not delete a non-empty directory without `--delete`/`--force`) | With `remove_missing=False`, a non-empty target directory is not replaced when the source entry at that name became a file or symlink: `IsADirectoryError` goes through `ignore_error` (`SyncEvent.TypeMismatch`) and the directory is kept. Empty directories, or `remove_missing=True`, are replaced. | `remove_missing=False` means "never delete target-only data"; replacing the directory silently deleted its whole subtree. |
84
+ | `Path.copy(recursive=True)` child names | `shutil.copytree` joins whatever the listing yields | A child name that would not stay inside `target` is refused with `ValueError` through `ignore_error`: `..`, and `\`/`:`/a trailing dot when the target reads names with Windows rules. | The names come from listings the destination does not control (an archive, an HTTP index, an object-store key); on a Windows target `"C:x"` joins to a drive-relative path outside the destination entirely. `PathSyncer` and `utils.unpack_archive()` already applied this per destination. |
85
+ | Archive member names | N/A (`zipfile`/`tarfile` expose the raw name as written) | Normalized as POSIX relative paths for every format: a leading `./`, empty segments and interior `.`/`..` resolve, so one member has one name and listings and lookups agree; the raw spelling still addresses it, and the later of two members that normalize alike wins. A name that escapes the root (`../x`, `/abs`) has no name inside the archive at all, while a drive- or backslash-shaped name is a normal member (an ordinary POSIX filename) that only a Windows destination refuses to receive. | The same file was reachable or not depending on how the writer spelled it: a zip written by `shutil.make_archive`-style `./` prefixes listed as empty, and zip and tar disagreed about identical archives. |
86
+ | The POSIX `//` root | `PurePosixPath("//")` keeps `//` as a root distinct from `/` (POSIX leaves exactly two leading slashes implementation-defined; three or more collapse) | The generic classes do not model it: `MemPath("//")` collapses to `/`, and `Uri("//")` reads `//` as the start of an authority (RFC 3986), giving an empty authority and an empty path -- so `Uri("//a/b")` has host `a` and path `/b`. `match()` therefore disagrees with `pathlib` on that one path. `LocalPath`/`WindowsPathname` are unaffected (they inherit pathlib's parsing, where `//server/share` is a UNC drive). | A distinct double-slash root has no meaning for an in-memory tree or a URI, and for a `Uri` it cannot: `//a/b` must read `a` as a host. Modelling it would change segment normalization everywhere (`parents`, `relative_to`, `is_absolute`, every scheme) to serve a spelling no backend can use. |
87
+ | `Uri` path dot segments | `pathlib` keeps `..` lexically (`PurePosixPath("a/../b")` is `a/../b`) | `Uri` removes dot segments as RFC 3986 requires of a URI reference, in the constructor and in `/`-joins: `Uri("a/../b")` is `b`, `Uri("http://h/x") / "a/../b"` is `http://h/x/b`, and `Uri("a/b/..")` is `a/`. A leading `..` that would pass the root is kept, not resolved. `MemPath` follows `pathlib` instead. | A URI is resolved, not spelled: `..` in a URI reference has a defined meaning that servers, caches and proxies already apply, so keeping it lexically would address a different resource than the same string typed into a browser. |
83
88
  | Nested archive URIs | N/A | Each leading archive scheme in `<archive-uri>` consumes one `!/` (`zip:zip:file:///outer.zip!/inner.zip!/x.txt`); a member name containing `!/` is written `%21/`. Nested archives are read-only. | The first `!/` was always taken as the separator, so an archive inside an archive could not be addressed. |
84
89
  | Object-store key that is both an object and a prefix | N/A (a filesystem entry has one type) | `iterdir()`/`walk()`/`copy(recursive=True)` on `s3:`/`gs:`/`az:` show only the object (`x`), matching `stat()`'s exact-object precedence; the subtree under `x/` is not listed. | Listings used to keep the directory and drop the object, contradicting `stat()`. |
85
90
  | `HttpPath.iterdir()` on a file | pathlib raises `NotADirectoryError` | Raises `NotADirectoryError` for a non-HTML response, without downloading it. An HTML file cannot be told apart from an index page and lists as empty. | HTTP has no directory type; the response content type is the only signal. |
@@ -205,9 +205,17 @@ The `<archive-uri>` is any absolute URI with an explicit scheme, so
205
205
  - An archive inside an archive is addressed by nesting
206
206
  (`zip:zip:file:///outer.zip!/inner.zip!/x.txt`) and is read-only; a `!/`
207
207
  inside a member name is written `%21/`.
208
- - Members whose names would escape a destination (`..`, absolute or drive
209
- paths) are never listed. Exception types are the POSIX ones on every
210
- platform.
208
+ - Member names are normalized as POSIX relative paths, the same way for
209
+ every format: `./x`, `a//b`, `a/./b` and `a/b/../c` all resolve, so a
210
+ member lists and reads under one name however the archive was written
211
+ (`tar -C dir .` and `shutil.make_archive` prefix every member with `./`).
212
+ The spelling as written still works.
213
+ - Members whose names would escape the archive (`..` past the root, or an
214
+ absolute path) have no name inside it: never listed, never readable. A
215
+ name that merely a Windows destination would misread -- `C:drive.txt`, or
216
+ one containing `\` -- is a normal member, since both are ordinary
217
+ filenames on POSIX; copying or extracting it onto a Windows path is what
218
+ refuses it. Exception types are the POSIX ones on every platform.
211
219
 
212
220
  ## Git hosting
213
221
 
@@ -13,7 +13,7 @@ build-backend = "hatchling.build"
13
13
  # `import pathlib_next`) -- a hyphen is not legal in a Python identifier.
14
14
  # Distribution name and import name differing is ordinary and intended.
15
15
  name = "pathlib-next"
16
- version = "0.9.4"
16
+ version = "0.9.6"
17
17
  authors = [{ name = "Jose A" }]
18
18
  description = "Generic Path Protocol based pathlib"
19
19
  readme = "README.md"
@@ -73,16 +73,28 @@ silently absent and `from pathlib_next.uri import UriPath` raises
73
73
  `PathSyncer`; override it when the listing call already returns metadata.
74
74
  `None` means "unknown", never "missing".
75
75
  - `glob(pattern, *, case_sensitive=None, include_hidden=True,
76
- recursive=None, dironly=None, recurse_symlinks=False)` — pathlib
77
- semantics: hidden entries included, a trailing `/` selects directories,
78
- `**` never descends into directory symlinks (`recurse_symlinks=True` →
79
- `NotImplementedError`), a trailing `**` selects files too on 3.13+, a
80
- missing or non-directory base yields nothing, `""` → `ValueError`, an
81
- absolute pattern → `glob.NonRelativePatternError`. `recursive=None`
82
- enables recursion when a component is `**`; an explicit value wins.
83
- Validates eagerly, selects lazily. On a remote scheme a recursive glob
84
- lists every directory of the subtree.
76
+ recursive=None, dironly=None, recurse_symlinks=False, native=True)` —
77
+ pathlib semantics: hidden entries included, `**` never descends into
78
+ directory symlinks (`recurse_symlinks=True` → `NotImplementedError`), a
79
+ trailing `**` selects files too on 3.13+, a missing or non-directory base
80
+ yields nothing, `""` → `ValueError`, an absolute pattern →
81
+ `glob.NonRelativePatternError`. `recursive=None` enables recursion when a
82
+ component is `**`; an explicit value wins. Validates eagerly, selects
83
+ lazily. On a remote scheme a recursive glob lists every directory of the
84
+ subtree.
85
+ - **`pattern=None`** expands the pattern THIS PATH CARRIES
86
+ (`LocalPath("/etc/*.conf").glob(None)`), splitting at the first
87
+ wildcard — the supported form for a path that is itself a pattern.
88
+ `""` still raises.
89
+ - **`native=True`** (default) follows the running interpreter on the two
90
+ rules pathlib changed mid-series: a trailing `/` is ignored before 3.11
91
+ and selects directories only from 3.11; `a**` raises `ValueError`
92
+ before 3.13 and is a plain wildcard from 3.13. `native=False` applies
93
+ one rule on every version (trailing `/` → directories only, `a**` → a
94
+ plain wildcard), so a pattern answers the same on every interpreter and
95
+ backend; `pathlib_next.testing`'s contract suite uses it.
85
96
  - `rglob(pattern, **same_kwargs)` — `glob(f"**/{pattern}", recursive=True)`.
97
+ `pattern=None` is `glob(None)`.
86
98
  - `walk(top_down=True, on_error=None, follow_symlinks=False)` — drives
87
99
  `_scandir()`; its stats are trusted only with `follow_symlinks=False`. No
88
100
  symlink-cycle protection when following (only `LocalPath` has pathlib's).
@@ -114,6 +126,13 @@ silently absent and `from pathlib_next.uri import UriPath` raises
114
126
  the same file (or a case-insensitive alias) → `OSError(EINVAL)`.
115
127
  - The source is opened before the target is touched; a failed stream
116
128
  removes the partial target.
129
+ - A recursive copy refuses any child name that would not stay inside
130
+ `target` — `..`, and `\`/`:`/a trailing dot when the target reads
131
+ names with Windows rules (`utils.is_windows_flavoured()`). The names
132
+ come from a listing the destination does not control (an archive, a
133
+ remote index, an object-store key), and on a Windows target `"C:x"`
134
+ joins to a drive-relative path outside it. Raised as `ValueError`
135
+ through `ignore_error`, per child, like any other child failure.
117
136
  - `follow_symlinks=False` on a symlink recreates the link
118
137
  (`NotImplementedError` if either side cannot).
119
138
  - `preserve_metadata=True` copies permission bits only, and only a mode the
@@ -481,15 +500,30 @@ chained (their text can carry credentials).
481
500
  - One shared handle per archive (keyed by the real local path, or the outer
482
501
  URI), released when no path references it. A non-local outer is read into
483
502
  memory.
484
- - Members named with `..`, an absolute path or a drive are never listed.
485
- Exception types are POSIX on every platform.
503
+ - **Member names are normalized POSIX relative paths**, whatever the
504
+ writer emitted and whichever format: a leading `./` (`tar -C dir .`,
505
+ `shutil.make_archive`), empty segments (`a//b`) and interior `.`/`..`
506
+ (`a/./b`, `a/b/../c`) resolve, so one member has one name and a listing
507
+ and a lookup always agree. The spelling as written still addresses the
508
+ member. A name that would leave the root -- `../x`, `/abs`, or a `..`
509
+ with nothing to spend it on -- has no name inside the archive: it is
510
+ never listed, never readable, and cannot be written (the write fails and
511
+ creates nothing). A name only a *Windows destination* would misread
512
+ (`C:drive.txt`, `a\b`) IS a member, because it is an ordinary POSIX
513
+ filename; refusing to join it is the destination's rule, applied by
514
+ whatever writes there (see `copy()` below, `PathSyncer`,
515
+ `unpack_archive()`). `zipfile` itself rewrites `\` to `/`, so a
516
+ backslash name only survives in a tar. When two spellings normalize to one name the
517
+ later member wins, as in `zipfile`/`tarfile`. Exception types are POSIX on every
518
+ platform.
486
519
  - Writes: zip only, and only with a local `file:` outer (else
487
520
  `NotImplementedError`). `"w"`/`"x"`/`"r+"`, `mkdir()`, `unlink()`,
488
521
  `rmdir()`, `rename()` (same archive; replaces like POSIX `rename`);
489
522
  parents must exist; `"a"` unsupported. Every mutation replaces the archive
490
523
  atomically (temp file + `os.replace`) and keeps other members' metadata,
491
- the comment and any prefix bytes. `tar:` (plain, gz, bz2, xz) is
492
- read-only; `./` member prefixes are dropped.
524
+ the comment and any prefix bytes. A write uses the normalized name; a
525
+ name that escapes the root fails and creates nothing.
526
+ `tar:` (plain, gz, bz2, xz) is read-only.
493
527
 
494
528
  ## CLI (`uripath`, `pathlib_next.tools.uripath`)
495
529
 
@@ -394,21 +394,35 @@ class LocalPath(
394
394
 
395
395
  def glob(
396
396
  self,
397
- pattern: str | _proto.FsPathLike,
397
+ pattern: "str | _proto.FsPathLike | None",
398
398
  *,
399
399
  case_sensitive: bool = None,
400
400
  include_hidden: bool = True,
401
401
  recursive: bool = None,
402
402
  dironly: bool = None,
403
403
  recurse_symlinks: bool = False,
404
+ native: bool = True,
404
405
  ):
405
406
  """Iterate over this subtree and yield all existing files (of any
406
407
  kind, including directories) matching the given relative pattern.
407
408
 
408
- Same semantics as Path.glob(); every separator of this flavour
409
- splits the pattern, and a pattern with a drive or root raises
410
- `glob.NonRelativePatternError` like pathlib.
409
+ Same semantics as Path.glob(), including `pattern=None` (expand the
410
+ pattern this path carries) and `native=` (follow the running
411
+ interpreter, or one rule on every version); every separator of this
412
+ flavour splits the pattern, and a pattern with a drive or root
413
+ raises `glob.NonRelativePatternError` like pathlib.
411
414
  """
415
+ if pattern is None:
416
+ return _proto.Path.glob(
417
+ self,
418
+ None,
419
+ case_sensitive=case_sensitive,
420
+ include_hidden=include_hidden,
421
+ recursive=recursive,
422
+ dironly=dironly,
423
+ recurse_symlinks=recurse_symlinks,
424
+ native=native,
425
+ )
412
426
  pattern = _os.fspath(pattern)
413
427
  if pattern:
414
428
  anchored = self.with_segments(pattern)
@@ -430,4 +444,5 @@ class LocalPath(
430
444
  recursive=recursive,
431
445
  dironly=dironly,
432
446
  recurse_symlinks=recurse_symlinks,
447
+ native=native,
433
448
  )
@@ -704,13 +704,14 @@ class Path(Pathname, Chmod, Stat, BinaryOpen):
704
704
 
705
705
  def glob(
706
706
  self,
707
- pattern: str | _ty.Self,
707
+ pattern: str | _ty.Self | None,
708
708
  *,
709
709
  case_sensitive: bool = None,
710
710
  include_hidden: bool = True,
711
711
  recursive: bool = None,
712
712
  dironly: bool = None,
713
713
  recurse_symlinks: bool = False,
714
+ native: bool = True,
714
715
  ):
715
716
  """Iterate over this subtree and yield all existing files (of any
716
717
  kind, including directories) matching the given relative pattern.
@@ -724,6 +725,20 @@ class Path(Pathname, Chmod, Stat, BinaryOpen):
724
725
  `glob.NonRelativePatternError` (a `NotImplementedError` and a
725
726
  `ValueError`). `recurse_symlinks=True` is not supported.
726
727
 
728
+ `pattern=None` expands the pattern THIS PATH CARRIES
729
+ (`LocalPath("/etc/*.conf").glob(None)`) instead of applying one to a
730
+ directory: the path is split at its first wildcard and globbed from
731
+ there (`utils.glob.glob()`). `""` still raises, as pathlib does.
732
+
733
+ `native=True` (the default) follows the running interpreter on the
734
+ two rules pathlib changed mid-series: a trailing "/" is ignored
735
+ before 3.11, and a component that merely contains "**" ("a**")
736
+ raises `ValueError` before 3.13. `native=False` applies one rule on
737
+ every version -- trailing "/" always selects directories only, "a**"
738
+ is always a plain wildcard -- so a pattern answers the same
739
+ everywhere, which is what a cross-backend or cross-version caller
740
+ usually wants.
741
+
727
742
  A "**" component auto-enables recursion. Pass `recursive=False`
728
743
  explicitly to treat "**" as a plain "*" instead.
729
744
  Note for remote schemes (http/sftp): a recursive glob walks the
@@ -731,10 +746,21 @@ class Path(Pathname, Chmod, Stat, BinaryOpen):
731
746
  """
732
747
  if recurse_symlinks:
733
748
  raise NotImplementedError("glob(recurse_symlinks=True)")
749
+ if pattern is None:
750
+ # The pattern is the path itself; `glob()` splits at the first
751
+ # wildcard, so an absolute one is fine here (unlike a pattern
752
+ # argument, which must stay relative to self).
753
+ return _glob.glob(
754
+ self,
755
+ recursive=bool(recursive),
756
+ include_hidden=include_hidden,
757
+ case_sensitive=case_sensitive,
758
+ dironly=bool(dironly),
759
+ )
734
760
  # Validates eagerly (like pathlib 3.13+); the returned selection is
735
761
  # lazy. The pattern is never joined onto self: `self / pattern` let an
736
762
  # absolute pattern escape self and re-parsed "?" as a URI query.
737
- parts, trailing_sep = _glob.parse_pattern(pattern)
763
+ parts, trailing_sep = _glob.parse_pattern(pattern, native=native)
738
764
  if recursive is None:
739
765
  recursive = _glob.RECURSIVE in parts
740
766
  return _glob.select(
@@ -748,19 +774,33 @@ class Path(Pathname, Chmod, Stat, BinaryOpen):
748
774
 
749
775
  def rglob(
750
776
  self,
751
- pattern: str,
777
+ pattern: str | None,
752
778
  *,
753
779
  case_sensitive: bool = None,
754
780
  include_hidden: bool = True,
755
781
  recursive: bool = True,
756
782
  dironly: bool = None,
757
783
  recurse_symlinks: bool = False,
784
+ native: bool = True,
758
785
  ):
759
- """Equivalent to `glob(f"**/{pattern}", recursive=True)`."""
786
+ """Equivalent to `glob(f"**/{pattern}", recursive=True)`.
787
+
788
+ `pattern=None` and `native=` mean what they do on `glob()`; with
789
+ `None` this is `glob(None, recursive=True)`, since a carried pattern
790
+ brings its own anchor and nothing can be prefixed to it."""
791
+ if pattern is None:
792
+ return self.glob(
793
+ None,
794
+ case_sensitive=case_sensitive,
795
+ include_hidden=include_hidden,
796
+ recursive=recursive,
797
+ dironly=dironly,
798
+ recurse_symlinks=recurse_symlinks,
799
+ )
760
800
  if not (isinstance(pattern, str) and not pattern):
761
801
  # Reject an absolute pattern before "**/" hides its anchor;
762
802
  # glob() validates without listing anything.
763
- self.glob(pattern, recursive=False)
803
+ self.glob(pattern, recursive=False, native=native)
764
804
  return self.glob(
765
805
  f"**/{pattern}",
766
806
  case_sensitive=case_sensitive,
@@ -768,6 +808,7 @@ class Path(Pathname, Chmod, Stat, BinaryOpen):
768
808
  recursive=recursive,
769
809
  dironly=dironly,
770
810
  recurse_symlinks=recurse_symlinks,
811
+ native=native,
771
812
  )
772
813
 
773
814
  def walk(
@@ -1180,8 +1221,27 @@ class Path(Pathname, Chmod, Stat, BinaryOpen):
1180
1221
  raise FileExistsError(target)
1181
1222
  else:
1182
1223
  target.mkdir()
1224
+ windows_target = _utils.is_windows_flavoured(target)
1183
1225
  for child in children:
1184
1226
  try:
1227
+ # The names come from a listing the destination does not
1228
+ # control (an archive, a remote index, an object-store
1229
+ # key), so one that is not a single component inside
1230
+ # `target` must never be joined onto it: on a Windows
1231
+ # target "C:x" joins to a drive-relative path outside it
1232
+ # entirely, and so does "a\\b". Reported through
1233
+ # `ignore_error` like any other per-child failure, not
1234
+ # silently skipped. `PathSyncer` and
1235
+ # `utils.unpack_archive()` apply the same rule per
1236
+ # destination; an archive listing keeps such names,
1237
+ # because they are ordinary filenames on POSIX.
1238
+ if not _utils.is_safe_child_name(
1239
+ child.name, windows=windows_target
1240
+ ):
1241
+ raise ValueError(
1242
+ f"refusing unsafe child name {child.name!r} "
1243
+ f"under {target}"
1244
+ )
1185
1245
  child.copy(
1186
1246
  target / child.name,
1187
1247
  overwrite=overwrite,
@@ -239,8 +239,13 @@ class ReadPathContract(PurePathContract):
239
239
  "empty_dir",
240
240
  }
241
241
  assert {_rel(root, p) for p in root.glob("sub/*.py")} == {"sub/c.py"}
242
- # A trailing separator selects directories only.
243
- assert {_rel(root, p) for p in root.glob("*/")} == {"sub", "empty_dir"}
242
+ # A trailing separator selects directories only -- with `native=False`,
243
+ # the one rule for every interpreter. The default follows the running
244
+ # one, and pathlib ignored a trailing separator before 3.11.
245
+ assert {_rel(root, p) for p in root.glob("*/", native=False)} == {
246
+ "sub",
247
+ "empty_dir",
248
+ }
244
249
 
245
250
  def test_glob_recursive(self, root):
246
251
  self._require("supports_listing")