py-zippy 1.0.0__py3-none-any.whl
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.
- py_zippy-1.0.0.dist-info/METADATA +149 -0
- py_zippy-1.0.0.dist-info/RECORD +16 -0
- py_zippy-1.0.0.dist-info/WHEEL +5 -0
- py_zippy-1.0.0.dist-info/entry_points.txt +2 -0
- py_zippy-1.0.0.dist-info/licenses/LICENSE +21 -0
- py_zippy-1.0.0.dist-info/top_level.txt +1 -0
- zippy/__init__.py +8 -0
- zippy/cli.py +286 -0
- zippy/create.py +237 -0
- zippy/extract.py +128 -0
- zippy/list.py +68 -0
- zippy/lock.py +104 -0
- zippy/repair.py +243 -0
- zippy/test.py +153 -0
- zippy/unlock.py +120 -0
- zippy/utils.py +375 -0
zippy/utils.py
ADDED
@@ -0,0 +1,375 @@
|
|
1
|
+
import getpass
|
2
|
+
import io
|
3
|
+
import itertools
|
4
|
+
import json
|
5
|
+
import logging
|
6
|
+
import os
|
7
|
+
import sys
|
8
|
+
import time
|
9
|
+
from contextlib import redirect_stdout
|
10
|
+
from pathlib import Path
|
11
|
+
|
12
|
+
try:
|
13
|
+
from colorama import Fore, Style, init as colorama_init
|
14
|
+
except ImportError: # pragma: no cover - dependency managed via project metadata
|
15
|
+
colorama_init = None
|
16
|
+
Fore = Style = None
|
17
|
+
|
18
|
+
try: # pragma: no cover - optional dependency resolved at runtime
|
19
|
+
import patoolib
|
20
|
+
from patoolib.util import PatoolError
|
21
|
+
except ImportError: # pragma: no cover - handled gracefully when invoked
|
22
|
+
patoolib = None # type: ignore[assignment]
|
23
|
+
PatoolError = None # type: ignore[assignment]
|
24
|
+
|
25
|
+
|
26
|
+
LOGGER_NAME = "zippy"
|
27
|
+
DEFAULT_SPINNER = ["|", "/", "-", "\\"]
|
28
|
+
|
29
|
+
|
30
|
+
class ZippyError(RuntimeError):
|
31
|
+
"""Custom exception used across the ZIPPY toolkit."""
|
32
|
+
|
33
|
+
def __init__(self, message, exit_code=1):
|
34
|
+
super().__init__(message)
|
35
|
+
self.exit_code = exit_code
|
36
|
+
|
37
|
+
|
38
|
+
SUPPORTED_ARCHIVE_TYPES = {
|
39
|
+
".zip": "zip",
|
40
|
+
".jar": "zip",
|
41
|
+
".war": "zip",
|
42
|
+
".ear": "zip",
|
43
|
+
".apk": "zip",
|
44
|
+
".ipa": "zip",
|
45
|
+
".tar": "tar",
|
46
|
+
".tar.gz": "tar.gz",
|
47
|
+
".tgz": "tar.gz",
|
48
|
+
".tar.bz2": "tar.bz2",
|
49
|
+
".tbz2": "tar.bz2",
|
50
|
+
".tbz": "tar.bz2",
|
51
|
+
".tar.xz": "tar.xz",
|
52
|
+
".txz": "tar.xz",
|
53
|
+
".tar.lzma": "tar.lzma",
|
54
|
+
".tlz": "tar.lzma",
|
55
|
+
".tar.zst": "tar.zst",
|
56
|
+
".tzst": "tar.zst",
|
57
|
+
".tar.lz": "tar.lz",
|
58
|
+
".tlz4": "tar.lz",
|
59
|
+
".gz": "gzip",
|
60
|
+
".bz2": "bz2",
|
61
|
+
".xz": "xz",
|
62
|
+
".lzma": "lzma",
|
63
|
+
".rar": "rar",
|
64
|
+
".7z": "7z",
|
65
|
+
".zst": "zst",
|
66
|
+
".lz": "lz",
|
67
|
+
".cab": "cab",
|
68
|
+
".iso": "iso",
|
69
|
+
".img": "img",
|
70
|
+
".sit": "sit",
|
71
|
+
".sitx": "sitx",
|
72
|
+
".hqx": "hqx",
|
73
|
+
".arj": "arj",
|
74
|
+
".lzh": "lzh",
|
75
|
+
".lha": "lzh",
|
76
|
+
".ace": "ace",
|
77
|
+
".z": "compress",
|
78
|
+
".Z": "compress",
|
79
|
+
".cpio": "cpio",
|
80
|
+
".deb": "deb",
|
81
|
+
".rpm": "rpm",
|
82
|
+
".pkg": "pkg",
|
83
|
+
".xar": "xar",
|
84
|
+
".appimage": "appimage",
|
85
|
+
}
|
86
|
+
|
87
|
+
TAR_MODE_MAP = {
|
88
|
+
"tar": "w",
|
89
|
+
"tar.gz": "w:gz",
|
90
|
+
"tar.bz2": "w:bz2",
|
91
|
+
"tar.xz": "w:xz",
|
92
|
+
"tar.lzma": "w:xz",
|
93
|
+
}
|
94
|
+
|
95
|
+
TAR_READ_MODE_MAP = {
|
96
|
+
"tar": "r:",
|
97
|
+
"tar.gz": "r:gz",
|
98
|
+
"tar.bz2": "r:bz2",
|
99
|
+
"tar.xz": "r:xz",
|
100
|
+
"tar.lzma": "r:xz",
|
101
|
+
}
|
102
|
+
|
103
|
+
SINGLE_FILE_COMPRESSORS = {"gzip", "bz2", "xz", "lzma"}
|
104
|
+
|
105
|
+
|
106
|
+
EXTERNAL_ARCHIVE_TYPES = {
|
107
|
+
"rar",
|
108
|
+
"7z",
|
109
|
+
"zst",
|
110
|
+
"lz",
|
111
|
+
"cab",
|
112
|
+
"iso",
|
113
|
+
"img",
|
114
|
+
"sit",
|
115
|
+
"sitx",
|
116
|
+
"hqx",
|
117
|
+
"arj",
|
118
|
+
"lzh",
|
119
|
+
"ace",
|
120
|
+
"compress",
|
121
|
+
"cpio",
|
122
|
+
"deb",
|
123
|
+
"rpm",
|
124
|
+
"pkg",
|
125
|
+
"xar",
|
126
|
+
"appimage",
|
127
|
+
"tar.zst",
|
128
|
+
"tar.lz",
|
129
|
+
}
|
130
|
+
|
131
|
+
|
132
|
+
def tar_write_mode(archive_type):
|
133
|
+
return TAR_MODE_MAP.get(archive_type)
|
134
|
+
|
135
|
+
|
136
|
+
def tar_read_mode(archive_type):
|
137
|
+
return TAR_READ_MODE_MAP.get(archive_type, "r:*")
|
138
|
+
|
139
|
+
|
140
|
+
def is_single_file_type(archive_type):
|
141
|
+
return archive_type in SINGLE_FILE_COMPRESSORS
|
142
|
+
|
143
|
+
|
144
|
+
_COLOR_ENABLED = False
|
145
|
+
|
146
|
+
|
147
|
+
def _init_colors():
|
148
|
+
global _COLOR_ENABLED
|
149
|
+
if colorama_init is None:
|
150
|
+
_COLOR_ENABLED = False
|
151
|
+
return
|
152
|
+
if not _COLOR_ENABLED:
|
153
|
+
colorama_init(autoreset=True)
|
154
|
+
_COLOR_ENABLED = sys.stdout.isatty()
|
155
|
+
|
156
|
+
|
157
|
+
def color_text(text, color):
|
158
|
+
if not _COLOR_ENABLED or not color:
|
159
|
+
return text
|
160
|
+
return f"{color}{text}{Style.RESET_ALL}"
|
161
|
+
|
162
|
+
|
163
|
+
def get_logger(name=None):
|
164
|
+
"""Return a module-scoped logger with the project namespace."""
|
165
|
+
|
166
|
+
|
167
|
+
def requires_external_tool(archive_type: str) -> bool:
|
168
|
+
"""Return True when handling the archive type requires patool/third-party tooling."""
|
169
|
+
return archive_type in EXTERNAL_ARCHIVE_TYPES
|
170
|
+
|
171
|
+
|
172
|
+
def _ensure_patool_available(archive_type: str):
|
173
|
+
if patoolib is None:
|
174
|
+
handle_errors(
|
175
|
+
f"Support for '{archive_type}' archives requires the optional 'patool' dependency and its backend binaries.")
|
176
|
+
|
177
|
+
|
178
|
+
def external_extract(archive_path: str, output_path: str, verbose: bool = False):
|
179
|
+
_ensure_patool_available(get_archive_type(archive_path) or archive_path)
|
180
|
+
try:
|
181
|
+
patoolib.extract_archive(
|
182
|
+
archive_path,
|
183
|
+
outdir=output_path,
|
184
|
+
verbosity=-1,
|
185
|
+
)
|
186
|
+
except PatoolError as exc: # pragma: no cover - delegated to external tools
|
187
|
+
handle_errors(f"External extractor failed: {exc}", verbose)
|
188
|
+
|
189
|
+
|
190
|
+
def external_test(archive_path: str, verbose: bool = False):
|
191
|
+
_ensure_patool_available(get_archive_type(archive_path) or archive_path)
|
192
|
+
try:
|
193
|
+
patoolib.test_archive(archive_path, verbosity=-1)
|
194
|
+
except PatoolError as exc: # pragma: no cover
|
195
|
+
handle_errors(f"External archive test failed: {exc}", verbose, exit_code=2)
|
196
|
+
|
197
|
+
|
198
|
+
def external_list(archive_path: str, verbose: bool = False):
|
199
|
+
_ensure_patool_available(get_archive_type(archive_path) or archive_path)
|
200
|
+
buffer = io.StringIO()
|
201
|
+
try:
|
202
|
+
with redirect_stdout(buffer):
|
203
|
+
patoolib.list_archive(archive_path, verbosity=-1)
|
204
|
+
except PatoolError as exc: # pragma: no cover
|
205
|
+
handle_errors(f"External archive listing failed: {exc}", verbose)
|
206
|
+
lines = [line.strip() for line in buffer.getvalue().splitlines() if line.strip()]
|
207
|
+
if not lines:
|
208
|
+
return ["(no entries reported)"]
|
209
|
+
return lines
|
210
|
+
|
211
|
+
|
212
|
+
def get_logger(name=None):
|
213
|
+
"""Return a module-scoped logger with the project namespace."""
|
214
|
+
return logging.getLogger(name or LOGGER_NAME)
|
215
|
+
|
216
|
+
|
217
|
+
def configure_logging(verbose=False):
|
218
|
+
"""Configure root logging once for the CLI entry point."""
|
219
|
+
level = logging.DEBUG if verbose else logging.INFO
|
220
|
+
logging.basicConfig(level=level, format="%(name)s | %(message)s")
|
221
|
+
_init_colors()
|
222
|
+
|
223
|
+
|
224
|
+
def loading_animation(message="Processing...", duration=2, disable_animation=False):
|
225
|
+
"""Display a loading animation while processing."""
|
226
|
+
logger = get_logger(__name__)
|
227
|
+
if disable_animation or not sys.stdout.isatty():
|
228
|
+
logger.info("%s", message)
|
229
|
+
return
|
230
|
+
spinner = itertools.cycle(DEFAULT_SPINNER)
|
231
|
+
end_time = time.time() + duration
|
232
|
+
sys.stdout.write(color_text(message + " ", Fore.CYAN if Fore else None))
|
233
|
+
sys.stdout.flush()
|
234
|
+
while time.time() < end_time:
|
235
|
+
frame = next(spinner)
|
236
|
+
sys.stdout.write(
|
237
|
+
color_text(f"\r{message} {frame}", Fore.CYAN if Fore else None)
|
238
|
+
)
|
239
|
+
sys.stdout.flush()
|
240
|
+
time.sleep(0.1)
|
241
|
+
sys.stdout.write(
|
242
|
+
color_text(f"\r{message} Done! \n", Fore.GREEN if Fore else None)
|
243
|
+
)
|
244
|
+
|
245
|
+
|
246
|
+
def get_archive_type(archive_path, forced_type=None):
|
247
|
+
"""Determine the archive type from file extension or forced type."""
|
248
|
+
if forced_type:
|
249
|
+
if forced_type not in set(SUPPORTED_ARCHIVE_TYPES.values()):
|
250
|
+
supported = ", ".join(sorted(set(SUPPORTED_ARCHIVE_TYPES.values())))
|
251
|
+
raise ValueError(
|
252
|
+
f"Invalid archive type specified: {forced_type}. Supported types: {supported}"
|
253
|
+
)
|
254
|
+
return forced_type
|
255
|
+
|
256
|
+
path = Path(archive_path)
|
257
|
+
suffixes = [suffix.lower() for suffix in path.suffixes]
|
258
|
+
for length in range(len(suffixes), 0, -1):
|
259
|
+
candidate = "".join(suffixes[-length:])
|
260
|
+
if candidate in SUPPORTED_ARCHIVE_TYPES:
|
261
|
+
return SUPPORTED_ARCHIVE_TYPES[candidate]
|
262
|
+
return None
|
263
|
+
|
264
|
+
|
265
|
+
def handle_errors(message, verbose=False, exit_code=1):
|
266
|
+
"""Handle errors consistently by raising a dedicated exception."""
|
267
|
+
logger = get_logger(__name__)
|
268
|
+
logger.error(message)
|
269
|
+
if verbose:
|
270
|
+
import traceback
|
271
|
+
|
272
|
+
traceback.print_exc()
|
273
|
+
raise ZippyError(message, exit_code)
|
274
|
+
|
275
|
+
|
276
|
+
def validate_path(path, description="Path", must_exist=True, is_dir=None):
|
277
|
+
"""Validate and return absolute path."""
|
278
|
+
if not path:
|
279
|
+
handle_errors(f"{description} cannot be empty.")
|
280
|
+
expanded_path = os.path.abspath(os.path.expanduser(os.path.expandvars(path)))
|
281
|
+
if must_exist and not os.path.exists(expanded_path):
|
282
|
+
handle_errors(f"{description} not found: {path}")
|
283
|
+
if is_dir is True and not os.path.isdir(expanded_path):
|
284
|
+
handle_errors(f"{description} must be a directory: {path}")
|
285
|
+
if is_dir is False and os.path.isdir(expanded_path):
|
286
|
+
handle_errors(f"{description} must be a file: {path}")
|
287
|
+
return expanded_path
|
288
|
+
|
289
|
+
|
290
|
+
def get_password_interactive(prompt="Enter password: "):
|
291
|
+
"""Get password input interactively."""
|
292
|
+
return getpass.getpass(prompt)
|
293
|
+
|
294
|
+
|
295
|
+
__all__ = [
|
296
|
+
"SUPPORTED_ARCHIVE_TYPES",
|
297
|
+
"ZippyError",
|
298
|
+
"configure_logging",
|
299
|
+
"get_logger",
|
300
|
+
"color_text",
|
301
|
+
"loading_animation",
|
302
|
+
"get_archive_type",
|
303
|
+
"handle_errors",
|
304
|
+
"validate_path",
|
305
|
+
"get_password_interactive",
|
306
|
+
"tar_write_mode",
|
307
|
+
"tar_read_mode",
|
308
|
+
"is_single_file_type",
|
309
|
+
"requires_external_tool",
|
310
|
+
"external_extract",
|
311
|
+
"external_test",
|
312
|
+
"external_list",
|
313
|
+
"Fore",
|
314
|
+
]
|
315
|
+
|
316
|
+
|
317
|
+
# Salvage functions referenced in repair.py but missing
|
318
|
+
def _salvage_extract_on_repair_fail(
|
319
|
+
archive_path, output_path=".", archive_type=None, verbose=False
|
320
|
+
):
|
321
|
+
"""Attempt salvage extraction when repair fails."""
|
322
|
+
try:
|
323
|
+
logging.info(f"Attempting salvage extraction for {archive_path}...")
|
324
|
+
# Import here to avoid circular imports
|
325
|
+
from .extract import extract_archive
|
326
|
+
|
327
|
+
extract_archive(
|
328
|
+
archive_path, output_path, verbose=verbose, disable_animation=True
|
329
|
+
)
|
330
|
+
logging.info("Salvage extraction completed successfully.")
|
331
|
+
return True
|
332
|
+
except Exception as e:
|
333
|
+
if verbose:
|
334
|
+
logging.info(f"Salvage extraction failed: {e}")
|
335
|
+
return False
|
336
|
+
|
337
|
+
|
338
|
+
def _tar_salvage_extraction(archive_path, output_path=".", verbose=False):
|
339
|
+
"""Attempt salvage extraction for TAR archives."""
|
340
|
+
import tarfile
|
341
|
+
|
342
|
+
extracted_count = 0
|
343
|
+
try:
|
344
|
+
logging.info(f"Attempting TAR salvage extraction for {archive_path}...")
|
345
|
+
with tarfile.open(archive_path, "r:*", ignore_zeros=True) as tf:
|
346
|
+
# Try to extract what we can
|
347
|
+
for member in tf:
|
348
|
+
try:
|
349
|
+
tf.extract(member, output_path)
|
350
|
+
extracted_count += 1
|
351
|
+
except Exception as e:
|
352
|
+
if verbose:
|
353
|
+
print(f"Failed to extract {member.name}: {e}")
|
354
|
+
continue
|
355
|
+
print(f"TAR salvage extraction completed. Extracted {extracted_count} files.")
|
356
|
+
return extracted_count
|
357
|
+
except Exception as e:
|
358
|
+
if verbose:
|
359
|
+
print(f"TAR salvage extraction failed: {e}")
|
360
|
+
return 0
|
361
|
+
|
362
|
+
|
363
|
+
# Functions imported by lock.py - use lazy imports to avoid circular dependencies
|
364
|
+
def extract_archive(*args, **kwargs):
|
365
|
+
"""Wrapper for extract_archive to avoid circular imports."""
|
366
|
+
from .extract import extract_archive as _extract_archive
|
367
|
+
|
368
|
+
return _extract_archive(*args, **kwargs)
|
369
|
+
|
370
|
+
|
371
|
+
def create_archive(*args, **kwargs):
|
372
|
+
"""Wrapper for create_archive to avoid circular imports."""
|
373
|
+
from .create import create_archive as _create_archive
|
374
|
+
|
375
|
+
return _create_archive(*args, **kwargs)
|