zipwire 0.2.0__tar.gz → 0.3.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.2.0 → zipwire-0.3.0}/PKG-INFO +1 -1
- {zipwire-0.2.0 → zipwire-0.3.0}/docs/api.rst +15 -0
- {zipwire-0.2.0 → zipwire-0.3.0}/docs/quickstart.rst +42 -1
- {zipwire-0.2.0 → zipwire-0.3.0}/src/zipwire/__init__.py +25 -1
- {zipwire-0.2.0 → zipwire-0.3.0}/src/zipwire/__main__.py +1 -1
- {zipwire-0.2.0 → zipwire-0.3.0}/src/zipwire/_async.py +23 -74
- zipwire-0.3.0/src/zipwire/_base.py +97 -0
- {zipwire-0.2.0 → zipwire-0.3.0}/src/zipwire/_sync.py +21 -70
- {zipwire-0.2.0 → zipwire-0.3.0}/src/zipwire/_version.py +2 -2
- zipwire-0.3.0/src/zipwire/_wheel.py +222 -0
- {zipwire-0.2.0 → zipwire-0.3.0}/tests/test_async.py +25 -24
- {zipwire-0.2.0 → zipwire-0.3.0}/tests/test_integration.py +11 -13
- zipwire-0.3.0/tests/test_wheel.py +268 -0
- {zipwire-0.2.0 → zipwire-0.3.0}/.github/dependabot.yml +0 -0
- {zipwire-0.2.0 → zipwire-0.3.0}/.github/workflows/ci.yml +0 -0
- {zipwire-0.2.0 → zipwire-0.3.0}/.github/workflows/codeql.yml +0 -0
- {zipwire-0.2.0 → zipwire-0.3.0}/.github/workflows/release.yml +0 -0
- {zipwire-0.2.0 → zipwire-0.3.0}/.github/workflows/scorecard.yml +0 -0
- {zipwire-0.2.0 → zipwire-0.3.0}/.gitignore +0 -0
- {zipwire-0.2.0 → zipwire-0.3.0}/.readthedocs.yaml +0 -0
- {zipwire-0.2.0 → zipwire-0.3.0}/AGENTS.md +0 -0
- {zipwire-0.2.0 → zipwire-0.3.0}/CLAUDE.md +0 -0
- {zipwire-0.2.0 → zipwire-0.3.0}/LICENSE +0 -0
- {zipwire-0.2.0 → zipwire-0.3.0}/README.md +0 -0
- {zipwire-0.2.0 → zipwire-0.3.0}/SECURITY.md +0 -0
- {zipwire-0.2.0 → zipwire-0.3.0}/docs/Makefile +0 -0
- {zipwire-0.2.0 → zipwire-0.3.0}/docs/backends.rst +0 -0
- {zipwire-0.2.0 → zipwire-0.3.0}/docs/conf.py +0 -0
- {zipwire-0.2.0 → zipwire-0.3.0}/docs/index.rst +0 -0
- {zipwire-0.2.0 → zipwire-0.3.0}/docs/requirements.txt +0 -0
- {zipwire-0.2.0 → zipwire-0.3.0}/docs/security.rst +0 -0
- {zipwire-0.2.0 → zipwire-0.3.0}/pyproject.toml +0 -0
- {zipwire-0.2.0 → zipwire-0.3.0}/src/zipwire/_constants.py +0 -0
- {zipwire-0.2.0 → zipwire-0.3.0}/src/zipwire/_decompress.py +0 -0
- {zipwire-0.2.0 → zipwire-0.3.0}/src/zipwire/_errors.py +0 -0
- {zipwire-0.2.0 → zipwire-0.3.0}/src/zipwire/_parser.py +0 -0
- {zipwire-0.2.0 → zipwire-0.3.0}/src/zipwire/_types.py +0 -0
- {zipwire-0.2.0 → zipwire-0.3.0}/src/zipwire/_zipinfo.py +0 -0
- {zipwire-0.2.0 → zipwire-0.3.0}/src/zipwire/backends/__init__.py +0 -0
- {zipwire-0.2.0 → zipwire-0.3.0}/src/zipwire/backends/_aiohttp.py +0 -0
- {zipwire-0.2.0 → zipwire-0.3.0}/src/zipwire/backends/_httpx2.py +0 -0
- {zipwire-0.2.0 → zipwire-0.3.0}/src/zipwire/backends/_requests.py +0 -0
- {zipwire-0.2.0 → zipwire-0.3.0}/src/zipwire/backends/_urllib3.py +0 -0
- {zipwire-0.2.0 → zipwire-0.3.0}/src/zipwire/py.typed +0 -0
- {zipwire-0.2.0 → zipwire-0.3.0}/tests/__init__.py +0 -0
- {zipwire-0.2.0 → zipwire-0.3.0}/tests/conftest.py +0 -0
- {zipwire-0.2.0 → zipwire-0.3.0}/tests/test_backends.py +0 -0
- {zipwire-0.2.0 → zipwire-0.3.0}/tests/test_cli.py +0 -0
- {zipwire-0.2.0 → zipwire-0.3.0}/tests/test_decompress.py +0 -0
- {zipwire-0.2.0 → zipwire-0.3.0}/tests/test_parser.py +0 -0
- {zipwire-0.2.0 → zipwire-0.3.0}/tests/test_sync.py +0 -0
- {zipwire-0.2.0 → zipwire-0.3.0}/tests/test_zipinfo.py +0 -0
- {zipwire-0.2.0 → zipwire-0.3.0}/tox.ini +0 -0
- {zipwire-0.2.0 → zipwire-0.3.0}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: zipwire
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.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/
|
|
@@ -18,12 +18,27 @@ Core
|
|
|
18
18
|
|
|
19
19
|
.. autoclass:: zipwire.SyncRemoteZip
|
|
20
20
|
:members:
|
|
21
|
+
:inherited-members:
|
|
21
22
|
:special-members: __enter__, __exit__
|
|
22
23
|
|
|
23
24
|
.. autoclass:: zipwire.AsyncRemoteZip
|
|
24
25
|
:members:
|
|
26
|
+
:inherited-members:
|
|
25
27
|
:special-members: __aenter__, __aexit__
|
|
26
28
|
|
|
29
|
+
Wheel
|
|
30
|
+
-----
|
|
31
|
+
|
|
32
|
+
.. autoclass:: zipwire.SyncRemoteWheel
|
|
33
|
+
:members:
|
|
34
|
+
:inherited-members:
|
|
35
|
+
:show-inheritance:
|
|
36
|
+
|
|
37
|
+
.. autoclass:: zipwire.AsyncRemoteWheel
|
|
38
|
+
:members:
|
|
39
|
+
:inherited-members:
|
|
40
|
+
:show-inheritance:
|
|
41
|
+
|
|
27
42
|
.. autoclass:: zipwire.RemoteZipInfo
|
|
28
43
|
:show-inheritance:
|
|
29
44
|
|
|
@@ -83,7 +83,7 @@ Async with httpx2
|
|
|
83
83
|
async def main():
|
|
84
84
|
reader = Httpx2AsyncReader("https://archive.example/data.zip")
|
|
85
85
|
async with AsyncRemoteZip(reader) as rz:
|
|
86
|
-
names =
|
|
86
|
+
names = rz.namelist()
|
|
87
87
|
print(names)
|
|
88
88
|
|
|
89
89
|
|
|
@@ -109,6 +109,47 @@ Async streaming to disk
|
|
|
109
109
|
|
|
110
110
|
asyncio.run(main())
|
|
111
111
|
|
|
112
|
+
Wheel dist-info metadata
|
|
113
|
+
------------------------
|
|
114
|
+
|
|
115
|
+
``SyncRemoteWheel`` and ``AsyncRemoteWheel`` extend the base classes
|
|
116
|
+
with an adaptive tail fetch and a ``distinfolist()`` method. The tail
|
|
117
|
+
size scales with the archive (at least 128 KiB, up to ~0.4% of the
|
|
118
|
+
file) so that dist-info entries are typically served from memory
|
|
119
|
+
without extra HTTP requests.
|
|
120
|
+
|
|
121
|
+
.. code-block:: python
|
|
122
|
+
|
|
123
|
+
from zipwire import SyncRemoteWheel
|
|
124
|
+
from zipwire.backends import Urllib3Reader
|
|
125
|
+
|
|
126
|
+
url = "https://files.pythonhosted.org/.../requests-2.32.3-py3-none-any.whl"
|
|
127
|
+
reader = Urllib3Reader(url)
|
|
128
|
+
with SyncRemoteWheel(reader) as whl:
|
|
129
|
+
for entry in whl.distinfolist():
|
|
130
|
+
data = whl.read(entry)
|
|
131
|
+
print(entry.filename, len(data))
|
|
132
|
+
|
|
133
|
+
Async variant:
|
|
134
|
+
|
|
135
|
+
.. code-block:: python
|
|
136
|
+
|
|
137
|
+
import asyncio
|
|
138
|
+
|
|
139
|
+
from zipwire import AsyncRemoteWheel
|
|
140
|
+
from zipwire.backends import AiohttpReader
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
async def main():
|
|
144
|
+
reader = AiohttpReader(url)
|
|
145
|
+
async with AsyncRemoteWheel(reader) as whl:
|
|
146
|
+
for entry in whl.distinfolist():
|
|
147
|
+
data = await whl.read(entry)
|
|
148
|
+
print(entry.filename, len(data))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
asyncio.run(main())
|
|
152
|
+
|
|
112
153
|
Passing a pre-configured client
|
|
113
154
|
--------------------------------
|
|
114
155
|
|
|
@@ -34,7 +34,7 @@ Asynchronous example
|
|
|
34
34
|
async def main():
|
|
35
35
|
reader = AiohttpReader("https://example.com/archive.zip")
|
|
36
36
|
async with AsyncRemoteZip(reader) as rz:
|
|
37
|
-
print(
|
|
37
|
+
print(rz.namelist())
|
|
38
38
|
data = await rz.read("path/inside/archive.txt")
|
|
39
39
|
|
|
40
40
|
# Stream a large entry directly to disk
|
|
@@ -58,6 +58,27 @@ Asynchronous:
|
|
|
58
58
|
- ``AiohttpReader`` -- uses *aiohttp* (``pip install zipwire[aiohttp]``)
|
|
59
59
|
- ``Httpx2AsyncReader`` -- uses *httpx2*, supports HTTP/2
|
|
60
60
|
(``pip install zipwire[httpx2]``)
|
|
61
|
+
|
|
62
|
+
Wheel subclasses
|
|
63
|
+
----------------
|
|
64
|
+
|
|
65
|
+
``SyncRemoteWheel`` and ``AsyncRemoteWheel`` extend the base classes
|
|
66
|
+
for Python wheels. They parse the wheel URL to derive the
|
|
67
|
+
``.dist-info`` directory name, fetch an adaptive tail (at least
|
|
68
|
+
128 KiB, scaling with archive size) to cover dist-info entries at
|
|
69
|
+
the end of the archive, and serve matching reads from the tail
|
|
70
|
+
buffer without extra HTTP requests.
|
|
71
|
+
|
|
72
|
+
::
|
|
73
|
+
|
|
74
|
+
from zipwire import SyncRemoteWheel
|
|
75
|
+
from zipwire.backends import Urllib3Reader
|
|
76
|
+
|
|
77
|
+
url = "https://files.pythonhosted.org/.../requests-2.32.3-py3-none-any.whl"
|
|
78
|
+
reader = Urllib3Reader(url)
|
|
79
|
+
with SyncRemoteWheel(reader) as whl:
|
|
80
|
+
for entry in whl.distinfolist():
|
|
81
|
+
print(entry.filename, whl.read(entry))
|
|
61
82
|
"""
|
|
62
83
|
|
|
63
84
|
from __future__ import annotations
|
|
@@ -76,10 +97,12 @@ from zipwire._errors import (
|
|
|
76
97
|
from zipwire._parser import EOCDInfo
|
|
77
98
|
from zipwire._sync import SyncRemoteZip
|
|
78
99
|
from zipwire._types import AsyncReader, Headers, SyncReader, Writable
|
|
100
|
+
from zipwire._wheel import AsyncRemoteWheel, SyncRemoteWheel
|
|
79
101
|
from zipwire._zipinfo import RemoteZipInfo
|
|
80
102
|
|
|
81
103
|
__all__ = [
|
|
82
104
|
"AsyncReader",
|
|
105
|
+
"AsyncRemoteWheel",
|
|
83
106
|
"AsyncRemoteZip",
|
|
84
107
|
"BadZipFile",
|
|
85
108
|
"CRCMismatch",
|
|
@@ -91,6 +114,7 @@ __all__ = [
|
|
|
91
114
|
"RangeRequestUnsupported",
|
|
92
115
|
"RemoteZipInfo",
|
|
93
116
|
"SyncReader",
|
|
117
|
+
"SyncRemoteWheel",
|
|
94
118
|
"SyncRemoteZip",
|
|
95
119
|
"UnsupportedCompression",
|
|
96
120
|
"Writable",
|
|
@@ -41,7 +41,7 @@ async def _run_async(url: str, backend: str, skip_dirs: bool) -> None:
|
|
|
41
41
|
names = {"httpx2": "Httpx2AsyncReader", "aiohttp": "AiohttpReader"}
|
|
42
42
|
reader_cls = getattr(backends, names[backend])
|
|
43
43
|
async with AsyncRemoteZip(reader_cls(url)) as rz:
|
|
44
|
-
_print_table(
|
|
44
|
+
_print_table(rz.infolist(), skip_dirs)
|
|
45
45
|
|
|
46
46
|
|
|
47
47
|
def main(argv: list[str] | None = None) -> None:
|
|
@@ -4,29 +4,22 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import typing
|
|
6
6
|
|
|
7
|
+
from zipwire._base import RemoteZipBase
|
|
7
8
|
from zipwire._constants import (
|
|
8
9
|
LOCAL_FILE_HEADER_SIZE,
|
|
9
|
-
MAX_EOCD_SEARCH,
|
|
10
10
|
PREFETCH_EXTRA,
|
|
11
11
|
PREFETCH_THRESHOLD,
|
|
12
12
|
)
|
|
13
13
|
from zipwire._decompress import StreamingDecompressor, decompress
|
|
14
|
-
from zipwire._errors import
|
|
15
|
-
from zipwire._parser import
|
|
16
|
-
EOCDInfo,
|
|
17
|
-
find_eocd,
|
|
18
|
-
parse_central_directory,
|
|
19
|
-
parse_local_file_header,
|
|
20
|
-
)
|
|
14
|
+
from zipwire._errors import FileTooLarge, RangeRequestUnsupported
|
|
15
|
+
from zipwire._parser import find_eocd, parse_local_file_header
|
|
21
16
|
from zipwire._zipinfo import RemoteZipInfo
|
|
22
17
|
|
|
23
18
|
if typing.TYPE_CHECKING:
|
|
24
19
|
from zipwire._types import AsyncReader, Writable
|
|
25
20
|
|
|
26
|
-
_NOT_LOADED = "archive not loaded; use as an async context manager"
|
|
27
|
-
|
|
28
21
|
|
|
29
|
-
class AsyncRemoteZip:
|
|
22
|
+
class AsyncRemoteZip(RemoteZipBase):
|
|
30
23
|
"""Read files from a remote ZIP archive using async HTTP range requests.
|
|
31
24
|
|
|
32
25
|
Must be used as an async context manager. The archive is loaded on
|
|
@@ -35,27 +28,21 @@ class AsyncRemoteZip:
|
|
|
35
28
|
Usage::
|
|
36
29
|
|
|
37
30
|
async with AsyncRemoteZip(reader) as rz:
|
|
38
|
-
for info in
|
|
31
|
+
for info in rz.infolist():
|
|
39
32
|
print(info.filename)
|
|
40
33
|
data = await rz.read("path/to/file.txt")
|
|
41
34
|
"""
|
|
42
35
|
|
|
43
36
|
def __init__(self, reader: AsyncReader) -> None:
|
|
37
|
+
super().__init__()
|
|
44
38
|
self._reader = reader
|
|
45
|
-
self._entries: list[RemoteZipInfo] | None = None
|
|
46
|
-
self._name_index: dict[str, RemoteZipInfo] | None = None
|
|
47
|
-
self._file_size: int | None = None
|
|
48
|
-
self._eocd: EOCDInfo | None = None
|
|
49
39
|
|
|
50
40
|
async def __aenter__(self) -> AsyncRemoteZip:
|
|
51
41
|
await self._ensure_loaded()
|
|
52
42
|
return self
|
|
53
43
|
|
|
54
44
|
async def __aexit__(self, *exc: object) -> None:
|
|
55
|
-
self.
|
|
56
|
-
self._name_index = None
|
|
57
|
-
self._file_size = None
|
|
58
|
-
self._eocd = None
|
|
45
|
+
self._clear()
|
|
59
46
|
await self.close()
|
|
60
47
|
|
|
61
48
|
async def close(self) -> None:
|
|
@@ -67,24 +54,16 @@ class AsyncRemoteZip:
|
|
|
67
54
|
"""The target URL of the underlying reader."""
|
|
68
55
|
return self._reader.url
|
|
69
56
|
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
57
|
+
async def _ensure_loaded(self) -> tuple[int, bytes]:
|
|
58
|
+
"""Fetch and parse the central directory.
|
|
76
59
|
|
|
77
|
-
|
|
78
|
-
|
|
60
|
+
Returns:
|
|
61
|
+
``(tail_start, tail_data)`` -- the offset and bytes of the
|
|
62
|
+
initial tail fetch.
|
|
79
63
|
|
|
80
|
-
|
|
64
|
+
Raises:
|
|
65
|
+
RuntimeError: If called more than once.
|
|
81
66
|
"""
|
|
82
|
-
if self._eocd is None:
|
|
83
|
-
raise RuntimeError(_NOT_LOADED)
|
|
84
|
-
return self._eocd
|
|
85
|
-
|
|
86
|
-
async def _ensure_loaded(self) -> None:
|
|
87
|
-
"""Fetch and parse the central directory."""
|
|
88
67
|
if self._entries is not None:
|
|
89
68
|
raise RuntimeError("_ensure_loaded() must not be called more than once")
|
|
90
69
|
|
|
@@ -96,46 +75,19 @@ class AsyncRemoteZip:
|
|
|
96
75
|
cl = head_headers.get("content-length")
|
|
97
76
|
if cl is None:
|
|
98
77
|
raise RangeRequestUnsupported("Server did not return a Content-Length header")
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
78
|
+
file_size = int(cl)
|
|
79
|
+
fetch_size = min(file_size, self._tail_read_size(file_size))
|
|
80
|
+
tail_start = max(0, file_size - fetch_size)
|
|
81
|
+
tail, _ = await self._reader.read_range(tail_start, file_size - tail_start)
|
|
102
82
|
|
|
103
83
|
# Step 2: Parse EOCD
|
|
104
|
-
eocd = find_eocd(tail,
|
|
105
|
-
self._eocd = eocd
|
|
84
|
+
eocd = find_eocd(tail, file_size)
|
|
106
85
|
|
|
107
|
-
# Step 3: Fetch and
|
|
86
|
+
# Step 3: Fetch central directory and store parsed state
|
|
108
87
|
cd_data, _ = await self._reader.read_range(eocd.cd_offset, eocd.cd_size)
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
# Step 4: Convert to RemoteZipInfo objects
|
|
112
|
-
self._entries = [RemoteZipInfo._from_central_dir_entry(e) for e in raw_entries]
|
|
113
|
-
self._name_index = {info.filename: info for info in self._entries}
|
|
88
|
+
self._set_entries(cd_data, file_size, eocd)
|
|
114
89
|
|
|
115
|
-
|
|
116
|
-
"""Return a list of RemoteZipInfo objects for all files in the archive."""
|
|
117
|
-
if self._entries is None:
|
|
118
|
-
raise RuntimeError(_NOT_LOADED)
|
|
119
|
-
return list(self._entries)
|
|
120
|
-
|
|
121
|
-
async def namelist(self) -> list[str]:
|
|
122
|
-
"""Return a list of filenames in the archive."""
|
|
123
|
-
if self._entries is None:
|
|
124
|
-
raise RuntimeError(_NOT_LOADED)
|
|
125
|
-
return [info.filename for info in self._entries]
|
|
126
|
-
|
|
127
|
-
async def getinfo(self, name: str) -> RemoteZipInfo:
|
|
128
|
-
"""Return the RemoteZipInfo for the given filename.
|
|
129
|
-
|
|
130
|
-
Raises:
|
|
131
|
-
FileNotFoundInZip: If the name is not in the archive.
|
|
132
|
-
"""
|
|
133
|
-
if self._name_index is None:
|
|
134
|
-
raise RuntimeError(_NOT_LOADED)
|
|
135
|
-
try:
|
|
136
|
-
return self._name_index[name]
|
|
137
|
-
except KeyError:
|
|
138
|
-
raise FileNotFoundInZip(name) from None
|
|
90
|
+
return tail_start, tail
|
|
139
91
|
|
|
140
92
|
async def _resolve_entry(
|
|
141
93
|
self,
|
|
@@ -149,10 +101,7 @@ class AsyncRemoteZip:
|
|
|
149
101
|
compressed data are fetched in a single request and *prefetched*
|
|
150
102
|
contains the compressed bytes. Otherwise *prefetched* is ``None``.
|
|
151
103
|
"""
|
|
152
|
-
if isinstance(name, RemoteZipInfo)
|
|
153
|
-
info = name
|
|
154
|
-
else:
|
|
155
|
-
info = await self.getinfo(name)
|
|
104
|
+
info = name if isinstance(name, RemoteZipInfo) else self.getinfo(name)
|
|
156
105
|
if info.is_dir():
|
|
157
106
|
return None
|
|
158
107
|
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Common base class for sync and async remote ZIP readers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from zipwire._constants import MAX_EOCD_SEARCH
|
|
6
|
+
from zipwire._errors import FileNotFoundInZip
|
|
7
|
+
from zipwire._parser import EOCDInfo, parse_central_directory
|
|
8
|
+
from zipwire._zipinfo import RemoteZipInfo
|
|
9
|
+
|
|
10
|
+
_NOT_LOADED = "archive not loaded; use as a context manager"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RemoteZipBase:
|
|
14
|
+
"""Shared state and sync accessors for remote ZIP readers.
|
|
15
|
+
|
|
16
|
+
This class holds the parsed central directory entries, the name
|
|
17
|
+
index, file size, and EOCD record. It is not meant to be
|
|
18
|
+
instantiated directly.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self) -> None:
|
|
22
|
+
self._entries: list[RemoteZipInfo] | None = None
|
|
23
|
+
self._name_index: dict[str, RemoteZipInfo] | None = None
|
|
24
|
+
self._file_size: int | None = None
|
|
25
|
+
self._eocd: EOCDInfo | None = None
|
|
26
|
+
|
|
27
|
+
def _clear(self) -> None:
|
|
28
|
+
"""Reset all loaded state."""
|
|
29
|
+
self._entries = None
|
|
30
|
+
self._name_index = None
|
|
31
|
+
self._file_size = None
|
|
32
|
+
self._eocd = None
|
|
33
|
+
|
|
34
|
+
def _set_entries(
|
|
35
|
+
self,
|
|
36
|
+
cd_data: bytes,
|
|
37
|
+
file_size: int,
|
|
38
|
+
eocd: EOCDInfo,
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Parse central directory data and store entries."""
|
|
41
|
+
raw_entries = parse_central_directory(cd_data, eocd.cd_entry_count)
|
|
42
|
+
entries = [RemoteZipInfo._from_central_dir_entry(e) for e in raw_entries]
|
|
43
|
+
self._entries = entries
|
|
44
|
+
self._name_index = {info.filename: info for info in entries}
|
|
45
|
+
self._file_size = file_size
|
|
46
|
+
self._eocd = eocd
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def file_size(self) -> int:
|
|
50
|
+
"""Total size of the remote archive in bytes."""
|
|
51
|
+
if self._file_size is None:
|
|
52
|
+
raise RuntimeError(_NOT_LOADED)
|
|
53
|
+
return self._file_size
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def eocd_info(self) -> EOCDInfo:
|
|
57
|
+
"""Parsed End of Central Directory record.
|
|
58
|
+
|
|
59
|
+
Contains ``cd_offset``, ``cd_size``, and ``cd_entry_count``.
|
|
60
|
+
"""
|
|
61
|
+
if self._eocd is None:
|
|
62
|
+
raise RuntimeError(_NOT_LOADED)
|
|
63
|
+
return self._eocd
|
|
64
|
+
|
|
65
|
+
def _tail_read_size(self, file_size: int) -> int:
|
|
66
|
+
"""Return the number of bytes to fetch from the end of the file.
|
|
67
|
+
|
|
68
|
+
Called during loading to determine the tail size.
|
|
69
|
+
The default returns :data:`MAX_EOCD_SEARCH`. Subclasses may
|
|
70
|
+
override this to request a larger tail.
|
|
71
|
+
"""
|
|
72
|
+
return MAX_EOCD_SEARCH
|
|
73
|
+
|
|
74
|
+
def infolist(self) -> list[RemoteZipInfo]:
|
|
75
|
+
"""Return a list of RemoteZipInfo objects for all files in the archive."""
|
|
76
|
+
if self._entries is None:
|
|
77
|
+
raise RuntimeError(_NOT_LOADED)
|
|
78
|
+
return list(self._entries)
|
|
79
|
+
|
|
80
|
+
def namelist(self) -> list[str]:
|
|
81
|
+
"""Return a list of filenames in the archive."""
|
|
82
|
+
if self._entries is None:
|
|
83
|
+
raise RuntimeError(_NOT_LOADED)
|
|
84
|
+
return [info.filename for info in self._entries]
|
|
85
|
+
|
|
86
|
+
def getinfo(self, name: str) -> RemoteZipInfo:
|
|
87
|
+
"""Return the RemoteZipInfo for the given filename.
|
|
88
|
+
|
|
89
|
+
Raises:
|
|
90
|
+
FileNotFoundInZip: If the name is not in the archive.
|
|
91
|
+
"""
|
|
92
|
+
if self._name_index is None:
|
|
93
|
+
raise RuntimeError(_NOT_LOADED)
|
|
94
|
+
try:
|
|
95
|
+
return self._name_index[name]
|
|
96
|
+
except KeyError:
|
|
97
|
+
raise FileNotFoundInZip(name) from None
|
|
@@ -4,29 +4,22 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import typing
|
|
6
6
|
|
|
7
|
+
from zipwire._base import RemoteZipBase
|
|
7
8
|
from zipwire._constants import (
|
|
8
9
|
LOCAL_FILE_HEADER_SIZE,
|
|
9
|
-
MAX_EOCD_SEARCH,
|
|
10
10
|
PREFETCH_EXTRA,
|
|
11
11
|
PREFETCH_THRESHOLD,
|
|
12
12
|
)
|
|
13
13
|
from zipwire._decompress import StreamingDecompressor, decompress
|
|
14
|
-
from zipwire._errors import
|
|
15
|
-
from zipwire._parser import
|
|
16
|
-
EOCDInfo,
|
|
17
|
-
find_eocd,
|
|
18
|
-
parse_central_directory,
|
|
19
|
-
parse_local_file_header,
|
|
20
|
-
)
|
|
14
|
+
from zipwire._errors import FileTooLarge, RangeRequestUnsupported
|
|
15
|
+
from zipwire._parser import find_eocd, parse_local_file_header
|
|
21
16
|
from zipwire._zipinfo import RemoteZipInfo
|
|
22
17
|
|
|
23
18
|
if typing.TYPE_CHECKING:
|
|
24
19
|
from zipwire._types import SyncReader, Writable
|
|
25
20
|
|
|
26
|
-
_NOT_LOADED = "archive not loaded; use as a context manager"
|
|
27
|
-
|
|
28
21
|
|
|
29
|
-
class SyncRemoteZip:
|
|
22
|
+
class SyncRemoteZip(RemoteZipBase):
|
|
30
23
|
"""Read files from a remote ZIP archive using synchronous HTTP range requests.
|
|
31
24
|
|
|
32
25
|
Must be used as a context manager. The archive is loaded on entry
|
|
@@ -41,21 +34,15 @@ class SyncRemoteZip:
|
|
|
41
34
|
"""
|
|
42
35
|
|
|
43
36
|
def __init__(self, reader: SyncReader) -> None:
|
|
37
|
+
super().__init__()
|
|
44
38
|
self._reader = reader
|
|
45
|
-
self._entries: list[RemoteZipInfo] | None = None
|
|
46
|
-
self._name_index: dict[str, RemoteZipInfo] | None = None
|
|
47
|
-
self._file_size: int | None = None
|
|
48
|
-
self._eocd: EOCDInfo | None = None
|
|
49
39
|
|
|
50
40
|
def __enter__(self) -> SyncRemoteZip:
|
|
51
41
|
self._ensure_loaded()
|
|
52
42
|
return self
|
|
53
43
|
|
|
54
44
|
def __exit__(self, *exc: object) -> None:
|
|
55
|
-
self.
|
|
56
|
-
self._name_index = None
|
|
57
|
-
self._file_size = None
|
|
58
|
-
self._eocd = None
|
|
45
|
+
self._clear()
|
|
59
46
|
self.close()
|
|
60
47
|
|
|
61
48
|
def close(self) -> None:
|
|
@@ -67,25 +54,16 @@ class SyncRemoteZip:
|
|
|
67
54
|
"""The target URL of the underlying reader."""
|
|
68
55
|
return self._reader.url
|
|
69
56
|
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
57
|
+
def _ensure_loaded(self) -> tuple[int, bytes]:
|
|
58
|
+
"""Fetch and parse the central directory.
|
|
76
59
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
60
|
+
Returns:
|
|
61
|
+
``(tail_start, tail_data)`` -- the offset and bytes of the
|
|
62
|
+
initial tail fetch.
|
|
80
63
|
|
|
81
|
-
|
|
64
|
+
Raises:
|
|
65
|
+
RuntimeError: If called more than once.
|
|
82
66
|
"""
|
|
83
|
-
if self._eocd is None:
|
|
84
|
-
raise RuntimeError(_NOT_LOADED)
|
|
85
|
-
return self._eocd
|
|
86
|
-
|
|
87
|
-
def _ensure_loaded(self) -> None:
|
|
88
|
-
"""Fetch and parse the central directory."""
|
|
89
67
|
if self._entries is not None:
|
|
90
68
|
raise RuntimeError("_ensure_loaded() must not be called more than once")
|
|
91
69
|
|
|
@@ -97,46 +75,19 @@ class SyncRemoteZip:
|
|
|
97
75
|
cl = head_headers.get("content-length")
|
|
98
76
|
if cl is None:
|
|
99
77
|
raise RangeRequestUnsupported("Server did not return a Content-Length header")
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
78
|
+
file_size = int(cl)
|
|
79
|
+
fetch_size = min(file_size, self._tail_read_size(file_size))
|
|
80
|
+
tail_start = max(0, file_size - fetch_size)
|
|
81
|
+
tail, _ = self._reader.read_range(tail_start, file_size - tail_start)
|
|
103
82
|
|
|
104
83
|
# Step 2: Parse EOCD
|
|
105
|
-
eocd = find_eocd(tail,
|
|
106
|
-
self._eocd = eocd
|
|
84
|
+
eocd = find_eocd(tail, file_size)
|
|
107
85
|
|
|
108
|
-
# Step 3: Fetch and
|
|
86
|
+
# Step 3: Fetch central directory and store parsed state
|
|
109
87
|
cd_data, _ = self._reader.read_range(eocd.cd_offset, eocd.cd_size)
|
|
110
|
-
|
|
88
|
+
self._set_entries(cd_data, file_size, eocd)
|
|
111
89
|
|
|
112
|
-
|
|
113
|
-
self._entries = [RemoteZipInfo._from_central_dir_entry(e) for e in raw_entries]
|
|
114
|
-
self._name_index = {info.filename: info for info in self._entries}
|
|
115
|
-
|
|
116
|
-
def infolist(self) -> list[RemoteZipInfo]:
|
|
117
|
-
"""Return a list of RemoteZipInfo objects for all files in the archive."""
|
|
118
|
-
if self._entries is None:
|
|
119
|
-
raise RuntimeError(_NOT_LOADED)
|
|
120
|
-
return list(self._entries)
|
|
121
|
-
|
|
122
|
-
def namelist(self) -> list[str]:
|
|
123
|
-
"""Return a list of filenames in the archive."""
|
|
124
|
-
if self._entries is None:
|
|
125
|
-
raise RuntimeError(_NOT_LOADED)
|
|
126
|
-
return [info.filename for info in self._entries]
|
|
127
|
-
|
|
128
|
-
def getinfo(self, name: str) -> RemoteZipInfo:
|
|
129
|
-
"""Return the RemoteZipInfo for the given filename.
|
|
130
|
-
|
|
131
|
-
Raises:
|
|
132
|
-
FileNotFoundInZip: If the name is not in the archive.
|
|
133
|
-
"""
|
|
134
|
-
if self._name_index is None:
|
|
135
|
-
raise RuntimeError(_NOT_LOADED)
|
|
136
|
-
try:
|
|
137
|
-
return self._name_index[name]
|
|
138
|
-
except KeyError:
|
|
139
|
-
raise FileNotFoundInZip(name) from None
|
|
90
|
+
return tail_start, tail
|
|
140
91
|
|
|
141
92
|
def _resolve_entry(
|
|
142
93
|
self,
|
|
@@ -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.3.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 3, 0)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|