pathlib-next 0.9.3__tar.gz → 0.9.4__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 (181) hide show
  1. pathlib_next-0.9.4/.gitignore +43 -0
  2. pathlib_next-0.9.4/AGENTS.md +116 -0
  3. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/CHANGELOG.md +453 -5
  4. pathlib_next-0.9.4/PKG-INFO +283 -0
  5. pathlib_next-0.9.4/README.md +215 -0
  6. pathlib_next-0.9.4/docs/api/cli.md +6 -0
  7. pathlib_next-0.9.4/docs/api/mempath.md +6 -0
  8. pathlib_next-0.9.4/docs/api/protocols.md +11 -0
  9. pathlib_next-0.9.4/docs/api/schemes/archive.md +11 -0
  10. pathlib_next-0.9.4/docs/api/schemes/ftp.md +9 -0
  11. pathlib_next-0.9.4/docs/api/schemes/git.md +26 -0
  12. pathlib_next-0.9.4/docs/api/schemes/http.md +17 -0
  13. pathlib_next-0.9.4/docs/api/schemes/local.md +8 -0
  14. pathlib_next-0.9.4/docs/api/schemes/objstore.md +30 -0
  15. pathlib_next-0.9.4/docs/api/schemes/sftp.md +14 -0
  16. pathlib_next-0.9.4/docs/api/uri.md +19 -0
  17. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/docs/api/utils.md +9 -2
  18. pathlib_next-0.9.4/docs/benchmarks.md +249 -0
  19. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/docs/divergences.md +36 -17
  20. pathlib_next-0.9.4/docs/guides/cli.md +55 -0
  21. pathlib_next-0.9.4/docs/guides/extending.md +200 -0
  22. pathlib_next-0.9.4/docs/guides/schemes.md +248 -0
  23. pathlib_next-0.9.4/docs/index.md +110 -0
  24. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/examples/az_listing.py +4 -1
  25. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/examples/data_and_archive.py +6 -5
  26. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/examples/ftp_listing.py +20 -8
  27. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/examples/github_listing.py +5 -1
  28. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/examples/gitlab_listing.py +5 -1
  29. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/examples/gs_listing.py +4 -1
  30. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/examples/http_listing.py +7 -6
  31. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/examples/local_and_mem.py +1 -0
  32. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/examples/s3_listing.py +5 -1
  33. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/examples/sftp_sync.py +19 -7
  34. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/examples/webdav_roundtrip.py +27 -17
  35. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/mkdocs.yml +11 -1
  36. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/pyproject.toml +25 -8
  37. pathlib_next-0.9.4/src/pathlib_next/AGENTS.md +653 -0
  38. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/src/pathlib_next/fspath.py +195 -12
  39. pathlib_next-0.9.4/src/pathlib_next/mempath.py +349 -0
  40. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/src/pathlib_next/path.py +584 -100
  41. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/src/pathlib_next/protocols/checksum.py +2 -2
  42. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/src/pathlib_next/protocols/fs.py +12 -8
  43. pathlib_next-0.9.4/src/pathlib_next/protocols/io.py +198 -0
  44. pathlib_next-0.9.4/src/pathlib_next/testing.py +557 -0
  45. pathlib_next-0.9.4/src/pathlib_next/tools/uripath.py +360 -0
  46. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/src/pathlib_next/uri/__init__.py +272 -48
  47. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/src/pathlib_next/uri/query.py +10 -2
  48. pathlib_next-0.9.4/src/pathlib_next/uri/schemes/__init__.py +72 -0
  49. pathlib_next-0.9.4/src/pathlib_next/uri/schemes/_gitrepo.py +255 -0
  50. pathlib_next-0.9.4/src/pathlib_next/uri/schemes/archive/_base.py +541 -0
  51. pathlib_next-0.9.4/src/pathlib_next/uri/schemes/archive/tar.py +89 -0
  52. pathlib_next-0.9.4/src/pathlib_next/uri/schemes/archive/zip.py +346 -0
  53. pathlib_next-0.9.4/src/pathlib_next/uri/schemes/az.py +544 -0
  54. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/src/pathlib_next/uri/schemes/data.py +24 -9
  55. pathlib_next-0.9.4/src/pathlib_next/uri/schemes/dav.py +397 -0
  56. pathlib_next-0.9.4/src/pathlib_next/uri/schemes/file.py +136 -0
  57. pathlib_next-0.9.4/src/pathlib_next/uri/schemes/ftp.py +593 -0
  58. pathlib_next-0.9.4/src/pathlib_next/uri/schemes/git/_base.py +50 -0
  59. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/src/pathlib_next/uri/schemes/github.py +70 -24
  60. pathlib_next-0.9.4/src/pathlib_next/uri/schemes/gitlab.py +230 -0
  61. pathlib_next-0.9.4/src/pathlib_next/uri/schemes/gs.py +419 -0
  62. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/src/pathlib_next/uri/schemes/http.py +383 -69
  63. pathlib_next-0.9.4/src/pathlib_next/uri/schemes/s3.py +590 -0
  64. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/src/pathlib_next/uri/schemes/sftp/__init__.py +253 -34
  65. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/src/pathlib_next/uri/schemes/sftp/_asyncssh.py +387 -122
  66. pathlib_next-0.9.4/src/pathlib_next/uri/schemes/sftp/_paramiko.py +426 -0
  67. pathlib_next-0.9.4/src/pathlib_next/uri/schemes/sftp/_sshconfig.py +77 -0
  68. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/src/pathlib_next/uri/source.py +85 -10
  69. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/src/pathlib_next/utils/__init__.py +129 -14
  70. pathlib_next-0.9.4/src/pathlib_next/utils/archive.py +235 -0
  71. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/src/pathlib_next/utils/checksum.py +7 -17
  72. pathlib_next-0.9.4/src/pathlib_next/utils/glob.py +302 -0
  73. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/src/pathlib_next/utils/stat.py +27 -5
  74. pathlib_next-0.9.4/src/pathlib_next/utils/sync.py +1057 -0
  75. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/tests/conftest.py +316 -103
  76. pathlib_next-0.9.4/tests/test_archive_parity.py +483 -0
  77. pathlib_next-0.9.4/tests/test_archive_safety.py +589 -0
  78. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/tests/test_archive_uri.py +2 -0
  79. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/tests/test_az.py +4 -4
  80. pathlib_next-0.9.4/tests/test_az_fake.py +619 -0
  81. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/tests/test_checksum.py +24 -0
  82. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/tests/test_contract.py +81 -29
  83. pathlib_next-0.9.4/tests/test_contract_helpers.py +180 -0
  84. pathlib_next-0.9.4/tests/test_destructive_safety.py +328 -0
  85. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/tests/test_ftp.py +65 -0
  86. pathlib_next-0.9.4/tests/test_ftp_objstore_parity.py +578 -0
  87. pathlib_next-0.9.4/tests/test_gitrepo.py +698 -0
  88. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/tests/test_glob.py +9 -10
  89. pathlib_next-0.9.4/tests/test_glob_parity.py +305 -0
  90. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/tests/test_gs.py +23 -4
  91. pathlib_next-0.9.4/tests/test_gs_fake.py +532 -0
  92. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/tests/test_http.py +217 -10
  93. pathlib_next-0.9.4/tests/test_httpdav_safety.py +505 -0
  94. pathlib_next-0.9.4/tests/test_io_parity.py +296 -0
  95. pathlib_next-0.9.4/tests/test_low_core.py +659 -0
  96. pathlib_next-0.9.4/tests/test_low_schemes.py +358 -0
  97. pathlib_next-0.9.4/tests/test_low_sync.py +469 -0
  98. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/tests/test_mempath.py +54 -2
  99. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/tests/test_mro_precedence.py +43 -1
  100. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/tests/test_parity_io.py +6 -2
  101. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/tests/test_path_gaps.py +150 -0
  102. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/tests/test_pathname.py +45 -9
  103. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/tests/test_plugins.py +28 -4
  104. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/tests/test_properties.py +165 -21
  105. pathlib_next-0.9.4/tests/test_pure_parity.py +664 -0
  106. pathlib_next-0.9.4/tests/test_routing.py +316 -0
  107. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/tests/test_s3.py +145 -1
  108. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/tests/test_sftp.py +365 -46
  109. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/tests/test_sftp_asyncssh.py +272 -5
  110. pathlib_next-0.9.4/tests/test_sftp_transport.py +875 -0
  111. pathlib_next-0.9.4/tests/test_smoke.py +250 -0
  112. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/tests/test_source.py +36 -1
  113. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/tests/test_sync.py +4 -116
  114. pathlib_next-0.9.4/tests/test_sync_safety.py +896 -0
  115. pathlib_next-0.9.4/tests/test_sync_sftp.py +142 -0
  116. pathlib_next-0.9.4/tests/test_sync_sftp_parity.py +508 -0
  117. pathlib_next-0.9.4/tests/test_transport_security.py +635 -0
  118. pathlib_next-0.9.4/tests/test_uri_core_parity.py +375 -0
  119. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/tests/test_uri_parse.py +8 -2
  120. pathlib_next-0.9.4/tests/test_uripath_tool.py +393 -0
  121. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/tests/test_utils.py +45 -5
  122. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/tests/test_walk.py +0 -2
  123. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/tests/test_webdav.py +118 -0
  124. pathlib_next-0.9.3/.gitignore +0 -29
  125. pathlib_next-0.9.3/PKG-INFO +0 -255
  126. pathlib_next-0.9.3/README.md +0 -196
  127. pathlib_next-0.9.3/docs/api/mempath.md +0 -3
  128. pathlib_next-0.9.3/docs/api/uri.md +0 -5
  129. pathlib_next-0.9.3/docs/benchmarks.md +0 -238
  130. pathlib_next-0.9.3/docs/guides/cli.md +0 -34
  131. pathlib_next-0.9.3/docs/guides/extending.md +0 -171
  132. pathlib_next-0.9.3/docs/guides/schemes.md +0 -133
  133. pathlib_next-0.9.3/docs/index.md +0 -99
  134. pathlib_next-0.9.3/src/pathlib_next/AGENTS.md +0 -425
  135. pathlib_next-0.9.3/src/pathlib_next/mempath.py +0 -237
  136. pathlib_next-0.9.3/src/pathlib_next/protocols/io.py +0 -125
  137. pathlib_next-0.9.3/src/pathlib_next/testing.py +0 -198
  138. pathlib_next-0.9.3/src/pathlib_next/tools/uripath.py +0 -180
  139. pathlib_next-0.9.3/src/pathlib_next/uri/schemes/__init__.py +0 -34
  140. pathlib_next-0.9.3/src/pathlib_next/uri/schemes/_gitrepo.py +0 -133
  141. pathlib_next-0.9.3/src/pathlib_next/uri/schemes/archive/_base.py +0 -296
  142. pathlib_next-0.9.3/src/pathlib_next/uri/schemes/archive/tar.py +0 -42
  143. pathlib_next-0.9.3/src/pathlib_next/uri/schemes/archive/zip.py +0 -160
  144. pathlib_next-0.9.3/src/pathlib_next/uri/schemes/az.py +0 -305
  145. pathlib_next-0.9.3/src/pathlib_next/uri/schemes/dav.py +0 -224
  146. pathlib_next-0.9.3/src/pathlib_next/uri/schemes/file.py +0 -84
  147. pathlib_next-0.9.3/src/pathlib_next/uri/schemes/ftp.py +0 -244
  148. pathlib_next-0.9.3/src/pathlib_next/uri/schemes/git/_base.py +0 -39
  149. pathlib_next-0.9.3/src/pathlib_next/uri/schemes/gitlab.py +0 -131
  150. pathlib_next-0.9.3/src/pathlib_next/uri/schemes/gs.py +0 -254
  151. pathlib_next-0.9.3/src/pathlib_next/uri/schemes/s3.py +0 -264
  152. pathlib_next-0.9.3/src/pathlib_next/uri/schemes/sftp/_paramiko.py +0 -239
  153. pathlib_next-0.9.3/src/pathlib_next/uri/schemes/sftp/_sshconfig.py +0 -34
  154. pathlib_next-0.9.3/src/pathlib_next/utils/archive.py +0 -156
  155. pathlib_next-0.9.3/src/pathlib_next/utils/glob.py +0 -176
  156. pathlib_next-0.9.3/src/pathlib_next/utils/sync.py +0 -617
  157. pathlib_next-0.9.3/tests/test_az_fake.py +0 -164
  158. pathlib_next-0.9.3/tests/test_gitrepo.py +0 -295
  159. pathlib_next-0.9.3/tests/test_gs_fake.py +0 -145
  160. pathlib_next-0.9.3/tests/test_smoke.py +0 -140
  161. pathlib_next-0.9.3/tests/test_uripath_tool.py +0 -90
  162. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/LICENSE +0 -0
  163. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/docs/api/path.md +0 -0
  164. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/docs/api/testing.md +0 -0
  165. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/docs/changelog.md +0 -0
  166. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/src/pathlib_next/__init__.py +0 -0
  167. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/src/pathlib_next/protocols/__init__.py +0 -0
  168. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/src/pathlib_next/py.typed +0 -0
  169. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/src/pathlib_next/tools/__init__.py +0 -0
  170. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/src/pathlib_next/uri/schemes/archive/__init__.py +0 -0
  171. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/src/pathlib_next/uri/schemes/git/__init__.py +0 -0
  172. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/src/pathlib_next/uri/schemes/git/github.py +0 -0
  173. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/src/pathlib_next/uri/schemes/git/gitlab.py +0 -0
  174. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/tests/test_data_uri.py +0 -0
  175. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/tests/test_dav.py +0 -0
  176. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/tests/test_http_live.py +0 -0
  177. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/tests/test_http_parser.py +0 -0
  178. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/tests/test_local.py +0 -0
  179. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/tests/test_parity_pure.py +0 -0
  180. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/tests/test_query.py +0 -0
  181. {pathlib_next-0.9.3 → pathlib_next-0.9.4}/tests/test_uri_path.py +0 -0
@@ -0,0 +1,43 @@
1
+ # Category rule: nothing dot-prefixed at the repo root gets tracked except the
2
+ # files re-included below (a category beats a list). Root-anchored on purpose;
3
+ # nested dotfiles are governed by the specific rules further down.
4
+ /.*
5
+ !/.gitignore
6
+ !/.gitattributes
7
+ !/.github/
8
+ # Shared editor settings (black on save, pytest discovery, build task) are
9
+ # tracked deliberately.
10
+ !/.vscode/
11
+
12
+ # Private agent config. No trailing slash on `.agents` on purpose: it may be a
13
+ # symlink, which git treats as a file, so a directory-only `.agents/` would not
14
+ # match it. These stay even though /.* covers the root: they also catch nested
15
+ # copies. *.local.* is an unshared, machine- or user-specific override and is
16
+ # never committed (it is also excluded from the build in pyproject.toml).
17
+ .agents
18
+ *.local.*
19
+ CLAUDE*
20
+ .claude
21
+
22
+ # Build/dist output and generated docs site
23
+ build/
24
+ dist/
25
+ site/
26
+
27
+ # Python
28
+ __pycache__/
29
+ *.py[cod]
30
+ .pytest_cache/
31
+ .hypothesis/
32
+ .mypy_cache/
33
+ .ruff_cache/
34
+ .coverage*
35
+ htmlcov/
36
+ *.egg-info/
37
+ .venv/
38
+ .pyvenv/
39
+
40
+ # OS/editor
41
+ .DS_Store
42
+ Thumbs.db
43
+ .idea/
@@ -0,0 +1,116 @@
1
+ # pathlib_next — contributor orientation
2
+
3
+ Orientation for working in a checkout of this repository: layout, environments,
4
+ commands, CI and release. It is not the API reference and it does not ship.
5
+
6
+ - **Public API contract** (every export, signature and gotcha):
7
+ [`src/pathlib_next/AGENTS.md`](src/pathlib_next/AGENTS.md). That file ships in
8
+ the wheel, so it must stay self-contained (no repo-relative links) and must be
9
+ updated in the same commit as any public API change.
10
+ - **Deliberate differences from `pathlib.Path`**:
11
+ [`docs/divergences.md`](docs/divergences.md). `pathlib.Path` parity is the
12
+ contract; a behavioral divergence that is not recorded there is a bug.
13
+
14
+ ## Layout
15
+
16
+ | Path | Contents |
17
+ | --- | --- |
18
+ | `src/pathlib_next/` | The package (`src/` layout). `py.typed` and the API header ship with it. |
19
+ | `src/pathlib_next/uri/schemes/` | One module per URI scheme; registered through the `pathlib_next.schemes` entry points in `pyproject.toml`. |
20
+ | `tests/` | The pytest suite. |
21
+ | `benchmarks/` | `bench.py` and committed JSON results; see [`benchmarks/README.md`](benchmarks/README.md). Not shipped. |
22
+ | `examples/` | Runnable scripts. Networked ones skip (exit 0) unless their environment variables are set. |
23
+ | `docs/`, `mkdocs.yml` | MkDocs site: hand-written pages plus a `mkdocstrings` API reference. |
24
+ | `CHANGELOG.md` | Keep a Changelog. Released sections are frozen records. |
25
+
26
+ ## Environments
27
+
28
+ Python 3.9 is the floor (`requires-python = ">=3.9"`) and 3.14 is the latest
29
+ supported. Test on both ends before claiming a change works.
30
+
31
+ Keep one virtualenv per interpreter under `.venv/<version>-<os>-<arch>/`
32
+ (gitignored), where `<os>` is `os.name` (`nt`/`posix`) or `darwin`, and `<arch>`
33
+ is the architecture the interpreter was built for:
34
+
35
+ ```bash
36
+ python -m venv .venv/3.14-posix-x86_64
37
+ .venv/3.14-posix-x86_64/bin/python -m pip install -e ".[dev,docs,uri,http,sftp,sftp-async,s3,gs,az]"
38
+ ```
39
+
40
+ On Windows the interpreter is `.venv\<name>\Scripts\python.exe`.
41
+
42
+ Install every extra that has tests. A missing extra does not fail the suite:
43
+ its tests are skipped instead, so check `pytest -rs` before trusting a green run.
44
+ The `gs`/`az` SDKs are the ones most likely to be unavailable for an older
45
+ interpreter or a less common platform; without them their contract suites skip.
46
+
47
+ ## Everyday commands
48
+
49
+ ```bash
50
+ python -m pytest -q # full suite (pythonpath=src is configured)
51
+ python -m pytest -q --cov=pathlib_next --cov-report=term-missing
52
+ python -m black src/ tests/ benchmarks/ examples/ # formatting; --check to verify
53
+ mkdocs build --strict # docs must build with no warnings
54
+ python -m build # sdist + wheel into dist/
55
+ ```
56
+
57
+ - **Formatting is black**, pinned to `target-version = ["py39"]` so it never
58
+ emits syntax the floor cannot parse. No linter or type checker is enforced.
59
+ - **Every file is LF** (`.gitattributes` sets `* text=auto eol=lf`). On Windows,
60
+ black writes CRLF: convert the files back to LF after formatting and check
61
+ `git diff --stat` for whitespace-only churn.
62
+ - **3.9 compatibility**: any module using `X | Y` in a runtime-evaluated
63
+ annotation needs `from __future__ import annotations`.
64
+ - **`*.local.*` files** are per-machine overrides: gitignored and excluded from
65
+ both build targets. Keep hostnames and credentials in those, never in tracked
66
+ files.
67
+
68
+ ## Benchmarks
69
+
70
+ `python benchmarks/bench.py --help` lists the suites. `--save` writes a JSON
71
+ result per (version, interpreter, platform) into `benchmarks/results/`; the
72
+ schema and the reproduce command are in
73
+ [`benchmarks/README.md`](benchmarks/README.md). A local run is a sanity check;
74
+ performance claims in the changelog or release notes come from CI runs.
75
+
76
+ ## CI
77
+
78
+ Workflows live in `.github/workflows/`:
79
+
80
+ - `test.yml` runs on `workflow_dispatch` (optional `ref` input) or on a pushed
81
+ `ci-*` tag, never on ordinary pushes. To test a commit without the dashboard,
82
+ push a uniquely named throwaway tag (`ci-<topic>-<timestamp>`), follow the run
83
+ to completion, then delete the tag locally and on the remote.
84
+ - `release.yml` runs on a `v*` tag: test gate, build, PyPI publish through
85
+ Trusted Publishing, and a GitHub release whose notes come from that
86
+ version's `CHANGELOG.md` section. Its docs job only checks that the site
87
+ builds strictly; it never deploys.
88
+ - `docs.yml` owns every GitHub Pages deploy: on a published release, on a push
89
+ to `main` that touches the docs sources, and on `workflow_dispatch`.
90
+
91
+ ## Releasing
92
+
93
+ 1. Move the `[Unreleased]` entries under a new `## [x.y.z] - <date>` heading and
94
+ add its link definition at the bottom of `CHANGELOG.md`.
95
+ 2. Bump `version` in `pyproject.toml` in the same commit (PEP 440 syntax there;
96
+ SemVer in tags and the changelog).
97
+ 3. Before 1.0, bump the minor only when the documented API breaks. New methods,
98
+ new optional arguments and fixes are patch releases.
99
+ 4. Run the full suite on the floor and latest interpreters, `mkdocs build
100
+ --strict`, `python -m build`, and the maintainer's leak check (a scan for
101
+ private references and agent-attribution commit trailers). Judge it by exit
102
+ code.
103
+ 5. Push `main`, then the `v*` tag. Publishing is irreversible, so the tag is
104
+ pushed only with the maintainer's explicit consent for that specific
105
+ release.
106
+
107
+ Changelog entries say what changed and what a user must do about it. They do
108
+ not describe how the work was done.
109
+
110
+ ## Commits
111
+
112
+ - Logical commits in `type: description` form (`feat:`, `fix:`, `docs:`,
113
+ `chore:`); keep code with its tests, and docs/config/CI in separate commits.
114
+ - No agent attribution in commit messages: no `Co-Authored-By:` naming a model
115
+ or assistant, no `*-Session:` trailers, no session URLs, no "generated with"
116
+ footers.
@@ -7,6 +7,452 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.4] - 2026-09-16
11
+
12
+ ### Fixed
13
+ - **`Path.copy()` destroyed or created the target when the source could not
14
+ be read.** It unlinked an existing target (with `overwrite=True`) and
15
+ opened the target for writing before opening the source, so a missing
16
+ file, a directory without `recursive=True`, or a source HTTP 404 left the
17
+ target empty, or left a new 0-byte file that made a retry fail with
18
+ `FileExistsError`. The source is now opened first, and a copy that fails
19
+ mid-stream removes its partial target.
20
+ - **Copying or moving a file onto itself deleted it.** `f.copy(f,
21
+ overwrite=True)` (or onto a case-insensitive alias such as `F.TXT` on
22
+ Windows/macOS) emptied the file; a case-only rename with
23
+ `move(overwrite=True)` deleted it. `copy()` now raises
24
+ `OSError(EINVAL, "Source and target are the same file")`; `move()` renames
25
+ in place.
26
+ - **`move(overwrite=True)` removed the target before checking the source.**
27
+ A missing source, or a file moved onto a directory, deleted the target
28
+ (including a whole tree) and only then raised. It now raises
29
+ `FileNotFoundError` / `IsADirectoryError` first and leaves the target
30
+ alone. A local file target is replaced atomically with `os.replace()`, so a
31
+ locked source on Windows no longer costs the target.
32
+ - **`rm(recursive=True)` deleted files outside the tree through Windows
33
+ junctions and `file:` directory symlinks.** A junction reads as a
34
+ directory to a non-following stat, and `UriPath`'s default `_scandir()`
35
+ used a following stat, so both were descended into and their targets'
36
+ contents deleted. Both are now removed as links. `FileUri` listings also
37
+ reuse `LocalPath`'s scandir metadata (one call per directory).
38
+ - **`PathSyncer.sync()` could delete or write outside its target.**
39
+ - A root source that does not exist now raises `FileNotFoundError`.
40
+ Before, with `remove_missing=True` it deleted the entire target (a typo,
41
+ an unmounted share, a 404); without it, it reported success. **Callers
42
+ that relied on syncing an absent source as a no-op must now catch the
43
+ error or pass `ignore_error`.**
44
+ - Overlapping source and target (one inside the other, same
45
+ implementation and backend) now raise `ValueError`. Before, the source
46
+ could be deleted, or copies nested until `RecursionError`.
47
+ - A child name that would leave the target (`..`, a name the parent-name
48
+ fallback turned into `..`, or `\`/`:` on a Windows target) now raises
49
+ `ValueError` through `ignore_error`. Before, such entries from an S3,
50
+ SFTP or archive listing were written, or removed, outside the target.
51
+ - Entries listed with an unknown stat (GitLab blobs, FTP without MLSD) are
52
+ now re-stat'd. Before, with `follow_symlinks=False` nothing was copied
53
+ and `remove_missing=True` deleted the existing mirror.
54
+ - A symlink inside the target is replaced by the real file or directory.
55
+ Before, sync listed, wrote and deleted through it, into whatever it
56
+ pointed at.
57
+ - **`http:`/`dav:` listings yielded `.` and `..` as children.** wsgidav's
58
+ parent row (`<a href="..">`) became a file named `..`, and unlinking it
59
+ deleted the parent collection; a `./` entry made `walk()` loop forever; a
60
+ PROPFIND href `%2E%2E/` let a recursive copy write outside its destination.
61
+ Such names are no longer listed.
62
+ - **`DavPath.unlink()` and `HttpPath.unlink()` deleted whole collections.**
63
+ They sent a bare `DELETE`, which WebDAV applies recursively; `unlink()` on
64
+ a directory, and `symlink_to(force=True)` over one, removed the tree. Both
65
+ now raise `IsADirectoryError` for a directory (`HttpPath` relies on its
66
+ HEAD-based directory check). `rm(recursive=True)` still deletes trees.
67
+ - **`DavPath` read an HTTP error page as file content.** `open("rb")` /
68
+ `read_bytes()` / `copy()` on a missing or forbidden file returned the
69
+ server's 404/401/500 body. They now raise `FileNotFoundError` /
70
+ `PermissionError` / `OSError`.
71
+ - **Reading a local `zip:` archive opened it for writing.** A read-only zip
72
+ was unreadable (`exists()` returned `False`), `exists()` on a missing
73
+ archive created it, and probing a file that is not a zip appended 22 bytes
74
+ to it. Reads now open the archive read-only; the first write into a
75
+ missing archive creates it. Probing a non-zip file now raises
76
+ `zipfile.BadZipFile`.
77
+ - **Zip `unlink()`/`rename()`/overwrite reset every other member.** The
78
+ rewrite gave all members the current time, DEFLATE compression and mode
79
+ 0600, and dropped the archive comment and any leading bytes (a zipapp
80
+ shebang); it also replaced a symlinked archive with a regular file. Member
81
+ metadata, the comment, the prefix bytes and the archive's file mode are
82
+ now kept, and a symlinked archive stays a symlink.
83
+ - **Zip `rename()` onto an existing member created a duplicate name**, and a
84
+ later rewrite kept the old content. It now replaces the target (POSIX
85
+ semantics; see `docs/divergences.md`).
86
+ - **Archive listings exposed traversal member names.** Members named with
87
+ `..`, an absolute path, `\`-separated traversal or a drive prefix are no
88
+ longer listed, so `iterdir()`/`walk()`/`copy(recursive=True)` cannot write
89
+ outside a destination through them.
90
+ - **`utils.unpack_archive()` let crafted members escape `dest` on Windows**
91
+ (`D:evil.txt`, and the same-drive `C:../C:../x`). Such members, and any
92
+ member with a `..` part (previously extracted with the `..` dropped), are
93
+ now skipped.
94
+
95
+ - **`rename()`/`move()` renamed onto the wrong host, bucket or archive.**
96
+ Every scheme renamed through its own connection or bucket with only the
97
+ target's path: `SftpPath`/`FtpPath` moves to another server renamed on the
98
+ source server, `S3Path`/`GsPath` moves to another bucket landed in the
99
+ source bucket (overwriting an existing object of that name there), a zip
100
+ member moved to a local path was renamed inside the archive, and
101
+ `LocalPath.move()` onto a remote path renamed a local file. `rename()` now
102
+ raises `NotImplementedError` for a target on another endpoint, archive or
103
+ Azure container, and `move()` copies and deletes instead. `move()` also
104
+ falls back to copy + delete on a cross-device rename (`EXDEV`), and
105
+ `SftpPath.copy(recursive=True)` uses its concurrent fan-out only for a
106
+ target on the same host.
107
+ - **Session credentials and tokens followed a join to another host.**
108
+ `base / "http://other/x"`, `UriPath(base, url)` and `base.with_source(...)`
109
+ reused `base`'s backend, so an `HttpPath.with_session(auth=...)` session or
110
+ a `github://TOKEN@...` token was sent to the other host. A backend is now
111
+ reused only for the same scheme, userinfo, host and port.
112
+ - **`AzPath.rename()` with a `str` target always raised `TypeError`**, and a
113
+ pending copy crashed with `KeyError` after starting it. **`GsPath`/`AzPath`/
114
+ `S3Path.rename()` onto the same key deleted the object.** Both fixed.
115
+ - **`FileUri.rename("b.txt")` resolved against the process cwd** and returned
116
+ a `LocalPath`. It now renames within the same directory and returns a
117
+ `FileUri`.
118
+ - **`MemPath.copy("/b.txt")`/`move("/c.txt")` wrote into a new, empty
119
+ in-memory filesystem**, and `move()` then deleted the source. A `str`
120
+ destination now stays on the source's backend.
121
+
122
+ - **`SftpPath.copy()` raised `ModuleNotFoundError` without asyncssh**
123
+ (paramiko-only `sftp` extra), including every single-file download and
124
+ `PathSyncer` with an SFTP source.
125
+ - **SFTP connections leaked or went stale.** A first-call asyncssh
126
+ `rm(recursive=True)` hung for 60 s and deleted nothing; dropped paramiko
127
+ connections and closed asyncssh SFTP channels were never replaced, so every
128
+ later call failed and `exists()` returned `False`; evicted connections,
129
+ failed logins and failed SFTP starts leaked sockets and threads; concurrent
130
+ first calls opened duplicate connections; a recursive copy held two remote
131
+ handles open per file in the tree; `SftpPath(url, ssh_config=...)` ignored
132
+ `ssh_config`; the paramiko backend ignored ssh_config `Include`.
133
+ - **`UriPath("ftps://...")` returned a stub `UriPath` in a fresh process**
134
+ instead of `FtpPath`.
135
+ - **URL credentials leaked into HTTP errors and redirects.** They are now sent
136
+ as Basic `auth=` instead of inside the request URL, WebDAV `MOVE`
137
+ `Destination` no longer carries them, and translated errors no longer chain
138
+ the `requests` exception (`__cause__` is `None`; the message carries the
139
+ HTTP status and reason). URL credentials now take priority over a matching
140
+ `~/.netrc` entry.
141
+ - **`github:`/`gitlab:` tokens leaked through `str()`/`repr()`/errors**, and
142
+ `user:TOKEN@host` authenticated with the username. The token is now read
143
+ from the password slot when present, and these schemes redact the whole
144
+ userinfo.
145
+
146
+ - **`glob()`/`rglob()` crashed, hung or returned wrong results.**
147
+ `glob("**")` and `glob("dir/**")` raised `NotADirectoryError` on any tree
148
+ containing a file; `**` followed directory symlinks, so a symlink loop
149
+ produced duplicates effectively forever; globbing under a missing directory
150
+ or a file raised instead of yielding nothing; repeated `**` returned
151
+ duplicates; a trailing `/` matched files on `MemPath` and URI paths; `?`
152
+ never matched on a `UriPath` (read as a query); `glob.full_match()` slowed
153
+ down exponentially with repeated `**`. A trailing `**` now follows the
154
+ running Python (files too on 3.13+).
155
+ - **`match()` on `MemPath`, `Uri` and every `UriPath` did not follow
156
+ pathlib.** It anchored at the start and let `*` cross `/`, and a URI's
157
+ `host:` prefix defeated absolute patterns. It is now pathlib's
158
+ right-anchored per-segment match; an empty pattern raises `ValueError`.
159
+ `LocalPath` accepts `match(case_sensitive=)` on 3.9-3.11, and its
160
+ `full_match()` handles rooted, drive and backslash patterns before 3.13.
161
+ - **`parents`/`parent` of an absolute `MemPath` or `Uri` lost the root.**
162
+ `MemPath('/a/b').parents` is now `['/a', '/']`; `Uri('http://h/a').parent` is
163
+ `http://h/`; a top-level `FileUri`'s parent is `/` (or the drive root
164
+ `C:/` on Windows) instead of resolving to the current directory.
165
+ `relative_to()`/`is_relative_to()` treat `s3://bucket`/`http://h` as the
166
+ root, so `relative_to(p.parent)` and `walk_up=True` work.
167
+ - **`with_name()`/`with_stem()`/`with_suffix()` accepted `''`, `.` and
168
+ separators** on `MemPath` and `Uri`, splicing `x/y` or `../../etc` into a
169
+ path. They now raise `ValueError` like pathlib.
170
+ - **`MemPath` did not normalize like `PurePosixPath`.** `MemPath('/') / 'a'`
171
+ was `//a` and unequal to `MemPath('/a')`; a trailing `/` changed equality;
172
+ an absolute join did not reset. All now match `PurePosixPath`.
173
+ - **`Path.touch()` made new files world-writable and could truncate existing
174
+ ones.** It chmod'ed every new file to 0o666 ignoring the umask (`file:`,
175
+ SFTP, FTP) and treated any `stat()` error as "missing". `mode` now defaults
176
+ to `None` (chmod only when passed), an existing file is never truncated, and
177
+ `FileUri.touch()` uses pathlib's `touch()`.
178
+ - **`copy()` made local copies read-only.** `preserve_metadata=True` applied
179
+ the placeholder 0o444/0o555 mode that `MemPath`, HTTP, WebDAV, object stores
180
+ and archives report, so a copied file could not be overwritten or re-synced.
181
+ Placeholder modes are no longer applied (`FileStat.mode_known`).
182
+ - **`utils.parsedate()` read GMT dates as local time**, so every HTTP/WebDAV
183
+ `st_mtime` was off by the host's UTC offset, and it raised `OverflowError`
184
+ on Windows east of UTC. It now returns UTC epoch seconds and passes numbers
185
+ through.
186
+ - **`s3://b/dir/` (trailing slash) was treated as the marker object**: not a
187
+ directory, and `rm(recursive=True)` removed only the marker. S3, GCS and
188
+ Azure keys now drop one trailing `/`. Azure keys keep interior empty
189
+ segments (`a//b`) as written.
190
+ - **`GsBackend` rewrote the process-wide `STORAGE_EMULATOR_HOST`** and dropped
191
+ other `client_options`. Keyword arguments now go to `storage.Client`
192
+ unchanged; for an emulator also pass `use_auth_w_custom_endpoint=False`.
193
+
194
+ - **URI queries were corrupted on the wire.** They were percent-decoded at
195
+ parse time and re-encoded with `&`, `=` and `+` treated as safe, so a signed
196
+ URL's `sig=ab%2Bcd%3D%3D` reached the server as `ab+cd==` and an escaped `&`
197
+ split a value; `with_query(dict)` double-encoded. `Uri.query` is now kept as
198
+ received and sent unchanged (see Changed).
199
+ - **A remote path joined with a relative `pathlib.Path` became a local
200
+ file.** `UriPath("sftp://h/srv/") / pathlib.Path("etc/x")` produced
201
+ `file:/srv/etc/x`, so reads and writes hit the local disk. A relative path
202
+ now joins like a `PurePath` and stays on the remote; only an absolute local
203
+ path becomes `file:`. `UriPath.joinpath()` picks the class from the scheme.
204
+ - **URIs with non-UTF-8 percent-escapes** (`caf%E9.html`) raised
205
+ `UnicodeDecodeError`; they now construct and round-trip. `data:` payloads
206
+ are no longer dot-normalized or decoded twice, and binary payloads work.
207
+ - **Windows `file:` URIs**: `file://localhost/C:/...` could not be printed,
208
+ hashed or compared; a `file://<host>/share` whose host is this machine
209
+ mapped to the current drive instead of a UNC path.
210
+ - **Scheme registry**: a `UriPath` subclass defined after the first dispatch
211
+ was never found, and every unknown scheme rescanned entry points.
212
+ - **`open()` modes**: `rt`/`wt` failed on every non-local backend and invalid
213
+ modes raised `NotImplementedError`; modes are now validated like the
214
+ built-in `open()` (`ValueError`). `open("r+")` on buffered backends (FTP,
215
+ S3, GCS, Azure, local zip members) returned a writable buffer whose writes
216
+ were discarded; writes are now uploaded on close (or `NotImplementedError`
217
+ where impossible).
218
+ - **`copy()`**: `copy(recursive=True)` into its own subtree recursed without
219
+ limit; `copy(follow_symlinks=False)` copied the link target's content with
220
+ the link's 0o777 mode (it now recreates the link, as pathlib 3.14 does);
221
+ `overwrite=False` was decided by `exists()`, which reads a transient 503 as
222
+ "missing". Downstream classes mixing a concrete stdlib path with `Path` now
223
+ also get pathlib_next's `stat`/`chmod`/`glob`/`walk`/`_scandir`.
224
+ - **`MemPath`**: `iterdir()` on a missing path raised `NotADirectoryError`;
225
+ files always reported `st_mtime=0`, so `PathSyncer` skipped same-size edits.
226
+ - **Archives (`zip:`/`tar:`/`archive:`)**: children of the archive root were
227
+ named `/name` and matched no member, so `iterdir`/`glob`/recursive `copy`
228
+ from the root failed; tarballs with `./` members (`tar -C dir .`,
229
+ `shutil.make_archive`) were unreadable; adding a zip member rewrote the
230
+ central directory in place (a crash corrupted the archive); a cached handle
231
+ ignored changes by other writers and kept the file locked on Windows;
232
+ archive URIs did not round-trip names with `#`, `?` or `%`; concurrent tar
233
+ reads returned wrong bytes; stored member modes were ignored; `iterdir()` on
234
+ a file returned `[]`. `utils.make_archive()` now accepts any `Path` source,
235
+ writes the target only when complete and supports zip64;
236
+ `utils.unpack_archive()` accepts non-seekable streams and extracts tar links.
237
+ - **HTTP/WebDAV**: gzip-encoded responses were returned compressed (and
238
+ rewrite-mode append re-uploaded them); a redirect made `stat()` report a
239
+ directory; listing hints fabricated sizes used as the patch-append offset;
240
+ mid-body read failures raised urllib3 exceptions; `DavPath` listed a
241
+ directory with a space in its name as its own child, treated a 207
242
+ Multi-Status with failed members as success, re-sent a failed PUT at garbage
243
+ collection and raised raw `requests.HTTPError`s; `with_session(headers=...)`
244
+ was replaced by internal headers.
245
+ - **GitHub/GitLab**: GitLab listings stopped after 100 entries (and `stat()`
246
+ called later subdirectories missing); GitHub listings stopped at 1,000;
247
+ self-hosted API roots dropped the port; GitLab root `stat()` answered from
248
+ the URI shape without asking the server.
249
+ - **`uripath` CLI**: `sync` compared sizes only, so same-size edits were never
250
+ copied; `--dry-run` printed nothing; `read`/`cp -` buffered whole objects.
251
+ - **FTP**: every separately built path opened its own connection; an error
252
+ mid-transfer desynchronized the cached connection for good; a write whose
253
+ connection timed out while idle lost its data; without MLSD, directories
254
+ stat'ed as missing; MLSD mtimes were read as local time; permission errors
255
+ surfaced as `FileNotFoundError` and raw `ftplib` errors escaped.
256
+ - **S3/GCS/Azure**: botocore `ClientError` escaped `exists()`/`walk()` and a
257
+ 403 read as "missing"; GCS/Azure turned every exception (including a missing
258
+ SDK) into "does not exist", so `copy(overwrite=False)` could overwrite;
259
+ `iterdir()` on a missing path returned `[]` and `rmdir()`/`unlink()` accepted
260
+ wrong-type targets; a failed upload was retried at garbage collection over
261
+ newer data; prefix-directory `move()` failed; S3 objects above 5 GiB could
262
+ not be written or renamed; `open("x")` was a check-then-put race;
263
+ `AzPath` without `backend=` ignored the URI's account; Azure recursive `rm()`
264
+ stopped at the first failing blob in a batch.
265
+ - **`PathSyncer`**: an interrupted copy lost the previous version (it now
266
+ writes a temporary sibling and renames); preserve mode deleted the target
267
+ before discovering symlinks were unsupported; dry runs crashed on new
268
+ subdirectories; `RemovedMissing` events carried the parent directory;
269
+ `ignore_error` was called once per ancestor with the wrong paths; two
270
+ unknown (0) mtimes counted as "in sync"; FIFOs and devices replaced the
271
+ target with an empty directory.
272
+ - **SFTP**: `rename()` onto an existing file failed with a bare
273
+ `OSError("Failure")`; `unlink(missing_ok=True)` skipped dangling symlinks;
274
+ a relative `readlink()` result could not be printed; asyncssh file handles
275
+ made one round trip per byte in `readline()` and an unclosed handle hung
276
+ interpreter exit for 60 s; the asyncssh recursive copy called a bool
277
+ `ignore_error` and ignored the own-subtree and symlink rules; paramiko's
278
+ native checksum probed an extension OpenSSH does not implement, paying extra
279
+ round trips per file.
280
+
281
+ - **`rename()` returned `None`** on `dav:`, `s3:`, `gs:`, `az:`, `ftp:` and
282
+ `sftp:`; it now returns the new path, as pathlib does.
283
+ - **Wrong exception types on `sftp:`, `dav:`, `gitlab:` and `MemPath`.**
284
+ Listing or `rmdir()` of a file raises `NotADirectoryError`, `rmdir()` of a
285
+ non-empty directory `OSError(ENOTEMPTY)` (`MemPath` raised
286
+ `FileExistsError`), opening or unlinking a directory `IsADirectoryError`;
287
+ `dav:` reading a collection raised nothing and returned its HTML index, and
288
+ `rmdir()` of a missing path raised `NotADirectoryError`. paramiko
289
+ `open("x")` returned a file that could not be written.
290
+ - **`Path.rm(recursive=True, ignore_error=callable)`** offered a declined
291
+ error to the callable again from every enclosing directory.
292
+ - **`uripath` crashed at import without the `uri` extra**, even for local
293
+ files. Local paths and `-` now work; a URI argument reports the extra to
294
+ install. Without the extra it also read every colon name as a URI
295
+ (`uripath read notes:draft` asked for `pathlib-next[uri]` instead of
296
+ reading the file); the schemes this package registers are now read from
297
+ its entry points, so a colon name behaves the same in either install.
298
+ - **`match()` disagreed with pathlib on Python 3.12** at the root. 3.12
299
+ matches the whole path as one string with its separators swapped for
300
+ newlines, so `"/"` is a single newline that `"**"` matches from either
301
+ side (`"/".match("**")`, `match("/**")` and `match("**/**")` are all
302
+ `True` there) while `"*"` never does, and a bracket expression such as
303
+ `"[!a]"` consumes the separator itself. `MemPath`/`Uri` answered `False`
304
+ throughout; 3.12 now runs a port of that algorithm instead of the
305
+ part-by-part comparison every other version uses.
306
+ - **`import pathlib_next` imported `netimps`** (a host-name query at import,
307
+ slow on Windows), and the first `file:`/`data:` path imported `requests`
308
+ and `botocore`; both now load on first use. `GsPath`/`AzPath` are exported
309
+ from `pathlib_next.uri.schemes`.
310
+ - **`*.local.*` files shipped in the sdist and wheel.**
311
+ - **The SFTP/FTP/WebDAV examples** built URIs from unencoded credentials (a
312
+ password containing `/`, `#`, `?` or `@` changed the host) and printed the
313
+ password.
314
+ - **`benchmarks/bench.py` crashed at import on Python 3.9.**
315
+ - **Docs**: the CI benchmark table had its Ubuntu, Windows and macOS columns
316
+ rotated; the paramiko single-file write/copy slowdown was on Ubuntu.
317
+
318
+ - **Core, MemPath and URI edge cases.** `open()` leaked the backend handle
319
+ when the text wrapper failed; synthesized errors from `rm()`/`touch()` and
320
+ `MemPath` lacked `errno`/`filename`; `copy(progress=)` never reported a
321
+ zero-byte file; `samefile(str)` lost the backend or host; `MemPath` handles
322
+ appended at the seek position, hid unflushed writes and accepted writes on
323
+ read handles, and exclusive create/`mkdir` could both succeed under
324
+ concurrency; `FileStat.from_stat()` kept `None` fields; checksums failed on
325
+ FIPS hosts (`usedforsecurity=False`); `is_dir()`/`is_file()` rejected
326
+ `follow_symlinks=`; `"prefix" / path` was unsupported; suffix/stem ignored
327
+ 3.14's rules; lazy URI parsing could expose unset components to another
328
+ thread; `Uri.__eq__` raised against a relative local path; a `//` path with
329
+ no authority could not be rendered; non-ASCII hosts were sent
330
+ percent-encoded; `Uri("/a").is_absolute()` was `False`; `data:` accepted
331
+ `r+` and discarded writes.
332
+ - **Scheme and CLI edge cases.** FTP listed MLSD `cdir`/`pdir` entries named
333
+ like children; archives nested in archives could not be addressed;
334
+ GitLab `iterdir()` on a file yielded nothing; GitHub/GitLab 429 and
335
+ secondary rate limits were not recognised; `git://<ip>` raised
336
+ `AttributeError`; a custom `BaseRepoBackend` without a cache crashed on
337
+ GitLab; git-hosting `open("r+")` returned a writable buffer; `uripath cp -`
338
+ overwrote an existing target without `--overwrite`, a local name with a
339
+ colon was treated as a URI, and a closed pipe or Ctrl-C printed errors;
340
+ `HttpPath.iterdir()` on a file downloaded it; HTTP 409 on PUT and 410 were
341
+ mis-mapped; WebDAV read only the first `<propstat>`; HTTP listings behind a
342
+ prefix were scoped by the page title; S3/GCS listings disagreed with
343
+ `stat()` when a key was both an object and a prefix; GCS/Azure roots always
344
+ reported existing; asyncssh SFTP errors had no `errno`/`filename`.
345
+ - **`PathSyncer` edge cases.** Tolerated errors left no trace; a directory
346
+ that became a file or symlink in the source deleted the target directory
347
+ even with `remove_missing=False`; `SyncStart` passed raw paths to the hook
348
+ and dry runs reported `dry_run=False` for traversal events; preserved
349
+ directory symlinks were created as file links on Windows; an identical
350
+ symlink was recreated on every run; `PathAndStat(path)` described a symlink
351
+ itself instead of following it.
352
+
353
+ ### Changed
354
+ - **`PathSyncer` keeps non-empty target directories on a type change unless
355
+ `remove_missing=True`** (`IsADirectoryError` through `ignore_error`,
356
+ `SyncEvent.TypeMismatch`). Every tolerated error is now logged at WARNING
357
+ on `pathlib_next.sync` and reported to the hook as `SyncEvent.Error`.
358
+ `PathAndStat` follows symlinks by default. `PathSyncer.hook()` gains a
359
+ keyword-only `always_run`.
360
+ - **URI comparisons and rendering**: `Uri == <non-URI Pathname>` (e.g. a
361
+ `LocalPath`) is now `False` (a `Uri` still equals another `Uri` or a URI
362
+ string); non-ASCII hosts are rendered in IDNA form; an explicit
363
+ `schemesmap=` is authoritative (an unknown scheme gives a plain `UriPath`);
364
+ `with_source()` with a scheme-less source returns a plain `UriPath`; `/` no
365
+ longer hides a `TypeError` raised inside a scheme class.
366
+ - **`data:` URIs** reject `r+`, decode base64 only with `;base64`, and imply
367
+ `text/plain` for a parameters-only header.
368
+ - **`uripath`** exits 141 on a closed stdout and 130 on Ctrl-C.
369
+ - **Package metadata uses PEP 639** (`License-Expression: MIT`) instead of
370
+ the `License ::` classifier, and adds `Typing :: Typed`,
371
+ `Development Status :: 4 - Beta` and Python 3.9-3.14 classifiers. Building
372
+ from source needs `hatchling>=1.27`.
373
+ - **The `az` extra installs `azure-identity`**, which an `AzPath` without
374
+ `backend=` needs for its default credential.
375
+ - **`pathlib_next.testing` contracts are stricter**: 48 `PathContract` tests
376
+ (was 20) covering pathlib error types, glob, walk, rename, open modes and
377
+ recursive copy. A backend that cannot meet a rule sets a capability
378
+ attribute to `False` (`supports_listing`, `supports_empty_directories`,
379
+ `distinguishes_file_types`, `supports_rename`, `supports_append`,
380
+ `supports_exclusive_create`, `enforces_directory_hierarchy`). The contract
381
+ root must be a fresh, function-scoped directory populated with
382
+ `populate_fixture_tree()`. `test_iterdir_lists_children` no longer passes
383
+ when `iterdir()` is unimplemented.
384
+ - **`Uri.query` is the percent-encoded query as received** and is sent
385
+ unchanged; `Query(...).decode()`/`to_dict()` decode each name and value
386
+ once. A `str` passed to `with_query()` is taken as already encoded; a
387
+ mapping's keys now escape `=`. Code that read `.query` expecting decoded
388
+ text must decode it.
389
+ - **Archive paths raise pathlib's POSIX exception types** (`iterdir()` on a
390
+ file, reading or `unlink()`ing a directory, `rmdir()` on a file), and
391
+ `mkdir()`/new zip members need an existing parent. Zip `rename()` returns the
392
+ new path. Archive `as_uri()` percent-encodes the member path.
393
+ - **`uripath sync` compares file content by default**; `--size-only` restores
394
+ the old comparison. `--dry-run` prints planned changes and `-v/--verbose`
395
+ prints changes made.
396
+ - **`gitlab:` reads a `-` segment at position 3 or later as GitLab's `/-/`
397
+ separator** (`gitlab://host/group/sub/project/-/path`).
398
+ - **`SftpPath.rename()` replaces an existing target** where the server
399
+ supports `posix-rename@openssh.com`, else raises `FileExistsError`.
400
+ - **`DavPath` maps request errors to pathlib exceptions** (PUT/MOVE into a
401
+ missing parent: `FileNotFoundError`; 423: `PermissionError`).
402
+ - **`Path.glob()`/`rglob()`/`LocalPath.glob()` include hidden files and
403
+ directories by default**, as pathlib does. Pass `include_hidden=False` for
404
+ the old results. `glob("")` now raises `ValueError` and an absolute pattern
405
+ raises `glob.NonRelativePatternError`; before, `""` yielded the base and an
406
+ absolute pattern listed outside it.
407
+ - **SFTP host keys are verified by default on both backends.** Before, any
408
+ server key was accepted (asyncssh even overrode ssh_config pinning), so a
409
+ man-in-the-middle received the URI password. Now an unknown or changed key
410
+ fails before credentials are sent. paramiko uses `~/.ssh/known_hosts`,
411
+ ssh_config `UserKnownHostsFile` and `RejectPolicy`. **To keep the old
412
+ behaviour** add the host to `known_hosts`, or opt out explicitly:
413
+ `SftpBackend(connect_opts, paramiko.AutoAddPolicy(), known_hosts=None)` /
414
+ `AsyncsshSftpBackend(connect_opts={"known_hosts": None})`.
415
+ - **`ftps:` verifies the server certificate and host name by default.**
416
+ Before, any certificate was accepted and the password sent to it. For a
417
+ private CA pass `FtpBackend(ssl_context=ssl.create_default_context(cafile=...))`;
418
+ `FtpBackend(verify=False)` disables verification. Data connections now reuse
419
+ the TLS session (vsftpd, FileZilla Server).
420
+ - **Network operations have default timeouts.** HTTP/WebDAV/github/gitlab:
421
+ `(10, 60)` s connect/read (`with_session(..., timeout=...)`,
422
+ `RepoBackend(timeout=...)`); FTP: 30 s (`FtpBackend(timeout=...)`); paramiko
423
+ connect/banner/auth/channel-open: 30 s (`SftpBackend(..., timeout=...)`).
424
+ `timeout=None` restores the unbounded wait. Before, a stalled server hung
425
+ the caller forever.
426
+ - **asyncssh timeouts apply to single requests only**
427
+ (`AsyncsshSftpBackend(timeout=...)`, default 60 s). Recursive
428
+ `copy()`/`rm()` and `read_bytes()`/`write_bytes()` no longer raise after
429
+ 60 s while the work continued in the background. A timed-out request is
430
+ cancelled and raises the builtin `TimeoutError` on every Python version (on
431
+ 3.9/3.10 it was `concurrent.futures.TimeoutError`).
432
+ - **paramiko backend: ssh_config `ProxyJump` raises `NotImplementedError`.**
433
+ Before, it was ignored and the connection went direct. Use asyncssh, a
434
+ `ProxyCommand`, or `connect_opts["sock"]`.
435
+ - **asyncssh `rm()`/`copy()` error callbacks run in a worker thread**, so they
436
+ may call ordinary path methods; a sync SFTP call made on the bridge-loop
437
+ thread raises `RuntimeError` instead of hanging.
438
+
439
+ ### Added
440
+ - `SyncEvent.Error` and `SyncEvent.TypeMismatch` reporting; `str / path`
441
+ (`__rtruediv__`) on `Pathname` and `Uri`.
442
+ - `pathlib_next.testing.populate_fixture_tree(root)` and `FIXTURE_TREE`.
443
+ - `benchmarks/bench.py --save` (min/median/max ms per call as JSON under
444
+ `benchmarks/results/`) and `--samples`.
445
+ - `SyncEvent.Compare` and `SyncEvent.Skipped`; `uripath sync --size-only` and
446
+ `-v/--verbose`.
447
+ - `glob.parse_pattern()`, `glob.select()`, `glob.NonRelativePatternError`;
448
+ `recurse_symlinks=False` on `glob()`/`rglob()`; `FileStat.mode_known`.
449
+ - `close()` on `SftpBackend`/`AsyncsshSftpBackend`; `FtpBackend(timeout=,
450
+ ssl_context=, verify=)`; `RepoBackend(timeout=)`; `utils.LRU(on_evict=...)`
451
+ and `LRU.discard()`.
452
+ - `utils.is_safe_child_name(name, *, windows=False)` and
453
+ `utils.is_windows_flavoured(path)`: check that an untrusted name stays a
454
+ single component inside its parent before joining it onto a destination.
455
+
10
456
  ## [0.9.3] - 2026-08-16
11
457
 
12
458
  ### Fixed
@@ -30,7 +476,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
30
476
  Nothing raised. When something already occupied the truncated name the call
31
477
  instead failed with a bare `OSError: Failure`, so the symptom was either
32
478
  silent misplacement or an unexplained error depending on what happened to
33
- be there. Downstream, `pytruenas`'s documented
479
+ be there. Downstream, a consumer's documented
34
480
  `client.path(x).symlink_to(y)` route created a wrong link, and
35
481
  `PathSyncer`'s `symlink_mode="preserve"` (which hands `symlink_to()` the
36
482
  raw target string `readlink()` returned) mirrored such a link to the wrong
@@ -823,10 +1269,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
823
1269
  - Sync error handling.
824
1270
  - Generic Path Protocol based pathlib implementation for URI paths with file access support for sftp, http, file schemes.
825
1271
 
826
- [Unreleased]: https://github.com/jose-pr/pathlib-next/compare/v0.9.3...HEAD
1272
+ [Unreleased]: https://github.com/jose-pr/pathlib-next/compare/v0.9.4...HEAD
1273
+ [0.9.4]: https://github.com/jose-pr/pathlib-next/compare/v0.9.3...v0.9.4
827
1274
  [0.9.3]: https://github.com/jose-pr/pathlib-next/compare/v0.9.2...v0.9.3
828
1275
  [0.9.2]: https://github.com/jose-pr/pathlib-next/compare/v0.9.1...v0.9.2
829
1276
  [0.9.1]: https://github.com/jose-pr/pathlib-next/compare/v0.9.0...v0.9.1
1277
+ [0.9.0]: https://github.com/jose-pr/pathlib-next/compare/v0.8.6...v0.9.0
830
1278
  [0.8.6]: https://github.com/jose-pr/pathlib-next/compare/v0.8.5...v0.8.6
831
1279
  [0.8.5]: https://github.com/jose-pr/pathlib-next/compare/v0.8.4...v0.8.5
832
1280
  [0.8.4]: https://github.com/jose-pr/pathlib-next/compare/v0.8.3...v0.8.4
@@ -837,6 +1285,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
837
1285
  [0.7.0]: https://github.com/jose-pr/pathlib-next/compare/v0.6.0...v0.7.0
838
1286
  [0.6.0]: https://github.com/jose-pr/pathlib-next/compare/v0.5.0...v0.6.0
839
1287
  [0.5.0]: https://github.com/jose-pr/pathlib-next/compare/v0.4.1...v0.5.0
840
- [0.4.1]: https://github.com/jose-pr/pathlib-next/compare/v0.4.0...v0.4.1
841
- [0.4.0]: https://github.com/jose-pr/pathlib-next/releases/tag/v0.4.0
842
- [0.3.5]: https://github.com/jose-pr/pathlib-next/releases/tag/v0.3.5
1288
+ [0.4.1]: https://github.com/jose-pr/pathlib-next/compare/82caebc61dbd87928474425d7fd784baf6a4aaab...v0.4.1
1289
+ [0.4.0]: https://github.com/jose-pr/pathlib-next/compare/22cbb198d6b0c5c187d6f0dcc35990ace8eb2950...82caebc61dbd87928474425d7fd784baf6a4aaab
1290
+ [0.3.5]: https://github.com/jose-pr/pathlib-next/tree/22cbb198d6b0c5c187d6f0dcc35990ace8eb2950