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.
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)