pathlib-next 0.9.2__tar.gz → 0.9.3__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.2 → pathlib_next-0.9.3}/CHANGELOG.md +47 -1
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/PKG-INFO +1 -1
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/docs/divergences.md +1 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/pyproject.toml +1 -1
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/AGENTS.md +22 -4
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/path.py +17 -4
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/uri/__init__.py +59 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/archive/_base.py +1 -2
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/az.py +1 -2
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/dav.py +1 -2
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/ftp.py +1 -2
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/gs.py +1 -2
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/s3.py +1 -2
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/sftp/__init__.py +5 -3
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_archive_uri.py +15 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_ftp.py +14 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_s3.py +14 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_sftp.py +129 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_uri_path.py +58 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/.gitignore +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/LICENSE +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/README.md +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/docs/api/mempath.md +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/docs/api/path.md +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/docs/api/testing.md +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/docs/api/uri.md +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/docs/api/utils.md +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/docs/benchmarks.md +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/docs/changelog.md +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/docs/guides/cli.md +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/docs/guides/extending.md +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/docs/guides/schemes.md +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/docs/index.md +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/examples/az_listing.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/examples/data_and_archive.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/examples/ftp_listing.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/examples/github_listing.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/examples/gitlab_listing.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/examples/gs_listing.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/examples/http_listing.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/examples/local_and_mem.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/examples/s3_listing.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/examples/sftp_sync.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/examples/webdav_roundtrip.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/mkdocs.yml +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/__init__.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/fspath.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/mempath.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/protocols/__init__.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/protocols/checksum.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/protocols/fs.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/protocols/io.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/py.typed +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/testing.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/tools/__init__.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/tools/uripath.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/uri/query.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/__init__.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/_gitrepo.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/archive/__init__.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/archive/tar.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/archive/zip.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/data.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/file.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/git/__init__.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/git/_base.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/git/github.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/git/gitlab.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/github.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/gitlab.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/http.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/sftp/_asyncssh.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/sftp/_paramiko.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/sftp/_sshconfig.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/uri/source.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/utils/__init__.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/utils/archive.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/utils/checksum.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/utils/glob.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/utils/stat.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/utils/sync.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/conftest.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_az.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_az_fake.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_checksum.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_contract.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_data_uri.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_dav.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_gitrepo.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_glob.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_gs.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_gs_fake.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_http.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_http_live.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_http_parser.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_local.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_mempath.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_mro_precedence.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_parity_io.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_parity_pure.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_path_gaps.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_pathname.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_plugins.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_properties.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_query.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_sftp_asyncssh.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_smoke.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_source.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_sync.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_uri_parse.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_uripath_tool.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_utils.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_walk.py +0 -0
- {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_webdav.py +0 -0
|
@@ -7,6 +7,51 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.9.3] - 2026-08-16
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **A `str` destination to `rename()`/`symlink_to()` was re-parsed as a URI,
|
|
14
|
+
so part of it was silently discarded.** Every scheme resolved the
|
|
15
|
+
destination by feeding it back through the URI parser
|
|
16
|
+
(`Uri(self.parent, target)` for `rename()`, `type(self)(target)` inside
|
|
17
|
+
`Path.symlink_to()`). That reads a **decoded filesystem path** as URI
|
|
18
|
+
syntax: everything from a `?` or `#` onward became a query/fragment and was
|
|
19
|
+
dropped, `%xx` was percent-decoded, and a relative destination whose first
|
|
20
|
+
segment ended in `:` was read as a *scheme*. Measured against a real SFTP
|
|
21
|
+
server (TrueNAS 26.0.0-BETA.1):
|
|
22
|
+
|
|
23
|
+
| call | file/link actually produced |
|
|
24
|
+
| --- | --- |
|
|
25
|
+
| `rename(".../rn?b.txt")` | `.../rn` |
|
|
26
|
+
| `rename(".../rn%20b.txt")` | `.../rn b.txt` |
|
|
27
|
+
| `symlink_to(".../cache?v=2")` | link points at `.../cache` |
|
|
28
|
+
| `rename("C:/Temp/x.txt")` | `/Temp/x.txt` (`C:` taken as a scheme) |
|
|
29
|
+
|
|
30
|
+
Nothing raised. When something already occupied the truncated name the call
|
|
31
|
+
instead failed with a bare `OSError: Failure`, so the symptom was either
|
|
32
|
+
silent misplacement or an unexplained error depending on what happened to
|
|
33
|
+
be there. Downstream, `pytruenas`'s documented
|
|
34
|
+
`client.path(x).symlink_to(y)` route created a wrong link, and
|
|
35
|
+
`PathSyncer`'s `symlink_mode="preserve"` (which hands `symlink_to()` the
|
|
36
|
+
raw target string `readlink()` returned) mirrored such a link to the wrong
|
|
37
|
+
place.
|
|
38
|
+
|
|
39
|
+
A `str` destination is now taken as an already-decoded path — `?`, `#`,
|
|
40
|
+
`%` and `:` are ordinary filename characters — via the new
|
|
41
|
+
`Uri._from_decoded_path()`, one implementation shared by
|
|
42
|
+
`Uri._rename_target()` (used by `SftpPath`, `FtpPath`, `DavPath`,
|
|
43
|
+
`S3Path`, `GsPath`, `AzPath` and `ArchiveUri`) and by
|
|
44
|
+
`UriPath._symlink_target()`, an override of a new `Path._symlink_target()`
|
|
45
|
+
hook. Relative destinations keep their existing meaning: a `rename()`
|
|
46
|
+
destination is a sibling, a `symlink_to()` target is stored verbatim and
|
|
47
|
+
stays relative. The string is **not** percent-encoded on the way in, so a
|
|
48
|
+
destination that legitimately contains a literal `%20` — or a path object
|
|
49
|
+
built by a consumer that already encoded it — is not encoded twice.
|
|
50
|
+
`readlink()`, `unlink()`, `rmdir()` and `hardlink_to()` never had this
|
|
51
|
+
defect. `copy()`/`move()` are deliberately unchanged: their `str`
|
|
52
|
+
destination is still parsed as a URI, which is what makes a cross-scheme
|
|
53
|
+
`copy("s3://bucket/key")` work. See `docs/divergences.md`.
|
|
54
|
+
|
|
10
55
|
## [0.9.2] - 2026-08-16
|
|
11
56
|
|
|
12
57
|
### Fixed
|
|
@@ -778,7 +823,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
778
823
|
- Sync error handling.
|
|
779
824
|
- Generic Path Protocol based pathlib implementation for URI paths with file access support for sftp, http, file schemes.
|
|
780
825
|
|
|
781
|
-
[Unreleased]: https://github.com/jose-pr/pathlib-next/compare/v0.9.
|
|
826
|
+
[Unreleased]: https://github.com/jose-pr/pathlib-next/compare/v0.9.3...HEAD
|
|
827
|
+
[0.9.3]: https://github.com/jose-pr/pathlib-next/compare/v0.9.2...v0.9.3
|
|
782
828
|
[0.9.2]: https://github.com/jose-pr/pathlib-next/compare/v0.9.1...v0.9.2
|
|
783
829
|
[0.9.1]: https://github.com/jose-pr/pathlib-next/compare/v0.9.0...v0.9.1
|
|
784
830
|
[0.8.6]: https://github.com/jose-pr/pathlib-next/compare/v0.8.5...v0.8.6
|
|
@@ -63,6 +63,7 @@ these operations itself always keeps its own implementation.
|
|
|
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
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
|
+
| A `str` destination to `rename()`/`symlink_to()` on a `UriPath` | `pathlib.Path.rename(str)`/`symlink_to(str)` take the string as a path, verbatim; a relative one is resolved against the **cwd** | The string is taken as an already-**decoded path**, never re-parsed as URI syntax: `?`, `#`, `%` and `:` are ordinary filename characters, so `rename("rn?b.txt")` renames to `rn?b.txt`. A relative `rename()` destination resolves against `self.parent` (sibling rename), since a URI has no cwd; a relative `symlink_to()` target is stored verbatim and stays relative, exactly as pathlib does. `copy()`/`move()` are unchanged -- their `str` destination is still parsed as a URI, which is what makes a cross-scheme `copy("s3://bucket/key")` work. | Restores pathlib parity on the two methods whose destination is unambiguously a path on the same host. Re-parsing it as a URI discarded everything from a `?`/`#` onward and percent-decoded the rest -- silently, so a rename landed on a different file and a symlink pointed somewhere else (measured against a real SFTP server, 0.9.3). Percent-encoding the string before parsing was rejected: it double-encodes a name that legitimately contains a literal `%20`, and it puts a copy of the safe set in every consumer. The parse is bypassed instead -- `Uri._from_decoded_path()`, one implementation for every scheme. |
|
|
66
67
|
| `PathSyncer` / `Query` / `Source` | N/A | Our own extensions | N/A -- pure extensions, no pathlib equivalent. |
|
|
67
68
|
| `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.** |
|
|
68
69
|
| `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. |
|
|
@@ -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.3"
|
|
16
16
|
authors = [{ name = "Jose A" }]
|
|
17
17
|
description = "Generic Path Protocol based pathlib"
|
|
18
18
|
readme = "README.md"
|
|
@@ -73,15 +73,21 @@ pathlib_next`.
|
|
|
73
73
|
equivalent. Removes a file or (with `recursive=True`) a directory tree;
|
|
74
74
|
`ignore_error` (bool or predicate) controls whether an error during the
|
|
75
75
|
walk is swallowed (predicate return `True`) or re-raised.
|
|
76
|
-
- `rename(target)` — not implemented by default.
|
|
76
|
+
- `rename(target)` — not implemented by default. On a `UriPath` a `str`
|
|
77
|
+
`target` is an already-**decoded path**, not URI syntax, and a relative
|
|
78
|
+
one is a sibling rename — see `Uri._rename_target()` under "URIs".
|
|
77
79
|
- `_symlink_to(target, target_is_directory=False)` (not implemented by
|
|
78
80
|
default) / `symlink_to(target, target_is_directory=False, *,
|
|
79
81
|
force=False)` — same primitive/wrapper split as `_mkdir`/`mkdir`: a
|
|
80
82
|
backend implements only `_symlink_to()` and receives an already
|
|
81
|
-
normalized path object
|
|
82
|
-
wrapper, as `copy()`/`move()` do), then reads the raw target string the
|
|
83
|
+
normalized path object, then reads the raw target string the
|
|
83
84
|
way its transport needs (`Uri.path` on the wire, `os.fspath()`
|
|
84
|
-
locally). `
|
|
85
|
+
locally). The `str`→path step is `_symlink_target(target)`, an
|
|
86
|
+
overridable hook: the default is `type(self)(target)`, and `UriPath`
|
|
87
|
+
overrides it so a link target is taken literally instead of being
|
|
88
|
+
re-parsed as a URI (a `?`/`#` in it is a filename character, not a
|
|
89
|
+
delimiter). A relative target is never anchored — it stays relative,
|
|
90
|
+
as in pathlib. `force=` is this library's extension: `False` is
|
|
85
91
|
stdlib-exact, `True` unlinks an existing **non-directory** entry at the
|
|
86
92
|
link path first (never a directory) and is **not** atomic. Listed in
|
|
87
93
|
`_OPERATION_NAMES`, since no stdlib version accepts `force=`.
|
|
@@ -232,6 +238,18 @@ extra that depends on it).
|
|
|
232
238
|
is the unambiguous accessor for "path on the URI's own host" — same
|
|
233
239
|
`_host_filesystem_path` gate, but never falls back to local-path
|
|
234
240
|
semantics. See `docs/divergences.md`.
|
|
241
|
+
Destination/target normalization (used by every scheme's `rename()` and
|
|
242
|
+
by `symlink_to()`): `_from_decoded_path(path)` builds a same-type URI
|
|
243
|
+
whose `.path` is `path` **verbatim** — an already-decoded path string,
|
|
244
|
+
not URI syntax, so `?`, `#`, `%` and `:` in it are ordinary filename
|
|
245
|
+
characters and only dot segments are normalized.
|
|
246
|
+
`_rename_target(target)` is what `rename()` calls: a `Uri` passes
|
|
247
|
+
through untouched, a `str` goes through `_from_decoded_path()` and, if
|
|
248
|
+
relative, is joined onto `self.parent` (sibling rename — a URI has no
|
|
249
|
+
cwd). `_symlink_target(target)` (overriding `Path`'s) is the same minus
|
|
250
|
+
the parent anchoring, so a relative link target stays relative.
|
|
251
|
+
`copy()`/`move()` deliberately still parse a `str` destination as a URI
|
|
252
|
+
— that is what makes a cross-scheme `copy("s3://bucket/key")` work.
|
|
235
253
|
- **`UriPath(Uri, Path)`** — `Uri` + `Path` (I/O) + scheme dispatch.
|
|
236
254
|
`UriPath(*uris, **options)` (the bare class) parses the URI and returns an
|
|
237
255
|
instance of the concrete subclass registered for its scheme via
|
|
@@ -732,6 +732,17 @@ class Path(Pathname, Chmod, Stat, BinaryOpen):
|
|
|
732
732
|
"""
|
|
733
733
|
...
|
|
734
734
|
|
|
735
|
+
def _symlink_target(self, target: "_ty.Self | str") -> "_ty.Self":
|
|
736
|
+
"""Normalize a `symlink_to()` target argument to a path object.
|
|
737
|
+
|
|
738
|
+
A `str` target is the **literal link target** -- whatever it says
|
|
739
|
+
is what gets stored, verbatim and unresolved, exactly as
|
|
740
|
+
`pathlib.Path.symlink_to()` does. Override this wherever
|
|
741
|
+
`type(self)(str)` would reinterpret the string instead of taking
|
|
742
|
+
it literally (`UriPath` does; see `UriPath._symlink_target`).
|
|
743
|
+
"""
|
|
744
|
+
return type(self)(target) if isinstance(target, str) else target
|
|
745
|
+
|
|
735
746
|
def symlink_to(
|
|
736
747
|
self,
|
|
737
748
|
target: "_ty.Self | str",
|
|
@@ -758,10 +769,12 @@ class Path(Pathname, Chmod, Stat, BinaryOpen):
|
|
|
758
769
|
for.
|
|
759
770
|
"""
|
|
760
771
|
# Normalize a str target to a path object, so the primitive only
|
|
761
|
-
# ever handles one type
|
|
762
|
-
#
|
|
763
|
-
|
|
764
|
-
|
|
772
|
+
# ever handles one type. Routed through `_symlink_target()` rather
|
|
773
|
+
# than inlining `type(self)(target)`: for a URI-backed path that
|
|
774
|
+
# constructor re-parses the string as URI syntax, which silently
|
|
775
|
+
# truncated a link target at a "?"/"#" and percent-decoded it (see
|
|
776
|
+
# `UriPath._symlink_target`).
|
|
777
|
+
target = self._symlink_target(target)
|
|
765
778
|
if force:
|
|
766
779
|
try:
|
|
767
780
|
self.unlink(missing_ok=True)
|
|
@@ -232,6 +232,50 @@ class Uri(Pathname):
|
|
|
232
232
|
uri._init(source, path, query, fragment, **kwargs)
|
|
233
233
|
return uri
|
|
234
234
|
|
|
235
|
+
def _from_decoded_path(self, path: str, /, **kwargs) -> "_ty.Self":
|
|
236
|
+
"""Build a same-type URI whose `.path` is `path` verbatim.
|
|
237
|
+
|
|
238
|
+
`path` is an **already-decoded path string**, not URI syntax: `?`,
|
|
239
|
+
`#`, `%` and `:` are ordinary filename characters here. Only
|
|
240
|
+
path-level normalization (dot segments, exactly what `_parse_uri`
|
|
241
|
+
applies *after* decoding) is performed -- nothing is split off and
|
|
242
|
+
nothing is percent-decoded.
|
|
243
|
+
|
|
244
|
+
This is what a destination/target argument must go through.
|
|
245
|
+
Feeding such a string back into the URI parser (`Uri(path)`,
|
|
246
|
+
`type(self)(path)`) reads it as syntax: "a?b.txt" silently became
|
|
247
|
+
"a" plus a query, "a#b.txt" became "a" plus a fragment,
|
|
248
|
+
"a%20b.txt" became "a b.txt", and a relative "C:/Temp/x" became
|
|
249
|
+
scheme "c" plus "/Temp/x" -- so the wire call went to a different
|
|
250
|
+
file than the caller named, with no error. Percent-encoding the
|
|
251
|
+
string before parsing would fix the truncation but re-encode an
|
|
252
|
+
already-encoded name (a literal "%20" would come back as a space),
|
|
253
|
+
so the parse is bypassed instead of being fed encoded input.
|
|
254
|
+
"""
|
|
255
|
+
return self._from_parsed_parts(
|
|
256
|
+
_NOSOURCE, _remove_dot_segments(path), None, None, **kwargs
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
def _rename_target(self, target: UriLike) -> "Uri":
|
|
260
|
+
"""Normalize a `rename()`/`replace()` destination to a `Uri`.
|
|
261
|
+
|
|
262
|
+
A `str` is an already-decoded path (see `_from_decoded_path`), and
|
|
263
|
+
a relative one is resolved against `self.parent` -- the documented
|
|
264
|
+
sibling-rename semantics ("rename this to a new name in the same
|
|
265
|
+
directory"), not against `self` itself. A `Uri` (of any scheme
|
|
266
|
+
class) is taken as given; anything else keeps the pre-existing
|
|
267
|
+
`Uri(...)` conversion, which is already lossless for
|
|
268
|
+
`PurePath`/`os.PathLike` (they are percent-encoded on the way in
|
|
269
|
+
and decoded back out).
|
|
270
|
+
"""
|
|
271
|
+
if isinstance(target, Uri):
|
|
272
|
+
return target
|
|
273
|
+
if isinstance(target, str):
|
|
274
|
+
target = self._from_decoded_path(target)
|
|
275
|
+
# target is a Uri by now, so this join re-uses `_load_parts`'
|
|
276
|
+
# existing right-to-left semantics without re-parsing anything.
|
|
277
|
+
return Uri(self.parent, target)
|
|
278
|
+
|
|
235
279
|
@classmethod
|
|
236
280
|
def _format_parsed_parts(
|
|
237
281
|
cls,
|
|
@@ -691,6 +735,21 @@ class UriPath(Uri, Path):
|
|
|
691
735
|
inst._init(source, self.path, self.query, self.fragment)
|
|
692
736
|
return inst
|
|
693
737
|
|
|
738
|
+
def _symlink_target(self, target: "UriLike") -> "_ty.Self":
|
|
739
|
+
"""`Path._symlink_target()` for URI-backed paths: a `str` target is
|
|
740
|
+
an already-decoded path, never URI syntax (see
|
|
741
|
+
`Uri._from_decoded_path`).
|
|
742
|
+
|
|
743
|
+
The default `type(self)(target)` ran the link target back through
|
|
744
|
+
the URI parser, so `symlink_to("/mnt/cache?v=2")` created a link
|
|
745
|
+
pointing at `/mnt/cache`. Unlike `_rename_target()` this never
|
|
746
|
+
anchors at `self.parent`: a symlink target is stored as given, so
|
|
747
|
+
a relative one stays relative.
|
|
748
|
+
"""
|
|
749
|
+
if isinstance(target, str):
|
|
750
|
+
return self._from_decoded_path(target)
|
|
751
|
+
return target
|
|
752
|
+
|
|
694
753
|
@_utils.notimplemented
|
|
695
754
|
def _listdir(self) -> "_ty.Iterator[str]": ...
|
|
696
755
|
|
|
@@ -283,8 +283,7 @@ class ArchiveUri(UriPath):
|
|
|
283
283
|
# A plain str target is a sibling rename (relative to self's
|
|
284
284
|
# parent), matching sftp.py's/ftp.py's rename() semantics.
|
|
285
285
|
self._require_writable()
|
|
286
|
-
|
|
287
|
-
target = Uri(self.parent, target)
|
|
286
|
+
target = self._rename_target(target)
|
|
288
287
|
old_path = self.path
|
|
289
288
|
new_path = target.path.lstrip("/")
|
|
290
289
|
names = self._names()
|
|
@@ -282,8 +282,7 @@ class AzPath(UriPath):
|
|
|
282
282
|
raise
|
|
283
283
|
|
|
284
284
|
def rename(self, target: "AzPath | Uri | str"):
|
|
285
|
-
|
|
286
|
-
target = Uri(self.parent, target)
|
|
285
|
+
target = self._rename_target(target)
|
|
287
286
|
dest_key = (
|
|
288
287
|
self.with_segments(target).key
|
|
289
288
|
if not isinstance(target, AzPath)
|
|
@@ -212,8 +212,7 @@ class DavPath(HttpPath):
|
|
|
212
212
|
raise
|
|
213
213
|
|
|
214
214
|
def rename(self, target: "DavPath | Uri | str"):
|
|
215
|
-
|
|
216
|
-
target = Uri(self.parent, target)
|
|
215
|
+
target = self._rename_target(target)
|
|
217
216
|
dest = self.with_path(target.path)._wire_uri()
|
|
218
217
|
resp = self.backend.request(
|
|
219
218
|
"MOVE", self._wire_uri(), headers={"Destination": dest, "Overwrite": "F"}
|
|
@@ -228,8 +228,7 @@ class FtpPath(UriPath):
|
|
|
228
228
|
def rename(self, target: "FtpPath | Uri | str"):
|
|
229
229
|
# A plain str target is a sibling rename (relative to self's
|
|
230
230
|
# parent), matching sftp.py's rename() semantics.
|
|
231
|
-
|
|
232
|
-
target = Uri(self.parent, target)
|
|
231
|
+
target = self._rename_target(target)
|
|
233
232
|
self._ftpclient.rename(self.path, target.path)
|
|
234
233
|
|
|
235
234
|
def chmod(self, mode: int | str, *, follow_symlinks: bool = True):
|
|
@@ -247,8 +247,7 @@ class GsPath(UriPath):
|
|
|
247
247
|
raise
|
|
248
248
|
|
|
249
249
|
def rename(self, target: "GsPath | Uri | str"):
|
|
250
|
-
|
|
251
|
-
target = Uri(self.parent, target)
|
|
250
|
+
target = self._rename_target(target)
|
|
252
251
|
dest_key = target.path.lstrip("/")
|
|
253
252
|
source_blob = self._bucket.blob(self.key)
|
|
254
253
|
self._bucket.copy_blob(source_blob, self._bucket, dest_key)
|
|
@@ -254,8 +254,7 @@ class S3Path(UriPath):
|
|
|
254
254
|
raise
|
|
255
255
|
|
|
256
256
|
def rename(self, target: "S3Path | Uri | str"):
|
|
257
|
-
|
|
258
|
-
target = Uri(self.parent, target)
|
|
257
|
+
target = self._rename_target(target)
|
|
259
258
|
dest_key = target.path.lstrip("/")
|
|
260
259
|
self._client.copy_object(
|
|
261
260
|
Bucket=self.bucket,
|
|
@@ -357,9 +357,11 @@ class SftpPath(UriPath):
|
|
|
357
357
|
# "host:" for the sftp wire protocol, which only wants the raw path.
|
|
358
358
|
# A plain str target is resolved relative to self's *parent*
|
|
359
359
|
# (sibling rename -- "rename this file to a new name in the same
|
|
360
|
-
# directory"), not to self itself (which would join it as a child)
|
|
361
|
-
|
|
362
|
-
|
|
360
|
+
# directory"), not to self itself (which would join it as a child)
|
|
361
|
+
# -- and is taken as a literal path rather than re-parsed as a URI,
|
|
362
|
+
# which used to truncate "rn?b.txt" to "rn" on the wire (see
|
|
363
|
+
# `Uri._rename_target`).
|
|
364
|
+
target = self._rename_target(target)
|
|
363
365
|
return self._sftpclient.rename(self.path, target.path)
|
|
364
366
|
|
|
365
367
|
def _symlink_to(
|
|
@@ -402,3 +402,18 @@ def test_archive_scheme_tar_detected_write_raises_not_implemented(tar_archive):
|
|
|
402
402
|
p = UriPath(_archive_uri(tar_archive)) / "new.txt"
|
|
403
403
|
with pytest.raises(NotImplementedError):
|
|
404
404
|
p.write_text("nope")
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
# --- destination member names are decoded paths, not URI syntax (0.9.3) ---
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
@pytest.mark.parametrize("name", ["re?named.txt", "re#named.txt", "re%20named.txt"])
|
|
411
|
+
def test_zip_rename_str_destination_is_a_literal_member_name(zip_archive, name):
|
|
412
|
+
# Asserted against the archive's own namelist, not via `root / name`:
|
|
413
|
+
# `__truediv__` is a URI join and would re-interpret the "?" itself.
|
|
414
|
+
root = UriPath(_zip_uri(zip_archive))
|
|
415
|
+
(root / "top.txt").rename(name)
|
|
416
|
+
with zipfile.ZipFile(zip_archive) as zf:
|
|
417
|
+
assert name in zf.namelist()
|
|
418
|
+
assert zf.read(name) == b"top level"
|
|
419
|
+
assert "top.txt" not in zf.namelist()
|
|
@@ -365,3 +365,17 @@ def test_chmod_follow_symlinks_false_raises_notimplemented():
|
|
|
365
365
|
p = _ftp("ftp://host/a.txt", backend=backend)
|
|
366
366
|
with pytest.raises(NotImplementedError):
|
|
367
367
|
p.chmod(0o644, follow_symlinks=False)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
# --- destination arguments are decoded paths, not URI syntax (0.9.3) ------
|
|
371
|
+
# Same defect as SftpPath's (`Uri(self.parent, target)` re-parsed the
|
|
372
|
+
# destination as a URI); FtpPath's `.path` is likewise a filesystem path on
|
|
373
|
+
# the server, so "?" / "#" / "%xx" in it are ordinary filename characters.
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
@pytest.mark.parametrize("name", ["rn?b.txt", "rn#b.txt", "rn%20b.txt", "rn:b.txt"])
|
|
377
|
+
def test_rename_str_destination_is_a_literal_path(name):
|
|
378
|
+
backend = _FakeBackend()
|
|
379
|
+
p = _ftp("ftp://host/mnt/a.txt", backend=backend)
|
|
380
|
+
p.rename(name)
|
|
381
|
+
assert backend._client.rename_calls == [("/mnt/a.txt", f"/mnt/{name}")]
|
|
@@ -294,3 +294,17 @@ def test_rename_uses_copy_then_delete():
|
|
|
294
294
|
def test_chmod_not_implemented():
|
|
295
295
|
with pytest.raises(NotImplementedError):
|
|
296
296
|
_s3("s3://bucket/a.txt").chmod(0o644)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
# --- destination keys are decoded paths, not URI syntax (0.9.3) ----------
|
|
300
|
+
# "?" and "#" are legal S3 key characters; the old
|
|
301
|
+
# `Uri(self.parent, target)` truncated the destination key at either one.
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
@pytest.mark.parametrize("name", ["b?x.txt", "b#x.txt", "b%20x.txt"])
|
|
305
|
+
def test_rename_str_destination_key_is_literal(name):
|
|
306
|
+
backend = _FakeBackend()
|
|
307
|
+
backend._client.objects["a.txt"] = b"content"
|
|
308
|
+
_s3("s3://bucket/a.txt", backend).rename(name)
|
|
309
|
+
assert backend._client.objects.get(name) == b"content"
|
|
310
|
+
assert "a.txt" not in backend._client.objects
|
|
@@ -34,6 +34,8 @@ class _FakeSftpClient:
|
|
|
34
34
|
self.rename_calls = []
|
|
35
35
|
self.chmod_calls = []
|
|
36
36
|
self.chown_calls = []
|
|
37
|
+
self.symlink_calls = []
|
|
38
|
+
self.link_calls = []
|
|
37
39
|
# Owner reported by stat(), so a partial chown() (one field left
|
|
38
40
|
# "unchanged") has something to read back for the other field.
|
|
39
41
|
self.stat_uid = 501
|
|
@@ -72,6 +74,12 @@ class _FakeSftpClient:
|
|
|
72
74
|
def mkdir(self, path, mode):
|
|
73
75
|
pass
|
|
74
76
|
|
|
77
|
+
def symlink(self, target, path):
|
|
78
|
+
self.symlink_calls.append((target, path))
|
|
79
|
+
|
|
80
|
+
def link(self, target, path):
|
|
81
|
+
self.link_calls.append((target, path))
|
|
82
|
+
|
|
75
83
|
|
|
76
84
|
class _FakeBackend(BaseSftpBackend):
|
|
77
85
|
def __init__(self):
|
|
@@ -1048,3 +1056,124 @@ def test_chmod_rejects_non_octal_digits():
|
|
|
1048
1056
|
p = _sftp("sftp://host/a.txt")
|
|
1049
1057
|
with pytest.raises(ValueError):
|
|
1050
1058
|
p.chmod("0899")
|
|
1059
|
+
|
|
1060
|
+
|
|
1061
|
+
# --- destination arguments are decoded paths, not URI syntax (0.9.3) ------
|
|
1062
|
+
#
|
|
1063
|
+
# `rename()`/`symlink_to()` used to run a `str` destination back through the
|
|
1064
|
+
# URI parser (`Uri(self.parent, target)` / `type(self)(target)`), so anything
|
|
1065
|
+
# from a "?" or "#" onward was discarded and "%xx" was decoded -- silently,
|
|
1066
|
+
# and against a real server (measured on TrueNAS 26.0.0-BETA.1):
|
|
1067
|
+
# rename(".../rn?b.txt") -> the file became ".../rn"
|
|
1068
|
+
# symlink_to(".../cache?v=2") -> the link pointed at ".../cache"
|
|
1069
|
+
# Every assertion below is on the argument the BACKEND received, not on the
|
|
1070
|
+
# absence of an exception: the buggy code raised nothing at all.
|
|
1071
|
+
|
|
1072
|
+
# ("filename", "the path the wire call must carry for /mnt/<filename>")
|
|
1073
|
+
_DEST_NAMES = [
|
|
1074
|
+
"rn?b.txt", # query delimiter
|
|
1075
|
+
"rn#b.txt", # fragment delimiter
|
|
1076
|
+
"rn b.txt", # space (already survived, guards the fix)
|
|
1077
|
+
"rn%20b.txt", # LITERAL percent-escape: must NOT become "rn b.txt"
|
|
1078
|
+
"rn%b.txt", # bare, un-decodable percent
|
|
1079
|
+
"rn:b.txt", # colon must stay readable (a "/C:/Temp" path depends on it)
|
|
1080
|
+
"a+b&c=d.txt", # other sub-delims
|
|
1081
|
+
]
|
|
1082
|
+
|
|
1083
|
+
|
|
1084
|
+
@pytest.mark.parametrize("name", _DEST_NAMES)
|
|
1085
|
+
def test_rename_str_destination_is_a_literal_path(name):
|
|
1086
|
+
backend = _FakeBackend()
|
|
1087
|
+
p = _sftp("sftp://host/mnt/a.txt", backend=backend)
|
|
1088
|
+
p.rename(name)
|
|
1089
|
+
# Relative str == sibling rename, so it lands beside self.
|
|
1090
|
+
assert backend._client.rename_calls == [("/mnt/a.txt", f"/mnt/{name}")]
|
|
1091
|
+
|
|
1092
|
+
|
|
1093
|
+
@pytest.mark.parametrize("name", _DEST_NAMES)
|
|
1094
|
+
def test_rename_absolute_str_destination_is_a_literal_path(name):
|
|
1095
|
+
backend = _FakeBackend()
|
|
1096
|
+
p = _sftp("sftp://host/mnt/a.txt", backend=backend)
|
|
1097
|
+
p.rename(f"/other/{name}")
|
|
1098
|
+
assert backend._client.rename_calls == [("/mnt/a.txt", f"/other/{name}")]
|
|
1099
|
+
|
|
1100
|
+
|
|
1101
|
+
@pytest.mark.parametrize("name", _DEST_NAMES)
|
|
1102
|
+
def test_symlink_to_str_target_is_a_literal_path(name):
|
|
1103
|
+
backend = _FakeBackend()
|
|
1104
|
+
p = _sftp("sftp://host/mnt/link", backend=backend)
|
|
1105
|
+
p.symlink_to(f"/mnt/{name}")
|
|
1106
|
+
assert backend._client.symlink_calls == [(f"/mnt/{name}", "/mnt/link")]
|
|
1107
|
+
|
|
1108
|
+
|
|
1109
|
+
@pytest.mark.parametrize("name", _DEST_NAMES)
|
|
1110
|
+
def test_symlink_to_relative_str_target_stays_relative_and_literal(name):
|
|
1111
|
+
backend = _FakeBackend()
|
|
1112
|
+
p = _sftp("sftp://host/mnt/link", backend=backend)
|
|
1113
|
+
p.symlink_to(name)
|
|
1114
|
+
# Unlike rename()'s destination, a symlink target is stored verbatim --
|
|
1115
|
+
# never anchored at self.parent (pathlib.Path.symlink_to() parity).
|
|
1116
|
+
assert backend._client.symlink_calls == [(name, "/mnt/link")]
|
|
1117
|
+
|
|
1118
|
+
|
|
1119
|
+
@pytest.mark.parametrize("name", _DEST_NAMES)
|
|
1120
|
+
def test_hardlink_to_str_target_is_a_literal_path(name):
|
|
1121
|
+
class _HardlinkBackend(_FakeBackend):
|
|
1122
|
+
supports_hardlink = True
|
|
1123
|
+
|
|
1124
|
+
backend = _HardlinkBackend()
|
|
1125
|
+
p = _sftp("sftp://host/mnt/link", backend=backend)
|
|
1126
|
+
p.hardlink_to(f"/mnt/{name}")
|
|
1127
|
+
assert backend._client.link_calls == [(f"/mnt/{name}", "/mnt/link")]
|
|
1128
|
+
|
|
1129
|
+
|
|
1130
|
+
@pytest.mark.parametrize("name", _DEST_NAMES)
|
|
1131
|
+
def test_rename_path_object_destination_is_not_double_decoded(name):
|
|
1132
|
+
"""A destination that is already a path OBJECT must reach the wire
|
|
1133
|
+
exactly once-decoded.
|
|
1134
|
+
|
|
1135
|
+
This is the double-encoding guard: consumers (pytruenas 0.4.4/0.4.5)
|
|
1136
|
+
percent-encode a decoded filesystem path and construct the path from
|
|
1137
|
+
the resulting URI. The fix must not add a second encode/decode round
|
|
1138
|
+
on top -- a literal "%20" in the name is the case that catches it.
|
|
1139
|
+
"""
|
|
1140
|
+
import uritools
|
|
1141
|
+
|
|
1142
|
+
# RFC 3986 pchar + "/" -- ":" deliberately left unencoded so that a
|
|
1143
|
+
# "/C:/Temp/..." path stays readable in the URI.
|
|
1144
|
+
encoded = uritools.uriencode(f"/mnt/{name}", safe="/:@-._~!$&'()*+,;=").decode()
|
|
1145
|
+
backend = _FakeBackend()
|
|
1146
|
+
p = _sftp("sftp://host/mnt/a.txt", backend=backend)
|
|
1147
|
+
target = SftpPath(f"sftp://host{encoded}", backend=backend)
|
|
1148
|
+
assert target.path == f"/mnt/{name}"
|
|
1149
|
+
p.rename(target)
|
|
1150
|
+
assert backend._client.rename_calls == [("/mnt/a.txt", f"/mnt/{name}")]
|
|
1151
|
+
|
|
1152
|
+
|
|
1153
|
+
@pytest.mark.parametrize("name", _DEST_NAMES)
|
|
1154
|
+
def test_symlink_to_path_object_target_is_not_double_decoded(name):
|
|
1155
|
+
import uritools
|
|
1156
|
+
|
|
1157
|
+
encoded = uritools.uriencode(f"/mnt/{name}", safe="/:@-._~!$&'()*+,;=").decode()
|
|
1158
|
+
backend = _FakeBackend()
|
|
1159
|
+
p = _sftp("sftp://host/mnt/link", backend=backend)
|
|
1160
|
+
target = SftpPath(f"sftp://host{encoded}", backend=backend)
|
|
1161
|
+
p.symlink_to(target)
|
|
1162
|
+
assert backend._client.symlink_calls == [(f"/mnt/{name}", "/mnt/link")]
|
|
1163
|
+
|
|
1164
|
+
|
|
1165
|
+
def test_rename_str_destination_keeps_a_windows_style_drive_path():
|
|
1166
|
+
# A relative destination whose first segment ends in ":" used to be
|
|
1167
|
+
# read as a URI SCHEME: "C:/Temp/x.txt" parsed as scheme "c" with path
|
|
1168
|
+
# "/Temp/x.txt", so the rename left the directory entirely.
|
|
1169
|
+
backend = _FakeBackend()
|
|
1170
|
+
p = _sftp("sftp://host/mnt/a.txt", backend=backend)
|
|
1171
|
+
p.rename("C:/Temp/x.txt")
|
|
1172
|
+
assert backend._client.rename_calls == [("/mnt/a.txt", "/mnt/C:/Temp/x.txt")]
|
|
1173
|
+
|
|
1174
|
+
|
|
1175
|
+
def test_symlink_to_str_target_keeps_dot_dot_relative():
|
|
1176
|
+
backend = _FakeBackend()
|
|
1177
|
+
p = _sftp("sftp://host/mnt/sub/link", backend=backend)
|
|
1178
|
+
p.symlink_to("../real.txt")
|
|
1179
|
+
assert backend._client.symlink_calls == [("../real.txt", "/mnt/sub/link")]
|
|
@@ -140,3 +140,61 @@ def test_uripath_unimplemented_unlink():
|
|
|
140
140
|
p = UriPath("custom://host/a")
|
|
141
141
|
with pytest.raises(NotImplementedError):
|
|
142
142
|
p.unlink()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# --- destination/target normalization (0.9.3) ----------------------------
|
|
146
|
+
#
|
|
147
|
+
# The single place every scheme's `rename()` and `symlink_to()` now converts
|
|
148
|
+
# a `str` destination. Tested here, once, rather than per-scheme: a `str`
|
|
149
|
+
# destination is an already-decoded PATH, so feeding it back through the URI
|
|
150
|
+
# parser truncated it at "?"/"#", percent-decoded it, and read a leading
|
|
151
|
+
# "C:" as a scheme -- silently, on the wire.
|
|
152
|
+
|
|
153
|
+
_DECODED = ["rn?b.txt", "rn#b.txt", "rn b.txt", "rn%20b.txt", "rn%b.txt", "rn:b.txt"]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@pytest.mark.parametrize("name", _DECODED)
|
|
157
|
+
def test_rename_target_str_is_a_sibling_literal_path(name):
|
|
158
|
+
p = UriPath("customscheme://host/mnt/a.txt")
|
|
159
|
+
assert p._rename_target(name).path == f"/mnt/{name}"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@pytest.mark.parametrize("name", _DECODED)
|
|
163
|
+
def test_rename_target_absolute_str_is_a_literal_path(name):
|
|
164
|
+
p = UriPath("customscheme://host/mnt/a.txt")
|
|
165
|
+
assert p._rename_target(f"/other/{name}").path == f"/other/{name}"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@pytest.mark.parametrize("name", _DECODED)
|
|
169
|
+
def test_rename_target_uri_is_taken_as_given(name):
|
|
170
|
+
p = UriPath("customscheme://host/mnt/a.txt")
|
|
171
|
+
target = Uri("customscheme://host/mnt/x")._from_decoded_path(f"/mnt/{name}")
|
|
172
|
+
# An already-built path object must pass straight through: no second
|
|
173
|
+
# encode/decode round on top of whatever built it (consumers that
|
|
174
|
+
# percent-encode a decoded path and construct from the resulting URI
|
|
175
|
+
# would otherwise see a literal "%20" come back as a space).
|
|
176
|
+
assert p._rename_target(target).path == f"/mnt/{name}"
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@pytest.mark.parametrize("name", _DECODED)
|
|
180
|
+
def test_symlink_target_str_is_literal_and_never_anchored(name):
|
|
181
|
+
p = UriPath("customscheme://host/mnt/link")
|
|
182
|
+
# Relative stays relative -- unlike rename(), a symlink target is
|
|
183
|
+
# stored verbatim (pathlib.Path.symlink_to() parity).
|
|
184
|
+
assert p._symlink_target(name).path == name
|
|
185
|
+
assert p._symlink_target(f"/mnt/{name}").path == f"/mnt/{name}"
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def test_symlink_target_keeps_dot_dot_relative():
|
|
189
|
+
p = UriPath("customscheme://host/mnt/sub/link")
|
|
190
|
+
assert p._symlink_target("../real.txt").path == "../real.txt"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def test_from_decoded_path_keeps_backend_and_drops_query_fragment():
|
|
194
|
+
p = UriPath("customscheme://host/mnt/a.txt?q=1#f")
|
|
195
|
+
target = p._from_decoded_path("/mnt/b?c#d.txt")
|
|
196
|
+
assert target.path == "/mnt/b?c#d.txt"
|
|
197
|
+
# self's own query/fragment must not leak onto a destination.
|
|
198
|
+
assert not target.query
|
|
199
|
+
assert not target.fragment
|
|
200
|
+
assert target.backend is p.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
|
|
File without changes
|