pathlib-next 0.9.4__tar.gz → 0.9.5__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 (143) hide show
  1. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/CHANGELOG.md +37 -1
  2. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/PKG-INFO +1 -1
  3. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/docs/divergences.md +3 -0
  4. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/docs/guides/schemes.md +11 -3
  5. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/pyproject.toml +1 -1
  6. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/AGENTS.md +26 -4
  7. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/path.py +19 -0
  8. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/uri/schemes/archive/_base.py +135 -46
  9. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_archive_safety.py +204 -8
  10. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/.gitignore +0 -0
  11. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/AGENTS.md +0 -0
  12. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/LICENSE +0 -0
  13. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/README.md +0 -0
  14. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/docs/api/cli.md +0 -0
  15. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/docs/api/mempath.md +0 -0
  16. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/docs/api/path.md +0 -0
  17. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/docs/api/protocols.md +0 -0
  18. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/docs/api/schemes/archive.md +0 -0
  19. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/docs/api/schemes/ftp.md +0 -0
  20. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/docs/api/schemes/git.md +0 -0
  21. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/docs/api/schemes/http.md +0 -0
  22. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/docs/api/schemes/local.md +0 -0
  23. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/docs/api/schemes/objstore.md +0 -0
  24. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/docs/api/schemes/sftp.md +0 -0
  25. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/docs/api/testing.md +0 -0
  26. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/docs/api/uri.md +0 -0
  27. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/docs/api/utils.md +0 -0
  28. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/docs/benchmarks.md +0 -0
  29. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/docs/changelog.md +0 -0
  30. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/docs/guides/cli.md +0 -0
  31. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/docs/guides/extending.md +0 -0
  32. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/docs/index.md +0 -0
  33. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/examples/az_listing.py +0 -0
  34. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/examples/data_and_archive.py +0 -0
  35. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/examples/ftp_listing.py +0 -0
  36. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/examples/github_listing.py +0 -0
  37. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/examples/gitlab_listing.py +0 -0
  38. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/examples/gs_listing.py +0 -0
  39. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/examples/http_listing.py +0 -0
  40. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/examples/local_and_mem.py +0 -0
  41. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/examples/s3_listing.py +0 -0
  42. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/examples/sftp_sync.py +0 -0
  43. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/examples/webdav_roundtrip.py +0 -0
  44. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/mkdocs.yml +0 -0
  45. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/__init__.py +0 -0
  46. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/fspath.py +0 -0
  47. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/mempath.py +0 -0
  48. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/protocols/__init__.py +0 -0
  49. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/protocols/checksum.py +0 -0
  50. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/protocols/fs.py +0 -0
  51. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/protocols/io.py +0 -0
  52. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/py.typed +0 -0
  53. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/testing.py +0 -0
  54. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/tools/__init__.py +0 -0
  55. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/tools/uripath.py +0 -0
  56. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/uri/__init__.py +0 -0
  57. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/uri/query.py +0 -0
  58. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/uri/schemes/__init__.py +0 -0
  59. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/uri/schemes/_gitrepo.py +0 -0
  60. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/uri/schemes/archive/__init__.py +0 -0
  61. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/uri/schemes/archive/tar.py +0 -0
  62. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/uri/schemes/archive/zip.py +0 -0
  63. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/uri/schemes/az.py +0 -0
  64. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/uri/schemes/data.py +0 -0
  65. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/uri/schemes/dav.py +0 -0
  66. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/uri/schemes/file.py +0 -0
  67. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/uri/schemes/ftp.py +0 -0
  68. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/uri/schemes/git/__init__.py +0 -0
  69. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/uri/schemes/git/_base.py +0 -0
  70. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/uri/schemes/git/github.py +0 -0
  71. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/uri/schemes/git/gitlab.py +0 -0
  72. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/uri/schemes/github.py +0 -0
  73. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/uri/schemes/gitlab.py +0 -0
  74. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/uri/schemes/gs.py +0 -0
  75. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/uri/schemes/http.py +0 -0
  76. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/uri/schemes/s3.py +0 -0
  77. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/uri/schemes/sftp/__init__.py +0 -0
  78. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/uri/schemes/sftp/_asyncssh.py +0 -0
  79. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/uri/schemes/sftp/_paramiko.py +0 -0
  80. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/uri/schemes/sftp/_sshconfig.py +0 -0
  81. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/uri/source.py +0 -0
  82. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/utils/__init__.py +0 -0
  83. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/utils/archive.py +0 -0
  84. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/utils/checksum.py +0 -0
  85. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/utils/glob.py +0 -0
  86. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/utils/stat.py +0 -0
  87. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/utils/sync.py +0 -0
  88. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/conftest.py +0 -0
  89. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_archive_parity.py +0 -0
  90. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_archive_uri.py +0 -0
  91. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_az.py +0 -0
  92. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_az_fake.py +0 -0
  93. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_checksum.py +0 -0
  94. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_contract.py +0 -0
  95. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_contract_helpers.py +0 -0
  96. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_data_uri.py +0 -0
  97. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_dav.py +0 -0
  98. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_destructive_safety.py +0 -0
  99. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_ftp.py +0 -0
  100. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_ftp_objstore_parity.py +0 -0
  101. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_gitrepo.py +0 -0
  102. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_glob.py +0 -0
  103. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_glob_parity.py +0 -0
  104. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_gs.py +0 -0
  105. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_gs_fake.py +0 -0
  106. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_http.py +0 -0
  107. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_http_live.py +0 -0
  108. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_http_parser.py +0 -0
  109. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_httpdav_safety.py +0 -0
  110. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_io_parity.py +0 -0
  111. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_local.py +0 -0
  112. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_low_core.py +0 -0
  113. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_low_schemes.py +0 -0
  114. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_low_sync.py +0 -0
  115. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_mempath.py +0 -0
  116. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_mro_precedence.py +0 -0
  117. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_parity_io.py +0 -0
  118. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_parity_pure.py +0 -0
  119. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_path_gaps.py +0 -0
  120. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_pathname.py +0 -0
  121. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_plugins.py +0 -0
  122. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_properties.py +0 -0
  123. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_pure_parity.py +0 -0
  124. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_query.py +0 -0
  125. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_routing.py +0 -0
  126. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_s3.py +0 -0
  127. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_sftp.py +0 -0
  128. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_sftp_asyncssh.py +0 -0
  129. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_sftp_transport.py +0 -0
  130. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_smoke.py +0 -0
  131. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_source.py +0 -0
  132. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_sync.py +0 -0
  133. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_sync_safety.py +0 -0
  134. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_sync_sftp.py +0 -0
  135. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_sync_sftp_parity.py +0 -0
  136. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_transport_security.py +0 -0
  137. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_uri_core_parity.py +0 -0
  138. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_uri_parse.py +0 -0
  139. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_uri_path.py +0 -0
  140. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_uripath_tool.py +0 -0
  141. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_utils.py +0 -0
  142. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_walk.py +0 -0
  143. {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_webdav.py +0 -0
@@ -7,6 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.5] - 2026-09-16
11
+
12
+ ### Fixed
13
+ - **Archive member names are normalized**, so the same member is reachable
14
+ however the archive was written and whichever format it is. A leading
15
+ `./` (what `tar -C dir .`, `TarFile.add(arcname=".")` and
16
+ `shutil.make_archive` put on every member), empty segments (`a//b`) and
17
+ interior `.`/`..` (`a/./b`, `a/b/../c`) now resolve to one name for
18
+ listing and lookup alike. Previously only `tar:` stripped a leading `./`:
19
+ a zip written that way listed as empty and none of its members could be
20
+ read under any spelling, while a name such as `d//e.txt` was readable but
21
+ absent from listings.
22
+
23
+ ### Changed
24
+ - **A drive- or backslash-shaped member name is no longer dropped.**
25
+ `C:drive.txt` and `a\b` are ordinary filenames on POSIX, and an archive
26
+ written there may contain them; they were silently absent from every
27
+ listing and unreadable on every platform. The rule they were failing is a
28
+ *destination* rule, and now lives where the joining happens:
29
+ `Path.copy(recursive=True)` refuses a child name that would not stay
30
+ inside its target (`ValueError` through `ignore_error`), which is what
31
+ `PathSyncer` and `utils.unpack_archive()` already did per destination.
32
+ Copying such a member onto a Windows path is still refused; copying it to
33
+ a POSIX path, a `MemPath` or another archive now works.
34
+ - **A member name that escapes the archive root** (`../x`, `/abs`, a
35
+ `..` with nothing left to consume) has no name inside the archive: it was
36
+ already never listed, and is now never readable either. Such a member
37
+ used to be hidden from listings while `read_bytes()` still returned it
38
+ under its raw name. (Writing to one already failed and created nothing;
39
+ that is unchanged.) A `..` that stays inside is resolved rather than
40
+ rejected (`pkg/../ok.txt` reads `ok.txt`), and when two spellings
41
+ normalize to one name the later member wins, as in `zipfile`/`tarfile`.
42
+ - **`Uri`'s RFC 3986 dot-segment removal is documented** as a divergence
43
+ from `pathlib` (`docs/divergences.md`); the behaviour is unchanged.
44
+
10
45
  ## [0.9.4] - 2026-09-16
11
46
 
12
47
  ### Fixed
@@ -1269,7 +1304,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
1269
1304
  - Sync error handling.
1270
1305
  - Generic Path Protocol based pathlib implementation for URI paths with file access support for sftp, http, file schemes.
1271
1306
 
1272
- [Unreleased]: https://github.com/jose-pr/pathlib-next/compare/v0.9.4...HEAD
1307
+ [Unreleased]: https://github.com/jose-pr/pathlib-next/compare/v0.9.5...HEAD
1308
+ [0.9.5]: https://github.com/jose-pr/pathlib-next/compare/v0.9.4...v0.9.5
1273
1309
  [0.9.4]: https://github.com/jose-pr/pathlib-next/compare/v0.9.3...v0.9.4
1274
1310
  [0.9.3]: https://github.com/jose-pr/pathlib-next/compare/v0.9.2...v0.9.3
1275
1311
  [0.9.2]: https://github.com/jose-pr/pathlib-next/compare/v0.9.1...v0.9.2
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pathlib-next
3
- Version: 0.9.4
3
+ Version: 0.9.5
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/
@@ -80,6 +80,9 @@ these operations itself always keeps its own implementation.
80
80
  | `str()`/`repr()` of `github:`/`gitlab:`/`git:` paths | N/A (no pathlib equivalent) | The **whole** userinfo is redacted, not just the part after `:` as for other schemes. `as_uri()` (unsanitized) still returns it. | The token is commonly the bare userinfo (`TOKEN@host`), the one part other schemes keep, so it reached logs and tracebacks. |
81
81
  | `Path.exists()` / `is_*()` on a stat error other than "not found" | pathlib 3.9-3.12 re-raise errors outside ENOENT/ENOTDIR/EBADF/ELOOP (e.g. `PermissionError`); 3.13+ return `False` | Every `OSError`/`ValueError` from `stat()` returns `False` on every Python version, including `LocalPath.exists()` on 3.9-3.12. | One rule across backends and versions, matching current pathlib. |
82
82
  | `PathSyncer` directory → file/symlink type change | N/A (closest: `rsync`, which will not delete a non-empty directory without `--delete`/`--force`) | With `remove_missing=False`, a non-empty target directory is not replaced when the source entry at that name became a file or symlink: `IsADirectoryError` goes through `ignore_error` (`SyncEvent.TypeMismatch`) and the directory is kept. Empty directories, or `remove_missing=True`, are replaced. | `remove_missing=False` means "never delete target-only data"; replacing the directory silently deleted its whole subtree. |
83
+ | `Path.copy(recursive=True)` child names | `shutil.copytree` joins whatever the listing yields | A child name that would not stay inside `target` is refused with `ValueError` through `ignore_error`: `..`, and `\`/`:`/a trailing dot when the target reads names with Windows rules. | The names come from listings the destination does not control (an archive, an HTTP index, an object-store key); on a Windows target `"C:x"` joins to a drive-relative path outside the destination entirely. `PathSyncer` and `utils.unpack_archive()` already applied this per destination. |
84
+ | Archive member names | N/A (`zipfile`/`tarfile` expose the raw name as written) | Normalized as POSIX relative paths for every format: a leading `./`, empty segments and interior `.`/`..` resolve, so one member has one name and listings and lookups agree; the raw spelling still addresses it, and the later of two members that normalize alike wins. A name that escapes the root (`../x`, `/abs`) has no name inside the archive at all, while a drive- or backslash-shaped name is a normal member (an ordinary POSIX filename) that only a Windows destination refuses to receive. | The same file was reachable or not depending on how the writer spelled it: a zip written by `shutil.make_archive`-style `./` prefixes listed as empty, and zip and tar disagreed about identical archives. |
85
+ | `Uri` path dot segments | `pathlib` keeps `..` lexically (`PurePosixPath("a/../b")` is `a/../b`) | `Uri` removes dot segments as RFC 3986 requires of a URI reference, in the constructor and in `/`-joins: `Uri("a/../b")` is `b`, `Uri("http://h/x") / "a/../b"` is `http://h/x/b`, and `Uri("a/b/..")` is `a/`. A leading `..` that would pass the root is kept, not resolved. `MemPath` follows `pathlib` instead. | A URI is resolved, not spelled: `..` in a URI reference has a defined meaning that servers, caches and proxies already apply, so keeping it lexically would address a different resource than the same string typed into a browser. |
83
86
  | Nested archive URIs | N/A | Each leading archive scheme in `<archive-uri>` consumes one `!/` (`zip:zip:file:///outer.zip!/inner.zip!/x.txt`); a member name containing `!/` is written `%21/`. Nested archives are read-only. | The first `!/` was always taken as the separator, so an archive inside an archive could not be addressed. |
84
87
  | Object-store key that is both an object and a prefix | N/A (a filesystem entry has one type) | `iterdir()`/`walk()`/`copy(recursive=True)` on `s3:`/`gs:`/`az:` show only the object (`x`), matching `stat()`'s exact-object precedence; the subtree under `x/` is not listed. | Listings used to keep the directory and drop the object, contradicting `stat()`. |
85
88
  | `HttpPath.iterdir()` on a file | pathlib raises `NotADirectoryError` | Raises `NotADirectoryError` for a non-HTML response, without downloading it. An HTML file cannot be told apart from an index page and lists as empty. | HTTP has no directory type; the response content type is the only signal. |
@@ -205,9 +205,17 @@ The `<archive-uri>` is any absolute URI with an explicit scheme, so
205
205
  - An archive inside an archive is addressed by nesting
206
206
  (`zip:zip:file:///outer.zip!/inner.zip!/x.txt`) and is read-only; a `!/`
207
207
  inside a member name is written `%21/`.
208
- - Members whose names would escape a destination (`..`, absolute or drive
209
- paths) are never listed. Exception types are the POSIX ones on every
210
- platform.
208
+ - Member names are normalized as POSIX relative paths, the same way for
209
+ every format: `./x`, `a//b`, `a/./b` and `a/b/../c` all resolve, so a
210
+ member lists and reads under one name however the archive was written
211
+ (`tar -C dir .` and `shutil.make_archive` prefix every member with `./`).
212
+ The spelling as written still works.
213
+ - Members whose names would escape the archive (`..` past the root, or an
214
+ absolute path) have no name inside it: never listed, never readable. A
215
+ name that merely a Windows destination would misread -- `C:drive.txt`, or
216
+ one containing `\` -- is a normal member, since both are ordinary
217
+ filenames on POSIX; copying or extracting it onto a Windows path is what
218
+ refuses it. Exception types are the POSIX ones on every platform.
211
219
 
212
220
  ## Git hosting
213
221
 
@@ -13,7 +13,7 @@ build-backend = "hatchling.build"
13
13
  # `import pathlib_next`) -- a hyphen is not legal in a Python identifier.
14
14
  # Distribution name and import name differing is ordinary and intended.
15
15
  name = "pathlib-next"
16
- version = "0.9.4"
16
+ version = "0.9.5"
17
17
  authors = [{ name = "Jose A" }]
18
18
  description = "Generic Path Protocol based pathlib"
19
19
  readme = "README.md"
@@ -114,6 +114,13 @@ silently absent and `from pathlib_next.uri import UriPath` raises
114
114
  the same file (or a case-insensitive alias) → `OSError(EINVAL)`.
115
115
  - The source is opened before the target is touched; a failed stream
116
116
  removes the partial target.
117
+ - A recursive copy refuses any child name that would not stay inside
118
+ `target` — `..`, and `\`/`:`/a trailing dot when the target reads
119
+ names with Windows rules (`utils.is_windows_flavoured()`). The names
120
+ come from a listing the destination does not control (an archive, a
121
+ remote index, an object-store key), and on a Windows target `"C:x"`
122
+ joins to a drive-relative path outside it. Raised as `ValueError`
123
+ through `ignore_error`, per child, like any other child failure.
117
124
  - `follow_symlinks=False` on a symlink recreates the link
118
125
  (`NotImplementedError` if either side cannot).
119
126
  - `preserve_metadata=True` copies permission bits only, and only a mode the
@@ -481,15 +488,30 @@ chained (their text can carry credentials).
481
488
  - One shared handle per archive (keyed by the real local path, or the outer
482
489
  URI), released when no path references it. A non-local outer is read into
483
490
  memory.
484
- - Members named with `..`, an absolute path or a drive are never listed.
485
- Exception types are POSIX on every platform.
491
+ - **Member names are normalized POSIX relative paths**, whatever the
492
+ writer emitted and whichever format: a leading `./` (`tar -C dir .`,
493
+ `shutil.make_archive`), empty segments (`a//b`) and interior `.`/`..`
494
+ (`a/./b`, `a/b/../c`) resolve, so one member has one name and a listing
495
+ and a lookup always agree. The spelling as written still addresses the
496
+ member. A name that would leave the root -- `../x`, `/abs`, or a `..`
497
+ with nothing to spend it on -- has no name inside the archive: it is
498
+ never listed, never readable, and cannot be written (the write fails and
499
+ creates nothing). A name only a *Windows destination* would misread
500
+ (`C:drive.txt`, `a\b`) IS a member, because it is an ordinary POSIX
501
+ filename; refusing to join it is the destination's rule, applied by
502
+ whatever writes there (see `copy()` below, `PathSyncer`,
503
+ `unpack_archive()`). `zipfile` itself rewrites `\` to `/`, so a
504
+ backslash name only survives in a tar. When two spellings normalize to one name the
505
+ later member wins, as in `zipfile`/`tarfile`. Exception types are POSIX on every
506
+ platform.
486
507
  - Writes: zip only, and only with a local `file:` outer (else
487
508
  `NotImplementedError`). `"w"`/`"x"`/`"r+"`, `mkdir()`, `unlink()`,
488
509
  `rmdir()`, `rename()` (same archive; replaces like POSIX `rename`);
489
510
  parents must exist; `"a"` unsupported. Every mutation replaces the archive
490
511
  atomically (temp file + `os.replace`) and keeps other members' metadata,
491
- the comment and any prefix bytes. `tar:` (plain, gz, bz2, xz) is
492
- read-only; `./` member prefixes are dropped.
512
+ the comment and any prefix bytes. A write uses the normalized name; a
513
+ name that escapes the root fails and creates nothing.
514
+ `tar:` (plain, gz, bz2, xz) is read-only.
493
515
 
494
516
  ## CLI (`uripath`, `pathlib_next.tools.uripath`)
495
517
 
@@ -1180,8 +1180,27 @@ class Path(Pathname, Chmod, Stat, BinaryOpen):
1180
1180
  raise FileExistsError(target)
1181
1181
  else:
1182
1182
  target.mkdir()
1183
+ windows_target = _utils.is_windows_flavoured(target)
1183
1184
  for child in children:
1184
1185
  try:
1186
+ # The names come from a listing the destination does not
1187
+ # control (an archive, a remote index, an object-store
1188
+ # key), so one that is not a single component inside
1189
+ # `target` must never be joined onto it: on a Windows
1190
+ # target "C:x" joins to a drive-relative path outside it
1191
+ # entirely, and so does "a\\b". Reported through
1192
+ # `ignore_error` like any other per-child failure, not
1193
+ # silently skipped. `PathSyncer` and
1194
+ # `utils.unpack_archive()` apply the same rule per
1195
+ # destination; an archive listing keeps such names,
1196
+ # because they are ordinary filenames on POSIX.
1197
+ if not _utils.is_safe_child_name(
1198
+ child.name, windows=windows_target
1199
+ ):
1200
+ raise ValueError(
1201
+ f"refusing unsafe child name {child.name!r} "
1202
+ f"under {target}"
1203
+ )
1185
1204
  child.copy(
1186
1205
  target / child.name,
1187
1206
  overwrite=overwrite,
@@ -17,26 +17,68 @@ from ..file import FileUri
17
17
 
18
18
  _SEP = "!/"
19
19
  _SCHEME_RE = _re.compile(r"^[a-zA-Z][a-zA-Z0-9+.\-]*:")
20
- _MEMBER_SEP_RE = _re.compile(r"[/\\]")
21
- _DRIVE_RE = _re.compile(r"^[a-zA-Z]:")
22
20
  # Every scheme an `ArchiveUri` class registers (see `archive/__init__.py`).
23
21
  _ARCHIVE_SCHEMES = ("zip", "tar", "archive", "archive+zip", "archive+tar")
24
22
 
25
23
 
26
24
  def _is_safe_member_name(name: str) -> bool:
27
- """Whether archive member `name` stays inside the archive root once its
28
- parts are joined onto a destination (e.g. by a recursive `copy()`).
29
-
30
- Both `/` and `\\` count as separators here -- only here: lookups keep
31
- the raw member name, since `\\` is a legal filename character on POSIX.
32
- Rejects absolute names, empty/`.`/`..` parts and drive-qualified parts
33
- (`C:x`, `C:..`); a trailing `/` (a directory marker) is allowed."""
25
+ """Whether archive member `name` is a usable relative path inside the
26
+ archive: every part a real name, none of them escaping the root.
27
+
28
+ Only the rules that hold on every platform are applied here, because an
29
+ archive has no platform of its own: `\\`, `:` and trailing dots are
30
+ ordinary filename characters on POSIX, and an archive written there may
31
+ legitimately contain `C:drive.txt` or `a\\b`. Refusing to *join* such a
32
+ name onto a destination that reads it differently is the destination's
33
+ rule, and is applied per target where the joining happens --
34
+ `Path.copy(recursive=True)`, `PathSyncer` and `utils.unpack_archive()`
35
+ each check `is_safe_child_name(..., windows=is_windows_flavoured(dest))`.
36
+ A trailing `/` (a directory marker) is allowed."""
34
37
  if name.endswith("/"):
35
38
  name = name[:-1]
36
- return all(
37
- is_safe_child_name(part) and not _DRIVE_RE.match(part)
38
- for part in _MEMBER_SEP_RE.split(name)
39
- )
39
+ return all(is_safe_child_name(part) for part in name.split("/"))
40
+
41
+
42
+ def _normalize_member_name(name: str) -> "str | None":
43
+ """`name` as a normalized POSIX relative path, or None if it does not
44
+ stay inside the archive root.
45
+
46
+ A member name is a relative path, and writers spell it several ways for
47
+ the same file: `./x` (`tar -C dir .`, `TarFile.add(arcname=".")`,
48
+ `shutil.make_archive`), `a//b`, `a/./b`, `a/b/../c`. Normalizing it the
49
+ way a URI reference resolves -- RFC 3986 dot-segment removal, with empty
50
+ segments dropped -- gives one member one name, so a listing and a lookup
51
+ agree however the archive was written, and the same spelling addresses
52
+ the same member in a zip and in a tar.
53
+
54
+ A name that would leave the root has no normalized form inside the
55
+ archive and is rejected (None): absolute (`/abs`), or with more `..`
56
+ than parts to spend them on. `..` that stays inside is resolved
57
+ (`pkg/../ok.txt` is `ok.txt`). A drive-shaped part (`C:x`) is NOT
58
+ rejected here: it is a legal POSIX filename, and only a destination
59
+ that reads names with Windows rules is endangered by it -- see
60
+ `_is_safe_member_name`. A trailing `/` (a directory marker) is kept, and
61
+ the archive root itself normalizes to "".
62
+ """
63
+ if name.startswith("/"):
64
+ return None
65
+ directory = name.endswith("/")
66
+ parts: "list[str]" = []
67
+ for part in name.split("/"):
68
+ if part in ("", "."):
69
+ continue
70
+ if part == "..":
71
+ if not parts:
72
+ return None # escapes the archive root
73
+ parts.pop()
74
+ continue
75
+ parts.append(part)
76
+ normalized = "/".join(parts)
77
+ if not normalized:
78
+ return "" # the archive root ("", ".", "./", "a/..")
79
+ if not _is_safe_member_name(normalized):
80
+ return None
81
+ return normalized + "/" if directory else normalized
40
82
 
41
83
 
42
84
  def _nesting_depth(archive_uri: str) -> int:
@@ -368,18 +410,52 @@ class ArchiveUri(UriPath):
368
410
  )
369
411
  return f"{self.source.scheme}:{outer_uri}{_SEP}{inner}{tail}"
370
412
 
413
+ def _member_index(self):
414
+ """Normalized member name -> the key the backend knows it by.
415
+
416
+ Built from `backend.names()` on every call, as the old raw scan was.
417
+ A member whose name escapes the root is left out entirely, so an
418
+ unsafe member ('../x', '/abs', 'C:x') can be neither listed nor
419
+ looked up. When two spellings normalize to one name, the later entry
420
+ wins -- what `zipfile` and `tarfile` do with duplicates themselves.
421
+ """
422
+ index = {}
423
+ for raw in self.backend.names():
424
+ normalized = _normalize_member_name(raw)
425
+ if not normalized: # None (escapes) or "" (the root itself)
426
+ continue
427
+ index[normalized] = raw
428
+ return index
429
+
371
430
  def _names(self):
372
- return self.backend.names()
431
+ return list(self._member_index())
432
+
433
+ def _raw_name(self, name: str) -> str:
434
+ """The backend's own key for a normalized name (the name itself when
435
+ the archive spells it canonically, or holds no such member yet)."""
436
+ return self._member_index().get(name, name)
437
+
438
+ @property
439
+ def _member(self) -> "str | None":
440
+ """This path as a normalized member name, or None if it escapes."""
441
+ return _normalize_member_name(self.path)
442
+
443
+ def _member_for_write(self) -> str:
444
+ member = self._member
445
+ if member is None:
446
+ raise ValueError(f"member name escapes the archive root: {self.path!r}")
447
+ return member
373
448
 
374
449
  def _listdir(self):
375
- if self.path and not self.stat().is_dir():
450
+ member = self._member
451
+ if member is None:
452
+ raise FileNotFoundError(self)
453
+ if member and not self.stat().is_dir():
376
454
  raise NotADirectoryError(_errno.ENOTDIR, "Not a directory", str(self))
377
- prefix = f"{self.path}/" if self.path else ""
455
+ prefix = f"{member}/" if member else ""
378
456
  seen = set()
379
457
  for name in self._names():
380
- # An unsafe member ('../x', '/abs', 'C:x', ...) is never listed:
381
- # a caller joining a listed name onto a destination must stay in it.
382
- if not name.startswith(prefix) or not _is_safe_member_name(name):
458
+ if not name.startswith(prefix):
383
459
  continue
384
460
  rest = name[len(prefix) :]
385
461
  if not rest:
@@ -390,16 +466,18 @@ class ArchiveUri(UriPath):
390
466
  yield child
391
467
 
392
468
  def stat(self, *, follow_symlinks=True):
393
- path = self.path
469
+ path = self._member
470
+ if path is None:
471
+ raise FileNotFoundError(self)
394
472
  if path == "":
395
473
  return FileStat(is_dir=True)
396
- names = self._names()
397
- if path in names:
398
- return self.backend.member_stat(path)
474
+ index = self._member_index()
475
+ if path in index:
476
+ return self.backend.member_stat(index[path])
399
477
  dirmarker = f"{path}/"
400
- if dirmarker in names:
401
- return self.backend.member_stat(dirmarker)
402
- if any(n.startswith(dirmarker) for n in names):
478
+ if dirmarker in index:
479
+ return self.backend.member_stat(index[dirmarker])
480
+ if any(n.startswith(dirmarker) for n in index):
403
481
  return FileStat(is_dir=True)
404
482
  raise FileNotFoundError(self)
405
483
 
@@ -441,8 +519,11 @@ class ArchiveUri(UriPath):
441
519
  )
442
520
 
443
521
  def _read_member(self):
522
+ member = self._member
523
+ if member is None:
524
+ raise FileNotFoundError(self)
444
525
  try:
445
- return self.backend.read_member(self.path)
526
+ return self.backend.read_member(self._raw_name(member))
446
527
  except KeyError as error:
447
528
  if self._is_dir_member():
448
529
  raise IsADirectoryError(
@@ -457,7 +538,9 @@ class ArchiveUri(UriPath):
457
538
  if "r" in mode:
458
539
  # Read-modify-write: what is written reaches the archive on close.
459
540
  data = self._read_member().read()
460
- return _ArchiveWriteStream(self.backend, self.path, initial=data)
541
+ return _ArchiveWriteStream(
542
+ self.backend, self._raw_name(self._member_for_write()), initial=data
543
+ )
461
544
  if mode not in ("w", "x"):
462
545
  raise NotImplementedError(f"open(mode={mode!r})")
463
546
  if self._is_dir_member():
@@ -465,38 +548,39 @@ class ArchiveUri(UriPath):
465
548
  if mode == "x" and self.exists():
466
549
  raise FileExistsError(self)
467
550
  self._check_parent()
468
- return _ArchiveWriteStream(self.backend, self.path)
551
+ return _ArchiveWriteStream(self.backend, self._member_for_write())
469
552
 
470
553
  def _mkdir(self, mode):
471
554
  self._require_writable()
472
555
  if self.exists():
473
556
  raise FileExistsError(self)
474
557
  self._check_parent()
475
- self.backend.write_member(f"{self.path}/", b"")
558
+ self.backend.write_member(f"{self._member_for_write()}/", b"")
476
559
 
477
560
  def unlink(self, missing_ok=False):
478
561
  self._require_writable()
479
- path = self.path
480
- if path not in self._names():
562
+ path = self._member_for_write()
563
+ index = self._member_index()
564
+ if path not in index:
481
565
  if self._is_dir_member():
482
566
  raise IsADirectoryError(_errno.EISDIR, "Is a directory", str(self))
483
567
  if missing_ok:
484
568
  return
485
569
  raise FileNotFoundError(self)
486
- self.backend.delete_member(path)
570
+ self.backend.delete_member(index[path])
487
571
 
488
572
  def rmdir(self):
489
573
  self._require_writable()
490
- path = self.path
574
+ path = self._member_for_write()
491
575
  marker = f"{path}/"
492
- names = self._names()
493
- if any(n != marker and n.startswith(marker) for n in names):
576
+ index = self._member_index()
577
+ if any(n != marker and n.startswith(marker) for n in index):
494
578
  raise OSError(_errno.ENOTEMPTY, "Directory not empty", str(self))
495
- if marker not in names:
496
- if path in names:
579
+ if marker not in index:
580
+ if path in index:
497
581
  raise NotADirectoryError(_errno.ENOTDIR, "Not a directory", str(self))
498
582
  raise FileNotFoundError(self)
499
- self.backend.delete_member(marker)
583
+ self.backend.delete_member(index[marker])
500
584
 
501
585
  def _same_location(self, other: Uri) -> bool:
502
586
  # Every archive URI has the same bare "zip:"/"tar:" authority, so the
@@ -516,26 +600,31 @@ class ArchiveUri(UriPath):
516
600
  # Returns the new path, as pathlib does.
517
601
  self._require_writable()
518
602
  target = self._rename_target(target)
519
- old_path = self.path
520
- new_path = target.path.lstrip("/")
521
- names = self._names()
603
+ old_path = self._member_for_write()
604
+ new_path = _normalize_member_name(target.path.lstrip("/"))
605
+ if not new_path:
606
+ raise ValueError(f"member name escapes the archive root: {target.path!r}")
607
+ index = self._member_index()
608
+ names = list(index)
522
609
  marker = f"{old_path}/"
523
610
  new_marker = f"{new_path}/"
524
611
  renamed = self._from_parsed_parts(self.source, new_path, "", "")
525
- if old_path in names:
612
+ if old_path in index:
526
613
  if new_path == old_path:
527
614
  return renamed
528
615
  if any(n.startswith(new_marker) for n in names):
529
616
  raise IsADirectoryError(_errno.EISDIR, "Is a directory", str(target))
530
- self.backend.rename_member(old_path, new_path)
617
+ self.backend.rename_member(index[old_path], new_path)
531
618
  elif any(n.startswith(marker) for n in names):
532
619
  if new_marker == marker:
533
620
  return renamed
534
- if new_path in names:
621
+ if new_path in index:
535
622
  raise NotADirectoryError(_errno.ENOTDIR, "Not a directory", str(target))
536
623
  if any(n != new_marker and n.startswith(new_marker) for n in names):
537
624
  raise OSError(_errno.ENOTEMPTY, "Directory not empty", str(target))
538
- self.backend.rename_member(marker, new_marker)
625
+ # A directory can be implicit (no marker entry of its own), so
626
+ # fall back to the marker itself rather than indexing.
627
+ self.backend.rename_member(index.get(marker, marker), new_marker)
539
628
  else:
540
629
  raise FileNotFoundError(self)
541
630
  return renamed