multiarchive 0.1.4__tar.gz → 0.2.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.
- {multiarchive-0.1.4 → multiarchive-0.2.0}/PKG-INFO +7 -2
- {multiarchive-0.1.4 → multiarchive-0.2.0}/README.md +6 -1
- {multiarchive-0.1.4 → multiarchive-0.2.0}/pyproject.toml +1 -1
- {multiarchive-0.1.4 → multiarchive-0.2.0}/pyproject.toml.orig +1 -1
- {multiarchive-0.1.4 → multiarchive-0.2.0}/src/multiarchive/_archive.py +147 -11
- {multiarchive-0.1.4 → multiarchive-0.2.0}/src/multiarchive/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: multiarchive
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: A high level archive handler for Python.
|
|
5
5
|
Author: NSPC911
|
|
6
6
|
Author-email: NSPC911 <87571998+NSPC911@users.noreply.github.com>
|
|
@@ -68,8 +68,13 @@ with Archive("archive.zip") as arc:
|
|
|
68
68
|
| TAR.XZ | Yes | Yes | `.tar.xz`, `.tar.lzma` |
|
|
69
69
|
| TAR.ZST | Yes | Yes | `.tzst`, `.tar.zst` |
|
|
70
70
|
| RAR | Yes | No | `.rar` |
|
|
71
|
+
| GZIP | Yes | No | `.gz` |
|
|
72
|
+
| BZIP2 | Yes | No | `.bz2` |
|
|
73
|
+
| XZ | Yes | No | `.xz` |
|
|
74
|
+
| ZSTD | Yes | No | `.zst`, `.zstd` |
|
|
71
75
|
|
|
72
76
|
RAR support requires the `rarfile` package and an external `unrar` tool.
|
|
77
|
+
Plain compressed files expose one member named after the file without its compression extension (e.g. `report.txt.gz` contains `report.txt`). They support `open()` and `extract()`, but not writing or `add()`. Their `infolist()` entry has `raw=None`; calculating uncompressed size reads the compressed data.
|
|
73
78
|
|
|
74
79
|
## Reading Archives
|
|
75
80
|
|
|
@@ -199,7 +204,7 @@ The `infolist()` method returns `ArchiveMemberInfo` objects with a unified inter
|
|
|
199
204
|
| `mtime` | `float` | Modification time as epoch seconds |
|
|
200
205
|
| `mode` | `int \| None` | Unix permission bits |
|
|
201
206
|
| `is_dir` | `bool` | Whether the member is a directory |
|
|
202
|
-
| `raw` | `ZipInfo \| TarInfo \| RarInfo` | Original backend object (
|
|
207
|
+
| `raw` | `ZipInfo \| TarInfo \| RarInfo \| None` | Original backend object (`None` for plain compressed files) |
|
|
203
208
|
|
|
204
209
|
Factory methods for direct conversion (you shouldn't need these in normal usage):
|
|
205
210
|
|
|
@@ -59,8 +59,13 @@ with Archive("archive.zip") as arc:
|
|
|
59
59
|
| TAR.XZ | Yes | Yes | `.tar.xz`, `.tar.lzma` |
|
|
60
60
|
| TAR.ZST | Yes | Yes | `.tzst`, `.tar.zst` |
|
|
61
61
|
| RAR | Yes | No | `.rar` |
|
|
62
|
+
| GZIP | Yes | No | `.gz` |
|
|
63
|
+
| BZIP2 | Yes | No | `.bz2` |
|
|
64
|
+
| XZ | Yes | No | `.xz` |
|
|
65
|
+
| ZSTD | Yes | No | `.zst`, `.zstd` |
|
|
62
66
|
|
|
63
67
|
RAR support requires the `rarfile` package and an external `unrar` tool.
|
|
68
|
+
Plain compressed files expose one member named after the file without its compression extension (e.g. `report.txt.gz` contains `report.txt`). They support `open()` and `extract()`, but not writing or `add()`. Their `infolist()` entry has `raw=None`; calculating uncompressed size reads the compressed data.
|
|
64
69
|
|
|
65
70
|
## Reading Archives
|
|
66
71
|
|
|
@@ -190,7 +195,7 @@ The `infolist()` method returns `ArchiveMemberInfo` objects with a unified inter
|
|
|
190
195
|
| `mtime` | `float` | Modification time as epoch seconds |
|
|
191
196
|
| `mode` | `int \| None` | Unix permission bits |
|
|
192
197
|
| `is_dir` | `bool` | Whether the member is a directory |
|
|
193
|
-
| `raw` | `ZipInfo \| TarInfo \| RarInfo` | Original backend object (
|
|
198
|
+
| `raw` | `ZipInfo \| TarInfo \| RarInfo \| None` | Original backend object (`None` for plain compressed files) |
|
|
194
199
|
|
|
195
200
|
Factory methods for direct conversion (you shouldn't need these in normal usage):
|
|
196
201
|
|
|
@@ -5,8 +5,9 @@ from datetime import datetime
|
|
|
5
5
|
from io import TextIOWrapper
|
|
6
6
|
from os import path
|
|
7
7
|
from pathlib import Path
|
|
8
|
+
from shutil import copyfileobj
|
|
8
9
|
from types import TracebackType
|
|
9
|
-
from typing import IO, Iterator, List, Literal, TypeAlias
|
|
10
|
+
from typing import IO, Iterator, List, Literal, TypeAlias, cast
|
|
10
11
|
|
|
11
12
|
try:
|
|
12
13
|
import bz2 as bzip2
|
|
@@ -37,6 +38,20 @@ except ImportError:
|
|
|
37
38
|
else:
|
|
38
39
|
zstd_available = True
|
|
39
40
|
|
|
41
|
+
try:
|
|
42
|
+
if sys.version_info < (3, 14):
|
|
43
|
+
from backports import zstd # ty: ignore[unresolved-import]
|
|
44
|
+
else:
|
|
45
|
+
from compression import zstd
|
|
46
|
+
except ImportError:
|
|
47
|
+
zstd = None # ty: ignore
|
|
48
|
+
|
|
49
|
+
BadCompression = (OSError, EOFError, ValueError)
|
|
50
|
+
if lzma is not None:
|
|
51
|
+
BadCompression += (lzma.LZMAError,)
|
|
52
|
+
if zstd is not None:
|
|
53
|
+
BadCompression += (zstd.ZstdError,)
|
|
54
|
+
|
|
40
55
|
|
|
41
56
|
# Type Aliases
|
|
42
57
|
if rarfile is not None:
|
|
@@ -61,7 +76,7 @@ Algos: TypeAlias = Literal[
|
|
|
61
76
|
]
|
|
62
77
|
|
|
63
78
|
|
|
64
|
-
class BadArchiveError(
|
|
79
|
+
class BadArchiveError(RuntimeError):
|
|
65
80
|
"""Custom exception for handling bad or unsupported archive files."""
|
|
66
81
|
|
|
67
82
|
|
|
@@ -83,7 +98,7 @@ class ArchiveMemberInfo:
|
|
|
83
98
|
|
|
84
99
|
The `raw` attribute provides direct access to the underlying backend-specific
|
|
85
100
|
info object (ZipInfo, TarInfo, or RarInfo) when advanced or format-specific
|
|
86
|
-
features are needed.
|
|
101
|
+
features are needed. It is None for single-file compressed formats.
|
|
87
102
|
"""
|
|
88
103
|
|
|
89
104
|
name: str
|
|
@@ -92,7 +107,7 @@ class ArchiveMemberInfo:
|
|
|
92
107
|
mtime: float
|
|
93
108
|
mode: int | None
|
|
94
109
|
is_dir: bool
|
|
95
|
-
raw: InfoType
|
|
110
|
+
raw: InfoType | None
|
|
96
111
|
|
|
97
112
|
@classmethod
|
|
98
113
|
def from_zipinfo(cls, info: zipfile.ZipInfo) -> "ArchiveMemberInfo":
|
|
@@ -171,8 +186,10 @@ class Archive:
|
|
|
171
186
|
self.mode = mode
|
|
172
187
|
self.compression_level = compression_level
|
|
173
188
|
self.algo = algo
|
|
174
|
-
self._archive: ArchiveType | None = None
|
|
175
|
-
self._archive_type:
|
|
189
|
+
self._archive: ArchiveType | IO[bytes] | None = None
|
|
190
|
+
self._archive_type: (
|
|
191
|
+
Literal["zip", "rar", "tar", "gz", "bz2", "xz", "zst"] | None
|
|
192
|
+
) = None
|
|
176
193
|
self._compress_file_obj: CompressFileObjType | None = None
|
|
177
194
|
self.strict_timestamps = strict_timestamps
|
|
178
195
|
|
|
@@ -281,11 +298,14 @@ class Archive:
|
|
|
281
298
|
FileNotFoundError: If the archive file doesn't exist
|
|
282
299
|
NotImplementedError: If the archive is password-protected (for ZIP and RAR)
|
|
283
300
|
ValueError: If the file is not a valid ZIP, RAR, or TAR archive
|
|
301
|
+
BadArchiveError: If a compressed file has invalid contents
|
|
284
302
|
RuntimeError: If the file is not readable or is not a file
|
|
285
303
|
""" # noqa: DOC502
|
|
286
304
|
# first check if it is readable at all
|
|
305
|
+
if not path.exists(self.filename):
|
|
306
|
+
raise FileNotFoundError(f"File '{self.filename}' does not exist")
|
|
287
307
|
if not path.isfile(self.filename):
|
|
288
|
-
raise RuntimeError(f"File '{self.filename}'
|
|
308
|
+
raise RuntimeError(f"File '{self.filename}' is not a regular file")
|
|
289
309
|
|
|
290
310
|
# Try ZIP first
|
|
291
311
|
try:
|
|
@@ -293,7 +313,9 @@ class Archive:
|
|
|
293
313
|
# Check for password protection
|
|
294
314
|
if any(zinfo.flag_bits & 0x1 for zinfo in archive.infolist()):
|
|
295
315
|
archive.close()
|
|
296
|
-
raise NotImplementedError(
|
|
316
|
+
raise NotImplementedError(
|
|
317
|
+
"Password-protected ZIP files are not supported"
|
|
318
|
+
)
|
|
297
319
|
self._archive = archive
|
|
298
320
|
self._archive_type = "zip"
|
|
299
321
|
return
|
|
@@ -306,7 +328,9 @@ class Archive:
|
|
|
306
328
|
# Check for password protection
|
|
307
329
|
if archive.needs_password():
|
|
308
330
|
archive.close()
|
|
309
|
-
raise NotImplementedError(
|
|
331
|
+
raise NotImplementedError(
|
|
332
|
+
"Password-protected RAR files are not supported"
|
|
333
|
+
)
|
|
310
334
|
self._archive = archive
|
|
311
335
|
self._archive_type = "rar"
|
|
312
336
|
return
|
|
@@ -321,6 +345,25 @@ class Archive:
|
|
|
321
345
|
except tarfile.TarError:
|
|
322
346
|
pass
|
|
323
347
|
|
|
348
|
+
# A compressed file without a TAR payload contains exactly one file.
|
|
349
|
+
suffix = Path(self.filename).suffix.lower()
|
|
350
|
+
if suffix in (".gz", ".bz2", ".xz", ".zst", ".zstd"):
|
|
351
|
+
kind = {
|
|
352
|
+
".gz": "gz",
|
|
353
|
+
".bz2": "bz2",
|
|
354
|
+
".xz": "xz",
|
|
355
|
+
".zst": "zst",
|
|
356
|
+
".zstd": "zst",
|
|
357
|
+
}[suffix]
|
|
358
|
+
try:
|
|
359
|
+
with self._open_compressed(kind) as stream:
|
|
360
|
+
stream.read(1) # Verify the stream before accepting the format.
|
|
361
|
+
except BadCompression as exc:
|
|
362
|
+
raise BadArchiveError(f"Failed to open compressed file. {exc}") from exc
|
|
363
|
+
self._archive = self._open_compressed(kind)
|
|
364
|
+
self._archive_type = cast(Literal["gz", "bz2", "xz", "zst"], kind)
|
|
365
|
+
return
|
|
366
|
+
|
|
324
367
|
raise ValueError(
|
|
325
368
|
f"Cannot open '{self.filename}': not a valid ZIP, RAR, or TAR archive"
|
|
326
369
|
)
|
|
@@ -333,6 +376,24 @@ class Archive:
|
|
|
333
376
|
"""
|
|
334
377
|
filename_lower = self.filename.lower()
|
|
335
378
|
|
|
379
|
+
if (
|
|
380
|
+
self.algo is None
|
|
381
|
+
and filename_lower.endswith((
|
|
382
|
+
".gz",
|
|
383
|
+
".bz2",
|
|
384
|
+
".xz",
|
|
385
|
+
".zst",
|
|
386
|
+
".zstd",
|
|
387
|
+
))
|
|
388
|
+
and not filename_lower.endswith((
|
|
389
|
+
".tar.gz",
|
|
390
|
+
".tar.bz2",
|
|
391
|
+
".tar.xz",
|
|
392
|
+
".tar.zst",
|
|
393
|
+
))
|
|
394
|
+
):
|
|
395
|
+
raise ValueError("Writing single-file compressed formats is not supported")
|
|
396
|
+
|
|
336
397
|
if filename_lower.endswith(ArchiveExtensions.zip) or (self.algo == "zip"):
|
|
337
398
|
self._archive_type = "zip"
|
|
338
399
|
if self.compression_level is not None:
|
|
@@ -450,6 +511,19 @@ class Archive:
|
|
|
450
511
|
|
|
451
512
|
try:
|
|
452
513
|
match self._archive_type:
|
|
514
|
+
case "gz" | "bz2" | "xz" | "zst":
|
|
515
|
+
stat = Path(self.filename).stat()
|
|
516
|
+
return [
|
|
517
|
+
ArchiveMemberInfo(
|
|
518
|
+
name=self._single_member_name(),
|
|
519
|
+
uncompressed_size=self.size,
|
|
520
|
+
compressed_size=stat.st_size,
|
|
521
|
+
mtime=stat.st_mtime,
|
|
522
|
+
mode=stat.st_mode & 0o777,
|
|
523
|
+
is_dir=False,
|
|
524
|
+
raw=None,
|
|
525
|
+
)
|
|
526
|
+
]
|
|
453
527
|
case "rar":
|
|
454
528
|
assert isinstance(self._archive, rarfile.RarFile)
|
|
455
529
|
return [
|
|
@@ -489,6 +563,8 @@ class Archive:
|
|
|
489
563
|
|
|
490
564
|
try:
|
|
491
565
|
match self._archive_type:
|
|
566
|
+
case "gz" | "bz2" | "xz" | "zst":
|
|
567
|
+
return [self._single_member_name()]
|
|
492
568
|
case "zip":
|
|
493
569
|
assert isinstance(self._archive, zipfile.ZipFile)
|
|
494
570
|
return self._archive.namelist()
|
|
@@ -525,10 +601,19 @@ class Archive:
|
|
|
525
601
|
if not self._archive:
|
|
526
602
|
raise RuntimeError("Archive not opened")
|
|
527
603
|
if isinstance(member, ArchiveMemberInfo):
|
|
528
|
-
member = member.raw
|
|
604
|
+
member = member.raw if member.raw is not None else member.name
|
|
529
605
|
|
|
530
606
|
try:
|
|
531
607
|
match self._archive_type:
|
|
608
|
+
case "gz" | "bz2" | "xz" | "zst":
|
|
609
|
+
source = self.open(member)
|
|
610
|
+
assert source is not None
|
|
611
|
+
with source:
|
|
612
|
+
destination = Path(path or ".") / self._single_member_name()
|
|
613
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
614
|
+
with destination.open("wb") as target:
|
|
615
|
+
copyfileobj(source, target)
|
|
616
|
+
return str(destination)
|
|
532
617
|
case "rar":
|
|
533
618
|
assert isinstance(self._archive, rarfile.RarFile)
|
|
534
619
|
if isinstance(member, rarfile.RarInfo):
|
|
@@ -583,14 +668,24 @@ class Archive:
|
|
|
583
668
|
ValueError: If a RAR file is attempted to be opened in anything that isn't read mode
|
|
584
669
|
BadArchiveError: If the member cannot be opened due to archive related errors
|
|
585
670
|
FileNotFoundError: If the file is no longer available
|
|
671
|
+
KeyError: If the requested member is not in a single-file compressed file
|
|
586
672
|
"""
|
|
587
673
|
if not self._archive:
|
|
588
674
|
raise RuntimeError("Archive not opened")
|
|
589
675
|
if isinstance(member, ArchiveMemberInfo):
|
|
590
|
-
member = member.raw
|
|
676
|
+
member = member.raw if member.raw is not None else member.name
|
|
591
677
|
|
|
592
678
|
try:
|
|
593
679
|
match self._archive_type:
|
|
680
|
+
case "gz" | "bz2" | "xz" | "zst":
|
|
681
|
+
if mode != "r":
|
|
682
|
+
raise ValueError(
|
|
683
|
+
"Compressed members can only be opened in read mode"
|
|
684
|
+
)
|
|
685
|
+
if member != self._single_member_name():
|
|
686
|
+
raise KeyError(member)
|
|
687
|
+
assert self._archive_type is not None
|
|
688
|
+
return self._open_compressed(self._archive_type)
|
|
594
689
|
case "zip":
|
|
595
690
|
assert isinstance(self._archive, zipfile.ZipFile)
|
|
596
691
|
member_arg = (
|
|
@@ -647,6 +742,14 @@ class Archive:
|
|
|
647
742
|
raise RuntimeError("Archive not opened")
|
|
648
743
|
|
|
649
744
|
match self._archive_type:
|
|
745
|
+
case "gz" | "bz2" | "xz" | "zst":
|
|
746
|
+
stream = self.open(self._single_member_name())
|
|
747
|
+
assert stream is not None
|
|
748
|
+
with stream:
|
|
749
|
+
return sum(
|
|
750
|
+
len(chunk)
|
|
751
|
+
for chunk in iter(lambda: stream.read(1024 * 1024), b"")
|
|
752
|
+
)
|
|
650
753
|
case "zip":
|
|
651
754
|
assert isinstance(self._archive, zipfile.ZipFile)
|
|
652
755
|
return sum(info.file_size for info in self._archive.infolist())
|
|
@@ -746,6 +849,7 @@ class Archive:
|
|
|
746
849
|
|
|
747
850
|
Raises:
|
|
748
851
|
RuntimeError: If archive is not opened
|
|
852
|
+
KeyError: If the requested member is not in a single-file compressed file
|
|
749
853
|
"""
|
|
750
854
|
if not self._archive:
|
|
751
855
|
raise RuntimeError("Archive not opened")
|
|
@@ -753,6 +857,10 @@ class Archive:
|
|
|
753
857
|
return member.is_dir
|
|
754
858
|
|
|
755
859
|
match self._archive_type:
|
|
860
|
+
case "gz" | "bz2" | "xz" | "zst":
|
|
861
|
+
if member != self._single_member_name():
|
|
862
|
+
raise KeyError(member)
|
|
863
|
+
return False
|
|
756
864
|
case "zip":
|
|
757
865
|
assert isinstance(self._archive, zipfile.ZipFile)
|
|
758
866
|
info = (
|
|
@@ -806,6 +914,8 @@ class Archive:
|
|
|
806
914
|
"""
|
|
807
915
|
if not self._archive:
|
|
808
916
|
raise RuntimeError("Archive not opened")
|
|
917
|
+
if self._archive_type in ("gz", "bz2", "xz", "zst"):
|
|
918
|
+
raise ValueError("Cannot add files to a single-file compressed format")
|
|
809
919
|
if self.mode not in ("w", "a"):
|
|
810
920
|
raise ValueError(
|
|
811
921
|
"Archive must be opened in write ('w') or append ('a') mode"
|
|
@@ -823,3 +933,29 @@ class Archive:
|
|
|
823
933
|
case _:
|
|
824
934
|
assert isinstance(self._archive, tarfile.TarFile)
|
|
825
935
|
self._archive.add(file, arcname=arcname)
|
|
936
|
+
|
|
937
|
+
def _single_member_name(self) -> str:
|
|
938
|
+
return Path(self.filename).name.rsplit(".", 1)[0]
|
|
939
|
+
|
|
940
|
+
def _open_compressed(self, kind: str) -> IO[bytes]:
|
|
941
|
+
match kind:
|
|
942
|
+
case "gz":
|
|
943
|
+
if gzip is None:
|
|
944
|
+
raise ModuleNotFoundError("gzip module is not available")
|
|
945
|
+
return cast(IO[bytes], gzip.open(self.filename, "rb"))
|
|
946
|
+
case "bz2":
|
|
947
|
+
if bzip2 is None:
|
|
948
|
+
raise ModuleNotFoundError("bzip2 module is not available")
|
|
949
|
+
return cast(IO[bytes], bzip2.open(self.filename, "rb"))
|
|
950
|
+
case "xz":
|
|
951
|
+
if lzma is None:
|
|
952
|
+
raise ModuleNotFoundError("lzma module is not available")
|
|
953
|
+
return cast(IO[bytes], lzma.open(self.filename, "rb"))
|
|
954
|
+
case "zst":
|
|
955
|
+
if zstd is None:
|
|
956
|
+
raise ModuleNotFoundError(
|
|
957
|
+
"Zstandard support requires Python 3.14 or backports-zstd"
|
|
958
|
+
)
|
|
959
|
+
return cast(IO[bytes], zstd.open(self.filename, "rb"))
|
|
960
|
+
case _:
|
|
961
|
+
raise ValueError(f"Unknown compression format: {kind}")
|
|
File without changes
|