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 ADDED
@@ -0,0 +1,33 @@
1
+ """zipwire - Read and extract files from remote ZIP archives over HTTP range requests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from zipwire._async import AsyncRemoteZip
6
+ from zipwire._constants import CompressionMethod
7
+ from zipwire._errors import (
8
+ BadZipFile,
9
+ CRCMismatch,
10
+ FileNotFoundInZip,
11
+ RangeRequestUnsupported,
12
+ UnsupportedCompression,
13
+ ZipwireError,
14
+ )
15
+ from zipwire._sync import SyncRemoteZip
16
+ from zipwire._types import AsyncReader, SyncReader, Writable
17
+ from zipwire._zipinfo import RemoteZipInfo
18
+
19
+ __all__ = [
20
+ "AsyncReader",
21
+ "AsyncRemoteZip",
22
+ "BadZipFile",
23
+ "CRCMismatch",
24
+ "CompressionMethod",
25
+ "FileNotFoundInZip",
26
+ "RangeRequestUnsupported",
27
+ "RemoteZipInfo",
28
+ "SyncReader",
29
+ "SyncRemoteZip",
30
+ "UnsupportedCompression",
31
+ "Writable",
32
+ "ZipwireError",
33
+ ]
zipwire/__main__.py ADDED
@@ -0,0 +1,74 @@
1
+ """CLI entry-point: ``python -m zipwire <url>``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import sys
8
+
9
+ from zipwire import AsyncRemoteZip, SyncRemoteZip, ZipwireError
10
+
11
+
12
+ def _print_table(infos: list) -> None:
13
+ print(f"{'Size':>10} {'Date':>10} {'Time':>8} Name")
14
+ print(f"{'----':>10} {'----':>10} {'----':>8} ----")
15
+ total_size = 0
16
+ for info in infos:
17
+ y, mo, d, h, mi, s = info.date_time
18
+ date_str = f"{y:04d}-{mo:02d}-{d:02d}"
19
+ time_str = f"{h:02d}:{mi:02d}:{s:02d}"
20
+ print(f"{info.file_size:>10} {date_str:>10} {time_str:>8} {info.filename}")
21
+ total_size += info.file_size
22
+ print(f"{'----':>10} {' ':>10} {' ':>8} ----")
23
+ print(f"{total_size:>10} {' ':>10} {' ':>8} {len(infos)} entries")
24
+
25
+
26
+ def _run_sync(url: str, backend: str) -> None:
27
+ from zipwire import backends
28
+
29
+ reader_cls = {
30
+ "urllib3": backends.Urllib3Reader,
31
+ "requests": backends.RequestsReader,
32
+ }[backend]
33
+ with SyncRemoteZip(reader_cls(url)) as rz:
34
+ _print_table(rz.infolist())
35
+
36
+
37
+ async def _run_async(url: str, backend: str) -> None:
38
+ from zipwire import backends
39
+
40
+ reader_cls = {
41
+ "httpx2": backends.Httpx2AsyncReader,
42
+ "aiohttp": backends.AiohttpReader,
43
+ }[backend]
44
+ async with AsyncRemoteZip(reader_cls(url)) as rz:
45
+ _print_table(await rz.infolist())
46
+
47
+
48
+ def main(argv: list[str] | None = None) -> None:
49
+ parser = argparse.ArgumentParser(
50
+ prog="python -m zipwire",
51
+ description="List files in a remote ZIP archive.",
52
+ )
53
+ parser.add_argument("url", help="URL of the remote ZIP archive")
54
+ parser.add_argument(
55
+ "-b",
56
+ "--backend",
57
+ choices=["urllib3", "requests", "httpx2", "aiohttp"],
58
+ default="urllib3",
59
+ help="HTTP backend to use (default: urllib3)",
60
+ )
61
+ args = parser.parse_args(argv)
62
+
63
+ try:
64
+ if args.backend in ("urllib3", "requests"):
65
+ _run_sync(args.url, args.backend)
66
+ else:
67
+ asyncio.run(_run_async(args.url, args.backend))
68
+ except ZipwireError as exc:
69
+ print(f"error: {exc}", file=sys.stderr)
70
+ sys.exit(1)
71
+
72
+
73
+ if __name__ == "__main__":
74
+ main()
zipwire/_async.py ADDED
@@ -0,0 +1,173 @@
1
+ """Asynchronous 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 AsyncReader, Writable
20
+
21
+
22
+ class AsyncRemoteZip:
23
+ """Read files from a remote ZIP archive using async HTTP range requests.
24
+
25
+ Usage::
26
+
27
+ async with AsyncRemoteZip(reader) as rz:
28
+ for info in await rz.infolist():
29
+ print(info.filename)
30
+ data = await rz.read("path/to/file.txt")
31
+ """
32
+
33
+ def __init__(self, reader: AsyncReader) -> 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
+ async def __aenter__(self) -> AsyncRemoteZip:
40
+ return self
41
+
42
+ async def __aexit__(self, *exc: object) -> None:
43
+ await self.close()
44
+
45
+ async def close(self) -> None:
46
+ """Close the underlying reader."""
47
+ await self._reader.close()
48
+
49
+ async 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 = await 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 = await 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 = await 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
+ async def infolist(self) -> list[RemoteZipInfo]:
74
+ """Return a list of RemoteZipInfo objects for all files in the archive."""
75
+ await self._ensure_loaded()
76
+ assert self._entries is not None
77
+ return list(self._entries)
78
+
79
+ async def namelist(self) -> list[str]:
80
+ """Return a list of filenames in the archive."""
81
+ await self._ensure_loaded()
82
+ assert self._entries is not None
83
+ return [info.filename for info in self._entries]
84
+
85
+ async 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
+ await 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
+ async 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
+ if isinstance(name, RemoteZipInfo):
108
+ info = name
109
+ else:
110
+ info = await self.getinfo(name)
111
+
112
+ # Directories have no data
113
+ if info.is_dir():
114
+ return b""
115
+
116
+ # Step 1: Read the local file header to get the actual data offset
117
+ local_header_data = await self._reader.read_range(
118
+ info.header_offset, LOCAL_FILE_HEADER_SIZE
119
+ )
120
+ local_header = parse_local_file_header(local_header_data)
121
+
122
+ # Step 2: Calculate the data offset
123
+ data_offset = (
124
+ info.header_offset + LOCAL_FILE_HEADER_SIZE + local_header.data_offset_past_header
125
+ )
126
+
127
+ # Step 3: Fetch the compressed data
128
+ compressed_data = await self._reader.read_range(data_offset, info.compress_size)
129
+
130
+ # Step 4: Decompress and verify CRC
131
+ return decompress(
132
+ compressed_data,
133
+ info.compress_type,
134
+ info.file_size,
135
+ info.CRC,
136
+ )
137
+
138
+ async def read_into(self, name: str | RemoteZipInfo, dest: Writable) -> None:
139
+ """Decompress a file from the archive into a writable destination.
140
+
141
+ Unlike :meth:`read`, this truly streams: the compressed payload is
142
+ fetched in chunks via :meth:`stream_range` and each chunk is
143
+ decompressed and written immediately, keeping peak memory low.
144
+
145
+ Args:
146
+ name: Filename string or RemoteZipInfo object.
147
+ dest: A writable file-like object (e.g. ``io.BytesIO``, open file).
148
+ """
149
+ if isinstance(name, RemoteZipInfo):
150
+ info = name
151
+ else:
152
+ info = await self.getinfo(name)
153
+
154
+ # Directories have no data
155
+ if info.is_dir():
156
+ return
157
+
158
+ # Step 1: Read the local file header to get the actual data offset
159
+ local_header_data = await self._reader.read_range(
160
+ info.header_offset, LOCAL_FILE_HEADER_SIZE
161
+ )
162
+ local_header = parse_local_file_header(local_header_data)
163
+
164
+ # Step 2: Calculate the data offset
165
+ data_offset = (
166
+ info.header_offset + LOCAL_FILE_HEADER_SIZE + local_header.data_offset_past_header
167
+ )
168
+
169
+ # Step 3: Stream compressed data and decompress into dest
170
+ sd = StreamingDecompressor(info.compress_type, info.CRC, dest)
171
+ async for chunk in self._reader.stream_range(data_offset, info.compress_size):
172
+ sd.feed(chunk)
173
+ sd.finish()
zipwire/_constants.py ADDED
@@ -0,0 +1,126 @@
1
+ """ZIP format constants: signatures, struct formats, and compression methods."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import struct
6
+ from enum import IntEnum
7
+
8
+ # ---------------------------------------------------------------------------
9
+ # Signatures
10
+ # ---------------------------------------------------------------------------
11
+ EOCD_SIGNATURE = b"PK\x05\x06" # zipfile.stringEndArchive
12
+ ZIP64_EOCD_LOCATOR_SIGNATURE = b"PK\x06\x07" # zipfile.stringEndArchive64Locator
13
+ ZIP64_EOCD_SIGNATURE = b"PK\x06\x06" # zipfile.stringEndArchive64
14
+ CENTRAL_DIR_SIGNATURE = b"PK\x01\x02" # zipfile.stringCentralDir
15
+ LOCAL_FILE_HEADER_SIGNATURE = b"PK\x03\x04" # zipfile.stringFileHeader
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # Fixed-part sizes
19
+ # ---------------------------------------------------------------------------
20
+ EOCD_SIZE = 22 # zipfile.sizeEndCentDir
21
+ ZIP64_EOCD_LOCATOR_SIZE = 20 # zipfile.sizeEndCentDir64Locator
22
+ ZIP64_EOCD_SIZE = 56 # zipfile.sizeEndCentDir64
23
+ CENTRAL_DIR_SIZE = 46 # zipfile.sizeCentralDir
24
+ LOCAL_FILE_HEADER_SIZE = 30 # zipfile.sizeFileHeader
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Compression methods
28
+ # ---------------------------------------------------------------------------
29
+
30
+
31
+ class CompressionMethod(IntEnum):
32
+ """ZIP compression method identifiers."""
33
+
34
+ STORED = 0 # zipfile.ZIP_STORED
35
+ DEFLATED = 8 # zipfile.ZIP_DEFLATED
36
+ BZIP2 = 12 # zipfile.ZIP_BZIP2
37
+ LZMA = 14 # zipfile.ZIP_LZMA
38
+ ZSTANDARD = 93 # zipfile.ZIP_ZSTANDARD (3.14+)
39
+
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Struct formats
43
+ #
44
+ # The stdlib defines struct formats too (e.g. zipfile.structCentralDir), but
45
+ # they use different field groupings that shift unpack indices. We define
46
+ # our own for cleaner parser code.
47
+ # ---------------------------------------------------------------------------
48
+
49
+ # End of Central Directory Record (22 bytes)
50
+ EOCD_STRUCT = struct.Struct(
51
+ "<4s" # [0] signature
52
+ "H" # [1] disk_num
53
+ "H" # [2] disk_cd_start
54
+ "H" # [3] entries_this_disk
55
+ "H" # [4] entries_total
56
+ "I" # [5] cd_size
57
+ "I" # [6] cd_offset
58
+ "H" # [7] comment_length
59
+ )
60
+
61
+ # ZIP64 End of Central Directory Locator (20 bytes)
62
+ ZIP64_EOCD_LOCATOR_STRUCT = struct.Struct(
63
+ "<4s" # [0] signature
64
+ "I" # [1] disk_with_zip64_eocd
65
+ "Q" # [2] zip64_eocd_offset
66
+ "I" # [3] total_disks
67
+ )
68
+
69
+ # ZIP64 End of Central Directory Record (56 bytes)
70
+ ZIP64_EOCD_STRUCT = struct.Struct(
71
+ "<4s" # [0] signature
72
+ "Q" # [1] record_size
73
+ "H" # [2] version_made
74
+ "H" # [3] version_needed
75
+ "I" # [4] disk_num
76
+ "I" # [5] disk_cd_start
77
+ "Q" # [6] entries_this_disk
78
+ "Q" # [7] entries_total
79
+ "Q" # [8] cd_size
80
+ "Q" # [9] cd_offset
81
+ )
82
+
83
+ # Central Directory File Header (46 bytes)
84
+ CENTRAL_DIR_STRUCT = struct.Struct(
85
+ "<4s" # [0] signature
86
+ "H" # [1] version_made
87
+ "H" # [2] version_needed
88
+ "H" # [3] flags
89
+ "H" # [4] compression
90
+ "H" # [5] mod_time
91
+ "H" # [6] mod_date
92
+ "I" # [7] crc32
93
+ "I" # [8] compressed_size
94
+ "I" # [9] uncompressed_size
95
+ "H" # [10] filename_len
96
+ "H" # [11] extra_len
97
+ "H" # [12] comment_len
98
+ "H" # [13] disk_start
99
+ "H" # [14] internal_attr
100
+ "I" # [15] external_attr
101
+ "I" # [16] header_offset
102
+ )
103
+
104
+ # Local File Header (30 bytes)
105
+ LOCAL_FILE_HEADER_STRUCT = struct.Struct(
106
+ "<4s" # [0] signature
107
+ "H" # [1] version_needed
108
+ "H" # [2] flags
109
+ "H" # [3] compression
110
+ "H" # [4] mod_time
111
+ "H" # [5] mod_date
112
+ "I" # [6] crc32
113
+ "I" # [7] compressed_size
114
+ "I" # [8] uncompressed_size
115
+ "H" # [9] filename_len
116
+ "H" # [10] extra_len
117
+ )
118
+
119
+ # ---------------------------------------------------------------------------
120
+ # Derived / misc
121
+ # ---------------------------------------------------------------------------
122
+ MAX_EOCD_SEARCH = EOCD_SIZE + 65535 # max ZIP comment is 65 535 bytes
123
+
124
+ ZIP64_EXTRA_FIELD_ID = 0x0001
125
+
126
+ STREAM_CHUNK_SIZE = 2 * 1024 * 1024 # 2 MiB - default for stream_range backends
zipwire/_decompress.py ADDED
@@ -0,0 +1,146 @@
1
+ """Decompression and CRC verification for ZIP entries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typing
6
+ import zlib
7
+ from enum import Enum
8
+
9
+ from zipwire._constants import CompressionMethod
10
+ from zipwire._errors import CRCMismatch, UnsupportedCompression
11
+
12
+ if typing.TYPE_CHECKING:
13
+ from zipwire._types import Writable
14
+
15
+
16
+ class _DecompressMode(Enum):
17
+ """Internal mode tags for StreamingDecompressor."""
18
+
19
+ STORED = "stored"
20
+ DEFLATED = "deflated"
21
+ INCREMENTAL = "incremental"
22
+
23
+
24
+ def decompress(
25
+ data: bytes,
26
+ method: int,
27
+ expected_size: int,
28
+ expected_crc: int,
29
+ ) -> bytes:
30
+ """Decompress data and verify CRC-32.
31
+
32
+ Args:
33
+ data: The raw (possibly compressed) file data.
34
+ method: ZIP compression method (0=stored, 8=deflated, 12=bzip2, 14=lzma, 93=zstandard).
35
+ expected_size: Expected uncompressed size.
36
+ expected_crc: Expected CRC-32 of the uncompressed data.
37
+
38
+ Returns:
39
+ The decompressed bytes.
40
+
41
+ Raises:
42
+ UnsupportedCompression: If the compression method is not supported.
43
+ CRCMismatch: If the CRC-32 check fails.
44
+ """
45
+ match method:
46
+ case CompressionMethod.STORED:
47
+ result = data
48
+ case CompressionMethod.DEFLATED:
49
+ # -15 = raw deflate (no zlib/gzip header)
50
+ result = zlib.decompress(data, -15)
51
+ case CompressionMethod.BZIP2:
52
+ try:
53
+ import bz2
54
+ except ImportError:
55
+ raise UnsupportedCompression(method) from None
56
+
57
+ result = bz2.decompress(data)
58
+ case CompressionMethod.LZMA:
59
+ try:
60
+ import lzma
61
+ except ImportError:
62
+ raise UnsupportedCompression(method) from None
63
+
64
+ result = lzma.decompress(data)
65
+ case CompressionMethod.ZSTANDARD:
66
+ try:
67
+ import zstandard # ty: ignore[unresolved-import]
68
+ except ImportError:
69
+ raise UnsupportedCompression(method) from None
70
+
71
+ result = zstandard.decompress(data)
72
+ case _:
73
+ raise UnsupportedCompression(method)
74
+
75
+ actual_crc = zlib.crc32(result) & 0xFFFFFFFF
76
+ if actual_crc != expected_crc:
77
+ raise CRCMismatch(expected_crc, actual_crc)
78
+
79
+ return result
80
+
81
+
82
+ class StreamingDecompressor:
83
+ """Feed compressed chunks via :meth:`feed`, then call :meth:`finish` to verify CRC-32."""
84
+
85
+ def __init__(self, method: int, expected_crc: int, dest: Writable) -> None:
86
+ self._expected_crc = expected_crc
87
+ self._dest = dest
88
+ self._crc: int = 0
89
+ self._dobj: typing.Any = None
90
+
91
+ match method:
92
+ case CompressionMethod.STORED:
93
+ self._mode = _DecompressMode.STORED
94
+ case CompressionMethod.DEFLATED:
95
+ self._mode = _DecompressMode.DEFLATED
96
+ self._dobj = zlib.decompressobj(-15)
97
+ case CompressionMethod.BZIP2:
98
+ try:
99
+ import bz2
100
+ except ImportError:
101
+ raise UnsupportedCompression(method) from None
102
+
103
+ self._mode = _DecompressMode.INCREMENTAL
104
+ self._dobj = bz2.BZ2Decompressor()
105
+ case CompressionMethod.LZMA:
106
+ try:
107
+ import lzma
108
+ except ImportError:
109
+ raise UnsupportedCompression(method) from None
110
+
111
+ self._mode = _DecompressMode.INCREMENTAL
112
+ self._dobj = lzma.LZMADecompressor()
113
+ case CompressionMethod.ZSTANDARD:
114
+ try:
115
+ import zstandard # ty: ignore[unresolved-import]
116
+ except ImportError:
117
+ raise UnsupportedCompression(method) from None
118
+
119
+ self._mode = _DecompressMode.INCREMENTAL
120
+ self._dobj = zstandard.ZstdDecompressor().decompressobj()
121
+ case _:
122
+ raise UnsupportedCompression(method)
123
+
124
+ def feed(self, data: bytes) -> None:
125
+ """Decompress *data*, write output to dest, update CRC."""
126
+ if self._mode is _DecompressMode.STORED:
127
+ self._crc = zlib.crc32(data, self._crc)
128
+ self._dest.write(data)
129
+ else:
130
+ # deflate, bz2, lzma, zstandard - all expose .decompress()
131
+ chunk = self._dobj.decompress(data)
132
+ if chunk:
133
+ self._crc = zlib.crc32(chunk, self._crc)
134
+ self._dest.write(chunk)
135
+
136
+ def finish(self) -> None:
137
+ """Flush remaining data (deflate only) and verify CRC-32."""
138
+ if self._mode is _DecompressMode.DEFLATED:
139
+ chunk = self._dobj.flush()
140
+ if chunk:
141
+ self._crc = zlib.crc32(chunk, self._crc)
142
+ self._dest.write(chunk)
143
+
144
+ actual_crc = self._crc & 0xFFFFFFFF
145
+ if actual_crc != self._expected_crc:
146
+ raise CRCMismatch(self._expected_crc, actual_crc)
zipwire/_errors.py ADDED
@@ -0,0 +1,40 @@
1
+ """Exception hierarchy for zipwire."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class ZipwireError(Exception):
7
+ """Base exception for all zipwire errors."""
8
+
9
+
10
+ class BadZipFile(ZipwireError):
11
+ """The data does not appear to be a valid ZIP archive."""
12
+
13
+
14
+ class UnsupportedCompression(ZipwireError):
15
+ """The file uses an unsupported compression method."""
16
+
17
+ def __init__(self, method: int) -> None:
18
+ self.method = method
19
+ super().__init__(f"Unsupported compression method: {method}")
20
+
21
+
22
+ class CRCMismatch(ZipwireError):
23
+ """CRC-32 check failed after decompression."""
24
+
25
+ def __init__(self, expected: int, actual: int) -> None:
26
+ self.expected = expected
27
+ self.actual = actual
28
+ super().__init__(f"CRC-32 mismatch: expected {expected:#010x}, got {actual:#010x}")
29
+
30
+
31
+ class RangeRequestUnsupported(ZipwireError):
32
+ """The HTTP server does not support range requests."""
33
+
34
+
35
+ class FileNotFoundInZip(ZipwireError, KeyError):
36
+ """The requested file was not found in the ZIP archive."""
37
+
38
+ def __init__(self, filename: str) -> None:
39
+ self.filename = filename
40
+ super().__init__(f"File not found in archive: {filename!r}")