rtzip 0.1.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.
rtzip-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,5 @@
1
+ Metadata-Version: 2.4
2
+ Name: rtzip
3
+ Version: 0.1.0
4
+ Requires-Python: >=3.12
5
+ Requires-Dist: obstruct==0.5.2
@@ -0,0 +1,7 @@
1
+ [project]
2
+ name = "rtzip"
3
+ version = "0.1.0"
4
+ requires-python = ">=3.12"
5
+ dependencies = [
6
+ "obstruct==0.5.2",
7
+ ]
rtzip-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ from .models import EOCD, Zip64EOCD, CDEntry
2
+ from .errors import *
3
+ from .remotezip import RemoteZip
@@ -0,0 +1,10 @@
1
+ class UnsupportedAlgorithmError(Exception):
2
+ ...
3
+
4
+
5
+ class UnsupportedCryptoError(Exception):
6
+ ...
7
+
8
+
9
+ class UnsupportedDataDescriptorError(Exception):
10
+ ...
@@ -0,0 +1,244 @@
1
+ import dataclasses
2
+ import struct
3
+ from collections.abc import Buffer
4
+ from typing import Annotated, Self
5
+
6
+ import obstruct as obs
7
+
8
+ EXTRA_FIELD_HEADER = struct.Struct("<HH")
9
+
10
+
11
+ def parse_extra_fields(buffer):
12
+ res = {}
13
+
14
+ buffer = memoryview(buffer)
15
+ length = buffer.nbytes
16
+ offset = 0
17
+
18
+ while offset + 4 <= length:
19
+ header_id, n_data = EXTRA_FIELD_HEADER.unpack_from(buffer, offset)
20
+
21
+ offset += 4
22
+ res[header_id] = buffer[offset: offset + n_data].tobytes()
23
+ offset += n_data
24
+
25
+ return res
26
+
27
+
28
+ @dataclasses.dataclass(slots=True)
29
+ class EOCD(obs.Struct):
30
+ disk_num: obs.u16
31
+ cd_disk: obs.u16
32
+ disk_entries: obs.u16
33
+ total_entries: obs.u16
34
+ cd_size: obs.u32
35
+ cd_offset: obs.u32
36
+ comment_length: obs.u16
37
+
38
+ @classmethod
39
+ def from_buffer(cls, buffer: Buffer, offset=4):
40
+ return cls.unpack_from(buffer, offset)
41
+
42
+
43
+ @dataclasses.dataclass(slots=True)
44
+ class Zip64EOCD(obs.Struct):
45
+ # 签名部分已忽略
46
+ record_size: obs.u64
47
+ version: obs.u16
48
+ unzip_min_version: obs.u16
49
+ disk_id: obs.u32
50
+ cd_disk: obs.u32
51
+ disk_entries: obs.u64
52
+ total_entries: obs.u64
53
+ cd_size: obs.u64
54
+ cd_offset: obs.u64
55
+ extra_data: Annotated[bytes, obs.Marks.NOT_A_CFIELD]
56
+
57
+ @property
58
+ def extra_offset(self):
59
+ return self.__cstruct__.size
60
+
61
+ @property
62
+ def extra_length(self):
63
+ return self.record_size - self.__cstruct__.size
64
+
65
+ @classmethod
66
+ def from_buffer(cls, buffer, offset=4, header_only: bool = False):
67
+ self = cls.unpack_from(buffer, offset)
68
+
69
+ extra_offset = self.extra_offset + offset
70
+ extra_length = self.extra_length
71
+
72
+ if not header_only:
73
+ self.extra_data = buffer[extra_offset: extra_offset + extra_length]
74
+ return self
75
+
76
+
77
+ @dataclasses.dataclass(slots=True)
78
+ class CDEntry(obs.Struct):
79
+ version: obs.u16
80
+ unzip_min_version: obs.u16
81
+ flags: obs.u16
82
+ algorithm: obs.u16
83
+ last_modified: obs.u16
84
+ last_modified_date: obs.u16
85
+ crc32_raw: obs.u32
86
+ compressed_size: obs.u32
87
+ original_size: obs.u32
88
+ filename_len: obs.u16
89
+ extra_len: obs.u16
90
+ comment_len: obs.u16
91
+ disk_id: obs.u16
92
+ inner_file_attrs: obs.u16
93
+ outer_file_attrs: obs.u32
94
+ local_header_offset: obs.u32
95
+
96
+ filename: Annotated[bytes, obs.Marks.NOT_A_CFIELD]
97
+ raw_extra_fields: Annotated[bytes, obs.Marks.NOT_A_CFIELD]
98
+ comment: Annotated[bytes, obs.Marks.NOT_A_CFIELD]
99
+
100
+ _extra_fields: Annotated[dict | None, obs.Marks.NOT_A_CFIELD] = dataclasses.field(default=None, repr=False)
101
+
102
+ @property
103
+ def is_encrypted(self):
104
+ return bool(self.flags & 0x0001)
105
+
106
+ @property
107
+ def is_zip64(self):
108
+ return self.compressed_size == 0xFFFFFFFF or self.original_size == 0xFFFFFFFF
109
+
110
+ @property
111
+ def has_data_descriptor(self):
112
+ return bool(self.flags & 0b100)
113
+
114
+ @property
115
+ def extra_fields(self) -> dict[int, bytes]:
116
+ if getattr(self, '_extra_fields', None) is None:
117
+ self._extra_fields = parse_extra_fields(self.raw_extra_fields)
118
+
119
+ return self._extra_fields
120
+
121
+ def fix_by_zip64(self):
122
+ zip64_data = (self.extra_fields.get(1))
123
+ offset = 0
124
+ if zip64_data:
125
+ zip64_data = memoryview(zip64_data)
126
+ length = len(zip64_data)
127
+ if self.original_size == 0xFFFFFFFF:
128
+ assert offset + 8 <= length
129
+ self.original_size = int.from_bytes(zip64_data[offset:offset + 8], 'little')
130
+ offset += 8
131
+ if self.compressed_size == 0xFFFFFFFF:
132
+ assert offset + 8 <= length
133
+ self.compressed_size = int.from_bytes(zip64_data[offset:offset + 8], 'little')
134
+ offset += 8
135
+ if self.local_header_offset == 0xFFFFFFFF:
136
+ assert offset + 8 <= length
137
+ self.local_header_offset = int.from_bytes(zip64_data[offset: offset + 8], 'little')
138
+ offset += 8
139
+ if self.disk_id == 0xFFFF:
140
+ assert offset + 4 <= length
141
+ self.disk_id = int.from_bytes(zip64_data[offset: offset + 4], 'little')
142
+
143
+ @classmethod
144
+ def from_buffer(cls, buffer: Buffer, offset=4) -> tuple[Self, int]:
145
+ buffer = memoryview(buffer)
146
+ # print(buffer.tobytes())
147
+ header_size = cls.__cstruct__.size
148
+
149
+ self = cls.unpack_from(buffer, offset)
150
+ filename_len, extra_len, comment_len = self.filename_len, self.extra_len, self.comment_len
151
+
152
+ pos = offset + header_size
153
+ filename = bytes(buffer[pos: pos + filename_len])
154
+ pos += filename_len
155
+
156
+ extra_attrs = buffer[pos: pos + extra_len].tobytes()
157
+ pos += extra_len
158
+
159
+ comment = buffer[pos: pos + comment_len].tobytes()
160
+ pos += comment_len
161
+
162
+ self.filename = filename
163
+ self.raw_extra_fields = extra_attrs
164
+ self.comment = comment
165
+
166
+ # print(self)
167
+
168
+ return (
169
+ self,
170
+ pos - offset
171
+ )
172
+
173
+
174
+ @dataclasses.dataclass()
175
+ class LocalFileHeader(obs.Struct):
176
+ unzip_min_version: obs.u16
177
+ flags: obs.u16
178
+ algorithm: obs.u16
179
+ last_modified_time: obs.u16
180
+ last_modified_date: obs.u16
181
+ crc32: obs.u32
182
+ compressed_size: obs.u32
183
+ uncompressed_size: obs.u32
184
+ filename_len: obs.u16
185
+ extra_len: obs.u16
186
+
187
+ filename: Annotated[bytes, obs.Marks.NOT_A_CFIELD]
188
+ raw_extra_fields: Annotated[bytes, obs.Marks.NOT_A_CFIELD]
189
+
190
+ _extra_fields: Annotated[dict[int, bytes] | None, obs.Marks.NOT_A_CFIELD] = None
191
+
192
+ @property
193
+ def extra_fields(self):
194
+ if self._extra_fields is None:
195
+ self._extra_fields = parse_extra_fields(self.raw_extra_fields)
196
+
197
+ return self._extra_fields
198
+
199
+ def fix_by_zip64(self):
200
+ zip64_data = self.extra_fields.get(0x0001)
201
+ if zip64_data:
202
+ original_size, compressed_size = struct.unpack('<QQ', zip64_data)
203
+ self.uncompressed_size = original_size
204
+ self.compressed_size = compressed_size
205
+
206
+ @classmethod
207
+ def from_buffer(cls, buffer: Buffer, offset: int = 4, header_only=False) -> tuple[Self, int]:
208
+ """从缓冲区解析 Local File Header,返回 (实例, 消耗字节数)"""
209
+ buf = memoryview(buffer)
210
+ header_size = cls.__cstruct__.size
211
+
212
+ # 解析固定头部
213
+ self = cls.unpack_from(buf, offset)
214
+
215
+ if header_only:
216
+ return self, header_size
217
+
218
+ # 提取长度字段
219
+ filename_len = self.filename_len
220
+ extra_len = self.extra_len
221
+
222
+ pos = offset + header_size
223
+
224
+ # 读取文件名
225
+ filename = bytes(buf[pos:pos + filename_len])
226
+ pos += filename_len
227
+
228
+ # 读取额外字段
229
+ extra_fields = bytes(buf[pos:pos + extra_len])
230
+ pos += extra_len
231
+
232
+ self.filename = filename
233
+ self.raw_extra_fields = extra_fields
234
+
235
+ return (
236
+ self,
237
+ pos - offset
238
+ )
239
+
240
+ @property
241
+ def data_offset(self) -> int:
242
+ """返回实际文件数据的起始偏移量(相对于 Local File Header 起始位置)"""
243
+ # 固定头 30 字节 + 文件名长度 + 额外字段长度
244
+ return self.__cstruct__.size + self.filename_len + self.extra_len + 4
@@ -0,0 +1,238 @@
1
+ import struct
2
+ from typing import AsyncGenerator
3
+
4
+ from .errors import UnsupportedAlgorithmError, UnsupportedCryptoError, UnsupportedDataDescriptorError
5
+ from .models import EOCD, Zip64EOCD, CDEntry, LocalFileHeader
6
+ from .utils import deflate_wrapper, single_chunk_wrapper
7
+
8
+
9
+ class DataSourceCallbacks:
10
+ async def read_range(self, offset, length) -> bytes | bytearray | memoryview:
11
+ ...
12
+
13
+ def read_range_stream(self, offset, length) -> AsyncGenerator[bytes | bytearray | memoryview]:
14
+ ...
15
+
16
+ async def get_total_size(self) -> int:
17
+ ...
18
+
19
+
20
+ class RemoteZip:
21
+ EOCD_SIGNATURE = b'PK\x05\x06'
22
+ CDENTITY_SIGNATURE = b'PK\x01\x02'
23
+ LFH_SIGNATURE = b'PK\x03\x04'
24
+ ZIP64_EOCD_LOCATOR_SIGNATURE = b'PK\x06\x07'
25
+ ZIP64_EOCD_SIGNATURE = b'PK\x06\x06'
26
+
27
+ async def _read_range(self, offset, length):
28
+ return await self.source.read_range(offset, length)
29
+
30
+ def _read_range_stream(self, offset, length):
31
+ return self.source.read_range_stream(offset, length)
32
+
33
+ async def _get_total_size(self):
34
+ return await self.source.get_total_size()
35
+
36
+ def __init__(self, source: DataSourceCallbacks):
37
+ self.source = source
38
+
39
+ self.cd_info: EOCD | Zip64EOCD | None = None
40
+ self.files: list[CDEntry] = []
41
+ self.file_mapping: dict[str, CDEntry] | None = None
42
+
43
+ self.is_zip64 = False
44
+
45
+ async def fetch_eocd(self, initial_chunk=4096, max_cnt=-1, loss_factor: int | float = 2):
46
+ chunk_size = initial_chunk
47
+
48
+ last_start = await self._get_total_size()
49
+ cnt = 0
50
+
51
+ buffer = b''
52
+ while last_start - chunk_size > 0:
53
+ cnt += 1
54
+ content = await self._read_range(last_start - chunk_size, chunk_size)
55
+
56
+ buffer = content + buffer[:32]
57
+
58
+ signature_pos = buffer.rfind(RemoteZip.EOCD_SIGNATURE)
59
+ if signature_pos != -1:
60
+ self.cd_info = eocd = EOCD.from_buffer(buffer[signature_pos:])
61
+ # return cnt
62
+ if eocd.total_entries == 0xFFFFFFFF or eocd.cd_offset == 0xFFFFFFFF or eocd.cd_size == 0xFFFFFFFF:
63
+ self.is_zip64 = True
64
+
65
+ # 继续获取 zip64 的 eocd
66
+ # zip64 locator 在 eocd 前的 20 字节处
67
+ # 字段包括 4 字节的签名,两个 8 字节数据
68
+
69
+ zip64_locator_offset = signature_pos - 20
70
+ # 检查需要的数据是否已经存在
71
+ if zip64_locator_offset < 0:
72
+ # print("--------------------Zero Padding")
73
+ buffer = (
74
+ await self._read_range(
75
+ last_start - chunk_size - abs(zip64_locator_offset),
76
+ abs(zip64_locator_offset)
77
+ )
78
+ + buffer
79
+ )
80
+
81
+ zip64_locator_offset = 0
82
+
83
+ # print(buffer)
84
+
85
+ assert buffer[zip64_locator_offset:zip64_locator_offset + 4] == self.ZIP64_EOCD_LOCATOR_SIGNATURE
86
+ zip64_eocd_disk_id, zip64_eocd_offset, zip64_total_disks = \
87
+ struct.unpack_from('<4xIQI', buffer, zip64_locator_offset)
88
+
89
+ # print(zip64_eocd_disk_id, zip64_eocd_offset, zip64_total_disks)
90
+
91
+ zip64_eocd_data = await self._read_range(zip64_eocd_offset, 96)
92
+ # print(zip64_eocd_data[:64])
93
+ assert zip64_eocd_data[:4] == self.ZIP64_EOCD_SIGNATURE
94
+
95
+ zip64_eocd = Zip64EOCD.from_buffer(zip64_eocd_data, header_only=True)
96
+ # print(zip64_eocd)
97
+
98
+ extra_offset = zip64_eocd.extra_offset + 4
99
+ extra_length = zip64_eocd.extra_length
100
+ extra_end = extra_offset + extra_length
101
+
102
+ # print(extra_offset, extra_end, buffer[extra_offset:extra_end])
103
+
104
+ if extra_end > len(zip64_eocd_data):
105
+ zip64_eocd_data += await self._read_range(
106
+ zip64_eocd_offset + 64,
107
+ extra_end - len(zip64_eocd_data)
108
+ )
109
+
110
+ extra_data = zip64_eocd_data[extra_offset:extra_offset + extra_length]
111
+
112
+ zip64_eocd.extra_data = extra_data
113
+
114
+ # print(zip64_eocd)
115
+
116
+ self.cd_info = zip64_eocd
117
+ return cnt
118
+ else:
119
+ return cnt
120
+
121
+ if max_cnt != -1 and cnt >= max_cnt:
122
+ raise RuntimeError
123
+
124
+ last_start -= chunk_size
125
+ chunk_size = int(chunk_size * loss_factor)
126
+ else:
127
+ raise RuntimeError
128
+
129
+ async def fetch_cd(self, ignore_extra: bool = False):
130
+ assert self.cd_info
131
+ eocd = self.cd_info
132
+ buffer = bytearray()
133
+ files = []
134
+
135
+ def consume_entry():
136
+ nonlocal buffer
137
+ entry, nbytes = CDEntry.from_buffer(buffer)
138
+
139
+ if self.is_zip64:
140
+ entry.fix_by_zip64()
141
+
142
+ files.append(entry)
143
+ buffer = buffer[nbytes + 4:]
144
+
145
+ async for chunk in self._read_range_stream(eocd.cd_offset, eocd.cd_size):
146
+ # print(chunk)
147
+ buffer.extend(chunk)
148
+
149
+ while buffer and buffer[:4] == self.CDENTITY_SIGNATURE:
150
+ try:
151
+ consume_entry()
152
+ except struct.error:
153
+ continue
154
+
155
+ if buffer and not ignore_extra:
156
+ # print("Read Entries: ", len(files))
157
+ # print("Buffer:", buffer)
158
+ raise ValueError()
159
+
160
+ self.files = files
161
+
162
+ async def fetch_single_file_header(self, filename=None, entry: CDEntry | None = None):
163
+ filename = str(filename)
164
+ entry = entry or self.find_file_entry(filename)
165
+ buffer = await self._read_range(entry.local_header_offset, entry.__cstruct__.size + 64) # 预留64字节的变长字段
166
+
167
+ # 仅头部模式解析 LocalFileHeader
168
+ header, _ = LocalFileHeader.from_buffer(buffer, 4, header_only=True)
169
+ if header.extra_len + header.filename_len > 64:
170
+ _ = await self._read_range(entry.local_header_offset + 64, header.extra_len + header.filename_len - 64)
171
+ buffer = bytearray(buffer) + _
172
+
173
+ pos = header.__cstruct__.size + 4 # 4 字节的签名在计算 nbytes 时会被忽略,实际需要加上
174
+ filename = buffer[pos: pos + header.filename_len]
175
+ pos += header.filename_len
176
+
177
+ extra_fields = buffer[pos:pos + header.extra_len]
178
+ pos += header.extra_len
179
+
180
+ header.filename = filename
181
+ header.raw_extra_fields = extra_fields
182
+
183
+ header.fix_by_zip64()
184
+ return header, buffer[pos:] # 返回剩余的数据部分
185
+
186
+ def build_mapping(self):
187
+ m = self.file_mapping = {}
188
+ for entry in self.files:
189
+ m[entry.filename] = entry
190
+
191
+ def find_file_entry(self, filename):
192
+ if self.file_mapping:
193
+ return self.file_mapping[filename]
194
+ else:
195
+ for i in self.files:
196
+ if i.filename == filename:
197
+ return i
198
+ else:
199
+ raise FileNotFoundError(filename)
200
+
201
+ async def _stream_single_file(self, filename):
202
+ entry = self.find_file_entry(filename)
203
+
204
+ if entry.is_encrypted:
205
+ raise UnsupportedCryptoError()
206
+
207
+ if entry.has_data_descriptor:
208
+ raise UnsupportedDataDescriptorError()
209
+
210
+ if entry.algorithm not in {0x0000, 0x0008}:
211
+ raise UnsupportedAlgorithmError(entry.algorithm)
212
+
213
+ header, buffer = await self.fetch_single_file_header(entry=entry)
214
+ # print(header)
215
+ real_data_offset = entry.local_header_offset + header.data_offset
216
+
217
+ if entry.compressed_size < len(buffer):
218
+ raw_generator = single_chunk_wrapper(buffer[:entry.compressed_size])
219
+ else:
220
+ raw_generator = self._read_range_stream(real_data_offset, entry.compressed_size)
221
+
222
+ match entry.algorithm:
223
+ case 0x0000:
224
+ return raw_generator
225
+ case 0x0008:
226
+ return deflate_wrapper(raw_generator)
227
+
228
+ async def stream_single_file(self, filename):
229
+ gen = await self._stream_single_file(filename)
230
+ async for i in gen:
231
+ yield i
232
+
233
+ async def load_single_file(self, filename) -> bytearray:
234
+ buffer = bytearray()
235
+ async for i in self.stream_single_file(filename):
236
+ buffer.extend(i)
237
+
238
+ return buffer
@@ -0,0 +1,15 @@
1
+ import zlib
2
+ from collections.abc import Buffer
3
+
4
+
5
+ async def deflate_wrapper(gen):
6
+ decompresser = zlib.decompressobj(-15)
7
+ async for chunk in gen:
8
+ yield decompresser.decompress(chunk)
9
+ remaining = decompresser.flush()
10
+ if remaining:
11
+ yield remaining
12
+
13
+
14
+ async def single_chunk_wrapper(data: Buffer):
15
+ yield data
@@ -0,0 +1,5 @@
1
+ Metadata-Version: 2.4
2
+ Name: rtzip
3
+ Version: 0.1.0
4
+ Requires-Python: >=3.12
5
+ Requires-Dist: obstruct==0.5.2
@@ -0,0 +1,11 @@
1
+ pyproject.toml
2
+ src/rtzip/__init__.py
3
+ src/rtzip/errors.py
4
+ src/rtzip/models.py
5
+ src/rtzip/remotezip.py
6
+ src/rtzip/utils.py
7
+ src/rtzip.egg-info/PKG-INFO
8
+ src/rtzip.egg-info/SOURCES.txt
9
+ src/rtzip.egg-info/dependency_links.txt
10
+ src/rtzip.egg-info/requires.txt
11
+ src/rtzip.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ obstruct==0.5.2
@@ -0,0 +1 @@
1
+ rtzip