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.
Files changed (114) hide show
  1. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/CHANGELOG.md +47 -1
  2. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/PKG-INFO +1 -1
  3. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/docs/divergences.md +1 -0
  4. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/pyproject.toml +1 -1
  5. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/AGENTS.md +22 -4
  6. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/path.py +17 -4
  7. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/uri/__init__.py +59 -0
  8. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/archive/_base.py +1 -2
  9. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/az.py +1 -2
  10. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/dav.py +1 -2
  11. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/ftp.py +1 -2
  12. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/gs.py +1 -2
  13. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/s3.py +1 -2
  14. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/sftp/__init__.py +5 -3
  15. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_archive_uri.py +15 -0
  16. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_ftp.py +14 -0
  17. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_s3.py +14 -0
  18. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_sftp.py +129 -0
  19. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_uri_path.py +58 -0
  20. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/.gitignore +0 -0
  21. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/LICENSE +0 -0
  22. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/README.md +0 -0
  23. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/docs/api/mempath.md +0 -0
  24. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/docs/api/path.md +0 -0
  25. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/docs/api/testing.md +0 -0
  26. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/docs/api/uri.md +0 -0
  27. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/docs/api/utils.md +0 -0
  28. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/docs/benchmarks.md +0 -0
  29. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/docs/changelog.md +0 -0
  30. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/docs/guides/cli.md +0 -0
  31. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/docs/guides/extending.md +0 -0
  32. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/docs/guides/schemes.md +0 -0
  33. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/docs/index.md +0 -0
  34. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/examples/az_listing.py +0 -0
  35. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/examples/data_and_archive.py +0 -0
  36. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/examples/ftp_listing.py +0 -0
  37. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/examples/github_listing.py +0 -0
  38. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/examples/gitlab_listing.py +0 -0
  39. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/examples/gs_listing.py +0 -0
  40. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/examples/http_listing.py +0 -0
  41. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/examples/local_and_mem.py +0 -0
  42. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/examples/s3_listing.py +0 -0
  43. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/examples/sftp_sync.py +0 -0
  44. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/examples/webdav_roundtrip.py +0 -0
  45. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/mkdocs.yml +0 -0
  46. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/__init__.py +0 -0
  47. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/fspath.py +0 -0
  48. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/mempath.py +0 -0
  49. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/protocols/__init__.py +0 -0
  50. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/protocols/checksum.py +0 -0
  51. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/protocols/fs.py +0 -0
  52. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/protocols/io.py +0 -0
  53. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/py.typed +0 -0
  54. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/testing.py +0 -0
  55. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/tools/__init__.py +0 -0
  56. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/tools/uripath.py +0 -0
  57. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/uri/query.py +0 -0
  58. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/__init__.py +0 -0
  59. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/_gitrepo.py +0 -0
  60. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/archive/__init__.py +0 -0
  61. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/archive/tar.py +0 -0
  62. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/archive/zip.py +0 -0
  63. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/data.py +0 -0
  64. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/file.py +0 -0
  65. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/git/__init__.py +0 -0
  66. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/git/_base.py +0 -0
  67. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/git/github.py +0 -0
  68. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/git/gitlab.py +0 -0
  69. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/github.py +0 -0
  70. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/gitlab.py +0 -0
  71. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/http.py +0 -0
  72. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/sftp/_asyncssh.py +0 -0
  73. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/sftp/_paramiko.py +0 -0
  74. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/uri/schemes/sftp/_sshconfig.py +0 -0
  75. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/uri/source.py +0 -0
  76. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/utils/__init__.py +0 -0
  77. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/utils/archive.py +0 -0
  78. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/utils/checksum.py +0 -0
  79. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/utils/glob.py +0 -0
  80. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/utils/stat.py +0 -0
  81. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/src/pathlib_next/utils/sync.py +0 -0
  82. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/conftest.py +0 -0
  83. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_az.py +0 -0
  84. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_az_fake.py +0 -0
  85. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_checksum.py +0 -0
  86. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_contract.py +0 -0
  87. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_data_uri.py +0 -0
  88. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_dav.py +0 -0
  89. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_gitrepo.py +0 -0
  90. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_glob.py +0 -0
  91. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_gs.py +0 -0
  92. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_gs_fake.py +0 -0
  93. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_http.py +0 -0
  94. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_http_live.py +0 -0
  95. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_http_parser.py +0 -0
  96. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_local.py +0 -0
  97. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_mempath.py +0 -0
  98. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_mro_precedence.py +0 -0
  99. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_parity_io.py +0 -0
  100. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_parity_pure.py +0 -0
  101. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_path_gaps.py +0 -0
  102. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_pathname.py +0 -0
  103. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_plugins.py +0 -0
  104. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_properties.py +0 -0
  105. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_query.py +0 -0
  106. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_sftp_asyncssh.py +0 -0
  107. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_smoke.py +0 -0
  108. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_source.py +0 -0
  109. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_sync.py +0 -0
  110. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_uri_parse.py +0 -0
  111. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_uripath_tool.py +0 -0
  112. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_utils.py +0 -0
  113. {pathlib_next-0.9.2 → pathlib_next-0.9.3}/tests/test_walk.py +0 -0
  114. {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.2...HEAD
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pathlib-next
3
- Version: 0.9.2
3
+ Version: 0.9.3
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/
@@ -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.2"
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 (a `str` target is turned into one by the
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). `force=` is this library's extension: `False` is
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 -- same `type(self)(target)` form as
762
- # copy()/move() use for their destination, for consistency.
763
- if isinstance(target, str):
764
- target = type(self)(target)
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
- if not isinstance(target, Uri):
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
- if not isinstance(target, Uri):
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
- if not isinstance(target, Uri):
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
- if not isinstance(target, Uri):
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
- if not isinstance(target, Uri):
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
- if not isinstance(target, Uri):
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
- if not isinstance(target, Uri):
362
- target = Uri(self.parent, target)
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