pathlib-next 0.9.6__tar.gz → 0.9.8__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.6 → pathlib_next-0.9.8}/CHANGELOG.md +129 -1
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/PKG-INFO +1 -1
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/docs/divergences.md +6 -1
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/pyproject.toml +1 -1
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/src/pathlib_next/AGENTS.md +53 -5
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/src/pathlib_next/fspath.py +29 -5
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/src/pathlib_next/path.py +190 -14
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/src/pathlib_next/uri/__init__.py +181 -20
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/src/pathlib_next/uri/schemes/archive/_base.py +75 -24
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/src/pathlib_next/uri/schemes/archive/zip.py +5 -2
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/src/pathlib_next/uri/schemes/file.py +16 -2
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/src/pathlib_next/uri/schemes/sftp/__init__.py +14 -1
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/src/pathlib_next/uri/schemes/sftp/_asyncssh.py +27 -20
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/src/pathlib_next/utils/archive.py +9 -3
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/src/pathlib_next/utils/glob.py +116 -15
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/tests/test_archive_safety.py +111 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/tests/test_destructive_safety.py +190 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/tests/test_glob_parity.py +92 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/tests/test_pure_parity.py +31 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/tests/test_routing.py +34 -3
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/tests/test_sftp.py +49 -0
- pathlib_next-0.9.8/tests/test_uri_path.py +385 -0
- pathlib_next-0.9.6/tests/test_uri_path.py +0 -200
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/.gitignore +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/AGENTS.md +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/LICENSE +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/README.md +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/docs/api/cli.md +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/docs/api/mempath.md +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/docs/api/path.md +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/docs/api/protocols.md +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/docs/api/schemes/archive.md +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/docs/api/schemes/ftp.md +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/docs/api/schemes/git.md +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/docs/api/schemes/http.md +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/docs/api/schemes/local.md +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/docs/api/schemes/objstore.md +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/docs/api/schemes/sftp.md +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/docs/api/testing.md +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/docs/api/uri.md +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/docs/api/utils.md +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/docs/benchmarks.md +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/docs/changelog.md +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/docs/guides/cli.md +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/docs/guides/extending.md +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/docs/guides/schemes.md +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/docs/index.md +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/examples/az_listing.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/examples/data_and_archive.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/examples/ftp_listing.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/examples/github_listing.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/examples/gitlab_listing.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/examples/gs_listing.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/examples/http_listing.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/examples/local_and_mem.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/examples/s3_listing.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/examples/sftp_sync.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/examples/webdav_roundtrip.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/mkdocs.yml +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/src/pathlib_next/__init__.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/src/pathlib_next/mempath.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/src/pathlib_next/protocols/__init__.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/src/pathlib_next/protocols/checksum.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/src/pathlib_next/protocols/fs.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/src/pathlib_next/protocols/io.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/src/pathlib_next/py.typed +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/src/pathlib_next/testing.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/src/pathlib_next/tools/__init__.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/src/pathlib_next/tools/uripath.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/src/pathlib_next/uri/query.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/src/pathlib_next/uri/schemes/__init__.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/src/pathlib_next/uri/schemes/_gitrepo.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/src/pathlib_next/uri/schemes/archive/__init__.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/src/pathlib_next/uri/schemes/archive/tar.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/src/pathlib_next/uri/schemes/az.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/src/pathlib_next/uri/schemes/data.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/src/pathlib_next/uri/schemes/dav.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/src/pathlib_next/uri/schemes/ftp.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/src/pathlib_next/uri/schemes/git/__init__.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/src/pathlib_next/uri/schemes/git/_base.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/src/pathlib_next/uri/schemes/git/github.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/src/pathlib_next/uri/schemes/git/gitlab.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/src/pathlib_next/uri/schemes/github.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/src/pathlib_next/uri/schemes/gitlab.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/src/pathlib_next/uri/schemes/gs.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/src/pathlib_next/uri/schemes/http.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/src/pathlib_next/uri/schemes/s3.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/src/pathlib_next/uri/schemes/sftp/_paramiko.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/src/pathlib_next/uri/schemes/sftp/_sshconfig.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/src/pathlib_next/uri/source.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/src/pathlib_next/utils/__init__.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/src/pathlib_next/utils/checksum.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/src/pathlib_next/utils/stat.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/src/pathlib_next/utils/sync.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/tests/conftest.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/tests/test_archive_parity.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/tests/test_archive_uri.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/tests/test_az.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/tests/test_az_fake.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/tests/test_checksum.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/tests/test_contract.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/tests/test_contract_helpers.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/tests/test_data_uri.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/tests/test_dav.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/tests/test_ftp.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/tests/test_ftp_objstore_parity.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/tests/test_gitrepo.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/tests/test_glob.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/tests/test_gs.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/tests/test_gs_fake.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/tests/test_http.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/tests/test_http_live.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/tests/test_http_parser.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/tests/test_httpdav_safety.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/tests/test_io_parity.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/tests/test_local.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/tests/test_low_core.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/tests/test_low_schemes.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/tests/test_low_sync.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/tests/test_mempath.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/tests/test_mro_precedence.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/tests/test_parity_io.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/tests/test_parity_pure.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/tests/test_path_gaps.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/tests/test_pathname.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/tests/test_plugins.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/tests/test_properties.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/tests/test_query.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/tests/test_s3.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/tests/test_sftp_asyncssh.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/tests/test_sftp_transport.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/tests/test_smoke.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/tests/test_source.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/tests/test_sync.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/tests/test_sync_safety.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/tests/test_sync_sftp.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/tests/test_sync_sftp_parity.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/tests/test_transport_security.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/tests/test_uri_core_parity.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/tests/test_uri_parse.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/tests/test_uripath_tool.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/tests/test_utils.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/tests/test_walk.py +0 -0
- {pathlib_next-0.9.6 → pathlib_next-0.9.8}/tests/test_webdav.py +0 -0
|
@@ -7,6 +7,132 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.9.8] - 2026-09-17
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **Joining a URI path no longer builds a backend.** 0.9.7 routed `/` and
|
|
14
|
+
`joinpath()` through the child builder a listing uses, which reads the
|
|
15
|
+
`backend` property -- and that property CREATES one, so spelling
|
|
16
|
+
`UriPath("sftp://h/x") / "y"` imported paramiko and raised `ImportError`
|
|
17
|
+
without the extra (`http:` wanted `requests`, `s3:` `botocore`). A join
|
|
18
|
+
is a pure-path operation and is lazy again; a child still shares its
|
|
19
|
+
parent's connection when one already exists, which is all the sharing was
|
|
20
|
+
ever for.
|
|
21
|
+
|
|
22
|
+
## [0.9.7] - 2026-09-17
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
- **`rm(follow_symlinks=, follow_binds=)`**: what a recursive removal does
|
|
26
|
+
with a symlink, and with a binding (a Windows junction, a mount point).
|
|
27
|
+
Each takes `False` (the default -- remove the entry itself, never its
|
|
28
|
+
contents, as `rm -r` does), `True` (remove what is behind it), `None`
|
|
29
|
+
(leave it in place, so the enclosing directory is not empty and reports
|
|
30
|
+
it), or a callable `policy(path) -> bool | None` asked per entry, so one
|
|
31
|
+
tree can keep one mount and follow another. Named for the keyword
|
|
32
|
+
`stat()`, `walk()` and `copy()` already use for the same idea, rather
|
|
33
|
+
than a second vocabulary.
|
|
34
|
+
- **`Path.is_junction()`, `Path.is_mount()` and `Path.is_dir_binding()`**:
|
|
35
|
+
a directory that is another tree's second NAME -- a Windows junction, or
|
|
36
|
+
a mount point such as a Linux bind mount -- is now a first-class concept
|
|
37
|
+
rather than a private hook used by one call site. Neither is a symlink
|
|
38
|
+
(`is_symlink()` is False, a non-following stat reports a plain
|
|
39
|
+
directory), which is precisely why a symlink check cannot protect a walk
|
|
40
|
+
from one. `is_junction()` matches pathlib 3.12's; both are False by
|
|
41
|
+
default and answered for real by `LocalPath`/`FileUri`.
|
|
42
|
+
|
|
43
|
+
- **`Path.glob(on_error=)` / `rglob(on_error=)`**: a hook called as
|
|
44
|
+
`on_error(error)` when a directory cannot be listed, the same contract as
|
|
45
|
+
`walk()` and `os.walk`. Raising from it makes an unreadable directory
|
|
46
|
+
fatal; returning treats it as empty. Without the hook the listing is
|
|
47
|
+
skipped silently, as pathlib does and as before -- which left a caller
|
|
48
|
+
unable to tell an unreadable layer from an absent one. `error.filename`
|
|
49
|
+
names the directory even when the backend left it unset.
|
|
50
|
+
- **`Path.glob(bound_loops=)` / `rglob(bound_loops=)`**: with `True`, a
|
|
51
|
+
directory is descended at most once per `**`, keyed on
|
|
52
|
+
`(st_dev, st_ino)` and seeded with the starting directory. This bounds a
|
|
53
|
+
Windows junction loop -- a junction reports `is_symlink() == False`, so
|
|
54
|
+
`recurse_symlinks=False` cannot see it, and one file in a looping tree
|
|
55
|
+
matched 64 times (pathlib walks it the same way). Default `False` keeps
|
|
56
|
+
pathlib's behaviour; a backend whose stat carries no identity is walked
|
|
57
|
+
unbounded. Both reported by yaconfiglib against 0.9.6.
|
|
58
|
+
|
|
59
|
+
### Fixed
|
|
60
|
+
- **An SFTP listing is untrusted input.** The server chooses the names, and
|
|
61
|
+
they were turned into child paths unchecked, so a crafted `../victim.txt`
|
|
62
|
+
made `rm(recursive=True)` delete outside the tree it was given and a
|
|
63
|
+
recursive copy read from outside it (measured on both backends). Names
|
|
64
|
+
that are not a single component inside the directory are now skipped, as
|
|
65
|
+
`dav:` and `http:` already did. The asyncssh fan-out walkers, which bypass
|
|
66
|
+
`_scandir()`, filter for themselves.
|
|
67
|
+
- **`unpack_archive()` split a backslash on every platform**, so a POSIX
|
|
68
|
+
member whose name contains one was extracted as two path components and
|
|
69
|
+
one carrying `..` was silently dropped -- both legal single filenames
|
|
70
|
+
there. A backslash now separates only for a Windows destination, which is
|
|
71
|
+
the rule the function already documented.
|
|
72
|
+
- **`has_glob_pattern()` was True for every Windows extended-length path.**
|
|
73
|
+
The `?` in a `\\?\C:\...` anchor (and the `\\?\UNC\...` form) is a prefix,
|
|
74
|
+
not a wildcard, so a caller using it to tell a pattern from a plain path
|
|
75
|
+
got the wrong answer for all of them. The anchor is no longer scanned.
|
|
76
|
+
Reported by yaconfiglib against 0.9.6.
|
|
77
|
+
- **Writing over an archive member spelled `./f.txt` appended a second
|
|
78
|
+
entry instead of rewriting it**, and `unlink()` then deleted that second
|
|
79
|
+
entry and reported success while the original content came back. 0.9.5
|
|
80
|
+
handed the normalized name to a backend keyed on the raw one; writes now
|
|
81
|
+
address the entry the archive really holds.
|
|
82
|
+
- **Renaming a directory in such an archive silently did nothing**, or
|
|
83
|
+
split it in two when its members were spelled inconsistently, while
|
|
84
|
+
`rename()` returned the new path as though it had worked. The backend now
|
|
85
|
+
receives the exact raw-name mapping instead of a normalized prefix.
|
|
86
|
+
- **Every archive operation rebuilt the member index**, so a listing, stat,
|
|
87
|
+
read or write on a 20k-member archive scanned all 20k names: measured
|
|
88
|
+
10x-224x slower than 0.9.4. The index is cached per open handle and
|
|
89
|
+
dropped whenever the handle is, which every mutation and every external
|
|
90
|
+
change already go through.
|
|
91
|
+
- **`glob(None)` ignored `native=`, never auto-detected `**`, and answered
|
|
92
|
+
differently from `rglob(None)`** for the same path, because that branch
|
|
93
|
+
bypassed the pattern parser: `LocalPath("/x/**/*.py").glob(None)` returned
|
|
94
|
+
a shallow subset with no error. `native=False` also left the trailing-`**`
|
|
95
|
+
rule following the interpreter, so it did not deliver the one-answer
|
|
96
|
+
promise it documents; it now pins that rule too.
|
|
97
|
+
- **`/` mangled a `data:` payload** (`data:,a/../b` joined to `data:b/x`,
|
|
98
|
+
not a data URI at all), dropped a scheme's own child handling (`gitlab:`'s
|
|
99
|
+
reserved `-`), still parsed a `bytes` name as URI syntax, and lost
|
|
100
|
+
per-instance scheme state such as `SftpPath`'s `ssh_config`. Joins now
|
|
101
|
+
walk segments through the same builder a listing uses.
|
|
102
|
+
- **A relative `str` destination whose first segment merely contained a
|
|
103
|
+
colon** (`notes:draft`, `Fedora-42:latest.tar`) was read as a URI scheme
|
|
104
|
+
by `copy()`/`move()`; the scheme must now be one a class registers, as
|
|
105
|
+
the `uripath` CLI already required. A Windows drive path (`C:/Temp/x`)
|
|
106
|
+
restarts the join for a `file:` path, as `PureWindowsPath` does.
|
|
107
|
+
- **`rename()` and `copy()`/`move()` resolved a relative `str` differently**
|
|
108
|
+
(`rename("../b")` sent a literal `sub/../b`, a different key on an object
|
|
109
|
+
store); both now resolve it the same way. A same-endpoint URI destination
|
|
110
|
+
reuses the configured connection instead of opening a second, bare one.
|
|
111
|
+
|
|
112
|
+
### Changed
|
|
113
|
+
- **`rm(recursive=True)` no longer descends into a mount point.** It
|
|
114
|
+
already removed a Windows junction as a binding rather than walking into
|
|
115
|
+
it; a POSIX bind mount is the same thing and was walked, so deleting a
|
|
116
|
+
tree containing one deleted the mounted filesystem's contents. It is now
|
|
117
|
+
`rmdir()`'d like a junction, which fails loudly on a live mount instead.
|
|
118
|
+
- **`UriPath / "name"` and `joinpath()` read a `str` as a decoded path**,
|
|
119
|
+
not as URI syntax. `base / "cache?v=2"` is now the file `cache?v=2`
|
|
120
|
+
instead of `base/cache` with a query; `"note#2.txt"`, `"a%20b.txt"` and
|
|
121
|
+
`"C:/Temp"` join verbatim too. This is what `iterdir()` always did, so
|
|
122
|
+
listing a directory and naming the same child by hand finally agree. Pass
|
|
123
|
+
a `Uri`/`UriPath` argument for a scheme-aware join
|
|
124
|
+
(`base / UriPath("s3://bucket/key")`); that stays the only form that can
|
|
125
|
+
cross endpoints, and it still drops a credential-bearing backend on the
|
|
126
|
+
way. Dot segments are removed from the result as before.
|
|
127
|
+
- **`copy()`/`move()` accept a plain-path `str` destination.** A string with
|
|
128
|
+
a scheme is a URI, as before, so `copy("s3://bucket/key")` keeps working;
|
|
129
|
+
one without is a decoded path on the same endpoint (absolute replaces the
|
|
130
|
+
path, relative is a sibling, as `rename()` resolves it) and reuses this
|
|
131
|
+
path's source and backend. `move("b.txt")` previously built a sourceless
|
|
132
|
+
path and failed on its first `exists()` call, and a same-host URI
|
|
133
|
+
destination opened a second connection. A one-letter scheme is treated as
|
|
134
|
+
a Windows drive, so `C:/Temp/x` is a path.
|
|
135
|
+
|
|
10
136
|
## [0.9.6] - 2026-09-16
|
|
11
137
|
|
|
12
138
|
### Added
|
|
@@ -1336,7 +1462,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
1336
1462
|
- Sync error handling.
|
|
1337
1463
|
- Generic Path Protocol based pathlib implementation for URI paths with file access support for sftp, http, file schemes.
|
|
1338
1464
|
|
|
1339
|
-
[Unreleased]: https://github.com/jose-pr/pathlib-next/compare/v0.9.
|
|
1465
|
+
[Unreleased]: https://github.com/jose-pr/pathlib-next/compare/v0.9.8...HEAD
|
|
1466
|
+
[0.9.8]: https://github.com/jose-pr/pathlib-next/compare/v0.9.7...v0.9.8
|
|
1467
|
+
[0.9.7]: https://github.com/jose-pr/pathlib-next/compare/v0.9.6...v0.9.7
|
|
1340
1468
|
[0.9.6]: https://github.com/jose-pr/pathlib-next/compare/v0.9.5...v0.9.6
|
|
1341
1469
|
[0.9.5]: https://github.com/jose-pr/pathlib-next/compare/v0.9.4...v0.9.5
|
|
1342
1470
|
[0.9.4]: https://github.com/jose-pr/pathlib-next/compare/v0.9.3...v0.9.4
|
|
@@ -56,6 +56,8 @@ these operations itself always keeps its own implementation.
|
|
|
56
56
|
| --- | --- | --- | --- |
|
|
57
57
|
| `Uri("http://h/d/").name` (trailing `/`) | `PurePosixPath("d/").name == "d"` | A trailing `/` is kept: `name` is `""` and `parent` is `http://h/d`. | For HTTP/WebDAV a trailing slash is how a directory URL is spelled; normalizing it away changes which URL is requested. |
|
|
58
58
|
| `Path.glob()` / `rglob()` edge cases | Version-dependent pathlib rules | Hidden entries are included by default (pathlib parity; `include_hidden=False` filters them). `recurse_symlinks=True` raises `NotImplementedError`: `**` never descends into directory symlinks. A trailing `**` follows the running interpreter (files too on 3.13+). The two rules pathlib changed mid-series follow the running interpreter by default (`native=True`): a trailing `/` is ignored before 3.11 and selects directories only from 3.11, and `a**` raises `ValueError` before 3.13 and is a plain wildcard from 3.13. `native=False` applies one rule on every version instead -- trailing `/` always selects directories only, `a**` is always a plain wildcard. | Loop-safe recursion without `st_dev`/`st_ino` (most backends' stats lack them) rules out following links. The `native` default keeps `LocalPath` answering exactly what the `pathlib` beside it answers; `native=False` is for a caller that wants one answer across backends and interpreters, which is what the contract suite asserts. |
|
|
59
|
+
| `Path.glob(on_error=)` / `rglob(on_error=)` | `pathlib` skips an unreadable directory silently, with no hook | Same default, plus a hook: `on_error(error)` is called per failed listing (the contract `walk()` and `os.walk` use), so a caller can log it or raise it. `error.filename` names the directory. | "Silently nothing" is the worst answer for a caller assembling something from a tree -- a configuration layer, a source list -- because the result is incomplete and nothing says so. The default stays pathlib's. |
|
|
60
|
+
| `Path.glob(bound_loops=)` / `rglob(bound_loops=)` | `pathlib` walks a directory loop until the recursion limit (and on Windows/3.9 eventually raises `WinError 1921` on the over-long path) | `bound_loops=True` descends a directory at most once per `**`, keyed on `(st_dev, st_ino)` and seeded with the starting directory; a directory reached a second way is skipped. Default `False` keeps pathlib's behaviour. | A Windows junction reports `is_symlink() == False`, so `recurse_symlinks=False` cannot see it and an ordinary config tree with a junction back into itself matched one file 64 times. Identity is the only signal that works; backends whose stats lack it are unaffected. |
|
|
59
61
|
| `Path.glob(None)` / `rglob(None)` | `pathlib` has no such form (its pattern is always applied to a directory) | `None` expands the pattern THIS PATH CARRIES: `LocalPath("/etc/*.conf").glob(None)` splits at the first wildcard and globs from there (`utils.glob.glob()`). `glob("")` still raises `ValueError`, as pathlib does. | An extension: a path that is itself a pattern is a common shape for config and CLI inputs, and before 0.9.4 `glob("")` was the accidental spelling for it. `None` cannot collide with a real pattern, so parity is untouched. |
|
|
60
62
|
| `Uri.query` | N/A (pathlib has no query) | The query is kept exactly as received (percent-encoded) and sent unchanged; `Query(...).decode()`/`to_dict()` decode. `Uri.parts` is `(source, path, query, fragment)`, not path segments (`segments` is). A `%2F` in a path decodes to `/` and is not distinguishable from a separator. | Decoding at parse time and re-encoding changed what reached the server (a signed URL's `%2B` became `+`, an escaped `&` split a value). The decoded-path model cannot keep `%2F` distinct. |
|
|
61
63
|
| `Path.copy(follow_symlinks=False)` on a symlink | CPython 3.14: copies the link as a link | Same: the link is recreated (not its metadata). Where the source cannot `readlink()` or the target cannot create links, raises `NotImplementedError` instead of copying content. `copy(recursive=True)` into its own subtree raises `OSError(EINVAL)` before creating anything. | Copying the link target's content under the link's permissions produced a 0o777 regular file. |
|
|
@@ -71,7 +73,8 @@ these operations itself always keeps its own implementation.
|
|
|
71
73
|
| `Path.__iter__` | `pathlib.Path` is not iterable (no `__iter__`) | `iter(path)` is `path.iterdir()` | Deliberate extension for ergonomic `for child in path:` loops. **Caution:** on remote schemes (http/sftp) this is a network call. **User decision, 2026-07-11.** |
|
|
72
74
|
| `Path.copy(target, ...)` | CPython 3.14 `Path.copy(target, *, follow_symlinks=True, preserve_metadata=False)`: overwrites an existing file, copies a directory tree without being asked, returns the new path | Ours predates 3.14. Signature: `copy(target, *, overwrite=False, follow_symlinks=True, preserve_metadata=True, recursive=False, ignore_error=None, progress=None)`; returns `None`. An existing target raises `FileExistsError` unless `overwrite=True`; a directory needs `recursive=True`. `overwrite=True` unlinks an existing non-directory target, but only once the source is open (a missing or unreadable source never destroys or creates the target; a copy that fails mid-stream removes its partial target); copying onto the same file raises `OSError(EINVAL)`; `preserve_metadata` defaults to **True** (opposite of 3.14) and only propagates `st_mode`, not timestamps/xattrs | Argument names aligned with 3.14 where cheap; `preserve_metadata=True` default kept for backward compat with this method's pre-existing (pre-3.14-alignment) behavior of always copying the mode bits. Full metadata preservation (timestamps, xattrs) is not implemented. |
|
|
73
75
|
| `Path.move(target, ...)` | CPython 3.14 `Path.move(target)`: `os.replace` semantics (replaces an existing file), falls back to copy + delete across filesystems, returns the new path | Ours predates 3.14 and keeps `overwrite=False`: an existing target raises `FileExistsError`. Returns whatever `rename()` returns (`None` on the copy fallback). Our own extension: tries `rename()`, falls back to copy+unlink. Validates before touching the target: a missing source raises `FileNotFoundError`, a file onto a directory raises `IsADirectoryError`, and the same file under another spelling (a case-only rename) is renamed in place. `overwrite=True` replaces a local file target atomically (`os.replace`); on other backends it unlinks the target immediately before `rename()`, so a failing rename can still lose it. | N/A -- pure extension, no pathlib method to diverge from. Removing the target before checking the source deleted it on a typo or a locked source. |
|
|
74
|
-
| `Path.
|
|
76
|
+
| `Path.is_junction()` / `is_mount()` / `is_dir_binding()` | `pathlib` has `is_junction()` (3.12+) and `is_mount()`, but only for local paths and with no shared concept | Both are part of the `Path` contract (False by default, answered by `LocalPath`/`FileUri`), and `is_dir_binding()` is their union: a directory that is another tree's second NAME rather than part of this one. A junction is the Windows spelling of a bind mount, and neither is a symlink -- `is_symlink()` is False and a non-following stat reports a plain directory. | Walking code needs the distinction: a symlink announces itself and every walker here already declines to follow one, while a binding looks exactly like an ordinary directory and its contents belong to whoever mounted or junctioned it. |
|
|
77
|
+
| `Path.rm(recursive=, missing_ok=, ignore_error=)` | Not in `pathlib` (closest: `shutil.rmtree`) | Our own extension. Recursive removal deletes bottom-up and uses non-following stat/listing metadata, so a symlink to a directory -- or a binding (`is_dir_binding()`: a Windows junction, a mount point), which a non-following stat still reports as a directory -- is unlinked or `rmdir()`'d rather than traversed -- `rm -r` semantics. `follow_symlinks=`/`follow_binds=` select that per call: `False` (default), `True` (remove what is behind it), `None` (leave it), or a callable asked per entry -- the same keyword `stat()`/`walk()`/`copy()` use for the same idea. `rmdir()` on a live mount fails, which is the intended answer. | N/A -- pure extension. Non-following recursive deletion avoids deleting through symlinked directory targets and lets backends with metadata-rich listings remove trees without a stat round trip per child. |
|
|
75
78
|
| `Path.symlink_to(target, target_is_directory=False, *, force=False)` | `pathlib.Path.symlink_to(target, target_is_directory=False)` -- raises `FileExistsError` if anything already exists at the link path | Adds a keyword-only `force=`. `force=False` (the default) is stdlib-exact. `force=True` unlinks an existing **non-directory** entry at the link path first, then creates the symlink; an existing *directory* is never removed and the underlying error propagates. Not atomic: no filesystem or transport offers "replace a symlink" as one operation, so the path briefly does not exist between the unlink and the symlink. | Additive extension (an extra optional kwarg, per the parity contract). No backend can offer this atomically, so every consumer was re-implementing the same unlink-then-symlink dance -- it is path semantics, not transport semantics, so it belongs at the `Path` layer where one implementation serves every backend. Backends implement only the `_symlink_to()` primitive (same `_mkdir`/`_open` shape) and get `force=` for free. Because no stdlib version accepts the keyword, `symlink_to` is in `_OPERATION_NAMES` so `LocalPath` honors it too. |
|
|
76
79
|
| `Path.chown(uid=None, gid=None, *, follow_symlinks=True)` | Not in `pathlib` at all -- it has `owner()`/`group()` **readers** but no writer (the stdlib writers are `os.chown`/`shutil.chown`, which are functions over a path, not path methods) | Our own extension. `None` (default) leaves a field unchanged; `-1` is accepted as an alias for `None` (`os.chown`'s own sentinel); an `int` is a uid/gid and a `str` is a user/group name. A call where both fields are unchanged short-circuits without touching the backend. Implemented for `LocalPath`/`FileUri` (via `shutil.chown`, or `os.lchown` for `follow_symlinks=False`) and `SftpPath` (`setstat`); `NotImplementedError` elsewhere -- including `LocalPath` on a platform without `os.chown` (Windows), where `shutil.chown` exists but cannot work. | Ownership was the one permission attribute `stat()` could report (`st_uid`/`st_gid`) that nothing could write back. The valuable part is centralizing the **"unchanged" sentinel** on `Path` (`utils.as_owner()`): `os.chown` spells it `-1`, SFTP omits the field, other middlewares use `None` -- normalizing per-scheme would be three chances to disagree. Backends implement `_chown()` and receive an already-canonical pair. SFTPv3 sends uid/gid as one paired attribute, so `SftpPath` reads the current owner for whichever field is unchanged rather than guessing a value. |
|
|
77
80
|
| `Path.chmod(mode, ...)` accepting a `str` | `mode` must be an `int`; a `str` raises `TypeError` | Additionally accepts a `str`, parsed as **octal**: `"0755"`, `"755"` and `0o755` all mean the same thing. An optional `0o` prefix is allowed; any character outside `[0-7]` raises `ValueError` rather than being coerced. | The string form is how modes are written in `chmod(1)`, Ansible, Dockerfiles and shell scripts, so config-driven callers arrive holding one. Accepted only with an **explicit base 8** (`utils.as_mode()`), never a plain `int()`: `int("0755")` in decimal is 755 == `0o1363`, a different *and valid* mode, so a fallback to decimal would set plausible-but-wrong permissions with nothing raising -- which is exactly why stdlib refuses strings. Parsing in one shared helper is what makes the base non-negotiable across the five backends that implement `chmod` directly. |
|
|
@@ -84,6 +87,8 @@ these operations itself always keeps its own implementation.
|
|
|
84
87
|
| `Path.copy(recursive=True)` child names | `shutil.copytree` joins whatever the listing yields | A child name that would not stay inside `target` is refused with `ValueError` through `ignore_error`: `..`, and `\`/`:`/a trailing dot when the target reads names with Windows rules. | The names come from listings the destination does not control (an archive, an HTTP index, an object-store key); on a Windows target `"C:x"` joins to a drive-relative path outside the destination entirely. `PathSyncer` and `utils.unpack_archive()` already applied this per destination. |
|
|
85
88
|
| Archive member names | N/A (`zipfile`/`tarfile` expose the raw name as written) | Normalized as POSIX relative paths for every format: a leading `./`, empty segments and interior `.`/`..` resolve, so one member has one name and listings and lookups agree; the raw spelling still addresses it, and the later of two members that normalize alike wins. A name that escapes the root (`../x`, `/abs`) has no name inside the archive at all, while a drive- or backslash-shaped name is a normal member (an ordinary POSIX filename) that only a Windows destination refuses to receive. | The same file was reachable or not depending on how the writer spelled it: a zip written by `shutil.make_archive`-style `./` prefixes listed as empty, and zip and tar disagreed about identical archives. |
|
|
86
89
|
| The POSIX `//` root | `PurePosixPath("//")` keeps `//` as a root distinct from `/` (POSIX leaves exactly two leading slashes implementation-defined; three or more collapse) | The generic classes do not model it: `MemPath("//")` collapses to `/`, and `Uri("//")` reads `//` as the start of an authority (RFC 3986), giving an empty authority and an empty path -- so `Uri("//a/b")` has host `a` and path `/b`. `match()` therefore disagrees with `pathlib` on that one path. `LocalPath`/`WindowsPathname` are unaffected (they inherit pathlib's parsing, where `//server/share` is a UNC drive). | A distinct double-slash root has no meaning for an in-memory tree or a URI, and for a `Uri` it cannot: `//a/b` must read `a` as a host. Modelling it would change segment normalization everywhere (`parents`, `relative_to`, `is_absolute`, every scheme) to serve a spelling no backend can use. |
|
|
90
|
+
| `Uri / "name"`, `joinpath("name")` | `pathlib` joins the string as path segments | Same: a `str` is an already-decoded path, so `?`, `#`, `%` and a leading `C:` are ordinary filename characters (`base / "cache?v=2"` is the file `cache?v=2`, not a query). Pass a `Uri`/`UriPath` argument for a scheme-aware join (`base / UriPath("s3://bucket/key")` crosses to S3, and drops a credential-bearing backend on the way). Dot segments in the result are still removed. | A join names a child, and `iterdir()` already named it literally -- listing a directory holding `cache?v=2` gave a working path while typing the same name by hand gave `/mnt/cache`. Only the explicit `Uri` form can now reach another host, so a `str` can no longer carry a session or token off the endpoint it belongs to. |
|
|
91
|
+
| `copy(str)` / `move(str)` destination | `shutil` resolves a relative destination against the process cwd | Read by shape: with a scheme (`s3://bucket/key`, `data:,abc`) it is a URI, so a cross-scheme copy works; without one it is a decoded path on the SAME endpoint -- absolute replaces the path, relative is a sibling, as `rename()` resolves it -- keeping this path's source and backend. A one-letter scheme is a Windows drive (`C:/Temp/x` is a path). | A URI has no cwd, and the previous URI-only parse turned `move("b.txt")` into a sourceless path that could not do I/O at all, while a same-host URI destination opened a second connection. |
|
|
87
92
|
| `Uri` path dot segments | `pathlib` keeps `..` lexically (`PurePosixPath("a/../b")` is `a/../b`) | `Uri` removes dot segments as RFC 3986 requires of a URI reference, in the constructor and in `/`-joins: `Uri("a/../b")` is `b`, `Uri("http://h/x") / "a/../b"` is `http://h/x/b`, and `Uri("a/b/..")` is `a/`. A leading `..` that would pass the root is kept, not resolved. `MemPath` follows `pathlib` instead. | A URI is resolved, not spelled: `..` in a URI reference has a defined meaning that servers, caches and proxies already apply, so keeping it lexically would address a different resource than the same string typed into a browser. |
|
|
88
93
|
| Nested archive URIs | N/A | Each leading archive scheme in `<archive-uri>` consumes one `!/` (`zip:zip:file:///outer.zip!/inner.zip!/x.txt`); a member name containing `!/` is written `%21/`. Nested archives are read-only. | The first `!/` was always taken as the separator, so an archive inside an archive could not be addressed. |
|
|
89
94
|
| Object-store key that is both an object and a prefix | N/A (a filesystem entry has one type) | `iterdir()`/`walk()`/`copy(recursive=True)` on `s3:`/`gs:`/`az:` show only the object (`x`), matching `stat()`'s exact-object precedence; the subtree under `x/` is not listed. | Listings used to keep the directory and drop the object, contradicting `stat()`. |
|
|
@@ -13,7 +13,7 @@ build-backend = "hatchling.build"
|
|
|
13
13
|
# `import pathlib_next`) -- a hyphen is not legal in a Python identifier.
|
|
14
14
|
# Distribution name and import name differing is ordinary and intended.
|
|
15
15
|
name = "pathlib-next"
|
|
16
|
-
version = "0.9.
|
|
16
|
+
version = "0.9.8"
|
|
17
17
|
authors = [{ name = "Jose A" }]
|
|
18
18
|
description = "Generic Path Protocol based pathlib"
|
|
19
19
|
readme = "README.md"
|
|
@@ -62,6 +62,16 @@ silently absent and `from pathlib_next.uri import UriPath` raises
|
|
|
62
62
|
a concrete stdlib path without `LocalPath`). A method defined in the
|
|
63
63
|
subclass itself always wins.
|
|
64
64
|
- `is_hidden()` — name starts with `"."`. `__iter__()` is `iterdir()`.
|
|
65
|
+
- **A binding is not a symlink.** `is_junction()` (pathlib 3.12 parity:
|
|
66
|
+
a Windows junction) and `is_mount()` (a mount point, bind mounts
|
|
67
|
+
included) report a directory that is another tree's second NAME.
|
|
68
|
+
Neither is a symlink: `is_symlink()` is False, `readlink()` says
|
|
69
|
+
nothing, and a non-following stat calls it an ordinary directory — which
|
|
70
|
+
is why a symlink check cannot protect a walk from one. `is_dir_binding()`
|
|
71
|
+
is the pair, and is what `rm(recursive=True)` consults: it removes the
|
|
72
|
+
binding itself rather than the contents behind it (`rmdir()` on a live
|
|
73
|
+
mount fails loudly, which beats emptying the mounted filesystem).
|
|
74
|
+
Default False everywhere; `LocalPath`/`FileUri` answer for real.
|
|
65
75
|
- `samefile(other_path)` — compares `(st_dev, st_ino)`; `NotImplementedError`
|
|
66
76
|
when `stat()` lacks them (`LocalPath` uses pathlib's).
|
|
67
77
|
- `iterdir() -> Iterator[Self]` — **stub** (`NotImplementedError`); a
|
|
@@ -73,7 +83,8 @@ silently absent and `from pathlib_next.uri import UriPath` raises
|
|
|
73
83
|
`PathSyncer`; override it when the listing call already returns metadata.
|
|
74
84
|
`None` means "unknown", never "missing".
|
|
75
85
|
- `glob(pattern, *, case_sensitive=None, include_hidden=True,
|
|
76
|
-
recursive=None, dironly=None, recurse_symlinks=False, native=True
|
|
86
|
+
recursive=None, dironly=None, recurse_symlinks=False, native=True,
|
|
87
|
+
on_error=None, bound_loops=False)` —
|
|
77
88
|
pathlib semantics: hidden entries included, `**` never descends into
|
|
78
89
|
directory symlinks (`recurse_symlinks=True` → `NotImplementedError`), a
|
|
79
90
|
trailing `**` selects files too on 3.13+, a missing or non-directory base
|
|
@@ -86,6 +97,20 @@ silently absent and `from pathlib_next.uri import UriPath` raises
|
|
|
86
97
|
(`LocalPath("/etc/*.conf").glob(None)`), splitting at the first
|
|
87
98
|
wildcard — the supported form for a path that is itself a pattern.
|
|
88
99
|
`""` still raises.
|
|
100
|
+
- **`on_error(error)`** is called when a directory cannot be listed,
|
|
101
|
+
the same contract as `walk()`/`os.walk`: raising from it propagates,
|
|
102
|
+
returning treats that directory as empty. Without it the listing is
|
|
103
|
+
skipped in silence (pathlib's behaviour), so a caller could not tell
|
|
104
|
+
an unreadable directory from an absent one. `error.filename` names the
|
|
105
|
+
directory even when the backend left it unset.
|
|
106
|
+
- **`bound_loops=True`** descends a directory at most once per `**`,
|
|
107
|
+
keyed on `(st_dev, st_ino)` and seeded with the starting directory. It
|
|
108
|
+
bounds a Windows junction loop, which `recurse_symlinks=False` cannot
|
|
109
|
+
(a junction reports `is_symlink() == False`) and which `pathlib`
|
|
110
|
+
itself walks until the recursion limit. A directory reached a second
|
|
111
|
+
way is skipped entirely, not just not descended. A backend whose stat
|
|
112
|
+
carries no identity (`MemPath`, most remote schemes) is walked
|
|
113
|
+
unbounded, as before.
|
|
89
114
|
- **`native=True`** (default) follows the running interpreter on the two
|
|
90
115
|
rules pathlib changed mid-series: a trailing `/` is ignored before 3.11
|
|
91
116
|
and selects directories only from 3.11; `a**` raises `ValueError`
|
|
@@ -105,11 +130,21 @@ silently absent and `from pathlib_next.uri import UriPath` raises
|
|
|
105
130
|
`LocalPath`/`FileUri` use pathlib's `touch()`.
|
|
106
131
|
- `_mkdir(mode)` (stub) / `mkdir(mode=0o777, parents=False, exist_ok=False)`.
|
|
107
132
|
- `unlink(missing_ok=False)`, `rmdir()` — stubs.
|
|
108
|
-
- `rm(recursive=False, missing_ok=False, ignore_error=False
|
|
133
|
+
- `rm(recursive=False, missing_ok=False, ignore_error=False, *,
|
|
134
|
+
follow_symlinks=False, follow_binds=False)` — extension.
|
|
109
135
|
`ignore_error` is a bool or `callable(error, path) -> bool` (True
|
|
110
136
|
swallows); each error is offered once. Recursive removal is bottom-up and
|
|
111
|
-
never descends through a directory symlink or a
|
|
112
|
-
|
|
137
|
+
never descends through a directory symlink or a binding (a Windows
|
|
138
|
+
junction, a mount point — `is_dir_binding()`): the entry itself is
|
|
139
|
+
removed, never what is behind it, which is what `rm -r` does.
|
|
140
|
+
`follow_symlinks=` (symlinks) and `follow_binds=` (bindings) choose per
|
|
141
|
+
call: `False` (default, remove the entry), `True` (remove the contents
|
|
142
|
+
behind it too), `None` (leave it in place — the enclosing directory is
|
|
143
|
+
then not empty and says so), or a callable `policy(path) -> bool | None`
|
|
144
|
+
asked per entry, so one tree can keep one mount and follow another. The
|
|
145
|
+
name matches `stat()`/`walk()`/`copy()`'s `follow_symlinks=` rather than
|
|
146
|
+
a second vocabulary for the same idea. Path components before the final
|
|
147
|
+
one are followed as usual.
|
|
113
148
|
- `rename(target)` — stub. Implementations return the new path.
|
|
114
149
|
- `_symlink_to(target, target_is_directory=False)` (stub; receives a path
|
|
115
150
|
object) / `symlink_to(target, target_is_directory=False, *, force=False)`
|
|
@@ -233,6 +268,12 @@ silently absent and `from pathlib_next.uri import UriPath` raises
|
|
|
233
268
|
3986 reference resolution and `..` is not resolved during a join. An
|
|
234
269
|
absolute local path becomes `file:`; a relative one joins like a
|
|
235
270
|
`PurePath`.
|
|
271
|
+
- `/` and `joinpath()` take a `str` as an **already-decoded path**: `?`,
|
|
272
|
+
`#`, `%` and a leading `C:` are ordinary filename characters
|
|
273
|
+
(`base / "cache?v=2"` names that file), which is what `iterdir()` builds.
|
|
274
|
+
A `Uri`/`UriPath` argument keeps URI semantics and is the only form that
|
|
275
|
+
can cross to another endpoint -- where a credential-bearing backend is
|
|
276
|
+
dropped. Dot segments are removed from the joined result either way.
|
|
236
277
|
- Properties: `source -> Source`, `path -> str` (percent-decoded),
|
|
237
278
|
`query -> Query` (**percent-encoded as received**, sent unchanged),
|
|
238
279
|
`fragment -> str`, `parts -> (source, path, query, fragment)` (not path
|
|
@@ -280,7 +321,14 @@ silently absent and `from pathlib_next.uri import UriPath` raises
|
|
|
280
321
|
`#`, `%`, `:` are filename characters); a relative `rename()` target is a
|
|
281
322
|
sibling of `self`. A target on another endpoint (or another archive or
|
|
282
323
|
Azure container) raises `NotImplementedError`, so `move()` copies and
|
|
283
|
-
deletes.
|
|
324
|
+
deletes.
|
|
325
|
+
- `copy()`/`move()` read a `str` destination **by its shape**: with a
|
|
326
|
+
scheme (`s3://bucket/key`, `data:,abc`) it is a URI, so a cross-scheme
|
|
327
|
+
copy works; without one it is a decoded path on this endpoint — absolute
|
|
328
|
+
replaces the path, relative is a sibling, as `rename()` resolves it — and
|
|
329
|
+
keeps this path's source and backend rather than opening a second
|
|
330
|
+
connection. A one-letter scheme is a Windows drive, so `C:/Temp/x` is a
|
|
331
|
+
path.
|
|
284
332
|
- **`Source(scheme, userinfo, host, port)`** (`uri.source`) — `NamedTuple`,
|
|
285
333
|
falsy when all fields are empty. `as_str(sanitize=True)`; `str()`/`repr()`
|
|
286
334
|
redact the password (the fields keep it). `Source.from_str(source,
|
|
@@ -241,13 +241,23 @@ class LocalPath(
|
|
|
241
241
|
except Exception:
|
|
242
242
|
return False
|
|
243
243
|
|
|
244
|
-
def
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
244
|
+
def is_junction(self) -> bool:
|
|
245
|
+
"""Whether this is a Windows junction. `os.path.isjunction()` on
|
|
246
|
+
3.12+, the reparse tag directly before that.
|
|
247
|
+
|
|
248
|
+
lstat() reports a junction (IO_REPARSE_TAG_MOUNT_POINT) as a plain
|
|
249
|
+
directory -- CPython only rewrites the mode to S_IFLNK for real
|
|
250
|
+
symlinks -- so `rm(recursive=True)` used to walk into one and delete
|
|
251
|
+
the target's files. `shutil.rmtree` guards the same case.
|
|
252
|
+
"""
|
|
249
253
|
if _os.name != "nt":
|
|
250
254
|
return False
|
|
255
|
+
isjunction = getattr(_os.path, "isjunction", None)
|
|
256
|
+
if isjunction is not None:
|
|
257
|
+
try:
|
|
258
|
+
return bool(isjunction(self))
|
|
259
|
+
except (OSError, ValueError):
|
|
260
|
+
return False
|
|
251
261
|
try:
|
|
252
262
|
st = _os.lstat(self)
|
|
253
263
|
except OSError:
|
|
@@ -256,6 +266,14 @@ class LocalPath(
|
|
|
256
266
|
_stat, "IO_REPARSE_TAG_MOUNT_POINT", 0xA0000003
|
|
257
267
|
)
|
|
258
268
|
|
|
269
|
+
def is_mount(self) -> bool:
|
|
270
|
+
"""Whether this is a mount point -- a bind mount included, which is
|
|
271
|
+
how POSIX spells what a junction does on Windows."""
|
|
272
|
+
try:
|
|
273
|
+
return bool(_os.path.ismount(self))
|
|
274
|
+
except (OSError, ValueError):
|
|
275
|
+
return False
|
|
276
|
+
|
|
259
277
|
def walk(self, top_down=True, on_error=None, follow_symlinks=False):
|
|
260
278
|
# 3.12+ stdlib `pathlib.Path.walk()` sits ahead of ours in the MRO
|
|
261
279
|
# and would otherwise win here. Its implementation calls
|
|
@@ -402,6 +420,8 @@ class LocalPath(
|
|
|
402
420
|
dironly: bool = None,
|
|
403
421
|
recurse_symlinks: bool = False,
|
|
404
422
|
native: bool = True,
|
|
423
|
+
on_error: "_ty.Callable[[OSError], None]" = None,
|
|
424
|
+
bound_loops: bool = False,
|
|
405
425
|
):
|
|
406
426
|
"""Iterate over this subtree and yield all existing files (of any
|
|
407
427
|
kind, including directories) matching the given relative pattern.
|
|
@@ -422,6 +442,8 @@ class LocalPath(
|
|
|
422
442
|
dironly=dironly,
|
|
423
443
|
recurse_symlinks=recurse_symlinks,
|
|
424
444
|
native=native,
|
|
445
|
+
on_error=on_error,
|
|
446
|
+
bound_loops=bound_loops,
|
|
425
447
|
)
|
|
426
448
|
pattern = _os.fspath(pattern)
|
|
427
449
|
if pattern:
|
|
@@ -445,4 +467,6 @@ class LocalPath(
|
|
|
445
467
|
dironly=dironly,
|
|
446
468
|
recurse_symlinks=recurse_symlinks,
|
|
447
469
|
native=native,
|
|
470
|
+
on_error=on_error,
|
|
471
|
+
bound_loops=bound_loops,
|
|
448
472
|
)
|