pathlib-next 0.7.0__tar.gz → 0.8.1__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 (124) hide show
  1. {pathlib_next-0.7.0 → pathlib_next-0.8.1}/.gitignore +1 -0
  2. pathlib_next-0.8.1/CHANGELOG.md +486 -0
  3. {pathlib_next-0.7.0 → pathlib_next-0.8.1}/PKG-INFO +64 -23
  4. {pathlib_next-0.7.0 → pathlib_next-0.8.1}/README.md +48 -20
  5. pathlib_next-0.8.1/docs/api/mempath.md +3 -0
  6. pathlib_next-0.8.1/docs/api/path.md +4 -0
  7. pathlib_next-0.8.1/docs/api/testing.md +3 -0
  8. pathlib_next-0.8.1/docs/api/uri.md +5 -0
  9. pathlib_next-0.8.1/docs/api/utils.md +6 -0
  10. pathlib_next-0.8.1/docs/benchmarks.md +204 -0
  11. {pathlib_next-0.7.0 → pathlib_next-0.8.1}/docs/divergences.md +15 -4
  12. pathlib_next-0.8.1/docs/guides/cli.md +34 -0
  13. {pathlib_next-0.7.0 → pathlib_next-0.8.1}/docs/guides/extending.md +47 -7
  14. pathlib_next-0.8.1/docs/guides/schemes.md +133 -0
  15. {pathlib_next-0.7.0 → pathlib_next-0.8.1}/docs/index.md +21 -7
  16. pathlib_next-0.8.1/examples/az_listing.py +37 -0
  17. pathlib_next-0.8.1/examples/data_and_archive.py +97 -0
  18. pathlib_next-0.8.1/examples/ftp_listing.py +49 -0
  19. pathlib_next-0.8.1/examples/github_listing.py +52 -0
  20. pathlib_next-0.8.1/examples/gitlab_listing.py +54 -0
  21. pathlib_next-0.8.1/examples/gs_listing.py +36 -0
  22. {pathlib_next-0.7.0 → pathlib_next-0.8.1}/examples/http_listing.py +5 -4
  23. pathlib_next-0.8.1/examples/s3_listing.py +46 -0
  24. pathlib_next-0.8.1/examples/webdav_roundtrip.py +72 -0
  25. {pathlib_next-0.7.0 → pathlib_next-0.8.1}/mkdocs.yml +9 -2
  26. pathlib_next-0.8.1/pyproject.toml +79 -0
  27. {pathlib_next-0.7.0 → pathlib_next-0.8.1}/src/pathlib_next/__init__.py +0 -1
  28. {pathlib_next-0.7.0 → pathlib_next-0.8.1}/src/pathlib_next/fspath.py +31 -0
  29. {pathlib_next-0.7.0 → pathlib_next-0.8.1}/src/pathlib_next/path.py +164 -27
  30. {pathlib_next-0.7.0 → pathlib_next-0.8.1}/src/pathlib_next/protocols/io.py +1 -0
  31. pathlib_next-0.8.1/src/pathlib_next/testing.py +197 -0
  32. pathlib_next-0.8.1/src/pathlib_next/tools/__init__.py +1 -0
  33. pathlib_next-0.8.1/src/pathlib_next/tools/uripath.py +180 -0
  34. {pathlib_next-0.7.0 → pathlib_next-0.8.1}/src/pathlib_next/uri/__init__.py +197 -39
  35. {pathlib_next-0.7.0 → pathlib_next-0.8.1}/src/pathlib_next/uri/query.py +4 -0
  36. {pathlib_next-0.7.0 → pathlib_next-0.8.1}/src/pathlib_next/uri/schemes/__init__.py +13 -1
  37. pathlib_next-0.8.1/src/pathlib_next/uri/schemes/_gitrepo.py +133 -0
  38. pathlib_next-0.8.1/src/pathlib_next/uri/schemes/archive/__init__.py +35 -0
  39. pathlib_next-0.8.1/src/pathlib_next/uri/schemes/archive/_base.py +295 -0
  40. pathlib_next-0.8.1/src/pathlib_next/uri/schemes/archive/tar.py +40 -0
  41. pathlib_next-0.8.1/src/pathlib_next/uri/schemes/archive/zip.py +153 -0
  42. pathlib_next-0.8.1/src/pathlib_next/uri/schemes/az.py +297 -0
  43. pathlib_next-0.7.0/src/pathlib_next/uri/schemes/webdav.py → pathlib_next-0.8.1/src/pathlib_next/uri/schemes/dav.py +71 -31
  44. {pathlib_next-0.7.0 → pathlib_next-0.8.1}/src/pathlib_next/uri/schemes/ftp.py +51 -15
  45. pathlib_next-0.8.1/src/pathlib_next/uri/schemes/git/__init__.py +4 -0
  46. pathlib_next-0.8.1/src/pathlib_next/uri/schemes/git/_base.py +39 -0
  47. pathlib_next-0.8.1/src/pathlib_next/uri/schemes/git/github.py +9 -0
  48. pathlib_next-0.8.1/src/pathlib_next/uri/schemes/git/gitlab.py +9 -0
  49. pathlib_next-0.8.1/src/pathlib_next/uri/schemes/github.py +101 -0
  50. pathlib_next-0.8.1/src/pathlib_next/uri/schemes/gitlab.py +129 -0
  51. pathlib_next-0.8.1/src/pathlib_next/uri/schemes/gs.py +251 -0
  52. pathlib_next-0.8.1/src/pathlib_next/uri/schemes/http.py +632 -0
  53. {pathlib_next-0.7.0 → pathlib_next-0.8.1}/src/pathlib_next/uri/schemes/s3.py +87 -3
  54. pathlib_next-0.8.1/src/pathlib_next/uri/schemes/sftp/__init__.py +351 -0
  55. pathlib_next-0.8.1/src/pathlib_next/uri/schemes/sftp/_asyncssh.py +739 -0
  56. pathlib_next-0.8.1/src/pathlib_next/uri/schemes/sftp/_paramiko.py +125 -0
  57. pathlib_next-0.8.1/src/pathlib_next/uri/source.py +272 -0
  58. {pathlib_next-0.7.0 → pathlib_next-0.8.1}/src/pathlib_next/utils/__init__.py +25 -3
  59. pathlib_next-0.8.1/src/pathlib_next/utils/archive.py +149 -0
  60. pathlib_next-0.8.1/src/pathlib_next/utils/checksum.py +31 -0
  61. {pathlib_next-0.7.0 → pathlib_next-0.8.1}/src/pathlib_next/utils/stat.py +14 -8
  62. {pathlib_next-0.7.0 → pathlib_next-0.8.1}/src/pathlib_next/utils/sync.py +67 -23
  63. pathlib_next-0.8.1/tests/conftest.py +1208 -0
  64. pathlib_next-0.8.1/tests/test_archive_uri.py +394 -0
  65. pathlib_next-0.8.1/tests/test_az.py +90 -0
  66. pathlib_next-0.8.1/tests/test_az_fake.py +164 -0
  67. pathlib_next-0.8.1/tests/test_contract.py +255 -0
  68. pathlib_next-0.8.1/tests/test_dav.py +50 -0
  69. pathlib_next-0.8.1/tests/test_gitrepo.py +288 -0
  70. pathlib_next-0.8.1/tests/test_gs.py +89 -0
  71. pathlib_next-0.8.1/tests/test_gs_fake.py +145 -0
  72. pathlib_next-0.8.1/tests/test_http.py +459 -0
  73. pathlib_next-0.8.1/tests/test_http_live.py +132 -0
  74. pathlib_next-0.8.1/tests/test_http_parser.py +237 -0
  75. {pathlib_next-0.7.0 → pathlib_next-0.8.1}/tests/test_parity_io.py +65 -1
  76. pathlib_next-0.8.1/tests/test_path_gaps.py +104 -0
  77. {pathlib_next-0.7.0 → pathlib_next-0.8.1}/tests/test_pathname.py +24 -0
  78. pathlib_next-0.8.1/tests/test_plugins.py +123 -0
  79. pathlib_next-0.8.1/tests/test_properties.py +366 -0
  80. {pathlib_next-0.7.0 → pathlib_next-0.8.1}/tests/test_s3.py +85 -0
  81. pathlib_next-0.8.1/tests/test_sftp.py +430 -0
  82. pathlib_next-0.8.1/tests/test_sftp_asyncssh.py +712 -0
  83. {pathlib_next-0.7.0 → pathlib_next-0.8.1}/tests/test_smoke.py +41 -3
  84. {pathlib_next-0.7.0 → pathlib_next-0.8.1}/tests/test_sync.py +54 -0
  85. {pathlib_next-0.7.0 → pathlib_next-0.8.1}/tests/test_uri_path.py +43 -0
  86. pathlib_next-0.8.1/tests/test_uripath_tool.py +90 -0
  87. pathlib_next-0.8.1/tests/test_utils.py +369 -0
  88. pathlib_next-0.8.1/tests/test_walk.py +117 -0
  89. {pathlib_next-0.7.0 → pathlib_next-0.8.1}/tests/test_webdav.py +90 -1
  90. pathlib_next-0.7.0/CHANGELOG.md +0 -263
  91. pathlib_next-0.7.0/docs/api/reference.md +0 -3
  92. pathlib_next-0.7.0/docs/guides/schemes.md +0 -78
  93. pathlib_next-0.7.0/pyproject.toml +0 -37
  94. pathlib_next-0.7.0/src/pathlib_next/testing.py +0 -132
  95. pathlib_next-0.7.0/src/pathlib_next/uri/schemes/archive.py +0 -266
  96. pathlib_next-0.7.0/src/pathlib_next/uri/schemes/http.py +0 -172
  97. pathlib_next-0.7.0/src/pathlib_next/uri/schemes/sftp.py +0 -130
  98. pathlib_next-0.7.0/src/pathlib_next/uri/source.py +0 -87
  99. pathlib_next-0.7.0/tests/conftest.py +0 -63
  100. pathlib_next-0.7.0/tests/test_archive_uri.py +0 -198
  101. pathlib_next-0.7.0/tests/test_contract.py +0 -29
  102. pathlib_next-0.7.0/tests/test_http.py +0 -70
  103. pathlib_next-0.7.0/tests/test_sftp.py +0 -158
  104. pathlib_next-0.7.0/tests/test_utils.py +0 -68
  105. {pathlib_next-0.7.0 → pathlib_next-0.8.1}/LICENSE +0 -0
  106. {pathlib_next-0.7.0 → pathlib_next-0.8.1}/docs/changelog.md +0 -0
  107. {pathlib_next-0.7.0 → pathlib_next-0.8.1}/examples/local_and_mem.py +0 -0
  108. {pathlib_next-0.7.0 → pathlib_next-0.8.1}/examples/sftp_sync.py +0 -0
  109. {pathlib_next-0.7.0 → pathlib_next-0.8.1}/src/pathlib_next/mempath.py +0 -0
  110. {pathlib_next-0.7.0 → pathlib_next-0.8.1}/src/pathlib_next/protocols/__init__.py +0 -0
  111. {pathlib_next-0.7.0 → pathlib_next-0.8.1}/src/pathlib_next/protocols/fs.py +0 -0
  112. {pathlib_next-0.7.0 → pathlib_next-0.8.1}/src/pathlib_next/py.typed +0 -0
  113. {pathlib_next-0.7.0 → pathlib_next-0.8.1}/src/pathlib_next/uri/schemes/data.py +0 -0
  114. {pathlib_next-0.7.0 → pathlib_next-0.8.1}/src/pathlib_next/uri/schemes/file.py +0 -0
  115. {pathlib_next-0.7.0 → pathlib_next-0.8.1}/src/pathlib_next/utils/glob.py +0 -0
  116. {pathlib_next-0.7.0 → pathlib_next-0.8.1}/tests/test_data_uri.py +0 -0
  117. {pathlib_next-0.7.0 → pathlib_next-0.8.1}/tests/test_ftp.py +0 -0
  118. {pathlib_next-0.7.0 → pathlib_next-0.8.1}/tests/test_glob.py +0 -0
  119. {pathlib_next-0.7.0 → pathlib_next-0.8.1}/tests/test_local.py +0 -0
  120. {pathlib_next-0.7.0 → pathlib_next-0.8.1}/tests/test_mempath.py +0 -0
  121. {pathlib_next-0.7.0 → pathlib_next-0.8.1}/tests/test_parity_pure.py +0 -0
  122. {pathlib_next-0.7.0 → pathlib_next-0.8.1}/tests/test_query.py +0 -0
  123. {pathlib_next-0.7.0 → pathlib_next-0.8.1}/tests/test_source.py +0 -0
  124. {pathlib_next-0.7.0 → pathlib_next-0.8.1}/tests/test_uri_parse.py +0 -0
@@ -14,6 +14,7 @@ CLAUDE.local.md
14
14
  __pycache__/
15
15
  *.py[cod]
16
16
  .pytest_cache/
17
+ .hypothesis/
17
18
  .mypy_cache/
18
19
  .ruff_cache/
19
20
  .coverage
@@ -0,0 +1,486 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.8.1] - 2026-07-16
11
+
12
+ ### Fixed
13
+ - **`LocalPath.walk()`/`rm()` raised `TypeError: cannot unpack non-iterable
14
+ DirEntry object` on Python 3.11/3.12.** Those stdlib versions define their
15
+ own `pathlib.Path._scandir()` (returning raw `os.scandir()` `DirEntry`
16
+ objects), which sits ahead of this project's `_scandir()` in `LocalPath`'s
17
+ MRO and silently shadowed it -- breaking the `(name, FileStat|None)`
18
+ contract `walk()`/`glob()`/`rm()` expect. `LocalPath` now defines its own
19
+ `_scandir()` explicitly, reusing each `DirEntry`'s cached `lstat()` so the
20
+ perf win from `_scandir()` unification is preserved. On 3.12+, stdlib
21
+ `pathlib.Path` also defines its own `walk()` ahead of ours in the MRO, and
22
+ that stdlib `walk()` treats `self._scandir()`'s return value as a context
23
+ manager (`with scandir_it:`) -- our own `_scandir()` is a plain generator,
24
+ so stdlib's `walk()` raised `TypeError: 'generator' object does not
25
+ support the context manager protocol` even with the override above.
26
+ `LocalPath` now also overrides `walk()` explicitly, routing to this
27
+ project's own implementation regardless of Python version. Introduced in
28
+ 0.8.0 (`8cdbefa`), exposed on the CI 3.11/3.12 legs.
29
+ - **`Test No-Extras` CI job was red.** `tests/test_smoke.py` unconditionally
30
+ constructed an `http://`/`sftp://` `UriPath` in two tests, requiring
31
+ `requests`/`paramiko` even though the no-extras job installs neither; a
32
+ third test wrongly assumed `S3Path` requires `boto3` to register (it only
33
+ needs `botocore`, imported lazily inside a method). The two hard tests now
34
+ `pytest.importorskip` their extra; the `S3Path` check now probes for
35
+ `botocore`. Introduced in 0.8.0 (`94bd545`/`8cdbefa`), fixed with the
36
+ expected skip count (2) verified in a real no-extras venv.
37
+ - **Importable on a clean Python 3.9 install.** `pathlib_next.utils` used
38
+ `typing.ParamSpec` (3.10+), falling back to `typing_extensions.ParamSpec` and
39
+ then to a bare `typing.TypeVar`. A `TypeVar` has no `.args`, so the
40
+ `*args: K.args` annotations raised `AttributeError: 'TypeVar' object has no
41
+ attribute 'args'` at import time, making `import pathlib_next` fail on 3.9
42
+ whenever `typing_extensions` was absent. Since `typing_extensions` is not a
43
+ runtime dependency, this broke a plain `pip install pathlib_next` on 3.9. The
44
+ final fallback is now a minimal `ParamSpec` shim providing `.args`/`.kwargs`,
45
+ so no runtime dependency is added and 3.10+ keeps using `typing.ParamSpec`
46
+ unchanged.
47
+
48
+ ## [0.8.0] - 2026-07-13
49
+
50
+ ### Added
51
+ - `uripath` command-line tool (`pathlib_next.tools.uripath`) for reading,
52
+ writing, copying, removing, and syncing local or URI-backed paths. `-`
53
+ works as stdin/stdout for byte-stream operations.
54
+ - Recursive benchmark probes for local, memory, object-store, and SFTP
55
+ backends, including provider call-shape rows for recursive deletes.
56
+ - Provider-native recursive delete overrides for `S3Path`, `GsPath`, and
57
+ `AzPath`, with bucket/container-root guards.
58
+ - `git:` convenience dispatch over the existing `github:`/`gitlab:` providers, plus explicit `git+github:` and `git+gitlab:` forms for self-hosted or enterprise instances. `git:` only auto-detects public `github.com`/`gitlab.com`; ambiguous hosts now raise a clear `ValueError` naming the explicit alternatives.
59
+ - HTTP write support (`PUT`, customizable to `POST` or other verbs via `write_method` configuration or `with_session()`) for `HttpPath`.
60
+ - HTTP delete support (`DELETE` for `unlink()` and `rmdir()`) for `HttpPath`.
61
+ - Comprehensive HTTP exception mapping in `HttpPath` translating client/server/timeout/connection errors into standard built-in `OSError` subclasses (`FileNotFoundError`, `PermissionError`, `FileExistsError`, `TimeoutError`, `ConnectionError`, `NotImplementedError`, or generic `OSError`).
62
+ - Dynamic loading of custom URI scheme plugins via standard Python packaging entry points under the `"pathlib_next.schemes"` group, allowing third-party package extensibility.
63
+ - Lazy-loading for all builtin scheme implementations (s3, sftp, http, etc.) to significantly reduce start-up and import overhead when heavy libraries are not needed.
64
+ - MD5 and SHA-256 checksum helpers in `pathlib_next.utils` (`md5` and `sha256`).
65
+ - Optional `checksum` parameter in `PathSyncer`, defaulting to the new `md5` helper.
66
+ - Recursive directory copying via `Path.copy(recursive=True)`.
67
+ - Support for recursive folder moves falling back to recursive copy + recursive delete when `rename` is not supported.
68
+ - Archive utilities `make_archive` and `unpack_archive` supporting ZIP and TAR formats using memory-efficient chunk streaming.
69
+ - Hierarchical test contracts: `PurePathContract` (pure path operations) and `ReadPathContract` (read-only path operations), allowing contract-based verification of read-only and memory/archive paths.
70
+ - Contract test suites wired for `DataUri`, `ZipUri`, `TarUri`, and `HttpPath`.
71
+ - Dedicated unit tests for `Path.walk()`, `samefile()`, and `Stat` device queries.
72
+ - Comprehensive runnable examples in `examples/` for URI schemes: offline (`data_and_archive.py`) and environment-variable configured ones (`ftp_listing.py`, `webdav_roundtrip.py`, `s3_listing.py`).
73
+ - Split monolith API reference documentation into per-module pages (`path`, `uri`, `mempath`, `utils`, `testing`).
74
+ - Complete docstring coverage for all public methods/properties across `Pathname`, `Path`, `Uri`, `UriPath`, and protocols, and configured `mkdocs` to enforce docstring presence (`show_if_no_docstring: false`).
75
+ - Detailed documentation of contract testing levels (`PurePathContract`, `ReadPathContract`, `PathContract`) in the extending guide.
76
+ - In-process real-server contract tests for `FtpPath` (pyftpdlib), `DavPath` (wsgidav/cheroot), and `S3Path` (moto mock_aws): `TestFtpContract`, `TestDavContract`, `TestS3Contract` run the full `PathContract` suite against live local servers.
77
+ - `ftp_server`, `dav_server`, and `s3_server` pytest fixtures in `conftest.py` serving ephemeral in-process servers with pre-populated `fixture_tree` contents.
78
+ - Entry-point declarations in `pyproject.toml` (`pathlib_next.schemes` group) for all built-in schemes, enabling pip-installed external packages to auto-register custom schemes.
79
+ - Plugin discovery tests in `tests/test_plugins.py` covering `_load_entry_point`, `_load_builtin_scheme`, and `get_scheme_cls` integration.
80
+ - Property-based tests (`hypothesis`, new `dev` extra) in `tests/test_properties.py`: URI parse/format round-trip identity, join associativity, and parity with `pathlib.PurePosixPath` for `segments`/`name`/`relative_to`/`match`/`is_relative_to`.
81
+ - `ZipUri`/`ArchiveUri` archive handle registry: independently-constructed `UriPath("zip:...")`/`"tar:..."` instances pointing at the same outer archive now share one `_ArchiveBackend` (keyed by backend class + outer URI, a `weakref.WeakValueDictionary`) instead of each opening its own handle -- fixes stale reads and out-of-sync writes across separately-constructed instances. The backend closes its handle automatically (`__del__`) once every referencing path is garbage-collected.
82
+ - Full `ZipUri` write support: `unlink()`, `rmdir()` (empty-dir check, mirrors `S3Path.rmdir()`), `rename()` (renames a directory's nested entries too), and overwriting an existing entry's content (previously `open("w")` on an existing entry silently appended a duplicate zipfile entry instead of replacing it). All four go through a new safe full-archive rewrite (`_ZipBackend._rewrite`) since `zipfile` has no in-place entry mutation: writes to a temp file beside the outer archive, then atomically replaces it (`os.replace`). Requires a local (`file:`) outer archive, same as existing new-entry writes.
83
+ - `archive:` catch-all URI scheme: auto-detects zip vs. tar for the outer archive (filename extension first, then a magic-byte sniff shared with `unpack_archive` via the new `utils.archive._detect_format` helper) instead of requiring the caller to know the format up front. Explicit `archive+zip:`/`archive+tar:` forms skip detection outright. `archive:...!/x` and `zip:...!/x` pointing at the same outer archive share one backend (same registry as `zip:`/`tar:`), and write support (new/overwritten entries, `unlink`/`rmdir`/`rename`) works through `archive:` exactly as it does through `zip:` when the detected format is zip and the outer archive is local -- tar-detected instances correctly raise `NotImplementedError` on any write attempt.
84
+ - New `gs:` (Google Cloud Storage) and `az:` (Azure Blob Storage) URI schemes (`GsPath`/`AzPath` in `pathlib_next.uri.schemes.gs`/`.az`, with `GsBackend`/`AzBackend` for credential/endpoint override): `gs://bucket/key/path` and `az://account/container/key/path`. Both support full `PathContract` (read/write/list/delete/rename), reusing the prefix-emulation directory semantics of `S3Path` (no real directories; `is_dir()` checks for keys under `"<path>/"`, `mkdir()` writes a zero-byte `"<path>/"` marker, `rmdir()` requires empty). Wired to `PathContract` against faithful in-process fake JSON/XML REST API servers (`gcs_api_server`/`gs_server`, `az_api_server`/`az_server` in `conftest.py`), plus scheme-specific unit tests. New `examples/gs_listing.py`/`examples/az_listing.py` (env-var gated, fail-soft). Like `S3Path`, both cache one service client per backend instance (thread-safe for both SDKs). Report no mtime (`st_mtime=0`, documented divergence). Each is its own `pyproject.toml` extra: `gs` (`google-cloud-storage`) and `az` (`azure-storage-blob`). `rename()` uses server-side copy+delete (same bucket/container only) instead of the generic download+upload+delete `move()` fallback.
85
+ - New `github:`/`gitlab:` read-only URI schemes (`GitHubPath` in `pathlib_next.uri.schemes.github`, `GitLabPath` in `pathlib_next.uri.schemes.gitlab`, sharing a private `_RepoApiPath` base and a plain-`requests` `RepoBackend`, no PyGithub/python-gitlab SDK): `<scheme>://host/owner/repo/path/in/repo?ref=<ref>`, `ref` always optional in the query string. `GitHubPath` lists via the contents API (one call gives type/size for a whole directory) and reads file bodies via the `raw` media type; `GitLabPath` lists via the tree API (no size, so only directory entries get a stat hint) and reads via the files `/raw` endpoint, resolving+caching the project's default branch itself when `ref` is omitted (GitLab's file endpoints -- unlike its tree endpoint -- 400 if `ref` is missing, confirmed live against gitlab.com). `host` defaults to the public SaaS host; any other host is treated as GitHub Enterprise (`https://{host}/api/v3`) or a self-hosted GitLab (`https://{host}/api/v4`). Auth via a bearer token (`RepoBackend(token=...)`) or URI userinfo. Both reuse the `http` extra (no new extra added). Wired to `ReadPathContract` against faithful in-process fake API servers (`github_api_server`/`gitlab_api_server` in `conftest.py`), plus scheme-specific unit tests (ref propagation through `iterdir()`, rate-limit/error translation, GitHub Enterprise API-base derivation, GitLab dir-vs-file stat disambiguation). New `examples/github_listing.py`/`examples/gitlab_listing.py`.
86
+ - New `sftp-async` extra: an `AsyncsshSftpBackend` (`asyncssh`, async internally, bridged to a sync API through one shared background event loop) alongside the existing paramiko-based `SftpBackend`. Auto-selected when `asyncssh` is importable (paramiko remains the fallback); override via the `PATHLIB_NEXT_SFTP_BACKEND` env var (`"paramiko"`/`"asyncssh"`/`"auto"`) or a `SftpPath._default_backend_cls` subclass hook -- precedence, highest to lowest: explicit `backend=` kwarg > `_default_backend_cls` > env var > auto-detect. `PATHLIB_NEXT_SFTP_BACKEND=asyncssh` with the package missing raises immediately rather than silently falling back. Connections are cached per `(backend, source)` (no thread dimension needed -- one shared connection serves concurrent calls from any calling thread, unlike paramiko's `(backend, source, thread)` cache). Works on Python 3.9 too via a verified version pin (`asyncssh<2.22`; current `asyncssh` needs >=3.10) resolved automatically through `pyproject.toml` environment markers -- no code branching. New `SftpPath.symlink_to()`/`readlink()` (both backends -- core SFTPv3 operations) and `hardlink_to()` (asyncssh backend only; paramiko's `SFTPClient` has no hard-link operation, so it raises `NotImplementedError` immediately with no server round trip). `chmod(follow_symlinks=False)` now works on the asyncssh backend (native support) while still raising `NotImplementedError` on paramiko (no `lchmod` equivalent). This is additive, not a performance change -- the (separate, unscheduled) concurrent-fan-out work that would actually exploit asyncssh's pipelining remains future work.
87
+
88
+ ### Changed
89
+ - Recursive `Path.rm()` now deletes bottom-up using non-following listing
90
+ metadata where available, avoiding traversal through directory symlinks
91
+ and reducing extra stat calls for metadata-rich backends.
92
+ - Asyncssh SFTP recursive copy/remove now use native bounded async helpers
93
+ for ordinary files/directories instead of recursing through sync path
94
+ methods on the bridge loop.
95
+ - `PathSyncer` reuses child metadata during tree sync when that metadata is
96
+ consistent with the active symlink-following policy.
97
+ - Replaced the third-party `htmllistparse` and `bs4` directory listing scraper dependencies with a hand-rolled, zero-dependency `html.parser.HTMLParser` subclass (`_DirectoryListingParser`), dropping both from the `http` extra in `pyproject.toml`. Verified equivalent output (name/size/modified, both Apache-`<pre>` and nginx-`<table>` formats) against the replaced `bs4`+`html5lib`+`htmllistparse` implementation, and 2.9x-6.2x faster depending on format/listing size (`benchmarks/bench.py`'s `8`/`9` entries benchmark the new parser alone going forward, since the old implementation no longer exists in the tree).
98
+ - Matrix expansion in GitHub Actions CI to test Python 3.10, 3.11, and 3.12 (on Ubuntu).
99
+ - Added a "no-extras" CI job to run tests without optional dependencies installed.
100
+ - `PathSyncer.log()` now logs through `logging.getLogger("pathlib_next.sync")`
101
+ at `INFO` instead of calling `print()` -- stdout consumers must configure
102
+ logging (e.g. `logging.basicConfig()`) to see sync progress again.
103
+ `EVENT_LOG_FORMAT` switched from `str.format` (`{event}`) to `%`-style
104
+ placeholders to match, and `log()` remains overridable for custom routing.
105
+ - `SyncEvent` members are now numbered sequentially (previously a mix of
106
+ explicit ints and `enum.auto()`, which raised a `DeprecationWarning` on
107
+ Python 3.13). Values are not part of any documented/persisted contract.
108
+ - Optimized performance across pure paths and URIs:
109
+ - Cache `Uri.segments` in a slot to avoid re-splitting the path string on every access.
110
+ - Cache `Uri.suffix` and `Uri.stem` in slots.
111
+ - Optimize `Source.__bool__` to use lazy index accesses and avoid tuple iteration.
112
+ - Short-circuit `Query.__new__` when the input is already a matching `Query` instance.
113
+ - `Uri._parse_uri()`/`Source.from_str()`: one-pass component extraction from `uritools.urisplit()`'s raw fields instead of calling its seven `get*()` accessors, each of which independently re-`rpartition`s the authority string and re-decodes. Ported (not reinvented) from `uritools.SplitResult`'s own property/getter logic -- including one of its quirks, reproduced on purpose (see `uri/source.py::_split_authority`) -- and verified equivalent by fuzzing 20,000+ generated URIs against uritools as the oracle (`tests/test_properties.py`, which stays the enforcement mechanism, not just a one-time check). `Uri._format_parsed_parts()`/`DavPath._wire_uri()`: direct string assembly instead of `uritools.uricompose()`'s full re-validation, for the same reason and with the same fuzzing rigor (`uri/source.py::_compose_uri`) -- both bypass a general-purpose library's necessarily-defensive validation only where the input is already known-canonical (parsed or otherwise internally normalized), not for arbitrary/untrusted URIs. `uritools` itself is unchanged as a dependency and remains the parsing/composing engine underneath both fast paths -- a hand-rolled RFC 3986 implementation was evaluated and rejected (verdict: slower or not worth the permanent edge-case-ownership cost). Measured on `.venv/3.12.10`, unique URIs per iteration (a repeated-URI microbenchmark flatters by masking real per-call cost): the full `Uri(unique_url).as_uri()` round trip (parse + compose, both changes) is ~20-25% faster; the parse side alone, isolated from `Uri.__new__`'s slot-initialization overhead (unaffected by this work), is ~17-30% faster on its own (see `benchmarks/bench.py`'s `1b`/`1c` entries).
114
+ - New `Path._scandir()` / `UriPath._scandir()` protocol: schemes whose
115
+ listing call already returns type/size/mtime for every child (HTML
116
+ directory index, WebDAV PROPFIND, SFTP `listdir_attr`, FTP MLSD, an S3
117
+ `list_objects_v2` page) can now yield `(name, FileStat)` pairs directly,
118
+ and `walk()`/`glob()` answer `is_dir()` from that instead of a `stat()`
119
+ round trip per entry -- a remote-tree walk goes from O(entries) requests
120
+ to O(dirs). `HttpPath`, `DavPath`, `SftpPath`, `FtpPath`, and `S3Path` all
121
+ adopt it; `_listdir()`/`iterdir()` remain fully supported for schemes that
122
+ don't override `_scandir()` (no behavior change, no win). On the local
123
+ `http_server` benchmark fixture, HTTP glob/walk over the fixture tree are
124
+ ~89-94% faster than the already-optimized pre-`_scandir()` baseline (see
125
+ `benchmarks/bench.py`). `HttpPath` also drops its `_isdir` instance-cache
126
+ slot and its `is_dir()`/`is_file()` overrides (now derived generically
127
+ from `stat()`, like every other scheme) in favor of a single-use stat
128
+ hint seeded by `_scandir()`; `DavPath`'s now-redundant `iterdir()`/
129
+ `is_dir()`/`is_file()` overrides are removed for the same reason.
130
+ - **Breaking** (pre-1.0, no compat shim kept): `uri/schemes/` module naming convention
131
+ -- every module is now named after the main URI scheme it implements (TLS/secondary
132
+ variants live with their main scheme). `webdav.py` -> `dav.py`; import from
133
+ `pathlib_next.uri.schemes.dav` (the old `pathlib_next.uri.schemes.webdav` path no
134
+ longer exists). `archive.py` -> `archive/` package (`_base.py` shared machinery,
135
+ `zip.py`, `tar.py`) -- import-compatible for free, `pathlib_next.uri.schemes.archive`
136
+ still resolves (now the package) and re-exports `ArchiveUri`/`ZipUri`/`TarUri`.
137
+ `sftp.py` -> `sftp/` package (`_paramiko.py` holds the existing paramiko-backed
138
+ `SftpBackend`; `__init__.py` keeps `SftpPath`/`BaseSftpBackend`) -- same free
139
+ import-compat, `pathlib_next.uri.schemes.sftp` still resolves and re-exports
140
+ `SftpPath`/`BaseSftpBackend`/`SftpBackend`. Prepares the layout for an upcoming
141
+ second (asyncssh) backend; `SftpBackend` gained a `default()` classmethod factory
142
+ so `SftpPath._initbackend()` doesn't need to import `paramiko` itself.
143
+ - `SftpPath`'s connection caching moved from an external cache wrapping `backend.client()`
144
+ calls to being each backend's own responsibility (`SftpPath._sftpclient` is now a
145
+ trivial `self.backend.client(self.source)`, no per-backend branching). Needed so the
146
+ new asyncssh backend can use its own `(backend, source)`-keyed cache (see the
147
+ `sftp-async` entry above) without `SftpPath` needing to know which caching scheme
148
+ applies. **Behavior-affecting for custom `BaseSftpBackend` subclasses**: a `client()`
149
+ override that doesn't cache internally will now be called on every `_sftpclient`
150
+ access, not just on a cache miss -- `SftpBackend`/`AsyncsshSftpBackend` both cache
151
+ internally, so this only matters for third-party/test-double backends.
152
+ - `TestSftpContract`'s in-process test server (`tests/conftest.py::sftp_server`) is now
153
+ asyncssh's own `SFTPServer` (chrooted to `fixture_tree`) instead of a ~150-line
154
+ hand-rolled paramiko `ServerInterface`/`SFTPServerInterface` -- a client backend choice
155
+ is independent of which library the test server uses (verified: a paramiko client
156
+ talks standard SFTP to an asyncssh server fine). `TestSftpContract` itself is now
157
+ parametrized across both client backends (`paramiko`, `asyncssh`).
158
+
159
+ ### Fixed
160
+ - Recursive delete on exact object-store keys now treats the exact object as
161
+ the addressed path before considering a `"<key>/"` prefix tree, preventing
162
+ accidental prefix-tree deletion for `S3Path`, `GsPath`, and `AzPath`.
163
+ - Azure recursive delete falls back from `delete_blobs()` to per-blob
164
+ deletion when a provider or emulator rejects the batch API.
165
+ - `Uri.relative_to()` computed the remaining segments from the raw `.segments` property instead of the root-aware `_segments_of()` helper `is_relative_to()` already used -- `Uri("/").segments` is the 2-tuple `("", "")` (an artifact of `"/".split("/")`), so `relative_to(<root>)` silently dropped the child's only real segment (e.g. `Uri("/a").relative_to(Uri("/"))` produced `""` instead of `"a"`). Found by the new property-based test suite.
166
+ - `TestSftpContract`'s in-process paramiko test server (`tests/conftest.py::sftp_server`) deadlocked every real I/O test: its `_SSHServer.check_channel_subsystem_request()` override returned `name == "sftp"` directly instead of delegating to `paramiko.ServerInterface`'s default implementation, which is what actually instantiates and starts the registered `SFTPServer` handler thread (`handler.start()`). Without it, the channel was reported "hooked up" to the client but nothing server-side ever read from or responded on it, so `SFTPClient.from_transport()` blocked forever in version negotiation. Fixed by removing the override (the inherited default already does exactly what the removed comment claimed it did).
167
+ - `SftpPath._mkdir()`/`_open(mode="x")` propagated a generic, untyped `OSError("Failure")` when the target already existed -- SFTPv3 has no dedicated "already exists" status code, so paramiko's server-side `convert_errno()` falls through to `SFTP_FAILURE` for `EEXIST` (unlike `ENOENT`, which it does map, giving a proper `FileNotFoundError`). Both now check `self.exists()` on failure and raise `FileExistsError` to match every other scheme's `mkdir`/`touch(exist_ok=False)` contract (mirrors `FtpPath._mkdir()`'s existing check-after-failure pattern). Found by `TestSftpContract` once the deadlock above was fixed and it could actually run.
168
+ - `FtpPath.stat()` returned `FileNotFoundError` for the FTP root path `"/"` because `_mlsd_entry()` has no parent directory to query; now uses `CWD /` to confirm the root exists as a directory.
169
+ - `FtpPath.rmdir()` propagated raw `ftplib.error_perm` (550) instead of `OSError` when the directory was non-empty, violating the pathlib contract.
170
+ - `FtpPath.chmod()` raised `ftplib.error_perm` when the server rejected `SITE CHMOD` (pyftpdlib does not implement it); now converts to `NotImplementedError` so `Path.copy()` silently skips the metadata step.
171
+ - `pytest filterwarnings` updated to suppress `boto3.exceptions.PythonDeprecationWarning` (boto3 EOL notice for Python 3.9, inherits `Warning` not `DeprecationWarning`) and `ResourceWarning` from daemon-thread server socket cleanup at GC teardown.
172
+ - README and docs landing page were still describing the pre-0.6.0 scheme set:
173
+ the capability matrix, extras table, and quick starts now cover `data:`,
174
+ `ftp(s):`, `zip:`/`tar:`, `dav(s):`, and `s3:` (all shipped in 0.6.0/0.7.0
175
+ but previously only documented in the Schemes guide).
176
+ - `LRU.maxsize` setter raised `TypeError` when shrinking below the current
177
+ fill (`OrderedDict.pop()` was called with the `last=False` kwarg meant for
178
+ `popitem()`).
179
+ - `DavPath.rmdir()` mapped directly to WebDAV `DELETE`, which is recursive
180
+ by spec (RFC 4918) -- it silently deleted non-empty collections instead
181
+ of enforcing pathlib's "must be empty" contract like every other scheme.
182
+ Now does a depth-1 PROPFIND first and raises `OSError` (`ENOTEMPTY`) if
183
+ children exist. The native recursive `DELETE` is still available, and
184
+ cheaper than the base class's client-side walk, via the new
185
+ `DavPath.rm(recursive=True)` override (one request).
186
+ - `Uri._make_child_relpath()` doubled the join slash for any scheme whose
187
+ `path` already ends in "/" (e.g. `f"{self.path}/{name}"` on an HTTP/DAV
188
+ directory path produced `"//name"`); also now treats an empty path with
189
+ an authority present as the same root as `"/"` (RFC 3986:
190
+ `"http://host"` == `"http://host/"`) instead of joining a bare, ambiguous
191
+ name with no leading slash.
192
+ - `_DirectoryListingParser._RE_FILESIZE`'s digit class excluded `,` -- the
193
+ `<table>` path strips commas from cell text before matching, but the
194
+ `<pre>` path matches first, so a comma-thousands size like `1,024`
195
+ matched only `"1"`, truncating `size` and leaking `,024` into
196
+ `description`.
197
+ - The RFC-1123 datetime bucket's trailing timezone match
198
+ (`... \d{2}:\d{2}:\d{2} .+`) used an unbounded, greedy `.+` that
199
+ swallowed the rest of the `<pre>` listing line, including any trailing
200
+ size/description text on the same row -- `time.strptime()` then raised
201
+ on the unconverted data, silently dropping `modified` **and** every
202
+ field after it for that entry. Narrowed to `\S+` (the timezone is one
203
+ token).
204
+ - `_DirectoryListingParser`'s absolute-href filter was a blanket
205
+ `startswith('/')` -- a reverse-proxied/absolute-URL-configured server
206
+ rendering *every* entry (not just the parent-directory link) as an
207
+ absolute href got back a completely empty listing, with no fallback
208
+ able to recover it. Scoped the filter to hrefs outside the listing's own
209
+ directory (parsed from `<title>Index of ...</title>`) instead, falling
210
+ back to the old blanket-drop behavior only when no title was parseable.
211
+ - `HttpPath.stat()`'s post-redirect HEAD re-fetch had no HEAD-405-to-GET
212
+ fallback, unlike the pre-redirect loop -- a server/proxy that rejects
213
+ HEAD outright (not just pre-redirect) surfaced `PermissionError` for a
214
+ directory that actually exists. Now mirrors the pre-redirect loop's
215
+ fallback.
216
+ - `HttpPath._listdir()` now retries once with a trailing slash if the
217
+ slash-less path 404s (defensive: real redirecting servers already work
218
+ via `requests`' default GET redirect-following, but a non-redirecting
219
+ server/proxy previously had no fallback at all).
220
+ - `HttpWriteStream.close()` raised *before* marking the underlying stream
221
+ closed on a failed upload, so a second `close()` call (context-manager
222
+ `__exit__` cleanup, or GC via `IOBase.__del__`) silently retried the PUT.
223
+ Now marks closed even on failure.
224
+ - `HttpPath.rmdir()`/`DavPath.rmdir()` never checked `is_dir()` before
225
+ falling through to `unlink()` -- an empty directory's listing and a
226
+ *file* whose body/PROPFIND response yields zero real entries are
227
+ indistinguishable from `_listdir()` alone, so calling `rmdir()` on a
228
+ file silently deleted it instead of raising `NotADirectoryError`
229
+ (`os.rmdir()`'s ENOTDIR contract).
230
+
231
+ ## [0.7.0] - 2026-07-11
232
+
233
+ ### Added (new schemes, optional extras)
234
+ - `dav:`/`davs:` scheme (`pathlib_next.uri.schemes.webdav.DavPath`):
235
+ extends `HttpPath` with WebDAV (RFC 4918) PROPFIND for real stat/listdir
236
+ metadata (replacing HTML-index scraping) and PUT/DELETE/MKCOL/MOVE for
237
+ full read/write access. Requests go to the equivalent `http:`/`https:`
238
+ URL; `as_uri()` still reports `dav:`/`davs:`. Reuses the `http` extra,
239
+ no new dependency. `rmdir()` is recursive by WebDAV spec, unlike
240
+ `pathlib.Path.rmdir()`'s "must be empty" contract -- documented, not
241
+ silent.
242
+ - `s3:` scheme (`pathlib_next.uri.schemes.s3.S3Path`,
243
+ `s3://bucket/key/path`): read/write/list via `boto3`. New `s3` extra.
244
+ S3 has no real directories -- `is_dir()` is prefix emulation (any
245
+ object key under `"<path>/"`), `mkdir()` creates a zero-byte `"<path>/"`
246
+ marker object, `rmdir()` requires no other keys under that prefix.
247
+ `rename()` uses server-side `copy_object`+`delete_object` (same-bucket
248
+ only) instead of the generic download+upload+delete `move()` fallback.
249
+
250
+ ## [0.6.0] - 2026-07-11
251
+
252
+ ### Added (new schemes, stdlib-only, no new deps)
253
+ - `data:` scheme (RFC 2397, `pathlib_next.uri.schemes.data.DataUri`):
254
+ read-only, no backend/connection -- the entire file content is embedded
255
+ in the URI (`data:[<mediatype>][;base64],<data>`). `stat().st_size` is
256
+ the decoded payload length; `iterdir()` raises `NotADirectoryError`
257
+ (it's always a single file); write operations raise `NotImplementedError`.
258
+ - `ftp:`/`ftps:` scheme (`pathlib_next.uri.schemes.ftp.FtpPath`): full
259
+ read/write/list access via stdlib `ftplib`, with a thread-keyed LRU
260
+ connection cache mirroring `sftp.py`. Listing/stat prefer MLSD (RFC
261
+ 3659); servers without it fall back to NLST (listing) and SIZE
262
+ (file-only stat). Writes buffer in memory and upload via STOR/APPE on
263
+ `close()`. `chmod()` uses the common but non-standard `SITE CHMOD`
264
+ extension (may not be supported by every server).
265
+ - `zip:`/`tar:` archive paths (`pathlib_next.uri.schemes.archive`):
266
+ `<scheme>:<archive-uri>!/<inner-path>` (Java-style `!/` separator,
267
+ URI form proposed to and confirmed by the user before implementation).
268
+ The archive half is itself any absolute URI with an explicit scheme, so
269
+ archives are readable straight off any other backend (`file:`, `http:`,
270
+ `sftp:`, `ftp:`, `data:`, ...). Read is supported for both schemes.
271
+ Write is `zip:`-only, and only for brand-new entries in a local (`file:`)
272
+ outer archive (overwriting/deleting/renaming an existing entry would
273
+ need a full-archive rewrite -- not implemented, raises
274
+ `NotImplementedError`). `tar:` auto-detects `.tar.gz`/`.tar.bz2`/`.tar.xz`
275
+ compression and is always read-only.
276
+
277
+ ## [0.5.0] - 2026-07-11
278
+
279
+ ### Fixed (critical -- found while writing the examples)
280
+ - `Path("...")` -- the top-level dispatcher documented in this project's
281
+ own README quick start and used throughout -- silently dropped its
282
+ constructor arguments on Python <3.12, leaving a blank instance that
283
+ crashed with `AttributeError: _drv` the moment anything touched it (e.g.
284
+ the `/` operator). Masked on 3.12+, where the real parsing happens in
285
+ `__init__` (called separately, with the original args, regardless of what
286
+ `__new__` did) rather than `__new__` itself. Every one of the new suite's 300
287
+ tests constructed via `LocalPath(...)` directly instead, so this went
288
+ undetected until `examples/local_and_mem.py` exercised the documented
289
+ `Path(...)` entry point end to end.
290
+
291
+ ### Fixed (found by the new test suite, not in the original bug list)
292
+ - `LocalPath.stat()`/`chmod()` inherit directly from `pathlib.Path` via MRO
293
+ and crashed with `TypeError` on Python 3.9 the moment anything passed
294
+ `follow_symlinks=` (e.g. `Path.walk()`'s default `follow_symlinks=False`)
295
+ -- now shimmed with `lstat()`/`lchmod()` on <3.10, same as the existing
296
+ `FileUri` shim (which now just delegates to `LocalPath`).
297
+ - `MemPath.__init__` decided whether to propagate a parent's backend with
298
+ `if _backend and backend is None:` -- an empty (but valid) backend dict is
299
+ falsy, so joining off a freshly-created, empty `MemPath` silently gave the
300
+ child a disconnected new backend instead of sharing the parent's.
301
+ - `MemPath.stat()` never set `st_size` for files (always defaulted to `0`),
302
+ breaking any size-based checksum comparison (notably `PathSyncer`'s
303
+ typical usage).
304
+ - `glob()`'s core algorithm decided whether to recurse into the *parent*
305
+ directory using whether the *leaf* segment is a wildcard, instead of
306
+ whether the *parent path itself* contains one. Since a wildcarded leaf
307
+ with a literal parent directory is the overwhelmingly common case
308
+ (`glob("*.py")`), this always took the "recurse into parent" branch,
309
+ which only degenerated back to the correct single directory when the
310
+ parent has a non-empty literal name to re-match against -- true for
311
+ essentially every real filesystem path except an OS root. It silently
312
+ returned the wrong result on `MemPath`'s virtual root (empty name).
313
+ - `HttpPath.iterdir()` gave every subdirectory entry an empty `.name`:
314
+ directory-listing entries for subdirectories carry a trailing `/`
315
+ (`htmllistparse`'s convention), which wasn't stripped before building the
316
+ child's path, and `Pathname.name` derives from the last path segment --
317
+ empty for a trailing-slash path.
318
+ - `SftpPath.rename()` resolved a plain string target relative to `self`
319
+ (joining it as a child, e.g. `"/a.txt".rename("b.txt")` produced
320
+ `"/a.txt/b.txt"`) instead of `self`'s parent (sibling rename).
321
+
322
+ ### Added (test suite)
323
+ - Full pytest suite (`tests/`): pure-path parity against `pathlib.PurePosixPath`
324
+ (`test_parity_pure.py`), local I/O parity against `pathlib.Path`/`os.walk`
325
+ (`test_parity_io.py`), a reusable filesystem-contract mixin run against
326
+ `LocalPath`/`MemPath`/`FileUri` and exported as `pathlib_next.testing.
327
+ PathContract` for third-party `Path`/`UriPath` implementers
328
+ (`test_contract.py`), glob vs. stdlib ground truth (`test_glob.py`), URI
329
+ parsing/scheme-dispatch/query/source coverage, MemPath- and SFTP-specific
330
+ unit tests (SFTP mocked, no real server), HTTP tests against a real stdlib
331
+ `ThreadingHTTPServer`, and `PathSyncer` coverage. 300 tests, ~85% line
332
+ coverage, green on both Python 3.9 and 3.13.
333
+
334
+ ### Added (docs)
335
+ - `docs/guides/schemes.md` (capability matrix per scheme) and
336
+ `docs/guides/extending.md` (both extension tracks, with worked examples
337
+ and `pathlib_next.testing.PathContract` usage). Rewrote `docs/index.md`
338
+ and the README with a 30-second example per scheme and a capability
339
+ matrix. Class-level docstrings added across the package for the rendered
340
+ API reference.
341
+
342
+ ### Changed
343
+ - `examples/example.py` (an unstructured scratch script) split into three
344
+ focused, runnable examples: `examples/local_and_mem.py` (self-contained,
345
+ no network), `examples/http_listing.py` and `examples/sftp_sync.py`
346
+ (network-touching, guarded under `if __name__ == "__main__"`,
347
+ configurable via env vars, fail soft when unreachable/unconfigured).
348
+
349
+ ### Added
350
+ - `Pathname.joinpath()`, `Pathname.full_match()` (3.13 parity, supports `**`
351
+ matching any number of segments), `Pathname.anchor`/`drive`/`root`
352
+ (generic derivation for non-local paths), `Path.rglob()`,
353
+ `read_text(..., newline=)` (3.13 parity), `Path.samefile()` (default
354
+ `st_dev`/`st_ino` comparison when the backend's `stat()` provides them,
355
+ `NotImplementedError` otherwise).
356
+ - `Path.glob()`/`LocalPath.glob()`: `recursive=` now auto-detects (`True` if
357
+ the pattern has a `"**"` component) instead of defaulting to `False`;
358
+ explicit `recursive=True`/`False` still overrides.
359
+ - `Path.copy()`: raises `IsADirectoryError` when the target is an existing
360
+ directory (previously misbehaved); gained `follow_symlinks=`/
361
+ `preserve_metadata=` kwargs, named to match CPython 3.14's `Path.copy()`.
362
+ - `docs/divergences.md`: registry of every deliberate behavioral divergence
363
+ from `pathlib`, with rationale. Linked from the docs nav.
364
+
365
+ ### Fixed
366
+ - `Path.mkdir(parents=True)` created intermediate parents with `exist_ok=False`
367
+ (racy, and wrong when a parent already existed) and dropped the caller's
368
+ `exist_ok` on the final retry.
369
+ - `Path.touch(exist_ok=False)` silently truncated an existing file instead of
370
+ raising `FileExistsError` (pathlib parity).
371
+ - `LocalPath.glob()`'s `dironly` parameter defaulted to `False`, which made the
372
+ `is None` check for trailing-slash directory-only detection dead code.
373
+ - `Stat._st_mode()` only caught `FileNotFoundError`, letting `PermissionError`
374
+ and other `OSError`s propagate out of `exists()`/`is_dir()`/etc. where pathlib
375
+ returns `False`. Also fixed: `follow_symlinks` was accepted but never
376
+ forwarded to the underlying `stat()` call, so `is_symlink()` never actually
377
+ inspected the symlink itself.
378
+ - `MemPath._open()` treated any mode other than `"w"` as a read, so `"a"`/`"x"`
379
+ silently misbehaved; now dispatches `r`/`w`/`x`/`a` correctly and raises
380
+ `NotImplementedError` for anything else. `MemBytesIO.close()` used
381
+ `seek(0);read()` instead of `getvalue()`, losing content if the caller's
382
+ cursor wasn't already at position 0 when closing.
383
+ - `MemPath.normalized` mangled `".."`-escaping paths (e.g. `".."`) into `"."`;
384
+ now normalizes against a virtual root so they clamp at the root instead.
385
+ - `PathAndStat.__getattr__()` returned `None` for any unrecognized attribute
386
+ instead of raising `AttributeError`, breaking `hasattr()`-based logic.
387
+ - `parsedate(None)` / an unparseable date string returned "now" instead of
388
+ epoch 0, which could poison `PathSyncer`'s checksum/freshness comparisons for
389
+ HTTP sources with no `Last-Modified` header.
390
+ - `HttpPath.stat()` used a bare `except:`; cached `_isdir` from a response that
391
+ hadn't been confirmed successful yet (including 404s); and didn't fall back
392
+ to GET when a server rejected `HEAD` with 405.
393
+ - `uri.Query` no longer depends on `uritools`' private `_querydict`/`_querylist`
394
+ helpers (reimplemented locally against the public `uriencode()`).
395
+ - `Uri` join (`_load_parts`): `query`/`fragment` are now resolved with the same
396
+ "last segment that actually sets one wins" rule already used for `source`
397
+ (previously any segment, even one with no query/fragment, would blank out an
398
+ earlier segment's). Join semantics are now documented explicitly:
399
+ pathlib-`joinpath`-like, not RFC 3986 reference resolution, `..` is never
400
+ resolved during join.
401
+ - `Source.is_local()` (DNS lookup) and `get_machine_ips()` are now
402
+ `functools.lru_cache`d -- previously ran on every call.
403
+
404
+ ### Fixed (crash-level bugs)
405
+ - `MemPath.stat()`/`MemPath._open()` returned a `FileNotFoundError` instance instead
406
+ of raising it for a missing path, causing an unrelated `AttributeError` downstream.
407
+ - `LRU.invalidate()` called `self.lock()` instead of using `self.lock` as a context
408
+ manager (`RLock` isn't callable) -- broke the SFTP client reconnect path.
409
+ - `Pathname.match()` had reversed `isinstance()` arguments and compared against
410
+ `str(self)` (which includes scheme/host for `Uri`) instead of `as_posix()`.
411
+ - Glob wildcard detection (`WILCARD_PATTERN`, renamed `WILDCARD_PATTERN`, old name
412
+ kept as an alias) used `.match()` (anchored) instead of `.search()`, so patterns
413
+ like `"foo*"` weren't recognized as wildcards.
414
+ - `Uri` was unhashable (defined `__eq__` without `__hash__`); `__eq__` now also
415
+ returns `NotImplemented` for non-`Pathname`/`str` operands instead of raising.
416
+ - `Uri.is_relative_to()` used `str.startswith()` on normalized path strings, so
417
+ `/foo/bar2` was incorrectly reported as relative to `/foo/bar`; now compares
418
+ path segments.
419
+ - `Uri.relative_to(walk_up=True)` was dead code -- an early guard raised
420
+ `ValueError` before the walk-up loop ever ran.
421
+ - `HttpPath.is_dir()`/`is_file()` tested truthiness of bound methods
422
+ (`self._is_dir`, `self.is_dir`) instead of calling/checking the right attribute,
423
+ so both always returned truthy nonsense.
424
+ - `SftpPath.chmod()` didn't accept `follow_symlinks=`, so the inherited `lchmod()`
425
+ crashed with `TypeError`; now raises `NotImplementedError` for
426
+ `follow_symlinks=False` (paramiko has no `lchmod`).
427
+ - `SftpPath` defined `_rename()`, which nothing ever called -- renamed to
428
+ `rename()` so `move()`/`rename()` actually use SFTP's native rename instead of
429
+ silently falling back to copy+unlink for every move.
430
+ - `Uri.__init__()` used a bare `except:` around `Path.as_uri()` (now
431
+ `except ValueError:`, matching what `as_uri()` actually raises for relative
432
+ paths) and crashed with `AttributeError` when constructing from an
433
+ `os.PathLike` that only implements `__fspath__` (no `as_posix()`).
434
+ - `Path.rm(ignore_error=callable)` never actually called the callable -- both
435
+ branches of its error handler returned the callable object itself.
436
+
437
+ ### Fixed (Python 3.9/3.10 compatibility)
438
+ - Actual Python 3.9/3.10 runtime compatibility (CI previously only tested 3.11/3.13
439
+ and missed these): `LocalPath`/`Uri` case-sensitivity and path-separator detection
440
+ crashed on 3.9-3.11 (`_flavour` object has no `normcase`); `open(mode="r")` crashed
441
+ on <3.10 (`io.text_encoding` is 3.10+); glob pattern compilation crashed on <3.11
442
+ (`re.NOFLAG` is 3.11+); `FileUri.stat()`/`chmod()` crashed on 3.9
443
+ (`pathlib.Path.stat/chmod` gained `follow_symlinks=` in 3.10; raises
444
+ `NotImplementedError` there for `follow_symlinks=False`).
445
+ - `LocalPath._path_separators` returned the env-var list separator (`;`/`:`) instead
446
+ of the path separator, and could include a `None` altsep on POSIX.
447
+
448
+ ### Added
449
+ - `tests/test_smoke.py`: regression coverage for README/example snippets across
450
+ supported Python versions.
451
+
452
+ ## [0.4.1] - 2026-07-11
453
+
454
+ ### Fixed
455
+ - Removed explicit `[tool.hatch.build.targets.wheel]` packages config that caused hatchling to fail resolving `README.md` during editable installs on CI.
456
+ - Converted `README.md` from a symlink (mode `120000`) to a regular file, fixing `git checkout` failures on macOS and Windows runners.
457
+ - Removed internal tooling references from committed files.
458
+
459
+ ## [0.4.0] - 2026-07-11
460
+
461
+ ### Added
462
+ - Standardized repository layout and relocated examples to `examples/` directory.
463
+ - Configured MkDocs documentation site with dynamic API reference using `mkdocstrings`.
464
+ - Added GitHub Actions workflows for matrix testing (`test.yml`) and release pipelines (`release.yml`).
465
+ - Added typing marker `py.typed` for PEP 561 compliance.
466
+
467
+ ### Changed
468
+ - Added backward compatibility support for Python 3.9 and 3.10: added `from __future__ import annotations` across the codebase, refactored runtime-evaluated union types to use `typing.Union`, and provided fallbacks for `TypeAlias` and `ParamSpec`.
469
+ - Updated package requirement to `requires-python = ">=3.9"`.
470
+
471
+ ## [0.3.5] - 2026-07-11
472
+
473
+ ### Added
474
+ - Split path into protocols that can be standalone.
475
+ - Sync error handling.
476
+ - Generic Path Protocol based pathlib implementation for URI paths with file access support for sftp, http, file schemes.
477
+
478
+ [Unreleased]: https://github.com/jose-pr/pathlib_next/compare/v0.8.1...HEAD
479
+ [0.8.1]: https://github.com/jose-pr/pathlib_next/compare/v0.8.0...v0.8.1
480
+ [0.8.0]: https://github.com/jose-pr/pathlib_next/compare/v0.7.0...v0.8.0
481
+ [0.7.0]: https://github.com/jose-pr/pathlib_next/compare/v0.6.0...v0.7.0
482
+ [0.6.0]: https://github.com/jose-pr/pathlib_next/compare/v0.5.0...v0.6.0
483
+ [0.5.0]: https://github.com/jose-pr/pathlib_next/compare/v0.4.1...v0.5.0
484
+ [0.4.1]: https://github.com/jose-pr/pathlib_next/compare/v0.4.0...v0.4.1
485
+ [0.4.0]: https://github.com/jose-pr/pathlib_next/releases/tag/v0.4.0
486
+ [0.3.5]: https://github.com/jose-pr/pathlib_next/releases/tag/v0.3.5