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.
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/CHANGELOG.md +37 -1
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/PKG-INFO +1 -1
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/docs/divergences.md +3 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/docs/guides/schemes.md +11 -3
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/pyproject.toml +1 -1
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/AGENTS.md +26 -4
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/path.py +19 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/uri/schemes/archive/_base.py +135 -46
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_archive_safety.py +204 -8
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/.gitignore +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/AGENTS.md +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/LICENSE +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/README.md +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/docs/api/cli.md +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/docs/api/mempath.md +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/docs/api/path.md +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/docs/api/protocols.md +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/docs/api/schemes/archive.md +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/docs/api/schemes/ftp.md +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/docs/api/schemes/git.md +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/docs/api/schemes/http.md +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/docs/api/schemes/local.md +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/docs/api/schemes/objstore.md +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/docs/api/schemes/sftp.md +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/docs/api/testing.md +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/docs/api/uri.md +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/docs/api/utils.md +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/docs/benchmarks.md +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/docs/changelog.md +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/docs/guides/cli.md +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/docs/guides/extending.md +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/docs/index.md +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/examples/az_listing.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/examples/data_and_archive.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/examples/ftp_listing.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/examples/github_listing.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/examples/gitlab_listing.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/examples/gs_listing.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/examples/http_listing.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/examples/local_and_mem.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/examples/s3_listing.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/examples/sftp_sync.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/examples/webdav_roundtrip.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/mkdocs.yml +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/__init__.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/fspath.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/mempath.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/protocols/__init__.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/protocols/checksum.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/protocols/fs.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/protocols/io.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/py.typed +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/testing.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/tools/__init__.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/tools/uripath.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/uri/__init__.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/uri/query.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/uri/schemes/__init__.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/uri/schemes/_gitrepo.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/uri/schemes/archive/__init__.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/uri/schemes/archive/tar.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/uri/schemes/archive/zip.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/uri/schemes/az.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/uri/schemes/data.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/uri/schemes/dav.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/uri/schemes/file.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/uri/schemes/ftp.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/uri/schemes/git/__init__.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/uri/schemes/git/_base.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/uri/schemes/git/github.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/uri/schemes/git/gitlab.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/uri/schemes/github.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/uri/schemes/gitlab.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/uri/schemes/gs.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/uri/schemes/http.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/uri/schemes/s3.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/uri/schemes/sftp/__init__.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/uri/schemes/sftp/_asyncssh.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/uri/schemes/sftp/_paramiko.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/uri/schemes/sftp/_sshconfig.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/uri/source.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/utils/__init__.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/utils/archive.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/utils/checksum.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/utils/glob.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/utils/stat.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/src/pathlib_next/utils/sync.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/conftest.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_archive_parity.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_archive_uri.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_az.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_az_fake.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_checksum.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_contract.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_contract_helpers.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_data_uri.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_dav.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_destructive_safety.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_ftp.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_ftp_objstore_parity.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_gitrepo.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_glob.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_glob_parity.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_gs.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_gs_fake.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_http.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_http_live.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_http_parser.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_httpdav_safety.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_io_parity.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_local.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_low_core.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_low_schemes.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_low_sync.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_mempath.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_mro_precedence.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_parity_io.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_parity_pure.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_path_gaps.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_pathname.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_plugins.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_properties.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_pure_parity.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_query.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_routing.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_s3.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_sftp.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_sftp_asyncssh.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_sftp_transport.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_smoke.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_source.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_sync.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_sync_safety.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_sync_sftp.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_sync_sftp_parity.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_transport_security.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_uri_core_parity.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_uri_parse.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_uri_path.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_uripath_tool.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_utils.py +0 -0
- {pathlib_next-0.9.4 → pathlib_next-0.9.5}/tests/test_walk.py +0 -0
- {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.
|
|
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
|
|
@@ -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
|
-
-
|
|
209
|
-
|
|
210
|
-
|
|
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.
|
|
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
|
-
-
|
|
485
|
-
|
|
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.
|
|
492
|
-
|
|
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`
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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.
|
|
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
|
-
|
|
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"{
|
|
455
|
+
prefix = f"{member}/" if member else ""
|
|
378
456
|
seen = set()
|
|
379
457
|
for name in self._names():
|
|
380
|
-
|
|
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.
|
|
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
|
-
|
|
397
|
-
if path in
|
|
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
|
|
401
|
-
return self.backend.member_stat(dirmarker)
|
|
402
|
-
if any(n.startswith(dirmarker) for n in
|
|
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.
|
|
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(
|
|
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.
|
|
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.
|
|
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.
|
|
480
|
-
|
|
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.
|
|
574
|
+
path = self._member_for_write()
|
|
491
575
|
marker = f"{path}/"
|
|
492
|
-
|
|
493
|
-
if any(n != marker and n.startswith(marker) for n in
|
|
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
|
|
496
|
-
if path in
|
|
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.
|
|
520
|
-
new_path = target.path.lstrip("/")
|
|
521
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|