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.
- zipctl/__init__.py +108 -0
- zipctl/__main__.py +5 -0
- zipctl/cli/__init__.py +108 -0
- zipctl/cli/archive.py +23 -0
- zipctl/cli/atomic.py +77 -0
- zipctl/cli/commands/__init__.py +8 -0
- zipctl/cli/commands/check_password.py +133 -0
- zipctl/cli/commands/create.py +374 -0
- zipctl/cli/commands/decrypt.py +70 -0
- zipctl/cli/commands/encrypt.py +102 -0
- zipctl/cli/commands/extract.py +262 -0
- zipctl/cli/commands/helpers/__init__.py +1 -0
- zipctl/cli/commands/helpers/collect.py +225 -0
- zipctl/cli/commands/helpers/command.py +119 -0
- zipctl/cli/commands/helpers/copying/__init__.py +1 -0
- zipctl/cli/commands/helpers/copying/copy.py +211 -0
- zipctl/cli/commands/helpers/copying/options.py +80 -0
- zipctl/cli/commands/helpers/copying/report.py +63 -0
- zipctl/cli/commands/helpers/copying/targets.py +76 -0
- zipctl/cli/commands/helpers/copying/verify.py +111 -0
- zipctl/cli/commands/helpers/create_report.py +154 -0
- zipctl/cli/commands/helpers/encryption/__init__.py +1 -0
- zipctl/cli/commands/helpers/encryption/options.py +192 -0
- zipctl/cli/commands/helpers/encryption/plan.py +119 -0
- zipctl/cli/commands/helpers/encryption/spec.py +256 -0
- zipctl/cli/commands/helpers/extract_report.py +148 -0
- zipctl/cli/commands/helpers/output_options.py +55 -0
- zipctl/cli/commands/helpers/passwords/__init__.py +1 -0
- zipctl/cli/commands/helpers/passwords/options.py +143 -0
- zipctl/cli/commands/helpers/passwords/pool.py +116 -0
- zipctl/cli/commands/helpers/passwords/sources.py +144 -0
- zipctl/cli/commands/helpers/policy_options.py +64 -0
- zipctl/cli/commands/helpers/progress.py +215 -0
- zipctl/cli/commands/helpers/selection.py +96 -0
- zipctl/cli/commands/helpers/sources.py +32 -0
- zipctl/cli/commands/inspect.py +145 -0
- zipctl/cli/commands/list.py +166 -0
- zipctl/cli/commands/policy.py +104 -0
- zipctl/cli/commands/rewrite.py +113 -0
- zipctl/cli/commands/test.py +154 -0
- zipctl/cli/context.py +57 -0
- zipctl/cli/errors.py +58 -0
- zipctl/cli/formatter.py +62 -0
- zipctl/cli/methods.py +136 -0
- zipctl/cli/output.py +138 -0
- zipctl/cli/parser.py +197 -0
- zipctl/compression/__init__.py +129 -0
- zipctl/compression/bz2.py +99 -0
- zipctl/compression/deflate.py +136 -0
- zipctl/compression/lzma.py +238 -0
- zipctl/compression/methods.py +176 -0
- zipctl/compression/stored.py +14 -0
- zipctl/compression/zstd.py +141 -0
- zipctl/cryptography/__init__.py +41 -0
- zipctl/cryptography/aes.py +500 -0
- zipctl/cryptography/base.py +163 -0
- zipctl/cryptography/zipcrypto.py +251 -0
- zipctl/exceptions.py +39 -0
- zipctl/py.typed +0 -0
- zipctl/zipfile/__init__.py +1 -0
- zipctl/zipfile/assessment.py +41 -0
- zipctl/zipfile/assessor.py +151 -0
- zipctl/zipfile/exceptions.py +27 -0
- zipctl/zipfile/ext.py +782 -0
- zipctl/zipfile/extract.py +186 -0
- zipctl/zipfile/file.py +1500 -0
- zipctl/zipfile/info.py +1127 -0
- zipctl/zipfile/inspection.py +127 -0
- zipctl/zipfile/io_wrappers.py +193 -0
- zipctl/zipfile/materialize.py +344 -0
- zipctl/zipfile/password.py +87 -0
- zipctl/zipfile/path/__init__.py +523 -0
- zipctl/zipfile/path/glob.py +104 -0
- zipctl/zipfile/policy_config.py +426 -0
- zipctl/zipfile/policy_extraction.py +267 -0
- zipctl/zipfile/progress.py +133 -0
- zipctl/zipfile/records.py +504 -0
- zipctl/zipfile/secure_fs.py +128 -0
- zipctl/zipfile/shared.py +104 -0
- zipctl/zipfile/validators.py +392 -0
- zipctl/zipfile/write.py +311 -0
- zipctl/zipfile/write_coordinator.py +111 -0
- zipctl-0.1.0.dist-info/METADATA +730 -0
- zipctl-0.1.0.dist-info/RECORD +91 -0
- zipctl-0.1.0.dist-info/WHEEL +4 -0
- zipctl-0.1.0.dist-info/entry_points.txt +2 -0
- zipctl-0.1.0.dist-info/licenses/LICENSE +21 -0
- zipctl-0.1.0.dist-info/licenses/NOTICE +32 -0
- zipctl-0.1.0.dist-info/licenses/licenses/CPYTHON-3.14.3.txt +277 -0
- zipctl-0.1.0.dist-info/licenses/licenses/JetBrainsMono-OFL.txt +93 -0
- 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."""
|