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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: multiarchive
3
- Version: 0.1.4
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 (escape hatch) |
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 (escape hatch) |
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
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "multiarchive"
3
- version = "0.1.4"
3
+ version = "0.2.0"
4
4
  description = "A high level archive handler for Python."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "multiarchive"
3
- version = "0.1.4"
3
+ version = "0.2.0"
4
4
  description = "A high level archive handler for Python."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -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(Exception):
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: Literal["zip", "rar", "tar"] | None = None
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}' does not exist or is not a file")
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("Password-protected ZIP files are not supported")
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("Password-protected RAR files are not supported")
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}")