zipctl 0.1.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.
Files changed (91) hide show
  1. zipctl/__init__.py +108 -0
  2. zipctl/__main__.py +5 -0
  3. zipctl/cli/__init__.py +108 -0
  4. zipctl/cli/archive.py +23 -0
  5. zipctl/cli/atomic.py +77 -0
  6. zipctl/cli/commands/__init__.py +8 -0
  7. zipctl/cli/commands/check_password.py +133 -0
  8. zipctl/cli/commands/create.py +374 -0
  9. zipctl/cli/commands/decrypt.py +70 -0
  10. zipctl/cli/commands/encrypt.py +102 -0
  11. zipctl/cli/commands/extract.py +262 -0
  12. zipctl/cli/commands/helpers/__init__.py +1 -0
  13. zipctl/cli/commands/helpers/collect.py +225 -0
  14. zipctl/cli/commands/helpers/command.py +119 -0
  15. zipctl/cli/commands/helpers/copying/__init__.py +1 -0
  16. zipctl/cli/commands/helpers/copying/copy.py +211 -0
  17. zipctl/cli/commands/helpers/copying/options.py +80 -0
  18. zipctl/cli/commands/helpers/copying/report.py +63 -0
  19. zipctl/cli/commands/helpers/copying/targets.py +76 -0
  20. zipctl/cli/commands/helpers/copying/verify.py +111 -0
  21. zipctl/cli/commands/helpers/create_report.py +154 -0
  22. zipctl/cli/commands/helpers/encryption/__init__.py +1 -0
  23. zipctl/cli/commands/helpers/encryption/options.py +192 -0
  24. zipctl/cli/commands/helpers/encryption/plan.py +119 -0
  25. zipctl/cli/commands/helpers/encryption/spec.py +256 -0
  26. zipctl/cli/commands/helpers/extract_report.py +148 -0
  27. zipctl/cli/commands/helpers/output_options.py +55 -0
  28. zipctl/cli/commands/helpers/passwords/__init__.py +1 -0
  29. zipctl/cli/commands/helpers/passwords/options.py +143 -0
  30. zipctl/cli/commands/helpers/passwords/pool.py +116 -0
  31. zipctl/cli/commands/helpers/passwords/sources.py +144 -0
  32. zipctl/cli/commands/helpers/policy_options.py +64 -0
  33. zipctl/cli/commands/helpers/progress.py +215 -0
  34. zipctl/cli/commands/helpers/selection.py +96 -0
  35. zipctl/cli/commands/helpers/sources.py +32 -0
  36. zipctl/cli/commands/inspect.py +145 -0
  37. zipctl/cli/commands/list.py +166 -0
  38. zipctl/cli/commands/policy.py +104 -0
  39. zipctl/cli/commands/rewrite.py +113 -0
  40. zipctl/cli/commands/test.py +154 -0
  41. zipctl/cli/context.py +57 -0
  42. zipctl/cli/errors.py +58 -0
  43. zipctl/cli/formatter.py +62 -0
  44. zipctl/cli/methods.py +136 -0
  45. zipctl/cli/output.py +138 -0
  46. zipctl/cli/parser.py +197 -0
  47. zipctl/compression/__init__.py +129 -0
  48. zipctl/compression/bz2.py +99 -0
  49. zipctl/compression/deflate.py +136 -0
  50. zipctl/compression/lzma.py +238 -0
  51. zipctl/compression/methods.py +176 -0
  52. zipctl/compression/stored.py +14 -0
  53. zipctl/compression/zstd.py +141 -0
  54. zipctl/cryptography/__init__.py +41 -0
  55. zipctl/cryptography/aes.py +500 -0
  56. zipctl/cryptography/base.py +163 -0
  57. zipctl/cryptography/zipcrypto.py +251 -0
  58. zipctl/exceptions.py +39 -0
  59. zipctl/py.typed +0 -0
  60. zipctl/zipfile/__init__.py +1 -0
  61. zipctl/zipfile/assessment.py +41 -0
  62. zipctl/zipfile/assessor.py +151 -0
  63. zipctl/zipfile/exceptions.py +27 -0
  64. zipctl/zipfile/ext.py +782 -0
  65. zipctl/zipfile/extract.py +186 -0
  66. zipctl/zipfile/file.py +1500 -0
  67. zipctl/zipfile/info.py +1127 -0
  68. zipctl/zipfile/inspection.py +127 -0
  69. zipctl/zipfile/io_wrappers.py +193 -0
  70. zipctl/zipfile/materialize.py +344 -0
  71. zipctl/zipfile/password.py +87 -0
  72. zipctl/zipfile/path/__init__.py +523 -0
  73. zipctl/zipfile/path/glob.py +104 -0
  74. zipctl/zipfile/policy_config.py +426 -0
  75. zipctl/zipfile/policy_extraction.py +267 -0
  76. zipctl/zipfile/progress.py +133 -0
  77. zipctl/zipfile/records.py +504 -0
  78. zipctl/zipfile/secure_fs.py +128 -0
  79. zipctl/zipfile/shared.py +104 -0
  80. zipctl/zipfile/validators.py +392 -0
  81. zipctl/zipfile/write.py +311 -0
  82. zipctl/zipfile/write_coordinator.py +111 -0
  83. zipctl-0.1.0.dist-info/METADATA +730 -0
  84. zipctl-0.1.0.dist-info/RECORD +91 -0
  85. zipctl-0.1.0.dist-info/WHEEL +4 -0
  86. zipctl-0.1.0.dist-info/entry_points.txt +2 -0
  87. zipctl-0.1.0.dist-info/licenses/LICENSE +21 -0
  88. zipctl-0.1.0.dist-info/licenses/NOTICE +32 -0
  89. zipctl-0.1.0.dist-info/licenses/licenses/CPYTHON-3.14.3.txt +277 -0
  90. zipctl-0.1.0.dist-info/licenses/licenses/JetBrainsMono-OFL.txt +93 -0
  91. zipctl-0.1.0.dist-info/licenses/licenses/pyzipper-MIT.txt +22 -0
@@ -0,0 +1,215 @@
1
+ """Rendering :class:`~zipctl.ProgressEvent` on standard error."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import time
8
+ from collections.abc import Callable, Generator, Iterable
9
+ from contextlib import contextmanager
10
+ from typing import Protocol, TextIO
11
+ from unicodedata import east_asian_width
12
+
13
+ from zipctl.cli.output import human_size, printable
14
+ from zipctl.zipfile.extract import MemberStatus
15
+ from zipctl.zipfile.progress import ProgressCallback, ProgressEvent, ProgressPhase
16
+
17
+ __all__ = [
18
+ "ProgressArgs",
19
+ "ProgressRenderer",
20
+ "StepReporter",
21
+ "add_progress_option",
22
+ "progress_renderer",
23
+ ]
24
+
25
+ _FALLBACK_WIDTH = 80
26
+ _REDRAW_INTERVAL = 0.05 # seconds between redraws of the live status line
27
+ _STATUS_WORDS = {
28
+ MemberStatus.EXTRACTED: "OK",
29
+ MemberStatus.PREVIEWED: "OK",
30
+ MemberStatus.SKIPPED: "SKIP",
31
+ MemberStatus.FAILED: "FAILED",
32
+ }
33
+
34
+
35
+ def _columns(text: str) -> int:
36
+ """Terminal columns *text* takes: East Asian wide characters take two."""
37
+ return sum(2 if east_asian_width(char) in "WF" else 1 for char in text)
38
+
39
+
40
+ def _terminal_width(stream: TextIO) -> int:
41
+ """Columns of the terminal *stream* is attached to (it is not standard output)."""
42
+ try:
43
+ return os.get_terminal_size(stream.fileno()).columns or _FALLBACK_WIDTH
44
+ except (OSError, ValueError, AttributeError): # not a terminal, or no fileno
45
+ return _FALLBACK_WIDTH
46
+
47
+
48
+ def _prefix(text: str, columns: int) -> str:
49
+ """The longest start of *text* that fits in *columns*."""
50
+ used = 0
51
+ for index, char in enumerate(text):
52
+ used += _columns(char)
53
+ if used > columns:
54
+ return text[:index]
55
+ return text
56
+
57
+
58
+ def _shorten(text: str, width: int) -> str:
59
+ """Trim *text* to *width* columns, keeping both ends of long names."""
60
+ if width < 8 or _columns(text) <= width:
61
+ return _prefix(text, max(width, 0))
62
+ keep = width - 3
63
+ head = keep // 2
64
+ tail = _prefix(text[::-1], keep - head)[::-1]
65
+ return f"{_prefix(text, head)}...{tail}"
66
+
67
+
68
+ class ProgressRenderer:
69
+ """A ``progress=`` callback that reports on *stream*.
70
+
71
+ On a terminal it keeps one status line up to date (``\\r`` rewrites); when
72
+ output is redirected it prints one plain line per finished member instead
73
+ (``[1/3] OK name``), so logs stay readable. The live line is redrawn at
74
+ most every 50 ms, except for the first event and the last member finishing,
75
+ which are always drawn.
76
+ """
77
+
78
+ def __init__(
79
+ self,
80
+ stream: TextIO,
81
+ *,
82
+ live: bool,
83
+ clock: Callable[[], float] = time.monotonic,
84
+ ) -> None:
85
+ self._stream: TextIO = stream
86
+ self._live: bool = live
87
+ self._clock: Callable[[], float] = clock
88
+ self._drawn: int = 0
89
+ self._last_draw: float | None = None
90
+
91
+ def __call__(self, event: ProgressEvent) -> None:
92
+ if self._live:
93
+ self._draw(event)
94
+ elif event.phase is ProgressPhase.FINISH and event.status is not None:
95
+ print(
96
+ f"[{event.member_index + 1}/{event.member_count}] "
97
+ f"{_STATUS_WORDS[event.status]:<8}{printable(event.member)}",
98
+ file=self._stream,
99
+ )
100
+
101
+ def _due(self, event: ProgressEvent) -> bool:
102
+ if self._last_draw is None:
103
+ return True
104
+ last = (
105
+ event.phase is ProgressPhase.FINISH
106
+ and event.member_index + 1 == event.member_count
107
+ )
108
+ return last or self._clock() - self._last_draw >= _REDRAW_INTERVAL
109
+
110
+ def _draw(self, event: ProgressEvent) -> None:
111
+ if not self._due(event):
112
+ return
113
+ self._last_draw = self._clock()
114
+ if event.total_bytes:
115
+ percent = min(100, event.total_bytes_done * 100 // event.total_bytes)
116
+ else:
117
+ percent = 100 if event.phase is ProgressPhase.FINISH else 0
118
+ tail = (
119
+ f" {percent:3d}% {human_size(event.total_bytes_done)}"
120
+ f"/{human_size(event.total_bytes)}"
121
+ )
122
+ head = f"[{event.member_index + 1}/{event.member_count}] "
123
+ width = _terminal_width(self._stream) - 1
124
+ name = _shorten(printable(event.member), width - len(head) - len(tail))
125
+ self._write(head + name + tail)
126
+
127
+ def _write(self, line: str) -> None:
128
+ columns = _columns(line)
129
+ padding = " " * max(0, self._drawn - columns)
130
+ self._stream.write(f"\r{line}{padding}")
131
+ self._stream.flush()
132
+ self._drawn = columns
133
+
134
+ def clear(self) -> None:
135
+ """Erase the status line, e.g. before a password prompt."""
136
+ if self._live and self._drawn:
137
+ self._stream.write("\r" + " " * self._drawn + "\r")
138
+ self._stream.flush()
139
+ self._drawn = 0
140
+ self._last_draw = None # what comes next is drawn at once
141
+
142
+ def close(self) -> None:
143
+ """Same as :meth:`clear`."""
144
+ self.clear()
145
+
146
+
147
+ class ProgressArgs(Protocol):
148
+ """What :func:`add_progress_option` leaves on the parsed arguments."""
149
+
150
+ progress: bool
151
+
152
+
153
+ def add_progress_option(parser: argparse.ArgumentParser) -> None:
154
+ parser.add_argument(
155
+ "--progress", action="store_true", help="report progress on standard error"
156
+ )
157
+
158
+
159
+ @contextmanager
160
+ def progress_renderer(
161
+ stream: TextIO, enabled: bool
162
+ ) -> Generator[ProgressRenderer | None]:
163
+ """The renderer for ``--progress`` (``None`` without it), closed on the way out."""
164
+ renderer = ProgressRenderer(stream, live=stream.isatty()) if enabled else None
165
+ try:
166
+ yield renderer
167
+ finally:
168
+ if renderer is not None:
169
+ renderer.close()
170
+
171
+
172
+ class StepReporter:
173
+ """Progress events for a command that does its own work member by member.
174
+
175
+ Extraction reports progress from inside the library; ``test`` and ``create``
176
+ call :meth:`start` and :meth:`finish` around each member instead. Sizes are
177
+ the declared ones, and there is one event pair per member (no byte-level
178
+ progress inside a member). Without a *callback* every call does nothing,
179
+ and *members* is never read (so it may be a lazy iterable).
180
+ """
181
+
182
+ def __init__(
183
+ self, callback: ProgressCallback | None, members: Iterable[tuple[str, int]]
184
+ ) -> None:
185
+ self._callback: ProgressCallback | None = callback
186
+ self._members: list[tuple[str, int]] = list(members) if callback else []
187
+ self._total: int = sum(size for _, size in self._members)
188
+ self._done: int = 0
189
+
190
+ def _event(
191
+ self, phase: ProgressPhase, index: int, status: MemberStatus | None = None
192
+ ) -> ProgressEvent:
193
+ name, size = self._members[index]
194
+ finished = phase is ProgressPhase.FINISH
195
+ return ProgressEvent(
196
+ phase=phase,
197
+ member=name,
198
+ member_index=index,
199
+ member_count=len(self._members),
200
+ member_bytes=size if finished else 0,
201
+ member_total=size,
202
+ total_bytes=self._total,
203
+ total_bytes_done=self._done,
204
+ status=status,
205
+ )
206
+
207
+ def start(self, index: int) -> None:
208
+ if self._callback:
209
+ self._callback(self._event(ProgressPhase.START, index))
210
+
211
+ def finish(self, index: int, *, ok: bool) -> None:
212
+ if self._callback:
213
+ self._done += self._members[index][1]
214
+ status = MemberStatus.EXTRACTED if ok else MemberStatus.FAILED
215
+ self._callback(self._event(ProgressPhase.FINISH, index, status))
@@ -0,0 +1,96 @@
1
+ """Choosing archive members with shell-style patterns."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from bisect import bisect_left
7
+ from collections.abc import Callable, Sequence
8
+
9
+ from zipctl.cli.errors import UsageError
10
+ from zipctl.cli.output import did_you_mean, printable
11
+ from zipctl.zipfile.info import ZipInfo
12
+ from zipctl.zipfile.path.glob import Translator
13
+
14
+ __all__ = ["MEMBER_HELP", "glob_matcher", "select", "select_infos"]
15
+
16
+ MEMBER_HELP = (
17
+ "Member names or glob patterns; a directory name picks everything under it. "
18
+ "Default: every member"
19
+ )
20
+
21
+ _TRANSLATOR = Translator(seps="/")
22
+ _GLOB_CHARS = frozenset("*?[")
23
+
24
+
25
+ def glob_matcher(pattern: str) -> Callable[[str], bool]:
26
+ """Return a predicate for member names matching *pattern*.
27
+
28
+ ``*`` and ``?`` stay within one path segment, ``**`` crosses ``/``, and
29
+ ``[...]`` is a character class. A name equal to *pattern* always matches,
30
+ so an archive member whose name contains glob characters can still be
31
+ named literally. Raises :class:`CliError` for a malformed pattern.
32
+ """
33
+ try:
34
+ compiled = re.compile(_TRANSLATOR.translate(pattern))
35
+ except (ValueError, re.error) as exc:
36
+ raise UsageError(
37
+ f"invalid pattern {printable(pattern)!r}: {printable(str(exc))}",
38
+ ) from None
39
+ return lambda name: name == pattern or compiled.match(name) is not None
40
+
41
+
42
+ def _under(ordered: list[str], directory: str) -> list[str]:
43
+ """The names in sorted *ordered* that start with *directory* (ending in ``/``)."""
44
+ start = end = bisect_left(ordered, directory)
45
+ while end < len(ordered) and ordered[end].startswith(directory):
46
+ end += 1
47
+ return ordered[start:end]
48
+
49
+
50
+ def select(names: Sequence[str], patterns: Sequence[str], what: str) -> set[str]:
51
+ """The *names* picked by *patterns* (all of them without any).
52
+
53
+ A plain name (no glob characters) that names a directory also picks
54
+ everything under it, as ``7z x`` and ``tar x`` do; ``unzip`` needs ``dir/*``.
55
+ A pattern that matches nothing is a usage error, raised before any work
56
+ is done: a typo must not leave members untouched while the command
57
+ reports success.
58
+ """
59
+ if not patterns:
60
+ return set(names)
61
+ present = set(names)
62
+ ordered = sorted(present)
63
+ chosen: set[str] = set()
64
+ dead: list[str] = []
65
+ for pattern in patterns:
66
+ if _GLOB_CHARS.isdisjoint(pattern):
67
+ # A plain name matches itself, its directory entry and what is under
68
+ # it: a lookup instead of scanning every name. A malformed pattern
69
+ # needs a glob character, so nothing is left unchecked.
70
+ hit = present & {pattern, pattern + "/"}
71
+ if directory := pattern.rstrip("/"):
72
+ hit.update(_under(ordered, directory + "/"))
73
+ else:
74
+ matches = glob_matcher(pattern)
75
+ hit = {name for name in names if matches(name)}
76
+ if not hit:
77
+ dead.append(pattern)
78
+ chosen |= hit
79
+ if dead:
80
+ listed = ", ".join(
81
+ f"'{printable(pattern)}'"
82
+ + ("" if _GLOB_CHARS & set(pattern) else did_you_mean(pattern, ordered))
83
+ for pattern in dead
84
+ )
85
+ raise UsageError(f"no {what} matches {listed}")
86
+ return chosen
87
+
88
+
89
+ def select_infos(
90
+ infos: Sequence[ZipInfo], patterns: Sequence[str], what: str = "member"
91
+ ) -> list[ZipInfo]:
92
+ """The *infos* whose names *patterns* pick, in archive order."""
93
+ if not patterns:
94
+ return list(infos)
95
+ chosen = select([info.filename for info in infos], patterns, what)
96
+ return [info for info in infos if info.filename in chosen]
@@ -0,0 +1,32 @@
1
+ """Reading text that a command line option names by file or standard input."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from zipctl.cli.context import Context
8
+ from zipctl.cli.errors import UsageError, os_error_text
9
+ from zipctl.cli.output import printable
10
+
11
+ __all__ = ["read_text_source"]
12
+
13
+
14
+ def read_text_source(source: str, ctx: Context, what: str) -> str:
15
+ """The UTF-8 text of file *source* (``-`` is standard input), BOM removed.
16
+
17
+ *what* names the file in messages, e.g. ``"policy file"``.
18
+ """
19
+ if source == "-":
20
+ named = f"the {what} on standard input"
21
+ ctx.claim_stdin(f"the {what}")
22
+ data = ctx.read_stdin()
23
+ else:
24
+ named = f"{what} {printable(source)}"
25
+ try:
26
+ data = Path(source).read_bytes()
27
+ except OSError as exc:
28
+ raise UsageError(f"cannot read {named}: {os_error_text(exc)}") from None
29
+ try:
30
+ return data.decode("utf-8-sig")
31
+ except UnicodeDecodeError:
32
+ raise UsageError(f"{named} is not valid UTF-8") from None
@@ -0,0 +1,145 @@
1
+ """The ``inspect`` command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Protocol
7
+
8
+ from zipctl.cli.archive import open_archive
9
+ from zipctl.cli.commands.helpers.command import Subparsers, add_command
10
+ from zipctl.cli.commands.helpers.output_options import OutputArgs, add_output_options
11
+ from zipctl.cli.commands.helpers.policy_options import (
12
+ PolicyArgs,
13
+ add_policy_options,
14
+ load_policy,
15
+ )
16
+ from zipctl.cli.context import Context
17
+ from zipctl.cli.errors import EXIT_FAILURE, EXIT_OK
18
+ from zipctl.cli.output import (
19
+ count,
20
+ human_size,
21
+ printable,
22
+ write_json,
23
+ )
24
+ from zipctl.zipfile.extract import (
25
+ ViolationAction,
26
+ )
27
+ from zipctl.zipfile.inspection import InspectionResult
28
+
29
+
30
+ class InspectArgs(OutputArgs, PolicyArgs, Protocol):
31
+ archive: str
32
+ destination: str | None
33
+
34
+
35
+ def _section(title: str, names: tuple[str, ...], flagged: set[str]) -> list[str]:
36
+ """A titled list of *names*, leaving out those a violation already reports."""
37
+ names = tuple(name for name in names if name not in flagged)
38
+ if not names:
39
+ return []
40
+ return [f" {title} ({len(names)}):", *(f" {printable(name)}" for name in names)]
41
+
42
+
43
+ def _violations(result: InspectionResult) -> list[str]:
44
+ if not result.violations:
45
+ return []
46
+ return [
47
+ f" Violations ({len(result.violations)}):",
48
+ *(
49
+ f" {violation.action.value.upper():<7}{printable(violation.member)}: "
50
+ f"{printable(violation.message)}"
51
+ for violation in result.violations
52
+ ),
53
+ ]
54
+
55
+
56
+ def _render_inspection(
57
+ ctx: Context,
58
+ archive: str,
59
+ destination: str,
60
+ result: InspectionResult,
61
+ errors: int,
62
+ quiet: bool,
63
+ ) -> None:
64
+ if quiet:
65
+ if errors: # the details alone: no verdict, as when nothing is wrong
66
+ ctx.out("Details:")
67
+ for line in _violations(result):
68
+ ctx.out(line)
69
+ return
70
+ verdict = "OK" if not errors else f"FAILED ({count(errors, 'error')})"
71
+ directories = sum(1 for member in result.members if member.is_directory)
72
+ flagged = {violation.member for violation in result.violations}
73
+ ctx.out(f"Archive: {printable(archive)}")
74
+ ctx.out(f"Destination: {printable(destination)}")
75
+ ctx.out(
76
+ f"Members: {result.total_entries} "
77
+ f"({count(directories, 'directory', 'directories')})"
78
+ )
79
+ ctx.out(
80
+ f"Size: {human_size(result.total_uncompressed_size)} "
81
+ f"({human_size(result.total_compressed_size)} compressed)"
82
+ )
83
+ details = [
84
+ *_violations(result),
85
+ *_section("Encrypted members", result.encrypted_members, flagged),
86
+ *_section("Suspicious paths", result.suspicious_paths, flagged),
87
+ *_section("Symlinks", result.symlinks, flagged),
88
+ *_section("Special files", result.special_files, flagged),
89
+ *_section("Large members", result.large_members, flagged),
90
+ *_section("Ratio outliers", result.compress_ratio_outliers, flagged),
91
+ *_section("Duplicate names", result.duplicate_member_names, flagged),
92
+ ]
93
+ ctx.out(f"Result: {verdict}")
94
+ if details:
95
+ ctx.out("")
96
+ ctx.out("Details:")
97
+ for line in details:
98
+ ctx.out(line)
99
+
100
+
101
+ def cmd_inspect(args: InspectArgs, ctx: Context) -> int:
102
+ policy = load_policy(args, ctx)
103
+ destination = os.path.abspath(args.destination or os.getcwd())
104
+ with open_archive(args.archive) as zf:
105
+ result = zf.inspect(destination, policy)
106
+ errors = sum(v.action is ViolationAction.ERROR for v in result.violations)
107
+ if args.json:
108
+ write_json(
109
+ ctx.stdout,
110
+ {
111
+ "ok": errors == 0,
112
+ "archive": args.archive,
113
+ "destination": destination,
114
+ "inspection": result,
115
+ },
116
+ )
117
+ else:
118
+ _render_inspection(
119
+ ctx,
120
+ args.archive,
121
+ destination,
122
+ result,
123
+ errors,
124
+ args.quiet,
125
+ )
126
+ return EXIT_FAILURE if errors else EXIT_OK
127
+
128
+
129
+ def register(subparsers: Subparsers) -> None:
130
+ parser = add_command(
131
+ subparsers,
132
+ "inspect",
133
+ cmd_inspect,
134
+ "report what extracting would flag, without reading any data",
135
+ )
136
+ parser.add_argument("archive", metavar="ARCHIVE", help="the archive to inspect")
137
+ parser.add_argument(
138
+ "-d",
139
+ "--destination",
140
+ metavar="DIR",
141
+ help="directory the members would be extracted into, used to detect "
142
+ "overwrites. Default: the current directory",
143
+ )
144
+ add_output_options(parser, quiet=True)
145
+ add_policy_options(parser)
@@ -0,0 +1,166 @@
1
+ """The ``list`` command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import stat
6
+ from dataclasses import dataclass
7
+ from typing import Protocol
8
+
9
+ from zipctl.cli.archive import open_archive
10
+ from zipctl.cli.commands.helpers.command import Subparsers, add_command
11
+ from zipctl.cli.commands.helpers.output_options import OutputArgs, add_output_options
12
+ from zipctl.cli.commands.helpers.selection import MEMBER_HELP, select_infos
13
+ from zipctl.cli.context import Context
14
+ from zipctl.cli.errors import EXIT_OK
15
+ from zipctl.cli.methods import compression_label, method_of
16
+ from zipctl.cli.output import (
17
+ count,
18
+ format_table,
19
+ human_size,
20
+ printable,
21
+ write_json,
22
+ )
23
+ from zipctl.zipfile.file import ZipFile
24
+ from zipctl.zipfile.info import ZipInfo
25
+
26
+
27
+ class ListArgs(OutputArgs, Protocol):
28
+ archive: str
29
+ members: list[str]
30
+ long: bool
31
+
32
+
33
+ def _timestamp(info: ZipInfo, separator: str) -> str:
34
+ year, month, day, hour, minute, second = info.date_time
35
+ date = f"{year:04d}-{month:02d}-{day:02d}"
36
+ return f"{date}{separator}{hour:02d}:{minute:02d}:{second:02d}"
37
+
38
+
39
+ def _mode_text(info: ZipInfo) -> str:
40
+ mode = info.unix_mode
41
+ if not mode:
42
+ return "-"
43
+ if not stat.S_IFMT(mode): # permissions only: the kind comes from the name
44
+ mode |= stat.S_IFDIR if info.is_dir() else stat.S_IFREG
45
+ return stat.filemode(mode)
46
+
47
+
48
+ def _saved(compressed: int, original: int) -> str:
49
+ """The share of *original* that compression saved (``-`` for no data).
50
+
51
+ Like ``unzip -v``: stored data saves ``0%``, data that grew is negative.
52
+ """
53
+ return f"{round(100 * (1 - compressed / original))}%" if original else "-"
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class Listed:
58
+ """One member as ``list --json`` reports it."""
59
+
60
+ name: str
61
+ directory: bool
62
+ is_symlink: bool
63
+ size: int
64
+ compressed_size: int
65
+ modified: str
66
+ compression: str
67
+ encryption: str
68
+ crc32: str
69
+ mode: str | None
70
+ comment: str
71
+
72
+
73
+ def _listed(info: ZipInfo) -> Listed:
74
+ mode = info.unix_mode
75
+ return Listed(
76
+ info.filename,
77
+ info.is_dir(),
78
+ info.is_symlink(),
79
+ info.file_size,
80
+ info.compress_size,
81
+ _timestamp(info, "T"),
82
+ compression_label(info),
83
+ method_of(info).label,
84
+ f"{info.CRC:08x}",
85
+ f"{mode:o}" if mode else None,
86
+ info.comment.decode("utf-8", "replace"),
87
+ )
88
+
89
+
90
+ def _list_json(ctx: Context, zf: ZipFile, archive: str, infos: list[ZipInfo]) -> None:
91
+ write_json(
92
+ ctx.stdout,
93
+ {
94
+ "ok": True,
95
+ "archive": archive,
96
+ "comment": zf.comment.decode("utf-8", "replace"),
97
+ "member_count": len(infos),
98
+ "members": [_listed(info) for info in infos],
99
+ },
100
+ )
101
+
102
+
103
+ def _crc_text(info: ZipInfo) -> str:
104
+ """The CRC-32, or ``-`` where AE-2 does not store it."""
105
+ return f"{info.CRC:08x}" if info.stores_crc else "-"
106
+
107
+
108
+ def _list_long(ctx: Context, zf: ZipFile, infos: list[ZipInfo]) -> None:
109
+ encrypted = any(method_of(info).is_encrypted for info in infos)
110
+ headers = ["Mode", "Length", "Compressed", "Saved", "Modified", "Method"]
111
+ if encrypted:
112
+ headers.append("Encryption")
113
+ headers += ["CRC-32", "Name"]
114
+ rows: list[list[str]] = []
115
+ for info in infos:
116
+ row = [
117
+ _mode_text(info),
118
+ str(info.file_size),
119
+ str(info.compress_size),
120
+ _saved(info.compress_size, info.file_size),
121
+ _timestamp(info, " "),
122
+ compression_label(info),
123
+ ]
124
+ if encrypted:
125
+ row.append(method_of(info).label)
126
+ rows.append([*row, _crc_text(info), printable(info.filename)])
127
+ for line in format_table(headers, rows, right_aligned=(1, 2, 3)):
128
+ ctx.out(line)
129
+ total = sum(info.file_size for info in infos)
130
+ stored = sum(info.compress_size for info in infos)
131
+ footer = f"{count(len(infos), 'member')}, {human_size(total)}"
132
+ if total:
133
+ footer += f" ({human_size(stored)} compressed, {_saved(stored, total)} saved)"
134
+ ctx.out("")
135
+ ctx.out(footer)
136
+ if zf.comment:
137
+ ctx.out("")
138
+ ctx.out("Comment:")
139
+ for line in zf.comment.decode("utf-8", "replace").splitlines():
140
+ ctx.out(f" {printable(line)}")
141
+
142
+
143
+ def cmd_list(args: ListArgs, ctx: Context) -> int:
144
+ with open_archive(args.archive) as zf:
145
+ infos = select_infos(zf.infolist(), args.members)
146
+ if args.json:
147
+ _list_json(ctx, zf, args.archive, infos)
148
+ elif args.long:
149
+ _list_long(ctx, zf, infos)
150
+ else:
151
+ for info in infos:
152
+ ctx.out(printable(info.filename))
153
+ return EXIT_OK
154
+
155
+
156
+ def register(subparsers: Subparsers) -> None:
157
+ parser = add_command(subparsers, "list", cmd_list, "list archive members")
158
+ parser.add_argument("archive", metavar="ARCHIVE", help="the archive to list")
159
+ parser.add_argument("members", nargs="*", metavar="MEMBER", help=MEMBER_HELP)
160
+ parser.add_argument(
161
+ "-l",
162
+ "--long",
163
+ action="store_true",
164
+ help="show modes, sizes, space saved, dates, methods and the archive comment",
165
+ )
166
+ add_output_options(parser)