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.
- {multiarchive-0.2.0 → multiarchive-0.2.2}/PKG-INFO +50 -30
- {multiarchive-0.2.0 → multiarchive-0.2.2}/README.md +49 -29
- {multiarchive-0.2.0 → multiarchive-0.2.2}/pyproject.toml +1 -1
- {multiarchive-0.2.0 → multiarchive-0.2.2}/pyproject.toml.orig +8 -8
- {multiarchive-0.2.0 → multiarchive-0.2.2}/src/multiarchive/_archive.py +128 -10
- {multiarchive-0.2.0 → multiarchive-0.2.2}/src/multiarchive/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: multiarchive
|
|
3
|
-
Version: 0.2.
|
|
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
|
-
```
|
|
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
|
-
```
|
|
85
|
+
```py
|
|
86
86
|
with Archive("file.zip") as arc:
|
|
87
87
|
...
|
|
88
88
|
```
|
|
89
89
|
|
|
90
90
|
Or the classmethod:
|
|
91
91
|
|
|
92
|
-
```
|
|
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
|
|
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
|
-
```
|
|
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
|
-
```
|
|
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
|
-
```
|
|
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
|
-
```
|
|
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
|
-
```
|
|
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
|
-
```
|
|
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
|
-
```
|
|
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
|
-
```
|
|
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
|
|
200
|
-
| ------------------- |
|
|
201
|
-
| `name` | `str`
|
|
202
|
-
| `uncompressed_size` | `int`
|
|
203
|
-
| `compressed_size` | `int \| None`
|
|
204
|
-
| `mtime` | `float`
|
|
205
|
-
| `mode` | `int \| None`
|
|
206
|
-
| `is_dir` | `bool`
|
|
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
|
-
```
|
|
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
|
|
220
|
-
|
|
|
221
|
-
| `members`
|
|
222
|
-
| `size`
|
|
223
|
-
| `comment` / `comment=`
|
|
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
|
-
```
|
|
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
|
-
```
|
|
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
|
-
```
|
|
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
|
-
```
|
|
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
|
-
```
|
|
76
|
+
```py
|
|
77
77
|
with Archive("file.zip") as arc:
|
|
78
78
|
...
|
|
79
79
|
```
|
|
80
80
|
|
|
81
81
|
Or the classmethod:
|
|
82
82
|
|
|
83
|
-
```
|
|
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
|
|
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
|
-
```
|
|
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
|
-
```
|
|
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
|
-
```
|
|
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
|
-
```
|
|
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
|
-
```
|
|
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
|
-
```
|
|
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
|
-
```
|
|
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
|
-
```
|
|
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
|
|
191
|
-
| ------------------- |
|
|
192
|
-
| `name` | `str`
|
|
193
|
-
| `uncompressed_size` | `int`
|
|
194
|
-
| `compressed_size` | `int \| None`
|
|
195
|
-
| `mtime` | `float`
|
|
196
|
-
| `mode` | `int \| None`
|
|
197
|
-
| `is_dir` | `bool`
|
|
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
|
-
```
|
|
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
|
|
211
|
-
|
|
|
212
|
-
| `members`
|
|
213
|
-
| `size`
|
|
214
|
-
| `comment` / `comment=`
|
|
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
|
-
```
|
|
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
|
-
```
|
|
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
|
-
```
|
|
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.
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
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
|
-
|
|
314
|
-
if
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
"Password
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
"Password
|
|
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
|
]
|
|
File without changes
|