pathlib-next 0.9.1__tar.gz → 0.9.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/CHANGELOG.md +48 -1
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/PKG-INFO +2 -2
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/docs/divergences.md +1 -1
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/docs/guides/extending.md +10 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/pyproject.toml +1 -1
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/AGENTS.md +19 -2
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/fspath.py +9 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/mempath.py +20 -3
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/path.py +28 -4
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/protocols/fs.py +1 -1
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/uri/__init__.py +10 -4
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/uri/source.py +1 -3
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/utils/glob.py +1 -1
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/utils/sync.py +1 -1
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_mempath.py +81 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_parity_io.py +23 -3
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_pathname.py +122 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_properties.py +15 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_sftp.py +10 -3
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_sync.py +1 -1
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/.gitignore +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/LICENSE +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/README.md +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/docs/api/mempath.md +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/docs/api/path.md +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/docs/api/testing.md +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/docs/api/uri.md +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/docs/api/utils.md +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/docs/benchmarks.md +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/docs/changelog.md +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/docs/guides/cli.md +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/docs/guides/schemes.md +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/docs/index.md +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/examples/az_listing.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/examples/data_and_archive.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/examples/ftp_listing.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/examples/github_listing.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/examples/gitlab_listing.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/examples/gs_listing.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/examples/http_listing.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/examples/local_and_mem.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/examples/s3_listing.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/examples/sftp_sync.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/examples/webdav_roundtrip.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/mkdocs.yml +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/__init__.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/protocols/__init__.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/protocols/checksum.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/protocols/io.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/py.typed +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/testing.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/tools/__init__.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/tools/uripath.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/uri/query.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/__init__.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/_gitrepo.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/archive/__init__.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/archive/_base.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/archive/tar.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/archive/zip.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/az.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/data.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/dav.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/file.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/ftp.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/git/__init__.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/git/_base.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/git/github.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/git/gitlab.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/github.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/gitlab.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/gs.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/http.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/s3.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/sftp/__init__.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/sftp/_asyncssh.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/sftp/_paramiko.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/uri/schemes/sftp/_sshconfig.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/utils/__init__.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/utils/archive.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/utils/checksum.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/src/pathlib_next/utils/stat.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/conftest.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_archive_uri.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_az.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_az_fake.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_checksum.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_contract.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_data_uri.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_dav.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_ftp.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_gitrepo.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_glob.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_gs.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_gs_fake.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_http.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_http_live.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_http_parser.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_local.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_mro_precedence.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_parity_pure.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_path_gaps.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_plugins.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_query.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_s3.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_sftp_asyncssh.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_smoke.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_source.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_uri_parse.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_uri_path.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_uripath_tool.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_utils.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_walk.py +0 -0
- {pathlib_next-0.9.1 → pathlib_next-0.9.2}/tests/test_webdav.py +0 -0
|
@@ -5,6 +5,52 @@ 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]
|
|
9
|
+
|
|
10
|
+
## [0.9.2] - 2026-08-16
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **`MemPath.open("w")` on an existing directory raised nothing and destroyed
|
|
14
|
+
the tree.** `_open()` assigned over whatever was already at the name, so
|
|
15
|
+
`MemPath("dir").write_text(...)` replaced a directory and everything under
|
|
16
|
+
it with a file — silently, in the class the docs present as the reference
|
|
17
|
+
exemplar for extending this library, and the class used as a mock
|
|
18
|
+
filesystem in tests. It now raises `IsADirectoryError`, as `pathlib` does.
|
|
19
|
+
The same guard covers the virtual root for every mode, which used to grow a
|
|
20
|
+
bogus `""` key in the backend on `"w"`/`"a"`.
|
|
21
|
+
- **A `MemPath` routed *through* a file raised `TypeError`.**
|
|
22
|
+
`_parent_container()` walked ancestors with `path not in parent`, which on a
|
|
23
|
+
`bytearray` ancestor evaluates `"seg" not in bytearray`. That `TypeError`
|
|
24
|
+
sails past the `OSError` guard in `Stat._st_mode()`, so even
|
|
25
|
+
`MemPath("file.txt/sub").exists()` crashed instead of returning `False` —
|
|
26
|
+
a routine shape in glob/walk and in `mkdir(parents=True)`. It now raises
|
|
27
|
+
`NotADirectoryError` naming the offending ancestor.
|
|
28
|
+
- **`Pathname` had no `__eq__`/`__hash__`, so subclasses compared by
|
|
29
|
+
identity.** Every pure subclass that didn't hand-write equality — including
|
|
30
|
+
`MemPath`, and any downstream class subclassing `Path` directly — was
|
|
31
|
+
unusable as a dict key or set member, and `is_relative_to()` (which decides
|
|
32
|
+
via `==` against freshly built parents) always returned `False` without
|
|
33
|
+
raising. There is now a default keyed on
|
|
34
|
+
`(type(self), tuple(self.segments))`. `LocalPath`, `PosixPathname` and
|
|
35
|
+
`WindowsPathname` are unaffected — `pathlib.PurePath` precedes `Pathname` in
|
|
36
|
+
their MRO and keeps its own equality — as is `Uri`, which defines one.
|
|
37
|
+
- **`is_relative_to()` normalized a `str` argument by joining it onto
|
|
38
|
+
`self`.** `Pathname` used `cls(self, other)` and `Uri` used
|
|
39
|
+
`Uri(self, _ROOT, other)`, so `Uri("a/b").is_relative_to("a")` compared
|
|
40
|
+
against `"a/b/a"` / `"/a"` and answered `False` while
|
|
41
|
+
`Uri("a/b").is_relative_to(Uri("a"))` answered `True` — the str and object
|
|
42
|
+
forms of the same call disagreed. Both now parse `other` standalone, as
|
|
43
|
+
CPython does. The generic side uses `self.with_segments(other)` so a
|
|
44
|
+
subclass's per-instance state (`MemPath`'s backend) survives the
|
|
45
|
+
normalization. `Uri("http://h/a/b").is_relative_to("/a")` is still `True`.
|
|
46
|
+
- **`LocalPath.chown()` leaked `AttributeError`/`LookupError` on Windows.**
|
|
47
|
+
`shutil.chown` exists there while `os.chown` does not, so an int id raised
|
|
48
|
+
`AttributeError` and a name raised a misleading `LookupError: no such user`
|
|
49
|
+
(with no `pwd` module, every name misses whether or not the user exists).
|
|
50
|
+
It now raises `NotImplementedError`, which `docs/divergences.md` already
|
|
51
|
+
promised and which every other unsupported capability here raises. The
|
|
52
|
+
all-unchanged no-op still succeeds.
|
|
53
|
+
|
|
8
54
|
## [0.9.1] - 2026-08-04
|
|
9
55
|
|
|
10
56
|
### Added
|
|
@@ -732,7 +778,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
732
778
|
- Sync error handling.
|
|
733
779
|
- Generic Path Protocol based pathlib implementation for URI paths with file access support for sftp, http, file schemes.
|
|
734
780
|
|
|
735
|
-
[Unreleased]: https://github.com/jose-pr/pathlib-next/compare/v0.9.
|
|
781
|
+
[Unreleased]: https://github.com/jose-pr/pathlib-next/compare/v0.9.2...HEAD
|
|
782
|
+
[0.9.2]: https://github.com/jose-pr/pathlib-next/compare/v0.9.1...v0.9.2
|
|
736
783
|
[0.9.1]: https://github.com/jose-pr/pathlib-next/compare/v0.9.0...v0.9.1
|
|
737
784
|
[0.8.6]: https://github.com/jose-pr/pathlib-next/compare/v0.8.5...v0.8.6
|
|
738
785
|
[0.8.5]: https://github.com/jose-pr/pathlib-next/compare/v0.8.4...v0.8.5
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: pathlib-next
|
|
3
|
-
Version: 0.9.
|
|
3
|
+
Version: 0.9.2
|
|
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/
|
|
@@ -61,7 +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
|
| `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. |
|
|
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 -- including `LocalPath` on a platform without `os.chown` (Windows), where `shutil.chown` exists but cannot work. | Ownership was the one permission attribute `stat()` could report (`st_uid`/`st_gid`) that nothing could write back. The valuable part is centralizing the **"unchanged" sentinel** on `Path` (`utils.as_owner()`): `os.chown` spells it `-1`, SFTP omits the field, other middlewares use `None` -- normalizing per-scheme would be three chances to disagree. Backends implement `_chown()` and receive an already-canonical pair. SFTPv3 sends uid/gid as one paired attribute, so `SftpPath` reads the current owner for whichever field is unchanged rather than guessing a value. |
|
|
65
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. |
|
|
66
66
|
| `PathSyncer` / `Query` / `Source` | N/A | Our own extensions | N/A -- pure extensions, no pathlib equivalent. |
|
|
67
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.** |
|
|
@@ -31,6 +31,16 @@ as_uri() # a URI string identifying this path (can be a custom scheme)
|
|
|
31
31
|
relative_to(other) # or raise NotImplementedError if not meaningful
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
+
Equality is **not** on that list: `Pathname` supplies a default `__eq__`/
|
|
35
|
+
`__hash__` keyed on `(type(self), tuple(self.segments))`, so your class is
|
|
36
|
+
usable as a dict key or set member, and the equality-based helpers
|
|
37
|
+
(`is_relative_to()`, `parents` membership) work, without you writing
|
|
38
|
+
anything. Override both together if your type needs a different identity
|
|
39
|
+
-- e.g. case-insensitive segments, or one that also distinguishes the
|
|
40
|
+
backing store two otherwise-identical paths point at. (`LocalPath` and the
|
|
41
|
+
`*Pathname` classes don't use this default: `pathlib.PurePath` precedes
|
|
42
|
+
`Pathname` in their MRO and keeps its own equality.)
|
|
43
|
+
|
|
34
44
|
Optional I/O, implement whichever your resource actually supports -- leave
|
|
35
45
|
the rest as the inherited `@notimplemented` stubs (derived helpers either
|
|
36
46
|
fall back, e.g. `move()` falls back to copy+unlink when `rename()` isn't
|
|
@@ -12,7 +12,7 @@ build-backend = "hatchling.build"
|
|
|
12
12
|
# `import pathlib_next`) -- a hyphen is not legal in a Python identifier.
|
|
13
13
|
# Distribution name and import name differing is ordinary and intended.
|
|
14
14
|
name = "pathlib-next"
|
|
15
|
-
version = "0.9.
|
|
15
|
+
version = "0.9.2"
|
|
16
16
|
authors = [{ name = "Jose A" }]
|
|
17
17
|
description = "Generic Path Protocol based pathlib"
|
|
18
18
|
readme = "README.md"
|
|
@@ -26,6 +26,17 @@ pathlib_next`.
|
|
|
26
26
|
(abstract), `match(pattern, *, case_sensitive=None)`,
|
|
27
27
|
`full_match(pattern, *, case_sensitive=None)`, `as_posix()`,
|
|
28
28
|
`has_glob_pattern()`. `as_uri()` is abstract on `Pathname` itself.
|
|
29
|
+
- `__eq__`/`__hash__` are supplied by default, keyed on
|
|
30
|
+
`(type(self), tuple(self.segments))` — exact type, so a subclass never
|
|
31
|
+
compares equal to its base. Classes mixing in `pathlib.PurePath`
|
|
32
|
+
(`PosixPathname`, `WindowsPathname`, `LocalPath`) keep stdlib's
|
|
33
|
+
equality instead, since `PurePath` precedes `Pathname` in their MRO;
|
|
34
|
+
`Uri` defines its own over `as_uri()`. Override both together if your
|
|
35
|
+
subclass needs a different identity.
|
|
36
|
+
- `is_relative_to(other)` parses a `str` `other` standalone, via
|
|
37
|
+
`self.with_segments(other)` — the same rule as CPython's
|
|
38
|
+
`self.with_segments(other)`, and it preserves per-instance state such
|
|
39
|
+
as `MemPath`'s backend.
|
|
29
40
|
- **`Path(Pathname, Chmod, Stat, BinaryOpen)`** — base class for I/O paths.
|
|
30
41
|
`Path(*args)` (the bare class, not a subclass) always constructs a
|
|
31
42
|
`LocalPath` (`fspath.py`) — the real local filesystem. Adds:
|
|
@@ -154,7 +165,10 @@ pathlib_next`.
|
|
|
154
165
|
alias for it; an `int` is an id, a `str` is a name. `chown()` normalizes
|
|
155
166
|
via `utils.as_owner()` and short-circuits when nothing would change, so
|
|
156
167
|
`_chown()` always receives a canonical pair and only converts to its own
|
|
157
|
-
wire spelling (`-1` for `os.chown`, an omitted attr for SFTP).
|
|
168
|
+
wire spelling (`-1` for `os.chown`, an omitted attr for SFTP). On a
|
|
169
|
+
platform without `os.chown` (Windows) `LocalPath.chown()` raises
|
|
170
|
+
`NotImplementedError` for any real change; the all-unchanged no-op still
|
|
171
|
+
succeeds because it never reaches the backend.
|
|
158
172
|
- **`io.BinaryOpen`** — `Protocol`. `_open(mode="r", buffering=-1) ->
|
|
159
173
|
io.IOBase` (not implemented by default; must yield a **binary** stream).
|
|
160
174
|
Derives `open(mode="r", buffering=-1, encoding=None, errors=None,
|
|
@@ -204,7 +218,10 @@ extra that depends on it).
|
|
|
204
218
|
`suffix`, `stem`, `parent`. Methods: `as_uri(sanitize=False)` (sanitize
|
|
205
219
|
strips password from userinfo before formatting), `with_source(source)`,
|
|
206
220
|
`with_segments(*segments)`, `with_path(path)`, `with_query(query)`,
|
|
207
|
-
`with_fragment(fragment)`, `is_absolute()`, `is_relative_to(other)
|
|
221
|
+
`with_fragment(fragment)`, `is_absolute()`, `is_relative_to(other)`
|
|
222
|
+
(a `str` `other` is parsed standalone as `Uri(other)`, matching
|
|
223
|
+
`relative_to()`; an `other` with no authority is compatible with any
|
|
224
|
+
`self.source`, so `Uri("http://h/a/b").is_relative_to("/a")` is `True`),
|
|
208
225
|
`relative_to(other, *, walk_up=False)`, `is_local()` (delegates to
|
|
209
226
|
`Source.is_local()` — does a DNS lookup, cached per `Source`),
|
|
210
227
|
`as_posix()` (`user@host:path` / `host:path` form when a source is
|
|
@@ -193,6 +193,15 @@ class LocalPath(
|
|
|
193
193
|
# canonical form Chmod.chown() already normalized to, so the pair
|
|
194
194
|
# passes straight through. os.chown's -1 sentinel never appears
|
|
195
195
|
# here.
|
|
196
|
+
if not hasattr(_os, "chown"):
|
|
197
|
+
# shutil.chown() *exists* on Windows while os.chown does not, so
|
|
198
|
+
# without this the int form leaked `AttributeError: module 'os'
|
|
199
|
+
# has no attribute 'chown'` and the name form leaked
|
|
200
|
+
# `LookupError: no such user` -- actively misleading, since
|
|
201
|
+
# there is no `pwd` module for shutil._get_uid to consult, so
|
|
202
|
+
# every name "misses" whether or not the user exists.
|
|
203
|
+
# docs/divergences.md promises NotImplementedError here.
|
|
204
|
+
raise NotImplementedError("chown()")
|
|
196
205
|
if not follow_symlinks:
|
|
197
206
|
if not hasattr(_os, "lchown"):
|
|
198
207
|
raise NotImplementedError("chown(follow_symlinks=False)")
|
|
@@ -121,11 +121,19 @@ class MemPath(Path):
|
|
|
121
121
|
def _parent_container(self) -> tuple[dict[str, bytearray], str]:
|
|
122
122
|
parent = self.backend
|
|
123
123
|
*ancestors, name = self.normalized
|
|
124
|
-
for path in ancestors:
|
|
124
|
+
for index, path in enumerate(ancestors):
|
|
125
125
|
if path not in parent:
|
|
126
126
|
raise FileNotFoundError(self.parent)
|
|
127
|
-
|
|
128
|
-
|
|
127
|
+
parent = parent[path]
|
|
128
|
+
if not isinstance(parent, dict):
|
|
129
|
+
# An ancestor segment names a file. Without this the next
|
|
130
|
+
# iteration evaluates `"seg" not in bytearray` and raises
|
|
131
|
+
# TypeError, which sails past the OSError guards in
|
|
132
|
+
# stat()/exists()/is_dir() -- so even exists() crashed on a
|
|
133
|
+
# path merely routed through a file. NotADirectoryError is
|
|
134
|
+
# an OSError, which is what stdlib raises and what those
|
|
135
|
+
# guards already swallow.
|
|
136
|
+
raise NotADirectoryError(self.with_segments(*ancestors[: index + 1]))
|
|
129
137
|
|
|
130
138
|
return parent, name
|
|
131
139
|
|
|
@@ -192,6 +200,11 @@ class MemPath(Path):
|
|
|
192
200
|
# mode contract: "r"/"w" are required; "x"/"a" are supported here
|
|
193
201
|
# as an extension. Anything else raises NotImplementedError.
|
|
194
202
|
parent, name = self._parent_container()
|
|
203
|
+
if not name:
|
|
204
|
+
# An empty name is the virtual root, which stat() reports as a
|
|
205
|
+
# directory. Without this guard "w"/"a" created a bogus ""
|
|
206
|
+
# entry in the backend and "r" claimed FileNotFoundError.
|
|
207
|
+
raise IsADirectoryError(self)
|
|
195
208
|
if mode == "r":
|
|
196
209
|
if name not in parent:
|
|
197
210
|
raise FileNotFoundError(self)
|
|
@@ -200,6 +213,10 @@ class MemPath(Path):
|
|
|
200
213
|
raise IsADirectoryError(self)
|
|
201
214
|
return io.BytesIO(content)
|
|
202
215
|
elif mode == "w":
|
|
216
|
+
if isinstance(parent.get(name), dict):
|
|
217
|
+
# Truncating over a directory silently replaced the whole
|
|
218
|
+
# subtree with a file; stdlib raises IsADirectoryError.
|
|
219
|
+
raise IsADirectoryError(self)
|
|
203
220
|
content = bytearray()
|
|
204
221
|
parent[name] = content
|
|
205
222
|
return MemBytesIO(content)
|
|
@@ -187,9 +187,35 @@ class Pathname(FsPathLike, _ty.Generic[_P]):
|
|
|
187
187
|
def is_relative_to(self, other: _ty.Self | str):
|
|
188
188
|
"""Return True if the path is relative to another path or False."""
|
|
189
189
|
cls = type(self)
|
|
190
|
-
|
|
190
|
+
# with_segments(other), NOT cls(self, other): joining `other` under
|
|
191
|
+
# `self` first turned `MemPath("a/b").is_relative_to("a")` into a
|
|
192
|
+
# comparison against "a/b/a" and answered False, while the object
|
|
193
|
+
# form of the same call answered True. CPython parses `other`
|
|
194
|
+
# standalone (`self.with_segments(other)`) and so do we -- via
|
|
195
|
+
# with_segments rather than the bare constructor so per-instance
|
|
196
|
+
# state a subclass carries (MemPath's backend) survives.
|
|
197
|
+
other = other if isinstance(other, cls) else self.with_segments(other)
|
|
191
198
|
return other == self or other in self.parents
|
|
192
199
|
|
|
200
|
+
def __eq__(self, other: object) -> bool:
|
|
201
|
+
"""Compare by (exact type, segments).
|
|
202
|
+
|
|
203
|
+
The ABC previously defined no equality at all, so every pure
|
|
204
|
+
subclass that didn't hand-write one -- including `MemPath`, the
|
|
205
|
+
documented reference exemplar -- compared by identity. That made
|
|
206
|
+
`is_relative_to()` (which decides via `==`) silently return False
|
|
207
|
+
for every subclass, and broke paths as dict keys or set members.
|
|
208
|
+
`_BaseFSPathname`/`LocalPath` are unaffected: `pathlib.PurePath`
|
|
209
|
+
precedes `Pathname` in their MRO and keeps its own `__eq__`, as
|
|
210
|
+
does `Uri`, which defines one.
|
|
211
|
+
"""
|
|
212
|
+
if type(self) is not type(other):
|
|
213
|
+
return NotImplemented
|
|
214
|
+
return tuple(self.segments) == tuple(other.segments)
|
|
215
|
+
|
|
216
|
+
def __hash__(self) -> int:
|
|
217
|
+
return hash((type(self), tuple(self.segments)))
|
|
218
|
+
|
|
193
219
|
def __truediv__(self, key: _ty.Self | str) -> _ty.Self:
|
|
194
220
|
try:
|
|
195
221
|
return type(self)(self, key)
|
|
@@ -262,7 +288,7 @@ class Pathname(FsPathLike, _ty.Generic[_P]):
|
|
|
262
288
|
def has_glob_pattern(self):
|
|
263
289
|
"""Return True if any of the path segments contain glob wildcards."""
|
|
264
290
|
for segment in self.segments:
|
|
265
|
-
if _glob.WILDCARD_PATTERN.search(segment)
|
|
291
|
+
if _glob.WILDCARD_PATTERN.search(segment) is not None:
|
|
266
292
|
return True
|
|
267
293
|
return False
|
|
268
294
|
|
|
@@ -854,8 +880,6 @@ class Path(Pathname, Chmod, Stat, BinaryOpen):
|
|
|
854
880
|
if isinstance(target, str):
|
|
855
881
|
target = type(self)(target)
|
|
856
882
|
src = self
|
|
857
|
-
if src is None:
|
|
858
|
-
return
|
|
859
883
|
|
|
860
884
|
if target.exists():
|
|
861
885
|
if overwrite:
|
|
@@ -432,7 +432,16 @@ class Uri(Pathname):
|
|
|
432
432
|
|
|
433
433
|
def is_relative_to(self, other: UriLike):
|
|
434
434
|
"""Return True if the path is relative to another path or False."""
|
|
435
|
-
|
|
435
|
+
# Uri(other), NOT Uri(self, _ROOT, other): anchoring a str `other`
|
|
436
|
+
# at self's root turned `Uri("a/b").is_relative_to("a")` into a
|
|
437
|
+
# comparison against "/a" and answered False, disagreeing with the
|
|
438
|
+
# object form of the same call. `relative_to()` below already
|
|
439
|
+
# parsed a str standalone; this matches it and CPython.
|
|
440
|
+
# The same-authority case still works: a standalone parse leaves
|
|
441
|
+
# `other.source` empty, which the guard below treats as compatible
|
|
442
|
+
# with any `self.source`, and the segment prefix compare is
|
|
443
|
+
# unaffected -- `Uri("http://h/a/b").is_relative_to("/a")` is True.
|
|
444
|
+
other = other if isinstance(other, Uri) else Uri(other)
|
|
436
445
|
if not (
|
|
437
446
|
(other.source == self.source)
|
|
438
447
|
or not (bool(self.source) and bool(other.source))
|
|
@@ -717,6 +726,3 @@ class UriPath(Uri, Path):
|
|
|
717
726
|
def iterdir(self) -> "_ty.Iterator[Self]":
|
|
718
727
|
for name, stat in self._scandir():
|
|
719
728
|
yield self._make_child_relpath(name, stat_hint=stat)
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
_ROOT = Uri("/")
|
|
@@ -306,9 +306,7 @@ class Source(_ty.NamedTuple):
|
|
|
306
306
|
types and treating `host` as local if ANY resolved address is.
|
|
307
307
|
`netimps.resolve()` gained OS-resolver-chain support in 0.2.0 --
|
|
308
308
|
before that it was dnspython-only, which is why this method
|
|
309
|
-
originally kept `socket.gethostbyname()` for this step
|
|
310
|
-
`.agents/findings/processed/2026-07-29_netimps_adoption_survey.md`
|
|
311
|
-
and the companion finding filed against `netimps` itself). The
|
|
309
|
+
originally kept `socket.gethostbyname()` for this step. The
|
|
312
310
|
"is this address MINE" comparison uses `netimps.is_local_address()`,
|
|
313
311
|
which enumerates real network interfaces
|
|
314
312
|
(`netimps.get_interfaces()`) instead of the weaker
|
|
@@ -76,7 +76,7 @@ def glob(
|
|
|
76
76
|
include_hidden = include_hidden or path.is_hidden()
|
|
77
77
|
pattern = compile_pattern(path.name, case_sensitive) if path.name else ANY_PATTERN
|
|
78
78
|
|
|
79
|
-
name_is_pattern = WILDCARD_PATTERN.search(path.name)
|
|
79
|
+
name_is_pattern = WILDCARD_PATTERN.search(path.name) is not None
|
|
80
80
|
wildcard_in_path = name_is_pattern or path.has_glob_pattern()
|
|
81
81
|
parent = next(iter(path.parents), None)
|
|
82
82
|
|
|
@@ -187,7 +187,7 @@ class PathAndStat(object):
|
|
|
187
187
|
return self._stat
|
|
188
188
|
|
|
189
189
|
def exists(self):
|
|
190
|
-
return self.stat
|
|
190
|
+
return self.stat is not None
|
|
191
191
|
|
|
192
192
|
def refresh(self, follow_symlink: bool):
|
|
193
193
|
self._stat = FileStat.from_path(self.path, follow_symlink=follow_symlink)
|
|
@@ -153,3 +153,84 @@ def test_rmdir_nonempty_raises():
|
|
|
153
153
|
(root / "d" / "f.txt").write_text("x")
|
|
154
154
|
with pytest.raises(FileExistsError):
|
|
155
155
|
(root / "d").rmdir()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# --- 2026-08-16: open("w") over a directory destroyed the whole subtree ---
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_open_w_on_directory_raises_and_keeps_the_tree():
|
|
162
|
+
backend = MemPathBackend()
|
|
163
|
+
root = MemPath("/", backend=backend)
|
|
164
|
+
(root / "dir").mkdir()
|
|
165
|
+
(root / "dir" / "child.txt").write_text("hi")
|
|
166
|
+
|
|
167
|
+
with pytest.raises(IsADirectoryError):
|
|
168
|
+
MemPath("dir", backend=backend).write_text("clobber")
|
|
169
|
+
|
|
170
|
+
# The whole point of the fix: the tree survives the refused write.
|
|
171
|
+
assert isinstance(backend["dir"], dict)
|
|
172
|
+
assert (root / "dir" / "child.txt").read_text() == "hi"
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_open_w_on_root_raises_instead_of_creating_an_empty_key():
|
|
176
|
+
backend = MemPathBackend()
|
|
177
|
+
with pytest.raises(IsADirectoryError):
|
|
178
|
+
MemPath("/", backend=backend).write_text("clobber")
|
|
179
|
+
assert backend == {}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def test_open_a_on_root_raises_instead_of_creating_an_empty_key():
|
|
183
|
+
backend = MemPathBackend()
|
|
184
|
+
with pytest.raises(IsADirectoryError):
|
|
185
|
+
MemPath("/", backend=backend)._open("a")
|
|
186
|
+
assert backend == {}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def test_open_w_still_truncates_an_existing_file():
|
|
190
|
+
# Guard against over-correcting: only directories are refused.
|
|
191
|
+
root = MemPath("/")
|
|
192
|
+
(root / "f.txt").write_text("original")
|
|
193
|
+
(root / "f.txt").write_text("new")
|
|
194
|
+
assert (root / "f.txt").read_text() == "new"
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# --- 2026-08-16: a path routed *through* a file raised TypeError ---
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def test_exists_through_a_file_segment_is_false():
|
|
201
|
+
backend = MemPathBackend()
|
|
202
|
+
MemPath("file.txt", backend=backend).write_text("x")
|
|
203
|
+
# Used to raise TypeError: a bytes-like object is required, not 'str'.
|
|
204
|
+
assert MemPath("file.txt/sub", backend=backend).exists() is False
|
|
205
|
+
assert MemPath("file.txt/sub/deeper", backend=backend).exists() is False
|
|
206
|
+
assert MemPath("file.txt/sub", backend=backend).is_dir() is False
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def test_stat_through_a_file_segment_raises_notadirectoryerror():
|
|
210
|
+
backend = MemPathBackend()
|
|
211
|
+
MemPath("file.txt", backend=backend).write_text("x")
|
|
212
|
+
with pytest.raises(NotADirectoryError):
|
|
213
|
+
MemPath("file.txt/sub", backend=backend).stat()
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def test_open_through_a_file_segment_raises_notadirectoryerror():
|
|
217
|
+
backend = MemPathBackend()
|
|
218
|
+
MemPath("file.txt", backend=backend).write_text("x")
|
|
219
|
+
with pytest.raises(NotADirectoryError):
|
|
220
|
+
MemPath("file.txt/sub", backend=backend).read_text()
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def test_mkdir_parents_under_a_file_raises_notadirectoryerror():
|
|
224
|
+
backend = MemPathBackend()
|
|
225
|
+
MemPath("file.txt", backend=backend).write_text("x")
|
|
226
|
+
with pytest.raises(NotADirectoryError):
|
|
227
|
+
MemPath("file.txt/sub", backend=backend).mkdir(parents=True)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def test_notadirectoryerror_names_the_offending_ancestor():
|
|
231
|
+
backend = MemPathBackend()
|
|
232
|
+
MemPath("a", backend=backend).mkdir()
|
|
233
|
+
MemPath("a/f.txt", backend=backend).write_text("x")
|
|
234
|
+
with pytest.raises(NotADirectoryError) as excinfo:
|
|
235
|
+
MemPath("a/f.txt/sub", backend=backend).stat()
|
|
236
|
+
assert str(excinfo.value.args[0]) == "a/f.txt"
|
|
@@ -248,8 +248,8 @@ def test_copy_progress_hook_monotonic_and_reaches_total(tmp_path):
|
|
|
248
248
|
def test_copy_progress_hook_not_called_without_callback_and_no_behavior_change(
|
|
249
249
|
tmp_path,
|
|
250
250
|
):
|
|
251
|
-
# No callback => identical behavior/content to plain shutil.copyfileobj
|
|
252
|
-
#
|
|
251
|
+
# No callback => identical behavior/content to plain shutil.copyfileobj:
|
|
252
|
+
# the hook must not cost anything when it is not used.
|
|
253
253
|
root = pathlib_next.LocalPath(tmp_path)
|
|
254
254
|
data = b"hello world" * 1000
|
|
255
255
|
(root / "src.bin").write_bytes(data)
|
|
@@ -278,7 +278,7 @@ def test_copy_progress_hook_fires_on_path_copy(tmp_path):
|
|
|
278
278
|
|
|
279
279
|
|
|
280
280
|
def test_copy_recursive_progress_hook_reports_per_file_identity(tmp_path):
|
|
281
|
-
#
|
|
281
|
+
# A recursive copy must report per-file identity alongside byte
|
|
282
282
|
# progress -- not just an anonymous byte stream.
|
|
283
283
|
root = pathlib_next.LocalPath(tmp_path)
|
|
284
284
|
src = root / "src"
|
|
@@ -495,3 +495,23 @@ def test_local_chown_no_op_needs_no_privileges(tmp_path):
|
|
|
495
495
|
f.chown()
|
|
496
496
|
f.chown(-1, -1)
|
|
497
497
|
assert f.read_text() == "x"
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
@pytest.mark.skipif(hasattr(os, "chown"), reason="platforms without os.chown only")
|
|
501
|
+
@pytest.mark.parametrize("uid", [1000, "someuser"])
|
|
502
|
+
def test_local_chown_unsupported_platform_raises_notimplementederror(tmp_path, uid):
|
|
503
|
+
# shutil.chown() exists on Windows while os.chown does not, so the
|
|
504
|
+
# mutation path used to leak AttributeError for an int id and a
|
|
505
|
+
# misleading "no such user" LookupError for a name (there is no pwd
|
|
506
|
+
# module, so every name misses). docs/divergences.md promises
|
|
507
|
+
# NotImplementedError, which is what every other unsupported capability
|
|
508
|
+
# in this library raises.
|
|
509
|
+
root = pathlib_next.LocalPath(tmp_path)
|
|
510
|
+
f = root / "f.txt"
|
|
511
|
+
f.write_text("x")
|
|
512
|
+
with pytest.raises(NotImplementedError):
|
|
513
|
+
f.chown(uid=uid)
|
|
514
|
+
with pytest.raises(NotImplementedError):
|
|
515
|
+
f.chown(gid=uid)
|
|
516
|
+
with pytest.raises(NotImplementedError):
|
|
517
|
+
f.chown(uid=uid, follow_symlinks=False)
|
|
@@ -165,3 +165,125 @@ def test_root_drive_anchor_relative(cls):
|
|
|
165
165
|
p = cls("a/b")
|
|
166
166
|
assert p.root == ""
|
|
167
167
|
assert p.anchor == ""
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# --- 2026-08-16: equality + is_relative_to str/object agreement ---
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@pytest.mark.parametrize("cls", IMPLS)
|
|
174
|
+
def test_equality_is_by_value_not_identity(cls):
|
|
175
|
+
# `Pathname` used to define no __eq__ at all, so any subclass that
|
|
176
|
+
# didn't hand-write one (MemPath, and every downstream Track A class)
|
|
177
|
+
# compared by identity.
|
|
178
|
+
assert cls("a/b") == cls("a/b")
|
|
179
|
+
assert cls("a/b") != cls("a/c")
|
|
180
|
+
assert cls("a/b") is not cls("a/b")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@pytest.mark.parametrize("cls", IMPLS)
|
|
184
|
+
def test_hash_matches_equality(cls):
|
|
185
|
+
assert hash(cls("a/b")) == hash(cls("a/b"))
|
|
186
|
+
assert len({cls("a/b"), cls("a/b"), cls("a/c")}) == 2
|
|
187
|
+
assert {cls("a/b"): 1}[cls("a/b")] == 1
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@pytest.mark.parametrize("cls", IMPLS)
|
|
191
|
+
def test_equality_against_a_foreign_type_is_false_not_an_error(cls):
|
|
192
|
+
assert (cls("a/b") == object()) is False
|
|
193
|
+
assert (cls("a/b") != object()) is True
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@pytest.mark.parametrize("cls", IMPLS)
|
|
197
|
+
@pytest.mark.parametrize("path,other", [("a/b", "a"), ("a/b/c", "a/b"), ("a", "a")])
|
|
198
|
+
def test_is_relative_to_str_and_object_agree_with_stdlib(cls, path, other):
|
|
199
|
+
expected = pathlib.PurePosixPath(path).is_relative_to(other)
|
|
200
|
+
assert expected is True # sanity: the oracle really does say True
|
|
201
|
+
assert cls(path).is_relative_to(cls(other)) is expected
|
|
202
|
+
# The str form used to join `other` onto `self` instead of parsing it
|
|
203
|
+
# standalone, so it answered False where the object form answered True.
|
|
204
|
+
assert cls(path).is_relative_to(other) is expected
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@pytest.mark.parametrize("cls", IMPLS)
|
|
208
|
+
@pytest.mark.parametrize("path,other", [("a/b", "b"), ("a/b", "c"), ("ab/c", "a")])
|
|
209
|
+
def test_is_relative_to_negative_str_and_object_agree_with_stdlib(cls, path, other):
|
|
210
|
+
expected = pathlib.PurePosixPath(path).is_relative_to(other)
|
|
211
|
+
assert expected is False
|
|
212
|
+
assert cls(path).is_relative_to(cls(other)) is expected
|
|
213
|
+
assert cls(path).is_relative_to(other) is expected
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def test_mempath_is_relative_to_str_keeps_the_backend():
|
|
217
|
+
# with_segments(), not type(self)(other): the bare constructor hands
|
|
218
|
+
# MemPath a fresh empty backend, which is the kind of per-instance
|
|
219
|
+
# state a generic normalization must not drop.
|
|
220
|
+
from pathlib_next.mempath import MemPathBackend
|
|
221
|
+
|
|
222
|
+
backend = MemPathBackend()
|
|
223
|
+
p = MemPath("a/b", backend=backend)
|
|
224
|
+
assert p.is_relative_to("a") is True
|
|
225
|
+
assert p.with_segments("a").backend is backend
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def test_generic_pathname_subclass_gets_working_equality():
|
|
229
|
+
"""A minimal Track A subclass -- the shape `docs/guides/extending.md`
|
|
230
|
+
documents -- must get equality (and therefore is_relative_to) without
|
|
231
|
+
hand-writing __eq__."""
|
|
232
|
+
from pathlib_next.path import Pathname
|
|
233
|
+
|
|
234
|
+
class Toy(Pathname):
|
|
235
|
+
__slots__ = ("_segments",)
|
|
236
|
+
|
|
237
|
+
def __init__(self, *segments):
|
|
238
|
+
parts = []
|
|
239
|
+
for segment in segments:
|
|
240
|
+
if isinstance(segment, Pathname):
|
|
241
|
+
parts.extend(segment.segments)
|
|
242
|
+
else:
|
|
243
|
+
parts.append(segment)
|
|
244
|
+
self._segments = "/".join(parts).split("/")
|
|
245
|
+
|
|
246
|
+
@property
|
|
247
|
+
def segments(self):
|
|
248
|
+
return self._segments
|
|
249
|
+
|
|
250
|
+
@property
|
|
251
|
+
def parts(self):
|
|
252
|
+
return tuple(self._segments)
|
|
253
|
+
|
|
254
|
+
@property
|
|
255
|
+
def parent(self):
|
|
256
|
+
return self.with_segments(*self._segments[:-1])
|
|
257
|
+
|
|
258
|
+
def with_segments(self, *segments):
|
|
259
|
+
return type(self)(*segments)
|
|
260
|
+
|
|
261
|
+
def relative_to(self, other):
|
|
262
|
+
raise NotImplementedError()
|
|
263
|
+
|
|
264
|
+
def as_uri(self):
|
|
265
|
+
return "toy:" + self.as_posix()
|
|
266
|
+
|
|
267
|
+
assert Toy("a", "b") == Toy("a/b")
|
|
268
|
+
assert Toy("a/b") != Toy("a/c")
|
|
269
|
+
assert len({Toy("a/b"), Toy("a/b")}) == 1
|
|
270
|
+
assert Toy("a/b").is_relative_to(Toy("a")) is True
|
|
271
|
+
assert Toy("a/b").is_relative_to("a") is True
|
|
272
|
+
assert Toy("a/b").is_relative_to("c") is False
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def test_localpath_keeps_stdlib_equality_semantics():
|
|
276
|
+
"""`pathlib.PurePath` precedes `Pathname` in the fspath MRO, so the new
|
|
277
|
+
default must NOT displace stdlib's (case-folding, cross-subclass)
|
|
278
|
+
equality there."""
|
|
279
|
+
from pathlib_next.fspath import LocalPath, _BaseFSPathname
|
|
280
|
+
from pathlib_next.path import Pathname
|
|
281
|
+
|
|
282
|
+
for cls in (LocalPath, PosixPathname):
|
|
283
|
+
names = [k.__name__ for k in cls.__mro__]
|
|
284
|
+
assert names.index("PurePath") < names.index("Pathname")
|
|
285
|
+
assert cls.__eq__ is not Pathname.__eq__
|
|
286
|
+
assert cls.__eq__ is pathlib.PurePath.__eq__
|
|
287
|
+
|
|
288
|
+
assert issubclass(PosixPathname, _BaseFSPathname)
|
|
289
|
+
assert LocalPath("a/b") == pathlib.Path("a/b")
|
|
@@ -161,6 +161,21 @@ def test_is_relative_to_matches_pure_posix_path(segs):
|
|
|
161
161
|
u, pp = Uri(path_str), pathlib.PurePosixPath(path_str)
|
|
162
162
|
u_parent, pp_parent = Uri(parent_str), pathlib.PurePosixPath(parent_str)
|
|
163
163
|
assert u.is_relative_to(u_parent) == pp.is_relative_to(pp_parent) == True
|
|
164
|
+
# The str form must agree with the object form: it used to anchor
|
|
165
|
+
# `other` at self's root (Uri(self, _ROOT, other)) and answer False for
|
|
166
|
+
# every *relative* self.
|
|
167
|
+
assert u.is_relative_to(parent_str) == pp.is_relative_to(parent_str) == True
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@given(segs=nonempty_segments_list)
|
|
171
|
+
@settings(max_examples=200)
|
|
172
|
+
def test_is_relative_to_str_matches_pure_posix_path_for_unrelated_other(segs):
|
|
173
|
+
# The negative direction of the same oracle: a str `other` that is not a
|
|
174
|
+
# prefix must answer False for both, relative and absolute self alike.
|
|
175
|
+
other_str = _posix(["zzz-unrelated"])
|
|
176
|
+
for path_str in (_posix(segs), "/" + _posix(segs)):
|
|
177
|
+
u, pp = Uri(path_str), pathlib.PurePosixPath(path_str)
|
|
178
|
+
assert u.is_relative_to(other_str) == pp.is_relative_to(other_str)
|
|
164
179
|
|
|
165
180
|
|
|
166
181
|
# --- uritools oracle: one-pass parse/compose fast paths ---
|
|
@@ -315,9 +315,16 @@ def test_sftp_backend_connect_and_client():
|
|
|
315
315
|
transport = backend.transport(source)
|
|
316
316
|
assert transport is mock_transport
|
|
317
317
|
mock_ssh.set_missing_host_key_policy.assert_called_with("policy")
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
318
|
+
# Assert the arguments this test is ABOUT, not the whole call: the
|
|
319
|
+
# backend also merges the developer's real ~/.ssh/config, so a machine
|
|
320
|
+
# with an `identityfile` entry adds `key_filename=` and an exact-call
|
|
321
|
+
# assertion fails there while passing on a key-less CI runner.
|
|
322
|
+
connect_kwargs = mock_ssh.connect.call_args.kwargs
|
|
323
|
+
assert connect_kwargs["timeout"] == 10
|
|
324
|
+
assert connect_kwargs["hostname"] == "host"
|
|
325
|
+
assert connect_kwargs["port"] == 2222
|
|
326
|
+
assert connect_kwargs["username"] == "user"
|
|
327
|
+
assert connect_kwargs["password"] == "pass"
|
|
321
328
|
|
|
322
329
|
# Test client()
|
|
323
330
|
client = backend.client(source)
|
|
@@ -547,7 +547,7 @@ def test_sync_symlink_preserve_cross_backend_unsupported_target_raises(
|
|
|
547
547
|
tmp_path,
|
|
548
548
|
):
|
|
549
549
|
# SFTP source (supports symlink_to) -> MemPath target (does not):
|
|
550
|
-
# the documented
|
|
550
|
+
# the documented behavior is NotImplementedError surfaced through
|
|
551
551
|
# ignore_error/hook(), not a silent skip or crash.
|
|
552
552
|
backend = _FakeSymlinkBackend()
|
|
553
553
|
source = _fake_sftp("sftp://host/link.txt", backend=backend)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|