multiarchive 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.
@@ -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,260 @@
1
+ # multiarchive
2
+
3
+ A high level abstraction of multiple archive formats
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ uv add multiarchive
9
+ ```
10
+
11
+ Optional dependency for RAR support:
12
+
13
+ ```bash
14
+ uv add rarfile
15
+ ```
16
+
17
+ ZStandard support requires the `backports-zstd` package on Python <3.14:
18
+
19
+ ```bash
20
+ uv add "backports-zstd; python_version < '3.14'"
21
+ ```
22
+
23
+ ## Quick Start
24
+
25
+ ```python
26
+ from multiarchive import Archive
27
+
28
+ # Open and inspect
29
+ with Archive("archive.zip") as arc:
30
+ print(arc.namelist()) # list of member names
31
+ print(len(arc)) # number of members
32
+ print(arc.size) # total uncompressed size in bytes
33
+
34
+ # Extract a single file
35
+ with Archive("archive.tar.gz") as arc:
36
+ arc.extract("docs/readme.md", path="./output")
37
+
38
+ # Read file contents without extracting
39
+ with Archive("archive.zip") as arc:
40
+ f = arc.open("config.json")
41
+ content = f.read()
42
+ f.close()
43
+
44
+ # Iterate over members
45
+ with Archive("archive.zip") as arc:
46
+ for name in arc:
47
+ if arc.is_file(name):
48
+ print(f" {name}")
49
+ ```
50
+
51
+ ## Supported Formats
52
+
53
+ | Format | Read | Write | Extensions |
54
+ | ------- | ---- | ----- | --------------------------- |
55
+ | ZIP | Yes | Yes | `.zip` |
56
+ | TAR | Yes | Yes | `.tar` |
57
+ | TAR.GZ | Yes | Yes | `.tgz`, `.tar.gz` |
58
+ | TAR.BZ2 | Yes | Yes | `.tbz`, `.tbz2`, `.tar.bz2` |
59
+ | TAR.XZ | Yes | Yes | `.tar.xz`, `.tar.lzma` |
60
+ | TAR.ZST | Yes | Yes | `.tzst`, `.tar.zst` |
61
+ | RAR | Yes | No | `.rar` |
62
+
63
+ RAR support requires the `rarfile` package and an external `unrar` tool.
64
+
65
+ ## Reading Archives
66
+
67
+ ### Opening
68
+
69
+ Use the context manager (recommended):
70
+
71
+ ```python
72
+ with Archive("file.zip") as arc:
73
+ ...
74
+ ```
75
+
76
+ Or the classmethod:
77
+
78
+ ```python
79
+ arc = Archive.open_archive("file.zip")
80
+ try:
81
+ arc.namelist()
82
+ finally:
83
+ arc.close()
84
+ ```
85
+
86
+ Reading is supported on all formats, but password-protected archives are not supported and will raise a `ValueError`.
87
+ 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.
88
+
89
+ ### Listing Members
90
+
91
+ ```python
92
+ with Archive("file.zip") as arc:
93
+ names = arc.namelist() # Just names
94
+ names = arc.members # alias to namelist
95
+
96
+ # extra info (ArchiveMemberInfo objects)
97
+ for info in arc.infolist():
98
+ print(f"{info.name}: {info.uncompressed_size} bytes, mtime={info.mtime}")
99
+ ```
100
+
101
+ ### Checking Membership
102
+
103
+ ```python
104
+ with Archive("file.zip") as arc:
105
+ if "config.json" in arc:
106
+ print("Found!")
107
+
108
+ for name in arc:
109
+ if arc.is_file(name):
110
+ ...
111
+ if arc.is_dir(name):
112
+ ...
113
+ ```
114
+
115
+ ### Extracting
116
+
117
+ ```python
118
+ with Archive("file.tar.gz") as arc:
119
+ # Single file
120
+ arc.extract("src/main.py", path="./extracted")
121
+ ```
122
+
123
+ ### Reading Member Contents
124
+
125
+ ```python
126
+ with Archive("file.zip") as arc:
127
+ f = arc.open("data.json")
128
+ if f is not None:
129
+ content = f.read()
130
+ f.close()
131
+ ```
132
+
133
+ ## Writing Archives
134
+
135
+ ### Creating a New Archive
136
+
137
+ ```python
138
+ from multiarchive import Archive
139
+
140
+ # ZIP
141
+ with Archive("output.zip", mode="w") as arc:
142
+ ...
143
+
144
+ # TAR.GZ with compression level
145
+ with Archive("output.tar.gz", mode="w", compression_level=9) as arc:
146
+ ...
147
+ ```
148
+
149
+ The format is determined by file extension. Use the `algo` parameter to override:
150
+
151
+ ```python
152
+ with Archive("my_archive", mode="w", algo="tar.xz") as arc:
153
+ ...
154
+ ```
155
+
156
+ ### Compression Levels
157
+
158
+ | Format | Range | Default |
159
+ | ------- | ----- | ------- |
160
+ | ZIP | 0–9 | 6 |
161
+ | TAR.GZ | 0–9 | 6 |
162
+ | TAR.BZ2 | 1–9 | 9 |
163
+ | TAR.XZ | 0–9 | 6 |
164
+ | TAR.ZST | 1–22 | 3 |
165
+
166
+ ## File Information Object
167
+
168
+ The `infolist()` method returns `ArchiveMemberInfo` objects with a unified interface across all formats:
169
+
170
+ | Field | Type | Description |
171
+ | ------------------- | ------------------------------- | -------------------------------------- |
172
+ | `name` | `str` | Member filename |
173
+ | `uncompressed_size` | `int` | Original file size in bytes |
174
+ | `compressed_size` | `int \| None` | Compressed size (`None` for TAR) |
175
+ | `mtime` | `float` | Modification time as epoch seconds |
176
+ | `mode` | `int \| None` | Unix permission bits |
177
+ | `is_dir` | `bool` | Whether the member is a directory |
178
+ | `raw` | `ZipInfo \| TarInfo \| RarInfo` | Original backend object (escape hatch) |
179
+
180
+ Factory methods for direct conversion (you shouldn't need these in normal usage):
181
+
182
+ ```python
183
+ info = ArchiveMemberInfo.from_zipinfo(zip_info)
184
+ info = ArchiveMemberInfo.from_tarinfo(tar_info)
185
+ info = ArchiveMemberInfo.from_rarinfo(rar_info)
186
+ ```
187
+
188
+ ## Properties
189
+
190
+ | Method/Property | Description |
191
+ | ----------------------- | --------------------------------------------------------------- |
192
+ | `members` | Alias for `namelist()` |
193
+ | `size` | Total uncompressed size of all members |
194
+ | `comment` / `comment=` | Get/set archive comment (ZIP only, raises ValueError otherwise) |
195
+
196
+ ## Error Handling
197
+
198
+ ```python
199
+ from multiarchive import Archive, BadArchiveError
200
+
201
+ try:
202
+ with Archive("corrupted.zip") as arc:
203
+ arc.namelist()
204
+ except BadArchiveError as e:
205
+ print(f"Archive error: {e}")
206
+ except NotImplementedError as e:
207
+ # password-protected (not supported)
208
+ print(f"Not implemented: {e}")
209
+ except ValueError as e:
210
+ # Unknown format
211
+ print(f"Value error: {e}")
212
+ except FileNotFoundError:
213
+ print("File not found")
214
+ ```
215
+
216
+ ## API Reference
217
+
218
+ ### `Archive`
219
+
220
+ ```python
221
+ Archive(
222
+ filename: str | Path,
223
+ mode: str = "r",
224
+ compression_level: int | None = None,
225
+ )
226
+ ```
227
+
228
+ | Parameter | Description |
229
+ | ------------------- | ------------------------------------------------- |
230
+ | `filename` | Path to the archive file |
231
+ | `mode` | `'r'` for read, `'w'` for write, `'a'` for append |
232
+ | `compression_level` | Compression level (format-dependent) |
233
+
234
+ #### Methods
235
+
236
+ | Method | Description |
237
+ | ------------------------------------------------------ | -------------------------------------- |
238
+ | `open_archive(cls, filename, mode, compression_level)` | Factory method, returns opened archive |
239
+ | `namelist()` | List of member names |
240
+ | `infolist()` | List of `ArchiveMemberInfo` objects |
241
+ | `extract(member, path)` | Extract single member |
242
+ | `open(member, mode)` | Open member as file-like object |
243
+ | `is_dir(member)` | Check if member is a directory |
244
+ | `is_file(member)` | Check if member is a file |
245
+ | `close()` | Explicitly close the archive |
246
+
247
+ ### `BadArchiveError`
248
+
249
+ Raised when an archive file is corrupt or in an unsupported format.
250
+
251
+ ### `ArchiveExtensions`
252
+
253
+ Frozen dataclass mapping format names to their common file extensions:
254
+
255
+ ```python
256
+ ArchiveExtensions.zip # (".zip",)
257
+ ArchiveExtensions.gz # (".tgz", ".tar.gz")
258
+ ArchiveExtensions.xz # (".tar.xz", ".tar.lzma")
259
+ # ... etc
260
+ ```
@@ -0,0 +1,88 @@
1
+ [project]
2
+ name = "multiarchive"
3
+ version = "0.1.0"
4
+ description = "A high level archive handler for Python."
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "NSPC911", email = "87571998+NSPC911@users.noreply.github.com" }
8
+ ]
9
+ requires-python = ">=3.11"
10
+ dependencies = []
11
+
12
+ [build-system]
13
+ requires = ["uv_build>=0.11.8,<0.12.0"]
14
+ build-backend = "uv_build"
15
+
16
+ [dependency-groups]
17
+ dev = [
18
+ "backports-zstd>=1.3.0 ; python_full_version < '3.14'",
19
+ "poethepoet>=0.45.0",
20
+ "pytest>=9.0.3",
21
+ "pytest-xdist>=3.8.0",
22
+ "rarfile>=4.2",
23
+ "ruff>=0.15.12",
24
+ "ty==0.0.32",
25
+ ]
26
+
27
+ [tool.poe.tasks]
28
+ check.help = "Run all checks (type checking and linting)"
29
+ check.sequence = [ { cmd = "ty check" },{ cmd = "ruff check" } ]
30
+
31
+ fmt.help = ""
32
+ fmt.ref = "format"
33
+ format.help = "Run ruff related formatters"
34
+ format.sequence = [ { cmd = "ruff check --unsafe-fixes --fix"}, { cmd = "ruff format"} ]
35
+
36
+ [tool.poe.tasks.test]
37
+ help = "Run tests"
38
+ cmd = "pytest -n ${workers} ${FILES}"
39
+ args = [
40
+ { name = "FILES", help = "Specific test files or directories to run (optional)", type = "string", positional = true, required = false, multiple = true },
41
+ { name = "workers", help = "Number of parallel workers for test execution (optional)", default = 4, type = "integer", options = ["-n"] },
42
+ ]
43
+
44
+ [tool.ruff.lint]
45
+ select = [
46
+ "ASYNC220", "ASYNC221", "ASYNC251",
47
+ "ANN",
48
+ "COM819",
49
+ "C400",
50
+ "DOC",
51
+ "D404",
52
+ "E",
53
+ "F",
54
+ "I",
55
+ "N801", "N802", "N805",
56
+ "PLE1142",
57
+ "Q",
58
+ "SIM",
59
+ "TD",
60
+ "W",
61
+ ]
62
+ ignore = [
63
+ "ANN002", "ANN003", "ANN401",
64
+ "E501",
65
+ "TD002", "TD003",
66
+ "W505",
67
+ ]
68
+ preview = true
69
+
70
+ [tool.ruff.format]
71
+ quote-style = "double"
72
+ indent-style = "space"
73
+ line-ending = "auto"
74
+ preview = true
75
+
76
+ [tool.ty.src]
77
+ exclude = [
78
+ "src/rovr/classes/archive.py"
79
+ ] # i dont want to add type indicators
80
+
81
+ [tool.ty.rules]
82
+ # possibly-missing-import = "ignore"
83
+ unresolved-reference = "ignore" # handled by ruff
84
+ unresolved-attribute = "ignore"
85
+ no-matching-overload = "ignore" # not that accurate
86
+ possibly-missing-attribute = "ignore" # no it is not missing
87
+ invalid-assignment = "warn" # probably already but warn for future
88
+ unused-ignore-comment = "ignore"
@@ -0,0 +1,3 @@
1
+ from ._archive import Archive, ArchiveMemberInfo
2
+
3
+ __all__ = ["Archive", "ArchiveMemberInfo"]
@@ -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)