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,262 @@
1
+ """The ``extract`` command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import warnings
7
+ from collections.abc import Callable
8
+ from dataclasses import replace
9
+ from typing import Protocol
10
+
11
+ from zipctl.cli.archive import open_archive
12
+ from zipctl.cli.commands.helpers.command import Subparsers, add_command
13
+ from zipctl.cli.commands.helpers.extract_report import report_extraction, report_plain
14
+ from zipctl.cli.commands.helpers.output_options import (
15
+ OutputArgs,
16
+ OutputOptions,
17
+ add_output_options,
18
+ )
19
+ from zipctl.cli.commands.helpers.passwords.options import (
20
+ PasswordArgs,
21
+ PasswordOptions,
22
+ add_password_options,
23
+ password_pool,
24
+ )
25
+ from zipctl.cli.commands.helpers.passwords.pool import PasswordPool, PasswordProblem
26
+ from zipctl.cli.commands.helpers.policy_options import (
27
+ PolicyArgs,
28
+ add_policy_options,
29
+ load_policy,
30
+ )
31
+ from zipctl.cli.commands.helpers.progress import (
32
+ ProgressArgs,
33
+ ProgressRenderer,
34
+ add_progress_option,
35
+ progress_renderer,
36
+ )
37
+ from zipctl.cli.commands.helpers.selection import MEMBER_HELP, select_infos
38
+ from zipctl.cli.context import Context
39
+ from zipctl.cli.errors import (
40
+ EXIT_FAILURE,
41
+ EXIT_OK,
42
+ CliError,
43
+ UsageError,
44
+ os_error_text,
45
+ )
46
+ from zipctl.cli.output import printable
47
+ from zipctl.exceptions import BadPassword, BadZipFile, PasswordError, PasswordRequired
48
+ from zipctl.zipfile.exceptions import ExtractionFailure
49
+ from zipctl.zipfile.extract import (
50
+ ExtractionError,
51
+ ExtractPolicy,
52
+ ExtractResult,
53
+ OverwritePolicy,
54
+ )
55
+ from zipctl.zipfile.file import ZipFile
56
+ from zipctl.zipfile.info import ZipInfo
57
+
58
+
59
+ class ExtractArgs(OutputArgs, PasswordArgs, PolicyArgs, ProgressArgs, Protocol):
60
+ archive: str
61
+ members: list[str]
62
+ match: list[str]
63
+ destination: str | None
64
+ no_policy: bool
65
+ dry_run: bool
66
+ overwrite: str | None
67
+ no_fsync: bool
68
+
69
+
70
+ def _extraction_policy(args: ExtractArgs, ctx: Context) -> ExtractPolicy:
71
+ policy = load_policy(args, ctx)
72
+ if args.overwrite is not None:
73
+ policy = replace(policy, overwrite_policy=OverwritePolicy(args.overwrite))
74
+ if args.no_fsync:
75
+ policy = replace(policy, fsync_files=False)
76
+ if args.dry_run:
77
+ policy = replace(policy, preview_only=True)
78
+ return policy
79
+
80
+
81
+ def _reject_conflicting_options(args: ExtractArgs) -> None:
82
+ if not args.no_policy:
83
+ return
84
+ used = [
85
+ flag
86
+ for flag, value in (
87
+ ("--policy", args.policy),
88
+ ("--policy-json", args.policy_json),
89
+ ("--dry-run", args.dry_run),
90
+ ("--overwrite", args.overwrite),
91
+ ("--no-fsync", args.no_fsync),
92
+ )
93
+ if value not in (None, False)
94
+ ]
95
+ if used:
96
+ raise UsageError(
97
+ f"--no-policy cannot be combined with {', '.join(used)}; those options "
98
+ "configure the policy that --no-policy turns off",
99
+ )
100
+
101
+
102
+ def _password_provider(
103
+ pool: PasswordPool, zf: ZipFile, renderer: ProgressRenderer | None
104
+ ) -> Callable[[ZipInfo], bytes | None]:
105
+ """A ``pwd=`` callable that finds (or asks for) each member's password."""
106
+
107
+ def provide(info: ZipInfo) -> bytes | None:
108
+ if renderer is not None:
109
+ renderer.clear() # keep a prompt from colliding with the status line
110
+ resolved = pool.resolve(zf, info)
111
+ if not isinstance(resolved, PasswordProblem):
112
+ return resolved
113
+ if resolved is PasswordProblem.CORRUPT:
114
+ raise BadZipFile(resolved.text)
115
+ if resolved is PasswordProblem.WRONG:
116
+ raise BadPassword(resolved.text)
117
+ raise PasswordRequired(resolved.text)
118
+
119
+ return provide
120
+
121
+
122
+ def _run_extraction(
123
+ zf: ZipFile,
124
+ ctx: Context,
125
+ pool: PasswordPool,
126
+ policy: ExtractPolicy | None,
127
+ members: list[ZipInfo] | None,
128
+ destination: str,
129
+ *,
130
+ progress: bool,
131
+ quiet: bool,
132
+ ) -> ExtractResult | None:
133
+ """Extract; the result is ``None`` for a plain (no policy) extraction."""
134
+ result = None
135
+ with (
136
+ progress_renderer(ctx.stderr, progress) as renderer,
137
+ warnings.catch_warnings(record=True) as caught,
138
+ ):
139
+ warnings.simplefilter("always")
140
+ provider = _password_provider(pool, zf, renderer)
141
+ try:
142
+ if policy is None:
143
+ zf.extractall(
144
+ destination, members=members, pwd=provider, progress=renderer
145
+ )
146
+ else:
147
+ result = zf.extractall(
148
+ destination,
149
+ members=members,
150
+ pwd=provider,
151
+ policy=policy,
152
+ progress=renderer,
153
+ )
154
+ except ExtractionError as exc:
155
+ result = exc.result
156
+ except (
157
+ ExtractionFailure,
158
+ PasswordError,
159
+ BadZipFile,
160
+ NotImplementedError,
161
+ ) as exc:
162
+ raise CliError(f"cannot extract: {printable(str(exc))}") from None
163
+ except OSError as exc:
164
+ raise CliError(f"cannot extract: {printable(os_error_text(exc))}") from None
165
+ # A policy violation is reported with the result; anything else is news.
166
+ reported: set[str] = {v.message for v in result.violations} if result else set()
167
+ for warning in caught:
168
+ text = str(warning.message)
169
+ if text not in reported and not quiet:
170
+ ctx.warn(printable(text))
171
+ return result
172
+
173
+
174
+ def cmd_extract(args: ExtractArgs, ctx: Context) -> int:
175
+ _reject_conflicting_options(args)
176
+ output = OutputOptions.from_args(args)
177
+ policy = None if args.no_policy else _extraction_policy(args, ctx)
178
+ destination = args.destination or os.getcwd()
179
+ with open_archive(args.archive) as zf:
180
+ patterns = [*args.members, *args.match]
181
+ members = select_infos(zf.infolist(), patterns) if patterns else None
182
+ pool = password_pool(PasswordOptions.from_args(args), ctx)
183
+ result = _run_extraction(
184
+ zf,
185
+ ctx,
186
+ pool,
187
+ policy,
188
+ members,
189
+ destination,
190
+ progress=args.progress,
191
+ quiet=output.quiet,
192
+ )
193
+ extracted = len(members) if members is not None else len(zf.infolist())
194
+ if result is None:
195
+ report_plain(ctx, args.archive, destination, extracted, output)
196
+ return EXIT_OK
197
+ report_extraction(
198
+ ctx,
199
+ args.archive,
200
+ destination,
201
+ result,
202
+ output,
203
+ )
204
+ return EXIT_FAILURE if result.failed_count > 0 else EXIT_OK
205
+
206
+
207
+ def register(subparsers: Subparsers) -> None:
208
+ parser = add_command(
209
+ subparsers,
210
+ "extract",
211
+ cmd_extract,
212
+ "extract members, enforcing an extraction policy",
213
+ )
214
+ parser.add_argument(
215
+ "archive", metavar="ARCHIVE", help="the archive to extract from"
216
+ )
217
+ parser.add_argument(
218
+ "members",
219
+ nargs="*",
220
+ metavar="MEMBER",
221
+ help=MEMBER_HELP,
222
+ )
223
+ parser.add_argument(
224
+ "--match",
225
+ action="append",
226
+ default=[],
227
+ metavar="GLOB",
228
+ help="same as a MEMBER pattern (repeatable); a pattern that matches "
229
+ "nothing is an error",
230
+ )
231
+ parser.add_argument(
232
+ "-d",
233
+ "--destination",
234
+ metavar="DIR",
235
+ help="directory to extract into. Default: the current directory",
236
+ )
237
+ parser.add_argument(
238
+ "--no-policy",
239
+ action="store_true",
240
+ help="extract with no limits or rules (path traversal is still neutralised)",
241
+ )
242
+ parser.add_argument(
243
+ "--dry-run",
244
+ action="store_true",
245
+ help="assess every member and report, writing nothing",
246
+ )
247
+ parser.add_argument(
248
+ "--overwrite",
249
+ choices=[choice.value for choice in OverwritePolicy],
250
+ metavar="MODE",
251
+ help="what to do when a target already exists: error, skip, replace or "
252
+ "rename. Default: error",
253
+ )
254
+ parser.add_argument(
255
+ "--no-fsync",
256
+ action="store_true",
257
+ help="do not fsync each file (faster, but less durable)",
258
+ )
259
+ add_progress_option(parser)
260
+ add_output_options(parser, verbose_help="list every member", quiet=True)
261
+ add_policy_options(parser)
262
+ add_password_options(parser)
@@ -0,0 +1 @@
1
+ """Code only the commands use: their option parsing, reporting and shared steps."""
@@ -0,0 +1,225 @@
1
+ """Turning the paths given to ``create`` into the entries to add."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import stat
7
+ from collections.abc import Callable, Sequence
8
+ from dataclasses import dataclass
9
+
10
+ from zipctl.cli.commands.helpers.selection import glob_matcher
11
+ from zipctl.cli.errors import CliError, os_error_filename, os_error_text
12
+ from zipctl.cli.output import printable
13
+
14
+ __all__ = ["Collector", "Entry"]
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Entry:
19
+ """One thing to add: the file on disk and its name in the archive."""
20
+
21
+ source: str
22
+ arcname: str
23
+ is_dir: bool
24
+ link_target: str | None = None # set for a symbolic link stored as a link
25
+
26
+
27
+ def _arcname(given: str) -> str:
28
+ """The archive name for command-line *given*, or refuse one that escapes."""
29
+ path = os.path.splitdrive(os.path.normpath(given))[1].lstrip("/\\")
30
+ if path == ".":
31
+ return ""
32
+ if path == ".." or path.startswith(("../", "..\\")):
33
+ raise CliError(
34
+ f"{printable(given)}: outside the current directory, so it cannot be "
35
+ "stored under a safe name (use -C DIR to choose the base directory)"
36
+ )
37
+ return path.replace(os.sep, "/")
38
+
39
+
40
+ def _stored_name(name: str) -> str:
41
+ try:
42
+ name.encode("utf-8")
43
+ except UnicodeEncodeError:
44
+ raise CliError(
45
+ f"cannot store {printable(name)}: the file name is not valid UTF-8"
46
+ ) from None
47
+ return name
48
+
49
+
50
+ def _unreadable(exc: OSError) -> None:
51
+ """``os.walk`` would skip a directory it cannot list; a backup must not."""
52
+ where = printable(os_error_filename(exc) or "")
53
+ raise CliError(f"cannot read {where}: {os_error_text(exc)}") from None
54
+
55
+
56
+ def _join(prefix: str, name: str) -> str:
57
+ return f"{prefix}/{name}".lstrip("/")
58
+
59
+
60
+ def _not_a_file(path: str) -> str | None:
61
+ """Why *path* cannot be added as a file, or ``None`` when it is a regular one."""
62
+ try:
63
+ mode = os.stat(path).st_mode
64
+ except OSError as exc:
65
+ broken = isinstance(exc, FileNotFoundError) and os.path.islink(path)
66
+ return "broken symbolic link" if broken else os_error_text(exc)
67
+ return None if stat.S_ISREG(mode) else "not a regular file"
68
+
69
+
70
+ class Collector:
71
+ """Walks the command-line paths and lists what to add, and what was skipped."""
72
+
73
+ def __init__(
74
+ self,
75
+ base: str | None,
76
+ archive: str,
77
+ exclude: Sequence[str] = (),
78
+ symlinks: str = "follow",
79
+ ) -> None:
80
+ """*symlinks* is ``follow``, ``store`` or ``skip`` (see ``--symlinks``)."""
81
+ self.base: str | None = base
82
+ self.symlinks: str = symlinks
83
+ try:
84
+ self._archive_stat: os.stat_result | None = os.stat(archive)
85
+ except OSError: # not written yet, so nothing collected can be it
86
+ self._archive_stat = None
87
+ self.entries: list[Entry] = []
88
+ self.skipped: list[tuple[str, str]] = []
89
+ self._seen: set[str] = set()
90
+ self._exclude: list[tuple[str, Callable[[str], bool]]] = [
91
+ (p, glob_matcher(p.rstrip("/") or p)) for p in exclude
92
+ ]
93
+ self._used: set[str] = set()
94
+
95
+ def unused_excludes(self) -> list[str]:
96
+ """The ``--exclude`` patterns that matched nothing (likely typos)."""
97
+ return [p for p, _ in self._exclude if p not in self._used]
98
+
99
+ def _excluded(self, arcname: str, is_dir: bool) -> bool:
100
+ """Whether an exclude pattern names *arcname*.
101
+
102
+ A pattern with a ``/`` in it is matched against the whole archive name;
103
+ one without is matched against the last name only, at any depth. A
104
+ directory is also matched as ``name/``, so ``build/**`` leaves out
105
+ ``build`` itself, as ``zip -x`` and ``tar --exclude`` do.
106
+ """
107
+ name = arcname.rstrip("/")
108
+ last = name.rsplit("/", 1)[-1]
109
+ excluded = False
110
+ for pattern, matches in self._exclude:
111
+ if "/" in pattern.rstrip("/"):
112
+ hit = matches(name) or (is_dir and matches(name + "/"))
113
+ else:
114
+ hit = matches(last)
115
+ if hit:
116
+ self._used.add(pattern)
117
+ excluded = True
118
+ return excluded
119
+
120
+ def _is_archive(self, source: str) -> bool:
121
+ if self._archive_stat is None:
122
+ return False
123
+ try:
124
+ return os.path.samestat(self._archive_stat, os.stat(source))
125
+ except OSError: # vanished or unreadable: reported when it is added
126
+ return False
127
+
128
+ def _add(
129
+ self,
130
+ source: str,
131
+ arcname: str,
132
+ is_dir: bool,
133
+ link_target: str | None = None,
134
+ ) -> None:
135
+ if link_target is None and self._is_archive(source):
136
+ self.skipped.append((source, "it is the archive being written"))
137
+ return
138
+ key = arcname + "/" if is_dir else arcname
139
+ if key in self._seen:
140
+ return
141
+ self._seen.add(key)
142
+ self.entries.append(Entry(source, _stored_name(key), is_dir, link_target))
143
+
144
+ def _link(self, source: str, arcname: str) -> None:
145
+ """Handle the symbolic link *source* per ``--symlinks`` (not ``follow``)."""
146
+ if self.symlinks == "skip":
147
+ self._skip(source, "symbolic link")
148
+ return
149
+ target = os.readlink(source)
150
+ try:
151
+ target.encode("utf-8")
152
+ except UnicodeEncodeError:
153
+ raise CliError(
154
+ f"cannot store the link {printable(source)}: its target is not "
155
+ "valid UTF-8"
156
+ ) from None
157
+ self._add(source, arcname, False, target)
158
+
159
+ def _skip(self, source: str, reason: str) -> None:
160
+ self.skipped.append((os.path.normpath(source), reason))
161
+
162
+ def add_path(self, given: str) -> None:
163
+ """Add a path named on the command line; one that cannot be read is fatal."""
164
+ source = (
165
+ given
166
+ if os.path.isabs(given) or not self.base
167
+ else os.path.join(self.base, given)
168
+ )
169
+ arc = _arcname(given)
170
+ if arc and self._excluded(arc, os.path.isdir(source)):
171
+ return
172
+ if self.symlinks != "follow" and os.path.islink(source):
173
+ self._link(source, arc or os.path.basename(source))
174
+ return
175
+ try:
176
+ mode = os.stat(source).st_mode
177
+ except OSError as exc:
178
+ raise CliError(
179
+ f"cannot read {printable(given)}: {os_error_text(exc)}"
180
+ ) from None
181
+ if stat.S_ISDIR(mode):
182
+ self._walk(source, arc)
183
+ elif stat.S_ISREG(mode):
184
+ self._add(source, arc or os.path.basename(source), False)
185
+ else:
186
+ raise CliError(f"{printable(given)}: not a regular file or directory")
187
+
188
+ def _walk(self, top: str, arc: str) -> None:
189
+ """Add what is under *top*; what cannot be read is skipped with a reason."""
190
+ if arc:
191
+ self._add(top, arc, True)
192
+ for root, dirs, files in os.walk(top, onerror=_unreadable):
193
+ dirs.sort()
194
+ files.sort()
195
+ here = os.path.relpath(root, top)
196
+ prefix = arc if here == "." else f"{arc}/{here}".lstrip("/")
197
+ prefix = prefix.replace(os.sep, "/")
198
+ self._walk_dirs(root, dirs, prefix)
199
+ for name in files:
200
+ path = os.path.join(root, name)
201
+ arcname = _join(prefix, name)
202
+ if self._excluded(arcname, False):
203
+ continue
204
+ if self.symlinks != "follow" and os.path.islink(path):
205
+ self._link(path, arcname)
206
+ elif reason := _not_a_file(path):
207
+ self._skip(path, reason)
208
+ else:
209
+ self._add(path, arcname, False)
210
+
211
+ def _walk_dirs(self, root: str, dirs: list[str], prefix: str) -> None:
212
+ """Add the directories of one level; *dirs* keeps those to descend into."""
213
+ for name in list(dirs):
214
+ path = os.path.join(root, name)
215
+ arcname = _join(prefix, name)
216
+ if self._excluded(arcname, True):
217
+ dirs.remove(name)
218
+ elif os.path.islink(path):
219
+ dirs.remove(name)
220
+ if self.symlinks == "store":
221
+ self._link(path, arcname)
222
+ else:
223
+ self._skip(path, "symbolic link to a directory")
224
+ else:
225
+ self._add(path, arcname, True)
@@ -0,0 +1,119 @@
1
+ """How a subcommand is added to the parser."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from collections.abc import Callable, Sequence
7
+ from difflib import get_close_matches
8
+ from typing import TYPE_CHECKING, TypeVar, cast
9
+
10
+ from typing_extensions import override
11
+
12
+ from zipctl.cli.context import Context
13
+ from zipctl.cli.formatter import HelpFormatter
14
+
15
+ __all__ = ["CommandsAction", "Handler", "Subparsers", "add_command", "add_subcommands"]
16
+
17
+ _Args = TypeVar("_Args")
18
+
19
+ Handler = Callable[[argparse.Namespace, Context], int]
20
+
21
+ # argparse's subparsers action is generic only for type checkers
22
+ if TYPE_CHECKING:
23
+ Subparsers = argparse._SubParsersAction[argparse.ArgumentParser] # pyright: ignore[reportPrivateUsage]
24
+ else:
25
+ Subparsers = argparse._SubParsersAction
26
+
27
+
28
+ def add_command(
29
+ subparsers: Subparsers,
30
+ name: str,
31
+ handler: Callable[[_Args, Context], int] | None,
32
+ help_text: str,
33
+ description: str | None = None,
34
+ ) -> argparse.ArgumentParser:
35
+ """Add subcommand *name* running *handler* to *subparsers*.
36
+
37
+ *handler* is ``None`` for a command that only groups further subcommands.
38
+ Its *_Args* is the shape of the options the command adds: the namespace
39
+ argparse builds is cast to it, the one place that trusts the parser.
40
+ """
41
+ parser: argparse.ArgumentParser = subparsers.add_parser(
42
+ name,
43
+ help=help_text,
44
+ description=description or help_text,
45
+ allow_abbrev=False,
46
+ formatter_class=HelpFormatter,
47
+ )
48
+ if handler is not None:
49
+
50
+ def run(args: argparse.Namespace, ctx: Context) -> int:
51
+ return handler(cast("_Args", args), ctx)
52
+
53
+ parser.set_defaults(handler=run)
54
+ return parser
55
+
56
+
57
+ class CommandsAction(Subparsers):
58
+ """The command choice, suggesting the nearest command for a mistyped one."""
59
+
60
+ def __init__(
61
+ self,
62
+ option_strings: Sequence[str],
63
+ prog: str,
64
+ parser_class: type[argparse.ArgumentParser],
65
+ dest: str = argparse.SUPPRESS,
66
+ required: bool = False,
67
+ help: str | None = None, # noqa: A002 # mirrors argparse.Action
68
+ metavar: str | tuple[str, ...] | None = None,
69
+ ) -> None:
70
+ super().__init__(
71
+ option_strings, prog, parser_class, dest, required, help, metavar
72
+ )
73
+ # argparse would reject an unknown name before __call__ gets to suggest one
74
+ self.choices = None # type: ignore[assignment] # ty: ignore[invalid-assignment] # pyright: ignore[reportAttributeAccessIssue, reportUnannotatedClassAttribute]
75
+
76
+ @override
77
+ def __call__(
78
+ self,
79
+ parser: argparse.ArgumentParser,
80
+ namespace: argparse.Namespace,
81
+ values: str | Sequence[str] | None,
82
+ option_string: str | None = None,
83
+ ) -> None:
84
+ assert values is not None
85
+ assert not isinstance(values, str)
86
+ name = str(values[0])
87
+ if name not in self._name_parser_map:
88
+ lines = [f"unknown command {name!r}"]
89
+ for close in get_close_matches(name, self._name_parser_map, n=1):
90
+ lines.append(f" Did you mean {close!r}?")
91
+ lines.append(f" Run '{parser.prog} --help' to see all commands.")
92
+ parser.error("\n".join(lines))
93
+ super().__call__(parser, namespace, values, option_string)
94
+ # argparse reports these at the top; the command's own usage is more useful
95
+ extras: list[str] | None = getattr(
96
+ namespace,
97
+ argparse._UNRECOGNIZED_ARGS_ATTR, # pyright: ignore[reportPrivateUsage]
98
+ None,
99
+ )
100
+ if extras:
101
+ sub = self._name_parser_map[name]
102
+ sub.error(
103
+ f"unrecognized arguments: {' '.join(extras)}\n"
104
+ f" Run '{sub.prog} --help' to see all options."
105
+ )
106
+
107
+
108
+ def add_subcommands(parser: argparse.ArgumentParser, dest: str) -> Subparsers:
109
+ """Give *parser* a ``Commands`` section listing the subcommands added to it."""
110
+ subparsers = parser.add_subparsers(
111
+ action=CommandsAction,
112
+ title="Commands",
113
+ dest=dest,
114
+ metavar="COMMAND",
115
+ required=True,
116
+ )
117
+ groups = parser._action_groups
118
+ groups.insert(1, groups.pop()) # "Commands" before "Options"
119
+ return subparsers
@@ -0,0 +1 @@
1
+ """How the copying commands (encrypt, decrypt, rewrite) copy an archive."""