pathlib-next 0.8.6__tar.gz → 0.9.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/CHANGELOG.md +131 -1
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/PKG-INFO +9 -2
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/README.md +1 -1
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/docs/benchmarks.md +24 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/docs/divergences.md +51 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/docs/index.md +1 -1
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/pyproject.toml +2 -2
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/AGENTS.md +122 -26
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/fspath.py +5 -2
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/path.py +23 -1
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/protocols/__init__.py +1 -0
- pathlib_next-0.9.0/src/pathlib_next/protocols/checksum.py +76 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/protocols/io.py +36 -3
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/uri/__init__.py +25 -2
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/uri/schemes/ftp.py +3 -1
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/uri/schemes/sftp/__init__.py +77 -2
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/uri/schemes/sftp/_asyncssh.py +4 -1
- pathlib_next-0.9.0/src/pathlib_next/uri/schemes/sftp/_paramiko.py +238 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/uri/source.py +71 -10
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/utils/__init__.py +0 -17
- pathlib_next-0.9.0/src/pathlib_next/utils/checksum.py +65 -0
- pathlib_next-0.9.0/src/pathlib_next/utils/sync.py +618 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/conftest.py +2 -6
- pathlib_next-0.9.0/tests/test_checksum.py +489 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_ftp.py +54 -1
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_parity_io.py +105 -0
- pathlib_next-0.9.0/tests/test_sftp.py +952 -0
- pathlib_next-0.9.0/tests/test_source.py +151 -0
- pathlib_next-0.9.0/tests/test_sync.py +558 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_uri_parse.py +71 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_utils.py +0 -18
- pathlib_next-0.8.6/src/pathlib_next/uri/schemes/sftp/_paramiko.py +0 -116
- pathlib_next-0.8.6/src/pathlib_next/utils/checksum.py +0 -31
- pathlib_next-0.8.6/src/pathlib_next/utils/sync.py +0 -361
- pathlib_next-0.8.6/tests/test_sftp.py +0 -430
- pathlib_next-0.8.6/tests/test_source.py +0 -62
- pathlib_next-0.8.6/tests/test_sync.py +0 -272
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/.gitignore +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/LICENSE +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/docs/api/mempath.md +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/docs/api/path.md +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/docs/api/testing.md +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/docs/api/uri.md +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/docs/api/utils.md +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/docs/changelog.md +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/docs/guides/cli.md +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/docs/guides/extending.md +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/docs/guides/schemes.md +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/examples/az_listing.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/examples/data_and_archive.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/examples/ftp_listing.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/examples/github_listing.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/examples/gitlab_listing.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/examples/gs_listing.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/examples/http_listing.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/examples/local_and_mem.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/examples/s3_listing.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/examples/sftp_sync.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/examples/webdav_roundtrip.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/mkdocs.yml +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/__init__.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/mempath.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/protocols/fs.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/py.typed +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/testing.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/tools/__init__.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/tools/uripath.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/uri/query.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/uri/schemes/__init__.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/uri/schemes/_gitrepo.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/uri/schemes/archive/__init__.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/uri/schemes/archive/_base.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/uri/schemes/archive/tar.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/uri/schemes/archive/zip.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/uri/schemes/az.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/uri/schemes/data.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/uri/schemes/dav.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/uri/schemes/file.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/uri/schemes/git/__init__.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/uri/schemes/git/_base.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/uri/schemes/git/github.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/uri/schemes/git/gitlab.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/uri/schemes/github.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/uri/schemes/gitlab.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/uri/schemes/gs.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/uri/schemes/http.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/uri/schemes/s3.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/uri/schemes/sftp/_sshconfig.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/utils/archive.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/utils/glob.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/utils/stat.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_archive_uri.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_az.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_az_fake.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_contract.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_data_uri.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_dav.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_gitrepo.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_glob.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_gs.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_gs_fake.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_http.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_http_live.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_http_parser.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_local.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_mempath.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_mro_precedence.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_parity_pure.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_path_gaps.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_pathname.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_plugins.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_properties.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_query.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_s3.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_sftp_asyncssh.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_smoke.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_uri_path.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_uripath_tool.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_walk.py +0 -0
- {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_webdav.py +0 -0
|
@@ -5,7 +5,137 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
7
7
|
|
|
8
|
-
## [
|
|
8
|
+
## [0.9.0] - 2026-07-29
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **`UriPath.host_fspath()`**, and `__fspath__()` now succeeds for schemes
|
|
12
|
+
with `_host_filesystem_path = True` (currently `sftp:`) instead of
|
|
13
|
+
unconditionally raising `NotImplementedError` for any non-`file:`
|
|
14
|
+
scheme. `os.fspath()` has two consumers -- "open this locally" (where a
|
|
15
|
+
remote path would silently read the wrong file) and "build a command
|
|
16
|
+
line for a process that runs on the path's host" (`subprocess`, remote
|
|
17
|
+
executors) -- and only the second is safe for a scheme like `sftp:`.
|
|
18
|
+
`host_fspath()` is the unambiguous accessor for that second case: it
|
|
19
|
+
never falls back to local-path semantics the way `__fspath__` does for
|
|
20
|
+
the `file:` branch.
|
|
21
|
+
- **`progress` callback for `Path.copy()`/`BinaryOpen.copy()`.** `BinaryOpen.copy(target, *, progress=None, chunk_size=shutil.COPY_BUFSIZE)`
|
|
22
|
+
now streams in caller-sized chunks and, when `progress` is given, calls
|
|
23
|
+
`progress(bytes_copied, total_size)` after each chunk (`total_size` is the
|
|
24
|
+
source's `stat().st_size` when available, else `None`). `Path.copy(target,
|
|
25
|
+
..., progress=None)` wraps this with per-file identity:
|
|
26
|
+
`progress(path, bytes_copied, total_size)`, so a `recursive=True` copy
|
|
27
|
+
reports which file is being streamed alongside its byte progress, not just
|
|
28
|
+
an anonymous byte stream. `progress=None` (the default) is behaviorally
|
|
29
|
+
identical to before this change -- no per-chunk overhead, same
|
|
30
|
+
`shutil.copyfileobj` bytes-on-wire. `SftpPath`'s asyncssh concurrent
|
|
31
|
+
fan-out (`copy(recursive=True)`) does not invoke `progress` -- native/batch
|
|
32
|
+
transfer paths are out of scope for this first cut (see
|
|
33
|
+
`docs/divergences.md`'s "Deliberate extensions" section).
|
|
34
|
+
- **`PathSyncer` can now create symlinks on `target` instead of always
|
|
35
|
+
rejecting a symlink source.** New constructor kwarg `symlink_mode`
|
|
36
|
+
(`"preserve"` default, `"reject"` opt-out), consulted only when
|
|
37
|
+
`follow_symlinks=False` and `source.is_symlink()` (with the default
|
|
38
|
+
`follow_symlinks=True`, symlinks are still resolved during traversal,
|
|
39
|
+
unchanged). `"preserve"` creates a matching symlink on `target` using the
|
|
40
|
+
exact raw, unresolved target string `readlink()` returned -- dangling
|
|
41
|
+
links and relative targets included, never validated or resolved against
|
|
42
|
+
`source`'s parent. If `target`'s implementation has no `symlink_to()` at
|
|
43
|
+
all (every backend except `LocalPath` and `SftpPath`), `"preserve"` mode
|
|
44
|
+
raises `NotImplementedError` through the existing `ignore_error`/`hook()`
|
|
45
|
+
flow, same as every other sync branch -- not a silent skip. New
|
|
46
|
+
`SyncEvent.Symlink` enum member.
|
|
47
|
+
- **Optional backend-native checksum protocol**
|
|
48
|
+
(`pathlib_next.protocols.checksum.NativeChecksum`,
|
|
49
|
+
`checksum(algorithm="md5") -> str`). A `Path` subclass may implement it to
|
|
50
|
+
compute a file digest server-side instead of streaming the content
|
|
51
|
+
through `open("rb")` -- implemented on `SftpPath` against the OpenSSH
|
|
52
|
+
`check-file@openssh.com` SFTP protocol extension (paramiko backend only;
|
|
53
|
+
the asyncssh backend has no equivalent client-library support and
|
|
54
|
+
correctly falls back). Not part of the base `Path`/`Pathname` ABC -- a
|
|
55
|
+
plain `Path` has no `.checksum` attribute at all. Implementations MUST
|
|
56
|
+
raise `NotImplementedError` (never return a value) when they can't
|
|
57
|
+
produce a genuine digest under the requested algorithm -- this is what
|
|
58
|
+
keeps two checksums from ever being compared under a mismatched
|
|
59
|
+
algorithm, or trusting something hash-shaped but not a real content hash
|
|
60
|
+
(e.g. S3's ETag for a multipart upload, deliberately not implemented here
|
|
61
|
+
for exactly that reason -- see `docs/divergences.md`). Also adds a
|
|
62
|
+
companion `supported_checksums() -> frozenset[str]` advisory query
|
|
63
|
+
(default `frozenset()`, never raises); `SftpPath.supported_checksums()`
|
|
64
|
+
is a real per-connection probe against the server (paramiko exposes no
|
|
65
|
+
cheaper way to know), cached per connection.
|
|
66
|
+
- **`PathSyncer`'s default checksum policy now prefers native digests on
|
|
67
|
+
both sides when available**, falling back to streaming
|
|
68
|
+
(`utils.checksum.md5`/the new generic `utils.checksum.stream`) when
|
|
69
|
+
either side can't produce one under the same algorithm -- never a
|
|
70
|
+
native-vs-streamed comparison under a mismatched algorithm. A
|
|
71
|
+
caller-supplied `checksum` callable is unaffected (invoked exactly as
|
|
72
|
+
before). New `utils.checksum.native(path, algorithm) -> str | None`
|
|
73
|
+
helper: tries the protocol, returns `None` (never raises) if unsupported.
|
|
74
|
+
- **`PathSyncer(..., quick_check=True)`** (new constructor kwarg, default
|
|
75
|
+
`True`): for a sync pair where at least one side is non-local, a
|
|
76
|
+
metadata-only pre-check (`st_size` + `st_mtime`, already-cached, no extra
|
|
77
|
+
round trip) skips the checksum call entirely when both match; a mismatch
|
|
78
|
+
always falls through to a real checksum rather than being treated as
|
|
79
|
+
"changed" on its own. Local-to-local pairs never engage this pre-check.
|
|
80
|
+
`quick_check=False` restores always-checksum behavior for non-local pairs.
|
|
81
|
+
|
|
82
|
+
### Changed
|
|
83
|
+
- **`PathSyncer` default behavior change**: `PathSyncer(follow_symlinks=
|
|
84
|
+
False).sync()` on a symlink source previously always raised
|
|
85
|
+
`NotImplementedError`. It now creates a matching symlink on `target` by
|
|
86
|
+
default (`symlink_mode="preserve"`); pass `symlink_mode="reject"` to
|
|
87
|
+
restore the old unconditional-raise behavior exactly.
|
|
88
|
+
- **The `uri` extra now requires `netimps>=0.2.0`** (alongside `uritools`).
|
|
89
|
+
`Source.is_local()` (`uri.source`) delegates its "is this address mine"
|
|
90
|
+
check to `netimps.is_local_address()`, which enumerates real network
|
|
91
|
+
interfaces (`netimps.get_interfaces()`) instead of the previous
|
|
92
|
+
`socket.getaddrinfo(socket.gethostname(), None)`-based approach, which
|
|
93
|
+
missed addresses not tied to the resolvable hostname (VMs, containers,
|
|
94
|
+
VPN interfaces, additional NICs on a multi-homed host). The
|
|
95
|
+
hostname->address step now uses `netimps.resolve()` (both `"a"`/`"aaaa"`
|
|
96
|
+
record types; `host` is local if ANY resolved address is) instead of
|
|
97
|
+
`socket.gethostbyname()` -- `resolve()`'s default backend chain
|
|
98
|
+
(dnspython, then the OS resolver via `getaddrinfo()`, then `nslookup`)
|
|
99
|
+
covers the same hosts-file/NSS/DNS resolution `gethostbyname()` did, and
|
|
100
|
+
additionally never raises for a name that simply doesn't resolve
|
|
101
|
+
(`resolve()`'s contract: always a list, empty on genuine failure) --
|
|
102
|
+
`gethostbyname()` raised `socket.gaierror` for that case. `utils.
|
|
103
|
+
get_machine_ips()` (the old implementation's helper, unused elsewhere in
|
|
104
|
+
this library) is removed. `SftpPath`'s two backends and `FtpPath` now
|
|
105
|
+
resolve their default ports (22, 21) via `netimps.get_default_port()`
|
|
106
|
+
instead of three separately hardcoded literals.
|
|
107
|
+
|
|
108
|
+
### Fixed
|
|
109
|
+
- **`Source.is_local()` crashed on a bare IPv6-literal `host` string.**
|
|
110
|
+
`Source(scheme, userinfo, "::1", port)` (a supported direct-construction
|
|
111
|
+
pattern -- `Source`'s fields are public `NamedTuple` fields) raised
|
|
112
|
+
`socket.gaierror` instead of returning a result, because
|
|
113
|
+
`socket.gethostbyname()` (this method's original hostname-resolution
|
|
114
|
+
step) is IPv4-only. `host` bracket-literals arriving via the normal
|
|
115
|
+
`Source.from_str()`/`Uri()` construction path were unaffected
|
|
116
|
+
(`_decode_host()` already parses those into a real `IPv6Address` before
|
|
117
|
+
`is_local()` ever sees a string). `is_local()` now tries
|
|
118
|
+
`netimps.try_parse()` first (handles any IP literal directly, string or
|
|
119
|
+
not) before falling through to hostname resolution.
|
|
120
|
+
- **`uri.source.Source` leaked the password in `str()`/`repr()`.**
|
|
121
|
+
`Source.__str__()` called `uricompose()` with the raw `userinfo`
|
|
122
|
+
(password included) -- a genuinely valid, connectable URI string, not
|
|
123
|
+
just a debug rendering -- and `Source` had no custom `__repr__` at all,
|
|
124
|
+
so the `NamedTuple` default rendered every field verbatim too. `repr()`
|
|
125
|
+
is what a traceback frame renders, so a `Source` anywhere on a failing
|
|
126
|
+
call stack leaked the credential into logs, even though `Uri.__str__()`
|
|
127
|
+
already redacted. Both now redact the password from `userinfo` the same
|
|
128
|
+
way `Uri.__str__()` does; the actual data (`.userinfo`,
|
|
129
|
+
`.parsed_userinfo()`, `["userinfo"]`) is unaffected, only display is
|
|
130
|
+
sanitized. **This is a behavior change, not purely additive**:
|
|
131
|
+
`str(source)` (or `f"{source}"`) no longer reconstructs an authenticated
|
|
132
|
+
URI -- verified nothing in this codebase relied on that (every real
|
|
133
|
+
connection site reads individual `Source` fields, never whole-object
|
|
134
|
+
`str()`), but a downstream caller that did would need the new
|
|
135
|
+
**`Source.as_str(sanitize=True)`** method instead: `sanitize=True` (the
|
|
136
|
+
default, matching `__str__`) redacts the password; `sanitize=False` is
|
|
137
|
+
the full, credentialed round trip -- same name/kwarg as
|
|
138
|
+
`Uri.as_uri(sanitize=)`, so both classes work the same way.
|
|
9
139
|
|
|
10
140
|
## [0.8.6] - 2026-07-26
|
|
11
141
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pathlib_next
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.0
|
|
4
4
|
Summary: Generic Path Protocol based pathlib
|
|
5
5
|
Project-URL: Homepage, https://github.com/jose-pr/pathlib_next/
|
|
6
6
|
Project-URL: Documentation, https://jose-pr.github.io/pathlib_next/
|
|
@@ -13,6 +13,7 @@ Classifier: Programming Language :: Python :: 3
|
|
|
13
13
|
Requires-Python: >=3.9
|
|
14
14
|
Provides-Extra: az
|
|
15
15
|
Requires-Dist: azure-storage-blob; extra == 'az'
|
|
16
|
+
Requires-Dist: netimps>=0.2.0; extra == 'az'
|
|
16
17
|
Requires-Dist: uritools; extra == 'az'
|
|
17
18
|
Provides-Extra: dev
|
|
18
19
|
Requires-Dist: build; extra == 'dev'
|
|
@@ -31,21 +32,27 @@ Requires-Dist: mkdocs-material; extra == 'docs'
|
|
|
31
32
|
Requires-Dist: mkdocstrings[python]; extra == 'docs'
|
|
32
33
|
Provides-Extra: gs
|
|
33
34
|
Requires-Dist: google-cloud-storage; extra == 'gs'
|
|
35
|
+
Requires-Dist: netimps>=0.2.0; extra == 'gs'
|
|
34
36
|
Requires-Dist: uritools; extra == 'gs'
|
|
35
37
|
Provides-Extra: http
|
|
38
|
+
Requires-Dist: netimps>=0.2.0; extra == 'http'
|
|
36
39
|
Requires-Dist: requests; extra == 'http'
|
|
37
40
|
Requires-Dist: uritools; extra == 'http'
|
|
38
41
|
Provides-Extra: s3
|
|
39
42
|
Requires-Dist: boto3; extra == 's3'
|
|
43
|
+
Requires-Dist: netimps>=0.2.0; extra == 's3'
|
|
40
44
|
Requires-Dist: uritools; extra == 's3'
|
|
41
45
|
Provides-Extra: sftp
|
|
46
|
+
Requires-Dist: netimps>=0.2.0; extra == 'sftp'
|
|
42
47
|
Requires-Dist: paramiko; extra == 'sftp'
|
|
43
48
|
Requires-Dist: uritools; extra == 'sftp'
|
|
44
49
|
Provides-Extra: sftp-async
|
|
45
50
|
Requires-Dist: asyncssh; (python_version >= '3.10') and extra == 'sftp-async'
|
|
46
51
|
Requires-Dist: asyncssh<2.22; (python_version < '3.10') and extra == 'sftp-async'
|
|
52
|
+
Requires-Dist: netimps>=0.2.0; extra == 'sftp-async'
|
|
47
53
|
Requires-Dist: uritools; extra == 'sftp-async'
|
|
48
54
|
Provides-Extra: uri
|
|
55
|
+
Requires-Dist: netimps>=0.2.0; extra == 'uri'
|
|
49
56
|
Requires-Dist: uritools; extra == 'uri'
|
|
50
57
|
Description-Content-Type: text/markdown
|
|
51
58
|
|
|
@@ -105,7 +112,7 @@ Optional features/extras:
|
|
|
105
112
|
|
|
106
113
|
| Extra/flag | Adds | Needed for |
|
|
107
114
|
| --- | --- | --- |
|
|
108
|
-
| `uri` | `uritools` | URI parsing (any `UriPath` scheme) |
|
|
115
|
+
| `uri` | `uritools`, `netimps` | URI parsing (any `UriPath` scheme) |
|
|
109
116
|
| `http` | `requests` | `http(s):` and `dav(s):` (WebDAV) paths |
|
|
110
117
|
| `sftp` | `paramiko` | `sftp:` path operations and transfers (sync backend) |
|
|
111
118
|
| `sftp-async` | `asyncssh` | `sftp:` path operations via the asyncssh backend instead (see `guides/schemes.md`'s `sftp:` row for selection precedence) |
|
|
@@ -54,7 +54,7 @@ Optional features/extras:
|
|
|
54
54
|
|
|
55
55
|
| Extra/flag | Adds | Needed for |
|
|
56
56
|
| --- | --- | --- |
|
|
57
|
-
| `uri` | `uritools` | URI parsing (any `UriPath` scheme) |
|
|
57
|
+
| `uri` | `uritools`, `netimps` | URI parsing (any `UriPath` scheme) |
|
|
58
58
|
| `http` | `requests` | `http(s):` and `dav(s):` (WebDAV) paths |
|
|
59
59
|
| `sftp` | `paramiko` | `sftp:` path operations and transfers (sync backend) |
|
|
60
60
|
| `sftp-async` | `asyncssh` | `sftp:` path operations via the asyncssh backend instead (see `guides/schemes.md`'s `sftp:` row for selection precedence) |
|
|
@@ -202,6 +202,30 @@ Legend: `p` = `paramiko`, `a` = `asyncssh`.
|
|
|
202
202
|
- The broad SFTP benchmark is useful for backend comparison even when the
|
|
203
203
|
operation itself is exposed synchronously, because backend internals still
|
|
204
204
|
affect overall throughput.
|
|
205
|
+
- **Native checksum protocol (this release): not benchmarked with a live
|
|
206
|
+
timing run.** The bytes-transferred claim for this feature is structural,
|
|
207
|
+
not something wall-clock benchmarking would usefully confirm: when
|
|
208
|
+
`PathSyncer`'s default policy can use both sides' native digest (e.g.
|
|
209
|
+
`SftpPath` against an OpenSSH server's `check-file@openssh.com`
|
|
210
|
+
extension), a file comparison transfers **zero content bytes** for a
|
|
211
|
+
match-or-mismatch verdict, versus the streaming fallback's full read on
|
|
212
|
+
*both* sides (`2 * file_size` for an unchanged file that still needs
|
|
213
|
+
comparing). This project's own SFTP test server (asyncssh's `SFTPServer`,
|
|
214
|
+
used by `tests/conftest.py::sftp_server`) has no `check-file@openssh.com`
|
|
215
|
+
support at all, so a live before/after run against it can only exercise
|
|
216
|
+
the streaming-fallback path, not the native one -- a real OpenSSH server
|
|
217
|
+
would be needed for genuine native-path timing, which is out of scope for
|
|
218
|
+
this environment's benchmark harness. `tests/test_sftp.py`'s
|
|
219
|
+
wire-level-fake tests (`test_paramiko_checksum_*`) are the correctness
|
|
220
|
+
proof for this release instead.
|
|
221
|
+
- **`PathSyncer(quick_check=True)` (this release):** also structural, same
|
|
222
|
+
reasoning. For an unchanged non-local file with matching `st_size`/
|
|
223
|
+
`st_mtime`, the pre-check skips the checksum step (native or streaming)
|
|
224
|
+
entirely -- **zero content bytes AND zero extension round trips**, versus
|
|
225
|
+
even the native-checksum path's still-nonzero per-file request. Only
|
|
226
|
+
applies when metadata already agrees; any mismatch (including a
|
|
227
|
+
genuinely unchanged file whose mtime wasn't preserved by a prior copy)
|
|
228
|
+
still pays for a real checksum, same cost as before this release.
|
|
205
229
|
|
|
206
230
|
## Caveats
|
|
207
231
|
|
|
@@ -61,6 +61,7 @@ these operations itself always keeps its own implementation.
|
|
|
61
61
|
| `Path.move(target, ...)` | Not in `pathlib` at all | Our own extension: tries `rename()`, falls back to copy+unlink | N/A -- pure extension, no pathlib method to diverge from. |
|
|
62
62
|
| `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 is unlinked rather than traversed. | 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. |
|
|
63
63
|
| `PathSyncer` / `Query` / `Source` | N/A | Our own extensions | N/A -- pure extensions, no pathlib equivalent. |
|
|
64
|
+
| `PathSyncer(follow_symlinks=False).sync()` on a symlink source | N/A (no pathlib equivalent) | Previously always raised `NotImplementedError`. Now controlled by the new `symlink_mode` constructor kwarg (`"preserve"` default, `"reject"` opt-out): `"preserve"` creates a matching symlink on `target` with the same raw, unresolved target string `readlink()` returned (dangling links and relative targets included, never validated/resolved); `"reject"` restores the exact old unconditional-raise behavior. If `target` can't create symlinks at all (every backend except `LocalPath` and `SftpPath`), `"preserve"` also raises `NotImplementedError`, through the same `ignore_error`/`hook()` machinery as every other sync branch, not a silent skip. **This is a default-behavior change, not a pure extension** -- flagged here because existing callers relying on the old unconditional raise (e.g. to detect and skip symlinks) must now pass `symlink_mode="reject"` explicitly. | Faithful one-way tree mirroring needs symlinks preserved as symlinks by default, not silently dropped/erroring -- discovered via a real cross-host sync use case (hostctl). `follow_symlinks=True` (unchanged default) still resolves through symlinks during traversal, so this only affects callers who already opted into `follow_symlinks=False`. **User decision, 2026-07-28.** |
|
|
64
65
|
| `S3Path` directories | N/A (pathlib directories are real filesystem entries) | `is_dir()` is prefix emulation (any object key under `"<path>/"`); `mkdir()` creates a zero-byte `"<path>/"` marker object; `rmdir()` requires no other keys under that prefix (pathlib-parity "must be empty"). If an exact object key and a `"<path>/"` prefix both exist, exact object operations such as `stat()` and `rm(recursive=True)` treat the path as the object first. | S3 has no native directory concept -- this is the same prefix convention the AWS console itself uses for an empty "folder". Exact-object precedence avoids deleting a prefix tree when the addressed path is a real object. |
|
|
65
66
|
| `GsPath`/`AzPath` directories | N/A (pathlib directories are real filesystem entries) | `is_dir()` is prefix emulation (any blob under `"<path>/"`); `mkdir()` creates a zero-byte `"<path>/"` marker blob; `rmdir()` requires no other blobs under that prefix (pathlib-parity "must be empty"). If an exact object/blob key and a `"<path>/"` prefix both exist, exact object operations such as `stat()` and `rm(recursive=True)` treat the path as the object first. | GCS and Azure Blob have no native directory concept -- same prefix emulation as `S3Path`. Exact-object precedence avoids deleting a prefix tree when the addressed path is a real object. |
|
|
66
67
|
| `GsPath`/`AzPath` `stat().st_mtime` | Real filesystem mtime | Always `0` | Querying just the mtime alone would require separate API calls beyond what the listing/get operations already provide. |
|
|
@@ -69,6 +70,9 @@ these operations itself always keeps its own implementation.
|
|
|
69
70
|
| `GitHubPath` `symlink`/`submodule` tree entries | pathlib exposes `is_symlink()` | Surfaced as a plain file, no distinction | No portable meaning for a submodule (a pointer to another repo, not file content) or a symlink (git stores the link target as the blob content) without extra API calls; not implemented. |
|
|
70
71
|
| `empty_dir/` in a `github:`/`gitlab:` tree | pathlib directories can be empty | Requires a placeholder blob inside (e.g. `.gitkeep`) to exist at all | Git itself has no empty-directory concept -- neither API can return a tree entry for a path with zero blobs under it, so this isn't a library limitation, it's inherent to git. |
|
|
71
72
|
| `HttpPath` `open("a")` default append mode | POSIX `O_APPEND` is atomic (all appends serialized) | Default "rewrite" mode is non-atomic (GET existing + append in client memory + PUT full body) -- concurrent appenders can race | HTTP has no native append primitive; rewrite mode trades atomicity for universality (works on any server that supports PUT). Opt-in "patch" mode using `Content-Range` PATCH is atomic on servers that support it (use `with_session(..., append_mode="patch")`), but raises `PermissionError` if the server rejects it. |
|
|
73
|
+
| `Uri`/`UriPath.__str__()` | `str(pathlib.Path)` round-trips the full path | Drops the password from userinfo (`sftp://u:pw@h/p` -> `"sftp://u@h/p"` via `as_uri(sanitize=True)`) -- reparsing the result gives a *different*, unauthenticated URI | `str()` is what logging/printing reach for; a credentialed URI landing in a log line is worse than a `str()` that doesn't round-trip. Use `as_uri(sanitize=False)` for the full URI including credentials. |
|
|
74
|
+
| `Uri`/`UriPath.__fspath__()` | `os.fspath(pathlib.Path)` always succeeds with a locally-openable path | Raises `NotImplementedError` for any non-`file` scheme, **except** schemes with `_host_filesystem_path = True` (currently `sftp:`), which return `.path` -- a path meaningful on the URI's *own* host, not the local machine | `os.fspath()` has two consumers: "open this locally" (where returning a remote path would silently read the wrong file) and "build a command line for a process that runs on the path's host" (`subprocess`, remote executors) -- correct for the second consumer, wrong for the first. Schemes opt in via `_host_filesystem_path` only when `.path` genuinely is a host filesystem path. `host_fspath()` is the unambiguous accessor for the second use case: it never falls back to treating a path as local. **User decision, 2026-07-28.** |
|
|
75
|
+
| `uri.source.Source.__str__()`/`__repr__()` | `str()` previously called `uricompose()` with the raw `userinfo` -- a genuinely valid, connectable URI including the password; `repr()` used `NamedTuple`'s default, which also renders every field, including `userinfo`, verbatim | Both now redact the password from `userinfo` the same way `Uri.__str__()` does (`root:secret` -> `root`) -- `.userinfo`/`.parsed_userinfo()`/`["userinfo"]` (the actual data-access API) still return the real password; only display is sanitized. `str(source)` no longer reconstructs an authenticated URI -- use the new `Source.as_str(sanitize=False)` for the full round trip (mirrors `Uri.as_uri(sanitize=False)` exactly, same name/kwarg) | `repr()` is what a traceback frame renders, so a `Source` anywhere on a failing call stack used to put the password in the log even though `Uri.__str__()` already redacted -- a caller who saw `Uri` redact reasonably assumed the layer beneath it did too. Same rationale as the `Uri.__str__()` row above. **User decision, 2026-07-29.** |
|
|
72
76
|
|
|
73
77
|
## Explicitly out of scope (not implemented on `Pathname`/`Path`)
|
|
74
78
|
|
|
@@ -113,3 +117,50 @@ because a behavioral decision needed documenting:
|
|
|
113
117
|
**User decision, 2026-07-11.** `include_hidden=`/`dironly=` are documented
|
|
114
118
|
extensions beyond pathlib's `glob()` signature. **Caution:** on remote
|
|
115
119
|
schemes (http/sftp), a recursive glob walks the whole remote subtree.
|
|
120
|
+
- `BinaryOpen.copy(target, *, progress=None, chunk_size=shutil.COPY_BUFSIZE)`
|
|
121
|
+
/ `Path.copy(target, ..., progress=None)`: optional progress-reporting
|
|
122
|
+
hook, no pathlib equivalent. `BinaryOpen.copy()`'s `progress(bytes_copied,
|
|
123
|
+
total_size)` fires after each chunk (`total_size` is `None` when the
|
|
124
|
+
source doesn't implement `Stat` or `stat()` fails); `Path.copy()`'s
|
|
125
|
+
`progress(path, bytes_copied, total_size)` adds the source `Path` being
|
|
126
|
+
streamed, so a `recursive=True` copy can report per-file identity
|
|
127
|
+
alongside byte progress. `chunk_size` is now caller-visible (previously
|
|
128
|
+
hardcoded to `shutil.copyfileobj`'s default). `progress=None` (the
|
|
129
|
+
default) is byte-for-byte identical to the prior `shutil.copyfileobj`
|
|
130
|
+
behavior -- no per-chunk overhead when unused. **Known limitation:**
|
|
131
|
+
native/batch transfer paths that bypass the generic streaming copy --
|
|
132
|
+
currently only `SftpPath`'s asyncssh concurrent fan-out
|
|
133
|
+
(`copy(recursive=True)` on a directory) -- do not invoke `progress`; this
|
|
134
|
+
was a deliberate scope decision for the first cut (generic-stream-only),
|
|
135
|
+
not an oversight. **2026-07-28.**
|
|
136
|
+
- `protocols.checksum.NativeChecksum` (`checksum(algorithm="md5") -> str`) --
|
|
137
|
+
an entirely new, optional protocol with no pathlib equivalent. A `Path`
|
|
138
|
+
subclass may implement it to compute a file digest server-side (e.g.
|
|
139
|
+
`SftpPath` against an OpenSSH server's `check-file@openssh.com` SFTP
|
|
140
|
+
extension) instead of streaming the content through `open("rb")`. Not
|
|
141
|
+
mixed into the base `Path`/`Pathname` ABC -- most backends never
|
|
142
|
+
implement it, and a plain `Path` has no `.checksum` attribute at all.
|
|
143
|
+
**Hard contract, not a style choice:** an implementation MUST raise
|
|
144
|
+
`NotImplementedError` (never return a value) when it cannot produce a
|
|
145
|
+
genuine content digest under the requested `algorithm` -- this is what
|
|
146
|
+
keeps `PathSyncer` (see its class docstring, `utils/sync.py`) from ever
|
|
147
|
+
comparing a native digest to a streamed one under a mismatched algorithm,
|
|
148
|
+
or trusting something hash-shaped but not actually a content hash (e.g.
|
|
149
|
+
S3's ETag for a multipart upload, deliberately NOT implemented here for
|
|
150
|
+
exactly that reason). `utils.checksum.md5`/`sha256`/`stream` (the
|
|
151
|
+
pre-existing streaming helpers) are unaffected and keep working for
|
|
152
|
+
direct callers that don't go through the protocol or `PathSyncer`.
|
|
153
|
+
`supported_checksums() -> frozenset[str]` (default `frozenset()`) is a
|
|
154
|
+
companion advisory capability query -- never raises, lets a caller pick a
|
|
155
|
+
shared algorithm across two paths before calling anything expensive, but
|
|
156
|
+
is advisory only: `checksum()`'s own `NotImplementedError` contract
|
|
157
|
+
remains authoritative regardless of what this advertises.
|
|
158
|
+
- `PathSyncer(..., quick_check=True)` -- a new constructor kwarg, no
|
|
159
|
+
pathlib equivalent. For any sync pair where at least one side is
|
|
160
|
+
non-local, a metadata-only pre-check (`st_size` + `st_mtime`, from
|
|
161
|
+
already-cached listing metadata, no extra round trip) skips the checksum
|
|
162
|
+
call entirely when both already match; a mismatch always falls through
|
|
163
|
+
to a real checksum rather than being treated as "changed" on its own.
|
|
164
|
+
Local-to-local pairs never engage this pre-check. `quick_check=False`
|
|
165
|
+
restores always-checksum behavior for non-local pairs too. **User
|
|
166
|
+
decision, 2026-07-28.**
|
|
@@ -17,7 +17,7 @@ pip install pathlib_next
|
|
|
17
17
|
|
|
18
18
|
| Extra | Adds | Needed for |
|
|
19
19
|
| --- | --- | --- |
|
|
20
|
-
| `uri` | `uritools` | `Uri`/`UriPath` parsing (any URI scheme) |
|
|
20
|
+
| `uri` | `uritools`, `netimps` | `Uri`/`UriPath` parsing (any URI scheme) |
|
|
21
21
|
| `http` | `requests` | `http(s)://` and `dav(s)://` (WebDAV) paths |
|
|
22
22
|
| `sftp` | `paramiko` | `sftp://` paths (sync backend) |
|
|
23
23
|
| `sftp-async` | `asyncssh` | `sftp://` paths via the asyncssh backend instead |
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pathlib_next"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.9.0"
|
|
8
8
|
authors = [{ name = "Jose A" }]
|
|
9
9
|
description = "Generic Path Protocol based pathlib"
|
|
10
10
|
readme = "README.md"
|
|
@@ -20,7 +20,7 @@ dependencies = []
|
|
|
20
20
|
uripath = "pathlib_next.tools.uripath:main"
|
|
21
21
|
|
|
22
22
|
[project.optional-dependencies]
|
|
23
|
-
uri = ["uritools"]
|
|
23
|
+
uri = ["uritools", "netimps>=0.2.0"]
|
|
24
24
|
http = ["requests", "pathlib_next[uri]"]
|
|
25
25
|
sftp = ["paramiko", "pathlib_next[uri]"]
|
|
26
26
|
sftp-async = ["asyncssh<2.22; python_version<'3.10'", "asyncssh; python_version>='3.10'", "pathlib_next[uri]"]
|
|
@@ -64,14 +64,22 @@ pathlib_next`.
|
|
|
64
64
|
walk is swallowed (predicate return `True`) or re-raised.
|
|
65
65
|
- `rename(target)` — not implemented by default.
|
|
66
66
|
- `copy(target, *, overwrite=False, follow_symlinks=True,
|
|
67
|
-
preserve_metadata=True, recursive=False, ignore_error=None
|
|
68
|
-
`follow_symlinks`/`preserve_metadata` names match
|
|
69
|
-
`Path.copy()`; `overwrite` is this library's own
|
|
70
|
-
raises if the destination exists).
|
|
71
|
-
here (3.14 defaults `False`) and
|
|
72
|
-
timestamps/xattrs. `ignore_error`, when
|
|
73
|
-
instead of raising (same contract as
|
|
74
|
-
(default) fails on the first error.
|
|
67
|
+
preserve_metadata=True, recursive=False, ignore_error=None,
|
|
68
|
+
progress=None)` — `follow_symlinks`/`preserve_metadata` names match
|
|
69
|
+
CPython 3.14's `Path.copy()`; `overwrite` is this library's own
|
|
70
|
+
extension (3.14 always raises if the destination exists).
|
|
71
|
+
`preserve_metadata` defaults `True` here (3.14 defaults `False`) and
|
|
72
|
+
only preserves `st_mode`, not timestamps/xattrs. `ignore_error`, when
|
|
73
|
+
given, receives exceptions instead of raising (same contract as
|
|
74
|
+
`rm()`'s callable form); `None` (default) fails on the first error.
|
|
75
|
+
`progress`, when given, is called as `progress(path, bytes_copied,
|
|
76
|
+
total_size)` per chunk written for each file streamed (`path` is the
|
|
77
|
+
source file; `total_size` is `None` if unknown); with `recursive=True`
|
|
78
|
+
this fires once per copied file, giving per-file identity alongside
|
|
79
|
+
byte progress. `progress=None` (default) has no per-chunk overhead and
|
|
80
|
+
is bytewise identical to before this kwarg existed. Not honored by
|
|
81
|
+
`SftpPath`'s asyncssh concurrent fan-out (native transfer, out of
|
|
82
|
+
scope) — see `docs/divergences.md`.
|
|
75
83
|
- `move(target, *, overwrite=False)` — tries `rename()` first, falls back
|
|
76
84
|
to `copy(recursive=True)` + `rm(recursive=True)`/`unlink()` when
|
|
77
85
|
`rename()` raises `NotImplementedError`.
|
|
@@ -128,8 +136,33 @@ pathlib_next`.
|
|
|
128
136
|
Derives `open(mode="r", buffering=-1, encoding=None, errors=None,
|
|
129
137
|
newline=None)`, `read_bytes()`, `read_text(encoding=None, errors=None,
|
|
130
138
|
newline=None)`, `write_bytes(data)`, `write_text(data, encoding=None,
|
|
131
|
-
errors=None, newline=None)`, `copy(target
|
|
132
|
-
|
|
139
|
+
errors=None, newline=None)`, `copy(target, *, progress=None,
|
|
140
|
+
chunk_size=shutil.COPY_BUFSIZE)` (streams this object's binary content
|
|
141
|
+
into another `BinaryOpen`; `progress(bytes_copied, total_size)` fires
|
|
142
|
+
per chunk when given — `total_size` from `stat().st_size` if `self` also
|
|
143
|
+
implements `Stat` and it succeeds, else `None`; `progress=None` default
|
|
144
|
+
is unchanged `shutil.copyfileobj` behavior).
|
|
145
|
+
- **`checksum.NativeChecksum`** — `Protocol`. `checksum(algorithm="md5") ->
|
|
146
|
+
str` (not implemented by default). Optional, backend-native file digest
|
|
147
|
+
(e.g. `SftpPath` against the OpenSSH `check-file@openssh.com` SFTP
|
|
148
|
+
extension) computed server-side instead of streaming content through
|
|
149
|
+
`open("rb")`. Not mixed into the base `Path`/`Pathname` ABC — a plain
|
|
150
|
+
`Path` has no `.checksum` attribute at all; a subclass opts in by mixing
|
|
151
|
+
this protocol in and implementing the method. MUST raise
|
|
152
|
+
`NotImplementedError` (never return a value) when it can't produce a
|
|
153
|
+
genuine digest under the requested `algorithm` — see
|
|
154
|
+
`docs/divergences.md` for why this is a hard contract, not a style
|
|
155
|
+
choice (the S3-ETag-for-multipart-uploads trap in particular).
|
|
156
|
+
`supported_checksums() -> frozenset[str]` (default: `frozenset()`) is a
|
|
157
|
+
companion *advisory* capability query — never raises, lets a caller pick
|
|
158
|
+
a shared algorithm across two paths (e.g. `source.supported_checksums()
|
|
159
|
+
& target.supported_checksums()`) without probing via trial-and-error.
|
|
160
|
+
Advisory only: `checksum()`'s own `NotImplementedError` remains the
|
|
161
|
+
authoritative per-call contract even if a caller skips this query.
|
|
162
|
+
`SftpPath.supported_checksums()` is a real per-connection probe (not a
|
|
163
|
+
static flag) — paramiko exposes no way to read the server's advertised
|
|
164
|
+
SFTP extension list, so the only reliable signal is an actual attempt,
|
|
165
|
+
cached per connection.
|
|
133
166
|
|
|
134
167
|
## URIs (`pathlib_next.uri`)
|
|
135
168
|
|
|
@@ -151,8 +184,13 @@ extra that depends on it).
|
|
|
151
184
|
`relative_to(other, *, walk_up=False)`, `is_local()` (delegates to
|
|
152
185
|
`Source.is_local()` — does a DNS lookup, cached per `Source`),
|
|
153
186
|
`as_posix()` (`user@host:path` / `host:path` form when a source is
|
|
154
|
-
present). `__fspath__()`
|
|
155
|
-
|
|
187
|
+
present). `__fspath__()` succeeds for a `file:`-scheme URI pointing at
|
|
188
|
+
this machine, and for any scheme with `_host_filesystem_path = True`
|
|
189
|
+
(currently `sftp:` — returns `.path`, meaningful on that URI's own host,
|
|
190
|
+
not the local one); otherwise raises `NotImplementedError`. `host_fspath()`
|
|
191
|
+
is the unambiguous accessor for "path on the URI's own host" — same
|
|
192
|
+
`_host_filesystem_path` gate, but never falls back to local-path
|
|
193
|
+
semantics. See `docs/divergences.md`.
|
|
156
194
|
- **`UriPath(Uri, Path)`** — `Uri` + `Path` (I/O) + scheme dispatch.
|
|
157
195
|
`UriPath(*uris, **options)` (the bare class) parses the URI and returns an
|
|
158
196
|
instance of the concrete subclass registered for its scheme via
|
|
@@ -176,12 +214,32 @@ extra that depends on it).
|
|
|
176
214
|
itself.
|
|
177
215
|
- **`Source`** (`uri.source`, re-exported at `uri.Source` via `uri/__init__`
|
|
178
216
|
imports) — `NamedTuple(scheme, userinfo, host, port)`; falsy when every
|
|
179
|
-
field is empty/`None`. `
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
`
|
|
217
|
+
field is empty/`None`. `as_str(sanitize=True) -> str` composes an
|
|
218
|
+
authority string (`scheme://userinfo@host:port`); `sanitize=True` (the
|
|
219
|
+
default) drops the password from `userinfo`, `sanitize=False` is the
|
|
220
|
+
full, credentialed round trip — same name/kwarg as `Uri.as_uri()`, so
|
|
221
|
+
both classes work the same way. `__str__()` is `as_str(sanitize=True)`;
|
|
222
|
+
`__repr__()` redacts the same way (`NamedTuple`'s default would render
|
|
223
|
+
every field, including the password, verbatim — see
|
|
224
|
+
`docs/divergences.md`). The actual data (`.userinfo`, `parsed_userinfo()`,
|
|
225
|
+
`["userinfo"]`) is unaffected by any of this, only display is sanitized.
|
|
226
|
+
`Source.from_str(source, strict=True) -> Source` (`strict=True` raises
|
|
227
|
+
`ValueError` if `source` carries a path/query/fragment).
|
|
228
|
+
`parsed_userinfo() -> (user, password)`.
|
|
229
|
+
`get_scheme_cls(schemesmap=None) -> type[UriPath]` — resolves (and lazily
|
|
230
|
+
loads) the scheme class. `is_local()` — IP-literal `host` (`str` or
|
|
231
|
+
`_IPAddress`) skips resolution via `netimps.try_parse()`; otherwise
|
|
232
|
+
`netimps.resolve(host, "a")` + `resolve(host, "aaaa")` (default backend
|
|
233
|
+
chain: dnspython, then the OS resolver via `getaddrinfo()` — hosts file,
|
|
234
|
+
NSS, DNS, OS cache — then `nslookup`; `host` is local if ANY resolved
|
|
235
|
+
address is; empty result -> not local, never an exception for a
|
|
236
|
+
genuinely non-resolving name). `netimps.is_local_address()` then decides
|
|
237
|
+
membership per address (real interface enumeration via
|
|
238
|
+
`netimps.get_interfaces()`, not DNS-based guessing). `lru_cache
|
|
239
|
+
(maxsize=256)`d per `Source` value; never call on a hot path uncached.
|
|
240
|
+
Requires `netimps>=0.2.0` (part of the `uri` extra; `resolve()`'s
|
|
241
|
+
OS-resolver-chain support landed in 0.2.0 — earlier versions were
|
|
242
|
+
dnspython-only).
|
|
185
243
|
- **`Query(str)`** (`uri.query`) — a URI query string, buildable from a
|
|
186
244
|
`str`, a sequence of `(key, value)` pairs, or a mapping (`value` may be a
|
|
187
245
|
sequence to repeat the key). `Query(query, *, encoding="utf-8",
|
|
@@ -230,13 +288,45 @@ Subclass one of these with your own `root` fixture to verify a custom
|
|
|
230
288
|
pathlib 3.13 `full_match()` semantics, `"**"` matches zero or more
|
|
231
289
|
segments. **`glob.RECURSIVE`** = `"**"`.
|
|
232
290
|
- **`sync.PathSyncer(checksum=None, /, remove_missing=False,
|
|
233
|
-
follow_symlinks=True, hook=None,
|
|
234
|
-
checksum-driven tree
|
|
235
|
-
`
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
291
|
+
follow_symlinks=True, symlink_mode="preserve", hook=None,
|
|
292
|
+
ignore_error=False, quick_check=True)`** — one-way checksum-driven tree
|
|
293
|
+
sync between any two `Path` implementations. `checksum=None` (the
|
|
294
|
+
default) resolves to a policy that prefers each side's
|
|
295
|
+
`protocols.checksum.NativeChecksum.checksum()` (no network transfer
|
|
296
|
+
needed just to decide whether a copy is needed) over streaming, but only
|
|
297
|
+
trusts a native digest from one side if the OTHER side can also produce a
|
|
298
|
+
digest under the same algorithm (native or streamed) — otherwise BOTH
|
|
299
|
+
sides fall back to streaming (`utils.checksum.md5`/`stream`), never a
|
|
300
|
+
native-vs-streamed comparison under a mismatched algorithm. A
|
|
301
|
+
caller-supplied `checksum` callable disables this entirely and is invoked
|
|
302
|
+
exactly as before (once per side, compared with `==`). `quick_check=True`
|
|
303
|
+
(default) adds a metadata-only pre-check (`st_size` + `st_mtime`, already
|
|
304
|
+
cached, no extra round trip) for any pair where at least one side is
|
|
305
|
+
non-local (`Uri.is_local()`/DNS-lookup-failure-safe; a side without
|
|
306
|
+
`is_local()` at all is treated as local) — both matching skips the
|
|
307
|
+
checksum call entirely; either differing always falls through to a real
|
|
308
|
+
checksum (never concludes "changed" from metadata alone). Local-to-local
|
|
309
|
+
pairs never engage this pre-check regardless of the flag's value.
|
|
310
|
+
`quick_check=False` disables the pre-check entirely.
|
|
311
|
+
`.sync(source, target, /, dry_run=False, ignore_error=False)`
|
|
312
|
+
copies/creates in `target` whatever differs from `source`;
|
|
313
|
+
`remove_missing=True` also removes `target` entries absent from `source`.
|
|
314
|
+
`follow_symlinks=True` (default) resolves through a symlink source during
|
|
315
|
+
traversal exactly like content sync (unchanged). With
|
|
316
|
+
`follow_symlinks=False`, a symlink source is reported as such and
|
|
317
|
+
`symlink_mode` decides what happens: `"preserve"` (default) creates a
|
|
318
|
+
matching symlink on `target` using the exact raw, unresolved target
|
|
319
|
+
string `readlink()` returned — dangling links and relative targets
|
|
320
|
+
included, never validated or resolved against `source`'s parent;
|
|
321
|
+
`"reject"` raises `NotImplementedError` instead (the sole behavior before
|
|
322
|
+
this kwarg existed). If `target`'s implementation has no `symlink_to()`
|
|
323
|
+
at all (every backend except `LocalPath` and `SftpPath` — see
|
|
324
|
+
`docs/divergences.md`), `"preserve"` mode also raises
|
|
325
|
+
`NotImplementedError`, through the same `ignore_error`/`hook()` flow as
|
|
326
|
+
every other branch, not a silent skip. `hook`/`.log()`/subclassing
|
|
327
|
+
`.log()` are the progress/logging seams; `SyncEvent` enum names the
|
|
328
|
+
events fired (`SyncEvent.Symlink` covers symlink creation, replacement,
|
|
329
|
+
and the not-implemented/error path alike).
|
|
240
330
|
**`sync.PathAndStat`** — a `Path` + cached `stat()` (`None` if missing);
|
|
241
331
|
`is_*` attribute access delegates to the cached stat, returning a
|
|
242
332
|
false-returning callable when the path doesn't exist.
|
|
@@ -250,7 +340,13 @@ Subclass one of these with your own `root` fixture to verify a custom
|
|
|
250
340
|
st.is_dir` (no parens) is always truthy.
|
|
251
341
|
- **`checksum.md5(path, chunk_size=65536) -> str`** /
|
|
252
342
|
**`checksum.sha256(path, chunk_size=65536) -> str`** — streaming file
|
|
253
|
-
checksums over any `Path`.
|
|
343
|
+
checksums over any `Path`. **`checksum.stream(path, algorithm="md5",
|
|
344
|
+
chunk_size=65536) -> str`** — the generic (runtime `algorithm`) form of
|
|
345
|
+
the above, used by `PathSyncer`'s streaming fallback. **`checksum.native(
|
|
346
|
+
path, algorithm="md5") -> str | None`** — tries `path.checksum(algorithm)`
|
|
347
|
+
(`protocols.checksum.NativeChecksum`); returns `None` (never raises) if
|
|
348
|
+
`path` doesn't implement the protocol at all, or raises
|
|
349
|
+
`NotImplementedError` for `algorithm`.
|
|
254
350
|
- **`archive.make_archive(src, format, target)`** (`format` is `"zip"` or
|
|
255
351
|
`"tar"`) / **`archive.unpack_archive(archive, dest)`** (format
|
|
256
352
|
auto-detected from `archive.name`, falling back to magic-byte sniffing) —
|
|
@@ -124,11 +124,13 @@ class LocalPath(
|
|
|
124
124
|
preserve_metadata=True,
|
|
125
125
|
recursive=False,
|
|
126
126
|
ignore_error=None,
|
|
127
|
+
progress=None,
|
|
127
128
|
):
|
|
128
129
|
# Python 3.14 added pathlib.Path.copy(), which sits ahead of our
|
|
129
130
|
# generic implementation in the MRO and does not accept pathlib_next's
|
|
130
|
-
# overwrite=/recursive=/ignore_error= extensions. Keep
|
|
131
|
-
# cross-version contract stable by routing explicitly to
|
|
131
|
+
# overwrite=/recursive=/ignore_error=/progress= extensions. Keep
|
|
132
|
+
# LocalPath's cross-version contract stable by routing explicitly to
|
|
133
|
+
# our method.
|
|
132
134
|
return _proto.Path.copy(
|
|
133
135
|
self,
|
|
134
136
|
target,
|
|
@@ -137,6 +139,7 @@ class LocalPath(
|
|
|
137
139
|
preserve_metadata=preserve_metadata,
|
|
138
140
|
recursive=recursive,
|
|
139
141
|
ignore_error=ignore_error,
|
|
142
|
+
progress=progress,
|
|
140
143
|
)
|
|
141
144
|
|
|
142
145
|
def move(self, target, *, overwrite=False):
|
|
@@ -692,6 +692,7 @@ class Path(Pathname, Chmod, Stat, BinaryOpen):
|
|
|
692
692
|
preserve_metadata=True,
|
|
693
693
|
recursive=False,
|
|
694
694
|
ignore_error=None,
|
|
695
|
+
progress: "_ty.Callable[[_ty.Self, int, _ty.Optional[int]], None]" = None,
|
|
695
696
|
):
|
|
696
697
|
"""Copy this file's content to `target`.
|
|
697
698
|
|
|
@@ -710,6 +711,19 @@ class Path(Pathname, Chmod, Stat, BinaryOpen):
|
|
|
710
711
|
regardless of what it returns, so handlers like `errors.append`
|
|
711
712
|
(returning None) keep working. Only errors from *child* copies
|
|
712
713
|
during a `recursive=True` copy are routed here.
|
|
714
|
+
|
|
715
|
+
`progress`, when given, is called as `progress(path, bytes_copied,
|
|
716
|
+
total_size)` for every chunk written during each *file* copy (`path`
|
|
717
|
+
is the source `Path` being streamed -- `self` for a single-file
|
|
718
|
+
copy, or the relevant child during a `recursive=True` copy).
|
|
719
|
+
`bytes_copied` increases monotonically per file and reaches
|
|
720
|
+
`total_size` (or `None` if the size couldn't be determined) at the
|
|
721
|
+
end of that file. Directories themselves don't get a progress call
|
|
722
|
+
(only the files inside them do). With `progress=None` (the
|
|
723
|
+
default), behavior is unchanged -- no per-chunk overhead. Native
|
|
724
|
+
backend transfers that bypass the generic streaming copy (e.g.
|
|
725
|
+
`SftpPath`'s asyncssh concurrent fan-out) do not invoke `progress`;
|
|
726
|
+
see `docs/divergences.md`'s "Deliberate extensions" section.
|
|
713
727
|
"""
|
|
714
728
|
if isinstance(target, str):
|
|
715
729
|
target = type(self)(target)
|
|
@@ -732,6 +746,7 @@ class Path(Pathname, Chmod, Stat, BinaryOpen):
|
|
|
732
746
|
preserve_metadata=preserve_metadata,
|
|
733
747
|
recursive=True,
|
|
734
748
|
ignore_error=ignore_error,
|
|
749
|
+
progress=progress,
|
|
735
750
|
)
|
|
736
751
|
except Exception as e:
|
|
737
752
|
# A callable stays a notify-and-suppress hook (its return
|
|
@@ -752,7 +767,14 @@ class Path(Pathname, Chmod, Stat, BinaryOpen):
|
|
|
752
767
|
target.unlink()
|
|
753
768
|
else:
|
|
754
769
|
raise FileExistsError(target)
|
|
755
|
-
|
|
770
|
+
if progress is None:
|
|
771
|
+
BinaryOpen.copy(src, target)
|
|
772
|
+
else:
|
|
773
|
+
BinaryOpen.copy(
|
|
774
|
+
src,
|
|
775
|
+
target,
|
|
776
|
+
progress=lambda copied, total: progress(src, copied, total),
|
|
777
|
+
)
|
|
756
778
|
|
|
757
779
|
if preserve_metadata:
|
|
758
780
|
try:
|