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.
Files changed (120) hide show
  1. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/CHANGELOG.md +131 -1
  2. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/PKG-INFO +9 -2
  3. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/README.md +1 -1
  4. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/docs/benchmarks.md +24 -0
  5. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/docs/divergences.md +51 -0
  6. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/docs/index.md +1 -1
  7. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/pyproject.toml +2 -2
  8. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/AGENTS.md +122 -26
  9. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/fspath.py +5 -2
  10. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/path.py +23 -1
  11. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/protocols/__init__.py +1 -0
  12. pathlib_next-0.9.0/src/pathlib_next/protocols/checksum.py +76 -0
  13. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/protocols/io.py +36 -3
  14. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/uri/__init__.py +25 -2
  15. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/uri/schemes/ftp.py +3 -1
  16. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/uri/schemes/sftp/__init__.py +77 -2
  17. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/uri/schemes/sftp/_asyncssh.py +4 -1
  18. pathlib_next-0.9.0/src/pathlib_next/uri/schemes/sftp/_paramiko.py +238 -0
  19. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/uri/source.py +71 -10
  20. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/utils/__init__.py +0 -17
  21. pathlib_next-0.9.0/src/pathlib_next/utils/checksum.py +65 -0
  22. pathlib_next-0.9.0/src/pathlib_next/utils/sync.py +618 -0
  23. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/conftest.py +2 -6
  24. pathlib_next-0.9.0/tests/test_checksum.py +489 -0
  25. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_ftp.py +54 -1
  26. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_parity_io.py +105 -0
  27. pathlib_next-0.9.0/tests/test_sftp.py +952 -0
  28. pathlib_next-0.9.0/tests/test_source.py +151 -0
  29. pathlib_next-0.9.0/tests/test_sync.py +558 -0
  30. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_uri_parse.py +71 -0
  31. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_utils.py +0 -18
  32. pathlib_next-0.8.6/src/pathlib_next/uri/schemes/sftp/_paramiko.py +0 -116
  33. pathlib_next-0.8.6/src/pathlib_next/utils/checksum.py +0 -31
  34. pathlib_next-0.8.6/src/pathlib_next/utils/sync.py +0 -361
  35. pathlib_next-0.8.6/tests/test_sftp.py +0 -430
  36. pathlib_next-0.8.6/tests/test_source.py +0 -62
  37. pathlib_next-0.8.6/tests/test_sync.py +0 -272
  38. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/.gitignore +0 -0
  39. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/LICENSE +0 -0
  40. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/docs/api/mempath.md +0 -0
  41. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/docs/api/path.md +0 -0
  42. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/docs/api/testing.md +0 -0
  43. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/docs/api/uri.md +0 -0
  44. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/docs/api/utils.md +0 -0
  45. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/docs/changelog.md +0 -0
  46. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/docs/guides/cli.md +0 -0
  47. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/docs/guides/extending.md +0 -0
  48. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/docs/guides/schemes.md +0 -0
  49. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/examples/az_listing.py +0 -0
  50. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/examples/data_and_archive.py +0 -0
  51. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/examples/ftp_listing.py +0 -0
  52. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/examples/github_listing.py +0 -0
  53. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/examples/gitlab_listing.py +0 -0
  54. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/examples/gs_listing.py +0 -0
  55. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/examples/http_listing.py +0 -0
  56. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/examples/local_and_mem.py +0 -0
  57. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/examples/s3_listing.py +0 -0
  58. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/examples/sftp_sync.py +0 -0
  59. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/examples/webdav_roundtrip.py +0 -0
  60. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/mkdocs.yml +0 -0
  61. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/__init__.py +0 -0
  62. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/mempath.py +0 -0
  63. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/protocols/fs.py +0 -0
  64. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/py.typed +0 -0
  65. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/testing.py +0 -0
  66. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/tools/__init__.py +0 -0
  67. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/tools/uripath.py +0 -0
  68. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/uri/query.py +0 -0
  69. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/uri/schemes/__init__.py +0 -0
  70. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/uri/schemes/_gitrepo.py +0 -0
  71. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/uri/schemes/archive/__init__.py +0 -0
  72. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/uri/schemes/archive/_base.py +0 -0
  73. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/uri/schemes/archive/tar.py +0 -0
  74. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/uri/schemes/archive/zip.py +0 -0
  75. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/uri/schemes/az.py +0 -0
  76. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/uri/schemes/data.py +0 -0
  77. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/uri/schemes/dav.py +0 -0
  78. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/uri/schemes/file.py +0 -0
  79. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/uri/schemes/git/__init__.py +0 -0
  80. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/uri/schemes/git/_base.py +0 -0
  81. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/uri/schemes/git/github.py +0 -0
  82. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/uri/schemes/git/gitlab.py +0 -0
  83. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/uri/schemes/github.py +0 -0
  84. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/uri/schemes/gitlab.py +0 -0
  85. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/uri/schemes/gs.py +0 -0
  86. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/uri/schemes/http.py +0 -0
  87. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/uri/schemes/s3.py +0 -0
  88. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/uri/schemes/sftp/_sshconfig.py +0 -0
  89. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/utils/archive.py +0 -0
  90. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/utils/glob.py +0 -0
  91. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/src/pathlib_next/utils/stat.py +0 -0
  92. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_archive_uri.py +0 -0
  93. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_az.py +0 -0
  94. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_az_fake.py +0 -0
  95. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_contract.py +0 -0
  96. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_data_uri.py +0 -0
  97. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_dav.py +0 -0
  98. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_gitrepo.py +0 -0
  99. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_glob.py +0 -0
  100. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_gs.py +0 -0
  101. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_gs_fake.py +0 -0
  102. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_http.py +0 -0
  103. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_http_live.py +0 -0
  104. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_http_parser.py +0 -0
  105. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_local.py +0 -0
  106. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_mempath.py +0 -0
  107. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_mro_precedence.py +0 -0
  108. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_parity_pure.py +0 -0
  109. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_path_gaps.py +0 -0
  110. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_pathname.py +0 -0
  111. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_plugins.py +0 -0
  112. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_properties.py +0 -0
  113. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_query.py +0 -0
  114. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_s3.py +0 -0
  115. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_sftp_asyncssh.py +0 -0
  116. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_smoke.py +0 -0
  117. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_uri_path.py +0 -0
  118. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_uripath_tool.py +0 -0
  119. {pathlib_next-0.8.6 → pathlib_next-0.9.0}/tests/test_walk.py +0 -0
  120. {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
- ## [Unreleased]
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.8.6
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.8.6"
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 CPython 3.14's
69
- `Path.copy()`; `overwrite` is this library's own extension (3.14 always
70
- raises if the destination exists). `preserve_metadata` defaults `True`
71
- here (3.14 defaults `False`) and only preserves `st_mode`, not
72
- timestamps/xattrs. `ignore_error`, when given, receives exceptions
73
- instead of raising (same contract as `rm()`'s callable form); `None`
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)` (streams this object's binary
132
- content into another `BinaryOpen`).
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__()` only succeeds for a `file:`-scheme URI pointing
155
- at this machine; otherwise raises `NotImplementedError`.
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`. `Source.from_str(source, strict=True) -> Source`
180
- (`strict=True` raises `ValueError` if `source` carries a path/query/
181
- fragment). `parsed_userinfo() -> (user, password)`. `get_scheme_cls(
182
- schemesmap=None) -> type[UriPath]` — resolves (and lazily loads) the
183
- scheme class. `is_local()` — DNS lookup, `lru_cache(maxsize=256)`d per
184
- `Source` value; never call on a hot path uncached.
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, ignore_error=False)`** — one-way
234
- checksum-driven tree sync between any two `Path` implementations.
235
- `checksum` defaults to `utils.checksum.md5`. `.sync(source, target, /,
236
- dry_run=False, ignore_error=False)` copies/creates in `target` whatever
237
- differs from `source`; `remove_missing=True` also removes `target`
238
- entries absent from `source`. `hook`/`.log()`/subclassing `.log()` are the
239
- progress/logging seams; `SyncEvent` enum names the events fired.
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 LocalPath's
131
- # cross-version contract stable by routing explicitly to our method.
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
- BinaryOpen.copy(src, target)
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:
@@ -1,2 +1,3 @@
1
1
  from .fs import *
2
2
  from .io import *
3
+ from .checksum import *