multiarchive 0.2.0__tar.gz → 0.2.2__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.2
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,21 @@ 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 |
225
+ | `kind` | `Literal["file", "directory", "symlink", "junction", "hardlink", "fifo", "character-device", "block-device", "unknown"]` | Member type |
226
+ | `link_target` | `str \| None` | Symlink, junction, or hardlink target when available |
207
227
  | `raw` | `ZipInfo \| TarInfo \| RarInfo \| None` | Original backend object (`None` for plain compressed files) |
208
228
 
209
229
  Factory methods for direct conversion (you shouldn't need these in normal usage):
210
230
 
211
- ```python
231
+ ```py
212
232
  info = ArchiveMemberInfo.from_zipinfo(zip_info)
213
233
  info = ArchiveMemberInfo.from_tarinfo(tar_info)
214
234
  info = ArchiveMemberInfo.from_rarinfo(rar_info)
@@ -216,15 +236,15 @@ info = ArchiveMemberInfo.from_rarinfo(rar_info)
216
236
 
217
237
  ## Properties
218
238
 
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) |
239
+ | Method/Property | Description |
240
+ | ---------------------- | --------------------------------------------------------------- |
241
+ | `members` | Alias for `namelist()` |
242
+ | `size` | Total uncompressed size of all members |
243
+ | `comment` / `comment=` | Get/set archive comment (ZIP only, raises ValueError otherwise) |
224
244
 
225
245
  ## Error Handling
226
246
 
227
- ```python
247
+ ```py
228
248
  from multiarchive import Archive, BadArchiveError
229
249
 
230
250
  try:
@@ -246,7 +266,7 @@ except FileNotFoundError:
246
266
 
247
267
  ### `Archive`
248
268
 
249
- ```python
269
+ ```py
250
270
  Archive(
251
271
  filename: str | Path,
252
272
  mode: str = "r",
@@ -281,7 +301,7 @@ Raised when an archive file is corrupt or in an unsupported format.
281
301
 
282
302
  Frozen dataclass mapping format names to their common file extensions:
283
303
 
284
- ```python
304
+ ```py
285
305
  ArchiveExtensions.zip # (".zip",)
286
306
  ArchiveExtensions.gz # (".tgz", ".tar.gz")
287
307
  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,21 @@ 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 |
216
+ | `kind` | `Literal["file", "directory", "symlink", "junction", "hardlink", "fifo", "character-device", "block-device", "unknown"]` | Member type |
217
+ | `link_target` | `str \| None` | Symlink, junction, or hardlink target when available |
198
218
  | `raw` | `ZipInfo \| TarInfo \| RarInfo \| None` | Original backend object (`None` for plain compressed files) |
199
219
 
200
220
  Factory methods for direct conversion (you shouldn't need these in normal usage):
201
221
 
202
- ```python
222
+ ```py
203
223
  info = ArchiveMemberInfo.from_zipinfo(zip_info)
204
224
  info = ArchiveMemberInfo.from_tarinfo(tar_info)
205
225
  info = ArchiveMemberInfo.from_rarinfo(rar_info)
@@ -207,15 +227,15 @@ info = ArchiveMemberInfo.from_rarinfo(rar_info)
207
227
 
208
228
  ## Properties
209
229
 
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) |
230
+ | Method/Property | Description |
231
+ | ---------------------- | --------------------------------------------------------------- |
232
+ | `members` | Alias for `namelist()` |
233
+ | `size` | Total uncompressed size of all members |
234
+ | `comment` / `comment=` | Get/set archive comment (ZIP only, raises ValueError otherwise) |
215
235
 
216
236
  ## Error Handling
217
237
 
218
- ```python
238
+ ```py
219
239
  from multiarchive import Archive, BadArchiveError
220
240
 
221
241
  try:
@@ -237,7 +257,7 @@ except FileNotFoundError:
237
257
 
238
258
  ### `Archive`
239
259
 
240
- ```python
260
+ ```py
241
261
  Archive(
242
262
  filename: str | Path,
243
263
  mode: str = "r",
@@ -272,7 +292,7 @@ Raised when an archive file is corrupt or in an unsupported format.
272
292
 
273
293
  Frozen dataclass mapping format names to their common file extensions:
274
294
 
275
- ```python
295
+ ```py
276
296
  ArchiveExtensions.zip # (".zip",)
277
297
  ArchiveExtensions.gz # (".tgz", ".tar.gz")
278
298
  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.2"
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.2"
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]
@@ -1,3 +1,4 @@
1
+ import stat
1
2
  import sys
2
3
  import zipfile
3
4
  from dataclasses import dataclass
@@ -74,6 +75,33 @@ else:
74
75
  Algos: TypeAlias = Literal[
75
76
  "zip", "tar", "rar", "tar.gz", "tar.bz2", "tar.xz", "tar.zst"
76
77
  ]
78
+ ArchiveMemberKind: TypeAlias = Literal[
79
+ "file",
80
+ "directory",
81
+ "symlink",
82
+ "junction",
83
+ "hardlink",
84
+ "fifo",
85
+ "character-device",
86
+ "block-device",
87
+ "unknown",
88
+ ]
89
+
90
+
91
+ def _kind_from_mode(mode: int | None) -> ArchiveMemberKind | None:
92
+ if mode is None:
93
+ return None
94
+ return cast(
95
+ ArchiveMemberKind | None,
96
+ {
97
+ stat.S_IFREG: "file",
98
+ stat.S_IFDIR: "directory",
99
+ stat.S_IFLNK: "symlink",
100
+ stat.S_IFIFO: "fifo",
101
+ stat.S_IFCHR: "character-device",
102
+ stat.S_IFBLK: "block-device",
103
+ }.get(stat.S_IFMT(mode)),
104
+ )
77
105
 
78
106
 
79
107
  class BadArchiveError(RuntimeError):
@@ -107,6 +135,8 @@ class ArchiveMemberInfo:
107
135
  mtime: float
108
136
  mode: int | None
109
137
  is_dir: bool
138
+ kind: ArchiveMemberKind
139
+ link_target: str | None
110
140
  raw: InfoType | None
111
141
 
112
142
  @classmethod
@@ -119,6 +149,12 @@ class ArchiveMemberInfo:
119
149
  mtime=mtime_dt.timestamp(),
120
150
  mode=(info.external_attr >> 16) or None,
121
151
  is_dir=info.is_dir(),
152
+ kind=(
153
+ "directory"
154
+ if info.is_dir()
155
+ else _kind_from_mode(info.external_attr >> 16) or "file"
156
+ ),
157
+ link_target=None,
122
158
  raw=info,
123
159
  )
124
160
 
@@ -131,11 +167,30 @@ class ArchiveMemberInfo:
131
167
  mtime=float(info.mtime or 0.0),
132
168
  mode=info.mode,
133
169
  is_dir=info.isdir(),
170
+ kind=(
171
+ "file"
172
+ if info.isreg()
173
+ else "directory"
174
+ if info.isdir()
175
+ else "symlink"
176
+ if info.issym()
177
+ else "hardlink"
178
+ if info.islnk()
179
+ else "fifo"
180
+ if info.isfifo()
181
+ else "character-device"
182
+ if info.ischr()
183
+ else "block-device"
184
+ if info.isblk()
185
+ else "unknown"
186
+ ),
187
+ link_target=info.linkname if info.issym() or info.islnk() else None,
134
188
  raw=info,
135
189
  )
136
190
 
137
191
  @classmethod
138
192
  def from_rarinfo(cls, info: "rarfile.RarInfo") -> "ArchiveMemberInfo":
193
+ assert rarfile is not None
139
194
  if info.mtime is not None:
140
195
  mtime = info.mtime.timestamp()
141
196
  elif info.date_time is not None:
@@ -145,6 +200,29 @@ class ArchiveMemberInfo:
145
200
  assert info.filename is not None
146
201
  assert info.file_size is not None
147
202
  assert info.compress_size is not None
203
+ redir = info.file_redir
204
+ if redir is not None:
205
+ kind = cast(
206
+ ArchiveMemberKind,
207
+ {
208
+ rarfile.RAR5_XREDIR_UNIX_SYMLINK: "symlink",
209
+ rarfile.RAR5_XREDIR_WINDOWS_SYMLINK: "symlink",
210
+ rarfile.RAR5_XREDIR_WINDOWS_JUNCTION: "junction",
211
+ rarfile.RAR5_XREDIR_HARD_LINK: "hardlink",
212
+ rarfile.RAR5_XREDIR_FILE_COPY: "file",
213
+ }.get(redir[0], "unknown"),
214
+ )
215
+ link_target = redir[2]
216
+ else:
217
+ kind = (
218
+ "directory"
219
+ if info.is_dir()
220
+ else "symlink"
221
+ if info.is_symlink()
222
+ else _kind_from_mode(info.mode)
223
+ or ("file" if info.is_file() else "unknown")
224
+ )
225
+ link_target = None
148
226
  return cls(
149
227
  name=info.filename,
150
228
  uncompressed_size=info.file_size,
@@ -152,6 +230,8 @@ class ArchiveMemberInfo:
152
230
  mtime=mtime,
153
231
  mode=info.mode,
154
232
  is_dir=info.is_dir(),
233
+ kind=kind,
234
+ link_target=link_target,
155
235
  raw=info,
156
236
  )
157
237
 
@@ -166,6 +246,7 @@ class Archive:
166
246
  mode: str = "r",
167
247
  compression_level: int | None = None,
168
248
  *,
249
+ pwd: bytes | str | None = None,
169
250
  strict_timestamps: bool = True,
170
251
  ) -> None:
171
252
  """Initialize the archive handler.
@@ -176,6 +257,7 @@ class Archive:
176
257
  mode: File access mode ('r' for read, 'w' for write, 'a' for append)
177
258
  compression_level: Compression level (ZIP: 0-9, TAR gzip: 0-9, TAR bzip2: 1-9)
178
259
  If None, uses default compression
260
+ pwd: Password for reading an encrypted ZIP file
179
261
  strict_timestamps: If True, reject timestamps outside the range supported
180
262
  by ZIP; if False, clamp them to the minimum or maximum supported value
181
263
 
@@ -185,6 +267,7 @@ class Archive:
185
267
  self.filename = str(filename)
186
268
  self.mode = mode
187
269
  self.compression_level = compression_level
270
+ self.pwd = pwd
188
271
  self.algo = algo
189
272
  self._archive: ArchiveType | IO[bytes] | None = None
190
273
  self._archive_type: (
@@ -201,6 +284,7 @@ class Archive:
201
284
  compression_level: int | None = None,
202
285
  *,
203
286
  algo: Algos | None = None,
287
+ pwd: bytes | None = None,
204
288
  strict_timestamps: bool = True,
205
289
  ) -> "Archive":
206
290
  """Create and open an archive without using a context manager.
@@ -212,6 +296,7 @@ class Archive:
212
296
  algo: Optional override for archive type based on file extension
213
297
  mode: File access mode ('r' for read, 'w' for write, 'a' for append)
214
298
  compression_level: Compression level (ZIP: 0-9, TAR gzip: 0-9, TAR bzip2: 1-9)
299
+ pwd: Password for reading an encrypted ZIP file
215
300
  strict_timestamps: If True, reject timestamps outside the range supported
216
301
  by ZIP; if False, clamp them to the minimum or maximum supported value
217
302
 
@@ -234,6 +319,7 @@ class Archive:
234
319
  algo=algo,
235
320
  mode=mode,
236
321
  compression_level=compression_level,
322
+ pwd=pwd,
237
323
  strict_timestamps=strict_timestamps,
238
324
  )
239
325
  archive._detect_and_open()
@@ -283,7 +369,7 @@ class Archive:
283
369
  self._detect_and_open_read()
284
370
  else:
285
371
  self._detect_and_open_write()
286
- except (BadArchive) + (EOFError,) as exc:
372
+ except BadArchive + (EOFError,) as exc:
287
373
  raise BadArchiveError(f"Failed to open archive. {exc}") from exc
288
374
  except (FileNotFoundError, ValueError):
289
375
  raise
@@ -296,8 +382,9 @@ class Archive:
296
382
 
297
383
  Raises:
298
384
  FileNotFoundError: If the archive file doesn't exist
299
- NotImplementedError: If the archive is password-protected (for ZIP and RAR)
300
385
  ValueError: If the file is not a valid ZIP, RAR, or TAR archive
386
+ KeyError: If the provided password is incorrect for an encrypted ZIP file
387
+ TypeError: If a password is provided for a TAR file (not supported)
301
388
  BadArchiveError: If a compressed file has invalid contents
302
389
  RuntimeError: If the file is not readable or is not a file
303
390
  """ # noqa: DOC502
@@ -310,12 +397,22 @@ class Archive:
310
397
  # Try ZIP first
311
398
  try:
312
399
  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"
400
+ encrypted = [zinfo for zinfo in archive.infolist() if zinfo.flag_bits & 0x1]
401
+ if encrypted:
402
+ if self.pwd is None:
403
+ archive.close()
404
+ raise ValueError("Password required for encrypted ZIP file")
405
+ archive.setpassword(
406
+ self.pwd if type(self.pwd) is bytes else self.pwd.encode()
318
407
  )
408
+ try:
409
+ for zinfo in encrypted:
410
+ with archive.open(zinfo) as member:
411
+ member.read(1)
412
+ break
413
+ except RuntimeError as exc:
414
+ archive.close()
415
+ raise KeyError("Invalid password for encrypted ZIP file") from exc
319
416
  self._archive = archive
320
417
  self._archive_type = "zip"
321
418
  return
@@ -327,10 +424,22 @@ class Archive:
327
424
  archive = rarfile.RarFile(self.filename, "r")
328
425
  # Check for password protection
329
426
  if archive.needs_password():
330
- archive.close()
331
- raise NotImplementedError(
332
- "Password-protected RAR files are not supported"
427
+ if self.pwd is None:
428
+ archive.close()
429
+ raise ValueError("Password required for encrypted RAR file")
430
+ archive.setpassword(
431
+ self.pwd.decode() if type(self.pwd) is bytes else self.pwd
333
432
  )
433
+ try:
434
+ for rarinfo in archive.infolist():
435
+ if rarinfo.needs_password():
436
+ with archive.open(rarinfo) as member:
437
+ member.read(1)
438
+ break
439
+ # might be too broad tbh
440
+ except rarfile.Error as exc:
441
+ archive.close()
442
+ raise KeyError("Invalid password for encrypted RAR file") from exc
334
443
  self._archive = archive
335
444
  self._archive_type = "rar"
336
445
  return
@@ -340,6 +449,10 @@ class Archive:
340
449
  # Try TAR (with auto-detection for compression)
341
450
  try:
342
451
  self._archive = tarfile.open(self.filename, "r:*") # noqa: SIM115
452
+ if self.pwd is not None:
453
+ self._archive.close()
454
+ self._archive = None
455
+ raise TypeError("Passwords are not supported for TAR files")
343
456
  self._archive_type = "tar"
344
457
  return
345
458
  except tarfile.TarError:
@@ -374,6 +487,9 @@ class Archive:
374
487
  Raises:
375
488
  ValueError: If file extension is not recognized or compression_level is invalid
376
489
  """
490
+ if self.pwd is not None:
491
+ raise ValueError("Passwords are only supported when reading ZIP files")
492
+
377
493
  filename_lower = self.filename.lower()
378
494
 
379
495
  if (
@@ -521,6 +637,8 @@ class Archive:
521
637
  mtime=stat.st_mtime,
522
638
  mode=stat.st_mode & 0o777,
523
639
  is_dir=False,
640
+ kind="file",
641
+ link_target=None,
524
642
  raw=None,
525
643
  )
526
644
  ]