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.
- {pathlib_next-0.5.0 → pathlib_next-0.7.0}/CHANGELOG.md +49 -1
- {pathlib_next-0.5.0 → pathlib_next-0.7.0}/PKG-INFO +4 -1
- {pathlib_next-0.5.0 → pathlib_next-0.7.0}/docs/divergences.md +2 -0
- pathlib_next-0.7.0/docs/guides/schemes.md +78 -0
- {pathlib_next-0.5.0 → pathlib_next-0.7.0}/pyproject.toml +2 -1
- pathlib_next-0.7.0/src/pathlib_next/uri/schemes/__init__.py +22 -0
- pathlib_next-0.7.0/src/pathlib_next/uri/schemes/archive.py +266 -0
- pathlib_next-0.7.0/src/pathlib_next/uri/schemes/data.py +58 -0
- pathlib_next-0.7.0/src/pathlib_next/uri/schemes/ftp.py +205 -0
- pathlib_next-0.7.0/src/pathlib_next/uri/schemes/s3.py +179 -0
- pathlib_next-0.7.0/src/pathlib_next/uri/schemes/webdav.py +180 -0
- pathlib_next-0.7.0/tests/test_archive_uri.py +198 -0
- pathlib_next-0.7.0/tests/test_data_uri.py +76 -0
- pathlib_next-0.7.0/tests/test_ftp.py +313 -0
- pathlib_next-0.7.0/tests/test_s3.py +210 -0
- pathlib_next-0.7.0/tests/test_webdav.py +250 -0
- pathlib_next-0.5.0/docs/guides/schemes.md +0 -39
- pathlib_next-0.5.0/src/pathlib_next/uri/schemes/__init__.py +0 -10
- {pathlib_next-0.5.0 → pathlib_next-0.7.0}/.gitignore +0 -0
- {pathlib_next-0.5.0 → pathlib_next-0.7.0}/LICENSE +0 -0
- {pathlib_next-0.5.0 → pathlib_next-0.7.0}/README.md +0 -0
- {pathlib_next-0.5.0 → pathlib_next-0.7.0}/docs/api/reference.md +0 -0
- {pathlib_next-0.5.0 → pathlib_next-0.7.0}/docs/changelog.md +0 -0
- {pathlib_next-0.5.0 → pathlib_next-0.7.0}/docs/guides/extending.md +0 -0
- {pathlib_next-0.5.0 → pathlib_next-0.7.0}/docs/index.md +0 -0
- {pathlib_next-0.5.0 → pathlib_next-0.7.0}/examples/http_listing.py +0 -0
- {pathlib_next-0.5.0 → pathlib_next-0.7.0}/examples/local_and_mem.py +0 -0
- {pathlib_next-0.5.0 → pathlib_next-0.7.0}/examples/sftp_sync.py +0 -0
- {pathlib_next-0.5.0 → pathlib_next-0.7.0}/mkdocs.yml +0 -0
- {pathlib_next-0.5.0 → pathlib_next-0.7.0}/src/pathlib_next/__init__.py +0 -0
- {pathlib_next-0.5.0 → pathlib_next-0.7.0}/src/pathlib_next/fspath.py +0 -0
- {pathlib_next-0.5.0 → pathlib_next-0.7.0}/src/pathlib_next/mempath.py +0 -0
- {pathlib_next-0.5.0 → pathlib_next-0.7.0}/src/pathlib_next/path.py +0 -0
- {pathlib_next-0.5.0 → pathlib_next-0.7.0}/src/pathlib_next/protocols/__init__.py +0 -0
- {pathlib_next-0.5.0 → pathlib_next-0.7.0}/src/pathlib_next/protocols/fs.py +0 -0
- {pathlib_next-0.5.0 → pathlib_next-0.7.0}/src/pathlib_next/protocols/io.py +0 -0
- {pathlib_next-0.5.0 → pathlib_next-0.7.0}/src/pathlib_next/py.typed +0 -0
- {pathlib_next-0.5.0 → pathlib_next-0.7.0}/src/pathlib_next/testing.py +0 -0
- {pathlib_next-0.5.0 → pathlib_next-0.7.0}/src/pathlib_next/uri/__init__.py +0 -0
- {pathlib_next-0.5.0 → pathlib_next-0.7.0}/src/pathlib_next/uri/query.py +0 -0
- {pathlib_next-0.5.0 → pathlib_next-0.7.0}/src/pathlib_next/uri/schemes/file.py +0 -0
- {pathlib_next-0.5.0 → pathlib_next-0.7.0}/src/pathlib_next/uri/schemes/http.py +0 -0
- {pathlib_next-0.5.0 → pathlib_next-0.7.0}/src/pathlib_next/uri/schemes/sftp.py +0 -0
- {pathlib_next-0.5.0 → pathlib_next-0.7.0}/src/pathlib_next/uri/source.py +0 -0
- {pathlib_next-0.5.0 → pathlib_next-0.7.0}/src/pathlib_next/utils/__init__.py +0 -0
- {pathlib_next-0.5.0 → pathlib_next-0.7.0}/src/pathlib_next/utils/glob.py +0 -0
- {pathlib_next-0.5.0 → pathlib_next-0.7.0}/src/pathlib_next/utils/stat.py +0 -0
- {pathlib_next-0.5.0 → pathlib_next-0.7.0}/src/pathlib_next/utils/sync.py +0 -0
- {pathlib_next-0.5.0 → pathlib_next-0.7.0}/tests/conftest.py +0 -0
- {pathlib_next-0.5.0 → pathlib_next-0.7.0}/tests/test_contract.py +0 -0
- {pathlib_next-0.5.0 → pathlib_next-0.7.0}/tests/test_glob.py +0 -0
- {pathlib_next-0.5.0 → pathlib_next-0.7.0}/tests/test_http.py +0 -0
- {pathlib_next-0.5.0 → pathlib_next-0.7.0}/tests/test_local.py +0 -0
- {pathlib_next-0.5.0 → pathlib_next-0.7.0}/tests/test_mempath.py +0 -0
- {pathlib_next-0.5.0 → pathlib_next-0.7.0}/tests/test_parity_io.py +0 -0
- {pathlib_next-0.5.0 → pathlib_next-0.7.0}/tests/test_parity_pure.py +0 -0
- {pathlib_next-0.5.0 → pathlib_next-0.7.0}/tests/test_pathname.py +0 -0
- {pathlib_next-0.5.0 → pathlib_next-0.7.0}/tests/test_query.py +0 -0
- {pathlib_next-0.5.0 → pathlib_next-0.7.0}/tests/test_sftp.py +0 -0
- {pathlib_next-0.5.0 → pathlib_next-0.7.0}/tests/test_smoke.py +0 -0
- {pathlib_next-0.5.0 → pathlib_next-0.7.0}/tests/test_source.py +0 -0
- {pathlib_next-0.5.0 → pathlib_next-0.7.0}/tests/test_sync.py +0 -0
- {pathlib_next-0.5.0 → pathlib_next-0.7.0}/tests/test_uri_parse.py +0 -0
- {pathlib_next-0.5.0 → pathlib_next-0.7.0}/tests/test_uri_path.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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)
|