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