zipwire 0.1.0__tar.gz → 0.2.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.
- {zipwire-0.1.0 → zipwire-0.2.0}/.github/workflows/ci.yml +5 -5
- {zipwire-0.1.0 → zipwire-0.2.0}/.github/workflows/codeql.yml +2 -2
- {zipwire-0.1.0 → zipwire-0.2.0}/.github/workflows/scorecard.yml +1 -1
- {zipwire-0.1.0 → zipwire-0.2.0}/AGENTS.md +12 -0
- zipwire-0.2.0/CLAUDE.md +1 -0
- {zipwire-0.1.0 → zipwire-0.2.0}/PKG-INFO +1 -1
- {zipwire-0.1.0 → zipwire-0.2.0}/docs/api.rst +15 -0
- zipwire-0.2.0/src/zipwire/__init__.py +98 -0
- {zipwire-0.1.0 → zipwire-0.2.0}/src/zipwire/__main__.py +4 -8
- {zipwire-0.1.0 → zipwire-0.2.0}/src/zipwire/_async.py +42 -8
- {zipwire-0.1.0 → zipwire-0.2.0}/src/zipwire/_decompress.py +4 -4
- {zipwire-0.1.0 → zipwire-0.2.0}/src/zipwire/_sync.py +43 -8
- {zipwire-0.1.0 → zipwire-0.2.0}/src/zipwire/_types.py +10 -0
- {zipwire-0.1.0 → zipwire-0.2.0}/src/zipwire/_version.py +2 -2
- {zipwire-0.1.0 → zipwire-0.2.0}/src/zipwire/backends/_aiohttp.py +6 -1
- {zipwire-0.1.0 → zipwire-0.2.0}/src/zipwire/backends/_httpx2.py +13 -3
- {zipwire-0.1.0 → zipwire-0.2.0}/src/zipwire/backends/_requests.py +6 -1
- {zipwire-0.1.0 → zipwire-0.2.0}/src/zipwire/backends/_urllib3.py +5 -0
- {zipwire-0.1.0 → zipwire-0.2.0}/tests/conftest.py +51 -4
- {zipwire-0.1.0 → zipwire-0.2.0}/tests/test_async.py +67 -5
- {zipwire-0.1.0 → zipwire-0.2.0}/tests/test_backends.py +79 -11
- {zipwire-0.1.0 → zipwire-0.2.0}/tests/test_cli.py +4 -1
- {zipwire-0.1.0 → zipwire-0.2.0}/tests/test_decompress.py +51 -7
- {zipwire-0.1.0 → zipwire-0.2.0}/tests/test_integration.py +23 -9
- {zipwire-0.1.0 → zipwire-0.2.0}/tests/test_sync.py +59 -6
- {zipwire-0.1.0 → zipwire-0.2.0}/tox.ini +25 -5
- {zipwire-0.1.0 → zipwire-0.2.0}/uv.lock +51 -16
- zipwire-0.1.0/src/zipwire/__init__.py +0 -36
- {zipwire-0.1.0 → zipwire-0.2.0}/.github/dependabot.yml +0 -0
- {zipwire-0.1.0 → zipwire-0.2.0}/.github/workflows/release.yml +0 -0
- {zipwire-0.1.0 → zipwire-0.2.0}/.gitignore +0 -0
- {zipwire-0.1.0 → zipwire-0.2.0}/.readthedocs.yaml +0 -0
- {zipwire-0.1.0 → zipwire-0.2.0}/LICENSE +0 -0
- {zipwire-0.1.0 → zipwire-0.2.0}/README.md +0 -0
- {zipwire-0.1.0 → zipwire-0.2.0}/SECURITY.md +0 -0
- {zipwire-0.1.0 → zipwire-0.2.0}/docs/Makefile +0 -0
- {zipwire-0.1.0 → zipwire-0.2.0}/docs/backends.rst +0 -0
- {zipwire-0.1.0 → zipwire-0.2.0}/docs/conf.py +0 -0
- {zipwire-0.1.0 → zipwire-0.2.0}/docs/index.rst +0 -0
- {zipwire-0.1.0 → zipwire-0.2.0}/docs/quickstart.rst +0 -0
- {zipwire-0.1.0 → zipwire-0.2.0}/docs/requirements.txt +0 -0
- {zipwire-0.1.0 → zipwire-0.2.0}/docs/security.rst +0 -0
- {zipwire-0.1.0 → zipwire-0.2.0}/pyproject.toml +0 -0
- {zipwire-0.1.0 → zipwire-0.2.0}/src/zipwire/_constants.py +0 -0
- {zipwire-0.1.0 → zipwire-0.2.0}/src/zipwire/_errors.py +0 -0
- {zipwire-0.1.0 → zipwire-0.2.0}/src/zipwire/_parser.py +0 -0
- {zipwire-0.1.0 → zipwire-0.2.0}/src/zipwire/_zipinfo.py +0 -0
- {zipwire-0.1.0 → zipwire-0.2.0}/src/zipwire/backends/__init__.py +0 -0
- {zipwire-0.1.0 → zipwire-0.2.0}/src/zipwire/py.typed +0 -0
- {zipwire-0.1.0 → zipwire-0.2.0}/tests/__init__.py +0 -0
- {zipwire-0.1.0 → zipwire-0.2.0}/tests/test_parser.py +0 -0
- {zipwire-0.1.0 → zipwire-0.2.0}/tests/test_zipinfo.py +0 -0
|
@@ -43,10 +43,10 @@ jobs:
|
|
|
43
43
|
- uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
|
44
44
|
with:
|
|
45
45
|
enable-cache: true
|
|
46
|
-
- run: uv
|
|
47
|
-
- run:
|
|
48
|
-
- run:
|
|
49
|
-
- uses: actions/upload-artifact@
|
|
46
|
+
- run: uv tool install --python ${{ matrix.python-version }} tox --with tox-uv --with tox-gh
|
|
47
|
+
- run: tox
|
|
48
|
+
- run: tox -e integration
|
|
49
|
+
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
50
50
|
with:
|
|
51
51
|
name: coverage-${{ matrix.python-version }}
|
|
52
52
|
path: .coverage.*
|
|
@@ -64,7 +64,7 @@ jobs:
|
|
|
64
64
|
- uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
|
65
65
|
with:
|
|
66
66
|
enable-cache: true
|
|
67
|
-
- uses: actions/download-artifact@
|
|
67
|
+
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
68
68
|
with:
|
|
69
69
|
pattern: coverage-*
|
|
70
70
|
merge-multiple: true
|
|
@@ -19,7 +19,7 @@ jobs:
|
|
|
19
19
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
20
20
|
with:
|
|
21
21
|
persist-credentials: false
|
|
22
|
-
- uses: github/codeql-action/init@
|
|
22
|
+
- uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
|
|
23
23
|
with:
|
|
24
24
|
languages: python
|
|
25
|
-
- uses: github/codeql-action/analyze@
|
|
25
|
+
- uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
|
|
@@ -28,6 +28,6 @@ jobs:
|
|
|
28
28
|
name: scorecard-results
|
|
29
29
|
path: results.sarif
|
|
30
30
|
retention-days: 5
|
|
31
|
-
- uses: github/codeql-action/upload-sarif@
|
|
31
|
+
- uses: github/codeql-action/upload-sarif@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
|
|
32
32
|
with:
|
|
33
33
|
sarif_file: results.sarif
|
|
@@ -28,6 +28,18 @@ entries on the fly.
|
|
|
28
28
|
- **No local imports.** All imports must be at the top of the file.
|
|
29
29
|
Do not use `from foo import bar` inside functions, methods, or test bodies.
|
|
30
30
|
|
|
31
|
+
## Documentation rules
|
|
32
|
+
|
|
33
|
+
- **Keep docs in sync with API changes.** Whenever you add, remove, rename,
|
|
34
|
+
or change the signature of any public API (classes, functions, exceptions,
|
|
35
|
+
type aliases, backends), you **must** update all of these:
|
|
36
|
+
1. `src/zipwire/__init__.py` -- module docstring (usage examples, backend
|
|
37
|
+
list) and `__all__`.
|
|
38
|
+
2. `docs/` -- Sphinx sources (`api.rst`, `backends.rst`, `quickstart.rst`,
|
|
39
|
+
and any other affected `.rst` file).
|
|
40
|
+
- After editing docs, verify they build cleanly:
|
|
41
|
+
`uvx --with tox-uv tox run -e docs`
|
|
42
|
+
|
|
31
43
|
## Git rules
|
|
32
44
|
|
|
33
45
|
- **Always** sign off commits with `git commit -s`.
|
zipwire-0.2.0/CLAUDE.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
AGENTS.md
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: zipwire
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Read and extract files from remote ZIP archives over HTTP range requests
|
|
5
5
|
Project-URL: Homepage, https://github.com/tiran/zipwire
|
|
6
6
|
Project-URL: Documentation, https://zipwire.readthedocs.io/
|
|
@@ -4,6 +4,18 @@ API Reference
|
|
|
4
4
|
Core
|
|
5
5
|
----
|
|
6
6
|
|
|
7
|
+
.. note::
|
|
8
|
+
|
|
9
|
+
``SyncRemoteZip`` and ``AsyncRemoteZip`` **must** be used as context
|
|
10
|
+
managers. The archive is loaded on entry and all internal state is
|
|
11
|
+
cleared on exit. Calling methods like :meth:`~zipwire.SyncRemoteZip.read`
|
|
12
|
+
outside a ``with`` / ``async with`` block raises :exc:`RuntimeError`.
|
|
13
|
+
|
|
14
|
+
.. warning::
|
|
15
|
+
|
|
16
|
+
``SyncRemoteZip`` and ``AsyncRemoteZip`` instances are **not thread-safe**.
|
|
17
|
+
Each thread or task should use its own instance.
|
|
18
|
+
|
|
7
19
|
.. autoclass:: zipwire.SyncRemoteZip
|
|
8
20
|
:members:
|
|
9
21
|
:special-members: __enter__, __exit__
|
|
@@ -15,6 +27,9 @@ Core
|
|
|
15
27
|
.. autoclass:: zipwire.RemoteZipInfo
|
|
16
28
|
:show-inheritance:
|
|
17
29
|
|
|
30
|
+
.. autoclass:: zipwire.EOCDInfo
|
|
31
|
+
:members:
|
|
32
|
+
|
|
18
33
|
Protocols and Types
|
|
19
34
|
-------------------
|
|
20
35
|
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Read and extract files from remote ZIP archives over HTTP range requests.
|
|
2
|
+
|
|
3
|
+
Only the central directory and the requested entries are downloaded,
|
|
4
|
+
so large archives can be inspected and partially extracted without
|
|
5
|
+
fetching the entire file.
|
|
6
|
+
|
|
7
|
+
Synchronous example
|
|
8
|
+
-------------------
|
|
9
|
+
|
|
10
|
+
::
|
|
11
|
+
|
|
12
|
+
from zipwire import SyncRemoteZip
|
|
13
|
+
from zipwire.backends import Urllib3Reader
|
|
14
|
+
|
|
15
|
+
reader = Urllib3Reader("https://example.com/archive.zip")
|
|
16
|
+
with SyncRemoteZip(reader) as rz:
|
|
17
|
+
for info in rz.infolist():
|
|
18
|
+
print(f"{info.filename} {info.file_size} bytes")
|
|
19
|
+
data = rz.read("path/inside/archive.txt")
|
|
20
|
+
|
|
21
|
+
# Stream a large entry directly to disk
|
|
22
|
+
with open("local.bin", "wb") as fh:
|
|
23
|
+
rz.read_into("large-file.bin", fh)
|
|
24
|
+
|
|
25
|
+
Asynchronous example
|
|
26
|
+
--------------------
|
|
27
|
+
|
|
28
|
+
::
|
|
29
|
+
|
|
30
|
+
import asyncio
|
|
31
|
+
from zipwire import AsyncRemoteZip
|
|
32
|
+
from zipwire.backends import AiohttpReader
|
|
33
|
+
|
|
34
|
+
async def main():
|
|
35
|
+
reader = AiohttpReader("https://example.com/archive.zip")
|
|
36
|
+
async with AsyncRemoteZip(reader) as rz:
|
|
37
|
+
print(await rz.namelist())
|
|
38
|
+
data = await rz.read("path/inside/archive.txt")
|
|
39
|
+
|
|
40
|
+
# Stream a large entry directly to disk
|
|
41
|
+
with open("local.bin", "wb") as fh:
|
|
42
|
+
await rz.read_into("large-file.bin", fh)
|
|
43
|
+
|
|
44
|
+
asyncio.run(main())
|
|
45
|
+
|
|
46
|
+
Backends
|
|
47
|
+
--------
|
|
48
|
+
|
|
49
|
+
All backends live in :mod:`zipwire.backends` and are lazily imported.
|
|
50
|
+
|
|
51
|
+
Synchronous:
|
|
52
|
+
- ``Urllib3Reader`` -- default, uses *urllib3*
|
|
53
|
+
- ``RequestsReader`` -- uses *requests* (``pip install zipwire[requests]``)
|
|
54
|
+
- ``Httpx2SyncReader`` -- uses *httpx2*, supports HTTP/2
|
|
55
|
+
(``pip install zipwire[httpx2]``)
|
|
56
|
+
|
|
57
|
+
Asynchronous:
|
|
58
|
+
- ``AiohttpReader`` -- uses *aiohttp* (``pip install zipwire[aiohttp]``)
|
|
59
|
+
- ``Httpx2AsyncReader`` -- uses *httpx2*, supports HTTP/2
|
|
60
|
+
(``pip install zipwire[httpx2]``)
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
from __future__ import annotations
|
|
64
|
+
|
|
65
|
+
from zipwire._async import AsyncRemoteZip
|
|
66
|
+
from zipwire._constants import CompressionMethod
|
|
67
|
+
from zipwire._errors import (
|
|
68
|
+
BadZipFile,
|
|
69
|
+
CRCMismatch,
|
|
70
|
+
FileNotFoundInZip,
|
|
71
|
+
FileTooLarge,
|
|
72
|
+
RangeRequestUnsupported,
|
|
73
|
+
UnsupportedCompression,
|
|
74
|
+
ZipwireError,
|
|
75
|
+
)
|
|
76
|
+
from zipwire._parser import EOCDInfo
|
|
77
|
+
from zipwire._sync import SyncRemoteZip
|
|
78
|
+
from zipwire._types import AsyncReader, Headers, SyncReader, Writable
|
|
79
|
+
from zipwire._zipinfo import RemoteZipInfo
|
|
80
|
+
|
|
81
|
+
__all__ = [
|
|
82
|
+
"AsyncReader",
|
|
83
|
+
"AsyncRemoteZip",
|
|
84
|
+
"BadZipFile",
|
|
85
|
+
"CRCMismatch",
|
|
86
|
+
"CompressionMethod",
|
|
87
|
+
"EOCDInfo",
|
|
88
|
+
"FileNotFoundInZip",
|
|
89
|
+
"FileTooLarge",
|
|
90
|
+
"Headers",
|
|
91
|
+
"RangeRequestUnsupported",
|
|
92
|
+
"RemoteZipInfo",
|
|
93
|
+
"SyncReader",
|
|
94
|
+
"SyncRemoteZip",
|
|
95
|
+
"UnsupportedCompression",
|
|
96
|
+
"Writable",
|
|
97
|
+
"ZipwireError",
|
|
98
|
+
]
|
|
@@ -29,10 +29,8 @@ def _print_table(infos: list[RemoteZipInfo], skip_dirs: bool) -> None:
|
|
|
29
29
|
def _run_sync(url: str, backend: str, skip_dirs: bool) -> None:
|
|
30
30
|
from zipwire import backends
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
"requests": backends.RequestsReader,
|
|
35
|
-
}[backend]
|
|
32
|
+
names = {"urllib3": "Urllib3Reader", "requests": "RequestsReader"}
|
|
33
|
+
reader_cls = getattr(backends, names[backend])
|
|
36
34
|
with SyncRemoteZip(reader_cls(url)) as rz:
|
|
37
35
|
_print_table(rz.infolist(), skip_dirs)
|
|
38
36
|
|
|
@@ -40,10 +38,8 @@ def _run_sync(url: str, backend: str, skip_dirs: bool) -> None:
|
|
|
40
38
|
async def _run_async(url: str, backend: str, skip_dirs: bool) -> None:
|
|
41
39
|
from zipwire import backends
|
|
42
40
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
"aiohttp": backends.AiohttpReader,
|
|
46
|
-
}[backend]
|
|
41
|
+
names = {"httpx2": "Httpx2AsyncReader", "aiohttp": "AiohttpReader"}
|
|
42
|
+
reader_cls = getattr(backends, names[backend])
|
|
47
43
|
async with AsyncRemoteZip(reader_cls(url)) as rz:
|
|
48
44
|
_print_table(await rz.infolist(), skip_dirs)
|
|
49
45
|
|
|
@@ -13,6 +13,7 @@ from zipwire._constants import (
|
|
|
13
13
|
from zipwire._decompress import StreamingDecompressor, decompress
|
|
14
14
|
from zipwire._errors import FileNotFoundInZip, FileTooLarge, RangeRequestUnsupported
|
|
15
15
|
from zipwire._parser import (
|
|
16
|
+
EOCDInfo,
|
|
16
17
|
find_eocd,
|
|
17
18
|
parse_central_directory,
|
|
18
19
|
parse_local_file_header,
|
|
@@ -22,10 +23,15 @@ from zipwire._zipinfo import RemoteZipInfo
|
|
|
22
23
|
if typing.TYPE_CHECKING:
|
|
23
24
|
from zipwire._types import AsyncReader, Writable
|
|
24
25
|
|
|
26
|
+
_NOT_LOADED = "archive not loaded; use as an async context manager"
|
|
27
|
+
|
|
25
28
|
|
|
26
29
|
class AsyncRemoteZip:
|
|
27
30
|
"""Read files from a remote ZIP archive using async HTTP range requests.
|
|
28
31
|
|
|
32
|
+
Must be used as an async context manager. The archive is loaded on
|
|
33
|
+
entry and all internal state is cleared on exit. Not thread-safe.
|
|
34
|
+
|
|
29
35
|
Usage::
|
|
30
36
|
|
|
31
37
|
async with AsyncRemoteZip(reader) as rz:
|
|
@@ -39,21 +45,48 @@ class AsyncRemoteZip:
|
|
|
39
45
|
self._entries: list[RemoteZipInfo] | None = None
|
|
40
46
|
self._name_index: dict[str, RemoteZipInfo] | None = None
|
|
41
47
|
self._file_size: int | None = None
|
|
48
|
+
self._eocd: EOCDInfo | None = None
|
|
42
49
|
|
|
43
50
|
async def __aenter__(self) -> AsyncRemoteZip:
|
|
51
|
+
await self._ensure_loaded()
|
|
44
52
|
return self
|
|
45
53
|
|
|
46
54
|
async def __aexit__(self, *exc: object) -> None:
|
|
55
|
+
self._entries = None
|
|
56
|
+
self._name_index = None
|
|
57
|
+
self._file_size = None
|
|
58
|
+
self._eocd = None
|
|
47
59
|
await self.close()
|
|
48
60
|
|
|
49
61
|
async def close(self) -> None:
|
|
50
62
|
"""Close the underlying reader."""
|
|
51
63
|
await self._reader.close()
|
|
52
64
|
|
|
65
|
+
@property
|
|
66
|
+
def url(self) -> str:
|
|
67
|
+
"""The target URL of the underlying reader."""
|
|
68
|
+
return self._reader.url
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def file_size(self) -> int:
|
|
72
|
+
"""Total size of the remote archive in bytes."""
|
|
73
|
+
if self._file_size is None:
|
|
74
|
+
raise RuntimeError(_NOT_LOADED)
|
|
75
|
+
return self._file_size
|
|
76
|
+
|
|
77
|
+
async def get_eocd_info(self) -> EOCDInfo:
|
|
78
|
+
"""Return the parsed End of Central Directory record.
|
|
79
|
+
|
|
80
|
+
Contains ``cd_offset``, ``cd_size``, and ``cd_entry_count``.
|
|
81
|
+
"""
|
|
82
|
+
if self._eocd is None:
|
|
83
|
+
raise RuntimeError(_NOT_LOADED)
|
|
84
|
+
return self._eocd
|
|
85
|
+
|
|
53
86
|
async def _ensure_loaded(self) -> None:
|
|
54
|
-
"""Fetch and parse the central directory
|
|
87
|
+
"""Fetch and parse the central directory."""
|
|
55
88
|
if self._entries is not None:
|
|
56
|
-
|
|
89
|
+
raise RuntimeError("_ensure_loaded() must not be called more than once")
|
|
57
90
|
|
|
58
91
|
# Step 1: HEAD to get total size, then fetch the tail for EOCD search.
|
|
59
92
|
# We use HEAD + absolute range instead of a single suffix-range
|
|
@@ -69,6 +102,7 @@ class AsyncRemoteZip:
|
|
|
69
102
|
|
|
70
103
|
# Step 2: Parse EOCD
|
|
71
104
|
eocd = find_eocd(tail, self._file_size)
|
|
105
|
+
self._eocd = eocd
|
|
72
106
|
|
|
73
107
|
# Step 3: Fetch and parse central directory
|
|
74
108
|
cd_data, _ = await self._reader.read_range(eocd.cd_offset, eocd.cd_size)
|
|
@@ -80,14 +114,14 @@ class AsyncRemoteZip:
|
|
|
80
114
|
|
|
81
115
|
async def infolist(self) -> list[RemoteZipInfo]:
|
|
82
116
|
"""Return a list of RemoteZipInfo objects for all files in the archive."""
|
|
83
|
-
|
|
84
|
-
|
|
117
|
+
if self._entries is None:
|
|
118
|
+
raise RuntimeError(_NOT_LOADED)
|
|
85
119
|
return list(self._entries)
|
|
86
120
|
|
|
87
121
|
async def namelist(self) -> list[str]:
|
|
88
122
|
"""Return a list of filenames in the archive."""
|
|
89
|
-
|
|
90
|
-
|
|
123
|
+
if self._entries is None:
|
|
124
|
+
raise RuntimeError(_NOT_LOADED)
|
|
91
125
|
return [info.filename for info in self._entries]
|
|
92
126
|
|
|
93
127
|
async def getinfo(self, name: str) -> RemoteZipInfo:
|
|
@@ -96,8 +130,8 @@ class AsyncRemoteZip:
|
|
|
96
130
|
Raises:
|
|
97
131
|
FileNotFoundInZip: If the name is not in the archive.
|
|
98
132
|
"""
|
|
99
|
-
|
|
100
|
-
|
|
133
|
+
if self._name_index is None:
|
|
134
|
+
raise RuntimeError(_NOT_LOADED)
|
|
101
135
|
try:
|
|
102
136
|
return self._name_index[name]
|
|
103
137
|
except KeyError:
|
|
@@ -64,11 +64,11 @@ def decompress(
|
|
|
64
64
|
result = lzma.decompress(data)
|
|
65
65
|
case CompressionMethod.ZSTANDARD:
|
|
66
66
|
try:
|
|
67
|
-
import
|
|
67
|
+
from compression.zstd import decompress as _zstd_decompress
|
|
68
68
|
except ImportError: # pragma: no cover
|
|
69
69
|
raise UnsupportedCompression(method) from None
|
|
70
70
|
|
|
71
|
-
result =
|
|
71
|
+
result = _zstd_decompress(data)
|
|
72
72
|
case _:
|
|
73
73
|
raise UnsupportedCompression(method)
|
|
74
74
|
|
|
@@ -128,12 +128,12 @@ class StreamingDecompressor:
|
|
|
128
128
|
self._dobj = lzma.LZMADecompressor()
|
|
129
129
|
case CompressionMethod.ZSTANDARD:
|
|
130
130
|
try:
|
|
131
|
-
|
|
131
|
+
from compression.zstd import ZstdDecompressor
|
|
132
132
|
except ImportError: # pragma: no cover
|
|
133
133
|
raise UnsupportedCompression(method) from None
|
|
134
134
|
|
|
135
135
|
self._mode = _DecompressMode.INCREMENTAL
|
|
136
|
-
self._dobj =
|
|
136
|
+
self._dobj = ZstdDecompressor()
|
|
137
137
|
case _:
|
|
138
138
|
raise UnsupportedCompression(method)
|
|
139
139
|
|
|
@@ -13,6 +13,7 @@ from zipwire._constants import (
|
|
|
13
13
|
from zipwire._decompress import StreamingDecompressor, decompress
|
|
14
14
|
from zipwire._errors import FileNotFoundInZip, FileTooLarge, RangeRequestUnsupported
|
|
15
15
|
from zipwire._parser import (
|
|
16
|
+
EOCDInfo,
|
|
16
17
|
find_eocd,
|
|
17
18
|
parse_central_directory,
|
|
18
19
|
parse_local_file_header,
|
|
@@ -22,10 +23,15 @@ from zipwire._zipinfo import RemoteZipInfo
|
|
|
22
23
|
if typing.TYPE_CHECKING:
|
|
23
24
|
from zipwire._types import SyncReader, Writable
|
|
24
25
|
|
|
26
|
+
_NOT_LOADED = "archive not loaded; use as a context manager"
|
|
27
|
+
|
|
25
28
|
|
|
26
29
|
class SyncRemoteZip:
|
|
27
30
|
"""Read files from a remote ZIP archive using synchronous HTTP range requests.
|
|
28
31
|
|
|
32
|
+
Must be used as a context manager. The archive is loaded on entry
|
|
33
|
+
and all internal state is cleared on exit. Not thread-safe.
|
|
34
|
+
|
|
29
35
|
Usage::
|
|
30
36
|
|
|
31
37
|
with SyncRemoteZip(reader) as rz:
|
|
@@ -39,21 +45,49 @@ class SyncRemoteZip:
|
|
|
39
45
|
self._entries: list[RemoteZipInfo] | None = None
|
|
40
46
|
self._name_index: dict[str, RemoteZipInfo] | None = None
|
|
41
47
|
self._file_size: int | None = None
|
|
48
|
+
self._eocd: EOCDInfo | None = None
|
|
42
49
|
|
|
43
50
|
def __enter__(self) -> SyncRemoteZip:
|
|
51
|
+
self._ensure_loaded()
|
|
44
52
|
return self
|
|
45
53
|
|
|
46
54
|
def __exit__(self, *exc: object) -> None:
|
|
55
|
+
self._entries = None
|
|
56
|
+
self._name_index = None
|
|
57
|
+
self._file_size = None
|
|
58
|
+
self._eocd = None
|
|
47
59
|
self.close()
|
|
48
60
|
|
|
49
61
|
def close(self) -> None:
|
|
50
62
|
"""Close the underlying reader."""
|
|
51
63
|
self._reader.close()
|
|
52
64
|
|
|
65
|
+
@property
|
|
66
|
+
def url(self) -> str:
|
|
67
|
+
"""The target URL of the underlying reader."""
|
|
68
|
+
return self._reader.url
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def file_size(self) -> int:
|
|
72
|
+
"""Total size of the remote archive in bytes."""
|
|
73
|
+
if self._file_size is None:
|
|
74
|
+
raise RuntimeError(_NOT_LOADED)
|
|
75
|
+
return self._file_size
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def eocd_info(self) -> EOCDInfo:
|
|
79
|
+
"""Parsed End of Central Directory record.
|
|
80
|
+
|
|
81
|
+
Contains ``cd_offset``, ``cd_size``, and ``cd_entry_count``.
|
|
82
|
+
"""
|
|
83
|
+
if self._eocd is None:
|
|
84
|
+
raise RuntimeError(_NOT_LOADED)
|
|
85
|
+
return self._eocd
|
|
86
|
+
|
|
53
87
|
def _ensure_loaded(self) -> None:
|
|
54
|
-
"""Fetch and parse the central directory
|
|
88
|
+
"""Fetch and parse the central directory."""
|
|
55
89
|
if self._entries is not None:
|
|
56
|
-
|
|
90
|
+
raise RuntimeError("_ensure_loaded() must not be called more than once")
|
|
57
91
|
|
|
58
92
|
# Step 1: HEAD to get total size, then fetch the tail for EOCD search.
|
|
59
93
|
# We use HEAD + absolute range instead of a single suffix-range
|
|
@@ -69,6 +103,7 @@ class SyncRemoteZip:
|
|
|
69
103
|
|
|
70
104
|
# Step 2: Parse EOCD
|
|
71
105
|
eocd = find_eocd(tail, self._file_size)
|
|
106
|
+
self._eocd = eocd
|
|
72
107
|
|
|
73
108
|
# Step 3: Fetch and parse central directory
|
|
74
109
|
cd_data, _ = self._reader.read_range(eocd.cd_offset, eocd.cd_size)
|
|
@@ -80,14 +115,14 @@ class SyncRemoteZip:
|
|
|
80
115
|
|
|
81
116
|
def infolist(self) -> list[RemoteZipInfo]:
|
|
82
117
|
"""Return a list of RemoteZipInfo objects for all files in the archive."""
|
|
83
|
-
self.
|
|
84
|
-
|
|
118
|
+
if self._entries is None:
|
|
119
|
+
raise RuntimeError(_NOT_LOADED)
|
|
85
120
|
return list(self._entries)
|
|
86
121
|
|
|
87
122
|
def namelist(self) -> list[str]:
|
|
88
123
|
"""Return a list of filenames in the archive."""
|
|
89
|
-
self.
|
|
90
|
-
|
|
124
|
+
if self._entries is None:
|
|
125
|
+
raise RuntimeError(_NOT_LOADED)
|
|
91
126
|
return [info.filename for info in self._entries]
|
|
92
127
|
|
|
93
128
|
def getinfo(self, name: str) -> RemoteZipInfo:
|
|
@@ -96,8 +131,8 @@ class SyncRemoteZip:
|
|
|
96
131
|
Raises:
|
|
97
132
|
FileNotFoundInZip: If the name is not in the archive.
|
|
98
133
|
"""
|
|
99
|
-
self.
|
|
100
|
-
|
|
134
|
+
if self._name_index is None:
|
|
135
|
+
raise RuntimeError(_NOT_LOADED)
|
|
101
136
|
try:
|
|
102
137
|
return self._name_index[name]
|
|
103
138
|
except KeyError:
|
|
@@ -32,6 +32,11 @@ class Headers(typing.Protocol):
|
|
|
32
32
|
class SyncReader(typing.Protocol):
|
|
33
33
|
"""Protocol for synchronous HTTP range-request readers."""
|
|
34
34
|
|
|
35
|
+
@property
|
|
36
|
+
def url(self) -> str:
|
|
37
|
+
"""The target URL for this reader."""
|
|
38
|
+
...
|
|
39
|
+
|
|
35
40
|
def read_range(
|
|
36
41
|
self,
|
|
37
42
|
offset: int,
|
|
@@ -67,6 +72,11 @@ class SyncReader(typing.Protocol):
|
|
|
67
72
|
class AsyncReader(typing.Protocol):
|
|
68
73
|
"""Protocol for asynchronous HTTP range-request readers."""
|
|
69
74
|
|
|
75
|
+
@property
|
|
76
|
+
def url(self) -> str:
|
|
77
|
+
"""The target URL for this reader."""
|
|
78
|
+
...
|
|
79
|
+
|
|
70
80
|
async def read_range(
|
|
71
81
|
self,
|
|
72
82
|
offset: int,
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '0.
|
|
22
|
-
__version_tuple__ = version_tuple = (0,
|
|
21
|
+
__version__ = version = '0.2.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 2, 0)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -7,7 +7,7 @@ import typing
|
|
|
7
7
|
|
|
8
8
|
try:
|
|
9
9
|
import aiohttp
|
|
10
|
-
except ImportError as exc:
|
|
10
|
+
except ImportError as exc:
|
|
11
11
|
raise ImportError(
|
|
12
12
|
"AiohttpReader requires aiohttp. Install it with: pip install zipwire[aiohttp]"
|
|
13
13
|
) from exc
|
|
@@ -38,6 +38,11 @@ class AiohttpReader:
|
|
|
38
38
|
self._session = session or aiohttp.ClientSession()
|
|
39
39
|
self._allow_redirects = allow_redirects
|
|
40
40
|
|
|
41
|
+
@property
|
|
42
|
+
def url(self) -> str:
|
|
43
|
+
"""The target URL for this reader."""
|
|
44
|
+
return self._url
|
|
45
|
+
|
|
41
46
|
async def head(self) -> Headers:
|
|
42
47
|
logger.debug("HEAD %s", self._url)
|
|
43
48
|
async with self._session.head(self._url, allow_redirects=self._allow_redirects) as resp:
|
|
@@ -7,7 +7,7 @@ import typing
|
|
|
7
7
|
|
|
8
8
|
try:
|
|
9
9
|
import httpx2
|
|
10
|
-
except ImportError as exc:
|
|
10
|
+
except ImportError as exc:
|
|
11
11
|
raise ImportError(
|
|
12
12
|
"httpx2 readers require httpx2. Install it with: pip install zipwire[httpx2]"
|
|
13
13
|
) from exc
|
|
@@ -46,10 +46,15 @@ class Httpx2SyncReader:
|
|
|
46
46
|
if client is not None:
|
|
47
47
|
self._client = client
|
|
48
48
|
else:
|
|
49
|
-
if http2 is None:
|
|
49
|
+
if http2 is None: # pragma: no cover
|
|
50
50
|
http2 = _h2_available
|
|
51
51
|
self._client = httpx2.Client(http2=http2, follow_redirects=allow_redirects)
|
|
52
52
|
|
|
53
|
+
@property
|
|
54
|
+
def url(self) -> str:
|
|
55
|
+
"""The target URL for this reader."""
|
|
56
|
+
return self._url
|
|
57
|
+
|
|
53
58
|
def head(self) -> Headers:
|
|
54
59
|
logger.debug("HEAD %s", self._url)
|
|
55
60
|
resp = self._client.head(self._url)
|
|
@@ -109,10 +114,15 @@ class Httpx2AsyncReader:
|
|
|
109
114
|
if client is not None:
|
|
110
115
|
self._client = client
|
|
111
116
|
else:
|
|
112
|
-
if http2 is None:
|
|
117
|
+
if http2 is None: # pragma: no cover
|
|
113
118
|
http2 = _h2_available
|
|
114
119
|
self._client = httpx2.AsyncClient(http2=http2, follow_redirects=allow_redirects)
|
|
115
120
|
|
|
121
|
+
@property
|
|
122
|
+
def url(self) -> str:
|
|
123
|
+
"""The target URL for this reader."""
|
|
124
|
+
return self._url
|
|
125
|
+
|
|
116
126
|
async def head(self) -> Headers:
|
|
117
127
|
logger.debug("HEAD %s", self._url)
|
|
118
128
|
resp = await self._client.head(self._url)
|
|
@@ -7,7 +7,7 @@ import typing
|
|
|
7
7
|
|
|
8
8
|
try:
|
|
9
9
|
import requests
|
|
10
|
-
except ImportError as exc:
|
|
10
|
+
except ImportError as exc:
|
|
11
11
|
raise ImportError(
|
|
12
12
|
"RequestsReader requires requests. Install it with: pip install zipwire[requests]"
|
|
13
13
|
) from exc
|
|
@@ -38,6 +38,11 @@ class RequestsReader:
|
|
|
38
38
|
self._session = session or requests.Session()
|
|
39
39
|
self._allow_redirects = allow_redirects
|
|
40
40
|
|
|
41
|
+
@property
|
|
42
|
+
def url(self) -> str:
|
|
43
|
+
"""The target URL for this reader."""
|
|
44
|
+
return self._url
|
|
45
|
+
|
|
41
46
|
def head(self) -> Headers:
|
|
42
47
|
logger.debug("HEAD %s", self._url)
|
|
43
48
|
resp = self._session.head(self._url, allow_redirects=self._allow_redirects)
|
|
@@ -33,6 +33,11 @@ class Urllib3Reader:
|
|
|
33
33
|
self._pool = pool or urllib3.PoolManager()
|
|
34
34
|
self._allow_redirects = allow_redirects
|
|
35
35
|
|
|
36
|
+
@property
|
|
37
|
+
def url(self) -> str:
|
|
38
|
+
"""The target URL for this reader."""
|
|
39
|
+
return self._url
|
|
40
|
+
|
|
36
41
|
def head(self) -> Headers:
|
|
37
42
|
logger.debug("HEAD %s", self._url)
|
|
38
43
|
resp = self._pool.request("HEAD", self._url, redirect=self._allow_redirects)
|