pathlib-next 0.5.0__tar.gz → 0.7.0__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 (65) hide show
  1. {pathlib_next-0.5.0 → pathlib_next-0.7.0}/CHANGELOG.md +49 -1
  2. {pathlib_next-0.5.0 → pathlib_next-0.7.0}/PKG-INFO +4 -1
  3. {pathlib_next-0.5.0 → pathlib_next-0.7.0}/docs/divergences.md +2 -0
  4. pathlib_next-0.7.0/docs/guides/schemes.md +78 -0
  5. {pathlib_next-0.5.0 → pathlib_next-0.7.0}/pyproject.toml +2 -1
  6. pathlib_next-0.7.0/src/pathlib_next/uri/schemes/__init__.py +22 -0
  7. pathlib_next-0.7.0/src/pathlib_next/uri/schemes/archive.py +266 -0
  8. pathlib_next-0.7.0/src/pathlib_next/uri/schemes/data.py +58 -0
  9. pathlib_next-0.7.0/src/pathlib_next/uri/schemes/ftp.py +205 -0
  10. pathlib_next-0.7.0/src/pathlib_next/uri/schemes/s3.py +179 -0
  11. pathlib_next-0.7.0/src/pathlib_next/uri/schemes/webdav.py +180 -0
  12. pathlib_next-0.7.0/tests/test_archive_uri.py +198 -0
  13. pathlib_next-0.7.0/tests/test_data_uri.py +76 -0
  14. pathlib_next-0.7.0/tests/test_ftp.py +313 -0
  15. pathlib_next-0.7.0/tests/test_s3.py +210 -0
  16. pathlib_next-0.7.0/tests/test_webdav.py +250 -0
  17. pathlib_next-0.5.0/docs/guides/schemes.md +0 -39
  18. pathlib_next-0.5.0/src/pathlib_next/uri/schemes/__init__.py +0 -10
  19. {pathlib_next-0.5.0 → pathlib_next-0.7.0}/.gitignore +0 -0
  20. {pathlib_next-0.5.0 → pathlib_next-0.7.0}/LICENSE +0 -0
  21. {pathlib_next-0.5.0 → pathlib_next-0.7.0}/README.md +0 -0
  22. {pathlib_next-0.5.0 → pathlib_next-0.7.0}/docs/api/reference.md +0 -0
  23. {pathlib_next-0.5.0 → pathlib_next-0.7.0}/docs/changelog.md +0 -0
  24. {pathlib_next-0.5.0 → pathlib_next-0.7.0}/docs/guides/extending.md +0 -0
  25. {pathlib_next-0.5.0 → pathlib_next-0.7.0}/docs/index.md +0 -0
  26. {pathlib_next-0.5.0 → pathlib_next-0.7.0}/examples/http_listing.py +0 -0
  27. {pathlib_next-0.5.0 → pathlib_next-0.7.0}/examples/local_and_mem.py +0 -0
  28. {pathlib_next-0.5.0 → pathlib_next-0.7.0}/examples/sftp_sync.py +0 -0
  29. {pathlib_next-0.5.0 → pathlib_next-0.7.0}/mkdocs.yml +0 -0
  30. {pathlib_next-0.5.0 → pathlib_next-0.7.0}/src/pathlib_next/__init__.py +0 -0
  31. {pathlib_next-0.5.0 → pathlib_next-0.7.0}/src/pathlib_next/fspath.py +0 -0
  32. {pathlib_next-0.5.0 → pathlib_next-0.7.0}/src/pathlib_next/mempath.py +0 -0
  33. {pathlib_next-0.5.0 → pathlib_next-0.7.0}/src/pathlib_next/path.py +0 -0
  34. {pathlib_next-0.5.0 → pathlib_next-0.7.0}/src/pathlib_next/protocols/__init__.py +0 -0
  35. {pathlib_next-0.5.0 → pathlib_next-0.7.0}/src/pathlib_next/protocols/fs.py +0 -0
  36. {pathlib_next-0.5.0 → pathlib_next-0.7.0}/src/pathlib_next/protocols/io.py +0 -0
  37. {pathlib_next-0.5.0 → pathlib_next-0.7.0}/src/pathlib_next/py.typed +0 -0
  38. {pathlib_next-0.5.0 → pathlib_next-0.7.0}/src/pathlib_next/testing.py +0 -0
  39. {pathlib_next-0.5.0 → pathlib_next-0.7.0}/src/pathlib_next/uri/__init__.py +0 -0
  40. {pathlib_next-0.5.0 → pathlib_next-0.7.0}/src/pathlib_next/uri/query.py +0 -0
  41. {pathlib_next-0.5.0 → pathlib_next-0.7.0}/src/pathlib_next/uri/schemes/file.py +0 -0
  42. {pathlib_next-0.5.0 → pathlib_next-0.7.0}/src/pathlib_next/uri/schemes/http.py +0 -0
  43. {pathlib_next-0.5.0 → pathlib_next-0.7.0}/src/pathlib_next/uri/schemes/sftp.py +0 -0
  44. {pathlib_next-0.5.0 → pathlib_next-0.7.0}/src/pathlib_next/uri/source.py +0 -0
  45. {pathlib_next-0.5.0 → pathlib_next-0.7.0}/src/pathlib_next/utils/__init__.py +0 -0
  46. {pathlib_next-0.5.0 → pathlib_next-0.7.0}/src/pathlib_next/utils/glob.py +0 -0
  47. {pathlib_next-0.5.0 → pathlib_next-0.7.0}/src/pathlib_next/utils/stat.py +0 -0
  48. {pathlib_next-0.5.0 → pathlib_next-0.7.0}/src/pathlib_next/utils/sync.py +0 -0
  49. {pathlib_next-0.5.0 → pathlib_next-0.7.0}/tests/conftest.py +0 -0
  50. {pathlib_next-0.5.0 → pathlib_next-0.7.0}/tests/test_contract.py +0 -0
  51. {pathlib_next-0.5.0 → pathlib_next-0.7.0}/tests/test_glob.py +0 -0
  52. {pathlib_next-0.5.0 → pathlib_next-0.7.0}/tests/test_http.py +0 -0
  53. {pathlib_next-0.5.0 → pathlib_next-0.7.0}/tests/test_local.py +0 -0
  54. {pathlib_next-0.5.0 → pathlib_next-0.7.0}/tests/test_mempath.py +0 -0
  55. {pathlib_next-0.5.0 → pathlib_next-0.7.0}/tests/test_parity_io.py +0 -0
  56. {pathlib_next-0.5.0 → pathlib_next-0.7.0}/tests/test_parity_pure.py +0 -0
  57. {pathlib_next-0.5.0 → pathlib_next-0.7.0}/tests/test_pathname.py +0 -0
  58. {pathlib_next-0.5.0 → pathlib_next-0.7.0}/tests/test_query.py +0 -0
  59. {pathlib_next-0.5.0 → pathlib_next-0.7.0}/tests/test_sftp.py +0 -0
  60. {pathlib_next-0.5.0 → pathlib_next-0.7.0}/tests/test_smoke.py +0 -0
  61. {pathlib_next-0.5.0 → pathlib_next-0.7.0}/tests/test_source.py +0 -0
  62. {pathlib_next-0.5.0 → pathlib_next-0.7.0}/tests/test_sync.py +0 -0
  63. {pathlib_next-0.5.0 → pathlib_next-0.7.0}/tests/test_uri_parse.py +0 -0
  64. {pathlib_next-0.5.0 → pathlib_next-0.7.0}/tests/test_uri_path.py +0 -0
  65. {pathlib_next-0.5.0 → pathlib_next-0.7.0}/tests/test_utils.py +0 -0
@@ -7,6 +7,52 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.7.0] - 2026-07-11
11
+
12
+ ### Added (Phase 7b new schemes, optional extras)
13
+ - `dav:`/`davs:` scheme (`pathlib_next.uri.schemes.webdav.DavPath`):
14
+ extends `HttpPath` with WebDAV (RFC 4918) PROPFIND for real stat/listdir
15
+ metadata (replacing HTML-index scraping) and PUT/DELETE/MKCOL/MOVE for
16
+ full read/write access. Requests go to the equivalent `http:`/`https:`
17
+ URL; `as_uri()` still reports `dav:`/`davs:`. Reuses the `http` extra,
18
+ no new dependency. `rmdir()` is recursive by WebDAV spec, unlike
19
+ `pathlib.Path.rmdir()`'s "must be empty" contract -- documented, not
20
+ silent.
21
+ - `s3:` scheme (`pathlib_next.uri.schemes.s3.S3Path`,
22
+ `s3://bucket/key/path`): read/write/list via `boto3`. New `s3` extra.
23
+ S3 has no real directories -- `is_dir()` is prefix emulation (any
24
+ object key under `"<path>/"`), `mkdir()` creates a zero-byte `"<path>/"`
25
+ marker object, `rmdir()` requires no other keys under that prefix.
26
+ `rename()` uses server-side `copy_object`+`delete_object` (same-bucket
27
+ only) instead of the generic download+upload+delete `move()` fallback.
28
+
29
+ ## [0.6.0] - 2026-07-11
30
+
31
+ ### Added (Phase 7a new schemes, stdlib-only, no new deps)
32
+ - `data:` scheme (RFC 2397, `pathlib_next.uri.schemes.data.DataUri`):
33
+ read-only, no backend/connection -- the entire file content is embedded
34
+ in the URI (`data:[<mediatype>][;base64],<data>`). `stat().st_size` is
35
+ the decoded payload length; `iterdir()` raises `NotADirectoryError`
36
+ (it's always a single file); write operations raise `NotImplementedError`.
37
+ - `ftp:`/`ftps:` scheme (`pathlib_next.uri.schemes.ftp.FtpPath`): full
38
+ read/write/list access via stdlib `ftplib`, with a thread-keyed LRU
39
+ connection cache mirroring `sftp.py`. Listing/stat prefer MLSD (RFC
40
+ 3659); servers without it fall back to NLST (listing) and SIZE
41
+ (file-only stat). Writes buffer in memory and upload via STOR/APPE on
42
+ `close()`. `chmod()` uses the common but non-standard `SITE CHMOD`
43
+ extension (may not be supported by every server).
44
+ - `zip:`/`tar:` archive paths (`pathlib_next.uri.schemes.archive`):
45
+ `<scheme>:<archive-uri>!/<inner-path>` (Java-style `!/` separator,
46
+ URI form proposed to and confirmed by the user before implementation).
47
+ The archive half is itself any absolute URI with an explicit scheme, so
48
+ archives are readable straight off any other backend (`file:`, `http:`,
49
+ `sftp:`, `ftp:`, `data:`, ...). Read is supported for both schemes.
50
+ Write is `zip:`-only, and only for brand-new entries in a local (`file:`)
51
+ outer archive (overwriting/deleting/renaming an existing entry would
52
+ need a full-archive rewrite -- not implemented, raises
53
+ `NotImplementedError`). `tar:` auto-detects `.tar.gz`/`.tar.bz2`/`.tar.xz`
54
+ compression and is always read-only.
55
+
10
56
  ## [0.5.0] - 2026-07-11
11
57
 
12
58
  ### Fixed (critical -- found while writing Phase 6 examples)
@@ -208,7 +254,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
208
254
  - Sync error handling.
209
255
  - Generic Path Protocol based pathlib implementation for URI paths with file access support for sftp, http, file schemes.
210
256
 
211
- [Unreleased]: https://github.com/jose-pr/pathlib_next/compare/v0.5.0...HEAD
257
+ [Unreleased]: https://github.com/jose-pr/pathlib_next/compare/v0.7.0...HEAD
258
+ [0.7.0]: https://github.com/jose-pr/pathlib_next/compare/v0.6.0...v0.7.0
259
+ [0.6.0]: https://github.com/jose-pr/pathlib_next/compare/v0.5.0...v0.6.0
212
260
  [0.5.0]: https://github.com/jose-pr/pathlib_next/compare/v0.4.1...v0.5.0
213
261
  [0.4.1]: https://github.com/jose-pr/pathlib_next/compare/v0.4.0...v0.4.1
214
262
  [0.4.0]: https://github.com/jose-pr/pathlib_next/releases/tag/v0.4.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pathlib_next
3
- Version: 0.5.0
3
+ Version: 0.7.0
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/
@@ -26,6 +26,9 @@ Requires-Dist: bs4; extra == 'http'
26
26
  Requires-Dist: htmllistparse; extra == 'http'
27
27
  Requires-Dist: requests; extra == 'http'
28
28
  Requires-Dist: uritools; extra == 'http'
29
+ Provides-Extra: s3
30
+ Requires-Dist: boto3; extra == 's3'
31
+ Requires-Dist: uritools; extra == 's3'
29
32
  Provides-Extra: sftp
30
33
  Requires-Dist: paramiko; extra == 'sftp'
31
34
  Requires-Dist: uritools; extra == 'sftp'
@@ -20,6 +20,8 @@ The divergences below apply to `Uri`/`UriPath` and `MemPath`.
20
20
  | `Path.move(target, ...)` | Not in `pathlib` at all | Our own extension: tries `rename()`, falls back to copy+unlink | N/A -- pure extension, no pathlib method to diverge from. |
21
21
  | `Path.rm(recursive=, missing_ok=, ignore_error=)` | Not in `pathlib` (closest: `shutil.rmtree`) | Our own extension | N/A -- pure extension. |
22
22
  | `PathSyncer` / `Query` / `Source` | N/A | Our own extensions | N/A -- pure extensions, no pathlib equivalent. |
23
+ | `DavPath.rmdir()` | `pathlib.Path.rmdir()` requires the directory to be empty, raises `OSError` otherwise | WebDAV `DELETE` on a collection is recursive by spec (RFC 4918) -- deletes non-empty collections too | Not our choice -- inherent to the WebDAV protocol's `DELETE` semantics; no separate "delete if empty" verb exists to build an empty-check on top of. **Caution**, not a feature. |
24
+ | `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", unlike `DavPath.rmdir()` above) | S3 has no native directory concept -- this is the same prefix convention the AWS console itself uses for an empty "folder". |
23
25
 
24
26
  ## Explicitly out of scope (not implemented on `Pathname`/`Path`)
25
27
 
@@ -0,0 +1,78 @@
1
+ # Schemes
2
+
3
+ Capability matrix for every built-in `Path`/`UriPath` implementation. A
4
+ "No" means the method raises `NotImplementedError` (or, for `http:`, isn't
5
+ meaningful for a read-only scheme) -- everything else (name/suffix parsing,
6
+ `glob()`, `walk()`, `copy()`, ...) is derived from these primitives and
7
+ works identically across all of them.
8
+
9
+ | Capability | `LocalPath` | `file:` (`FileUri`) | `mem:` (`MemPath`) | `http(s):` (`HttpPath`) | `sftp:` (`SftpPath`) | `data:` (`DataUri`) | `ftp(s):` (`FtpPath`) | `zip:` (`ZipUri`) | `tar:` (`TarUri`) | `dav(s):` (`DavPath`) | `s3:` (`S3Path`) |
10
+ | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
11
+ | Read (`read_text`/`read_bytes`/`open`) | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
12
+ | Write (`write_text`/`write_bytes`/`open("w")`) | Yes | Yes | Yes | No | Yes | No | Yes | New entries only, local outer archive only | No | Yes | Yes |
13
+ | List (`iterdir`) | Yes | Yes | Yes | Yes (scrapes an HTML index) | Yes | No (`NotADirectoryError`) | Yes (MLSD, falls back to NLST) | Yes | Yes | Yes (PROPFIND) | Yes (prefix + delimiter emulation) |
14
+ | Stat (`stat`, `exists`, `is_dir`, `is_file`, ...) | Yes | Yes | Yes | Yes (via `HEAD`, falls back to `GET`) | Yes | Yes (`st_size` from decoded payload) | Yes (MLSD, falls back to SIZE for files) | Yes | Yes | Yes (PROPFIND) | Yes (`is_dir` is prefix emulation) |
15
+ | `mkdir` | Yes | Yes | Yes | No | Yes | No | Yes | Local outer archive only (zero-length `name/` entry) | No | Yes (MKCOL) | Yes (zero-byte `key/` marker object) |
16
+ | Delete (`unlink`/`rmdir`/`rm`) | Yes | Yes | Yes | No | Yes | No | Yes | No | No | Yes (`rmdir` is recursive, see notes) | Yes (`rmdir` requires empty prefix) |
17
+ | `rename` | Yes | Yes | No (`move()` falls back to copy+unlink) | No | Yes | No | Yes | No | No | Yes (MOVE) | Yes (server-side `copy_object`+delete, same bucket) |
18
+ | `chmod` | Yes | Yes | No | No | Yes (no `follow_symlinks=False`; paramiko has no `lchmod`) | No | Yes (`SITE CHMOD`, server-dependent) | No | No | No | No |
19
+ | Extra required | none | none | none | `http` | `sftp` | none (stdlib) | none (stdlib `ftplib`) | none (stdlib `zipfile`) | none (stdlib `tarfile`) | `http` (reused) | `s3` |
20
+
21
+ Notes:
22
+
23
+ - **`file:`** is a thin `UriPath` wrapper around `LocalPath` -- it has
24
+ identical capabilities, just addressed by URI instead of a native path.
25
+ - **`mem:` (`MemPath`)** isn't a `UriPath` at all -- it's a plain `Path`
26
+ subclass (Track A of [Extending](extending.md)), backed by nested dicts
27
+ (`MemPathBackend`). No `as_uri()` scheme is registered for it; construct
28
+ it directly via `MemPath(...)`.
29
+ - **`http(s):`** is inherently read-only: there's no portable "create/
30
+ delete/rename a resource" over plain HTTP. Directory listing depends on
31
+ the server producing an Apache/nginx-style HTML index that
32
+ `htmllistparse` can parse (confirmed against Python's own
33
+ `http.server.SimpleHTTPRequestHandler`).
34
+ - **`sftp:`** has the fullest capability set of the URI schemes (it's a
35
+ real remote filesystem protocol). Connections are cached per
36
+ `(backend, source, thread)` (see `pathlib_next.uri.schemes.sftp`).
37
+ - **`data:`** (RFC 2397) has no server or connection at all -- the entire
38
+ "file" content lives in the URI string itself
39
+ (`data:[<mediatype>][;base64],<data>`). It's always a single file, never a
40
+ directory.
41
+ - **`ftp(s):`** uses the same connection-cache pattern as `sftp:` (stdlib
42
+ `ftplib`, no extra required). Prefers MLSD (RFC 3659) for listing/stat --
43
+ gives type/size/modify in one round trip -- and falls back to NLST/SIZE on
44
+ servers that don't support it (that fallback path can't distinguish "file
45
+ doesn't exist" from "is a directory" for `stat()`, since SIZE only works on
46
+ files).
47
+ - **`zip:`/`tar:`** address an entry *inside* an archive:
48
+ `zip:<archive-uri>!/<inner-path>` (Java-style `!/` separator, as in JAR
49
+ URLs / NIO `ZipFileSystem`). The `<archive-uri>` half is itself any
50
+ absolute URI with an explicit scheme -- `file:`, `http:`, `sftp:`,
51
+ `ftp:`, even `data:` -- so an archive is readable straight off any other
52
+ backend; `zip:file:///backups/site.zip!/index.html` and
53
+ `zip:sftp://host/nightly.zip!/index.html` both work the same way.
54
+ `segments`/`name`/`parent`/`glob()`/... all operate on the *inner* path.
55
+ Writing a brand-new entry (not overwriting/deleting/renaming an existing
56
+ one -- that would need a full-archive rewrite, not implemented) only
57
+ works when the outer archive is itself a local `file:` URI; every other
58
+ outer scheme is read-only. `tar:` (with transparent `.tar.gz`/`.tar.bz2`/
59
+ `.tar.xz` decompression) is read-only regardless of the outer scheme.
60
+ - **`dav(s):`** is WebDAV (RFC 4918) layered on `HttpPath` -- PROPFIND
61
+ replaces HTML-index scraping for real stat/listdir metadata, and PUT/
62
+ DELETE/MKCOL/MOVE give it full write support (unlike plain `http(s):`).
63
+ Requests go out over the equivalent `http:`/`https:` URL; `as_uri()`
64
+ still reports `dav:`/`davs:`. Reuses the `http` extra, no new dependency.
65
+ **Caution:** `rmdir()` maps to WebDAV `DELETE`, which is recursive by
66
+ spec -- unlike `pathlib.Path.rmdir()`, it does not require the
67
+ collection to be empty first.
68
+ - **`s3:`** (`s3://bucket/key/path`) has no real directories: `is_dir()`
69
+ is prefix emulation (any object key under `"<path>/"`), and `mkdir()`
70
+ creates a zero-byte `"<path>/"` marker object (the same convention the
71
+ AWS console itself uses for an empty "folder") -- `rmdir()` requires no
72
+ other keys under that prefix (pathlib's "must be empty" semantics, unlike
73
+ `dav:`'s recursive `DELETE`). A single `boto3` client is cached per
74
+ backend (documented thread-safe, unlike `sftp:`/`ftp:`'s per-thread
75
+ connection pools).
76
+ - See [Divergences from pathlib](../divergences.md) for the "explicitly out
77
+ of scope" list (`resolve`, `symlink_to`, `owner`, `expanduser`, ...) that
78
+ applies uniformly across every non-`LocalPath` implementation.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pathlib_next"
7
- version = "0.5.0"
7
+ version = "0.7.0"
8
8
  authors = [{ name = "Jose A" }]
9
9
  description = "Generic Path Protocol based pathlib"
10
10
  readme = "README.md"
@@ -19,6 +19,7 @@ dependencies = []
19
19
  uri = ["uritools"]
20
20
  http = ["requests", "pathlib_next[uri]", 'htmllistparse', 'bs4']
21
21
  sftp = ["paramiko", "pathlib_next[uri]"]
22
+ s3 = ["boto3", "pathlib_next[uri]"]
22
23
  dev = ['build', 'twine', 'hatchling', 'pytest', 'pytest-cov']
23
24
  docs = ['mkdocs', 'mkdocs-material', 'mkdocstrings[python]']
24
25
 
@@ -0,0 +1,22 @@
1
+ from .archive import TarUri as TarUri
2
+ from .archive import ZipUri as ZipUri
3
+ from .data import DataUri as DataUri
4
+ from .file import FileUri as FileUri
5
+ from .ftp import FtpPath as FtpPath
6
+
7
+ try:
8
+ from .http import HttpPath as HttpPath
9
+ except ImportError:
10
+ pass
11
+ try:
12
+ from .webdav import DavPath as DavPath
13
+ except ImportError:
14
+ pass
15
+ try:
16
+ from .sftp import SftpPath as SftpPath
17
+ except ImportError:
18
+ pass
19
+ try:
20
+ from .s3 import S3Path as S3Path
21
+ except ImportError:
22
+ pass
@@ -0,0 +1,266 @@
1
+ from __future__ import annotations
2
+
3
+ import io as _io
4
+ import re as _re
5
+ import tarfile as _tarfile
6
+ import time as _time
7
+ import zipfile as _zipfile
8
+
9
+ from ...utils.stat import FileStat
10
+ from .. import UriPath
11
+ from .file import FileUri
12
+
13
+ _SEP = "!/"
14
+ _SCHEME_RE = _re.compile(r"^[a-zA-Z][a-zA-Z0-9+.\-]*:")
15
+
16
+
17
+ def _split_archive_path(path: str) -> "tuple[str, str]":
18
+ """Split `<archive-uri>!/<inner-path>` (Java-style separator, as used
19
+ for JAR URLs / NIO ZipFileSystem) into its two halves. No separator (or
20
+ a bare trailing "!") means the archive root."""
21
+ if _SEP in path:
22
+ archive, inner = path.split(_SEP, 1)
23
+ elif path.endswith("!"):
24
+ archive, inner = path[:-1], ""
25
+ else:
26
+ archive, inner = path, ""
27
+ return archive, inner
28
+
29
+
30
+ def _open_outer(archive_uri: str) -> "UriPath":
31
+ if not _SCHEME_RE.match(archive_uri):
32
+ raise ValueError(
33
+ f"Archive URI {archive_uri!r} has no scheme -- prefix it "
34
+ "explicitly, e.g. 'file:///path/to/archive.zip'."
35
+ )
36
+ return UriPath(archive_uri)
37
+
38
+
39
+ class _ArchiveBackend:
40
+ """Lazily opens+caches the archive handle for one outer archive URI.
41
+ Shared by every `ArchiveUri` instance derived from the same one (backend
42
+ propagation is the existing `UriPath` machinery -- see `ArchiveUri._init`).
43
+ NOT deduplicated across independently-constructed top-level `UriPath(...)`
44
+ strings pointing at the same archive -- derive sibling members via `/`/
45
+ `joinpath` from a shared instance rather than reparsing the same archive
46
+ URI twice, especially for writable zips (two separate handles writing
47
+ the same underlying file can corrupt it)."""
48
+
49
+ __slots__ = ("outer", "_handle")
50
+
51
+ def __init__(self, outer: "UriPath"):
52
+ self.outer = outer
53
+ self._handle = None
54
+
55
+ def _open(self):
56
+ raise NotImplementedError
57
+
58
+ @property
59
+ def handle(self):
60
+ if self._handle is None:
61
+ self._handle = self._open()
62
+ return self._handle
63
+
64
+ @property
65
+ def writable(self) -> bool:
66
+ return False
67
+
68
+ def names(self) -> "list[str]":
69
+ raise NotImplementedError
70
+
71
+ def read_member(self, path: str):
72
+ raise NotImplementedError
73
+
74
+ def member_stat(self, path: str) -> FileStat:
75
+ raise NotImplementedError
76
+
77
+
78
+ class _ZipBackend(_ArchiveBackend):
79
+ __slots__ = ()
80
+
81
+ @property
82
+ def writable(self) -> bool:
83
+ # Writing individual entries in place (via ZipFile.open(name, "w"))
84
+ # only works when we hold the archive open in "a" mode against a
85
+ # real seekable local file -- not for remote/embedded outer URIs.
86
+ return isinstance(self.outer, FileUri)
87
+
88
+ def _open(self):
89
+ if self.writable:
90
+ return _zipfile.ZipFile(str(self.outer.filepath), mode="a")
91
+ return _zipfile.ZipFile(_io.BytesIO(self.outer.read_bytes()), mode="r")
92
+
93
+ def names(self):
94
+ return self.handle.namelist()
95
+
96
+ def read_member(self, path):
97
+ return self.handle.open(path, "r") # raises KeyError if missing
98
+
99
+ def write_member(self, path: str, data: bytes):
100
+ # zipfile only persists the central directory to disk when the
101
+ # *archive* (not just the entry) is closed -- close and drop the
102
+ # cached handle so both this backend's next operation and any
103
+ # independently-opened ZipUri see the write.
104
+ self.handle.writestr(path, data)
105
+ self.handle.close()
106
+ self._handle = None
107
+
108
+ def member_stat(self, path):
109
+ info = self.handle.getinfo(path)
110
+ mtime = int(_time.mktime((*info.date_time, 0, 0, -1))) if info.date_time else 0
111
+ return FileStat(st_size=info.file_size, st_mtime=mtime, is_dir=path.endswith("/"))
112
+
113
+
114
+ class _TarBackend(_ArchiveBackend):
115
+ __slots__ = ()
116
+
117
+ def _open(self):
118
+ return _tarfile.open(fileobj=_io.BytesIO(self.outer.read_bytes()), mode="r:*")
119
+
120
+ def names(self):
121
+ return self.handle.getnames()
122
+
123
+ def read_member(self, path):
124
+ try:
125
+ member = self.handle.getmember(path)
126
+ except KeyError:
127
+ raise
128
+ f = self.handle.extractfile(member)
129
+ if f is None:
130
+ raise IsADirectoryError(path)
131
+ return f
132
+
133
+ def member_stat(self, path):
134
+ info = self.handle.getmember(path)
135
+ return FileStat(st_size=info.size, st_mtime=int(info.mtime), is_dir=info.isdir())
136
+
137
+
138
+ class ArchiveUri(UriPath):
139
+ """Common base for `zip:`/`tar:` archive paths:
140
+ `<scheme>:<archive-uri>!/<inner-path>` (Java-style separator; the
141
+ `<archive-uri>` half is itself any absolute URI with an explicit scheme
142
+ -- `file:`, `http:`, `sftp:`, `ftp:`, ... -- so archives are readable
143
+ straight off any existing backend). `segments`/`name`/`parent`/`glob`/
144
+ ... all operate on the *inner* path; the outer archive handle is the
145
+ `backend` (`_ZipBackend`/`_TarBackend`), propagated through the normal
146
+ backend machinery to every path derived from this one."""
147
+
148
+ __slots__ = ()
149
+ _backend_cls: type = None
150
+
151
+ def _init(self, source, path, query, fragment, /, **kwargs):
152
+ backend = kwargs.get("backend", None) or self._backend
153
+ if backend is None:
154
+ # Fresh top-level construction (e.g. UriPath("zip:...!/...")) --
155
+ # `path` is still the raw "<archive-uri>!/<inner>" string.
156
+ archive_str, inner = _split_archive_path(path)
157
+ backend = self._backend_cls(_open_outer(archive_str))
158
+ else:
159
+ # Derived instance (with_segments/joinpath/_make_child_relpath)
160
+ # -- backend already known, `path` is already just the inner
161
+ # path (no "archive!/" prefix to strip).
162
+ inner = path
163
+ kwargs["backend"] = backend
164
+ super()._init(source, inner, query, fragment, **kwargs)
165
+
166
+ def as_uri(self, /, sanitize=False):
167
+ outer_uri = self.backend.outer.as_uri(sanitize=sanitize)
168
+ return f"{self.source.scheme}:{outer_uri}!/{self.path}"
169
+
170
+ def _names(self):
171
+ return self.backend.names()
172
+
173
+ def _listdir(self):
174
+ prefix = f"{self.path}/" if self.path else ""
175
+ seen = set()
176
+ for name in self._names():
177
+ if not name.startswith(prefix):
178
+ continue
179
+ rest = name[len(prefix) :]
180
+ if not rest:
181
+ continue
182
+ child = rest.split("/", 1)[0]
183
+ if child and child not in seen:
184
+ seen.add(child)
185
+ yield child
186
+
187
+ def stat(self, *, follow_symlinks=True):
188
+ path = self.path
189
+ if path == "":
190
+ return FileStat(is_dir=True)
191
+ names = self._names()
192
+ if path in names:
193
+ return self.backend.member_stat(path)
194
+ dirmarker = f"{path}/"
195
+ if dirmarker in names or any(n.startswith(dirmarker) for n in names):
196
+ return FileStat(is_dir=True)
197
+ raise FileNotFoundError(self)
198
+
199
+ def _open(self, mode="r", buffering=-1):
200
+ if "r" in mode:
201
+ try:
202
+ return self.backend.read_member(self.path)
203
+ except KeyError as error:
204
+ raise FileNotFoundError(self) from error
205
+ raise NotImplementedError(f"open(mode={mode!r}) on {self.source.scheme}:")
206
+
207
+
208
+ class _ZipWriteStream(_io.BytesIO):
209
+ """Buffers a new entry's content in memory; on close(), writes it via
210
+ `writestr()` and flushes+reopens the archive (see
211
+ `_ZipBackend.write_member`)."""
212
+
213
+ def __init__(self, backend: "_ZipBackend", path: str):
214
+ super().__init__()
215
+ self._backend = backend
216
+ self._path = path
217
+
218
+ def close(self):
219
+ if not self.closed:
220
+ self._backend.write_member(self._path, self.getvalue())
221
+ super().close()
222
+
223
+
224
+ class ZipUri(ArchiveUri):
225
+ """`zip:` scheme. Read/write: writing a new entry works when the outer
226
+ archive is a local `file:` URI (opened in `zipfile`'s "a" -- append --
227
+ mode, so existing entries are untouched); every other outer scheme is
228
+ read-only (fetched fully into memory first). Overwriting/deleting/
229
+ renaming an existing entry is not implemented (would require rewriting
230
+ the whole archive) -- only adding new entries and (for directories)
231
+ zero-length `"name/"` marker entries."""
232
+
233
+ __SCHEMES = ("zip",)
234
+ __slots__ = ()
235
+ _backend_cls = _ZipBackend
236
+
237
+ def _open(self, mode="r", buffering=-1):
238
+ if "r" in mode:
239
+ return super()._open(mode, buffering)
240
+ if not self.backend.writable:
241
+ raise NotImplementedError(
242
+ "zip: write support requires a local (file:) outer archive"
243
+ )
244
+ if mode not in ("w", "x"):
245
+ raise NotImplementedError(f"open(mode={mode!r})")
246
+ if mode == "x" and self.exists():
247
+ raise FileExistsError(self)
248
+ return _ZipWriteStream(self.backend, self.path)
249
+
250
+ def _mkdir(self, mode):
251
+ if not self.backend.writable:
252
+ raise NotImplementedError(
253
+ "zip: write support requires a local (file:) outer archive"
254
+ )
255
+ if self.exists():
256
+ raise FileExistsError(self)
257
+ self.backend.write_member(f"{self.path}/", b"")
258
+
259
+
260
+ class TarUri(ArchiveUri):
261
+ """`tar:` scheme (also handles `.tar.gz`/`.tar.bz2`/`.tar.xz` via
262
+ `tarfile`'s auto-detected "r:*" mode). Read-only."""
263
+
264
+ __SCHEMES = ("tar",)
265
+ __slots__ = ()
266
+ _backend_cls = _TarBackend
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ import io as _io
4
+ import urllib.parse as _urlparse
5
+ from base64 import b64decode as _b64decode
6
+
7
+ from ...utils.stat import FileStat
8
+ from .. import UriPath
9
+
10
+ _DEFAULT_MEDIATYPE = "text/plain;charset=US-ASCII"
11
+
12
+
13
+ class DataUri(UriPath):
14
+ """`data:` scheme (RFC 2397): a read-only "file" whose entire content is
15
+ embedded in the URI itself (`data:[<mediatype>][;base64],<data>`) -- no
16
+ filesystem, no directories, no backend/connection. Percent-encoded
17
+ reserved characters (`?`/`#`) in the payload are only decoded correctly
18
+ if the URI was built with them escaped, per RFC 2397/3986."""
19
+
20
+ __SCHEMES = ("data",)
21
+ __slots__ = ()
22
+
23
+ @property
24
+ def _header(self) -> str:
25
+ header, _, _ = self.path.partition(",")
26
+ return header
27
+
28
+ @property
29
+ def _is_base64(self) -> bool:
30
+ return self._header.rsplit(";", 1)[-1].strip().lower() == "base64"
31
+
32
+ @property
33
+ def mediatype(self) -> str:
34
+ header = self._header
35
+ if not header:
36
+ return _DEFAULT_MEDIATYPE
37
+ if self._is_base64:
38
+ header = header.rsplit(";", 1)[0]
39
+ return header or _DEFAULT_MEDIATYPE
40
+
41
+ def _content(self) -> bytes:
42
+ header, sep, data = self.path.partition(",")
43
+ if not sep:
44
+ raise FileNotFoundError(self)
45
+ if self._is_base64:
46
+ return _b64decode(_urlparse.unquote_to_bytes(data))
47
+ return _urlparse.unquote_to_bytes(data)
48
+
49
+ def stat(self, *, follow_symlinks=True):
50
+ return FileStat(st_size=len(self._content()), is_dir=False)
51
+
52
+ def _open(self, mode="r", buffering=-1):
53
+ if "r" not in mode:
54
+ raise NotImplementedError("data: URIs are read-only")
55
+ return _io.BytesIO(self._content())
56
+
57
+ def _listdir(self):
58
+ raise NotADirectoryError(self)