multiarchive 0.1.0__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.
multiarchive/__init__.py
ADDED
multiarchive/_archive.py
ADDED
|
@@ -0,0 +1,753 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import zipfile
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from io import TextIOWrapper
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from types import TracebackType
|
|
8
|
+
from typing import IO, List, Literal, TypeAlias
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
import bz2 as bzip2
|
|
12
|
+
except ImportError:
|
|
13
|
+
bzip2 = None # ty: ignore
|
|
14
|
+
try:
|
|
15
|
+
import gzip
|
|
16
|
+
except ImportError:
|
|
17
|
+
gzip = None # ty: ignore
|
|
18
|
+
try:
|
|
19
|
+
import lzma
|
|
20
|
+
except ImportError:
|
|
21
|
+
lzma = None # ty: ignore
|
|
22
|
+
try:
|
|
23
|
+
import rarfile
|
|
24
|
+
except ImportError:
|
|
25
|
+
rarfile = None # ty: ignore
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
if sys.version_info.major == 3 and sys.version_info.minor <= 13:
|
|
29
|
+
from backports.zstd import tarfile # noqa # ty: ignore
|
|
30
|
+
else:
|
|
31
|
+
import tarfile
|
|
32
|
+
except ImportError:
|
|
33
|
+
import tarfile
|
|
34
|
+
|
|
35
|
+
zstd_available = False
|
|
36
|
+
else:
|
|
37
|
+
zstd_available = True
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# Type Aliases
|
|
41
|
+
if rarfile is not None:
|
|
42
|
+
InfoType: TypeAlias = zipfile.ZipInfo | tarfile.TarInfo | rarfile.RarInfo
|
|
43
|
+
InfoList: TypeAlias = (
|
|
44
|
+
list[zipfile.ZipInfo] | list[tarfile.TarInfo] | list[rarfile.RarInfo]
|
|
45
|
+
)
|
|
46
|
+
ArchiveType: TypeAlias = zipfile.ZipFile | tarfile.TarFile | rarfile.RarFile
|
|
47
|
+
BadArchive = (zipfile.BadZipFile, tarfile.TarError, rarfile.BadRarFile)
|
|
48
|
+
else:
|
|
49
|
+
InfoType: TypeAlias = zipfile.ZipInfo | tarfile.TarInfo
|
|
50
|
+
InfoList: TypeAlias = list[zipfile.ZipInfo] | list[tarfile.TarInfo]
|
|
51
|
+
ArchiveType: TypeAlias = zipfile.ZipFile | tarfile.TarFile
|
|
52
|
+
BadArchive = (zipfile.BadZipFile, tarfile.TarError)
|
|
53
|
+
if gzip is not None:
|
|
54
|
+
CompressFileObjType: TypeAlias = IO[bytes] | TextIOWrapper | gzip.GzipFile
|
|
55
|
+
else:
|
|
56
|
+
CompressFileObjType: TypeAlias = IO[bytes] | TextIOWrapper
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class BadArchiveError(Exception):
|
|
60
|
+
"""Custom exception for handling bad or unsupported archive files."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# hard code because i dont want to get the file headers to identify them
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class ArchiveExtensions:
|
|
66
|
+
zip = (".zip",)
|
|
67
|
+
rar = (".rar",)
|
|
68
|
+
tar = (".tar",)
|
|
69
|
+
gz = (".tgz", ".tar.gz")
|
|
70
|
+
bz2 = (".tbz", ".tbz2", ".tar.bz2")
|
|
71
|
+
xz = (".tar.xz", ".tar.lzma")
|
|
72
|
+
zst = (".tzst", ".tar.zst")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class ArchiveMemberInfo:
|
|
77
|
+
"""Unified metadata for archive members with a consistent interface.
|
|
78
|
+
|
|
79
|
+
The `raw` attribute provides direct access to the underlying backend-specific
|
|
80
|
+
info object (ZipInfo, TarInfo, or RarInfo) when advanced or format-specific
|
|
81
|
+
features are needed.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
name: str
|
|
85
|
+
uncompressed_size: int
|
|
86
|
+
compressed_size: int | None
|
|
87
|
+
mtime: float
|
|
88
|
+
mode: int | None
|
|
89
|
+
is_dir: bool
|
|
90
|
+
raw: InfoType
|
|
91
|
+
|
|
92
|
+
@classmethod
|
|
93
|
+
def from_zipinfo(cls, info: zipfile.ZipInfo) -> "ArchiveMemberInfo":
|
|
94
|
+
mtime_dt = datetime(*info.date_time)
|
|
95
|
+
return cls(
|
|
96
|
+
name=info.filename,
|
|
97
|
+
uncompressed_size=info.file_size,
|
|
98
|
+
compressed_size=info.compress_size,
|
|
99
|
+
mtime=mtime_dt.timestamp(),
|
|
100
|
+
mode=(info.external_attr >> 16) or None,
|
|
101
|
+
is_dir=info.is_dir(),
|
|
102
|
+
raw=info,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
@classmethod
|
|
106
|
+
def from_tarinfo(cls, info: tarfile.TarInfo) -> "ArchiveMemberInfo":
|
|
107
|
+
return cls(
|
|
108
|
+
name=info.name,
|
|
109
|
+
uncompressed_size=info.size,
|
|
110
|
+
compressed_size=None,
|
|
111
|
+
mtime=float(info.mtime or 0.0),
|
|
112
|
+
mode=info.mode,
|
|
113
|
+
is_dir=info.isdir(),
|
|
114
|
+
raw=info,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
@classmethod
|
|
118
|
+
def from_rarinfo(cls, info: "rarfile.RarInfo") -> "ArchiveMemberInfo":
|
|
119
|
+
if info.mtime is not None:
|
|
120
|
+
mtime = info.mtime.timestamp()
|
|
121
|
+
elif info.date_time is not None:
|
|
122
|
+
mtime = datetime(*info.date_time).timestamp()
|
|
123
|
+
else:
|
|
124
|
+
mtime = 0.0
|
|
125
|
+
assert info.filename is not None
|
|
126
|
+
assert info.file_size is not None
|
|
127
|
+
assert info.compress_size is not None
|
|
128
|
+
return cls(
|
|
129
|
+
name=info.filename,
|
|
130
|
+
uncompressed_size=info.file_size,
|
|
131
|
+
compressed_size=info.compress_size,
|
|
132
|
+
mtime=mtime,
|
|
133
|
+
mode=info.mode,
|
|
134
|
+
is_dir=info.is_dir(),
|
|
135
|
+
raw=info,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class Archive:
|
|
140
|
+
"""Unified handler for ZIP, TAR and RAR files with context manager support."""
|
|
141
|
+
|
|
142
|
+
def __init__(
|
|
143
|
+
self,
|
|
144
|
+
filename: str | Path,
|
|
145
|
+
algo: Literal["zip", "tar", "rar", "tar.gz", "tar.bz2", "tar.xz", "tar.zst"]
|
|
146
|
+
| None = None,
|
|
147
|
+
mode: str = "r",
|
|
148
|
+
compression_level: int | None = None,
|
|
149
|
+
) -> None:
|
|
150
|
+
"""Initialize the archive handler.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
filename: Path to the archive file
|
|
154
|
+
mode: File access mode ('r' for read, 'w' for write, 'a' for append)
|
|
155
|
+
compression_level: Compression level (ZIP: 0-9, TAR gzip: 0-9, TAR bzip2: 1-9)
|
|
156
|
+
If None, uses default compression
|
|
157
|
+
|
|
158
|
+
Raises:
|
|
159
|
+
ValueError: If mode is not supported or compression_level is out of range
|
|
160
|
+
""" # noqa: DOC502
|
|
161
|
+
self.filename = str(filename)
|
|
162
|
+
self.mode = mode
|
|
163
|
+
self.compression_level = compression_level
|
|
164
|
+
self.algo = algo
|
|
165
|
+
self._archive: ArchiveType | None = None
|
|
166
|
+
self._archive_type: Literal["zip", "rar", "tar"] | None = None
|
|
167
|
+
self._compress_file_obj: CompressFileObjType | None = None
|
|
168
|
+
|
|
169
|
+
@classmethod
|
|
170
|
+
def open_archive(
|
|
171
|
+
cls,
|
|
172
|
+
filename: str | Path,
|
|
173
|
+
mode: str = "r",
|
|
174
|
+
compression_level: int | None = None,
|
|
175
|
+
) -> "Archive":
|
|
176
|
+
"""Create and open an archive without using a context manager.
|
|
177
|
+
|
|
178
|
+
This is a factory method alternative to using __init__ directly.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
filename: Path to the archive file
|
|
182
|
+
mode: File access mode ('r' for read, 'w' for write, 'a' for append)
|
|
183
|
+
compression_level: Compression level (ZIP: 0-9, TAR gzip: 0-9, TAR bzip2: 1-9)
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
Opened Archive instance ready for use
|
|
187
|
+
|
|
188
|
+
Raises:
|
|
189
|
+
FileNotFoundError: If the archive file doesn't exist (for read mode)
|
|
190
|
+
ValueError: If file extension is not recognized or compression_level is invalid
|
|
191
|
+
BadArchiveError: If the archive cannot be opened due to format errors
|
|
192
|
+
|
|
193
|
+
Examples:
|
|
194
|
+
>>> archive = Archive.open_archive("file.zip")
|
|
195
|
+
>>> archive.namelist()
|
|
196
|
+
['file1.txt', 'file2.txt']
|
|
197
|
+
>>> archive.close()
|
|
198
|
+
"""
|
|
199
|
+
archive = cls(filename, mode=mode, compression_level=compression_level)
|
|
200
|
+
archive._detect_and_open()
|
|
201
|
+
return archive
|
|
202
|
+
|
|
203
|
+
def __enter__(self) -> "Archive":
|
|
204
|
+
"""Context manager entry - opens the archive.
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
Self for method chaining in with statement
|
|
208
|
+
"""
|
|
209
|
+
self._detect_and_open()
|
|
210
|
+
return self
|
|
211
|
+
|
|
212
|
+
def __exit__(
|
|
213
|
+
self,
|
|
214
|
+
exc_type: type | None,
|
|
215
|
+
exc_val: Exception | None,
|
|
216
|
+
exc_tb: TracebackType | None,
|
|
217
|
+
) -> None:
|
|
218
|
+
"""Context manager exit - closes the archive.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
exc_type: Exception type if an exception occurred
|
|
222
|
+
exc_val: Exception value if an exception occurred
|
|
223
|
+
exc_tb: Traceback if an exception occurred
|
|
224
|
+
"""
|
|
225
|
+
if self._archive:
|
|
226
|
+
self._archive.close()
|
|
227
|
+
if self._compress_file_obj:
|
|
228
|
+
self._compress_file_obj.close()
|
|
229
|
+
|
|
230
|
+
def _detect_and_open(self) -> None:
|
|
231
|
+
"""Detect file type and open appropriate handler.
|
|
232
|
+
|
|
233
|
+
For read mode, attempts to open as ZIP, then RAR, then TAR by trying each
|
|
234
|
+
format and catching format-specific errors. For write mode, uses file
|
|
235
|
+
extension to determine the format.
|
|
236
|
+
|
|
237
|
+
Raises:
|
|
238
|
+
FileNotFoundError: If the archive file doesn't exist (for read mode)
|
|
239
|
+
ValueError: If file extension is not recognized or compression_level is invalid
|
|
240
|
+
BadArchiveError: If the archive cannot be opened due to format errors
|
|
241
|
+
"""
|
|
242
|
+
try:
|
|
243
|
+
if self.mode == "r":
|
|
244
|
+
self._detect_and_open_read()
|
|
245
|
+
else:
|
|
246
|
+
self._detect_and_open_write()
|
|
247
|
+
except BadArchive as exc:
|
|
248
|
+
raise BadArchiveError(f"Failed to open archive. {exc}") from exc
|
|
249
|
+
except (FileNotFoundError, ValueError):
|
|
250
|
+
raise
|
|
251
|
+
|
|
252
|
+
def _detect_and_open_read(self) -> None:
|
|
253
|
+
"""Attempt to open archive for reading by trying each format.
|
|
254
|
+
|
|
255
|
+
Tries ZIP first, then RAR, then TAR. Uses actual file content detection
|
|
256
|
+
rather than relying on file extensions.
|
|
257
|
+
|
|
258
|
+
Raises:
|
|
259
|
+
FileNotFoundError: If the archive file doesn't exist
|
|
260
|
+
NotImplementedError: If the archive is password-protected (for ZIP and RAR)
|
|
261
|
+
ValueError: If the file is not a valid ZIP, RAR, or TAR archive
|
|
262
|
+
""" # noqa: DOC502
|
|
263
|
+
# Try ZIP first
|
|
264
|
+
try:
|
|
265
|
+
archive = zipfile.ZipFile(self.filename, "r")
|
|
266
|
+
# Check for password protection
|
|
267
|
+
if any(zinfo.flag_bits & 0x1 for zinfo in archive.infolist()):
|
|
268
|
+
archive.close()
|
|
269
|
+
raise NotImplementedError("Password-protected ZIP files are not supported")
|
|
270
|
+
self._archive = archive
|
|
271
|
+
self._archive_type = "zip"
|
|
272
|
+
return
|
|
273
|
+
except zipfile.BadZipFile:
|
|
274
|
+
pass
|
|
275
|
+
|
|
276
|
+
# Try RAR
|
|
277
|
+
try:
|
|
278
|
+
archive = rarfile.RarFile(self.filename, "r")
|
|
279
|
+
# Check for password protection
|
|
280
|
+
if archive.needs_password():
|
|
281
|
+
archive.close()
|
|
282
|
+
raise NotImplementedError("Password-protected RAR files are not supported")
|
|
283
|
+
self._archive = archive
|
|
284
|
+
self._archive_type = "rar"
|
|
285
|
+
return
|
|
286
|
+
except rarfile.NotRarFile:
|
|
287
|
+
pass
|
|
288
|
+
|
|
289
|
+
# Try TAR (with auto-detection for compression)
|
|
290
|
+
try:
|
|
291
|
+
self._archive = tarfile.open(self.filename, "r:*") # noqa: SIM115
|
|
292
|
+
self._archive_type = "tar"
|
|
293
|
+
return
|
|
294
|
+
except tarfile.TarError:
|
|
295
|
+
pass
|
|
296
|
+
|
|
297
|
+
raise ValueError(
|
|
298
|
+
f"Cannot open '{self.filename}': not a valid ZIP, RAR, or TAR archive"
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
def _detect_and_open_write(self) -> None:
|
|
302
|
+
"""Open archive for writing based on file extension.
|
|
303
|
+
|
|
304
|
+
Raises:
|
|
305
|
+
ValueError: If file extension is not recognized or compression_level is invalid
|
|
306
|
+
"""
|
|
307
|
+
filename_lower = self.filename.lower()
|
|
308
|
+
|
|
309
|
+
if filename_lower.endswith(ArchiveExtensions.zip) or (self.algo == "zip"):
|
|
310
|
+
self._archive_type = "zip"
|
|
311
|
+
if self.compression_level is not None:
|
|
312
|
+
if not (0 <= self.compression_level <= 9):
|
|
313
|
+
raise ValueError("ZIP compression level must be between 0-9")
|
|
314
|
+
self._archive = zipfile.ZipFile(
|
|
315
|
+
self.filename, self.mode, compresslevel=self.compression_level
|
|
316
|
+
)
|
|
317
|
+
else:
|
|
318
|
+
self._archive = zipfile.ZipFile(self.filename, self.mode)
|
|
319
|
+
elif filename_lower.endswith(ArchiveExtensions.rar) or (self.algo == "rar"):
|
|
320
|
+
raise ValueError("RAR files can only be opened in read mode ('r')")
|
|
321
|
+
else:
|
|
322
|
+
# Assume it's a tar file
|
|
323
|
+
self._archive_type = "tar"
|
|
324
|
+
tar_mode = self._get_tar_write_mode()
|
|
325
|
+
if self.compression_level is not None:
|
|
326
|
+
self._archive = self._open_tar_with_compression(tar_mode)
|
|
327
|
+
else:
|
|
328
|
+
self._archive = tarfile.open(self.filename, tar_mode) # noqa: SIM115
|
|
329
|
+
|
|
330
|
+
def _get_tar_write_mode(self) -> Literal["w:gz", "w:bz2", "w:xz", "w:zst", "w"]:
|
|
331
|
+
"""Determine tar write mode based on file extension.
|
|
332
|
+
|
|
333
|
+
Returns:
|
|
334
|
+
Appropriate tarfile mode string for writing
|
|
335
|
+
"""
|
|
336
|
+
filename_lower = self.filename.lower()
|
|
337
|
+
if filename_lower.endswith(ArchiveExtensions.gz) or (self.algo == "tar.gz"):
|
|
338
|
+
return "w:gz"
|
|
339
|
+
elif filename_lower.endswith(ArchiveExtensions.bz2) or (self.algo == "tar.bz2"):
|
|
340
|
+
return "w:bz2"
|
|
341
|
+
elif filename_lower.endswith(ArchiveExtensions.xz) or (self.algo == "tar.xz"):
|
|
342
|
+
return "w:xz"
|
|
343
|
+
elif filename_lower.endswith(ArchiveExtensions.zst) or (self.algo == "tar.zst"):
|
|
344
|
+
return "w:zst"
|
|
345
|
+
else:
|
|
346
|
+
return "w"
|
|
347
|
+
|
|
348
|
+
def _open_tar_with_compression(
|
|
349
|
+
self, tar_mode: Literal["w:gz", "w:bz2", "w:xz", "w:zst", "w"]
|
|
350
|
+
) -> tarfile.TarFile:
|
|
351
|
+
"""Open TAR file with specified compression level.
|
|
352
|
+
|
|
353
|
+
Args:
|
|
354
|
+
tar_mode: TAR mode string (e.g., 'w:gz', 'w:bz2')
|
|
355
|
+
|
|
356
|
+
Returns:
|
|
357
|
+
Opened TarFile with compression level applied
|
|
358
|
+
|
|
359
|
+
Raises:
|
|
360
|
+
ValueError: If compression level is invalid for the compression type
|
|
361
|
+
ModuleNotFoundError: If the required compression module is not available
|
|
362
|
+
"""
|
|
363
|
+
assert self.compression_level is not None
|
|
364
|
+
|
|
365
|
+
if ":gz" in tar_mode:
|
|
366
|
+
if not (0 <= self.compression_level <= 9):
|
|
367
|
+
raise ValueError("Gzip compression level must be between 0-9")
|
|
368
|
+
self._compress_file_obj = gzip.open( # noqa: SIM115
|
|
369
|
+
self.filename, self.mode + "b", compresslevel=self.compression_level
|
|
370
|
+
)
|
|
371
|
+
return tarfile.open(fileobj=self._compress_file_obj, mode="w")
|
|
372
|
+
|
|
373
|
+
elif ":bz2" in tar_mode:
|
|
374
|
+
if not (1 <= self.compression_level <= 9):
|
|
375
|
+
raise ValueError("bzip2 compression level must be between 1-9")
|
|
376
|
+
if bzip2 is None:
|
|
377
|
+
raise ModuleNotFoundError("bzip2 module is not available")
|
|
378
|
+
self._compress_file_obj = bzip2.open( # noqa: SIM115
|
|
379
|
+
self.filename, self.mode + "b", compresslevel=self.compression_level
|
|
380
|
+
)
|
|
381
|
+
return tarfile.open(fileobj=self._compress_file_obj, mode="w")
|
|
382
|
+
|
|
383
|
+
elif ":xz" in tar_mode:
|
|
384
|
+
if not (0 <= self.compression_level <= 9):
|
|
385
|
+
raise ValueError("xz compression level must be between 0-9")
|
|
386
|
+
if lzma is None:
|
|
387
|
+
raise ModuleNotFoundError("lzma module is not available")
|
|
388
|
+
xz_file = lzma.open( # noqa: SIM115
|
|
389
|
+
self.filename, self.mode + "b", preset=self.compression_level
|
|
390
|
+
)
|
|
391
|
+
return tarfile.open(fileobj=xz_file, mode="w")
|
|
392
|
+
elif ":zst" in tar_mode:
|
|
393
|
+
if not (1 <= self.compression_level <= 22):
|
|
394
|
+
raise ValueError("zstd compression level must be between 1-22")
|
|
395
|
+
if not zstd_available:
|
|
396
|
+
raise ModuleNotFoundError("zstd compression is not available")
|
|
397
|
+
return tarfile.open(self.filename, tar_mode, level=self.compression_level)
|
|
398
|
+
else:
|
|
399
|
+
return tarfile.open(self.filename, mode="w")
|
|
400
|
+
|
|
401
|
+
def infolist(
|
|
402
|
+
self,
|
|
403
|
+
) -> list[ArchiveMemberInfo]:
|
|
404
|
+
"""Return list of archive members wrapped in ArchiveMemberInfo.
|
|
405
|
+
|
|
406
|
+
Returns:
|
|
407
|
+
List of ArchiveMemberInfo objects with unified metadata
|
|
408
|
+
|
|
409
|
+
Raises:
|
|
410
|
+
RuntimeError: If archive is not opened
|
|
411
|
+
BadArchiveError: If the archive cannot be listed due to any archive related errors
|
|
412
|
+
FileNotFoundError: If the file is no longer available
|
|
413
|
+
"""
|
|
414
|
+
if not self._archive:
|
|
415
|
+
raise RuntimeError("Archive not opened")
|
|
416
|
+
|
|
417
|
+
try:
|
|
418
|
+
match self._archive_type:
|
|
419
|
+
case "rar":
|
|
420
|
+
assert isinstance(self._archive, rarfile.RarFile)
|
|
421
|
+
return [
|
|
422
|
+
ArchiveMemberInfo.from_rarinfo(i)
|
|
423
|
+
for i in self._archive.infolist()
|
|
424
|
+
]
|
|
425
|
+
case "zip":
|
|
426
|
+
assert isinstance(self._archive, zipfile.ZipFile)
|
|
427
|
+
return [
|
|
428
|
+
ArchiveMemberInfo.from_zipinfo(i)
|
|
429
|
+
for i in self._archive.infolist()
|
|
430
|
+
]
|
|
431
|
+
case _:
|
|
432
|
+
assert isinstance(self._archive, tarfile.TarFile)
|
|
433
|
+
return [
|
|
434
|
+
ArchiveMemberInfo.from_tarinfo(i)
|
|
435
|
+
for i in self._archive.getmembers()
|
|
436
|
+
]
|
|
437
|
+
except BadArchive as exc:
|
|
438
|
+
raise BadArchiveError(f"Failed to open archive. {exc}") from exc
|
|
439
|
+
except FileNotFoundError:
|
|
440
|
+
raise
|
|
441
|
+
|
|
442
|
+
def namelist(self) -> List[str]:
|
|
443
|
+
"""Return list of member names.
|
|
444
|
+
|
|
445
|
+
Returns:
|
|
446
|
+
List of strings containing all member file/directory names in the archive
|
|
447
|
+
|
|
448
|
+
Raises:
|
|
449
|
+
RuntimeError: If archive is not opened
|
|
450
|
+
BadArchiveError: If the archive cannot be listed due to any archive related errors
|
|
451
|
+
FileNotFoundError: If the file is no longer available
|
|
452
|
+
"""
|
|
453
|
+
if not self._archive:
|
|
454
|
+
raise RuntimeError("Archive not opened")
|
|
455
|
+
|
|
456
|
+
try:
|
|
457
|
+
match self._archive_type:
|
|
458
|
+
case "zip":
|
|
459
|
+
assert isinstance(self._archive, zipfile.ZipFile)
|
|
460
|
+
return self._archive.namelist()
|
|
461
|
+
case "rar":
|
|
462
|
+
assert isinstance(self._archive, rarfile.RarFile)
|
|
463
|
+
return self._archive.namelist()
|
|
464
|
+
case _:
|
|
465
|
+
assert isinstance(self._archive, tarfile.TarFile)
|
|
466
|
+
return self._archive.getnames()
|
|
467
|
+
except BadArchive as exc:
|
|
468
|
+
raise BadArchiveError(f"Failed to open archive. {exc}") from exc
|
|
469
|
+
except FileNotFoundError:
|
|
470
|
+
raise
|
|
471
|
+
|
|
472
|
+
def extract(
|
|
473
|
+
self,
|
|
474
|
+
member: str | InfoType,
|
|
475
|
+
path: str | Path = "",
|
|
476
|
+
) -> str:
|
|
477
|
+
"""Extract a single member to the specified path.
|
|
478
|
+
|
|
479
|
+
Args:
|
|
480
|
+
member: Name of the file to extract, or ZipInfo/TarInfo/RarInfo object
|
|
481
|
+
path: Directory to extract to. If None, extracts to current directory
|
|
482
|
+
|
|
483
|
+
Returns:
|
|
484
|
+
Path to the extracted file
|
|
485
|
+
|
|
486
|
+
Raises:
|
|
487
|
+
RuntimeError: If archive is not opened
|
|
488
|
+
BadArchiveError: If the extraction fails due to archive related errors
|
|
489
|
+
FileNotFoundError: If the file is no longer available
|
|
490
|
+
"""
|
|
491
|
+
if not self._archive:
|
|
492
|
+
raise RuntimeError("Archive not opened")
|
|
493
|
+
|
|
494
|
+
try:
|
|
495
|
+
match self._archive_type:
|
|
496
|
+
case "rar":
|
|
497
|
+
assert isinstance(self._archive, rarfile.RarFile)
|
|
498
|
+
if isinstance(member, rarfile.RarInfo):
|
|
499
|
+
member_filename = str(member.filename)
|
|
500
|
+
else:
|
|
501
|
+
member_filename = str(member)
|
|
502
|
+
self._archive.extract(member, path)
|
|
503
|
+
return str(Path(path or ".") / member_filename)
|
|
504
|
+
case "zip":
|
|
505
|
+
assert isinstance(self._archive, zipfile.ZipFile)
|
|
506
|
+
member_arg = (
|
|
507
|
+
member
|
|
508
|
+
if isinstance(member, (str, zipfile.ZipInfo))
|
|
509
|
+
else str(member)
|
|
510
|
+
)
|
|
511
|
+
return self._archive.extract(member_arg, path)
|
|
512
|
+
case _:
|
|
513
|
+
assert isinstance(self._archive, tarfile.TarFile)
|
|
514
|
+
member_arg = (
|
|
515
|
+
member
|
|
516
|
+
if isinstance(member, (str, tarfile.TarInfo))
|
|
517
|
+
else str(member)
|
|
518
|
+
)
|
|
519
|
+
result = self._archive.extract(member_arg, path)
|
|
520
|
+
return (
|
|
521
|
+
str(result)
|
|
522
|
+
if result
|
|
523
|
+
else str(Path(path or ".") / str(member_arg))
|
|
524
|
+
)
|
|
525
|
+
except BadArchive as exc:
|
|
526
|
+
raise BadArchiveError(f"Failed to extract member. {exc}") from exc
|
|
527
|
+
except FileNotFoundError:
|
|
528
|
+
raise
|
|
529
|
+
|
|
530
|
+
def open(
|
|
531
|
+
self,
|
|
532
|
+
member: str | InfoType,
|
|
533
|
+
mode: Literal["r", "w"] = "r",
|
|
534
|
+
) -> IO[bytes] | None:
|
|
535
|
+
"""Open a member file for reading.
|
|
536
|
+
|
|
537
|
+
Args:
|
|
538
|
+
member: Name of the file to open, or ZipInfo/TarInfo/RarInfo object
|
|
539
|
+
mode: File open mode (only 'r' supported for TAR and RAR files)
|
|
540
|
+
|
|
541
|
+
Returns:
|
|
542
|
+
File-like object for reading the member's contents, or None if member
|
|
543
|
+
is a directory or cannot be opened
|
|
544
|
+
|
|
545
|
+
Raises:
|
|
546
|
+
RuntimeError: If archive is not opened
|
|
547
|
+
ValueError: If a RAR file is attempted to be opened in anything that isn't read mode
|
|
548
|
+
BadArchiveError: If the member cannot be opened due to archive related errors
|
|
549
|
+
FileNotFoundError: If the file is no longer available
|
|
550
|
+
"""
|
|
551
|
+
if not self._archive:
|
|
552
|
+
raise RuntimeError("Archive not opened")
|
|
553
|
+
|
|
554
|
+
try:
|
|
555
|
+
match self._archive_type:
|
|
556
|
+
case "zip":
|
|
557
|
+
assert isinstance(self._archive, zipfile.ZipFile)
|
|
558
|
+
member_arg = (
|
|
559
|
+
member
|
|
560
|
+
if isinstance(member, (str, zipfile.ZipInfo))
|
|
561
|
+
else str(member)
|
|
562
|
+
)
|
|
563
|
+
return self._archive.open(member_arg, mode)
|
|
564
|
+
case "rar":
|
|
565
|
+
assert isinstance(self._archive, rarfile.RarFile)
|
|
566
|
+
if mode != "r":
|
|
567
|
+
raise ValueError(
|
|
568
|
+
"RAR members can only be opened in read mode ('r')"
|
|
569
|
+
)
|
|
570
|
+
member_arg = (
|
|
571
|
+
member
|
|
572
|
+
if isinstance(member, (str, rarfile.RarInfo))
|
|
573
|
+
else str(member)
|
|
574
|
+
)
|
|
575
|
+
return self._archive.open(member_arg, mode)
|
|
576
|
+
case _:
|
|
577
|
+
assert isinstance(self._archive, tarfile.TarFile)
|
|
578
|
+
member_arg = (
|
|
579
|
+
member
|
|
580
|
+
if isinstance(member, (str, tarfile.TarInfo))
|
|
581
|
+
else str(member)
|
|
582
|
+
)
|
|
583
|
+
return self._archive.extractfile(member_arg)
|
|
584
|
+
except BadArchive as exc:
|
|
585
|
+
raise BadArchiveError(f"Failed to open member. {exc}") from exc
|
|
586
|
+
except FileNotFoundError:
|
|
587
|
+
raise
|
|
588
|
+
|
|
589
|
+
@property
|
|
590
|
+
def members(self) -> List[str]:
|
|
591
|
+
"""Return list of member names (alias for namelist()).
|
|
592
|
+
|
|
593
|
+
Returns:
|
|
594
|
+
List of strings containing all member file/directory names
|
|
595
|
+
"""
|
|
596
|
+
return self.namelist()
|
|
597
|
+
|
|
598
|
+
@property
|
|
599
|
+
def size(self) -> int:
|
|
600
|
+
"""Return total uncompressed size of all members in bytes.
|
|
601
|
+
|
|
602
|
+
Returns:
|
|
603
|
+
Total uncompressed size across all archive members
|
|
604
|
+
|
|
605
|
+
Raises:
|
|
606
|
+
RuntimeError: If archive is not opened
|
|
607
|
+
"""
|
|
608
|
+
if not self._archive:
|
|
609
|
+
raise RuntimeError("Archive not opened")
|
|
610
|
+
|
|
611
|
+
match self._archive_type:
|
|
612
|
+
case "zip":
|
|
613
|
+
assert isinstance(self._archive, zipfile.ZipFile)
|
|
614
|
+
return sum(info.file_size for info in self._archive.infolist())
|
|
615
|
+
case "rar":
|
|
616
|
+
assert isinstance(self._archive, rarfile.RarFile)
|
|
617
|
+
return sum(info.file_size for info in self._archive.infolist())
|
|
618
|
+
case _:
|
|
619
|
+
assert isinstance(self._archive, tarfile.TarFile)
|
|
620
|
+
return sum(info.size for info in self._archive.getmembers())
|
|
621
|
+
|
|
622
|
+
@property
|
|
623
|
+
def comment(self) -> bytes:
|
|
624
|
+
"""Get the archive comment (ZIP only).
|
|
625
|
+
|
|
626
|
+
Returns:
|
|
627
|
+
Archive comment as bytes, or empty bytes for non-ZIP archives
|
|
628
|
+
|
|
629
|
+
Raises:
|
|
630
|
+
RuntimeError: If archive is not opened
|
|
631
|
+
"""
|
|
632
|
+
if not self._archive:
|
|
633
|
+
raise RuntimeError("Archive not opened")
|
|
634
|
+
|
|
635
|
+
if self._archive_type == "zip":
|
|
636
|
+
assert isinstance(self._archive, zipfile.ZipFile)
|
|
637
|
+
return self._archive.comment
|
|
638
|
+
return b""
|
|
639
|
+
|
|
640
|
+
@comment.setter
|
|
641
|
+
def comment(self, value: bytes) -> None:
|
|
642
|
+
"""Set the archive comment (ZIP only).
|
|
643
|
+
|
|
644
|
+
Args:
|
|
645
|
+
value: Comment to set as bytes
|
|
646
|
+
|
|
647
|
+
Raises:
|
|
648
|
+
RuntimeError: If archive is not opened
|
|
649
|
+
ValueError: If attempting to set comment on non-ZIP archive
|
|
650
|
+
"""
|
|
651
|
+
if not self._archive:
|
|
652
|
+
raise RuntimeError("Archive not opened")
|
|
653
|
+
|
|
654
|
+
if self._archive_type != "zip":
|
|
655
|
+
raise ValueError("Archive comment is only supported for ZIP files")
|
|
656
|
+
|
|
657
|
+
assert isinstance(self._archive, zipfile.ZipFile)
|
|
658
|
+
self._archive.comment = value
|
|
659
|
+
|
|
660
|
+
def __iter__(self):
|
|
661
|
+
"""Iterate over member names.
|
|
662
|
+
|
|
663
|
+
Yields:
|
|
664
|
+
String containing each member file/directory name
|
|
665
|
+
"""
|
|
666
|
+
yield from self.namelist()
|
|
667
|
+
|
|
668
|
+
def __contains__(self, member: str) -> bool:
|
|
669
|
+
"""Check if a member exists in the archive.
|
|
670
|
+
|
|
671
|
+
Args:
|
|
672
|
+
member: Name of the member to check
|
|
673
|
+
|
|
674
|
+
Returns:
|
|
675
|
+
True if member exists in archive, False otherwise
|
|
676
|
+
"""
|
|
677
|
+
return member in self.namelist()
|
|
678
|
+
|
|
679
|
+
def __len__(self) -> int:
|
|
680
|
+
"""Return number of members in the archive.
|
|
681
|
+
|
|
682
|
+
Returns:
|
|
683
|
+
Number of files/directories in the archive
|
|
684
|
+
"""
|
|
685
|
+
return len(self.namelist())
|
|
686
|
+
|
|
687
|
+
def close(self) -> None:
|
|
688
|
+
"""Explicitly close the archive.
|
|
689
|
+
|
|
690
|
+
Safe to call multiple times. Closes both the archive and any
|
|
691
|
+
compression file objects.
|
|
692
|
+
"""
|
|
693
|
+
if self._archive:
|
|
694
|
+
self._archive.close()
|
|
695
|
+
self._archive = None
|
|
696
|
+
if self._compress_file_obj:
|
|
697
|
+
self._compress_file_obj.close()
|
|
698
|
+
self._compress_file_obj = None
|
|
699
|
+
|
|
700
|
+
def is_dir(self, member: str | InfoType) -> bool:
|
|
701
|
+
"""Check if a member is a directory.
|
|
702
|
+
|
|
703
|
+
Args:
|
|
704
|
+
member: Name of the member or its info object
|
|
705
|
+
|
|
706
|
+
Returns:
|
|
707
|
+
True if the member is a directory, False otherwise
|
|
708
|
+
|
|
709
|
+
Raises:
|
|
710
|
+
RuntimeError: If archive is not opened
|
|
711
|
+
"""
|
|
712
|
+
if not self._archive:
|
|
713
|
+
raise RuntimeError("Archive not opened")
|
|
714
|
+
|
|
715
|
+
match self._archive_type:
|
|
716
|
+
case "zip":
|
|
717
|
+
assert isinstance(self._archive, zipfile.ZipFile)
|
|
718
|
+
info = (
|
|
719
|
+
member
|
|
720
|
+
if isinstance(member, zipfile.ZipInfo)
|
|
721
|
+
else self._archive.getinfo(str(member))
|
|
722
|
+
)
|
|
723
|
+
return info.is_dir()
|
|
724
|
+
case "rar":
|
|
725
|
+
assert isinstance(self._archive, rarfile.RarFile)
|
|
726
|
+
info = (
|
|
727
|
+
member
|
|
728
|
+
if isinstance(member, rarfile.RarInfo)
|
|
729
|
+
else self._archive.getinfo(str(member))
|
|
730
|
+
)
|
|
731
|
+
return info.is_dir()
|
|
732
|
+
case _:
|
|
733
|
+
assert isinstance(self._archive, tarfile.TarFile)
|
|
734
|
+
info = (
|
|
735
|
+
member
|
|
736
|
+
if isinstance(member, tarfile.TarInfo)
|
|
737
|
+
else self._archive.getmember(member)
|
|
738
|
+
)
|
|
739
|
+
return info.isdir()
|
|
740
|
+
|
|
741
|
+
def is_file(self, member: str | InfoType) -> bool:
|
|
742
|
+
"""Check if a member is a regular file.
|
|
743
|
+
|
|
744
|
+
Args:
|
|
745
|
+
member: Name of the member or its info object
|
|
746
|
+
|
|
747
|
+
Returns:
|
|
748
|
+
True if the member is a file (not a directory), False otherwise
|
|
749
|
+
|
|
750
|
+
Raises:
|
|
751
|
+
RuntimeError: If archive is not opened
|
|
752
|
+
"""
|
|
753
|
+
return not self.is_dir(member)
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: multiarchive
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A high level archive handler for Python.
|
|
5
|
+
Author: NSPC911
|
|
6
|
+
Author-email: NSPC911 <87571998+NSPC911@users.noreply.github.com>
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# multiarchive
|
|
11
|
+
|
|
12
|
+
A high level abstraction of multiple archive formats
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
uv add multiarchive
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Optional dependency for RAR support:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
uv add rarfile
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
ZStandard support requires the `backports-zstd` package on Python <3.14:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
uv add "backports-zstd; python_version < '3.14'"
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Quick Start
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from multiarchive import Archive
|
|
36
|
+
|
|
37
|
+
# Open and inspect
|
|
38
|
+
with Archive("archive.zip") as arc:
|
|
39
|
+
print(arc.namelist()) # list of member names
|
|
40
|
+
print(len(arc)) # number of members
|
|
41
|
+
print(arc.size) # total uncompressed size in bytes
|
|
42
|
+
|
|
43
|
+
# Extract a single file
|
|
44
|
+
with Archive("archive.tar.gz") as arc:
|
|
45
|
+
arc.extract("docs/readme.md", path="./output")
|
|
46
|
+
|
|
47
|
+
# Read file contents without extracting
|
|
48
|
+
with Archive("archive.zip") as arc:
|
|
49
|
+
f = arc.open("config.json")
|
|
50
|
+
content = f.read()
|
|
51
|
+
f.close()
|
|
52
|
+
|
|
53
|
+
# Iterate over members
|
|
54
|
+
with Archive("archive.zip") as arc:
|
|
55
|
+
for name in arc:
|
|
56
|
+
if arc.is_file(name):
|
|
57
|
+
print(f" {name}")
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Supported Formats
|
|
61
|
+
|
|
62
|
+
| Format | Read | Write | Extensions |
|
|
63
|
+
| ------- | ---- | ----- | --------------------------- |
|
|
64
|
+
| ZIP | Yes | Yes | `.zip` |
|
|
65
|
+
| TAR | Yes | Yes | `.tar` |
|
|
66
|
+
| TAR.GZ | Yes | Yes | `.tgz`, `.tar.gz` |
|
|
67
|
+
| TAR.BZ2 | Yes | Yes | `.tbz`, `.tbz2`, `.tar.bz2` |
|
|
68
|
+
| TAR.XZ | Yes | Yes | `.tar.xz`, `.tar.lzma` |
|
|
69
|
+
| TAR.ZST | Yes | Yes | `.tzst`, `.tar.zst` |
|
|
70
|
+
| RAR | Yes | No | `.rar` |
|
|
71
|
+
|
|
72
|
+
RAR support requires the `rarfile` package and an external `unrar` tool.
|
|
73
|
+
|
|
74
|
+
## Reading Archives
|
|
75
|
+
|
|
76
|
+
### Opening
|
|
77
|
+
|
|
78
|
+
Use the context manager (recommended):
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
with Archive("file.zip") as arc:
|
|
82
|
+
...
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Or the classmethod:
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
arc = Archive.open_archive("file.zip")
|
|
89
|
+
try:
|
|
90
|
+
arc.namelist()
|
|
91
|
+
finally:
|
|
92
|
+
arc.close()
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Reading is supported on all formats, but password-protected archives are not supported and will raise a `ValueError`.
|
|
96
|
+
If `algo` isnt explicitly provided, it will attempt to open it as ZIP first, then TAR, then RAR (if `rarfile` is installed). If all fails, it raises a ValueError.
|
|
97
|
+
|
|
98
|
+
### Listing Members
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
with Archive("file.zip") as arc:
|
|
102
|
+
names = arc.namelist() # Just names
|
|
103
|
+
names = arc.members # alias to namelist
|
|
104
|
+
|
|
105
|
+
# extra info (ArchiveMemberInfo objects)
|
|
106
|
+
for info in arc.infolist():
|
|
107
|
+
print(f"{info.name}: {info.uncompressed_size} bytes, mtime={info.mtime}")
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### Checking Membership
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
with Archive("file.zip") as arc:
|
|
114
|
+
if "config.json" in arc:
|
|
115
|
+
print("Found!")
|
|
116
|
+
|
|
117
|
+
for name in arc:
|
|
118
|
+
if arc.is_file(name):
|
|
119
|
+
...
|
|
120
|
+
if arc.is_dir(name):
|
|
121
|
+
...
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### Extracting
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
with Archive("file.tar.gz") as arc:
|
|
128
|
+
# Single file
|
|
129
|
+
arc.extract("src/main.py", path="./extracted")
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### Reading Member Contents
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
with Archive("file.zip") as arc:
|
|
136
|
+
f = arc.open("data.json")
|
|
137
|
+
if f is not None:
|
|
138
|
+
content = f.read()
|
|
139
|
+
f.close()
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Writing Archives
|
|
143
|
+
|
|
144
|
+
### Creating a New Archive
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
from multiarchive import Archive
|
|
148
|
+
|
|
149
|
+
# ZIP
|
|
150
|
+
with Archive("output.zip", mode="w") as arc:
|
|
151
|
+
...
|
|
152
|
+
|
|
153
|
+
# TAR.GZ with compression level
|
|
154
|
+
with Archive("output.tar.gz", mode="w", compression_level=9) as arc:
|
|
155
|
+
...
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
The format is determined by file extension. Use the `algo` parameter to override:
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
with Archive("my_archive", mode="w", algo="tar.xz") as arc:
|
|
162
|
+
...
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### Compression Levels
|
|
166
|
+
|
|
167
|
+
| Format | Range | Default |
|
|
168
|
+
| ------- | ----- | ------- |
|
|
169
|
+
| ZIP | 0–9 | 6 |
|
|
170
|
+
| TAR.GZ | 0–9 | 6 |
|
|
171
|
+
| TAR.BZ2 | 1–9 | 9 |
|
|
172
|
+
| TAR.XZ | 0–9 | 6 |
|
|
173
|
+
| TAR.ZST | 1–22 | 3 |
|
|
174
|
+
|
|
175
|
+
## File Information Object
|
|
176
|
+
|
|
177
|
+
The `infolist()` method returns `ArchiveMemberInfo` objects with a unified interface across all formats:
|
|
178
|
+
|
|
179
|
+
| Field | Type | Description |
|
|
180
|
+
| ------------------- | ------------------------------- | -------------------------------------- |
|
|
181
|
+
| `name` | `str` | Member filename |
|
|
182
|
+
| `uncompressed_size` | `int` | Original file size in bytes |
|
|
183
|
+
| `compressed_size` | `int \| None` | Compressed size (`None` for TAR) |
|
|
184
|
+
| `mtime` | `float` | Modification time as epoch seconds |
|
|
185
|
+
| `mode` | `int \| None` | Unix permission bits |
|
|
186
|
+
| `is_dir` | `bool` | Whether the member is a directory |
|
|
187
|
+
| `raw` | `ZipInfo \| TarInfo \| RarInfo` | Original backend object (escape hatch) |
|
|
188
|
+
|
|
189
|
+
Factory methods for direct conversion (you shouldn't need these in normal usage):
|
|
190
|
+
|
|
191
|
+
```python
|
|
192
|
+
info = ArchiveMemberInfo.from_zipinfo(zip_info)
|
|
193
|
+
info = ArchiveMemberInfo.from_tarinfo(tar_info)
|
|
194
|
+
info = ArchiveMemberInfo.from_rarinfo(rar_info)
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
## Properties
|
|
198
|
+
|
|
199
|
+
| Method/Property | Description |
|
|
200
|
+
| ----------------------- | --------------------------------------------------------------- |
|
|
201
|
+
| `members` | Alias for `namelist()` |
|
|
202
|
+
| `size` | Total uncompressed size of all members |
|
|
203
|
+
| `comment` / `comment=` | Get/set archive comment (ZIP only, raises ValueError otherwise) |
|
|
204
|
+
|
|
205
|
+
## Error Handling
|
|
206
|
+
|
|
207
|
+
```python
|
|
208
|
+
from multiarchive import Archive, BadArchiveError
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
with Archive("corrupted.zip") as arc:
|
|
212
|
+
arc.namelist()
|
|
213
|
+
except BadArchiveError as e:
|
|
214
|
+
print(f"Archive error: {e}")
|
|
215
|
+
except NotImplementedError as e:
|
|
216
|
+
# password-protected (not supported)
|
|
217
|
+
print(f"Not implemented: {e}")
|
|
218
|
+
except ValueError as e:
|
|
219
|
+
# Unknown format
|
|
220
|
+
print(f"Value error: {e}")
|
|
221
|
+
except FileNotFoundError:
|
|
222
|
+
print("File not found")
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
## API Reference
|
|
226
|
+
|
|
227
|
+
### `Archive`
|
|
228
|
+
|
|
229
|
+
```python
|
|
230
|
+
Archive(
|
|
231
|
+
filename: str | Path,
|
|
232
|
+
mode: str = "r",
|
|
233
|
+
compression_level: int | None = None,
|
|
234
|
+
)
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
| Parameter | Description |
|
|
238
|
+
| ------------------- | ------------------------------------------------- |
|
|
239
|
+
| `filename` | Path to the archive file |
|
|
240
|
+
| `mode` | `'r'` for read, `'w'` for write, `'a'` for append |
|
|
241
|
+
| `compression_level` | Compression level (format-dependent) |
|
|
242
|
+
|
|
243
|
+
#### Methods
|
|
244
|
+
|
|
245
|
+
| Method | Description |
|
|
246
|
+
| ------------------------------------------------------ | -------------------------------------- |
|
|
247
|
+
| `open_archive(cls, filename, mode, compression_level)` | Factory method, returns opened archive |
|
|
248
|
+
| `namelist()` | List of member names |
|
|
249
|
+
| `infolist()` | List of `ArchiveMemberInfo` objects |
|
|
250
|
+
| `extract(member, path)` | Extract single member |
|
|
251
|
+
| `open(member, mode)` | Open member as file-like object |
|
|
252
|
+
| `is_dir(member)` | Check if member is a directory |
|
|
253
|
+
| `is_file(member)` | Check if member is a file |
|
|
254
|
+
| `close()` | Explicitly close the archive |
|
|
255
|
+
|
|
256
|
+
### `BadArchiveError`
|
|
257
|
+
|
|
258
|
+
Raised when an archive file is corrupt or in an unsupported format.
|
|
259
|
+
|
|
260
|
+
### `ArchiveExtensions`
|
|
261
|
+
|
|
262
|
+
Frozen dataclass mapping format names to their common file extensions:
|
|
263
|
+
|
|
264
|
+
```python
|
|
265
|
+
ArchiveExtensions.zip # (".zip",)
|
|
266
|
+
ArchiveExtensions.gz # (".tgz", ".tar.gz")
|
|
267
|
+
ArchiveExtensions.xz # (".tar.xz", ".tar.lzma")
|
|
268
|
+
# ... etc
|
|
269
|
+
```
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
multiarchive/__init__.py,sha256=17M_IkvBDNH28SY5dw7hZtYTHa6GXCvu0DDB-VNM6M8,93
|
|
2
|
+
multiarchive/_archive.py,sha256=YTMNzoRROt6ZdSbqbSRtPeC8QQ_gRHyoz_VO41SzXgU,27748
|
|
3
|
+
multiarchive-0.1.0.dist-info/WHEEL,sha256=q5IF0q2xCp3ktUFRCVWsQLjl2ChNlWXBJtnI1LCGdJ8,80
|
|
4
|
+
multiarchive-0.1.0.dist-info/METADATA,sha256=zrnNREtk0Ap89TrGM1vdsCmEEr19vnsFNClvId1NPGA,8054
|
|
5
|
+
multiarchive-0.1.0.dist-info/RECORD,,
|