pathlib-next 0.7.0__tar.gz → 0.8.0__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.0}/.gitignore +1 -0
  2. pathlib_next-0.8.0/CHANGELOG.md +446 -0
  3. {pathlib_next-0.7.0 → pathlib_next-0.8.0}/PKG-INFO +64 -23
  4. {pathlib_next-0.7.0 → pathlib_next-0.8.0}/README.md +48 -20
  5. pathlib_next-0.8.0/docs/api/mempath.md +3 -0
  6. pathlib_next-0.8.0/docs/api/path.md +4 -0
  7. pathlib_next-0.8.0/docs/api/testing.md +3 -0
  8. pathlib_next-0.8.0/docs/api/uri.md +5 -0
  9. pathlib_next-0.8.0/docs/api/utils.md +6 -0
  10. pathlib_next-0.8.0/docs/benchmarks.md +204 -0
  11. {pathlib_next-0.7.0 → pathlib_next-0.8.0}/docs/divergences.md +15 -4
  12. pathlib_next-0.8.0/docs/guides/cli.md +34 -0
  13. {pathlib_next-0.7.0 → pathlib_next-0.8.0}/docs/guides/extending.md +47 -7
  14. pathlib_next-0.8.0/docs/guides/schemes.md +133 -0
  15. {pathlib_next-0.7.0 → pathlib_next-0.8.0}/docs/index.md +21 -7
  16. pathlib_next-0.8.0/examples/az_listing.py +37 -0
  17. pathlib_next-0.8.0/examples/data_and_archive.py +97 -0
  18. pathlib_next-0.8.0/examples/ftp_listing.py +49 -0
  19. pathlib_next-0.8.0/examples/github_listing.py +52 -0
  20. pathlib_next-0.8.0/examples/gitlab_listing.py +54 -0
  21. pathlib_next-0.8.0/examples/gs_listing.py +36 -0
  22. {pathlib_next-0.7.0 → pathlib_next-0.8.0}/examples/http_listing.py +5 -4
  23. pathlib_next-0.8.0/examples/s3_listing.py +46 -0
  24. pathlib_next-0.8.0/examples/webdav_roundtrip.py +72 -0
  25. {pathlib_next-0.7.0 → pathlib_next-0.8.0}/mkdocs.yml +9 -2
  26. pathlib_next-0.8.0/pyproject.toml +79 -0
  27. {pathlib_next-0.7.0 → pathlib_next-0.8.0}/src/pathlib_next/__init__.py +0 -1
  28. {pathlib_next-0.7.0 → pathlib_next-0.8.0}/src/pathlib_next/path.py +164 -27
  29. {pathlib_next-0.7.0 → pathlib_next-0.8.0}/src/pathlib_next/protocols/io.py +1 -0
  30. pathlib_next-0.8.0/src/pathlib_next/testing.py +197 -0
  31. pathlib_next-0.8.0/src/pathlib_next/tools/__init__.py +1 -0
  32. pathlib_next-0.8.0/src/pathlib_next/tools/uripath.py +180 -0
  33. {pathlib_next-0.7.0 → pathlib_next-0.8.0}/src/pathlib_next/uri/__init__.py +197 -39
  34. {pathlib_next-0.7.0 → pathlib_next-0.8.0}/src/pathlib_next/uri/query.py +4 -0
  35. {pathlib_next-0.7.0 → pathlib_next-0.8.0}/src/pathlib_next/uri/schemes/__init__.py +13 -1
  36. pathlib_next-0.8.0/src/pathlib_next/uri/schemes/_gitrepo.py +133 -0
  37. pathlib_next-0.8.0/src/pathlib_next/uri/schemes/archive/__init__.py +35 -0
  38. pathlib_next-0.8.0/src/pathlib_next/uri/schemes/archive/_base.py +295 -0
  39. pathlib_next-0.8.0/src/pathlib_next/uri/schemes/archive/tar.py +40 -0
  40. pathlib_next-0.8.0/src/pathlib_next/uri/schemes/archive/zip.py +153 -0
  41. pathlib_next-0.8.0/src/pathlib_next/uri/schemes/az.py +297 -0
  42. pathlib_next-0.7.0/src/pathlib_next/uri/schemes/webdav.py → pathlib_next-0.8.0/src/pathlib_next/uri/schemes/dav.py +71 -31
  43. {pathlib_next-0.7.0 → pathlib_next-0.8.0}/src/pathlib_next/uri/schemes/ftp.py +51 -15
  44. pathlib_next-0.8.0/src/pathlib_next/uri/schemes/git/__init__.py +4 -0
  45. pathlib_next-0.8.0/src/pathlib_next/uri/schemes/git/_base.py +39 -0
  46. pathlib_next-0.8.0/src/pathlib_next/uri/schemes/git/github.py +9 -0
  47. pathlib_next-0.8.0/src/pathlib_next/uri/schemes/git/gitlab.py +9 -0
  48. pathlib_next-0.8.0/src/pathlib_next/uri/schemes/github.py +101 -0
  49. pathlib_next-0.8.0/src/pathlib_next/uri/schemes/gitlab.py +129 -0
  50. pathlib_next-0.8.0/src/pathlib_next/uri/schemes/gs.py +251 -0
  51. pathlib_next-0.8.0/src/pathlib_next/uri/schemes/http.py +632 -0
  52. {pathlib_next-0.7.0 → pathlib_next-0.8.0}/src/pathlib_next/uri/schemes/s3.py +87 -3
  53. pathlib_next-0.8.0/src/pathlib_next/uri/schemes/sftp/__init__.py +351 -0
  54. pathlib_next-0.8.0/src/pathlib_next/uri/schemes/sftp/_asyncssh.py +739 -0
  55. pathlib_next-0.8.0/src/pathlib_next/uri/schemes/sftp/_paramiko.py +125 -0
  56. pathlib_next-0.8.0/src/pathlib_next/uri/source.py +272 -0
  57. {pathlib_next-0.7.0 → pathlib_next-0.8.0}/src/pathlib_next/utils/__init__.py +6 -1
  58. pathlib_next-0.8.0/src/pathlib_next/utils/archive.py +149 -0
  59. pathlib_next-0.8.0/src/pathlib_next/utils/checksum.py +31 -0
  60. {pathlib_next-0.7.0 → pathlib_next-0.8.0}/src/pathlib_next/utils/stat.py +14 -8
  61. {pathlib_next-0.7.0 → pathlib_next-0.8.0}/src/pathlib_next/utils/sync.py +67 -23
  62. pathlib_next-0.8.0/tests/conftest.py +1208 -0
  63. pathlib_next-0.8.0/tests/test_archive_uri.py +394 -0
  64. pathlib_next-0.8.0/tests/test_az.py +90 -0
  65. pathlib_next-0.8.0/tests/test_az_fake.py +164 -0
  66. pathlib_next-0.8.0/tests/test_contract.py +255 -0
  67. pathlib_next-0.8.0/tests/test_dav.py +50 -0
  68. pathlib_next-0.8.0/tests/test_gitrepo.py +288 -0
  69. pathlib_next-0.8.0/tests/test_gs.py +89 -0
  70. pathlib_next-0.8.0/tests/test_gs_fake.py +145 -0
  71. pathlib_next-0.8.0/tests/test_http.py +459 -0
  72. pathlib_next-0.8.0/tests/test_http_live.py +132 -0
  73. pathlib_next-0.8.0/tests/test_http_parser.py +237 -0
  74. {pathlib_next-0.7.0 → pathlib_next-0.8.0}/tests/test_parity_io.py +65 -1
  75. pathlib_next-0.8.0/tests/test_path_gaps.py +104 -0
  76. {pathlib_next-0.7.0 → pathlib_next-0.8.0}/tests/test_pathname.py +24 -0
  77. pathlib_next-0.8.0/tests/test_plugins.py +123 -0
  78. pathlib_next-0.8.0/tests/test_properties.py +366 -0
  79. {pathlib_next-0.7.0 → pathlib_next-0.8.0}/tests/test_s3.py +85 -0
  80. pathlib_next-0.8.0/tests/test_sftp.py +430 -0
  81. pathlib_next-0.8.0/tests/test_sftp_asyncssh.py +712 -0
  82. {pathlib_next-0.7.0 → pathlib_next-0.8.0}/tests/test_smoke.py +36 -3
  83. {pathlib_next-0.7.0 → pathlib_next-0.8.0}/tests/test_sync.py +54 -0
  84. {pathlib_next-0.7.0 → pathlib_next-0.8.0}/tests/test_uri_path.py +43 -0
  85. pathlib_next-0.8.0/tests/test_uripath_tool.py +90 -0
  86. pathlib_next-0.8.0/tests/test_utils.py +308 -0
  87. pathlib_next-0.8.0/tests/test_walk.py +117 -0
  88. {pathlib_next-0.7.0 → pathlib_next-0.8.0}/tests/test_webdav.py +90 -1
  89. pathlib_next-0.7.0/CHANGELOG.md +0 -263
  90. pathlib_next-0.7.0/docs/api/reference.md +0 -3
  91. pathlib_next-0.7.0/docs/guides/schemes.md +0 -78
  92. pathlib_next-0.7.0/pyproject.toml +0 -37
  93. pathlib_next-0.7.0/src/pathlib_next/testing.py +0 -132
  94. pathlib_next-0.7.0/src/pathlib_next/uri/schemes/archive.py +0 -266
  95. pathlib_next-0.7.0/src/pathlib_next/uri/schemes/http.py +0 -172
  96. pathlib_next-0.7.0/src/pathlib_next/uri/schemes/sftp.py +0 -130
  97. pathlib_next-0.7.0/src/pathlib_next/uri/source.py +0 -87
  98. pathlib_next-0.7.0/tests/conftest.py +0 -63
  99. pathlib_next-0.7.0/tests/test_archive_uri.py +0 -198
  100. pathlib_next-0.7.0/tests/test_contract.py +0 -29
  101. pathlib_next-0.7.0/tests/test_http.py +0 -70
  102. pathlib_next-0.7.0/tests/test_sftp.py +0 -158
  103. pathlib_next-0.7.0/tests/test_utils.py +0 -68
  104. {pathlib_next-0.7.0 → pathlib_next-0.8.0}/LICENSE +0 -0
  105. {pathlib_next-0.7.0 → pathlib_next-0.8.0}/docs/changelog.md +0 -0
  106. {pathlib_next-0.7.0 → pathlib_next-0.8.0}/examples/local_and_mem.py +0 -0
  107. {pathlib_next-0.7.0 → pathlib_next-0.8.0}/examples/sftp_sync.py +0 -0
  108. {pathlib_next-0.7.0 → pathlib_next-0.8.0}/src/pathlib_next/fspath.py +0 -0
  109. {pathlib_next-0.7.0 → pathlib_next-0.8.0}/src/pathlib_next/mempath.py +0 -0
  110. {pathlib_next-0.7.0 → pathlib_next-0.8.0}/src/pathlib_next/protocols/__init__.py +0 -0
  111. {pathlib_next-0.7.0 → pathlib_next-0.8.0}/src/pathlib_next/protocols/fs.py +0 -0
  112. {pathlib_next-0.7.0 → pathlib_next-0.8.0}/src/pathlib_next/py.typed +0 -0
  113. {pathlib_next-0.7.0 → pathlib_next-0.8.0}/src/pathlib_next/uri/schemes/data.py +0 -0
  114. {pathlib_next-0.7.0 → pathlib_next-0.8.0}/src/pathlib_next/uri/schemes/file.py +0 -0
  115. {pathlib_next-0.7.0 → pathlib_next-0.8.0}/src/pathlib_next/utils/glob.py +0 -0
  116. {pathlib_next-0.7.0 → pathlib_next-0.8.0}/tests/test_data_uri.py +0 -0
  117. {pathlib_next-0.7.0 → pathlib_next-0.8.0}/tests/test_ftp.py +0 -0
  118. {pathlib_next-0.7.0 → pathlib_next-0.8.0}/tests/test_glob.py +0 -0
  119. {pathlib_next-0.7.0 → pathlib_next-0.8.0}/tests/test_local.py +0 -0
  120. {pathlib_next-0.7.0 → pathlib_next-0.8.0}/tests/test_mempath.py +0 -0
  121. {pathlib_next-0.7.0 → pathlib_next-0.8.0}/tests/test_parity_pure.py +0 -0
  122. {pathlib_next-0.7.0 → pathlib_next-0.8.0}/tests/test_query.py +0 -0
  123. {pathlib_next-0.7.0 → pathlib_next-0.8.0}/tests/test_source.py +0 -0
  124. {pathlib_next-0.7.0 → pathlib_next-0.8.0}/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,446 @@
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.0] - 2026-07-13
11
+
12
+ ### Added
13
+ - `uripath` command-line tool (`pathlib_next.tools.uripath`) for reading,
14
+ writing, copying, removing, and syncing local or URI-backed paths. `-`
15
+ works as stdin/stdout for byte-stream operations.
16
+ - Recursive benchmark probes for local, memory, object-store, and SFTP
17
+ backends, including provider call-shape rows for recursive deletes.
18
+ - Provider-native recursive delete overrides for `S3Path`, `GsPath`, and
19
+ `AzPath`, with bucket/container-root guards.
20
+ - `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.
21
+ - HTTP write support (`PUT`, customizable to `POST` or other verbs via `write_method` configuration or `with_session()`) for `HttpPath`.
22
+ - HTTP delete support (`DELETE` for `unlink()` and `rmdir()`) for `HttpPath`.
23
+ - 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`).
24
+ - Dynamic loading of custom URI scheme plugins via standard Python packaging entry points under the `"pathlib_next.schemes"` group, allowing third-party package extensibility.
25
+ - 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.
26
+ - MD5 and SHA-256 checksum helpers in `pathlib_next.utils` (`md5` and `sha256`).
27
+ - Optional `checksum` parameter in `PathSyncer`, defaulting to the new `md5` helper.
28
+ - Recursive directory copying via `Path.copy(recursive=True)`.
29
+ - Support for recursive folder moves falling back to recursive copy + recursive delete when `rename` is not supported.
30
+ - Archive utilities `make_archive` and `unpack_archive` supporting ZIP and TAR formats using memory-efficient chunk streaming.
31
+ - Hierarchical test contracts: `PurePathContract` (pure path operations) and `ReadPathContract` (read-only path operations), allowing contract-based verification of read-only and memory/archive paths.
32
+ - Contract test suites wired for `DataUri`, `ZipUri`, `TarUri`, and `HttpPath`.
33
+ - Dedicated unit tests for `Path.walk()`, `samefile()`, and `Stat` device queries.
34
+ - 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`).
35
+ - Split monolith API reference documentation into per-module pages (`path`, `uri`, `mempath`, `utils`, `testing`).
36
+ - 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`).
37
+ - Detailed documentation of contract testing levels (`PurePathContract`, `ReadPathContract`, `PathContract`) in the extending guide.
38
+ - 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.
39
+ - `ftp_server`, `dav_server`, and `s3_server` pytest fixtures in `conftest.py` serving ephemeral in-process servers with pre-populated `fixture_tree` contents.
40
+ - 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.
41
+ - Plugin discovery tests in `tests/test_plugins.py` covering `_load_entry_point`, `_load_builtin_scheme`, and `get_scheme_cls` integration.
42
+ - 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`.
43
+ - `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.
44
+ - 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.
45
+ - `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.
46
+ - 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.
47
+ - 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`.
48
+ - 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.
49
+
50
+ ### Changed
51
+ - Recursive `Path.rm()` now deletes bottom-up using non-following listing
52
+ metadata where available, avoiding traversal through directory symlinks
53
+ and reducing extra stat calls for metadata-rich backends.
54
+ - Asyncssh SFTP recursive copy/remove now use native bounded async helpers
55
+ for ordinary files/directories instead of recursing through sync path
56
+ methods on the bridge loop.
57
+ - `PathSyncer` reuses child metadata during tree sync when that metadata is
58
+ consistent with the active symlink-following policy.
59
+ - 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).
60
+ - Matrix expansion in GitHub Actions CI to test Python 3.10, 3.11, and 3.12 (on Ubuntu).
61
+ - Added a "no-extras" CI job to run tests without optional dependencies installed.
62
+ - `PathSyncer.log()` now logs through `logging.getLogger("pathlib_next.sync")`
63
+ at `INFO` instead of calling `print()` -- stdout consumers must configure
64
+ logging (e.g. `logging.basicConfig()`) to see sync progress again.
65
+ `EVENT_LOG_FORMAT` switched from `str.format` (`{event}`) to `%`-style
66
+ placeholders to match, and `log()` remains overridable for custom routing.
67
+ - `SyncEvent` members are now numbered sequentially (previously a mix of
68
+ explicit ints and `enum.auto()`, which raised a `DeprecationWarning` on
69
+ Python 3.13). Values are not part of any documented/persisted contract.
70
+ - Optimized performance across pure paths and URIs:
71
+ - Cache `Uri.segments` in a slot to avoid re-splitting the path string on every access.
72
+ - Cache `Uri.suffix` and `Uri.stem` in slots.
73
+ - Optimize `Source.__bool__` to use lazy index accesses and avoid tuple iteration.
74
+ - Short-circuit `Query.__new__` when the input is already a matching `Query` instance.
75
+ - `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).
76
+ - New `Path._scandir()` / `UriPath._scandir()` protocol: schemes whose
77
+ listing call already returns type/size/mtime for every child (HTML
78
+ directory index, WebDAV PROPFIND, SFTP `listdir_attr`, FTP MLSD, an S3
79
+ `list_objects_v2` page) can now yield `(name, FileStat)` pairs directly,
80
+ and `walk()`/`glob()` answer `is_dir()` from that instead of a `stat()`
81
+ round trip per entry -- a remote-tree walk goes from O(entries) requests
82
+ to O(dirs). `HttpPath`, `DavPath`, `SftpPath`, `FtpPath`, and `S3Path` all
83
+ adopt it; `_listdir()`/`iterdir()` remain fully supported for schemes that
84
+ don't override `_scandir()` (no behavior change, no win). On the local
85
+ `http_server` benchmark fixture, HTTP glob/walk over the fixture tree are
86
+ ~89-94% faster than the already-optimized pre-`_scandir()` baseline (see
87
+ `benchmarks/bench.py`). `HttpPath` also drops its `_isdir` instance-cache
88
+ slot and its `is_dir()`/`is_file()` overrides (now derived generically
89
+ from `stat()`, like every other scheme) in favor of a single-use stat
90
+ hint seeded by `_scandir()`; `DavPath`'s now-redundant `iterdir()`/
91
+ `is_dir()`/`is_file()` overrides are removed for the same reason.
92
+ - **Breaking** (pre-1.0, no compat shim kept): `uri/schemes/` module naming convention
93
+ -- every module is now named after the main URI scheme it implements (TLS/secondary
94
+ variants live with their main scheme). `webdav.py` -> `dav.py`; import from
95
+ `pathlib_next.uri.schemes.dav` (the old `pathlib_next.uri.schemes.webdav` path no
96
+ longer exists). `archive.py` -> `archive/` package (`_base.py` shared machinery,
97
+ `zip.py`, `tar.py`) -- import-compatible for free, `pathlib_next.uri.schemes.archive`
98
+ still resolves (now the package) and re-exports `ArchiveUri`/`ZipUri`/`TarUri`.
99
+ `sftp.py` -> `sftp/` package (`_paramiko.py` holds the existing paramiko-backed
100
+ `SftpBackend`; `__init__.py` keeps `SftpPath`/`BaseSftpBackend`) -- same free
101
+ import-compat, `pathlib_next.uri.schemes.sftp` still resolves and re-exports
102
+ `SftpPath`/`BaseSftpBackend`/`SftpBackend`. Prepares the layout for an upcoming
103
+ second (asyncssh) backend; `SftpBackend` gained a `default()` classmethod factory
104
+ so `SftpPath._initbackend()` doesn't need to import `paramiko` itself.
105
+ - `SftpPath`'s connection caching moved from an external cache wrapping `backend.client()`
106
+ calls to being each backend's own responsibility (`SftpPath._sftpclient` is now a
107
+ trivial `self.backend.client(self.source)`, no per-backend branching). Needed so the
108
+ new asyncssh backend can use its own `(backend, source)`-keyed cache (see the
109
+ `sftp-async` entry above) without `SftpPath` needing to know which caching scheme
110
+ applies. **Behavior-affecting for custom `BaseSftpBackend` subclasses**: a `client()`
111
+ override that doesn't cache internally will now be called on every `_sftpclient`
112
+ access, not just on a cache miss -- `SftpBackend`/`AsyncsshSftpBackend` both cache
113
+ internally, so this only matters for third-party/test-double backends.
114
+ - `TestSftpContract`'s in-process test server (`tests/conftest.py::sftp_server`) is now
115
+ asyncssh's own `SFTPServer` (chrooted to `fixture_tree`) instead of a ~150-line
116
+ hand-rolled paramiko `ServerInterface`/`SFTPServerInterface` -- a client backend choice
117
+ is independent of which library the test server uses (verified: a paramiko client
118
+ talks standard SFTP to an asyncssh server fine). `TestSftpContract` itself is now
119
+ parametrized across both client backends (`paramiko`, `asyncssh`).
120
+
121
+ ### Fixed
122
+ - Recursive delete on exact object-store keys now treats the exact object as
123
+ the addressed path before considering a `"<key>/"` prefix tree, preventing
124
+ accidental prefix-tree deletion for `S3Path`, `GsPath`, and `AzPath`.
125
+ - Azure recursive delete falls back from `delete_blobs()` to per-blob
126
+ deletion when a provider or emulator rejects the batch API.
127
+ - `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.
128
+ - `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).
129
+ - `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.
130
+ - `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.
131
+ - `FtpPath.rmdir()` propagated raw `ftplib.error_perm` (550) instead of `OSError` when the directory was non-empty, violating the pathlib contract.
132
+ - `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.
133
+ - `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.
134
+ - README and docs landing page were still describing the pre-0.6.0 scheme set:
135
+ the capability matrix, extras table, and quick starts now cover `data:`,
136
+ `ftp(s):`, `zip:`/`tar:`, `dav(s):`, and `s3:` (all shipped in 0.6.0/0.7.0
137
+ but previously only documented in the Schemes guide).
138
+ - `LRU.maxsize` setter raised `TypeError` when shrinking below the current
139
+ fill (`OrderedDict.pop()` was called with the `last=False` kwarg meant for
140
+ `popitem()`).
141
+ - `DavPath.rmdir()` mapped directly to WebDAV `DELETE`, which is recursive
142
+ by spec (RFC 4918) -- it silently deleted non-empty collections instead
143
+ of enforcing pathlib's "must be empty" contract like every other scheme.
144
+ Now does a depth-1 PROPFIND first and raises `OSError` (`ENOTEMPTY`) if
145
+ children exist. The native recursive `DELETE` is still available, and
146
+ cheaper than the base class's client-side walk, via the new
147
+ `DavPath.rm(recursive=True)` override (one request).
148
+ - `Uri._make_child_relpath()` doubled the join slash for any scheme whose
149
+ `path` already ends in "/" (e.g. `f"{self.path}/{name}"` on an HTTP/DAV
150
+ directory path produced `"//name"`); also now treats an empty path with
151
+ an authority present as the same root as `"/"` (RFC 3986:
152
+ `"http://host"` == `"http://host/"`) instead of joining a bare, ambiguous
153
+ name with no leading slash.
154
+ - `_DirectoryListingParser._RE_FILESIZE`'s digit class excluded `,` -- the
155
+ `<table>` path strips commas from cell text before matching, but the
156
+ `<pre>` path matches first, so a comma-thousands size like `1,024`
157
+ matched only `"1"`, truncating `size` and leaking `,024` into
158
+ `description`.
159
+ - The RFC-1123 datetime bucket's trailing timezone match
160
+ (`... \d{2}:\d{2}:\d{2} .+`) used an unbounded, greedy `.+` that
161
+ swallowed the rest of the `<pre>` listing line, including any trailing
162
+ size/description text on the same row -- `time.strptime()` then raised
163
+ on the unconverted data, silently dropping `modified` **and** every
164
+ field after it for that entry. Narrowed to `\S+` (the timezone is one
165
+ token).
166
+ - `_DirectoryListingParser`'s absolute-href filter was a blanket
167
+ `startswith('/')` -- a reverse-proxied/absolute-URL-configured server
168
+ rendering *every* entry (not just the parent-directory link) as an
169
+ absolute href got back a completely empty listing, with no fallback
170
+ able to recover it. Scoped the filter to hrefs outside the listing's own
171
+ directory (parsed from `<title>Index of ...</title>`) instead, falling
172
+ back to the old blanket-drop behavior only when no title was parseable.
173
+ - `HttpPath.stat()`'s post-redirect HEAD re-fetch had no HEAD-405-to-GET
174
+ fallback, unlike the pre-redirect loop -- a server/proxy that rejects
175
+ HEAD outright (not just pre-redirect) surfaced `PermissionError` for a
176
+ directory that actually exists. Now mirrors the pre-redirect loop's
177
+ fallback.
178
+ - `HttpPath._listdir()` now retries once with a trailing slash if the
179
+ slash-less path 404s (defensive: real redirecting servers already work
180
+ via `requests`' default GET redirect-following, but a non-redirecting
181
+ server/proxy previously had no fallback at all).
182
+ - `HttpWriteStream.close()` raised *before* marking the underlying stream
183
+ closed on a failed upload, so a second `close()` call (context-manager
184
+ `__exit__` cleanup, or GC via `IOBase.__del__`) silently retried the PUT.
185
+ Now marks closed even on failure.
186
+ - `HttpPath.rmdir()`/`DavPath.rmdir()` never checked `is_dir()` before
187
+ falling through to `unlink()` -- an empty directory's listing and a
188
+ *file* whose body/PROPFIND response yields zero real entries are
189
+ indistinguishable from `_listdir()` alone, so calling `rmdir()` on a
190
+ file silently deleted it instead of raising `NotADirectoryError`
191
+ (`os.rmdir()`'s ENOTDIR contract).
192
+
193
+ ## [0.7.0] - 2026-07-11
194
+
195
+ ### Added (new schemes, optional extras)
196
+ - `dav:`/`davs:` scheme (`pathlib_next.uri.schemes.webdav.DavPath`):
197
+ extends `HttpPath` with WebDAV (RFC 4918) PROPFIND for real stat/listdir
198
+ metadata (replacing HTML-index scraping) and PUT/DELETE/MKCOL/MOVE for
199
+ full read/write access. Requests go to the equivalent `http:`/`https:`
200
+ URL; `as_uri()` still reports `dav:`/`davs:`. Reuses the `http` extra,
201
+ no new dependency. `rmdir()` is recursive by WebDAV spec, unlike
202
+ `pathlib.Path.rmdir()`'s "must be empty" contract -- documented, not
203
+ silent.
204
+ - `s3:` scheme (`pathlib_next.uri.schemes.s3.S3Path`,
205
+ `s3://bucket/key/path`): read/write/list via `boto3`. New `s3` extra.
206
+ S3 has no real directories -- `is_dir()` is prefix emulation (any
207
+ object key under `"<path>/"`), `mkdir()` creates a zero-byte `"<path>/"`
208
+ marker object, `rmdir()` requires no other keys under that prefix.
209
+ `rename()` uses server-side `copy_object`+`delete_object` (same-bucket
210
+ only) instead of the generic download+upload+delete `move()` fallback.
211
+
212
+ ## [0.6.0] - 2026-07-11
213
+
214
+ ### Added (new schemes, stdlib-only, no new deps)
215
+ - `data:` scheme (RFC 2397, `pathlib_next.uri.schemes.data.DataUri`):
216
+ read-only, no backend/connection -- the entire file content is embedded
217
+ in the URI (`data:[<mediatype>][;base64],<data>`). `stat().st_size` is
218
+ the decoded payload length; `iterdir()` raises `NotADirectoryError`
219
+ (it's always a single file); write operations raise `NotImplementedError`.
220
+ - `ftp:`/`ftps:` scheme (`pathlib_next.uri.schemes.ftp.FtpPath`): full
221
+ read/write/list access via stdlib `ftplib`, with a thread-keyed LRU
222
+ connection cache mirroring `sftp.py`. Listing/stat prefer MLSD (RFC
223
+ 3659); servers without it fall back to NLST (listing) and SIZE
224
+ (file-only stat). Writes buffer in memory and upload via STOR/APPE on
225
+ `close()`. `chmod()` uses the common but non-standard `SITE CHMOD`
226
+ extension (may not be supported by every server).
227
+ - `zip:`/`tar:` archive paths (`pathlib_next.uri.schemes.archive`):
228
+ `<scheme>:<archive-uri>!/<inner-path>` (Java-style `!/` separator,
229
+ URI form proposed to and confirmed by the user before implementation).
230
+ The archive half is itself any absolute URI with an explicit scheme, so
231
+ archives are readable straight off any other backend (`file:`, `http:`,
232
+ `sftp:`, `ftp:`, `data:`, ...). Read is supported for both schemes.
233
+ Write is `zip:`-only, and only for brand-new entries in a local (`file:`)
234
+ outer archive (overwriting/deleting/renaming an existing entry would
235
+ need a full-archive rewrite -- not implemented, raises
236
+ `NotImplementedError`). `tar:` auto-detects `.tar.gz`/`.tar.bz2`/`.tar.xz`
237
+ compression and is always read-only.
238
+
239
+ ## [0.5.0] - 2026-07-11
240
+
241
+ ### Fixed (critical -- found while writing the examples)
242
+ - `Path("...")` -- the top-level dispatcher documented in this project's
243
+ own README quick start and used throughout -- silently dropped its
244
+ constructor arguments on Python <3.12, leaving a blank instance that
245
+ crashed with `AttributeError: _drv` the moment anything touched it (e.g.
246
+ the `/` operator). Masked on 3.12+, where the real parsing happens in
247
+ `__init__` (called separately, with the original args, regardless of what
248
+ `__new__` did) rather than `__new__` itself. Every one of the new suite's 300
249
+ tests constructed via `LocalPath(...)` directly instead, so this went
250
+ undetected until `examples/local_and_mem.py` exercised the documented
251
+ `Path(...)` entry point end to end.
252
+
253
+ ### Fixed (found by the new test suite, not in the original bug list)
254
+ - `LocalPath.stat()`/`chmod()` inherit directly from `pathlib.Path` via MRO
255
+ and crashed with `TypeError` on Python 3.9 the moment anything passed
256
+ `follow_symlinks=` (e.g. `Path.walk()`'s default `follow_symlinks=False`)
257
+ -- now shimmed with `lstat()`/`lchmod()` on <3.10, same as the existing
258
+ `FileUri` shim (which now just delegates to `LocalPath`).
259
+ - `MemPath.__init__` decided whether to propagate a parent's backend with
260
+ `if _backend and backend is None:` -- an empty (but valid) backend dict is
261
+ falsy, so joining off a freshly-created, empty `MemPath` silently gave the
262
+ child a disconnected new backend instead of sharing the parent's.
263
+ - `MemPath.stat()` never set `st_size` for files (always defaulted to `0`),
264
+ breaking any size-based checksum comparison (notably `PathSyncer`'s
265
+ typical usage).
266
+ - `glob()`'s core algorithm decided whether to recurse into the *parent*
267
+ directory using whether the *leaf* segment is a wildcard, instead of
268
+ whether the *parent path itself* contains one. Since a wildcarded leaf
269
+ with a literal parent directory is the overwhelmingly common case
270
+ (`glob("*.py")`), this always took the "recurse into parent" branch,
271
+ which only degenerated back to the correct single directory when the
272
+ parent has a non-empty literal name to re-match against -- true for
273
+ essentially every real filesystem path except an OS root. It silently
274
+ returned the wrong result on `MemPath`'s virtual root (empty name).
275
+ - `HttpPath.iterdir()` gave every subdirectory entry an empty `.name`:
276
+ directory-listing entries for subdirectories carry a trailing `/`
277
+ (`htmllistparse`'s convention), which wasn't stripped before building the
278
+ child's path, and `Pathname.name` derives from the last path segment --
279
+ empty for a trailing-slash path.
280
+ - `SftpPath.rename()` resolved a plain string target relative to `self`
281
+ (joining it as a child, e.g. `"/a.txt".rename("b.txt")` produced
282
+ `"/a.txt/b.txt"`) instead of `self`'s parent (sibling rename).
283
+
284
+ ### Added (test suite)
285
+ - Full pytest suite (`tests/`): pure-path parity against `pathlib.PurePosixPath`
286
+ (`test_parity_pure.py`), local I/O parity against `pathlib.Path`/`os.walk`
287
+ (`test_parity_io.py`), a reusable filesystem-contract mixin run against
288
+ `LocalPath`/`MemPath`/`FileUri` and exported as `pathlib_next.testing.
289
+ PathContract` for third-party `Path`/`UriPath` implementers
290
+ (`test_contract.py`), glob vs. stdlib ground truth (`test_glob.py`), URI
291
+ parsing/scheme-dispatch/query/source coverage, MemPath- and SFTP-specific
292
+ unit tests (SFTP mocked, no real server), HTTP tests against a real stdlib
293
+ `ThreadingHTTPServer`, and `PathSyncer` coverage. 300 tests, ~85% line
294
+ coverage, green on both Python 3.9 and 3.13.
295
+
296
+ ### Added (docs)
297
+ - `docs/guides/schemes.md` (capability matrix per scheme) and
298
+ `docs/guides/extending.md` (both extension tracks, with worked examples
299
+ and `pathlib_next.testing.PathContract` usage). Rewrote `docs/index.md`
300
+ and the README with a 30-second example per scheme and a capability
301
+ matrix. Class-level docstrings added across the package for the rendered
302
+ API reference.
303
+
304
+ ### Changed
305
+ - `examples/example.py` (an unstructured scratch script) split into three
306
+ focused, runnable examples: `examples/local_and_mem.py` (self-contained,
307
+ no network), `examples/http_listing.py` and `examples/sftp_sync.py`
308
+ (network-touching, guarded under `if __name__ == "__main__"`,
309
+ configurable via env vars, fail soft when unreachable/unconfigured).
310
+
311
+ ### Added
312
+ - `Pathname.joinpath()`, `Pathname.full_match()` (3.13 parity, supports `**`
313
+ matching any number of segments), `Pathname.anchor`/`drive`/`root`
314
+ (generic derivation for non-local paths), `Path.rglob()`,
315
+ `read_text(..., newline=)` (3.13 parity), `Path.samefile()` (default
316
+ `st_dev`/`st_ino` comparison when the backend's `stat()` provides them,
317
+ `NotImplementedError` otherwise).
318
+ - `Path.glob()`/`LocalPath.glob()`: `recursive=` now auto-detects (`True` if
319
+ the pattern has a `"**"` component) instead of defaulting to `False`;
320
+ explicit `recursive=True`/`False` still overrides.
321
+ - `Path.copy()`: raises `IsADirectoryError` when the target is an existing
322
+ directory (previously misbehaved); gained `follow_symlinks=`/
323
+ `preserve_metadata=` kwargs, named to match CPython 3.14's `Path.copy()`.
324
+ - `docs/divergences.md`: registry of every deliberate behavioral divergence
325
+ from `pathlib`, with rationale. Linked from the docs nav.
326
+
327
+ ### Fixed
328
+ - `Path.mkdir(parents=True)` created intermediate parents with `exist_ok=False`
329
+ (racy, and wrong when a parent already existed) and dropped the caller's
330
+ `exist_ok` on the final retry.
331
+ - `Path.touch(exist_ok=False)` silently truncated an existing file instead of
332
+ raising `FileExistsError` (pathlib parity).
333
+ - `LocalPath.glob()`'s `dironly` parameter defaulted to `False`, which made the
334
+ `is None` check for trailing-slash directory-only detection dead code.
335
+ - `Stat._st_mode()` only caught `FileNotFoundError`, letting `PermissionError`
336
+ and other `OSError`s propagate out of `exists()`/`is_dir()`/etc. where pathlib
337
+ returns `False`. Also fixed: `follow_symlinks` was accepted but never
338
+ forwarded to the underlying `stat()` call, so `is_symlink()` never actually
339
+ inspected the symlink itself.
340
+ - `MemPath._open()` treated any mode other than `"w"` as a read, so `"a"`/`"x"`
341
+ silently misbehaved; now dispatches `r`/`w`/`x`/`a` correctly and raises
342
+ `NotImplementedError` for anything else. `MemBytesIO.close()` used
343
+ `seek(0);read()` instead of `getvalue()`, losing content if the caller's
344
+ cursor wasn't already at position 0 when closing.
345
+ - `MemPath.normalized` mangled `".."`-escaping paths (e.g. `".."`) into `"."`;
346
+ now normalizes against a virtual root so they clamp at the root instead.
347
+ - `PathAndStat.__getattr__()` returned `None` for any unrecognized attribute
348
+ instead of raising `AttributeError`, breaking `hasattr()`-based logic.
349
+ - `parsedate(None)` / an unparseable date string returned "now" instead of
350
+ epoch 0, which could poison `PathSyncer`'s checksum/freshness comparisons for
351
+ HTTP sources with no `Last-Modified` header.
352
+ - `HttpPath.stat()` used a bare `except:`; cached `_isdir` from a response that
353
+ hadn't been confirmed successful yet (including 404s); and didn't fall back
354
+ to GET when a server rejected `HEAD` with 405.
355
+ - `uri.Query` no longer depends on `uritools`' private `_querydict`/`_querylist`
356
+ helpers (reimplemented locally against the public `uriencode()`).
357
+ - `Uri` join (`_load_parts`): `query`/`fragment` are now resolved with the same
358
+ "last segment that actually sets one wins" rule already used for `source`
359
+ (previously any segment, even one with no query/fragment, would blank out an
360
+ earlier segment's). Join semantics are now documented explicitly:
361
+ pathlib-`joinpath`-like, not RFC 3986 reference resolution, `..` is never
362
+ resolved during join.
363
+ - `Source.is_local()` (DNS lookup) and `get_machine_ips()` are now
364
+ `functools.lru_cache`d -- previously ran on every call.
365
+
366
+ ### Fixed (crash-level bugs)
367
+ - `MemPath.stat()`/`MemPath._open()` returned a `FileNotFoundError` instance instead
368
+ of raising it for a missing path, causing an unrelated `AttributeError` downstream.
369
+ - `LRU.invalidate()` called `self.lock()` instead of using `self.lock` as a context
370
+ manager (`RLock` isn't callable) -- broke the SFTP client reconnect path.
371
+ - `Pathname.match()` had reversed `isinstance()` arguments and compared against
372
+ `str(self)` (which includes scheme/host for `Uri`) instead of `as_posix()`.
373
+ - Glob wildcard detection (`WILCARD_PATTERN`, renamed `WILDCARD_PATTERN`, old name
374
+ kept as an alias) used `.match()` (anchored) instead of `.search()`, so patterns
375
+ like `"foo*"` weren't recognized as wildcards.
376
+ - `Uri` was unhashable (defined `__eq__` without `__hash__`); `__eq__` now also
377
+ returns `NotImplemented` for non-`Pathname`/`str` operands instead of raising.
378
+ - `Uri.is_relative_to()` used `str.startswith()` on normalized path strings, so
379
+ `/foo/bar2` was incorrectly reported as relative to `/foo/bar`; now compares
380
+ path segments.
381
+ - `Uri.relative_to(walk_up=True)` was dead code -- an early guard raised
382
+ `ValueError` before the walk-up loop ever ran.
383
+ - `HttpPath.is_dir()`/`is_file()` tested truthiness of bound methods
384
+ (`self._is_dir`, `self.is_dir`) instead of calling/checking the right attribute,
385
+ so both always returned truthy nonsense.
386
+ - `SftpPath.chmod()` didn't accept `follow_symlinks=`, so the inherited `lchmod()`
387
+ crashed with `TypeError`; now raises `NotImplementedError` for
388
+ `follow_symlinks=False` (paramiko has no `lchmod`).
389
+ - `SftpPath` defined `_rename()`, which nothing ever called -- renamed to
390
+ `rename()` so `move()`/`rename()` actually use SFTP's native rename instead of
391
+ silently falling back to copy+unlink for every move.
392
+ - `Uri.__init__()` used a bare `except:` around `Path.as_uri()` (now
393
+ `except ValueError:`, matching what `as_uri()` actually raises for relative
394
+ paths) and crashed with `AttributeError` when constructing from an
395
+ `os.PathLike` that only implements `__fspath__` (no `as_posix()`).
396
+ - `Path.rm(ignore_error=callable)` never actually called the callable -- both
397
+ branches of its error handler returned the callable object itself.
398
+
399
+ ### Fixed (Python 3.9/3.10 compatibility)
400
+ - Actual Python 3.9/3.10 runtime compatibility (CI previously only tested 3.11/3.13
401
+ and missed these): `LocalPath`/`Uri` case-sensitivity and path-separator detection
402
+ crashed on 3.9-3.11 (`_flavour` object has no `normcase`); `open(mode="r")` crashed
403
+ on <3.10 (`io.text_encoding` is 3.10+); glob pattern compilation crashed on <3.11
404
+ (`re.NOFLAG` is 3.11+); `FileUri.stat()`/`chmod()` crashed on 3.9
405
+ (`pathlib.Path.stat/chmod` gained `follow_symlinks=` in 3.10; raises
406
+ `NotImplementedError` there for `follow_symlinks=False`).
407
+ - `LocalPath._path_separators` returned the env-var list separator (`;`/`:`) instead
408
+ of the path separator, and could include a `None` altsep on POSIX.
409
+
410
+ ### Added
411
+ - `tests/test_smoke.py`: regression coverage for README/example snippets across
412
+ supported Python versions.
413
+
414
+ ## [0.4.1] - 2026-07-11
415
+
416
+ ### Fixed
417
+ - Removed explicit `[tool.hatch.build.targets.wheel]` packages config that caused hatchling to fail resolving `README.md` during editable installs on CI.
418
+ - Converted `README.md` from a symlink (mode `120000`) to a regular file, fixing `git checkout` failures on macOS and Windows runners.
419
+ - Removed internal tooling references from committed files.
420
+
421
+ ## [0.4.0] - 2026-07-11
422
+
423
+ ### Added
424
+ - Standardized repository layout and relocated examples to `examples/` directory.
425
+ - Configured MkDocs documentation site with dynamic API reference using `mkdocstrings`.
426
+ - Added GitHub Actions workflows for matrix testing (`test.yml`) and release pipelines (`release.yml`).
427
+ - Added typing marker `py.typed` for PEP 561 compliance.
428
+
429
+ ### Changed
430
+ - 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`.
431
+ - Updated package requirement to `requires-python = ">=3.9"`.
432
+
433
+ ## [0.3.5] - 2026-07-11
434
+
435
+ ### Added
436
+ - Split path into protocols that can be standalone.
437
+ - Sync error handling.
438
+ - Generic Path Protocol based pathlib implementation for URI paths with file access support for sftp, http, file schemes.
439
+
440
+ [Unreleased]: https://github.com/jose-pr/pathlib_next/compare/v0.7.0...HEAD
441
+ [0.7.0]: https://github.com/jose-pr/pathlib_next/compare/v0.6.0...v0.7.0
442
+ [0.6.0]: https://github.com/jose-pr/pathlib_next/compare/v0.5.0...v0.6.0
443
+ [0.5.0]: https://github.com/jose-pr/pathlib_next/compare/v0.4.1...v0.5.0
444
+ [0.4.1]: https://github.com/jose-pr/pathlib_next/compare/v0.4.0...v0.4.1
445
+ [0.4.0]: https://github.com/jose-pr/pathlib_next/releases/tag/v0.4.0
446
+ [0.3.5]: https://github.com/jose-pr/pathlib_next/releases/tag/v0.3.5
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pathlib_next
3
- Version: 0.7.0
3
+ Version: 0.8.0
4
4
  Summary: Generic Path Protocol based pathlib
5
5
  Project-URL: Homepage, https://github.com/jose-pr/pathlib_next/
6
6
  Project-URL: Documentation, https://jose-pr.github.io/pathlib_next/
@@ -11,19 +11,28 @@ Classifier: License :: OSI Approved :: MIT License
11
11
  Classifier: Operating System :: OS Independent
12
12
  Classifier: Programming Language :: Python :: 3
13
13
  Requires-Python: >=3.9
14
+ Provides-Extra: az
15
+ Requires-Dist: azure-storage-blob; extra == 'az'
16
+ Requires-Dist: uritools; extra == 'az'
14
17
  Provides-Extra: dev
15
18
  Requires-Dist: build; extra == 'dev'
19
+ Requires-Dist: cheroot; extra == 'dev'
16
20
  Requires-Dist: hatchling; extra == 'dev'
21
+ Requires-Dist: hypothesis; extra == 'dev'
22
+ Requires-Dist: moto[s3]; extra == 'dev'
23
+ Requires-Dist: pyftpdlib; extra == 'dev'
17
24
  Requires-Dist: pytest; extra == 'dev'
18
25
  Requires-Dist: pytest-cov; extra == 'dev'
19
26
  Requires-Dist: twine; extra == 'dev'
27
+ Requires-Dist: wsgidav; extra == 'dev'
20
28
  Provides-Extra: docs
21
29
  Requires-Dist: mkdocs; extra == 'docs'
22
30
  Requires-Dist: mkdocs-material; extra == 'docs'
23
31
  Requires-Dist: mkdocstrings[python]; extra == 'docs'
32
+ Provides-Extra: gs
33
+ Requires-Dist: google-cloud-storage; extra == 'gs'
34
+ Requires-Dist: uritools; extra == 'gs'
24
35
  Provides-Extra: http
25
- Requires-Dist: bs4; extra == 'http'
26
- Requires-Dist: htmllistparse; extra == 'http'
27
36
  Requires-Dist: requests; extra == 'http'
28
37
  Requires-Dist: uritools; extra == 'http'
29
38
  Provides-Extra: s3
@@ -32,6 +41,10 @@ Requires-Dist: uritools; extra == 's3'
32
41
  Provides-Extra: sftp
33
42
  Requires-Dist: paramiko; extra == 'sftp'
34
43
  Requires-Dist: uritools; extra == 'sftp'
44
+ Provides-Extra: sftp-async
45
+ Requires-Dist: asyncssh; (python_version >= '3.10') and extra == 'sftp-async'
46
+ Requires-Dist: asyncssh<2.22; (python_version < '3.10') and extra == 'sftp-async'
47
+ Requires-Dist: uritools; extra == 'sftp-async'
35
48
  Provides-Extra: uri
36
49
  Requires-Dist: uritools; extra == 'uri'
37
50
  Description-Content-Type: text/markdown
@@ -54,23 +67,24 @@ tree, an HTTP index, or an SFTP server. Every intentional divergence from
54
67
 
55
68
  ## Features
56
69
 
57
- | Capability | `LocalPath` | `file:` | `mem:` (`MemPath`) | `http(s):` | `sftp:` |
58
- | --- | --- | --- | --- | --- | --- |
59
- | Read | Yes | Yes | Yes | Yes | Yes |
60
- | Write | Yes | Yes | Yes | No | Yes |
61
- | List (`iterdir`) | Yes | Yes | Yes | Yes (HTML index) | Yes |
62
- | Stat / exists / is_dir / is_file | Yes | Yes | Yes | Yes | Yes |
63
- | `mkdir` | Yes | Yes | Yes | No | Yes |
64
- | Delete | Yes | Yes | Yes | No | Yes |
65
- | `rename` | Yes | Yes | No (copy+unlink fallback) | No | Yes |
66
- | Extra required | none | none | none | `http` | `sftp` |
70
+ | Scheme | Read | Write | List | Stat | mkdir | Delete | rename | Extra required |
71
+ | --- | --- | --- | --- | --- | --- | --- | --- | --- |
72
+ | `LocalPath` / `file:` | Yes | Yes | Yes | Yes | Yes | Yes | Yes | none |
73
+ | `mem:` (`MemPath`) | Yes | Yes | Yes | Yes | Yes | Yes | No | none |
74
+ | `data:` (RFC 2397) | Yes | No | No | Yes | No | No | No | none |
75
+ | `zip:` / `tar:` (archive `!/` paths) | Yes | zip: new entries, local archive | Yes | Yes | zip: local | No | No | none |
76
+ | `ftp(s):` | Yes | Yes | Yes | Yes | Yes | Yes | Yes | none |
77
+ | `http(s):` | Yes | No | Yes (HTML index) | Yes | No | No | No | `http` |
78
+ | `dav(s):` (WebDAV) | Yes | Yes | Yes (PROPFIND) | Yes | Yes | Yes | Yes | `http` |
79
+ | `sftp:` | Yes | Yes | Yes | Yes | Yes | Yes | Yes | `sftp` |
80
+ | `s3:` | Yes | Yes | Yes (prefix emulation) | Yes | Yes | Yes | Yes (same bucket) | `s3` |
67
81
 
68
82
  Every scheme shares the same `glob()`, `walk()`, `copy()`/`move()`, `rm()`
69
83
  implementations -- see the full matrix and notes in
70
84
  [Schemes](https://jose-pr.github.io/pathlib_next/guides/schemes/).
71
85
 
72
- - **Unified path interface** across local files, in-memory paths, and
73
- `sftp`/`http`/`file` URIs.
86
+ - **Unified path interface** across local files, in-memory paths, archive
87
+ members, and `file`/`data`/`ftp`/`http`/`dav`/`sftp`/`s3` URIs.
74
88
  - **`MemPath`** -- a lightweight virtual filesystem for mocks, tests, or
75
89
  transient storage.
76
90
  - **`PathSyncer`** -- one-way checksum-driven tree sync between any two
@@ -91,12 +105,15 @@ Optional features/extras:
91
105
 
92
106
  | Extra/flag | Adds | Needed for |
93
107
  | --- | --- | --- |
94
- | `uri` | `uritools` | URI parsing capabilities |
95
- | `http` | `requests`, `bs4`, `htmllistparse` | Read and list files over HTTP/HTTPS |
96
- | `sftp` | `paramiko` | SFTP path operations and transfers |
108
+ | `uri` | `uritools` | URI parsing (any `UriPath` scheme) |
109
+ | `http` | `requests` | `http(s):` and `dav(s):` (WebDAV) paths |
110
+ | `sftp` | `paramiko` | `sftp:` path operations and transfers (sync backend) |
111
+ | `sftp-async` | `asyncssh` | `sftp:` path operations via the asyncssh backend instead (see `guides/schemes.md`'s `sftp:` row for selection precedence) |
112
+ | `s3` | `boto3` | `s3://bucket/key` paths |
97
113
 
98
114
  `import pathlib_next` and `LocalPath`/`MemPath` work with no extras
99
- installed.
115
+ installed; `data:`, `ftp(s):`, and `zip:`/`tar:` only need the `uri` extra
116
+ (they're stdlib-based otherwise).
100
117
 
101
118
  ## Quick start
102
119
 
@@ -149,6 +166,21 @@ p = UriPath("sftp://user@host/var/log/app.log")
149
166
  print(p.read_text())
150
167
  ```
151
168
 
169
+ **`zip:`/`tar:`** -- address a member *inside* an archive (Java-style `!/`
170
+ separator; the archive half is itself any URI -- `file:`, `http:`, `sftp:`, ...):
171
+
172
+ ```python
173
+ from pathlib_next.uri import UriPath
174
+
175
+ member = UriPath("zip:file:./backup.zip!/etc/config.ini")
176
+ print(member.read_text())
177
+ ```
178
+
179
+ Also built in: `data:` (RFC 2397 inline payloads), `ftp(s):` (stdlib
180
+ `ftplib`), `dav(s):` (WebDAV, full read/write over HTTP), and `s3:`
181
+ (`boto3`) -- one example per scheme in
182
+ [Schemes](https://jose-pr.github.io/pathlib_next/guides/schemes/).
183
+
152
184
  ## Extending
153
185
 
154
186
  Two first-class ways to add a new path-addressable resource -- both covered
@@ -160,9 +192,7 @@ in depth, with worked examples, in
160
192
  - Subclass `UriPath` and set `__SCHEMES` for a new URI scheme (`FileUri`/
161
193
  `HttpPath`/`SftpPath` are the built-in examples).
162
194
 
163
- `pathlib_next.testing.PathContract` is a reusable pytest mixin covering the
164
- baseline contract every implementation must satisfy -- subclass it with a
165
- `root` fixture to verify your own.
195
+ `pathlib_next.testing` provides reusable pytest mixins (`PurePathContract`, `ReadPathContract`, and `PathContract`) covering the baseline contracts for various levels of capabilities -- subclass one of them with a `root` fixture to verify your own implementation.
166
196
 
167
197
  ## API overview
168
198
 
@@ -170,6 +200,7 @@ baseline contract every implementation must satisfy -- subclass it with a
170
200
  | --- | --- |
171
201
  | `pathlib_next.path` | Base Path implementation and protocols |
172
202
  | `pathlib_next.uri` | URI/URL specific path support and Query utils |
203
+ | `pathlib_next.uri.schemes` | Built-in schemes: `file`, `data`, `ftp`, `zip`/`tar`, `http`, `dav`, `sftp`, `s3` |
173
204
  | `pathlib_next.mempath` | In-memory transient path structure |
174
205
  | `pathlib_next.utils.sync` | Synchronization functions and PathSyncer class |
175
206
  | `pathlib_next.testing` | `PathContract`, a pytest mixin for verifying custom implementations |
@@ -182,7 +213,7 @@ Python >= 3.9, tested on 3.9 and 3.13 in CI (see
182
213
  ## Development
183
214
 
184
215
  ```bash
185
- pip install -e ".[dev,uri,http,sftp]"
216
+ pip install -e ".[dev,uri,http,sftp,sftp-async]"
186
217
  pytest -q
187
218
  ```
188
219
 
@@ -190,6 +221,16 @@ If you maintain separate virtual environments per Python version locally
190
221
  (e.g. `.venv/3.9/`, `.venv/3.13/`), run the same `pytest -q` in each --
191
222
  CI does the equivalent across Python 3.9/3.13 on Linux, macOS, and Windows.
192
223
 
224
+ ### Benchmarks
225
+
226
+ Run the benchmark suite using:
227
+ ```bash
228
+ python benchmarks/bench.py
229
+ ```
230
+
231
+ A benchmark report and methodology notes live in
232
+ [`docs/benchmarks.md`](docs/benchmarks.md).
233
+
193
234
  ### Releasing
194
235
 
195
236
  This project follows [Semantic Versioning](https://semver.org/) and keeps a