pathlib-next 0.8.6__tar.gz → 0.9.1__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 (121) hide show
  1. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/CHANGELOG.md +168 -15
  2. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/PKG-INFO +23 -15
  3. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/README.md +10 -10
  4. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/docs/benchmarks.md +24 -0
  5. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/docs/divergences.md +54 -0
  6. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/docs/index.md +1 -1
  7. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/mkdocs.yml +3 -3
  8. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/pyproject.toml +20 -7
  9. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/src/pathlib_next/AGENTS.md +149 -29
  10. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/src/pathlib_next/fspath.py +50 -3
  11. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/src/pathlib_next/mempath.py +1 -3
  12. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/src/pathlib_next/path.py +90 -2
  13. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/src/pathlib_next/protocols/__init__.py +1 -0
  14. pathlib_next-0.9.1/src/pathlib_next/protocols/checksum.py +76 -0
  15. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/src/pathlib_next/protocols/fs.py +62 -2
  16. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/src/pathlib_next/protocols/io.py +36 -3
  17. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/src/pathlib_next/testing.py +4 -3
  18. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/src/pathlib_next/uri/__init__.py +30 -3
  19. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/src/pathlib_next/uri/query.py +1 -3
  20. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/src/pathlib_next/uri/schemes/archive/_base.py +3 -1
  21. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/src/pathlib_next/uri/schemes/archive/tar.py +3 -1
  22. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/src/pathlib_next/uri/schemes/archive/zip.py +13 -6
  23. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/src/pathlib_next/uri/schemes/az.py +11 -2
  24. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/src/pathlib_next/uri/schemes/dav.py +9 -4
  25. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/src/pathlib_next/uri/schemes/file.py +14 -2
  26. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/src/pathlib_next/uri/schemes/ftp.py +11 -7
  27. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/src/pathlib_next/uri/schemes/git/__init__.py +0 -1
  28. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/src/pathlib_next/uri/schemes/git/github.py +0 -1
  29. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/src/pathlib_next/uri/schemes/git/gitlab.py +0 -1
  30. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/src/pathlib_next/uri/schemes/github.py +9 -2
  31. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/src/pathlib_next/uri/schemes/gitlab.py +6 -4
  32. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/src/pathlib_next/uri/schemes/gs.py +6 -2
  33. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/src/pathlib_next/uri/schemes/http.py +109 -90
  34. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/src/pathlib_next/uri/schemes/s3.py +3 -1
  35. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/src/pathlib_next/uri/schemes/sftp/__init__.py +116 -10
  36. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/src/pathlib_next/uri/schemes/sftp/_asyncssh.py +44 -9
  37. pathlib_next-0.9.1/src/pathlib_next/uri/schemes/sftp/_paramiko.py +239 -0
  38. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/src/pathlib_next/uri/source.py +77 -12
  39. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/src/pathlib_next/utils/__init__.py +66 -12
  40. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/src/pathlib_next/utils/archive.py +10 -2
  41. pathlib_next-0.9.1/src/pathlib_next/utils/checksum.py +65 -0
  42. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/src/pathlib_next/utils/glob.py +1 -4
  43. pathlib_next-0.9.1/src/pathlib_next/utils/sync.py +617 -0
  44. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/tests/conftest.py +31 -26
  45. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/tests/test_archive_uri.py +14 -4
  46. pathlib_next-0.9.1/tests/test_checksum.py +489 -0
  47. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/tests/test_contract.py +10 -1
  48. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/tests/test_data_uri.py +1 -0
  49. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/tests/test_dav.py +1 -0
  50. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/tests/test_ftp.py +55 -1
  51. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/tests/test_gitrepo.py +9 -2
  52. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/tests/test_glob.py +1 -0
  53. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/tests/test_http.py +9 -3
  54. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/tests/test_http_live.py +1 -0
  55. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/tests/test_http_parser.py +12 -3
  56. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/tests/test_local.py +10 -8
  57. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/tests/test_mro_precedence.py +13 -1
  58. pathlib_next-0.9.1/tests/test_parity_io.py +497 -0
  59. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/tests/test_parity_pure.py +9 -9
  60. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/tests/test_path_gaps.py +66 -0
  61. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/tests/test_plugins.py +10 -1
  62. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/tests/test_properties.py +51 -13
  63. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/tests/test_s3.py +1 -0
  64. pathlib_next-0.9.1/tests/test_sftp.py +1043 -0
  65. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/tests/test_sftp_asyncssh.py +32 -12
  66. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/tests/test_smoke.py +5 -2
  67. pathlib_next-0.9.1/tests/test_source.py +149 -0
  68. pathlib_next-0.9.1/tests/test_sync.py +558 -0
  69. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/tests/test_uri_parse.py +71 -0
  70. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/tests/test_uri_path.py +0 -1
  71. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/tests/test_utils.py +20 -35
  72. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/tests/test_walk.py +24 -6
  73. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/tests/test_webdav.py +2 -0
  74. pathlib_next-0.8.6/src/pathlib_next/uri/schemes/sftp/_paramiko.py +0 -116
  75. pathlib_next-0.8.6/src/pathlib_next/utils/checksum.py +0 -31
  76. pathlib_next-0.8.6/src/pathlib_next/utils/sync.py +0 -361
  77. pathlib_next-0.8.6/tests/test_parity_io.py +0 -232
  78. pathlib_next-0.8.6/tests/test_sftp.py +0 -430
  79. pathlib_next-0.8.6/tests/test_source.py +0 -62
  80. pathlib_next-0.8.6/tests/test_sync.py +0 -272
  81. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/.gitignore +0 -0
  82. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/LICENSE +0 -0
  83. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/docs/api/mempath.md +0 -0
  84. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/docs/api/path.md +0 -0
  85. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/docs/api/testing.md +0 -0
  86. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/docs/api/uri.md +0 -0
  87. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/docs/api/utils.md +0 -0
  88. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/docs/changelog.md +0 -0
  89. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/docs/guides/cli.md +0 -0
  90. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/docs/guides/extending.md +0 -0
  91. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/docs/guides/schemes.md +0 -0
  92. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/examples/az_listing.py +0 -0
  93. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/examples/data_and_archive.py +0 -0
  94. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/examples/ftp_listing.py +0 -0
  95. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/examples/github_listing.py +0 -0
  96. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/examples/gitlab_listing.py +0 -0
  97. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/examples/gs_listing.py +0 -0
  98. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/examples/http_listing.py +0 -0
  99. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/examples/local_and_mem.py +0 -0
  100. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/examples/s3_listing.py +0 -0
  101. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/examples/sftp_sync.py +0 -0
  102. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/examples/webdav_roundtrip.py +0 -0
  103. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/src/pathlib_next/__init__.py +0 -0
  104. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/src/pathlib_next/py.typed +0 -0
  105. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/src/pathlib_next/tools/__init__.py +0 -0
  106. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/src/pathlib_next/tools/uripath.py +0 -0
  107. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/src/pathlib_next/uri/schemes/__init__.py +0 -0
  108. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/src/pathlib_next/uri/schemes/_gitrepo.py +0 -0
  109. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/src/pathlib_next/uri/schemes/archive/__init__.py +0 -0
  110. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/src/pathlib_next/uri/schemes/data.py +0 -0
  111. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/src/pathlib_next/uri/schemes/git/_base.py +0 -0
  112. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/src/pathlib_next/uri/schemes/sftp/_sshconfig.py +0 -0
  113. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/src/pathlib_next/utils/stat.py +0 -0
  114. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/tests/test_az.py +0 -0
  115. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/tests/test_az_fake.py +0 -0
  116. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/tests/test_gs.py +0 -0
  117. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/tests/test_gs_fake.py +0 -0
  118. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/tests/test_mempath.py +0 -0
  119. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/tests/test_pathname.py +0 -0
  120. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/tests/test_query.py +0 -0
  121. {pathlib_next-0.8.6 → pathlib_next-0.9.1}/tests/test_uripath_tool.py +0 -0
@@ -5,7 +5,159 @@ 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.1] - 2026-08-04
9
+
10
+ ### Added
11
+ - **`symlink_to(..., force=True)`** — replace an existing entry at the link
12
+ path instead of failing. SFTP has no atomic "replace symlink", so every
13
+ consumer was re-implementing the unlink-then-symlink dance. Implemented at
14
+ the `Path` layer over a new `_symlink_to()` backend primitive (the same
15
+ wrapper/primitive split as `_mkdir`/`mkdir`), so all backends inherit it.
16
+ Removes a **non-directory** entry only, and is documented as non-atomic.
17
+ - **`chown(uid=None, gid=None)`** over a new `_chown()` backend primitive.
18
+ `chown` was the one POSIX permission attribute `stat()` could read but
19
+ nothing could write back. `None` means "leave unchanged"; the normalization
20
+ lives on `Path` so each backend receives an already-canonical pair rather
21
+ than re-deriving the mapping (`os.chown` wants `-1`, SFTP omits the field).
22
+ - **`chmod()` accepts a string octal mode** (`"0755"`), normalized with an
23
+ explicit base-8 parse. A mode string outside `[0-7]` raises rather than
24
+ being coerced — `int("0755")` in *decimal* is a different mode, which is
25
+ exactly the wrong-but-plausible failure this guards.
26
+
27
+ ### Changed
28
+ - Adopted `black`, pinned to the 3.9 floor; `src/` and `tests/` reformatted.
29
+
30
+ ## [0.9.0] - 2026-07-29
31
+
32
+ ### Added
33
+ - **`UriPath.host_fspath()`**, and `__fspath__()` now succeeds for schemes
34
+ with `_host_filesystem_path = True` (currently `sftp:`) instead of
35
+ unconditionally raising `NotImplementedError` for any non-`file:`
36
+ scheme. `os.fspath()` has two consumers -- "open this locally" (where a
37
+ remote path would silently read the wrong file) and "build a command
38
+ line for a process that runs on the path's host" (`subprocess`, remote
39
+ executors) -- and only the second is safe for a scheme like `sftp:`.
40
+ `host_fspath()` is the unambiguous accessor for that second case: it
41
+ never falls back to local-path semantics the way `__fspath__` does for
42
+ the `file:` branch.
43
+ - **`progress` callback for `Path.copy()`/`BinaryOpen.copy()`.** `BinaryOpen.copy(target, *, progress=None, chunk_size=shutil.COPY_BUFSIZE)`
44
+ now streams in caller-sized chunks and, when `progress` is given, calls
45
+ `progress(bytes_copied, total_size)` after each chunk (`total_size` is the
46
+ source's `stat().st_size` when available, else `None`). `Path.copy(target,
47
+ ..., progress=None)` wraps this with per-file identity:
48
+ `progress(path, bytes_copied, total_size)`, so a `recursive=True` copy
49
+ reports which file is being streamed alongside its byte progress, not just
50
+ an anonymous byte stream. `progress=None` (the default) is behaviorally
51
+ identical to before this change -- no per-chunk overhead, same
52
+ `shutil.copyfileobj` bytes-on-wire. `SftpPath`'s asyncssh concurrent
53
+ fan-out (`copy(recursive=True)`) does not invoke `progress` -- native/batch
54
+ transfer paths are out of scope for this first cut (see
55
+ `docs/divergences.md`'s "Deliberate extensions" section).
56
+ - **`PathSyncer` can now create symlinks on `target` instead of always
57
+ rejecting a symlink source.** New constructor kwarg `symlink_mode`
58
+ (`"preserve"` default, `"reject"` opt-out), consulted only when
59
+ `follow_symlinks=False` and `source.is_symlink()` (with the default
60
+ `follow_symlinks=True`, symlinks are still resolved during traversal,
61
+ unchanged). `"preserve"` creates a matching symlink on `target` using the
62
+ exact raw, unresolved target string `readlink()` returned -- dangling
63
+ links and relative targets included, never validated or resolved against
64
+ `source`'s parent. If `target`'s implementation has no `symlink_to()` at
65
+ all (every backend except `LocalPath` and `SftpPath`), `"preserve"` mode
66
+ raises `NotImplementedError` through the existing `ignore_error`/`hook()`
67
+ flow, same as every other sync branch -- not a silent skip. New
68
+ `SyncEvent.Symlink` enum member.
69
+ - **Optional backend-native checksum protocol**
70
+ (`pathlib_next.protocols.checksum.NativeChecksum`,
71
+ `checksum(algorithm="md5") -> str`). A `Path` subclass may implement it to
72
+ compute a file digest server-side instead of streaming the content
73
+ through `open("rb")` -- implemented on `SftpPath` against the OpenSSH
74
+ `check-file@openssh.com` SFTP protocol extension (paramiko backend only;
75
+ the asyncssh backend has no equivalent client-library support and
76
+ correctly falls back). Not part of the base `Path`/`Pathname` ABC -- a
77
+ plain `Path` has no `.checksum` attribute at all. Implementations MUST
78
+ raise `NotImplementedError` (never return a value) when they can't
79
+ produce a genuine digest under the requested algorithm -- this is what
80
+ keeps two checksums from ever being compared under a mismatched
81
+ algorithm, or trusting something hash-shaped but not a real content hash
82
+ (e.g. S3's ETag for a multipart upload, deliberately not implemented here
83
+ for exactly that reason -- see `docs/divergences.md`). Also adds a
84
+ companion `supported_checksums() -> frozenset[str]` advisory query
85
+ (default `frozenset()`, never raises); `SftpPath.supported_checksums()`
86
+ is a real per-connection probe against the server (paramiko exposes no
87
+ cheaper way to know), cached per connection.
88
+ - **`PathSyncer`'s default checksum policy now prefers native digests on
89
+ both sides when available**, falling back to streaming
90
+ (`utils.checksum.md5`/the new generic `utils.checksum.stream`) when
91
+ either side can't produce one under the same algorithm -- never a
92
+ native-vs-streamed comparison under a mismatched algorithm. A
93
+ caller-supplied `checksum` callable is unaffected (invoked exactly as
94
+ before). New `utils.checksum.native(path, algorithm) -> str | None`
95
+ helper: tries the protocol, returns `None` (never raises) if unsupported.
96
+ - **`PathSyncer(..., quick_check=True)`** (new constructor kwarg, default
97
+ `True`): for a sync pair where at least one side is non-local, a
98
+ metadata-only pre-check (`st_size` + `st_mtime`, already-cached, no extra
99
+ round trip) skips the checksum call entirely when both match; a mismatch
100
+ always falls through to a real checksum rather than being treated as
101
+ "changed" on its own. Local-to-local pairs never engage this pre-check.
102
+ `quick_check=False` restores always-checksum behavior for non-local pairs.
103
+
104
+ ### Changed
105
+ - **`PathSyncer` default behavior change**: `PathSyncer(follow_symlinks=
106
+ False).sync()` on a symlink source previously always raised
107
+ `NotImplementedError`. It now creates a matching symlink on `target` by
108
+ default (`symlink_mode="preserve"`); pass `symlink_mode="reject"` to
109
+ restore the old unconditional-raise behavior exactly.
110
+ - **The `uri` extra now requires `netimps>=0.2.0`** (alongside `uritools`).
111
+ `Source.is_local()` (`uri.source`) delegates its "is this address mine"
112
+ check to `netimps.is_local_address()`, which enumerates real network
113
+ interfaces (`netimps.get_interfaces()`) instead of the previous
114
+ `socket.getaddrinfo(socket.gethostname(), None)`-based approach, which
115
+ missed addresses not tied to the resolvable hostname (VMs, containers,
116
+ VPN interfaces, additional NICs on a multi-homed host). The
117
+ hostname->address step now uses `netimps.resolve()` (both `"a"`/`"aaaa"`
118
+ record types; `host` is local if ANY resolved address is) instead of
119
+ `socket.gethostbyname()` -- `resolve()`'s default backend chain
120
+ (dnspython, then the OS resolver via `getaddrinfo()`, then `nslookup`)
121
+ covers the same hosts-file/NSS/DNS resolution `gethostbyname()` did, and
122
+ additionally never raises for a name that simply doesn't resolve
123
+ (`resolve()`'s contract: always a list, empty on genuine failure) --
124
+ `gethostbyname()` raised `socket.gaierror` for that case. `utils.
125
+ get_machine_ips()` (the old implementation's helper, unused elsewhere in
126
+ this library) is removed. `SftpPath`'s two backends and `FtpPath` now
127
+ resolve their default ports (22, 21) via `netimps.get_default_port()`
128
+ instead of three separately hardcoded literals.
129
+
130
+ ### Fixed
131
+ - **`Source.is_local()` crashed on a bare IPv6-literal `host` string.**
132
+ `Source(scheme, userinfo, "::1", port)` (a supported direct-construction
133
+ pattern -- `Source`'s fields are public `NamedTuple` fields) raised
134
+ `socket.gaierror` instead of returning a result, because
135
+ `socket.gethostbyname()` (this method's original hostname-resolution
136
+ step) is IPv4-only. `host` bracket-literals arriving via the normal
137
+ `Source.from_str()`/`Uri()` construction path were unaffected
138
+ (`_decode_host()` already parses those into a real `IPv6Address` before
139
+ `is_local()` ever sees a string). `is_local()` now tries
140
+ `netimps.try_parse()` first (handles any IP literal directly, string or
141
+ not) before falling through to hostname resolution.
142
+ - **`uri.source.Source` leaked the password in `str()`/`repr()`.**
143
+ `Source.__str__()` called `uricompose()` with the raw `userinfo`
144
+ (password included) -- a genuinely valid, connectable URI string, not
145
+ just a debug rendering -- and `Source` had no custom `__repr__` at all,
146
+ so the `NamedTuple` default rendered every field verbatim too. `repr()`
147
+ is what a traceback frame renders, so a `Source` anywhere on a failing
148
+ call stack leaked the credential into logs, even though `Uri.__str__()`
149
+ already redacted. Both now redact the password from `userinfo` the same
150
+ way `Uri.__str__()` does; the actual data (`.userinfo`,
151
+ `.parsed_userinfo()`, `["userinfo"]`) is unaffected, only display is
152
+ sanitized. **This is a behavior change, not purely additive**:
153
+ `str(source)` (or `f"{source}"`) no longer reconstructs an authenticated
154
+ URI -- verified nothing in this codebase relied on that (every real
155
+ connection site reads individual `Source` fields, never whole-object
156
+ `str()`), but a downstream caller that did would need the new
157
+ **`Source.as_str(sanitize=True)`** method instead: `sanitize=True` (the
158
+ default, matching `__str__`) redacts the password; `sanitize=False` is
159
+ the full, credentialed round trip -- same name/kwarg as
160
+ `Uri.as_uri(sanitize=)`, so both classes work the same way.
9
161
 
10
162
  ## [0.8.6] - 2026-07-26
11
163
 
@@ -580,17 +732,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
580
732
  - Sync error handling.
581
733
  - Generic Path Protocol based pathlib implementation for URI paths with file access support for sftp, http, file schemes.
582
734
 
583
- [Unreleased]: https://github.com/jose-pr/pathlib_next/compare/v0.8.6...HEAD
584
- [0.8.6]: https://github.com/jose-pr/pathlib_next/compare/v0.8.5...v0.8.6
585
- [0.8.5]: https://github.com/jose-pr/pathlib_next/compare/v0.8.4...v0.8.5
586
- [0.8.4]: https://github.com/jose-pr/pathlib_next/compare/v0.8.3...v0.8.4
587
- [0.8.3]: https://github.com/jose-pr/pathlib_next/compare/v0.8.2...v0.8.3
588
- [0.8.2]: https://github.com/jose-pr/pathlib_next/compare/v0.8.1...v0.8.2
589
- [0.8.1]: https://github.com/jose-pr/pathlib_next/compare/v0.8.0...v0.8.1
590
- [0.8.0]: https://github.com/jose-pr/pathlib_next/compare/v0.7.0...v0.8.0
591
- [0.7.0]: https://github.com/jose-pr/pathlib_next/compare/v0.6.0...v0.7.0
592
- [0.6.0]: https://github.com/jose-pr/pathlib_next/compare/v0.5.0...v0.6.0
593
- [0.5.0]: https://github.com/jose-pr/pathlib_next/compare/v0.4.1...v0.5.0
594
- [0.4.1]: https://github.com/jose-pr/pathlib_next/compare/v0.4.0...v0.4.1
595
- [0.4.0]: https://github.com/jose-pr/pathlib_next/releases/tag/v0.4.0
596
- [0.3.5]: https://github.com/jose-pr/pathlib_next/releases/tag/v0.3.5
735
+ [Unreleased]: https://github.com/jose-pr/pathlib-next/compare/v0.9.1...HEAD
736
+ [0.9.1]: https://github.com/jose-pr/pathlib-next/compare/v0.9.0...v0.9.1
737
+ [0.8.6]: https://github.com/jose-pr/pathlib-next/compare/v0.8.5...v0.8.6
738
+ [0.8.5]: https://github.com/jose-pr/pathlib-next/compare/v0.8.4...v0.8.5
739
+ [0.8.4]: https://github.com/jose-pr/pathlib-next/compare/v0.8.3...v0.8.4
740
+ [0.8.3]: https://github.com/jose-pr/pathlib-next/compare/v0.8.2...v0.8.3
741
+ [0.8.2]: https://github.com/jose-pr/pathlib-next/compare/v0.8.1...v0.8.2
742
+ [0.8.1]: https://github.com/jose-pr/pathlib-next/compare/v0.8.0...v0.8.1
743
+ [0.8.0]: https://github.com/jose-pr/pathlib-next/compare/v0.7.0...v0.8.0
744
+ [0.7.0]: https://github.com/jose-pr/pathlib-next/compare/v0.6.0...v0.7.0
745
+ [0.6.0]: https://github.com/jose-pr/pathlib-next/compare/v0.5.0...v0.6.0
746
+ [0.5.0]: https://github.com/jose-pr/pathlib-next/compare/v0.4.1...v0.5.0
747
+ [0.4.1]: https://github.com/jose-pr/pathlib-next/compare/v0.4.0...v0.4.1
748
+ [0.4.0]: https://github.com/jose-pr/pathlib-next/releases/tag/v0.4.0
749
+ [0.3.5]: https://github.com/jose-pr/pathlib-next/releases/tag/v0.3.5
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.4
2
- Name: pathlib_next
3
- Version: 0.8.6
2
+ Name: pathlib-next
3
+ Version: 0.9.1
4
4
  Summary: Generic Path Protocol based pathlib
5
- Project-URL: Homepage, https://github.com/jose-pr/pathlib_next/
6
- Project-URL: Documentation, https://jose-pr.github.io/pathlib_next/
7
- Project-URL: Issues, https://github.com/jose-pr/pathlib_next/issues
5
+ Project-URL: Homepage, https://github.com/jose-pr/pathlib-next/
6
+ Project-URL: Documentation, https://jose-pr.github.io/pathlib-next/
7
+ Project-URL: Issues, https://github.com/jose-pr/pathlib-next/issues
8
8
  Author: Jose A
9
9
  License-File: LICENSE
10
10
  Classifier: License :: OSI Approved :: MIT License
@@ -13,8 +13,10 @@ 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
19
+ Requires-Dist: black; extra == 'dev'
18
20
  Requires-Dist: build; extra == 'dev'
19
21
  Requires-Dist: cheroot; extra == 'dev'
20
22
  Requires-Dist: hatchling; extra == 'dev'
@@ -31,31 +33,37 @@ Requires-Dist: mkdocs-material; extra == 'docs'
31
33
  Requires-Dist: mkdocstrings[python]; extra == 'docs'
32
34
  Provides-Extra: gs
33
35
  Requires-Dist: google-cloud-storage; extra == 'gs'
36
+ Requires-Dist: netimps>=0.2.0; extra == 'gs'
34
37
  Requires-Dist: uritools; extra == 'gs'
35
38
  Provides-Extra: http
39
+ Requires-Dist: netimps>=0.2.0; extra == 'http'
36
40
  Requires-Dist: requests; extra == 'http'
37
41
  Requires-Dist: uritools; extra == 'http'
38
42
  Provides-Extra: s3
39
43
  Requires-Dist: boto3; extra == 's3'
44
+ Requires-Dist: netimps>=0.2.0; extra == 's3'
40
45
  Requires-Dist: uritools; extra == 's3'
41
46
  Provides-Extra: sftp
47
+ Requires-Dist: netimps>=0.2.0; extra == 'sftp'
42
48
  Requires-Dist: paramiko; extra == 'sftp'
43
49
  Requires-Dist: uritools; extra == 'sftp'
44
50
  Provides-Extra: sftp-async
45
51
  Requires-Dist: asyncssh; (python_version >= '3.10') and extra == 'sftp-async'
46
52
  Requires-Dist: asyncssh<2.22; (python_version < '3.10') and extra == 'sftp-async'
53
+ Requires-Dist: netimps>=0.2.0; extra == 'sftp-async'
47
54
  Requires-Dist: uritools; extra == 'sftp-async'
48
55
  Provides-Extra: uri
56
+ Requires-Dist: netimps>=0.2.0; extra == 'uri'
49
57
  Requires-Dist: uritools; extra == 'uri'
50
58
  Description-Content-Type: text/markdown
51
59
 
52
60
  # pathlib_next
53
61
 
54
- [![Version](https://img.shields.io/pypi/v/pathlib_next.svg)](https://pypi.org/project/pathlib_next/)
55
- [![Python versions](https://img.shields.io/pypi/pyversions/pathlib_next.svg)](https://pypi.org/project/pathlib_next/)
62
+ [![Version](https://img.shields.io/pypi/v/pathlib_next.svg)](https://pypi.org/project/pathlib-next/)
63
+ [![Python versions](https://img.shields.io/pypi/pyversions/pathlib_next.svg)](https://pypi.org/project/pathlib-next/)
56
64
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
57
- [![Docs](https://img.shields.io/badge/docs-latest-blue.svg)](https://jose-pr.github.io/pathlib_next/)
58
- [![CI](https://img.shields.io/github/actions/workflow/status/jose-pr/pathlib_next/test.yml)](https://github.com/jose-pr/pathlib_next/actions/workflows/test.yml)
65
+ [![Docs](https://img.shields.io/badge/docs-latest-blue.svg)](https://jose-pr.github.io/pathlib-next/)
66
+ [![CI](https://img.shields.io/github/actions/workflow/status/jose-pr/pathlib_next/test.yml)](https://github.com/jose-pr/pathlib-next/actions/workflows/test.yml)
59
67
 
60
68
  A **robust, extensible pathlib-like base** for any resource addressable as a
61
69
  path or URI. Same method names, signatures, semantics, and exception types as
@@ -63,7 +71,7 @@ path or URI. Same method names, signatures, semantics, and exception types as
63
71
  against `Path`/`UriPath` and it works against your local disk, an in-memory
64
72
  tree, an HTTP index, or an SFTP server. Every intentional divergence from
65
73
  `pathlib`'s behavior is documented, not silent -- see
66
- [`docs/divergences.md`](https://jose-pr.github.io/pathlib_next/divergences/).
74
+ [`docs/divergences.md`](https://jose-pr.github.io/pathlib-next/divergences/).
67
75
 
68
76
  ## Features
69
77
 
@@ -81,7 +89,7 @@ tree, an HTTP index, or an SFTP server. Every intentional divergence from
81
89
 
82
90
  Every scheme shares the same `glob()`, `walk()`, `copy()`/`move()`, `rm()`
83
91
  implementations -- see the full matrix and notes in
84
- [Schemes](https://jose-pr.github.io/pathlib_next/guides/schemes/).
92
+ [Schemes](https://jose-pr.github.io/pathlib-next/guides/schemes/).
85
93
 
86
94
  - **Unified path interface** across local files, in-memory paths, archive
87
95
  members, and `file`/`data`/`ftp`/`http`/`dav`/`sftp`/`s3` URIs.
@@ -93,7 +101,7 @@ implementations -- see the full matrix and notes in
93
101
  authority components.
94
102
  - **Extensible two ways**: subclass `Path` directly for a custom
95
103
  non-URI resource, or subclass `UriPath` for a new URI scheme -- see
96
- [Extending](https://jose-pr.github.io/pathlib_next/guides/extending/).
104
+ [Extending](https://jose-pr.github.io/pathlib-next/guides/extending/).
97
105
 
98
106
  ## Installation
99
107
 
@@ -105,7 +113,7 @@ Optional features/extras:
105
113
 
106
114
  | Extra/flag | Adds | Needed for |
107
115
  | --- | --- | --- |
108
- | `uri` | `uritools` | URI parsing (any `UriPath` scheme) |
116
+ | `uri` | `uritools`, `netimps` | URI parsing (any `UriPath` scheme) |
109
117
  | `http` | `requests` | `http(s):` and `dav(s):` (WebDAV) paths |
110
118
  | `sftp` | `paramiko` | `sftp:` path operations and transfers (sync backend) |
111
119
  | `sftp-async` | `asyncssh` | `sftp:` path operations via the asyncssh backend instead (see `guides/schemes.md`'s `sftp:` row for selection precedence) |
@@ -179,13 +187,13 @@ print(member.read_text())
179
187
  Also built in: `data:` (RFC 2397 inline payloads), `ftp(s):` (stdlib
180
188
  `ftplib`), `dav(s):` (WebDAV, full read/write over HTTP), and `s3:`
181
189
  (`boto3`) -- one example per scheme in
182
- [Schemes](https://jose-pr.github.io/pathlib_next/guides/schemes/).
190
+ [Schemes](https://jose-pr.github.io/pathlib-next/guides/schemes/).
183
191
 
184
192
  ## Extending
185
193
 
186
194
  Two first-class ways to add a new path-addressable resource -- both covered
187
195
  in depth, with worked examples, in
188
- [Extending](https://jose-pr.github.io/pathlib_next/guides/extending/):
196
+ [Extending](https://jose-pr.github.io/pathlib-next/guides/extending/):
189
197
 
190
198
  - Subclass `Path` directly for a custom, non-URI resource (`MemPath` is the
191
199
  reference exemplar).
@@ -1,10 +1,10 @@
1
1
  # pathlib_next
2
2
 
3
- [![Version](https://img.shields.io/pypi/v/pathlib_next.svg)](https://pypi.org/project/pathlib_next/)
4
- [![Python versions](https://img.shields.io/pypi/pyversions/pathlib_next.svg)](https://pypi.org/project/pathlib_next/)
3
+ [![Version](https://img.shields.io/pypi/v/pathlib_next.svg)](https://pypi.org/project/pathlib-next/)
4
+ [![Python versions](https://img.shields.io/pypi/pyversions/pathlib_next.svg)](https://pypi.org/project/pathlib-next/)
5
5
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
- [![Docs](https://img.shields.io/badge/docs-latest-blue.svg)](https://jose-pr.github.io/pathlib_next/)
7
- [![CI](https://img.shields.io/github/actions/workflow/status/jose-pr/pathlib_next/test.yml)](https://github.com/jose-pr/pathlib_next/actions/workflows/test.yml)
6
+ [![Docs](https://img.shields.io/badge/docs-latest-blue.svg)](https://jose-pr.github.io/pathlib-next/)
7
+ [![CI](https://img.shields.io/github/actions/workflow/status/jose-pr/pathlib_next/test.yml)](https://github.com/jose-pr/pathlib-next/actions/workflows/test.yml)
8
8
 
9
9
  A **robust, extensible pathlib-like base** for any resource addressable as a
10
10
  path or URI. Same method names, signatures, semantics, and exception types as
@@ -12,7 +12,7 @@ path or URI. Same method names, signatures, semantics, and exception types as
12
12
  against `Path`/`UriPath` and it works against your local disk, an in-memory
13
13
  tree, an HTTP index, or an SFTP server. Every intentional divergence from
14
14
  `pathlib`'s behavior is documented, not silent -- see
15
- [`docs/divergences.md`](https://jose-pr.github.io/pathlib_next/divergences/).
15
+ [`docs/divergences.md`](https://jose-pr.github.io/pathlib-next/divergences/).
16
16
 
17
17
  ## Features
18
18
 
@@ -30,7 +30,7 @@ tree, an HTTP index, or an SFTP server. Every intentional divergence from
30
30
 
31
31
  Every scheme shares the same `glob()`, `walk()`, `copy()`/`move()`, `rm()`
32
32
  implementations -- see the full matrix and notes in
33
- [Schemes](https://jose-pr.github.io/pathlib_next/guides/schemes/).
33
+ [Schemes](https://jose-pr.github.io/pathlib-next/guides/schemes/).
34
34
 
35
35
  - **Unified path interface** across local files, in-memory paths, archive
36
36
  members, and `file`/`data`/`ftp`/`http`/`dav`/`sftp`/`s3` URIs.
@@ -42,7 +42,7 @@ implementations -- see the full matrix and notes in
42
42
  authority components.
43
43
  - **Extensible two ways**: subclass `Path` directly for a custom
44
44
  non-URI resource, or subclass `UriPath` for a new URI scheme -- see
45
- [Extending](https://jose-pr.github.io/pathlib_next/guides/extending/).
45
+ [Extending](https://jose-pr.github.io/pathlib-next/guides/extending/).
46
46
 
47
47
  ## Installation
48
48
 
@@ -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) |
@@ -128,13 +128,13 @@ print(member.read_text())
128
128
  Also built in: `data:` (RFC 2397 inline payloads), `ftp(s):` (stdlib
129
129
  `ftplib`), `dav(s):` (WebDAV, full read/write over HTTP), and `s3:`
130
130
  (`boto3`) -- one example per scheme in
131
- [Schemes](https://jose-pr.github.io/pathlib_next/guides/schemes/).
131
+ [Schemes](https://jose-pr.github.io/pathlib-next/guides/schemes/).
132
132
 
133
133
  ## Extending
134
134
 
135
135
  Two first-class ways to add a new path-addressable resource -- both covered
136
136
  in depth, with worked examples, in
137
- [Extending](https://jose-pr.github.io/pathlib_next/guides/extending/):
137
+ [Extending](https://jose-pr.github.io/pathlib-next/guides/extending/):
138
138
 
139
139
  - Subclass `Path` directly for a custom, non-URI resource (`MemPath` is the
140
140
  reference exemplar).
@@ -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
 
@@ -60,7 +60,11 @@ these operations itself always keeps its own implementation.
60
60
  | `Path.copy(target, ...)` | CPython 3.14 `Path.copy(target, *, follow_symlinks=True, dirs_exist_ok=False, preserve_metadata=False)`; always raises if `target` exists | Ours predates 3.14. Signature: `copy(target, *, overwrite=False, follow_symlinks=True, preserve_metadata=True)`. `overwrite=True` unlinks an existing non-directory target first; `preserve_metadata` defaults to **True** (opposite of 3.14) and only propagates `st_mode`, not timestamps/xattrs | Argument names aligned with 3.14 where cheap; `preserve_metadata=True` default kept for backward compat with this method's pre-existing (pre-3.14-alignment) behavior of always copying the mode bits. Full metadata preservation (timestamps, xattrs) is not implemented. |
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
+ | `Path.symlink_to(target, target_is_directory=False, *, force=False)` | `pathlib.Path.symlink_to(target, target_is_directory=False)` -- raises `FileExistsError` if anything already exists at the link path | Adds a keyword-only `force=`. `force=False` (the default) is stdlib-exact. `force=True` unlinks an existing **non-directory** entry at the link path first, then creates the symlink; an existing *directory* is never removed and the underlying error propagates. Not atomic: no filesystem or transport offers "replace a symlink" as one operation, so the path briefly does not exist between the unlink and the symlink. | Additive extension (an extra optional kwarg, per the parity contract). No backend can offer this atomically, so every consumer was re-implementing the same unlink-then-symlink dance -- it is path semantics, not transport semantics, so it belongs at the `Path` layer where one implementation serves every backend. Backends implement only the `_symlink_to()` primitive (same `_mkdir`/`_open` shape) and get `force=` for free. Because no stdlib version accepts the keyword, `symlink_to` is in `_OPERATION_NAMES` so `LocalPath` honors it too. |
64
+ | `Path.chown(uid=None, gid=None, *, follow_symlinks=True)` | Not in `pathlib` at all -- it has `owner()`/`group()` **readers** but no writer (the stdlib writers are `os.chown`/`shutil.chown`, which are functions over a path, not path methods) | Our own extension. `None` (default) leaves a field unchanged; `-1` is accepted as an alias for `None` (`os.chown`'s own sentinel); an `int` is a uid/gid and a `str` is a user/group name. A call where both fields are unchanged short-circuits without touching the backend. Implemented for `LocalPath`/`FileUri` (via `shutil.chown`, or `os.lchown` for `follow_symlinks=False`) and `SftpPath` (`setstat`); `NotImplementedError` elsewhere. | Ownership was the one permission attribute `stat()` could report (`st_uid`/`st_gid`) that nothing could write back. The valuable part is centralizing the **"unchanged" sentinel** on `Path` (`utils.as_owner()`): `os.chown` spells it `-1`, SFTP omits the field, other middlewares use `None` -- normalizing per-scheme would be three chances to disagree. Backends implement `_chown()` and receive an already-canonical pair. SFTPv3 sends uid/gid as one paired attribute, so `SftpPath` reads the current owner for whichever field is unchanged rather than guessing a value. |
65
+ | `Path.chmod(mode, ...)` accepting a `str` | `mode` must be an `int`; a `str` raises `TypeError` | Additionally accepts a `str`, parsed as **octal**: `"0755"`, `"755"` and `0o755` all mean the same thing. An optional `0o` prefix is allowed; any character outside `[0-7]` raises `ValueError` rather than being coerced. | The string form is how modes are written in `chmod(1)`, Ansible, Dockerfiles and shell scripts, so config-driven callers arrive holding one. Accepted only with an **explicit base 8** (`utils.as_mode()`), never a plain `int()`: `int("0755")` in decimal is 755 == `0o1363`, a different *and valid* mode, so a fallback to decimal would set plausible-but-wrong permissions with nothing raising -- which is exactly why stdlib refuses strings. Parsing in one shared helper is what makes the base non-negotiable across the five backends that implement `chmod` directly. |
63
66
  | `PathSyncer` / `Query` / `Source` | N/A | Our own extensions | N/A -- pure extensions, no pathlib equivalent. |
67
+ | `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
68
  | `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
69
  | `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
70
  | `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 +73,9 @@ these operations itself always keeps its own implementation.
69
73
  | `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
74
  | `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
75
  | `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. |
76
+ | `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. |
77
+ | `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.** |
78
+ | `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
79
 
73
80
  ## Explicitly out of scope (not implemented on `Pathname`/`Path`)
74
81
 
@@ -113,3 +120,50 @@ because a behavioral decision needed documenting:
113
120
  **User decision, 2026-07-11.** `include_hidden=`/`dironly=` are documented
114
121
  extensions beyond pathlib's `glob()` signature. **Caution:** on remote
115
122
  schemes (http/sftp), a recursive glob walks the whole remote subtree.
123
+ - `BinaryOpen.copy(target, *, progress=None, chunk_size=shutil.COPY_BUFSIZE)`
124
+ / `Path.copy(target, ..., progress=None)`: optional progress-reporting
125
+ hook, no pathlib equivalent. `BinaryOpen.copy()`'s `progress(bytes_copied,
126
+ total_size)` fires after each chunk (`total_size` is `None` when the
127
+ source doesn't implement `Stat` or `stat()` fails); `Path.copy()`'s
128
+ `progress(path, bytes_copied, total_size)` adds the source `Path` being
129
+ streamed, so a `recursive=True` copy can report per-file identity
130
+ alongside byte progress. `chunk_size` is now caller-visible (previously
131
+ hardcoded to `shutil.copyfileobj`'s default). `progress=None` (the
132
+ default) is byte-for-byte identical to the prior `shutil.copyfileobj`
133
+ behavior -- no per-chunk overhead when unused. **Known limitation:**
134
+ native/batch transfer paths that bypass the generic streaming copy --
135
+ currently only `SftpPath`'s asyncssh concurrent fan-out
136
+ (`copy(recursive=True)` on a directory) -- do not invoke `progress`; this
137
+ was a deliberate scope decision for the first cut (generic-stream-only),
138
+ not an oversight. **2026-07-28.**
139
+ - `protocols.checksum.NativeChecksum` (`checksum(algorithm="md5") -> str`) --
140
+ an entirely new, optional protocol with no pathlib equivalent. A `Path`
141
+ subclass may implement it to compute a file digest server-side (e.g.
142
+ `SftpPath` against an OpenSSH server's `check-file@openssh.com` SFTP
143
+ extension) instead of streaming the content through `open("rb")`. Not
144
+ mixed into the base `Path`/`Pathname` ABC -- most backends never
145
+ implement it, and a plain `Path` has no `.checksum` attribute at all.
146
+ **Hard contract, not a style choice:** an implementation MUST raise
147
+ `NotImplementedError` (never return a value) when it cannot produce a
148
+ genuine content digest under the requested `algorithm` -- this is what
149
+ keeps `PathSyncer` (see its class docstring, `utils/sync.py`) from ever
150
+ comparing a native digest to a streamed one under a mismatched algorithm,
151
+ or trusting something hash-shaped but not actually a content hash (e.g.
152
+ S3's ETag for a multipart upload, deliberately NOT implemented here for
153
+ exactly that reason). `utils.checksum.md5`/`sha256`/`stream` (the
154
+ pre-existing streaming helpers) are unaffected and keep working for
155
+ direct callers that don't go through the protocol or `PathSyncer`.
156
+ `supported_checksums() -> frozenset[str]` (default `frozenset()`) is a
157
+ companion advisory capability query -- never raises, lets a caller pick a
158
+ shared algorithm across two paths before calling anything expensive, but
159
+ is advisory only: `checksum()`'s own `NotImplementedError` contract
160
+ remains authoritative regardless of what this advertises.
161
+ - `PathSyncer(..., quick_check=True)` -- a new constructor kwarg, no
162
+ pathlib equivalent. For any sync pair where at least one side is
163
+ non-local, a metadata-only pre-check (`st_size` + `st_mtime`, from
164
+ already-cached listing metadata, no extra round trip) skips the checksum
165
+ call entirely when both already match; a mismatch always falls through
166
+ to a real checksum rather than being treated as "changed" on its own.
167
+ Local-to-local pairs never engage this pre-check. `quick_check=False`
168
+ restores always-checksum behavior for non-local pairs too. **User
169
+ 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 |
@@ -1,8 +1,8 @@
1
1
  site_name: pathlib_next
2
2
  site_description: Generic Path Protocol based pathlib
3
- site_url: https://jose-pr.github.io/pathlib_next/
4
- repo_url: https://github.com/jose-pr/pathlib_next
5
- repo_name: jose-pr/pathlib_next
3
+ site_url: https://jose-pr.github.io/pathlib-next/
4
+ repo_url: https://github.com/jose-pr/pathlib-next
5
+ repo_name: jose-pr/pathlib-next
6
6
  edit_uri: edit/main/docs/
7
7
 
8
8
  theme:
@@ -3,8 +3,16 @@ requires = ["hatchling"]
3
3
  build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
- name = "pathlib_next"
7
- version = "0.8.6"
6
+ # The DISTRIBUTION name, hyphenated to match what PyPI displays and what
7
+ # `pip install pathlib-next` takes. PEP 503 normalises `_` to `-`, so this
8
+ # was already published as `pathlib-next`; spelling it that way here just
9
+ # stops the repo, the docs and the index from disagreeing.
10
+ #
11
+ # The IMPORT name stays `pathlib_next` (`packages` below, and every
12
+ # `import pathlib_next`) -- a hyphen is not legal in a Python identifier.
13
+ # Distribution name and import name differing is ordinary and intended.
14
+ name = "pathlib-next"
15
+ version = "0.9.1"
8
16
  authors = [{ name = "Jose A" }]
9
17
  description = "Generic Path Protocol based pathlib"
10
18
  readme = "README.md"
@@ -20,14 +28,14 @@ dependencies = []
20
28
  uripath = "pathlib_next.tools.uripath:main"
21
29
 
22
30
  [project.optional-dependencies]
23
- uri = ["uritools"]
31
+ uri = ["uritools", "netimps>=0.2.0"]
24
32
  http = ["requests", "pathlib_next[uri]"]
25
33
  sftp = ["paramiko", "pathlib_next[uri]"]
26
34
  sftp-async = ["asyncssh<2.22; python_version<'3.10'", "asyncssh; python_version>='3.10'", "pathlib_next[uri]"]
27
35
  s3 = ["boto3", "pathlib_next[uri]"]
28
36
  gs = ["google-cloud-storage", "pathlib_next[uri]"]
29
37
  az = ["azure-storage-blob", "pathlib_next[uri]"]
30
- dev = ['build', 'twine', 'hatchling', 'pytest', 'pytest-cov', 'pyftpdlib', 'wsgidav', 'cheroot', 'moto[s3]', 'hypothesis']
38
+ dev = ['build', 'twine', 'hatchling', 'pytest', 'pytest-cov', 'pyftpdlib', 'wsgidav', 'cheroot', 'moto[s3]', 'hypothesis', 'black']
31
39
  docs = ['mkdocs', 'mkdocs-material', 'mkdocstrings[python]']
32
40
 
33
41
  [project.entry-points."pathlib_next.schemes"]
@@ -54,9 +62,9 @@ git = "pathlib_next.uri.schemes.git:GitPath"
54
62
  "git+gitlab" = "pathlib_next.uri.schemes.git:GitLabGitPath"
55
63
 
56
64
  [project.urls]
57
- Homepage = "https://github.com/jose-pr/pathlib_next/"
58
- Documentation = "https://jose-pr.github.io/pathlib_next/"
59
- Issues = "https://github.com/jose-pr/pathlib_next/issues"
65
+ Homepage = "https://github.com/jose-pr/pathlib-next/"
66
+ Documentation = "https://jose-pr.github.io/pathlib-next/"
67
+ Issues = "https://github.com/jose-pr/pathlib-next/issues"
60
68
 
61
69
 
62
70
  # Ship the consumer-facing docs inside the installed package, so they are
@@ -74,6 +82,11 @@ exclude = ["/.*", "/benchmarks"]
74
82
  packages = ["src/pathlib_next"]
75
83
 
76
84
 
85
+ [tool.black]
86
+ # Pinned to the project's *minimum* supported Python so black never emits
87
+ # syntax the 3.9 floor cannot parse.
88
+ target-version = ["py39"]
89
+
77
90
  [tool.pytest.ini_options]
78
91
  pythonpath = ["src"]
79
92
  testpaths = ["tests"]