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/_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__