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.
- {pathlib_next-0.7.0 → pathlib_next-0.8.0}/.gitignore +1 -0
- pathlib_next-0.8.0/CHANGELOG.md +446 -0
- {pathlib_next-0.7.0 → pathlib_next-0.8.0}/PKG-INFO +64 -23
- {pathlib_next-0.7.0 → pathlib_next-0.8.0}/README.md +48 -20
- pathlib_next-0.8.0/docs/api/mempath.md +3 -0
- pathlib_next-0.8.0/docs/api/path.md +4 -0
- pathlib_next-0.8.0/docs/api/testing.md +3 -0
- pathlib_next-0.8.0/docs/api/uri.md +5 -0
- pathlib_next-0.8.0/docs/api/utils.md +6 -0
- pathlib_next-0.8.0/docs/benchmarks.md +204 -0
- {pathlib_next-0.7.0 → pathlib_next-0.8.0}/docs/divergences.md +15 -4
- pathlib_next-0.8.0/docs/guides/cli.md +34 -0
- {pathlib_next-0.7.0 → pathlib_next-0.8.0}/docs/guides/extending.md +47 -7
- pathlib_next-0.8.0/docs/guides/schemes.md +133 -0
- {pathlib_next-0.7.0 → pathlib_next-0.8.0}/docs/index.md +21 -7
- pathlib_next-0.8.0/examples/az_listing.py +37 -0
- pathlib_next-0.8.0/examples/data_and_archive.py +97 -0
- pathlib_next-0.8.0/examples/ftp_listing.py +49 -0
- pathlib_next-0.8.0/examples/github_listing.py +52 -0
- pathlib_next-0.8.0/examples/gitlab_listing.py +54 -0
- pathlib_next-0.8.0/examples/gs_listing.py +36 -0
- {pathlib_next-0.7.0 → pathlib_next-0.8.0}/examples/http_listing.py +5 -4
- pathlib_next-0.8.0/examples/s3_listing.py +46 -0
- pathlib_next-0.8.0/examples/webdav_roundtrip.py +72 -0
- {pathlib_next-0.7.0 → pathlib_next-0.8.0}/mkdocs.yml +9 -2
- pathlib_next-0.8.0/pyproject.toml +79 -0
- {pathlib_next-0.7.0 → pathlib_next-0.8.0}/src/pathlib_next/__init__.py +0 -1
- {pathlib_next-0.7.0 → pathlib_next-0.8.0}/src/pathlib_next/path.py +164 -27
- {pathlib_next-0.7.0 → pathlib_next-0.8.0}/src/pathlib_next/protocols/io.py +1 -0
- pathlib_next-0.8.0/src/pathlib_next/testing.py +197 -0
- pathlib_next-0.8.0/src/pathlib_next/tools/__init__.py +1 -0
- pathlib_next-0.8.0/src/pathlib_next/tools/uripath.py +180 -0
- {pathlib_next-0.7.0 → pathlib_next-0.8.0}/src/pathlib_next/uri/__init__.py +197 -39
- {pathlib_next-0.7.0 → pathlib_next-0.8.0}/src/pathlib_next/uri/query.py +4 -0
- {pathlib_next-0.7.0 → pathlib_next-0.8.0}/src/pathlib_next/uri/schemes/__init__.py +13 -1
- pathlib_next-0.8.0/src/pathlib_next/uri/schemes/_gitrepo.py +133 -0
- pathlib_next-0.8.0/src/pathlib_next/uri/schemes/archive/__init__.py +35 -0
- pathlib_next-0.8.0/src/pathlib_next/uri/schemes/archive/_base.py +295 -0
- pathlib_next-0.8.0/src/pathlib_next/uri/schemes/archive/tar.py +40 -0
- pathlib_next-0.8.0/src/pathlib_next/uri/schemes/archive/zip.py +153 -0
- pathlib_next-0.8.0/src/pathlib_next/uri/schemes/az.py +297 -0
- 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
- {pathlib_next-0.7.0 → pathlib_next-0.8.0}/src/pathlib_next/uri/schemes/ftp.py +51 -15
- pathlib_next-0.8.0/src/pathlib_next/uri/schemes/git/__init__.py +4 -0
- pathlib_next-0.8.0/src/pathlib_next/uri/schemes/git/_base.py +39 -0
- pathlib_next-0.8.0/src/pathlib_next/uri/schemes/git/github.py +9 -0
- pathlib_next-0.8.0/src/pathlib_next/uri/schemes/git/gitlab.py +9 -0
- pathlib_next-0.8.0/src/pathlib_next/uri/schemes/github.py +101 -0
- pathlib_next-0.8.0/src/pathlib_next/uri/schemes/gitlab.py +129 -0
- pathlib_next-0.8.0/src/pathlib_next/uri/schemes/gs.py +251 -0
- pathlib_next-0.8.0/src/pathlib_next/uri/schemes/http.py +632 -0
- {pathlib_next-0.7.0 → pathlib_next-0.8.0}/src/pathlib_next/uri/schemes/s3.py +87 -3
- pathlib_next-0.8.0/src/pathlib_next/uri/schemes/sftp/__init__.py +351 -0
- pathlib_next-0.8.0/src/pathlib_next/uri/schemes/sftp/_asyncssh.py +739 -0
- pathlib_next-0.8.0/src/pathlib_next/uri/schemes/sftp/_paramiko.py +125 -0
- pathlib_next-0.8.0/src/pathlib_next/uri/source.py +272 -0
- {pathlib_next-0.7.0 → pathlib_next-0.8.0}/src/pathlib_next/utils/__init__.py +6 -1
- pathlib_next-0.8.0/src/pathlib_next/utils/archive.py +149 -0
- pathlib_next-0.8.0/src/pathlib_next/utils/checksum.py +31 -0
- {pathlib_next-0.7.0 → pathlib_next-0.8.0}/src/pathlib_next/utils/stat.py +14 -8
- {pathlib_next-0.7.0 → pathlib_next-0.8.0}/src/pathlib_next/utils/sync.py +67 -23
- pathlib_next-0.8.0/tests/conftest.py +1208 -0
- pathlib_next-0.8.0/tests/test_archive_uri.py +394 -0
- pathlib_next-0.8.0/tests/test_az.py +90 -0
- pathlib_next-0.8.0/tests/test_az_fake.py +164 -0
- pathlib_next-0.8.0/tests/test_contract.py +255 -0
- pathlib_next-0.8.0/tests/test_dav.py +50 -0
- pathlib_next-0.8.0/tests/test_gitrepo.py +288 -0
- pathlib_next-0.8.0/tests/test_gs.py +89 -0
- pathlib_next-0.8.0/tests/test_gs_fake.py +145 -0
- pathlib_next-0.8.0/tests/test_http.py +459 -0
- pathlib_next-0.8.0/tests/test_http_live.py +132 -0
- pathlib_next-0.8.0/tests/test_http_parser.py +237 -0
- {pathlib_next-0.7.0 → pathlib_next-0.8.0}/tests/test_parity_io.py +65 -1
- pathlib_next-0.8.0/tests/test_path_gaps.py +104 -0
- {pathlib_next-0.7.0 → pathlib_next-0.8.0}/tests/test_pathname.py +24 -0
- pathlib_next-0.8.0/tests/test_plugins.py +123 -0
- pathlib_next-0.8.0/tests/test_properties.py +366 -0
- {pathlib_next-0.7.0 → pathlib_next-0.8.0}/tests/test_s3.py +85 -0
- pathlib_next-0.8.0/tests/test_sftp.py +430 -0
- pathlib_next-0.8.0/tests/test_sftp_asyncssh.py +712 -0
- {pathlib_next-0.7.0 → pathlib_next-0.8.0}/tests/test_smoke.py +36 -3
- {pathlib_next-0.7.0 → pathlib_next-0.8.0}/tests/test_sync.py +54 -0
- {pathlib_next-0.7.0 → pathlib_next-0.8.0}/tests/test_uri_path.py +43 -0
- pathlib_next-0.8.0/tests/test_uripath_tool.py +90 -0
- pathlib_next-0.8.0/tests/test_utils.py +308 -0
- pathlib_next-0.8.0/tests/test_walk.py +117 -0
- {pathlib_next-0.7.0 → pathlib_next-0.8.0}/tests/test_webdav.py +90 -1
- pathlib_next-0.7.0/CHANGELOG.md +0 -263
- pathlib_next-0.7.0/docs/api/reference.md +0 -3
- pathlib_next-0.7.0/docs/guides/schemes.md +0 -78
- pathlib_next-0.7.0/pyproject.toml +0 -37
- pathlib_next-0.7.0/src/pathlib_next/testing.py +0 -132
- pathlib_next-0.7.0/src/pathlib_next/uri/schemes/archive.py +0 -266
- pathlib_next-0.7.0/src/pathlib_next/uri/schemes/http.py +0 -172
- pathlib_next-0.7.0/src/pathlib_next/uri/schemes/sftp.py +0 -130
- pathlib_next-0.7.0/src/pathlib_next/uri/source.py +0 -87
- pathlib_next-0.7.0/tests/conftest.py +0 -63
- pathlib_next-0.7.0/tests/test_archive_uri.py +0 -198
- pathlib_next-0.7.0/tests/test_contract.py +0 -29
- pathlib_next-0.7.0/tests/test_http.py +0 -70
- pathlib_next-0.7.0/tests/test_sftp.py +0 -158
- pathlib_next-0.7.0/tests/test_utils.py +0 -68
- {pathlib_next-0.7.0 → pathlib_next-0.8.0}/LICENSE +0 -0
- {pathlib_next-0.7.0 → pathlib_next-0.8.0}/docs/changelog.md +0 -0
- {pathlib_next-0.7.0 → pathlib_next-0.8.0}/examples/local_and_mem.py +0 -0
- {pathlib_next-0.7.0 → pathlib_next-0.8.0}/examples/sftp_sync.py +0 -0
- {pathlib_next-0.7.0 → pathlib_next-0.8.0}/src/pathlib_next/fspath.py +0 -0
- {pathlib_next-0.7.0 → pathlib_next-0.8.0}/src/pathlib_next/mempath.py +0 -0
- {pathlib_next-0.7.0 → pathlib_next-0.8.0}/src/pathlib_next/protocols/__init__.py +0 -0
- {pathlib_next-0.7.0 → pathlib_next-0.8.0}/src/pathlib_next/protocols/fs.py +0 -0
- {pathlib_next-0.7.0 → pathlib_next-0.8.0}/src/pathlib_next/py.typed +0 -0
- {pathlib_next-0.7.0 → pathlib_next-0.8.0}/src/pathlib_next/uri/schemes/data.py +0 -0
- {pathlib_next-0.7.0 → pathlib_next-0.8.0}/src/pathlib_next/uri/schemes/file.py +0 -0
- {pathlib_next-0.7.0 → pathlib_next-0.8.0}/src/pathlib_next/utils/glob.py +0 -0
- {pathlib_next-0.7.0 → pathlib_next-0.8.0}/tests/test_data_uri.py +0 -0
- {pathlib_next-0.7.0 → pathlib_next-0.8.0}/tests/test_ftp.py +0 -0
- {pathlib_next-0.7.0 → pathlib_next-0.8.0}/tests/test_glob.py +0 -0
- {pathlib_next-0.7.0 → pathlib_next-0.8.0}/tests/test_local.py +0 -0
- {pathlib_next-0.7.0 → pathlib_next-0.8.0}/tests/test_mempath.py +0 -0
- {pathlib_next-0.7.0 → pathlib_next-0.8.0}/tests/test_parity_pure.py +0 -0
- {pathlib_next-0.7.0 → pathlib_next-0.8.0}/tests/test_query.py +0 -0
- {pathlib_next-0.7.0 → pathlib_next-0.8.0}/tests/test_source.py +0 -0
- {pathlib_next-0.7.0 → pathlib_next-0.8.0}/tests/test_uri_parse.py +0 -0
|
@@ -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.
|
|
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
|
-
|
|
|
58
|
-
| --- | --- | --- | --- | --- | --- |
|
|
59
|
-
|
|
|
60
|
-
|
|
|
61
|
-
|
|
|
62
|
-
|
|
|
63
|
-
| `
|
|
64
|
-
|
|
|
65
|
-
| `
|
|
66
|
-
|
|
|
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,
|
|
73
|
-
`
|
|
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
|
|
95
|
-
| `http` | `requests
|
|
96
|
-
| `sftp` | `paramiko` |
|
|
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
|
|
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
|