zipwire 0.0.1__py3-none-any.whl
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/__init__.py +33 -0
- zipwire/__main__.py +74 -0
- zipwire/_async.py +173 -0
- zipwire/_constants.py +126 -0
- zipwire/_decompress.py +146 -0
- zipwire/_errors.py +40 -0
- zipwire/_parser.py +273 -0
- zipwire/_sync.py +163 -0
- zipwire/_types.py +57 -0
- zipwire/_version.py +24 -0
- zipwire/_zipinfo.py +93 -0
- zipwire/backends/__init__.py +42 -0
- zipwire/backends/_aiohttp.py +55 -0
- zipwire/backends/_httpx2_async.py +55 -0
- zipwire/backends/_httpx2_sync.py +54 -0
- zipwire/backends/_requests.py +54 -0
- zipwire/backends/_urllib3.py +59 -0
- zipwire/py.typed +0 -0
- zipwire-0.0.1.dist-info/METADATA +134 -0
- zipwire-0.0.1.dist-info/RECORD +22 -0
- zipwire-0.0.1.dist-info/WHEEL +4 -0
- zipwire-0.0.1.dist-info/licenses/LICENSE +177 -0
zipwire/_parser.py
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""Pure ZIP parsing: EOCD, central directory, and local file header.
|
|
2
|
+
|
|
3
|
+
All functions operate on `bytes` - no IO. This module is shared by both
|
|
4
|
+
the sync and async code paths.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
from zipwire._constants import (
|
|
12
|
+
CENTRAL_DIR_SIGNATURE,
|
|
13
|
+
CENTRAL_DIR_SIZE,
|
|
14
|
+
CENTRAL_DIR_STRUCT,
|
|
15
|
+
EOCD_SIGNATURE,
|
|
16
|
+
EOCD_SIZE,
|
|
17
|
+
EOCD_STRUCT,
|
|
18
|
+
LOCAL_FILE_HEADER_SIGNATURE,
|
|
19
|
+
LOCAL_FILE_HEADER_SIZE,
|
|
20
|
+
LOCAL_FILE_HEADER_STRUCT,
|
|
21
|
+
MAX_EOCD_SEARCH,
|
|
22
|
+
ZIP64_EOCD_LOCATOR_SIGNATURE,
|
|
23
|
+
ZIP64_EOCD_LOCATOR_SIZE,
|
|
24
|
+
ZIP64_EOCD_LOCATOR_STRUCT,
|
|
25
|
+
ZIP64_EOCD_SIGNATURE,
|
|
26
|
+
ZIP64_EOCD_SIZE,
|
|
27
|
+
ZIP64_EOCD_STRUCT,
|
|
28
|
+
)
|
|
29
|
+
from zipwire._errors import BadZipFile
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class EOCDInfo:
|
|
34
|
+
"""Parsed End of Central Directory information."""
|
|
35
|
+
|
|
36
|
+
cd_offset: int
|
|
37
|
+
cd_size: int
|
|
38
|
+
cd_entry_count: int
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class CentralDirEntry:
|
|
43
|
+
"""Raw parsed central directory entry."""
|
|
44
|
+
|
|
45
|
+
version_made_by: int
|
|
46
|
+
version_needed: int
|
|
47
|
+
flags: int
|
|
48
|
+
compression_method: int
|
|
49
|
+
mod_time: int
|
|
50
|
+
mod_date: int
|
|
51
|
+
crc32: int
|
|
52
|
+
compressed_size: int
|
|
53
|
+
uncompressed_size: int
|
|
54
|
+
disk_start: int
|
|
55
|
+
internal_attr: int
|
|
56
|
+
external_attr: int
|
|
57
|
+
header_offset: int
|
|
58
|
+
filename: bytes
|
|
59
|
+
extra: bytes
|
|
60
|
+
comment: bytes
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True, slots=True)
|
|
64
|
+
class LocalHeaderInfo:
|
|
65
|
+
"""Parsed local file header - just enough to find the data offset."""
|
|
66
|
+
|
|
67
|
+
filename_length: int
|
|
68
|
+
extra_length: int
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def data_offset_past_header(self) -> int:
|
|
72
|
+
"""Number of bytes after the fixed header to skip to reach file data."""
|
|
73
|
+
return self.filename_length + self.extra_length
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def find_eocd(tail: bytes, file_size: int) -> EOCDInfo:
|
|
77
|
+
"""Find and parse the End of Central Directory record.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
tail: The last `min(MAX_EOCD_SEARCH, file_size)` bytes of the file.
|
|
81
|
+
file_size: Total size of the ZIP file.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
EOCDInfo with central directory location.
|
|
85
|
+
|
|
86
|
+
Raises:
|
|
87
|
+
BadZipFile: If no valid EOCD record is found.
|
|
88
|
+
"""
|
|
89
|
+
# Search backwards for EOCD signature
|
|
90
|
+
pos = tail.rfind(EOCD_SIGNATURE)
|
|
91
|
+
if pos == -1:
|
|
92
|
+
raise BadZipFile("Could not find End of Central Directory record")
|
|
93
|
+
|
|
94
|
+
if len(tail) - pos < EOCD_SIZE:
|
|
95
|
+
raise BadZipFile("EOCD record is truncated")
|
|
96
|
+
|
|
97
|
+
fields = EOCD_STRUCT.unpack_from(tail, pos)
|
|
98
|
+
# fields: signature, disk_num, disk_cd_start, cd_entries_this_disk,
|
|
99
|
+
# cd_entries_total, cd_size, cd_offset, comment_length
|
|
100
|
+
cd_entry_count = fields[4]
|
|
101
|
+
cd_size = fields[5]
|
|
102
|
+
cd_offset = fields[6]
|
|
103
|
+
|
|
104
|
+
# Check for ZIP64 - indicated by 0xFFFF or 0xFFFFFFFF sentinel values
|
|
105
|
+
is_zip64 = cd_entry_count == 0xFFFF or cd_size == 0xFFFFFFFF or cd_offset == 0xFFFFFFFF
|
|
106
|
+
|
|
107
|
+
if is_zip64:
|
|
108
|
+
return _parse_zip64_eocd(tail, pos, file_size)
|
|
109
|
+
|
|
110
|
+
return EOCDInfo(
|
|
111
|
+
cd_offset=cd_offset,
|
|
112
|
+
cd_size=cd_size,
|
|
113
|
+
cd_entry_count=cd_entry_count,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _parse_zip64_eocd(tail: bytes, eocd_pos: int, file_size: int) -> EOCDInfo:
|
|
118
|
+
"""Parse ZIP64 EOCD locator and record.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
tail: The tail bytes of the file.
|
|
122
|
+
eocd_pos: Position of the regular EOCD in `tail`.
|
|
123
|
+
file_size: Total file size.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
EOCDInfo with 64-bit values.
|
|
127
|
+
|
|
128
|
+
Raises:
|
|
129
|
+
BadZipFile: If ZIP64 structures are missing or invalid.
|
|
130
|
+
"""
|
|
131
|
+
# ZIP64 EOCD Locator is right before the regular EOCD
|
|
132
|
+
locator_pos = eocd_pos - ZIP64_EOCD_LOCATOR_SIZE
|
|
133
|
+
if locator_pos < 0:
|
|
134
|
+
raise BadZipFile("ZIP64 EOCD locator not found (not enough data before EOCD)")
|
|
135
|
+
|
|
136
|
+
locator_sig = tail[locator_pos : locator_pos + 4]
|
|
137
|
+
if locator_sig != ZIP64_EOCD_LOCATOR_SIGNATURE:
|
|
138
|
+
raise BadZipFile("ZIP64 EOCD locator signature mismatch")
|
|
139
|
+
|
|
140
|
+
locator_fields = ZIP64_EOCD_LOCATOR_STRUCT.unpack_from(tail, locator_pos)
|
|
141
|
+
zip64_eocd_abs_offset = locator_fields[2]
|
|
142
|
+
|
|
143
|
+
# The ZIP64 EOCD record offset is absolute in the file. Convert to tail-relative.
|
|
144
|
+
tail_start = file_size - len(tail)
|
|
145
|
+
zip64_eocd_rel = zip64_eocd_abs_offset - tail_start
|
|
146
|
+
|
|
147
|
+
if zip64_eocd_rel < 0 or zip64_eocd_rel + ZIP64_EOCD_SIZE > len(tail):
|
|
148
|
+
raise BadZipFile("ZIP64 EOCD record is outside the fetched tail data")
|
|
149
|
+
|
|
150
|
+
z64_sig = tail[zip64_eocd_rel : zip64_eocd_rel + 4]
|
|
151
|
+
if z64_sig != ZIP64_EOCD_SIGNATURE:
|
|
152
|
+
raise BadZipFile("ZIP64 EOCD record signature mismatch")
|
|
153
|
+
|
|
154
|
+
z64_fields = ZIP64_EOCD_STRUCT.unpack_from(tail, zip64_eocd_rel)
|
|
155
|
+
# fields: signature, size_of_record, version_made, version_needed,
|
|
156
|
+
# disk_num, disk_cd_start, cd_entries_this_disk,
|
|
157
|
+
# cd_entries_total, cd_size, cd_offset
|
|
158
|
+
cd_entry_count = z64_fields[7]
|
|
159
|
+
cd_size = z64_fields[8]
|
|
160
|
+
cd_offset = z64_fields[9]
|
|
161
|
+
|
|
162
|
+
return EOCDInfo(
|
|
163
|
+
cd_offset=cd_offset,
|
|
164
|
+
cd_size=cd_size,
|
|
165
|
+
cd_entry_count=cd_entry_count,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def parse_central_directory(data: bytes, entry_count: int) -> list[CentralDirEntry]:
|
|
170
|
+
"""Parse all central directory entries from raw bytes.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
data: The raw central directory data.
|
|
174
|
+
entry_count: Expected number of entries.
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
List of CentralDirEntry objects.
|
|
178
|
+
|
|
179
|
+
Raises:
|
|
180
|
+
BadZipFile: If the data is malformed.
|
|
181
|
+
"""
|
|
182
|
+
entries: list[CentralDirEntry] = []
|
|
183
|
+
offset = 0
|
|
184
|
+
|
|
185
|
+
for _ in range(entry_count):
|
|
186
|
+
if offset + CENTRAL_DIR_SIZE > len(data):
|
|
187
|
+
raise BadZipFile("Central directory is truncated")
|
|
188
|
+
|
|
189
|
+
if data[offset : offset + 4] != CENTRAL_DIR_SIGNATURE:
|
|
190
|
+
raise BadZipFile(f"Expected central directory signature at offset {offset}")
|
|
191
|
+
|
|
192
|
+
fields = CENTRAL_DIR_STRUCT.unpack_from(data, offset)
|
|
193
|
+
# fields: signature(0), version_made(1), version_needed(2), flags(3),
|
|
194
|
+
# compression(4), mod_time(5), mod_date(6), crc32(7),
|
|
195
|
+
# compressed_size(8), uncompressed_size(9), filename_length(10),
|
|
196
|
+
# extra_length(11), comment_length(12), disk_start(13),
|
|
197
|
+
# internal_attr(14), external_attr(15), header_offset(16)
|
|
198
|
+
|
|
199
|
+
filename_length = fields[10]
|
|
200
|
+
extra_length = fields[11]
|
|
201
|
+
comment_length = fields[12]
|
|
202
|
+
|
|
203
|
+
var_start = offset + CENTRAL_DIR_SIZE
|
|
204
|
+
var_end = var_start + filename_length + extra_length + comment_length
|
|
205
|
+
|
|
206
|
+
if var_end > len(data):
|
|
207
|
+
raise BadZipFile("Central directory entry extends past end of data")
|
|
208
|
+
|
|
209
|
+
filename = data[var_start : var_start + filename_length]
|
|
210
|
+
extra = data[var_start + filename_length : var_start + filename_length + extra_length]
|
|
211
|
+
comment = data[
|
|
212
|
+
var_start + filename_length + extra_length : var_start
|
|
213
|
+
+ filename_length
|
|
214
|
+
+ extra_length
|
|
215
|
+
+ comment_length
|
|
216
|
+
]
|
|
217
|
+
|
|
218
|
+
entry = CentralDirEntry(
|
|
219
|
+
version_made_by=fields[1],
|
|
220
|
+
version_needed=fields[2],
|
|
221
|
+
flags=fields[3],
|
|
222
|
+
compression_method=fields[4],
|
|
223
|
+
mod_time=fields[5],
|
|
224
|
+
mod_date=fields[6],
|
|
225
|
+
crc32=fields[7],
|
|
226
|
+
compressed_size=fields[8],
|
|
227
|
+
uncompressed_size=fields[9],
|
|
228
|
+
disk_start=fields[13],
|
|
229
|
+
internal_attr=fields[14],
|
|
230
|
+
external_attr=fields[15],
|
|
231
|
+
header_offset=fields[16],
|
|
232
|
+
filename=filename,
|
|
233
|
+
extra=extra,
|
|
234
|
+
comment=comment,
|
|
235
|
+
)
|
|
236
|
+
entries.append(entry)
|
|
237
|
+
offset = var_end
|
|
238
|
+
|
|
239
|
+
return entries
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def parse_local_file_header(data: bytes) -> LocalHeaderInfo:
|
|
243
|
+
"""Parse a local file header to determine the data offset.
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
data: At least LOCAL_FILE_HEADER_SIZE bytes of the local header.
|
|
247
|
+
|
|
248
|
+
Returns:
|
|
249
|
+
LocalHeaderInfo with filename and extra field lengths.
|
|
250
|
+
|
|
251
|
+
Raises:
|
|
252
|
+
BadZipFile: If the header is invalid.
|
|
253
|
+
"""
|
|
254
|
+
if len(data) < LOCAL_FILE_HEADER_SIZE:
|
|
255
|
+
raise BadZipFile("Local file header is truncated")
|
|
256
|
+
|
|
257
|
+
fields = LOCAL_FILE_HEADER_STRUCT.unpack_from(data, 0)
|
|
258
|
+
# fields: signature(0), version_needed(1), flags(2), compression(3),
|
|
259
|
+
# mod_time(4), mod_date(5), crc32(6), compressed_size(7),
|
|
260
|
+
# uncompressed_size(8), filename_length(9), extra_length(10)
|
|
261
|
+
|
|
262
|
+
if fields[0] != LOCAL_FILE_HEADER_SIGNATURE:
|
|
263
|
+
raise BadZipFile("Invalid local file header signature")
|
|
264
|
+
|
|
265
|
+
return LocalHeaderInfo(
|
|
266
|
+
filename_length=fields[9],
|
|
267
|
+
extra_length=fields[10],
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def eocd_search_length(file_size: int) -> int:
|
|
272
|
+
"""How many bytes to fetch from the end of the file for EOCD search."""
|
|
273
|
+
return min(MAX_EOCD_SEARCH, file_size)
|
zipwire/_sync.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Synchronous remote ZIP reader."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typing
|
|
6
|
+
|
|
7
|
+
from zipwire._constants import LOCAL_FILE_HEADER_SIZE
|
|
8
|
+
from zipwire._decompress import StreamingDecompressor, decompress
|
|
9
|
+
from zipwire._errors import FileNotFoundInZip
|
|
10
|
+
from zipwire._parser import (
|
|
11
|
+
eocd_search_length,
|
|
12
|
+
find_eocd,
|
|
13
|
+
parse_central_directory,
|
|
14
|
+
parse_local_file_header,
|
|
15
|
+
)
|
|
16
|
+
from zipwire._zipinfo import RemoteZipInfo
|
|
17
|
+
|
|
18
|
+
if typing.TYPE_CHECKING:
|
|
19
|
+
from zipwire._types import SyncReader, Writable
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SyncRemoteZip:
|
|
23
|
+
"""Read files from a remote ZIP archive using synchronous HTTP range requests.
|
|
24
|
+
|
|
25
|
+
Usage::
|
|
26
|
+
|
|
27
|
+
with SyncRemoteZip(reader) as rz:
|
|
28
|
+
for info in rz.infolist():
|
|
29
|
+
print(info.filename)
|
|
30
|
+
data = rz.read("path/to/file.txt")
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, reader: SyncReader) -> None:
|
|
34
|
+
self._reader = reader
|
|
35
|
+
self._entries: list[RemoteZipInfo] | None = None
|
|
36
|
+
self._name_index: dict[str, RemoteZipInfo] | None = None
|
|
37
|
+
self._file_size: int | None = None
|
|
38
|
+
|
|
39
|
+
def __enter__(self) -> SyncRemoteZip:
|
|
40
|
+
return self
|
|
41
|
+
|
|
42
|
+
def __exit__(self, *exc: object) -> None:
|
|
43
|
+
self.close()
|
|
44
|
+
|
|
45
|
+
def close(self) -> None:
|
|
46
|
+
"""Close the underlying reader."""
|
|
47
|
+
self._reader.close()
|
|
48
|
+
|
|
49
|
+
def _ensure_loaded(self) -> None:
|
|
50
|
+
"""Fetch and parse the central directory if not already done."""
|
|
51
|
+
if self._entries is not None:
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
# Step 1: Get file size
|
|
55
|
+
self._file_size = self._reader.get_content_length()
|
|
56
|
+
|
|
57
|
+
# Step 2: Fetch the tail for EOCD search
|
|
58
|
+
search_len = eocd_search_length(self._file_size)
|
|
59
|
+
tail_offset = self._file_size - search_len
|
|
60
|
+
tail = self._reader.read_range(tail_offset, search_len)
|
|
61
|
+
|
|
62
|
+
# Step 3: Parse EOCD
|
|
63
|
+
eocd = find_eocd(tail, self._file_size)
|
|
64
|
+
|
|
65
|
+
# Step 4: Fetch and parse central directory
|
|
66
|
+
cd_data = self._reader.read_range(eocd.cd_offset, eocd.cd_size)
|
|
67
|
+
raw_entries = parse_central_directory(cd_data, eocd.cd_entry_count)
|
|
68
|
+
|
|
69
|
+
# Step 5: Convert to RemoteZipInfo objects
|
|
70
|
+
self._entries = [RemoteZipInfo._from_central_dir_entry(e) for e in raw_entries]
|
|
71
|
+
self._name_index = {info.filename: info for info in self._entries}
|
|
72
|
+
|
|
73
|
+
def infolist(self) -> list[RemoteZipInfo]:
|
|
74
|
+
"""Return a list of RemoteZipInfo objects for all files in the archive."""
|
|
75
|
+
self._ensure_loaded()
|
|
76
|
+
assert self._entries is not None
|
|
77
|
+
return list(self._entries)
|
|
78
|
+
|
|
79
|
+
def namelist(self) -> list[str]:
|
|
80
|
+
"""Return a list of filenames in the archive."""
|
|
81
|
+
self._ensure_loaded()
|
|
82
|
+
assert self._entries is not None
|
|
83
|
+
return [info.filename for info in self._entries]
|
|
84
|
+
|
|
85
|
+
def getinfo(self, name: str) -> RemoteZipInfo:
|
|
86
|
+
"""Return the RemoteZipInfo for the given filename.
|
|
87
|
+
|
|
88
|
+
Raises:
|
|
89
|
+
FileNotFoundInZip: If the name is not in the archive.
|
|
90
|
+
"""
|
|
91
|
+
self._ensure_loaded()
|
|
92
|
+
assert self._name_index is not None
|
|
93
|
+
try:
|
|
94
|
+
return self._name_index[name]
|
|
95
|
+
except KeyError:
|
|
96
|
+
raise FileNotFoundInZip(name) from None
|
|
97
|
+
|
|
98
|
+
def read(self, name: str | RemoteZipInfo) -> bytes:
|
|
99
|
+
"""Read and decompress a file from the archive.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
name: Filename string or RemoteZipInfo object.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
The decompressed file contents.
|
|
106
|
+
"""
|
|
107
|
+
info = name if isinstance(name, RemoteZipInfo) else self.getinfo(name)
|
|
108
|
+
|
|
109
|
+
# Directories have no data
|
|
110
|
+
if info.is_dir():
|
|
111
|
+
return b""
|
|
112
|
+
|
|
113
|
+
# Step 1: Read the local file header to get the actual data offset
|
|
114
|
+
local_header_data = self._reader.read_range(info.header_offset, LOCAL_FILE_HEADER_SIZE)
|
|
115
|
+
local_header = parse_local_file_header(local_header_data)
|
|
116
|
+
|
|
117
|
+
# Step 2: Calculate the data offset
|
|
118
|
+
data_offset = (
|
|
119
|
+
info.header_offset + LOCAL_FILE_HEADER_SIZE + local_header.data_offset_past_header
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# Step 3: Fetch the compressed data
|
|
123
|
+
compressed_data = self._reader.read_range(data_offset, info.compress_size)
|
|
124
|
+
|
|
125
|
+
# Step 4: Decompress and verify CRC
|
|
126
|
+
return decompress(
|
|
127
|
+
compressed_data,
|
|
128
|
+
info.compress_type,
|
|
129
|
+
info.file_size,
|
|
130
|
+
info.CRC,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def read_into(self, name: str | RemoteZipInfo, dest: Writable) -> None:
|
|
134
|
+
"""Decompress a file from the archive into a writable destination.
|
|
135
|
+
|
|
136
|
+
Unlike :meth:`read`, this truly streams: the compressed payload is
|
|
137
|
+
fetched in chunks via :meth:`stream_range` and each chunk is
|
|
138
|
+
decompressed and written immediately, keeping peak memory low.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
name: Filename string or RemoteZipInfo object.
|
|
142
|
+
dest: A writable file-like object (e.g. ``io.BytesIO``, open file).
|
|
143
|
+
"""
|
|
144
|
+
info = name if isinstance(name, RemoteZipInfo) else self.getinfo(name)
|
|
145
|
+
|
|
146
|
+
# Directories have no data
|
|
147
|
+
if info.is_dir():
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
# Step 1: Read the local file header to get the actual data offset
|
|
151
|
+
local_header_data = self._reader.read_range(info.header_offset, LOCAL_FILE_HEADER_SIZE)
|
|
152
|
+
local_header = parse_local_file_header(local_header_data)
|
|
153
|
+
|
|
154
|
+
# Step 2: Calculate the data offset
|
|
155
|
+
data_offset = (
|
|
156
|
+
info.header_offset + LOCAL_FILE_HEADER_SIZE + local_header.data_offset_past_header
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
# Step 3: Stream compressed data and decompress into dest
|
|
160
|
+
sd = StreamingDecompressor(info.compress_type, info.CRC, dest)
|
|
161
|
+
for chunk in self._reader.stream_range(data_offset, info.compress_size):
|
|
162
|
+
sd.feed(chunk)
|
|
163
|
+
sd.finish()
|
zipwire/_types.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Reader protocols for sync and async IO."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typing
|
|
6
|
+
|
|
7
|
+
if typing.TYPE_CHECKING:
|
|
8
|
+
from collections.abc import AsyncIterator, Iterator
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@typing.runtime_checkable
|
|
12
|
+
class SyncReader(typing.Protocol):
|
|
13
|
+
"""Protocol for synchronous HTTP range-request readers."""
|
|
14
|
+
|
|
15
|
+
def read_range(self, offset: int, length: int) -> bytes:
|
|
16
|
+
"""Read *length* bytes starting at *offset*."""
|
|
17
|
+
...
|
|
18
|
+
|
|
19
|
+
def stream_range(self, offset: int, length: int) -> Iterator[bytes]:
|
|
20
|
+
"""Stream *length* bytes starting at *offset* as chunks."""
|
|
21
|
+
...
|
|
22
|
+
|
|
23
|
+
def get_content_length(self) -> int:
|
|
24
|
+
"""Return the total size of the remote resource in bytes."""
|
|
25
|
+
...
|
|
26
|
+
|
|
27
|
+
def close(self) -> None:
|
|
28
|
+
"""Release any held resources."""
|
|
29
|
+
...
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@typing.runtime_checkable
|
|
33
|
+
class AsyncReader(typing.Protocol):
|
|
34
|
+
"""Protocol for asynchronous HTTP range-request readers."""
|
|
35
|
+
|
|
36
|
+
async def read_range(self, offset: int, length: int) -> bytes:
|
|
37
|
+
"""Read *length* bytes starting at *offset*."""
|
|
38
|
+
...
|
|
39
|
+
|
|
40
|
+
def stream_range(self, offset: int, length: int) -> AsyncIterator[bytes]:
|
|
41
|
+
"""Stream *length* bytes starting at *offset* as chunks."""
|
|
42
|
+
...
|
|
43
|
+
|
|
44
|
+
async def get_content_length(self) -> int:
|
|
45
|
+
"""Return the total size of the remote resource in bytes."""
|
|
46
|
+
...
|
|
47
|
+
|
|
48
|
+
async def close(self) -> None:
|
|
49
|
+
"""Release any held resources."""
|
|
50
|
+
...
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@typing.runtime_checkable
|
|
54
|
+
class Writable(typing.Protocol):
|
|
55
|
+
"""Protocol for writable file-like objects (io.BytesIO, io.BufferedWriter, etc.)."""
|
|
56
|
+
|
|
57
|
+
def write(self, data: bytes, /) -> object: ...
|
zipwire/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.0.1'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 0, 1)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
zipwire/_zipinfo.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""ZipInfo subclass that is fully compatible with stdlib zipfile.ZipInfo."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import struct
|
|
6
|
+
import typing
|
|
7
|
+
import zipfile
|
|
8
|
+
|
|
9
|
+
from zipwire._constants import ZIP64_EXTRA_FIELD_ID
|
|
10
|
+
|
|
11
|
+
if typing.TYPE_CHECKING:
|
|
12
|
+
from zipwire._parser import CentralDirEntry
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RemoteZipInfo(zipfile.ZipInfo):
|
|
16
|
+
"""A ZipInfo subclass for entries from a remote ZIP archive.
|
|
17
|
+
|
|
18
|
+
``isinstance(info, zipfile.ZipInfo)`` returns True. All standard
|
|
19
|
+
attributes and methods (is_dir, FileHeader, etc.) are available.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def _from_central_dir_entry(cls, entry: CentralDirEntry) -> RemoteZipInfo:
|
|
24
|
+
"""Construct a ZipInfo from a parsed central directory entry."""
|
|
25
|
+
# Decode filename
|
|
26
|
+
# Bit 11 of flags indicates UTF-8 encoding
|
|
27
|
+
if entry.flags & 0x800:
|
|
28
|
+
filename = entry.filename.decode("utf-8")
|
|
29
|
+
else:
|
|
30
|
+
filename = entry.filename.decode("cp437")
|
|
31
|
+
|
|
32
|
+
info = cls(filename)
|
|
33
|
+
|
|
34
|
+
# Convert DOS date/time to tuple
|
|
35
|
+
info.date_time = (
|
|
36
|
+
((entry.mod_date >> 9) & 0x7F) + 1980, # year
|
|
37
|
+
(entry.mod_date >> 5) & 0x0F, # month
|
|
38
|
+
entry.mod_date & 0x1F, # day
|
|
39
|
+
(entry.mod_time >> 11) & 0x1F, # hour
|
|
40
|
+
(entry.mod_time >> 5) & 0x3F, # minute
|
|
41
|
+
(entry.mod_time & 0x1F) * 2, # second
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
info.compress_type = entry.compression_method
|
|
45
|
+
info.CRC = entry.crc32
|
|
46
|
+
info.compress_size = entry.compressed_size
|
|
47
|
+
info.file_size = entry.uncompressed_size
|
|
48
|
+
info.header_offset = entry.header_offset
|
|
49
|
+
info.internal_attr = entry.internal_attr
|
|
50
|
+
info.external_attr = entry.external_attr
|
|
51
|
+
info.create_system = (entry.version_made_by >> 8) & 0xFF
|
|
52
|
+
info.create_version = entry.version_made_by & 0xFF
|
|
53
|
+
info.extract_version = entry.version_needed
|
|
54
|
+
info.flag_bits = entry.flags
|
|
55
|
+
info.volume = entry.disk_start
|
|
56
|
+
info.extra = entry.extra
|
|
57
|
+
info.comment = entry.comment
|
|
58
|
+
|
|
59
|
+
# Handle ZIP64 extra field
|
|
60
|
+
_apply_zip64_extra(info)
|
|
61
|
+
|
|
62
|
+
return info
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _apply_zip64_extra(info: RemoteZipInfo) -> None:
|
|
66
|
+
"""Parse ZIP64 extra field and update sizes/offset if present."""
|
|
67
|
+
extra = info.extra
|
|
68
|
+
if not extra:
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
offset = 0
|
|
72
|
+
while offset + 4 <= len(extra):
|
|
73
|
+
header_id, data_size = struct.unpack_from("<HH", extra, offset)
|
|
74
|
+
offset += 4
|
|
75
|
+
|
|
76
|
+
if header_id == ZIP64_EXTRA_FIELD_ID:
|
|
77
|
+
# ZIP64 extended information extra field
|
|
78
|
+
# Fields appear in order only if the corresponding regular field is 0xFFFFFFFF
|
|
79
|
+
idx = 0
|
|
80
|
+
if info.file_size == 0xFFFFFFFF and idx + 8 <= data_size:
|
|
81
|
+
info.file_size = struct.unpack_from("<Q", extra, offset + idx)[0]
|
|
82
|
+
idx += 8
|
|
83
|
+
if info.compress_size == 0xFFFFFFFF and idx + 8 <= data_size:
|
|
84
|
+
info.compress_size = struct.unpack_from("<Q", extra, offset + idx)[0]
|
|
85
|
+
idx += 8
|
|
86
|
+
if info.header_offset == 0xFFFFFFFF and idx + 8 <= data_size:
|
|
87
|
+
info.header_offset = struct.unpack_from("<Q", extra, offset + idx)[0]
|
|
88
|
+
idx += 8
|
|
89
|
+
if info.volume == 0xFFFF and idx + 4 <= data_size:
|
|
90
|
+
info.volume = struct.unpack_from("<I", extra, offset + idx)[0]
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
offset += data_size
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Backend readers with lazy imports.
|
|
2
|
+
|
|
3
|
+
No backend library is imported until its reader class is accessed.
|
|
4
|
+
|
|
5
|
+
Usage::
|
|
6
|
+
|
|
7
|
+
from zipwire.backends import Httpx2SyncReader
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import importlib
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from zipwire.backends._aiohttp import AiohttpReader as AiohttpReader
|
|
17
|
+
from zipwire.backends._httpx2_async import Httpx2AsyncReader as Httpx2AsyncReader
|
|
18
|
+
from zipwire.backends._httpx2_sync import Httpx2SyncReader as Httpx2SyncReader
|
|
19
|
+
from zipwire.backends._requests import RequestsReader as RequestsReader
|
|
20
|
+
from zipwire.backends._urllib3 import Urllib3Reader as Urllib3Reader
|
|
21
|
+
|
|
22
|
+
_LAZY_IMPORTS: dict[str, tuple[str, str]] = {
|
|
23
|
+
"Httpx2SyncReader": ("zipwire.backends._httpx2_sync", "Httpx2SyncReader"),
|
|
24
|
+
"Httpx2AsyncReader": ("zipwire.backends._httpx2_async", "Httpx2AsyncReader"),
|
|
25
|
+
"AiohttpReader": ("zipwire.backends._aiohttp", "AiohttpReader"),
|
|
26
|
+
"Urllib3Reader": ("zipwire.backends._urllib3", "Urllib3Reader"),
|
|
27
|
+
"RequestsReader": ("zipwire.backends._requests", "RequestsReader"),
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
__all__ = list(_LAZY_IMPORTS)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def __getattr__(name: str) -> object:
|
|
34
|
+
if name in _LAZY_IMPORTS:
|
|
35
|
+
module_path, attr_name = _LAZY_IMPORTS[name]
|
|
36
|
+
module = importlib.import_module(module_path)
|
|
37
|
+
return getattr(module, attr_name)
|
|
38
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def __dir__() -> list[str]:
|
|
42
|
+
return __all__
|