multiarchive 0.2.0__tar.gz → 0.2.1__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.2.0
3
+ Version: 0.2.1
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>
@@ -31,7 +31,7 @@ uv add "backports-zstd; python_version < '3.14'"
31
31
 
32
32
  ## Quick Start
33
33
 
34
- ```python
34
+ ```py
35
35
  from multiarchive import Archive
36
36
 
37
37
  # Open and inspect
@@ -82,14 +82,14 @@ Plain compressed files expose one member named after the file without its compre
82
82
 
83
83
  Use the context manager (recommended):
84
84
 
85
- ```python
85
+ ```py
86
86
  with Archive("file.zip") as arc:
87
87
  ...
88
88
  ```
89
89
 
90
90
  Or the classmethod:
91
91
 
92
- ```python
92
+ ```py
93
93
  arc = Archive.open_archive("file.zip")
94
94
  try:
95
95
  arc.namelist()
@@ -97,12 +97,30 @@ finally:
97
97
  arc.close()
98
98
  ```
99
99
 
100
- Reading is supported on all formats, but password-protected archives are not supported and will raise a `ValueError`.
100
+ Reading is supported on all formats
101
101
  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.
102
102
 
103
+ #### Passwords
104
+
105
+ ```py
106
+ with Archive("file.zip", pwd=b"secret") as arc:
107
+ ...
108
+ ```
109
+
110
+ classmethod
111
+
112
+ ```py
113
+ arc = Archive.open_archive("file.zip", pwd=b"secret")
114
+ ```
115
+
116
+ Exceptions for passwords:
117
+ - `ValueError`: if archive needs password but none was provided
118
+ - `KeyError`: if provided password is incorrect
119
+ - `TypeError`: if password is provided for a tarball (not supported)
120
+
103
121
  ### Listing Members
104
122
 
105
- ```python
123
+ ```py
106
124
  with Archive("file.zip") as arc:
107
125
  names = arc.namelist() # Just names
108
126
  names = arc.members # alias to namelist
@@ -114,7 +132,7 @@ with Archive("file.zip") as arc:
114
132
 
115
133
  ### Checking Membership
116
134
 
117
- ```python
135
+ ```py
118
136
  with Archive("file.zip") as arc:
119
137
  if "config.json" in arc:
120
138
  print("Found!")
@@ -128,7 +146,7 @@ with Archive("file.zip") as arc:
128
146
 
129
147
  ### Extracting
130
148
 
131
- ```python
149
+ ```py
132
150
  with Archive("file.tar.gz") as arc:
133
151
  # Single file
134
152
  arc.extract("src/main.py", path="./extracted")
@@ -136,7 +154,7 @@ with Archive("file.tar.gz") as arc:
136
154
 
137
155
  ### Reading Member Contents
138
156
 
139
- ```python
157
+ ```py
140
158
  with Archive("file.zip") as arc:
141
159
  f = arc.open("data.json")
142
160
  if f is not None:
@@ -148,7 +166,7 @@ with Archive("file.zip") as arc:
148
166
 
149
167
  ### Creating a New Archive
150
168
 
151
- ```python
169
+ ```py
152
170
  from multiarchive import Archive
153
171
 
154
172
  # ZIP
@@ -162,7 +180,7 @@ with Archive("output.tar.gz", mode="w", compression_level=9) as arc:
162
180
 
163
181
  Add members with an optional name to use inside the archive:
164
182
 
165
- ```python
183
+ ```py
166
184
  with Archive("output.zip", mode="w") as arc:
167
185
  arc.add("src/main.py", arcname="main.py")
168
186
  ```
@@ -170,14 +188,14 @@ with Archive("output.zip", mode="w") as arc:
170
188
  ZIP timestamps are limited to the years 1980 through 2107. To clamp timestamps
171
189
  outside that range instead of raising an error:
172
190
 
173
- ```python
191
+ ```py
174
192
  with Archive("output.zip", mode="w", strict_timestamps=False) as arc:
175
193
  arc.add("old-file.txt")
176
194
  ```
177
195
 
178
196
  The format is determined by file extension. Use the `algo` parameter to override:
179
197
 
180
- ```python
198
+ ```py
181
199
  with Archive("my_archive", mode="w", algo="tar.xz") as arc:
182
200
  ...
183
201
  ```
@@ -196,19 +214,19 @@ with Archive("my_archive", mode="w", algo="tar.xz") as arc:
196
214
 
197
215
  The `infolist()` method returns `ArchiveMemberInfo` objects with a unified interface across all formats:
198
216
 
199
- | Field | Type | Description |
200
- | ------------------- | ------------------------------- | -------------------------------------- |
201
- | `name` | `str` | Member filename |
202
- | `uncompressed_size` | `int` | Original file size in bytes |
203
- | `compressed_size` | `int \| None` | Compressed size (`None` for TAR) |
204
- | `mtime` | `float` | Modification time as epoch seconds |
205
- | `mode` | `int \| None` | Unix permission bits |
206
- | `is_dir` | `bool` | Whether the member is a directory |
217
+ | Field | Type | Description |
218
+ | ------------------- | --------------------------------------- | ----------------------------------------------------------- |
219
+ | `name` | `str` | Member filename |
220
+ | `uncompressed_size` | `int` | Original file size in bytes |
221
+ | `compressed_size` | `int \| None` | Compressed size (`None` for TAR) |
222
+ | `mtime` | `float` | Modification time as epoch seconds |
223
+ | `mode` | `int \| None` | Unix permission bits |
224
+ | `is_dir` | `bool` | Whether the member is a directory |
207
225
  | `raw` | `ZipInfo \| TarInfo \| RarInfo \| None` | Original backend object (`None` for plain compressed files) |
208
226
 
209
227
  Factory methods for direct conversion (you shouldn't need these in normal usage):
210
228
 
211
- ```python
229
+ ```py
212
230
  info = ArchiveMemberInfo.from_zipinfo(zip_info)
213
231
  info = ArchiveMemberInfo.from_tarinfo(tar_info)
214
232
  info = ArchiveMemberInfo.from_rarinfo(rar_info)
@@ -216,15 +234,15 @@ info = ArchiveMemberInfo.from_rarinfo(rar_info)
216
234
 
217
235
  ## Properties
218
236
 
219
- | Method/Property | Description |
220
- | ----------------------- | --------------------------------------------------------------- |
221
- | `members` | Alias for `namelist()` |
222
- | `size` | Total uncompressed size of all members |
223
- | `comment` / `comment=` | Get/set archive comment (ZIP only, raises ValueError otherwise) |
237
+ | Method/Property | Description |
238
+ | ---------------------- | --------------------------------------------------------------- |
239
+ | `members` | Alias for `namelist()` |
240
+ | `size` | Total uncompressed size of all members |
241
+ | `comment` / `comment=` | Get/set archive comment (ZIP only, raises ValueError otherwise) |
224
242
 
225
243
  ## Error Handling
226
244
 
227
- ```python
245
+ ```py
228
246
  from multiarchive import Archive, BadArchiveError
229
247
 
230
248
  try:
@@ -246,7 +264,7 @@ except FileNotFoundError:
246
264
 
247
265
  ### `Archive`
248
266
 
249
- ```python
267
+ ```py
250
268
  Archive(
251
269
  filename: str | Path,
252
270
  mode: str = "r",
@@ -281,7 +299,7 @@ Raised when an archive file is corrupt or in an unsupported format.
281
299
 
282
300
  Frozen dataclass mapping format names to their common file extensions:
283
301
 
284
- ```python
302
+ ```py
285
303
  ArchiveExtensions.zip # (".zip",)
286
304
  ArchiveExtensions.gz # (".tgz", ".tar.gz")
287
305
  ArchiveExtensions.xz # (".tar.xz", ".tar.lzma")
@@ -22,7 +22,7 @@ uv add "backports-zstd; python_version < '3.14'"
22
22
 
23
23
  ## Quick Start
24
24
 
25
- ```python
25
+ ```py
26
26
  from multiarchive import Archive
27
27
 
28
28
  # Open and inspect
@@ -73,14 +73,14 @@ Plain compressed files expose one member named after the file without its compre
73
73
 
74
74
  Use the context manager (recommended):
75
75
 
76
- ```python
76
+ ```py
77
77
  with Archive("file.zip") as arc:
78
78
  ...
79
79
  ```
80
80
 
81
81
  Or the classmethod:
82
82
 
83
- ```python
83
+ ```py
84
84
  arc = Archive.open_archive("file.zip")
85
85
  try:
86
86
  arc.namelist()
@@ -88,12 +88,30 @@ finally:
88
88
  arc.close()
89
89
  ```
90
90
 
91
- Reading is supported on all formats, but password-protected archives are not supported and will raise a `ValueError`.
91
+ Reading is supported on all formats
92
92
  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.
93
93
 
94
+ #### Passwords
95
+
96
+ ```py
97
+ with Archive("file.zip", pwd=b"secret") as arc:
98
+ ...
99
+ ```
100
+
101
+ classmethod
102
+
103
+ ```py
104
+ arc = Archive.open_archive("file.zip", pwd=b"secret")
105
+ ```
106
+
107
+ Exceptions for passwords:
108
+ - `ValueError`: if archive needs password but none was provided
109
+ - `KeyError`: if provided password is incorrect
110
+ - `TypeError`: if password is provided for a tarball (not supported)
111
+
94
112
  ### Listing Members
95
113
 
96
- ```python
114
+ ```py
97
115
  with Archive("file.zip") as arc:
98
116
  names = arc.namelist() # Just names
99
117
  names = arc.members # alias to namelist
@@ -105,7 +123,7 @@ with Archive("file.zip") as arc:
105
123
 
106
124
  ### Checking Membership
107
125
 
108
- ```python
126
+ ```py
109
127
  with Archive("file.zip") as arc:
110
128
  if "config.json" in arc:
111
129
  print("Found!")
@@ -119,7 +137,7 @@ with Archive("file.zip") as arc:
119
137
 
120
138
  ### Extracting
121
139
 
122
- ```python
140
+ ```py
123
141
  with Archive("file.tar.gz") as arc:
124
142
  # Single file
125
143
  arc.extract("src/main.py", path="./extracted")
@@ -127,7 +145,7 @@ with Archive("file.tar.gz") as arc:
127
145
 
128
146
  ### Reading Member Contents
129
147
 
130
- ```python
148
+ ```py
131
149
  with Archive("file.zip") as arc:
132
150
  f = arc.open("data.json")
133
151
  if f is not None:
@@ -139,7 +157,7 @@ with Archive("file.zip") as arc:
139
157
 
140
158
  ### Creating a New Archive
141
159
 
142
- ```python
160
+ ```py
143
161
  from multiarchive import Archive
144
162
 
145
163
  # ZIP
@@ -153,7 +171,7 @@ with Archive("output.tar.gz", mode="w", compression_level=9) as arc:
153
171
 
154
172
  Add members with an optional name to use inside the archive:
155
173
 
156
- ```python
174
+ ```py
157
175
  with Archive("output.zip", mode="w") as arc:
158
176
  arc.add("src/main.py", arcname="main.py")
159
177
  ```
@@ -161,14 +179,14 @@ with Archive("output.zip", mode="w") as arc:
161
179
  ZIP timestamps are limited to the years 1980 through 2107. To clamp timestamps
162
180
  outside that range instead of raising an error:
163
181
 
164
- ```python
182
+ ```py
165
183
  with Archive("output.zip", mode="w", strict_timestamps=False) as arc:
166
184
  arc.add("old-file.txt")
167
185
  ```
168
186
 
169
187
  The format is determined by file extension. Use the `algo` parameter to override:
170
188
 
171
- ```python
189
+ ```py
172
190
  with Archive("my_archive", mode="w", algo="tar.xz") as arc:
173
191
  ...
174
192
  ```
@@ -187,19 +205,19 @@ with Archive("my_archive", mode="w", algo="tar.xz") as arc:
187
205
 
188
206
  The `infolist()` method returns `ArchiveMemberInfo` objects with a unified interface across all formats:
189
207
 
190
- | Field | Type | Description |
191
- | ------------------- | ------------------------------- | -------------------------------------- |
192
- | `name` | `str` | Member filename |
193
- | `uncompressed_size` | `int` | Original file size in bytes |
194
- | `compressed_size` | `int \| None` | Compressed size (`None` for TAR) |
195
- | `mtime` | `float` | Modification time as epoch seconds |
196
- | `mode` | `int \| None` | Unix permission bits |
197
- | `is_dir` | `bool` | Whether the member is a directory |
208
+ | Field | Type | Description |
209
+ | ------------------- | --------------------------------------- | ----------------------------------------------------------- |
210
+ | `name` | `str` | Member filename |
211
+ | `uncompressed_size` | `int` | Original file size in bytes |
212
+ | `compressed_size` | `int \| None` | Compressed size (`None` for TAR) |
213
+ | `mtime` | `float` | Modification time as epoch seconds |
214
+ | `mode` | `int \| None` | Unix permission bits |
215
+ | `is_dir` | `bool` | Whether the member is a directory |
198
216
  | `raw` | `ZipInfo \| TarInfo \| RarInfo \| None` | Original backend object (`None` for plain compressed files) |
199
217
 
200
218
  Factory methods for direct conversion (you shouldn't need these in normal usage):
201
219
 
202
- ```python
220
+ ```py
203
221
  info = ArchiveMemberInfo.from_zipinfo(zip_info)
204
222
  info = ArchiveMemberInfo.from_tarinfo(tar_info)
205
223
  info = ArchiveMemberInfo.from_rarinfo(rar_info)
@@ -207,15 +225,15 @@ info = ArchiveMemberInfo.from_rarinfo(rar_info)
207
225
 
208
226
  ## Properties
209
227
 
210
- | Method/Property | Description |
211
- | ----------------------- | --------------------------------------------------------------- |
212
- | `members` | Alias for `namelist()` |
213
- | `size` | Total uncompressed size of all members |
214
- | `comment` / `comment=` | Get/set archive comment (ZIP only, raises ValueError otherwise) |
228
+ | Method/Property | Description |
229
+ | ---------------------- | --------------------------------------------------------------- |
230
+ | `members` | Alias for `namelist()` |
231
+ | `size` | Total uncompressed size of all members |
232
+ | `comment` / `comment=` | Get/set archive comment (ZIP only, raises ValueError otherwise) |
215
233
 
216
234
  ## Error Handling
217
235
 
218
- ```python
236
+ ```py
219
237
  from multiarchive import Archive, BadArchiveError
220
238
 
221
239
  try:
@@ -237,7 +255,7 @@ except FileNotFoundError:
237
255
 
238
256
  ### `Archive`
239
257
 
240
- ```python
258
+ ```py
241
259
  Archive(
242
260
  filename: str | Path,
243
261
  mode: str = "r",
@@ -272,7 +290,7 @@ Raised when an archive file is corrupt or in an unsupported format.
272
290
 
273
291
  Frozen dataclass mapping format names to their common file extensions:
274
292
 
275
- ```python
293
+ ```py
276
294
  ArchiveExtensions.zip # (".zip",)
277
295
  ArchiveExtensions.gz # (".tgz", ".tar.gz")
278
296
  ArchiveExtensions.xz # (".tar.xz", ".tar.lzma")
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "multiarchive"
3
- version = "0.2.0"
3
+ version = "0.2.1"
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.2.0"
3
+ version = "0.2.1"
4
4
  description = "A high level archive handler for Python."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -15,13 +15,13 @@ build-backend = "uv_build"
15
15
 
16
16
  [dependency-groups]
17
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",
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
25
  ]
26
26
 
27
27
  [tool.poe.tasks]
@@ -166,6 +166,7 @@ class Archive:
166
166
  mode: str = "r",
167
167
  compression_level: int | None = None,
168
168
  *,
169
+ pwd: bytes | str | None = None,
169
170
  strict_timestamps: bool = True,
170
171
  ) -> None:
171
172
  """Initialize the archive handler.
@@ -176,6 +177,7 @@ class Archive:
176
177
  mode: File access mode ('r' for read, 'w' for write, 'a' for append)
177
178
  compression_level: Compression level (ZIP: 0-9, TAR gzip: 0-9, TAR bzip2: 1-9)
178
179
  If None, uses default compression
180
+ pwd: Password for reading an encrypted ZIP file
179
181
  strict_timestamps: If True, reject timestamps outside the range supported
180
182
  by ZIP; if False, clamp them to the minimum or maximum supported value
181
183
 
@@ -185,6 +187,7 @@ class Archive:
185
187
  self.filename = str(filename)
186
188
  self.mode = mode
187
189
  self.compression_level = compression_level
190
+ self.pwd = pwd
188
191
  self.algo = algo
189
192
  self._archive: ArchiveType | IO[bytes] | None = None
190
193
  self._archive_type: (
@@ -201,6 +204,7 @@ class Archive:
201
204
  compression_level: int | None = None,
202
205
  *,
203
206
  algo: Algos | None = None,
207
+ pwd: bytes | None = None,
204
208
  strict_timestamps: bool = True,
205
209
  ) -> "Archive":
206
210
  """Create and open an archive without using a context manager.
@@ -212,6 +216,7 @@ class Archive:
212
216
  algo: Optional override for archive type based on file extension
213
217
  mode: File access mode ('r' for read, 'w' for write, 'a' for append)
214
218
  compression_level: Compression level (ZIP: 0-9, TAR gzip: 0-9, TAR bzip2: 1-9)
219
+ pwd: Password for reading an encrypted ZIP file
215
220
  strict_timestamps: If True, reject timestamps outside the range supported
216
221
  by ZIP; if False, clamp them to the minimum or maximum supported value
217
222
 
@@ -234,6 +239,7 @@ class Archive:
234
239
  algo=algo,
235
240
  mode=mode,
236
241
  compression_level=compression_level,
242
+ pwd=pwd,
237
243
  strict_timestamps=strict_timestamps,
238
244
  )
239
245
  archive._detect_and_open()
@@ -283,7 +289,7 @@ class Archive:
283
289
  self._detect_and_open_read()
284
290
  else:
285
291
  self._detect_and_open_write()
286
- except (BadArchive) + (EOFError,) as exc:
292
+ except BadArchive + (EOFError,) as exc:
287
293
  raise BadArchiveError(f"Failed to open archive. {exc}") from exc
288
294
  except (FileNotFoundError, ValueError):
289
295
  raise
@@ -296,8 +302,9 @@ class Archive:
296
302
 
297
303
  Raises:
298
304
  FileNotFoundError: If the archive file doesn't exist
299
- NotImplementedError: If the archive is password-protected (for ZIP and RAR)
300
305
  ValueError: If the file is not a valid ZIP, RAR, or TAR archive
306
+ KeyError: If the provided password is incorrect for an encrypted ZIP file
307
+ TypeError: If a password is provided for a TAR file (not supported)
301
308
  BadArchiveError: If a compressed file has invalid contents
302
309
  RuntimeError: If the file is not readable or is not a file
303
310
  """ # noqa: DOC502
@@ -310,12 +317,22 @@ class Archive:
310
317
  # Try ZIP first
311
318
  try:
312
319
  archive = zipfile.ZipFile(self.filename, "r")
313
- # Check for password protection
314
- if any(zinfo.flag_bits & 0x1 for zinfo in archive.infolist()):
315
- archive.close()
316
- raise NotImplementedError(
317
- "Password-protected ZIP files are not supported"
320
+ encrypted = [zinfo for zinfo in archive.infolist() if zinfo.flag_bits & 0x1]
321
+ if encrypted:
322
+ if self.pwd is None:
323
+ archive.close()
324
+ raise ValueError("Password required for encrypted ZIP file")
325
+ archive.setpassword(
326
+ self.pwd if type(self.pwd) is bytes else self.pwd.encode()
318
327
  )
328
+ try:
329
+ for zinfo in encrypted:
330
+ with archive.open(zinfo) as member:
331
+ member.read(1)
332
+ break
333
+ except RuntimeError as exc:
334
+ archive.close()
335
+ raise KeyError("Invalid password for encrypted ZIP file") from exc
319
336
  self._archive = archive
320
337
  self._archive_type = "zip"
321
338
  return
@@ -327,10 +344,22 @@ class Archive:
327
344
  archive = rarfile.RarFile(self.filename, "r")
328
345
  # Check for password protection
329
346
  if archive.needs_password():
330
- archive.close()
331
- raise NotImplementedError(
332
- "Password-protected RAR files are not supported"
347
+ if self.pwd is None:
348
+ archive.close()
349
+ raise ValueError("Password required for encrypted RAR file")
350
+ archive.setpassword(
351
+ self.pwd.decode() if type(self.pwd) is bytes else self.pwd
333
352
  )
353
+ try:
354
+ for rarinfo in archive.infolist():
355
+ if rarinfo.needs_password():
356
+ with archive.open(rarinfo) as member:
357
+ member.read(1)
358
+ break
359
+ # might be too broad tbh
360
+ except rarfile.Error as exc:
361
+ archive.close()
362
+ raise KeyError("Invalid password for encrypted RAR file") from exc
334
363
  self._archive = archive
335
364
  self._archive_type = "rar"
336
365
  return
@@ -340,6 +369,10 @@ class Archive:
340
369
  # Try TAR (with auto-detection for compression)
341
370
  try:
342
371
  self._archive = tarfile.open(self.filename, "r:*") # noqa: SIM115
372
+ if self.pwd is not None:
373
+ self._archive.close()
374
+ self._archive = None
375
+ raise TypeError("Passwords are not supported for TAR files")
343
376
  self._archive_type = "tar"
344
377
  return
345
378
  except tarfile.TarError:
@@ -374,6 +407,9 @@ class Archive:
374
407
  Raises:
375
408
  ValueError: If file extension is not recognized or compression_level is invalid
376
409
  """
410
+ if self.pwd is not None:
411
+ raise ValueError("Passwords are only supported when reading ZIP files")
412
+
377
413
  filename_lower = self.filename.lower()
378
414
 
379
415
  if (