pathlib-next 0.9.7__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.7 → pathlib_next-0.9.9}/CHANGELOG.md +46 -1
  2. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/PKG-INFO +1 -1
  3. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/docs/divergences.md +1 -1
  4. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/pyproject.toml +1 -1
  5. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/AGENTS.md +10 -8
  6. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/uri/__init__.py +7 -1
  7. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/utils/glob.py +28 -18
  8. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_glob_parity.py +59 -0
  9. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_routing.py +7 -1
  10. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_uri_path.py +32 -0
  11. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/.gitignore +0 -0
  12. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/AGENTS.md +0 -0
  13. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/LICENSE +0 -0
  14. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/README.md +0 -0
  15. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/docs/api/cli.md +0 -0
  16. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/docs/api/mempath.md +0 -0
  17. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/docs/api/path.md +0 -0
  18. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/docs/api/protocols.md +0 -0
  19. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/docs/api/schemes/archive.md +0 -0
  20. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/docs/api/schemes/ftp.md +0 -0
  21. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/docs/api/schemes/git.md +0 -0
  22. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/docs/api/schemes/http.md +0 -0
  23. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/docs/api/schemes/local.md +0 -0
  24. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/docs/api/schemes/objstore.md +0 -0
  25. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/docs/api/schemes/sftp.md +0 -0
  26. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/docs/api/testing.md +0 -0
  27. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/docs/api/uri.md +0 -0
  28. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/docs/api/utils.md +0 -0
  29. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/docs/benchmarks.md +0 -0
  30. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/docs/changelog.md +0 -0
  31. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/docs/guides/cli.md +0 -0
  32. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/docs/guides/extending.md +0 -0
  33. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/docs/guides/schemes.md +0 -0
  34. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/docs/index.md +0 -0
  35. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/examples/az_listing.py +0 -0
  36. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/examples/data_and_archive.py +0 -0
  37. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/examples/ftp_listing.py +0 -0
  38. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/examples/github_listing.py +0 -0
  39. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/examples/gitlab_listing.py +0 -0
  40. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/examples/gs_listing.py +0 -0
  41. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/examples/http_listing.py +0 -0
  42. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/examples/local_and_mem.py +0 -0
  43. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/examples/s3_listing.py +0 -0
  44. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/examples/sftp_sync.py +0 -0
  45. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/examples/webdav_roundtrip.py +0 -0
  46. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/mkdocs.yml +0 -0
  47. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/__init__.py +0 -0
  48. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/fspath.py +0 -0
  49. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/mempath.py +0 -0
  50. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/path.py +0 -0
  51. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/protocols/__init__.py +0 -0
  52. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/protocols/checksum.py +0 -0
  53. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/protocols/fs.py +0 -0
  54. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/protocols/io.py +0 -0
  55. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/py.typed +0 -0
  56. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/testing.py +0 -0
  57. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/tools/__init__.py +0 -0
  58. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/tools/uripath.py +0 -0
  59. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/uri/query.py +0 -0
  60. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/__init__.py +0 -0
  61. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/_gitrepo.py +0 -0
  62. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/archive/__init__.py +0 -0
  63. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/archive/_base.py +0 -0
  64. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/archive/tar.py +0 -0
  65. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/archive/zip.py +0 -0
  66. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/az.py +0 -0
  67. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/data.py +0 -0
  68. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/dav.py +0 -0
  69. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/file.py +0 -0
  70. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/ftp.py +0 -0
  71. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/git/__init__.py +0 -0
  72. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/git/_base.py +0 -0
  73. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/git/github.py +0 -0
  74. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/git/gitlab.py +0 -0
  75. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/github.py +0 -0
  76. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/gitlab.py +0 -0
  77. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/gs.py +0 -0
  78. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/http.py +0 -0
  79. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/s3.py +0 -0
  80. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/sftp/__init__.py +0 -0
  81. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/sftp/_asyncssh.py +0 -0
  82. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/sftp/_paramiko.py +0 -0
  83. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/uri/schemes/sftp/_sshconfig.py +0 -0
  84. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/uri/source.py +0 -0
  85. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/utils/__init__.py +0 -0
  86. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/utils/archive.py +0 -0
  87. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/utils/checksum.py +0 -0
  88. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/utils/stat.py +0 -0
  89. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/src/pathlib_next/utils/sync.py +0 -0
  90. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/conftest.py +0 -0
  91. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_archive_parity.py +0 -0
  92. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_archive_safety.py +0 -0
  93. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_archive_uri.py +0 -0
  94. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_az.py +0 -0
  95. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_az_fake.py +0 -0
  96. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_checksum.py +0 -0
  97. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_contract.py +0 -0
  98. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_contract_helpers.py +0 -0
  99. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_data_uri.py +0 -0
  100. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_dav.py +0 -0
  101. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_destructive_safety.py +0 -0
  102. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_ftp.py +0 -0
  103. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_ftp_objstore_parity.py +0 -0
  104. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_gitrepo.py +0 -0
  105. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_glob.py +0 -0
  106. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_gs.py +0 -0
  107. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_gs_fake.py +0 -0
  108. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_http.py +0 -0
  109. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_http_live.py +0 -0
  110. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_http_parser.py +0 -0
  111. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_httpdav_safety.py +0 -0
  112. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_io_parity.py +0 -0
  113. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_local.py +0 -0
  114. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_low_core.py +0 -0
  115. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_low_schemes.py +0 -0
  116. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_low_sync.py +0 -0
  117. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_mempath.py +0 -0
  118. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_mro_precedence.py +0 -0
  119. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_parity_io.py +0 -0
  120. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_parity_pure.py +0 -0
  121. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_path_gaps.py +0 -0
  122. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_pathname.py +0 -0
  123. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_plugins.py +0 -0
  124. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_properties.py +0 -0
  125. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_pure_parity.py +0 -0
  126. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_query.py +0 -0
  127. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_s3.py +0 -0
  128. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_sftp.py +0 -0
  129. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_sftp_asyncssh.py +0 -0
  130. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_sftp_transport.py +0 -0
  131. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_smoke.py +0 -0
  132. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_source.py +0 -0
  133. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_sync.py +0 -0
  134. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_sync_safety.py +0 -0
  135. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_sync_sftp.py +0 -0
  136. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_sync_sftp_parity.py +0 -0
  137. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_transport_security.py +0 -0
  138. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_uri_core_parity.py +0 -0
  139. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_uri_parse.py +0 -0
  140. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_uripath_tool.py +0 -0
  141. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_utils.py +0 -0
  142. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_walk.py +0 -0
  143. {pathlib_next-0.9.7 → pathlib_next-0.9.9}/tests/test_webdav.py +0 -0
@@ -7,6 +7,49 @@ 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
+
41
+ ## [0.9.8] - 2026-09-17
42
+
43
+ ### Fixed
44
+ - **Joining a URI path no longer builds a backend.** 0.9.7 routed `/` and
45
+ `joinpath()` through the child builder a listing uses, which reads the
46
+ `backend` property -- and that property CREATES one, so spelling
47
+ `UriPath("sftp://h/x") / "y"` imported paramiko and raised `ImportError`
48
+ without the extra (`http:` wanted `requests`, `s3:` `botocore`). A join
49
+ is a pure-path operation and is lazy again; a child still shares its
50
+ parent's connection when one already exists, which is all the sharing was
51
+ ever for.
52
+
10
53
  ## [0.9.7] - 2026-09-17
11
54
 
12
55
  ### Added
@@ -1450,7 +1493,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
1450
1493
  - Sync error handling.
1451
1494
  - Generic Path Protocol based pathlib implementation for URI paths with file access support for sftp, http, file schemes.
1452
1495
 
1453
- [Unreleased]: https://github.com/jose-pr/pathlib-next/compare/v0.9.7...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
1498
+ [0.9.8]: https://github.com/jose-pr/pathlib-next/compare/v0.9.7...v0.9.8
1454
1499
  [0.9.7]: https://github.com/jose-pr/pathlib-next/compare/v0.9.6...v0.9.7
1455
1500
  [0.9.6]: https://github.com/jose-pr/pathlib-next/compare/v0.9.5...v0.9.6
1456
1501
  [0.9.5]: https://github.com/jose-pr/pathlib-next/compare/v0.9.4...v0.9.5
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pathlib-next
3
- Version: 0.9.7
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.7"
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`
@@ -1149,7 +1149,13 @@ class UriPath(Uri, Path):
1149
1149
  def _make_child_relpath(
1150
1150
  self, name: str, stat_hint: "FileStat" = None, **kwargs
1151
1151
  ) -> _ty.Self:
1152
- inst = super()._make_child_relpath(name, backend=self.backend, **kwargs)
1152
+ # The backend ALREADY BUILT, never `self.backend` -- that property
1153
+ # creates one, and naming a child is a pure-path operation: since
1154
+ # `/` walks segments through here, reading the property made
1155
+ # `UriPath("sftp://h/x") / "y"` import paramiko (and `http:` import
1156
+ # requests) just to spell a path. A listing has one by the time it
1157
+ # gets here, so its children still share the instance.
1158
+ inst = super()._make_child_relpath(name, backend=self._backend, **kwargs)
1153
1159
  inst._stat_hint = stat_hint
1154
1160
  return inst
1155
1161
 
@@ -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
+ ]
@@ -74,7 +74,13 @@ def test_github_token_backend_does_not_follow_join_to_another_host():
74
74
  # A str join stays on the original host, so the token goes nowhere new.
75
75
  literal = root / "github://evil.invalid/x/y"
76
76
  assert literal.source.host == "github.com"
77
- assert literal.backend is root.backend
77
+ assert literal.backend.token == "ghp_SECRET"
78
+ # Identity is only promised when the parent already HAS a backend: a
79
+ # join must not build one (it is a pure-path operation), so a child of a
80
+ # connectionless parent makes its own on first use.
81
+ assert root._backend is None
82
+ root.backend # now it exists, and the next child shares it
83
+ assert (root / "docs").backend is root.backend
78
84
 
79
85
  # Crossing hosts needs a Uri argument, and that drops the token.
80
86
  evil = root / GitHubPath("github://evil.invalid/x/y")
@@ -357,3 +357,35 @@ def test_a_same_endpoint_uri_destination_keeps_the_configured_backend():
357
357
  base._coerce_target("http://trusted.invalid/api/b.txt").backend is base.backend
358
358
  )
359
359
  assert base._coerce_target("http://other.invalid/b.txt").backend is not base.backend
360
+
361
+
362
+ def test_joining_a_path_never_builds_a_backend():
363
+ """A join is a PURE-PATH operation. 0.9.7 routed `/` through
364
+ `_make_child_relpath()`, which read the `backend` property -- and that
365
+ property builds one, so spelling `UriPath("sftp://h/x") / "y"` imported
366
+ paramiko and raised ImportError without the extra. Every scheme, not
367
+ just the ones whose client happens to be installed."""
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"):
375
+ base = UriPath(uri)
376
+ child = base / "child"
377
+ assert child.path.endswith("/child")
378
+ assert child._backend is None, uri
379
+ assert base._backend is None, uri
380
+ assert base.joinpath("a", "b")._backend is None, uri
381
+
382
+
383
+ def test_joining_shares_a_backend_that_already_exists():
384
+ """What the property read was there for: children of a listing share the
385
+ parent's live connection. That still holds -- by then it exists."""
386
+ base = UriPath("sftp://h/mnt")
387
+ sentinel = object()
388
+ base._backend = sentinel
389
+ assert (base / "c")._backend is sentinel
390
+ assert base.joinpath("c", "d")._backend is sentinel
391
+ assert base._make_child_relpath("c")._backend is sentinel
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes