pathlib-next 0.9.8__tar.gz → 0.9.9__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.8 → pathlib_next-0.9.9}/CHANGELOG.md +33 -1
  2. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/PKG-INFO +1 -1
  3. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/docs/divergences.md +1 -1
  4. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/pyproject.toml +1 -1
  5. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/src/pathlib_next/AGENTS.md +10 -8
  6. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/src/pathlib_next/utils/glob.py +28 -18
  7. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/tests/test_glob_parity.py +59 -0
  8. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/tests/test_uri_path.py +8 -2
  9. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/.gitignore +0 -0
  10. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/AGENTS.md +0 -0
  11. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/LICENSE +0 -0
  12. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/README.md +0 -0
  13. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/docs/api/cli.md +0 -0
  14. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/docs/api/mempath.md +0 -0
  15. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/docs/api/path.md +0 -0
  16. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/docs/api/protocols.md +0 -0
  17. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/docs/api/schemes/archive.md +0 -0
  18. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/docs/api/schemes/ftp.md +0 -0
  19. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/docs/api/schemes/git.md +0 -0
  20. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/docs/api/schemes/http.md +0 -0
  21. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/docs/api/schemes/local.md +0 -0
  22. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/docs/api/schemes/objstore.md +0 -0
  23. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/docs/api/schemes/sftp.md +0 -0
  24. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/docs/api/testing.md +0 -0
  25. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/docs/api/uri.md +0 -0
  26. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/docs/api/utils.md +0 -0
  27. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/docs/benchmarks.md +0 -0
  28. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/docs/changelog.md +0 -0
  29. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/docs/guides/cli.md +0 -0
  30. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/docs/guides/extending.md +0 -0
  31. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/docs/guides/schemes.md +0 -0
  32. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/docs/index.md +0 -0
  33. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/examples/az_listing.py +0 -0
  34. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/examples/data_and_archive.py +0 -0
  35. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/examples/ftp_listing.py +0 -0
  36. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/examples/github_listing.py +0 -0
  37. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/examples/gitlab_listing.py +0 -0
  38. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/examples/gs_listing.py +0 -0
  39. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/examples/http_listing.py +0 -0
  40. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/examples/local_and_mem.py +0 -0
  41. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/examples/s3_listing.py +0 -0
  42. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/examples/sftp_sync.py +0 -0
  43. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/examples/webdav_roundtrip.py +0 -0
  44. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/mkdocs.yml +0 -0
  45. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/src/pathlib_next/__init__.py +0 -0
  46. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/src/pathlib_next/fspath.py +0 -0
  47. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/src/pathlib_next/mempath.py +0 -0
  48. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/src/pathlib_next/path.py +0 -0
  49. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/src/pathlib_next/protocols/__init__.py +0 -0
  50. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/src/pathlib_next/protocols/checksum.py +0 -0
  51. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/src/pathlib_next/protocols/fs.py +0 -0
  52. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/src/pathlib_next/protocols/io.py +0 -0
  53. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/src/pathlib_next/py.typed +0 -0
  54. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/src/pathlib_next/testing.py +0 -0
  55. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/src/pathlib_next/tools/__init__.py +0 -0
  56. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/src/pathlib_next/tools/uripath.py +0 -0
  57. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/src/pathlib_next/uri/__init__.py +0 -0
  58. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/src/pathlib_next/uri/query.py +0 -0
  59. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/__init__.py +0 -0
  60. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/_gitrepo.py +0 -0
  61. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/archive/__init__.py +0 -0
  62. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/archive/_base.py +0 -0
  63. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/archive/tar.py +0 -0
  64. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/archive/zip.py +0 -0
  65. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/az.py +0 -0
  66. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/data.py +0 -0
  67. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/dav.py +0 -0
  68. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/file.py +0 -0
  69. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/ftp.py +0 -0
  70. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/git/__init__.py +0 -0
  71. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/git/_base.py +0 -0
  72. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/git/github.py +0 -0
  73. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/git/gitlab.py +0 -0
  74. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/github.py +0 -0
  75. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/gitlab.py +0 -0
  76. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/gs.py +0 -0
  77. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/http.py +0 -0
  78. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/s3.py +0 -0
  79. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/sftp/__init__.py +0 -0
  80. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/sftp/_asyncssh.py +0 -0
  81. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/sftp/_paramiko.py +0 -0
  82. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/sftp/_sshconfig.py +0 -0
  83. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/src/pathlib_next/uri/source.py +0 -0
  84. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/src/pathlib_next/utils/__init__.py +0 -0
  85. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/src/pathlib_next/utils/archive.py +0 -0
  86. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/src/pathlib_next/utils/checksum.py +0 -0
  87. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/src/pathlib_next/utils/stat.py +0 -0
  88. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/src/pathlib_next/utils/sync.py +0 -0
  89. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/tests/conftest.py +0 -0
  90. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/tests/test_archive_parity.py +0 -0
  91. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/tests/test_archive_safety.py +0 -0
  92. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/tests/test_archive_uri.py +0 -0
  93. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/tests/test_az.py +0 -0
  94. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/tests/test_az_fake.py +0 -0
  95. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/tests/test_checksum.py +0 -0
  96. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/tests/test_contract.py +0 -0
  97. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/tests/test_contract_helpers.py +0 -0
  98. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/tests/test_data_uri.py +0 -0
  99. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/tests/test_dav.py +0 -0
  100. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/tests/test_destructive_safety.py +0 -0
  101. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/tests/test_ftp.py +0 -0
  102. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/tests/test_ftp_objstore_parity.py +0 -0
  103. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/tests/test_gitrepo.py +0 -0
  104. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/tests/test_glob.py +0 -0
  105. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/tests/test_gs.py +0 -0
  106. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/tests/test_gs_fake.py +0 -0
  107. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/tests/test_http.py +0 -0
  108. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/tests/test_http_live.py +0 -0
  109. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/tests/test_http_parser.py +0 -0
  110. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/tests/test_httpdav_safety.py +0 -0
  111. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/tests/test_io_parity.py +0 -0
  112. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/tests/test_local.py +0 -0
  113. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/tests/test_low_core.py +0 -0
  114. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/tests/test_low_schemes.py +0 -0
  115. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/tests/test_low_sync.py +0 -0
  116. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/tests/test_mempath.py +0 -0
  117. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/tests/test_mro_precedence.py +0 -0
  118. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/tests/test_parity_io.py +0 -0
  119. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/tests/test_parity_pure.py +0 -0
  120. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/tests/test_path_gaps.py +0 -0
  121. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/tests/test_pathname.py +0 -0
  122. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/tests/test_plugins.py +0 -0
  123. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/tests/test_properties.py +0 -0
  124. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/tests/test_pure_parity.py +0 -0
  125. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/tests/test_query.py +0 -0
  126. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/tests/test_routing.py +0 -0
  127. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/tests/test_s3.py +0 -0
  128. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/tests/test_sftp.py +0 -0
  129. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/tests/test_sftp_asyncssh.py +0 -0
  130. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/tests/test_sftp_transport.py +0 -0
  131. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/tests/test_smoke.py +0 -0
  132. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/tests/test_source.py +0 -0
  133. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/tests/test_sync.py +0 -0
  134. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/tests/test_sync_safety.py +0 -0
  135. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/tests/test_sync_sftp.py +0 -0
  136. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/tests/test_sync_sftp_parity.py +0 -0
  137. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/tests/test_transport_security.py +0 -0
  138. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/tests/test_uri_core_parity.py +0 -0
  139. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/tests/test_uri_parse.py +0 -0
  140. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/tests/test_uripath_tool.py +0 -0
  141. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/tests/test_utils.py +0 -0
  142. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/tests/test_walk.py +0 -0
  143. {pathlib_next-0.9.8 → pathlib_next-0.9.9}/tests/test_webdav.py +0 -0
@@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.9] - 2026-09-17
11
+
12
+ ### Changed
13
+ - **CI gains a URI-only job** (`test.yml`): the package installed with just
14
+ the `uri` extra and no scheme client at all, running the modules that must
15
+ work without one plus an explicit check that a pure-path join imports
16
+ nothing. Every other job installs all extras, which is why a join that
17
+ built a backend -- and so needed paramiko to spell an `sftp:` path --
18
+ survived from 0.9.3 to 0.9.8 behind a green matrix.
19
+
20
+ ### Fixed
21
+ - **`glob(bound_loops=True)` no longer drops a directory shared under two
22
+ names.** It bounded on every identity seen during the walk, so one
23
+ directory junctioned in as both `site-a` and `site-b` -- a deliberate
24
+ layout, and not a loop -- expanded under the first name only, silently. A
25
+ loop is a directory reachable BELOW ITSELF, so the bound is now the
26
+ current descent path rather than everything seen, which is the line
27
+ `find -L` draws. The loop case is unchanged: 128 matches become 2.
28
+ Reported by yaconfiglib against 0.9.7.
29
+
30
+ ### Documentation
31
+ - **Corrected the 0.9.8 entry's provenance.** It says 0.9.7 introduced the
32
+ join-builds-a-backend defect; it did not. Measured against the published
33
+ wheels in a venv with only the `uri` extra: `UriPath("sftp://h/mnt") / "c"`
34
+ raises `ImportError` on **0.9.3 and 0.9.6** as well, so the defect predates
35
+ the 0.9.7 join rewrite, which preserved it rather than causing it. 0.9.8
36
+ fixes it for the first time. (A related and DELIBERATE behaviour, unchanged
37
+ throughout: constructing an `http:`/`s3:` path at all requires that
38
+ scheme's extra, because the scheme module imports its client -- see the
39
+ extras table in the shipped header.)
40
+
10
41
  ## [0.9.8] - 2026-09-17
11
42
 
12
43
  ### Fixed
@@ -1462,7 +1493,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
1462
1493
  - Sync error handling.
1463
1494
  - Generic Path Protocol based pathlib implementation for URI paths with file access support for sftp, http, file schemes.
1464
1495
 
1465
- [Unreleased]: https://github.com/jose-pr/pathlib-next/compare/v0.9.8...HEAD
1496
+ [Unreleased]: https://github.com/jose-pr/pathlib-next/compare/v0.9.9...HEAD
1497
+ [0.9.9]: https://github.com/jose-pr/pathlib-next/compare/v0.9.8...v0.9.9
1466
1498
  [0.9.8]: https://github.com/jose-pr/pathlib-next/compare/v0.9.7...v0.9.8
1467
1499
  [0.9.7]: https://github.com/jose-pr/pathlib-next/compare/v0.9.6...v0.9.7
1468
1500
  [0.9.6]: https://github.com/jose-pr/pathlib-next/compare/v0.9.5...v0.9.6
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pathlib-next
3
- Version: 0.9.8
3
+ Version: 0.9.9
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/
@@ -57,7 +57,7 @@ these operations itself always keeps its own implementation.
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
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
59
  | `Path.glob(on_error=)` / `rglob(on_error=)` | `pathlib` skips an unreadable directory silently, with no hook | Same default, plus a hook: `on_error(error)` is called per failed listing (the contract `walk()` and `os.walk` use), so a caller can log it or raise it. `error.filename` names the directory. | "Silently nothing" is the worst answer for a caller assembling something from a tree -- a configuration layer, a source list -- because the result is incomplete and nothing says so. The default stays pathlib's. |
60
- | `Path.glob(bound_loops=)` / `rglob(bound_loops=)` | `pathlib` walks a directory loop until the recursion limit (and on Windows/3.9 eventually raises `WinError 1921` on the over-long path) | `bound_loops=True` descends a directory at most once per `**`, keyed on `(st_dev, st_ino)` and seeded with the starting directory; a directory reached a second way is skipped. Default `False` keeps pathlib's behaviour. | A Windows junction reports `is_symlink() == False`, so `recurse_symlinks=False` cannot see it and an ordinary config tree with a junction back into itself matched one file 64 times. Identity is the only signal that works; backends whose stats lack it are unaffected. |
60
+ | `Path.glob(bound_loops=)` / `rglob(bound_loops=)` | `pathlib` walks a directory loop until the recursion limit (and on Windows/3.9 eventually raises `WinError 1921` on the over-long path) | `bound_loops=True` skips a directory whose `(st_dev, st_ino)` is already on the current descent path -- reachable below itself, which is what a loop is. A directory deliberately reachable under two sibling names still expands under both. Default `False` keeps pathlib's behaviour. | A Windows junction reports `is_symlink() == False`, so `recurse_symlinks=False` cannot see it and an ordinary config tree with a junction back into itself matched one file 128 times (and failed outright at `WinError 1921` on the path length). Identity is the only signal that works, and the ancestor chain is the part of it that means "loop" -- keying on everything seen also dropped a shared layer junctioned in twice. Backends whose stats lack identity are unaffected. |
61
61
  | `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. |
62
62
  | `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. |
63
63
  | `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. |
@@ -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.8"
16
+ version = "0.9.9"
17
17
  authors = [{ name = "Jose A" }]
18
18
  description = "Generic Path Protocol based pathlib"
19
19
  readme = "README.md"
@@ -103,14 +103,16 @@ silently absent and `from pathlib_next.uri import UriPath` raises
103
103
  skipped in silence (pathlib's behaviour), so a caller could not tell
104
104
  an unreadable directory from an absent one. `error.filename` names the
105
105
  directory even when the backend left it unset.
106
- - **`bound_loops=True`** descends a directory at most once per `**`,
107
- keyed on `(st_dev, st_ino)` and seeded with the starting directory. It
108
- bounds a Windows junction loop, which `recurse_symlinks=False` cannot
109
- (a junction reports `is_symlink() == False`) and which `pathlib`
110
- itself walks until the recursion limit. A directory reached a second
111
- way is skipped entirely, not just not descended. A backend whose stat
112
- carries no identity (`MemPath`, most remote schemes) is walked
113
- unbounded, as before.
106
+ - **`bound_loops=True`** skips a directory whose `(st_dev, st_ino)` is
107
+ already on the CURRENT DESCENT PATH — a directory reachable below
108
+ itself, which is what a loop is. It bounds a Windows junction loop,
109
+ which `recurse_symlinks=False` cannot (a junction reports
110
+ `is_symlink() == False`) and which `pathlib` itself walks until the
111
+ recursion limit. One directory deliberately reachable under two
112
+ SIBLING names (a shared layer junctioned in twice) is not a loop and
113
+ both names expand — the rule is the ancestor chain, not everything
114
+ seen, the same line `find -L` draws. A backend whose stat carries no
115
+ identity (`MemPath`, most remote schemes) is walked unbounded.
114
116
  - **`native=True`** (default) follows the running interpreter on the two
115
117
  rules pathlib changed mid-series: a trailing `/` is ignored before 3.11
116
118
  and selects directories only from 3.11; `a**` raises `ValueError`
@@ -337,39 +337,49 @@ def _recurse(
337
337
  """Yield `top` and every directory below it (plus every other entry when
338
338
  `with_files`), never descending through a directory symlink.
339
339
 
340
- With `bound_loops`, a directory is descended at most once per `**`,
341
- keyed on `(st_dev, st_ino)` and seeded with `top`: that bounds a Windows
340
+ With `bound_loops`, a directory whose identity (`st_dev`, `st_ino`) is
341
+ already on the CURRENT DESCENT PATH is skipped: that is what a loop is
342
+ -- a directory reachable below itself -- and it bounds a Windows
342
343
  junction loop, which no symlink check can see (a junction reports
343
- `is_symlink() == False`). The entry itself is still yielded -- it exists
344
- -- only the descent is skipped. A backend whose stat has no identity
345
- cannot be bounded this way and is walked as before.
344
+ `is_symlink() == False`).
345
+
346
+ The ancestor chain is the whole rule, not a set of everything seen. One
347
+ directory deliberately reachable under two SIBLING names (a shared
348
+ config layer junctioned in as `site-a` and `site-b`) is not a loop: the
349
+ walk terminates, and both names must expand. A `visited` set spanning
350
+ the traversal dropped the second one silently. `find -L` draws the line
351
+ in the same place.
352
+
353
+ A backend whose stat has no identity cannot be bounded this way and is
354
+ walked as before.
346
355
  """
347
356
  yield top
348
- visited = set()
349
- if bound_loops:
350
- key = _identity(top)
351
- if key is not None:
352
- visited.add(key)
353
- stack = [top]
357
+ top_key = _identity(top) if bound_loops else None
358
+ # Each stack entry carries the identities of the directories the walk is
359
+ # currently inside -- pushed on descent, dropped with the branch.
360
+ stack = [(top, (top_key,) if top_key is not None else ())]
354
361
  while stack:
355
- directory = stack.pop()
362
+ directory, ancestors = stack.pop()
356
363
  for child, stat in _scan(directory, on_error):
357
364
  if not include_hidden and child.is_hidden():
358
365
  continue
359
366
  is_dir = _entry_is_dir(child, stat, follow_symlinks=False)
367
+ child_ancestors = ancestors
360
368
  if is_dir and bound_loops:
361
369
  key = _identity(child)
362
370
  if key is not None:
363
- if key in visited:
364
- # Already walked under another name: a junction back
365
- # into the tree, or a second link to one directory.
366
- # Yielding it too would repeat everything under it.
371
+ if key in ancestors:
372
+ # The child IS one of its own ancestors: descending
373
+ # would walk the same tree again, without end.
374
+ # Skipped entirely -- yielding it would let the next
375
+ # pattern component match everything under it a
376
+ # second time, through the loop.
367
377
  continue
368
- visited.add(key)
378
+ child_ancestors = ancestors + (key,)
369
379
  if is_dir or with_files:
370
380
  yield child
371
381
  if is_dir:
372
- stack.append(child)
382
+ stack.append((child, child_ancestors))
373
383
 
374
384
 
375
385
  def _scan(directory: _Globable, on_error=None):
@@ -479,3 +479,62 @@ def test_bound_loops_is_accepted_where_stats_have_no_identity():
479
479
  (mem / "s").mkdir()
480
480
  (mem / "s" / "x.py").write_text("x")
481
481
  assert [str(p) for p in mem.glob("**/*.py", bound_loops=True)] == ["/m/s/x.py"]
482
+
483
+
484
+ def _junction(link, target):
485
+ """`mklink /J` returns 0 without elevation, unlike `os.symlink`."""
486
+ import subprocess
487
+
488
+ subprocess.run(
489
+ ["cmd", "/d", "/c", "mklink", "/J", str(link), str(target)],
490
+ check=True,
491
+ capture_output=True,
492
+ )
493
+
494
+
495
+ @pytest.mark.skipif(os.name != "nt", reason="junctions are Windows")
496
+ def test_bound_loops_keeps_a_directory_shared_under_two_names(tmp_path):
497
+ """A loop is a directory reachable BELOW ITSELF -- not one reachable
498
+ twice. Two sibling junctions onto one shared directory (a common config
499
+ layer linked in as `site-a` and `site-b`) is a deliberate layout that
500
+ terminates on its own, and both names must expand. Keying the bound on
501
+ everything seen dropped the second one silently."""
502
+ conf = tmp_path / "conf"
503
+ conf.mkdir()
504
+ (conf / "own.yaml").write_text("own")
505
+ shared = tmp_path / "shared"
506
+ shared.mkdir()
507
+ (shared / "common.yaml").write_text("common")
508
+ _junction(conf / "site-a", shared)
509
+ _junction(conf / "site-b", shared)
510
+
511
+ base = pathlib_next.LocalPath(tmp_path)
512
+ expected = sorted(
513
+ str(q.relative_to(tmp_path)).replace("\\", "/")
514
+ for q in base.glob("conf/**/*.yaml")
515
+ )
516
+ bounded = sorted(
517
+ str(q.relative_to(tmp_path)).replace("\\", "/")
518
+ for q in base.glob("conf/**/*.yaml", bound_loops=True)
519
+ )
520
+ assert "conf/site-b/common.yaml" in expected # the default finds both
521
+ assert bounded == expected # and so does the bounded walk
522
+
523
+
524
+ @pytest.mark.skipif(os.name != "nt", reason="junctions are Windows")
525
+ def test_bound_loops_still_bounds_a_loop_back_to_an_ancestor(tmp_path):
526
+ """The case the flag exists for: unbounded, the two real files are
527
+ matched 128 times (and a deeper walk fails outright on the path
528
+ length)."""
529
+ conf = tmp_path / "conf"
530
+ (conf / "deep").mkdir(parents=True)
531
+ (conf / "10-base.yaml").write_text("a")
532
+ (conf / "deep" / "20-extra.yaml").write_text("b")
533
+ _junction(conf / "deep" / "loop", conf)
534
+
535
+ base = pathlib_next.LocalPath(tmp_path)
536
+ assert len(list(base.glob("conf/**/*.yaml"))) > 2
537
+ assert sorted(q.name for q in base.glob("conf/**/*.yaml", bound_loops=True)) == [
538
+ "10-base.yaml",
539
+ "20-extra.yaml",
540
+ ]
@@ -365,7 +365,13 @@ def test_joining_a_path_never_builds_a_backend():
365
365
  property builds one, so spelling `UriPath("sftp://h/x") / "y"` imported
366
366
  paramiko and raised ImportError without the extra. Every scheme, not
367
367
  just the ones whose client happens to be installed."""
368
- for uri in ("sftp://h/mnt", "http://h/a", "s3://b/k", "file:///tmp/x"):
368
+ # Schemes whose class module does not import a client at import time.
369
+ # (`http:`/`s3:` cannot even be CONSTRUCTED without their extra -- that
370
+ # is deliberate and documented, and a separate matter from joining.)
371
+ # (`zip:`/`tar:` are left out on purpose: their "backend" is the shared
372
+ # archive handle from the registry, stdlib-only and opened lazily, so it
373
+ # is attached at construction and a join inherits it.)
374
+ for uri in ("sftp://h/mnt", "file:///tmp/x", "data:,abc"):
369
375
  base = UriPath(uri)
370
376
  child = base / "child"
371
377
  assert child.path.endswith("/child")
@@ -377,7 +383,7 @@ def test_joining_a_path_never_builds_a_backend():
377
383
  def test_joining_shares_a_backend_that_already_exists():
378
384
  """What the property read was there for: children of a listing share the
379
385
  parent's live connection. That still holds -- by then it exists."""
380
- base = UriPath("http://h/a")
386
+ base = UriPath("sftp://h/mnt")
381
387
  sentinel = object()
382
388
  base._backend = sentinel
383
389
  assert (base / "c")._backend is sentinel
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes