Marco Ricci commited on 2025-01-29 22:23:26
Zeige 9 geänderte Dateien mit 2226 Einfügungen und 2064 Löschungen.
Split off the `logging` and `click` support code (the "machinery") and the command-specific helper functions (the "helpers") into separate modules under `derivepassphrase._internals`. Change all functions, classes and attributes within those new modules to be public with respect to the module, since the module itself is already non-public. Also document all previously undocumented classes and functions: `cli_helpers.ORIGIN`, `cli_helpers.check_for_misleading_passphrase`, `cli_helpers.key_to_phrase` and `cli_helpers.print_config_as_sh_script`. Add a better default value (`cli_helpers.default_error_callback`) for the `error_callback` parameter of `cli_helpers.key_to_phrase`, and document that too. Finally, since the shell completion parts are now split across two modules, add an explicit TODO to the `cli_machinery.ZshComplete` class to add context.
... | ... |
@@ -29,12 +29,14 @@ nav: |
29 | 29 |
- Technical prerequisites: |
30 | 30 |
- 'Using derivepassphrase vault with an SSH key': reference/prerequisites-ssh-key.md |
31 | 31 |
- 'Internal API docs: Submodule derivepassphrase._internals': |
32 |
+ - Submodule cli_helpers: reference/derivepassphrase._internals.cli_helpers.md |
|
33 |
+ - Submodule cli_machinery: reference/derivepassphrase._internals.cli_machinery.md |
|
32 | 34 |
- Submodule cli_messages: reference/derivepassphrase._internals.cli_messages.md |
33 | 35 |
- 'Internal API docs: Tests': |
34 | 36 |
- Basic testing infrastructure: reference/tests.md |
35 | 37 |
- Localization machinery: reference/tests.test_l10n.md |
36 | 38 |
- derivepassphrase command-line: |
37 |
- - cli module: reference/tests.test_derivepassphrase_cli.md |
|
39 |
+ - cli module, helpers and machinery: reference/tests.test_derivepassphrase_cli.md |
|
38 | 40 |
- '"export vault" subcommand tests': reference/tests.test_derivepassphrase_cli_export_vault.md |
39 | 41 |
- exporter module: reference/tests.test_derivepassphrase_exporter.md |
40 | 42 |
- sequin module: reference/tests.test_derivepassphrase_sequin.md |
... | ... |
@@ -0,0 +1,810 @@ |
1 |
+# SPDX-FileCopyrightText: 2025 Marco Ricci <software@the13thletter.info> |
|
2 |
+# |
|
3 |
+# SPDX-License-Identifier: Zlib |
|
4 |
+ |
|
5 |
+# ruff: noqa: TRY400 |
|
6 |
+ |
|
7 |
+"""Helper functions for the derivepassphrase command-line. |
|
8 |
+ |
|
9 |
+Warning: |
|
10 |
+ Non-public module (implementation detail), provided for didactical and |
|
11 |
+ educational purposes only. Subject to change without notice, including |
|
12 |
+ removal. |
|
13 |
+ |
|
14 |
+""" |
|
15 |
+ |
|
16 |
+from __future__ import annotations |
|
17 |
+ |
|
18 |
+import base64 |
|
19 |
+import copy |
|
20 |
+import enum |
|
21 |
+import json |
|
22 |
+import logging |
|
23 |
+import os |
|
24 |
+import pathlib |
|
25 |
+import shlex |
|
26 |
+import sys |
|
27 |
+import unicodedata |
|
28 |
+from typing import TYPE_CHECKING, Callable, NoReturn, TextIO, cast |
|
29 |
+ |
|
30 |
+import click |
|
31 |
+import click.shell_completion |
|
32 |
+from typing_extensions import Any |
|
33 |
+ |
|
34 |
+import derivepassphrase as dpp |
|
35 |
+from derivepassphrase import _types, ssh_agent, vault |
|
36 |
+from derivepassphrase._internals import cli_messages as _msg |
|
37 |
+ |
|
38 |
+if sys.version_info >= (3, 11): |
|
39 |
+ import tomllib |
|
40 |
+else: |
|
41 |
+ import tomli as tomllib |
|
42 |
+ |
|
43 |
+if TYPE_CHECKING: |
|
44 |
+ import socket |
|
45 |
+ from collections.abc import ( |
|
46 |
+ Iterator, |
|
47 |
+ Sequence, |
|
48 |
+ ) |
|
49 |
+ |
|
50 |
+ from typing_extensions import Buffer |
|
51 |
+ |
|
52 |
+__author__ = dpp.__author__ |
|
53 |
+__version__ = dpp.__version__ |
|
54 |
+ |
|
55 |
+PROG_NAME = _msg.PROG_NAME |
|
56 |
+KEY_DISPLAY_LENGTH = 50 |
|
57 |
+ |
|
58 |
+# Error messages |
|
59 |
+INVALID_VAULT_CONFIG = 'Invalid vault config' |
|
60 |
+AGENT_COMMUNICATION_ERROR = 'Error communicating with the SSH agent' |
|
61 |
+NO_SUITABLE_KEYS = 'No suitable SSH keys were found' |
|
62 |
+EMPTY_SELECTION = 'Empty selection' |
|
63 |
+ |
|
64 |
+ |
|
65 |
+# Shell completion |
|
66 |
+# ================ |
|
67 |
+ |
|
68 |
+# Use naive filename completion for the `path` argument of |
|
69 |
+# `derivepassphrase vault`'s `--import` and `--export` options, as well |
|
70 |
+# as the `path` argument of `derivepassphrase export vault`. The latter |
|
71 |
+# treats the pseudo-filename `VAULT_PATH` specially, but this is awkward |
|
72 |
+# to combine with standard filename completion, particularly in bash, so |
|
73 |
+# we would probably have to implement *all* completion (`VAULT_PATH` and |
|
74 |
+# filename completion) ourselves, lacking some niceties of bash's |
|
75 |
+# built-in completion (e.g., adding spaces or slashes depending on |
|
76 |
+# whether the completion is a directory or a complete filename). |
|
77 |
+ |
|
78 |
+ |
|
79 |
+def shell_complete_path( |
|
80 |
+ ctx: click.Context, |
|
81 |
+ parameter: click.Parameter, |
|
82 |
+ value: str, |
|
83 |
+) -> list[str | click.shell_completion.CompletionItem]: |
|
84 |
+ """Request standard path completion for the `path` argument.""" # noqa: DOC201 |
|
85 |
+ del ctx, parameter, value |
|
86 |
+ return [click.shell_completion.CompletionItem('', type='file')] |
|
87 |
+ |
|
88 |
+ |
|
89 |
+# The standard `click` shell completion scripts serialize the completion |
|
90 |
+# items as newline-separated one-line entries, which get silently |
|
91 |
+# corrupted if the value contains newlines. Each shell imposes |
|
92 |
+# additional restrictions: Fish uses newlines in all internal completion |
|
93 |
+# helper scripts, so it is difficult, if not impossible, to register |
|
94 |
+# completion entries containing newlines if completion comes from within |
|
95 |
+# a Fish completion function (instead of a Fish builtin). Zsh's |
|
96 |
+# completion system supports descriptions for each completion item, and |
|
97 |
+# the completion helper functions parse every entry as a colon-separated |
|
98 |
+# 2-tuple of item and description, meaning any colon in the item value |
|
99 |
+# must be escaped. Finally, Bash requires the result array to be |
|
100 |
+# populated at the completion function's top-level scope, but for/while |
|
101 |
+# loops within pipelines do not run at top-level scope, and Bash *also* |
|
102 |
+# strips NUL characters from command substitution output, making it |
|
103 |
+# difficult to read in external data into an array in a cross-platform |
|
104 |
+# manner from entirely within Bash. |
|
105 |
+# |
|
106 |
+# We capitulate in front of these problems---most egregiously because of |
|
107 |
+# Fish---and ensure that completion items (in this case: service names) |
|
108 |
+# never contain ASCII control characters by refusing to offer such |
|
109 |
+# items as valid completions. On the other side, `derivepassphrase` |
|
110 |
+# will warn the user when configuring or importing a service with such |
|
111 |
+# a name that it will not be available for shell completion. |
|
112 |
+ |
|
113 |
+ |
|
114 |
+def is_completable_item(obj: object) -> bool: |
|
115 |
+ """Return whether the item is completable on the command-line. |
|
116 |
+ |
|
117 |
+ The item is completable if and only if it contains no ASCII control |
|
118 |
+ characters (U+0000 through U+001F, and U+007F). |
|
119 |
+ |
|
120 |
+ """ |
|
121 |
+ obj = str(obj) |
|
122 |
+ forbidden = frozenset(chr(i) for i in range(32)) | {'\x7f'} |
|
123 |
+ return not any(f in obj for f in forbidden) |
|
124 |
+ |
|
125 |
+ |
|
126 |
+def shell_complete_service( |
|
127 |
+ ctx: click.Context, |
|
128 |
+ parameter: click.Parameter, |
|
129 |
+ value: str, |
|
130 |
+) -> list[str | click.shell_completion.CompletionItem]: |
|
131 |
+ """Return known vault service names as completion items. |
|
132 |
+ |
|
133 |
+ Service names are looked up in the vault configuration file. All |
|
134 |
+ errors will be suppressed. Additionally, any service names deemed |
|
135 |
+ not completable as per [`is_completable_item`][] will be silently |
|
136 |
+ skipped. |
|
137 |
+ |
|
138 |
+ """ |
|
139 |
+ del ctx, parameter |
|
140 |
+ try: |
|
141 |
+ config = load_config() |
|
142 |
+ return sorted( |
|
143 |
+ sv |
|
144 |
+ for sv in config['services'] |
|
145 |
+ if sv.startswith(value) and is_completable_item(sv) |
|
146 |
+ ) |
|
147 |
+ except FileNotFoundError: |
|
148 |
+ try: |
|
149 |
+ config, _exc = migrate_and_load_old_config() |
|
150 |
+ return sorted( |
|
151 |
+ sv |
|
152 |
+ for sv in config['services'] |
|
153 |
+ if sv.startswith(value) and is_completable_item(sv) |
|
154 |
+ ) |
|
155 |
+ except FileNotFoundError: |
|
156 |
+ return [] |
|
157 |
+ except Exception: # noqa: BLE001 |
|
158 |
+ return [] |
|
159 |
+ |
|
160 |
+ |
|
161 |
+# Vault |
|
162 |
+# ===== |
|
163 |
+ |
|
164 |
+config_filename_table = { |
|
165 |
+ None: '.', |
|
166 |
+ 'vault': 'vault.json', |
|
167 |
+ 'user configuration': 'config.toml', |
|
168 |
+ # TODO(the-13th-letter): Remove the old settings.json file. |
|
169 |
+ # https://the13thletter.info/derivepassphrase/latest/upgrade-notes.html#v1.0-old-settings-file |
|
170 |
+ 'old settings.json': 'settings.json', |
|
171 |
+} |
|
172 |
+ |
|
173 |
+ |
|
174 |
+def config_filename( |
|
175 |
+ subsystem: str | None = 'old settings.json', |
|
176 |
+) -> pathlib.Path: |
|
177 |
+ """Return the filename of the configuration file for the subsystem. |
|
178 |
+ |
|
179 |
+ The (implicit default) file is currently named `settings.json`, |
|
180 |
+ located within the configuration directory as determined by the |
|
181 |
+ `DERIVEPASSPHRASE_PATH` environment variable, or by |
|
182 |
+ [`click.get_app_dir`][] in POSIX mode. Depending on the requested |
|
183 |
+ subsystem, this will usually be a different file within that |
|
184 |
+ directory. |
|
185 |
+ |
|
186 |
+ Args: |
|
187 |
+ subsystem: |
|
188 |
+ Name of the configuration subsystem whose configuration |
|
189 |
+ filename to return. If not given, return the old filename |
|
190 |
+ from before the subcommand migration. If `None`, return the |
|
191 |
+ configuration directory instead. |
|
192 |
+ |
|
193 |
+ Raises: |
|
194 |
+ AssertionError: |
|
195 |
+ An unknown subsystem was passed. |
|
196 |
+ |
|
197 |
+ Deprecated: |
|
198 |
+ Since v0.2.0: The implicit default subsystem and the old |
|
199 |
+ configuration filename are deprecated, and will be removed in v1.0. |
|
200 |
+ The subsystem will be mandatory to specify. |
|
201 |
+ |
|
202 |
+ """ |
|
203 |
+ path = pathlib.Path( |
|
204 |
+ os.getenv(PROG_NAME.upper() + '_PATH') |
|
205 |
+ or click.get_app_dir(PROG_NAME, force_posix=True) |
|
206 |
+ ) |
|
207 |
+ try: |
|
208 |
+ filename = config_filename_table[subsystem] |
|
209 |
+ except (KeyError, TypeError): # pragma: no cover |
|
210 |
+ msg = f'Unknown configuration subsystem: {subsystem!r}' |
|
211 |
+ raise AssertionError(msg) from None |
|
212 |
+ return path / filename |
|
213 |
+ |
|
214 |
+ |
|
215 |
+def load_config() -> _types.VaultConfig: |
|
216 |
+ """Load a vault(1)-compatible config from the application directory. |
|
217 |
+ |
|
218 |
+ The filename is obtained via [`config_filename`][]. This must be |
|
219 |
+ an unencrypted JSON file. |
|
220 |
+ |
|
221 |
+ Returns: |
|
222 |
+ The vault settings. See [`_types.VaultConfig`][] for details. |
|
223 |
+ |
|
224 |
+ Raises: |
|
225 |
+ OSError: |
|
226 |
+ There was an OS error accessing the file. |
|
227 |
+ ValueError: |
|
228 |
+ The data loaded from the file is not a vault(1)-compatible |
|
229 |
+ config. |
|
230 |
+ |
|
231 |
+ """ |
|
232 |
+ filename = config_filename(subsystem='vault') |
|
233 |
+ with filename.open('rb') as fileobj: |
|
234 |
+ data = json.load(fileobj) |
|
235 |
+ if not _types.is_vault_config(data): |
|
236 |
+ raise ValueError(INVALID_VAULT_CONFIG) |
|
237 |
+ return data |
|
238 |
+ |
|
239 |
+ |
|
240 |
+# TODO(the-13th-letter): Remove this function. |
|
241 |
+# https://the13thletter.info/derivepassphrase/latest/upgrade-notes.html#v1.0-old-settings-file |
|
242 |
+def migrate_and_load_old_config() -> tuple[ |
|
243 |
+ _types.VaultConfig, OSError | None |
|
244 |
+]: |
|
245 |
+ """Load and migrate a vault(1)-compatible config. |
|
246 |
+ |
|
247 |
+ The (old) filename is obtained via [`config_filename`][]. This |
|
248 |
+ must be an unencrypted JSON file. After loading, the file is |
|
249 |
+ migrated to the new standard filename. |
|
250 |
+ |
|
251 |
+ Returns: |
|
252 |
+ The vault settings, and an optional exception encountered during |
|
253 |
+ migration. See [`_types.VaultConfig`][] for details on the |
|
254 |
+ former. |
|
255 |
+ |
|
256 |
+ Raises: |
|
257 |
+ OSError: |
|
258 |
+ There was an OS error accessing the old file. |
|
259 |
+ ValueError: |
|
260 |
+ The data loaded from the file is not a vault(1)-compatible |
|
261 |
+ config. |
|
262 |
+ |
|
263 |
+ """ |
|
264 |
+ new_filename = config_filename(subsystem='vault') |
|
265 |
+ old_filename = config_filename(subsystem='old settings.json') |
|
266 |
+ with old_filename.open('rb') as fileobj: |
|
267 |
+ data = json.load(fileobj) |
|
268 |
+ if not _types.is_vault_config(data): |
|
269 |
+ raise ValueError(INVALID_VAULT_CONFIG) |
|
270 |
+ try: |
|
271 |
+ old_filename.rename(new_filename) |
|
272 |
+ except OSError as exc: |
|
273 |
+ return data, exc |
|
274 |
+ else: |
|
275 |
+ return data, None |
|
276 |
+ |
|
277 |
+ |
|
278 |
+def save_config(config: _types.VaultConfig, /) -> None: |
|
279 |
+ """Save a vault(1)-compatible config to the application directory. |
|
280 |
+ |
|
281 |
+ The filename is obtained via [`config_filename`][]. The config |
|
282 |
+ will be stored as an unencrypted JSON file. |
|
283 |
+ |
|
284 |
+ Args: |
|
285 |
+ config: |
|
286 |
+ vault configuration to save. |
|
287 |
+ |
|
288 |
+ Raises: |
|
289 |
+ OSError: |
|
290 |
+ There was an OS error accessing or writing the file. |
|
291 |
+ ValueError: |
|
292 |
+ The data cannot be stored as a vault(1)-compatible config. |
|
293 |
+ |
|
294 |
+ """ |
|
295 |
+ if not _types.is_vault_config(config): |
|
296 |
+ raise ValueError(INVALID_VAULT_CONFIG) |
|
297 |
+ filename = config_filename(subsystem='vault') |
|
298 |
+ filedir = filename.resolve().parent |
|
299 |
+ filedir.mkdir(parents=True, exist_ok=True) |
|
300 |
+ with filename.open('w', encoding='UTF-8') as fileobj: |
|
301 |
+ json.dump(config, fileobj) |
|
302 |
+ |
|
303 |
+ |
|
304 |
+def load_user_config() -> dict[str, Any]: |
|
305 |
+ """Load the user config from the application directory. |
|
306 |
+ |
|
307 |
+ The filename is obtained via [`config_filename`][]. |
|
308 |
+ |
|
309 |
+ Returns: |
|
310 |
+ The user configuration, as a nested `dict`. |
|
311 |
+ |
|
312 |
+ Raises: |
|
313 |
+ OSError: |
|
314 |
+ There was an OS error accessing the file. |
|
315 |
+ ValueError: |
|
316 |
+ The data loaded from the file is not a valid configuration |
|
317 |
+ file. |
|
318 |
+ |
|
319 |
+ """ |
|
320 |
+ filename = config_filename(subsystem='user configuration') |
|
321 |
+ with filename.open('rb') as fileobj: |
|
322 |
+ return tomllib.load(fileobj) |
|
323 |
+ |
|
324 |
+ |
|
325 |
+def get_suitable_ssh_keys( |
|
326 |
+ conn: ssh_agent.SSHAgentClient | socket.socket | None = None, / |
|
327 |
+) -> Iterator[_types.SSHKeyCommentPair]: |
|
328 |
+ """Yield all SSH keys suitable for passphrase derivation. |
|
329 |
+ |
|
330 |
+ Suitable SSH keys are queried from the running SSH agent (see |
|
331 |
+ [`ssh_agent.SSHAgentClient.list_keys`][]). |
|
332 |
+ |
|
333 |
+ Args: |
|
334 |
+ conn: |
|
335 |
+ An optional connection hint to the SSH agent. See |
|
336 |
+ [`ssh_agent.SSHAgentClient.ensure_agent_subcontext`][]. |
|
337 |
+ |
|
338 |
+ Yields: |
|
339 |
+ Every SSH key from the SSH agent that is suitable for passphrase |
|
340 |
+ derivation. |
|
341 |
+ |
|
342 |
+ Raises: |
|
343 |
+ KeyError: |
|
344 |
+ `conn` was `None`, and the `SSH_AUTH_SOCK` environment |
|
345 |
+ variable was not found. |
|
346 |
+ NotImplementedError: |
|
347 |
+ `conn` was `None`, and this Python does not support |
|
348 |
+ [`socket.AF_UNIX`][], so the SSH agent client cannot be |
|
349 |
+ automatically set up. |
|
350 |
+ OSError: |
|
351 |
+ `conn` was a socket or `None`, and there was an error |
|
352 |
+ setting up a socket connection to the agent. |
|
353 |
+ LookupError: |
|
354 |
+ No keys usable for passphrase derivation are loaded into the |
|
355 |
+ SSH agent. |
|
356 |
+ RuntimeError: |
|
357 |
+ There was an error communicating with the SSH agent. |
|
358 |
+ ssh_agent.SSHAgentFailedError: |
|
359 |
+ The agent failed to supply a list of loaded keys. |
|
360 |
+ |
|
361 |
+ """ |
|
362 |
+ with ssh_agent.SSHAgentClient.ensure_agent_subcontext(conn) as client: |
|
363 |
+ try: |
|
364 |
+ all_key_comment_pairs = list(client.list_keys()) |
|
365 |
+ except EOFError as exc: # pragma: no cover |
|
366 |
+ raise RuntimeError(AGENT_COMMUNICATION_ERROR) from exc |
|
367 |
+ suitable_keys = copy.copy(all_key_comment_pairs) |
|
368 |
+ for pair in all_key_comment_pairs: |
|
369 |
+ key, _comment = pair |
|
370 |
+ if vault.Vault.is_suitable_ssh_key(key, client=client): |
|
371 |
+ yield pair |
|
372 |
+ if not suitable_keys: # pragma: no cover |
|
373 |
+ raise LookupError(NO_SUITABLE_KEYS) |
|
374 |
+ |
|
375 |
+ |
|
376 |
+def prompt_for_selection( |
|
377 |
+ items: Sequence[str | bytes], |
|
378 |
+ heading: str = 'Possible choices:', |
|
379 |
+ single_choice_prompt: str = 'Confirm this choice?', |
|
380 |
+ ctx: click.Context | None = None, |
|
381 |
+) -> int: |
|
382 |
+ """Prompt user for a choice among the given items. |
|
383 |
+ |
|
384 |
+ Print the heading, if any, then present the items to the user. If |
|
385 |
+ there are multiple items, prompt the user for a selection, validate |
|
386 |
+ the choice, then return the list index of the selected item. If |
|
387 |
+ there is only a single item, request confirmation for that item |
|
388 |
+ instead, and return the correct index. |
|
389 |
+ |
|
390 |
+ Args: |
|
391 |
+ items: |
|
392 |
+ The list of items to choose from. |
|
393 |
+ heading: |
|
394 |
+ A heading for the list of items, to print immediately |
|
395 |
+ before. Defaults to a reasonable standard heading. If |
|
396 |
+ explicitly empty, print no heading. |
|
397 |
+ single_choice_prompt: |
|
398 |
+ The confirmation prompt if there is only a single possible |
|
399 |
+ choice. Defaults to a reasonable standard prompt. |
|
400 |
+ ctx: |
|
401 |
+ An optional `click` context, from which output device |
|
402 |
+ properties and color preferences will be queried. |
|
403 |
+ |
|
404 |
+ Returns: |
|
405 |
+ An index into the items sequence, indicating the user's |
|
406 |
+ selection. |
|
407 |
+ |
|
408 |
+ Raises: |
|
409 |
+ IndexError: |
|
410 |
+ The user made an invalid or empty selection, or requested an |
|
411 |
+ abort. |
|
412 |
+ |
|
413 |
+ """ |
|
414 |
+ n = len(items) |
|
415 |
+ color = ctx.color if ctx is not None else None |
|
416 |
+ if heading: |
|
417 |
+ click.echo(click.style(heading, bold=True), color=color) |
|
418 |
+ for i, x in enumerate(items, start=1): |
|
419 |
+ click.echo(click.style(f'[{i}]', bold=True), nl=False, color=color) |
|
420 |
+ click.echo(' ', nl=False, color=color) |
|
421 |
+ click.echo(x, color=color) |
|
422 |
+ if n > 1: |
|
423 |
+ choices = click.Choice([''] + [str(i) for i in range(1, n + 1)]) |
|
424 |
+ choice = click.prompt( |
|
425 |
+ f'Your selection? (1-{n}, leave empty to abort)', |
|
426 |
+ err=True, |
|
427 |
+ type=choices, |
|
428 |
+ show_choices=False, |
|
429 |
+ show_default=False, |
|
430 |
+ default='', |
|
431 |
+ ) |
|
432 |
+ if not choice: |
|
433 |
+ raise IndexError(EMPTY_SELECTION) |
|
434 |
+ return int(choice) - 1 |
|
435 |
+ prompt_suffix = ( |
|
436 |
+ ' ' if single_choice_prompt.endswith(tuple('?.!')) else ': ' |
|
437 |
+ ) |
|
438 |
+ try: |
|
439 |
+ click.confirm( |
|
440 |
+ single_choice_prompt, |
|
441 |
+ prompt_suffix=prompt_suffix, |
|
442 |
+ err=True, |
|
443 |
+ abort=True, |
|
444 |
+ default=False, |
|
445 |
+ show_default=False, |
|
446 |
+ ) |
|
447 |
+ except click.Abort: |
|
448 |
+ raise IndexError(EMPTY_SELECTION) from None |
|
449 |
+ return 0 |
|
450 |
+ |
|
451 |
+ |
|
452 |
+def select_ssh_key( |
|
453 |
+ conn: ssh_agent.SSHAgentClient | socket.socket | None = None, |
|
454 |
+ /, |
|
455 |
+ *, |
|
456 |
+ ctx: click.Context | None = None, |
|
457 |
+) -> bytes | bytearray: |
|
458 |
+ """Interactively select an SSH key for passphrase derivation. |
|
459 |
+ |
|
460 |
+ Suitable SSH keys are queried from the running SSH agent (see |
|
461 |
+ [`ssh_agent.SSHAgentClient.list_keys`][]), then the user is prompted |
|
462 |
+ interactively (see [`click.prompt`][]) for a selection. |
|
463 |
+ |
|
464 |
+ Args: |
|
465 |
+ conn: |
|
466 |
+ An optional connection hint to the SSH agent. See |
|
467 |
+ [`ssh_agent.SSHAgentClient.ensure_agent_subcontext`][]. |
|
468 |
+ ctx: |
|
469 |
+ An `click` context, queried for output device properties and |
|
470 |
+ color preferences when issuing the prompt. |
|
471 |
+ |
|
472 |
+ Returns: |
|
473 |
+ The selected SSH key. |
|
474 |
+ |
|
475 |
+ Raises: |
|
476 |
+ KeyError: |
|
477 |
+ `conn` was `None`, and the `SSH_AUTH_SOCK` environment |
|
478 |
+ variable was not found. |
|
479 |
+ NotImplementedError: |
|
480 |
+ `conn` was `None`, and this Python does not support |
|
481 |
+ [`socket.AF_UNIX`][], so the SSH agent client cannot be |
|
482 |
+ automatically set up. |
|
483 |
+ OSError: |
|
484 |
+ `conn` was a socket or `None`, and there was an error |
|
485 |
+ setting up a socket connection to the agent. |
|
486 |
+ IndexError: |
|
487 |
+ The user made an invalid or empty selection, or requested an |
|
488 |
+ abort. |
|
489 |
+ LookupError: |
|
490 |
+ No keys usable for passphrase derivation are loaded into the |
|
491 |
+ SSH agent. |
|
492 |
+ RuntimeError: |
|
493 |
+ There was an error communicating with the SSH agent. |
|
494 |
+ SSHAgentFailedError: |
|
495 |
+ The agent failed to supply a list of loaded keys. |
|
496 |
+ """ |
|
497 |
+ suitable_keys = list(get_suitable_ssh_keys(conn)) |
|
498 |
+ key_listing: list[str] = [] |
|
499 |
+ unstring_prefix = ssh_agent.SSHAgentClient.unstring_prefix |
|
500 |
+ for key, comment in suitable_keys: |
|
501 |
+ keytype = unstring_prefix(key)[0].decode('ASCII') |
|
502 |
+ key_str = base64.standard_b64encode(key).decode('ASCII') |
|
503 |
+ remaining_key_display_length = KEY_DISPLAY_LENGTH - 1 - len(keytype) |
|
504 |
+ key_extract = min( |
|
505 |
+ key_str, |
|
506 |
+ '...' + key_str[-remaining_key_display_length:], |
|
507 |
+ key=len, |
|
508 |
+ ) |
|
509 |
+ comment_str = comment.decode('UTF-8', errors='replace') |
|
510 |
+ key_listing.append(f'{keytype} {key_extract} {comment_str}') |
|
511 |
+ choice = prompt_for_selection( |
|
512 |
+ key_listing, |
|
513 |
+ heading='Suitable SSH keys:', |
|
514 |
+ single_choice_prompt='Use this key?', |
|
515 |
+ ctx=ctx, |
|
516 |
+ ) |
|
517 |
+ return suitable_keys[choice].key |
|
518 |
+ |
|
519 |
+ |
|
520 |
+def prompt_for_passphrase() -> str: |
|
521 |
+ """Interactively prompt for the passphrase. |
|
522 |
+ |
|
523 |
+ Calls [`click.prompt`][] internally. Moved into a separate function |
|
524 |
+ mainly for testing/mocking purposes. |
|
525 |
+ |
|
526 |
+ Returns: |
|
527 |
+ The user input. |
|
528 |
+ |
|
529 |
+ """ |
|
530 |
+ return cast( |
|
531 |
+ 'str', |
|
532 |
+ click.prompt( |
|
533 |
+ 'Passphrase', |
|
534 |
+ default='', |
|
535 |
+ hide_input=True, |
|
536 |
+ show_default=False, |
|
537 |
+ err=True, |
|
538 |
+ ), |
|
539 |
+ ) |
|
540 |
+ |
|
541 |
+ |
|
542 |
+def toml_key(*parts: str) -> str: |
|
543 |
+ """Return a formatted TOML key, given its parts.""" |
|
544 |
+ |
|
545 |
+ def escape(string: str) -> str: |
|
546 |
+ translated = string.translate({ |
|
547 |
+ 0: r'\u0000', |
|
548 |
+ 1: r'\u0001', |
|
549 |
+ 2: r'\u0002', |
|
550 |
+ 3: r'\u0003', |
|
551 |
+ 4: r'\u0004', |
|
552 |
+ 5: r'\u0005', |
|
553 |
+ 6: r'\u0006', |
|
554 |
+ 7: r'\u0007', |
|
555 |
+ 8: r'\b', |
|
556 |
+ 9: r'\t', |
|
557 |
+ 10: r'\n', |
|
558 |
+ 11: r'\u000B', |
|
559 |
+ 12: r'\f', |
|
560 |
+ 13: r'\r', |
|
561 |
+ 14: r'\u000E', |
|
562 |
+ 15: r'\u000F', |
|
563 |
+ ord('"'): r'\"', |
|
564 |
+ ord('\\'): r'\\', |
|
565 |
+ 127: r'\u007F', |
|
566 |
+ }) |
|
567 |
+ return f'"{translated}"' if translated != string else string |
|
568 |
+ |
|
569 |
+ return '.'.join(map(escape, parts)) |
|
570 |
+ |
|
571 |
+ |
|
572 |
+class ORIGIN(enum.Enum): |
|
573 |
+ """The origin of a setting, if not from the user configuration file. |
|
574 |
+ |
|
575 |
+ Attributes: |
|
576 |
+ INTERACTIVE: interactive input |
|
577 |
+ |
|
578 |
+ """ |
|
579 |
+ INTERACTIVE: str = 'interactive input' |
|
580 |
+ """""" |
|
581 |
+ |
|
582 |
+ |
|
583 |
+def check_for_misleading_passphrase( |
|
584 |
+ key: tuple[str, ...] | ORIGIN, |
|
585 |
+ value: dict[str, Any], |
|
586 |
+ *, |
|
587 |
+ main_config: dict[str, Any], |
|
588 |
+ ctx: click.Context | None = None, |
|
589 |
+) -> None: |
|
590 |
+ """Check for a misleading passphrase according to user configuration. |
|
591 |
+ |
|
592 |
+ Look up the desired Unicode normalization form in the user |
|
593 |
+ configuration, and if the passphrase is not normalized according to |
|
594 |
+ this form, issue a warning to the user. |
|
595 |
+ |
|
596 |
+ Args: |
|
597 |
+ key: |
|
598 |
+ A vault configuration key or an origin of the |
|
599 |
+ value/configuration section, e.g. [`ORIGIN.INTERACTIVE`][], |
|
600 |
+ or `("global",)`, or `("services", "foo")`. |
|
601 |
+ value: |
|
602 |
+ The vault configuration section maybe containing |
|
603 |
+ a passphrase to vet. |
|
604 |
+ main_config: |
|
605 |
+ The parsed main user configuration. |
|
606 |
+ ctx: |
|
607 |
+ The click context. This is necessary to pass output options |
|
608 |
+ set on the context to the logging machinery. |
|
609 |
+ |
|
610 |
+ Raises: |
|
611 |
+ AssertionError: |
|
612 |
+ The main user configuration is invalid. |
|
613 |
+ |
|
614 |
+ """ |
|
615 |
+ form_key = 'unicode-normalization-form' |
|
616 |
+ default_form: str = main_config.get('vault', {}).get( |
|
617 |
+ f'default-{form_key}', 'NFC' |
|
618 |
+ ) |
|
619 |
+ form_dict: dict[str, dict] = main_config.get('vault', {}).get(form_key, {}) |
|
620 |
+ form: Any = ( |
|
621 |
+ default_form |
|
622 |
+ if isinstance(key, ORIGIN) or key == ('global',) |
|
623 |
+ else form_dict.get(key[1], default_form) |
|
624 |
+ ) |
|
625 |
+ config_key = ( |
|
626 |
+ toml_key('vault', key[1], form_key) |
|
627 |
+ if isinstance(key, tuple) and len(key) > 1 and key[1] in form_dict |
|
628 |
+ else f'vault.default-{form_key}' |
|
629 |
+ ) |
|
630 |
+ if form not in {'NFC', 'NFD', 'NFKC', 'NFKD'}: |
|
631 |
+ msg = f'Invalid value {form!r} for config key {config_key}' |
|
632 |
+ raise AssertionError(msg) |
|
633 |
+ logger = logging.getLogger(PROG_NAME) |
|
634 |
+ formatted_key = ( |
|
635 |
+ key.value if isinstance(key, ORIGIN) else _types.json_path(key) |
|
636 |
+ ) |
|
637 |
+ if 'phrase' in value: |
|
638 |
+ phrase = value['phrase'] |
|
639 |
+ if not unicodedata.is_normalized(form, phrase): |
|
640 |
+ logger.warning( |
|
641 |
+ ( |
|
642 |
+ 'The %s passphrase is not %s-normalized. Its ' |
|
643 |
+ 'serialization as a byte string may not be what you ' |
|
644 |
+ 'expect it to be, even if it *displays* correctly. ' |
|
645 |
+ 'Please make sure to double-check any derived ' |
|
646 |
+ 'passphrases for unexpected results.' |
|
647 |
+ ), |
|
648 |
+ formatted_key, |
|
649 |
+ form, |
|
650 |
+ stacklevel=2, |
|
651 |
+ extra={'color': ctx.color if ctx is not None else None}, |
|
652 |
+ ) |
|
653 |
+ |
|
654 |
+ |
|
655 |
+def default_error_callback( |
|
656 |
+ message: Any, # noqa: ANN401 |
|
657 |
+ /, |
|
658 |
+ *_args: Any, # noqa: ANN401 |
|
659 |
+ **_kwargs: Any, # noqa: ANN401 |
|
660 |
+) -> NoReturn: # pragma: no cover |
|
661 |
+ """Calls [`sys.exit`][] on its first argument, ignoring the rest.""" |
|
662 |
+ sys.exit(message) |
|
663 |
+ |
|
664 |
+ |
|
665 |
+def key_to_phrase( |
|
666 |
+ key: str | Buffer, |
|
667 |
+ /, |
|
668 |
+ *, |
|
669 |
+ error_callback: Callable[..., NoReturn] = default_error_callback, |
|
670 |
+) -> bytes: |
|
671 |
+ """Return the equivalent master passphrase, or abort. |
|
672 |
+ |
|
673 |
+ This wrapper around [`vault.Vault.phrase_from_key`][] emits |
|
674 |
+ user-facing error messages if no equivalent master passphrase can be |
|
675 |
+ obtained from the key, because this is the first point of contact |
|
676 |
+ with the SSH agent. |
|
677 |
+ |
|
678 |
+ """ |
|
679 |
+ key = base64.standard_b64decode(key) |
|
680 |
+ try: |
|
681 |
+ with ssh_agent.SSHAgentClient.ensure_agent_subcontext() as client: |
|
682 |
+ try: |
|
683 |
+ return vault.Vault.phrase_from_key(key, conn=client) |
|
684 |
+ except ssh_agent.SSHAgentFailedError as exc: |
|
685 |
+ try: |
|
686 |
+ keylist = client.list_keys() |
|
687 |
+ except ssh_agent.SSHAgentFailedError: |
|
688 |
+ pass |
|
689 |
+ except Exception as exc2: # noqa: BLE001 |
|
690 |
+ exc.__context__ = exc2 |
|
691 |
+ else: |
|
692 |
+ if not any( # pragma: no branch |
|
693 |
+ k == key for k, _ in keylist |
|
694 |
+ ): |
|
695 |
+ error_callback( |
|
696 |
+ _msg.TranslatedString( |
|
697 |
+ _msg.ErrMsgTemplate.SSH_KEY_NOT_LOADED |
|
698 |
+ ) |
|
699 |
+ ) |
|
700 |
+ error_callback( |
|
701 |
+ _msg.TranslatedString( |
|
702 |
+ _msg.ErrMsgTemplate.AGENT_REFUSED_SIGNATURE |
|
703 |
+ ), |
|
704 |
+ exc_info=exc, |
|
705 |
+ ) |
|
706 |
+ except KeyError: |
|
707 |
+ error_callback( |
|
708 |
+ _msg.TranslatedString(_msg.ErrMsgTemplate.NO_SSH_AGENT_FOUND) |
|
709 |
+ ) |
|
710 |
+ except NotImplementedError: |
|
711 |
+ error_callback(_msg.TranslatedString(_msg.ErrMsgTemplate.NO_AF_UNIX)) |
|
712 |
+ except OSError as exc: |
|
713 |
+ error_callback( |
|
714 |
+ _msg.TranslatedString( |
|
715 |
+ _msg.ErrMsgTemplate.CANNOT_CONNECT_TO_AGENT, |
|
716 |
+ error=exc.strerror, |
|
717 |
+ filename=exc.filename, |
|
718 |
+ ).maybe_without_filename() |
|
719 |
+ ) |
|
720 |
+ except RuntimeError as exc: |
|
721 |
+ error_callback( |
|
722 |
+ _msg.TranslatedString(_msg.ErrMsgTemplate.CANNOT_UNDERSTAND_AGENT), |
|
723 |
+ exc_info=exc, |
|
724 |
+ ) |
|
725 |
+ |
|
726 |
+ |
|
727 |
+def print_config_as_sh_script( |
|
728 |
+ config: _types.VaultConfig, |
|
729 |
+ /, |
|
730 |
+ *, |
|
731 |
+ outfile: TextIO, |
|
732 |
+ prog_name_list: Sequence[str], |
|
733 |
+) -> None: |
|
734 |
+ """Print the given vault configuration as a sh(1) script. |
|
735 |
+ |
|
736 |
+ This implements the `--export-as=sh` option of `derivepassphrase vault`. |
|
737 |
+ |
|
738 |
+ Args: |
|
739 |
+ config: |
|
740 |
+ The configuration to serialize. |
|
741 |
+ outfile: |
|
742 |
+ A file object to write the output to. |
|
743 |
+ prog_name_list: |
|
744 |
+ A list of (subcommand) names for the command emitting this |
|
745 |
+ output, e.g. `["derivepassphrase", "vault"]`. |
|
746 |
+ |
|
747 |
+ """ |
|
748 |
+ service_keys = ( |
|
749 |
+ 'length', |
|
750 |
+ 'repeat', |
|
751 |
+ 'lower', |
|
752 |
+ 'upper', |
|
753 |
+ 'number', |
|
754 |
+ 'space', |
|
755 |
+ 'dash', |
|
756 |
+ 'symbol', |
|
757 |
+ ) |
|
758 |
+ print('#!/bin/sh -e', file=outfile) |
|
759 |
+ print(file=outfile) |
|
760 |
+ print(shlex.join([*prog_name_list, '--clear']), file=outfile) |
|
761 |
+ sv_obj_pairs: list[ |
|
762 |
+ tuple[ |
|
763 |
+ str | None, |
|
764 |
+ _types.VaultConfigGlobalSettings |
|
765 |
+ | _types.VaultConfigServicesSettings, |
|
766 |
+ ], |
|
767 |
+ ] = list(config['services'].items()) |
|
768 |
+ if config.get('global', {}): |
|
769 |
+ sv_obj_pairs.insert(0, (None, config['global'])) |
|
770 |
+ for sv, sv_obj in sv_obj_pairs: |
|
771 |
+ this_service_keys = tuple(k for k in service_keys if k in sv_obj) |
|
772 |
+ this_other_keys = tuple(k for k in sv_obj if k not in service_keys) |
|
773 |
+ if this_other_keys: |
|
774 |
+ other_sv_obj = {k: sv_obj[k] for k in this_other_keys} # type: ignore[literal-required] |
|
775 |
+ dumped_config = json.dumps( |
|
776 |
+ ( |
|
777 |
+ {'services': {sv: other_sv_obj}} |
|
778 |
+ if sv is not None |
|
779 |
+ else {'global': other_sv_obj, 'services': {}} |
|
780 |
+ ), |
|
781 |
+ ensure_ascii=False, |
|
782 |
+ indent=None, |
|
783 |
+ ) |
|
784 |
+ print( |
|
785 |
+ shlex.join([*prog_name_list, '--import', '-']) + " <<'HERE'", |
|
786 |
+ dumped_config, |
|
787 |
+ 'HERE', |
|
788 |
+ sep='\n', |
|
789 |
+ file=outfile, |
|
790 |
+ ) |
|
791 |
+ if not this_service_keys and not this_other_keys and sv: |
|
792 |
+ dumped_config = json.dumps( |
|
793 |
+ {'services': {sv: {}}}, |
|
794 |
+ ensure_ascii=False, |
|
795 |
+ indent=None, |
|
796 |
+ ) |
|
797 |
+ print( |
|
798 |
+ shlex.join([*prog_name_list, '--import', '-']) + " <<'HERE'", |
|
799 |
+ dumped_config, |
|
800 |
+ 'HERE', |
|
801 |
+ sep='\n', |
|
802 |
+ file=outfile, |
|
803 |
+ ) |
|
804 |
+ elif this_service_keys: |
|
805 |
+ tokens = [*prog_name_list, '--config'] |
|
806 |
+ for key in this_service_keys: |
|
807 |
+ tokens.extend([f'--{key}', str(sv_obj[key])]) # type: ignore[literal-required] |
|
808 |
+ if sv is not None: |
|
809 |
+ tokens.extend(['--', sv]) |
|
810 |
+ print(shlex.join(tokens), file=outfile) |
... | ... |
@@ -0,0 +1,1196 @@ |
1 |
+# SPDX-FileCopyrightText: 2025 Marco Ricci <software@the13thletter.info> |
|
2 |
+# |
|
3 |
+# SPDX-License-Identifier: Zlib |
|
4 |
+ |
|
5 |
+# ruff: noqa: TRY400 |
|
6 |
+ |
|
7 |
+"""Command-line machinery for derivepassphrase. |
|
8 |
+ |
|
9 |
+Warning: |
|
10 |
+ Non-public module (implementation detail), provided for didactical and |
|
11 |
+ educational purposes only. Subject to change without notice, including |
|
12 |
+ removal. |
|
13 |
+ |
|
14 |
+""" |
|
15 |
+ |
|
16 |
+from __future__ import annotations |
|
17 |
+ |
|
18 |
+import collections |
|
19 |
+import inspect |
|
20 |
+import logging |
|
21 |
+import os |
|
22 |
+import warnings |
|
23 |
+from typing import TYPE_CHECKING, Callable, Literal, TextIO, TypeVar |
|
24 |
+ |
|
25 |
+import click |
|
26 |
+import click.shell_completion |
|
27 |
+from typing_extensions import Any, ParamSpec, override |
|
28 |
+ |
|
29 |
+import derivepassphrase as dpp |
|
30 |
+from derivepassphrase._internals import cli_messages as _msg |
|
31 |
+ |
|
32 |
+if TYPE_CHECKING: |
|
33 |
+ import types |
|
34 |
+ from collections.abc import ( |
|
35 |
+ MutableSequence, |
|
36 |
+ ) |
|
37 |
+ |
|
38 |
+ from typing_extensions import Self |
|
39 |
+ |
|
40 |
+__author__ = dpp.__author__ |
|
41 |
+__version__ = dpp.__version__ |
|
42 |
+ |
|
43 |
+PROG_NAME = _msg.PROG_NAME |
|
44 |
+ |
|
45 |
+# Error messages |
|
46 |
+NOT_AN_INTEGER = 'not an integer' |
|
47 |
+NOT_A_NONNEGATIVE_INTEGER = 'not a non-negative integer' |
|
48 |
+NOT_A_POSITIVE_INTEGER = 'not a positive integer' |
|
49 |
+ |
|
50 |
+ |
|
51 |
+# Logging |
|
52 |
+# ======= |
|
53 |
+ |
|
54 |
+ |
|
55 |
+class ClickEchoStderrHandler(logging.Handler): |
|
56 |
+ """A [`logging.Handler`][] for `click` applications. |
|
57 |
+ |
|
58 |
+ Outputs log messages to [`sys.stderr`][] via [`click.echo`][]. |
|
59 |
+ |
|
60 |
+ """ |
|
61 |
+ |
|
62 |
+ def emit(self, record: logging.LogRecord) -> None: |
|
63 |
+ """Emit a log record. |
|
64 |
+ |
|
65 |
+ Format the log record, then emit it via [`click.echo`][] to |
|
66 |
+ [`sys.stderr`][]. |
|
67 |
+ |
|
68 |
+ """ |
|
69 |
+ click.echo( |
|
70 |
+ self.format(record), |
|
71 |
+ err=True, |
|
72 |
+ color=getattr(record, 'color', None), |
|
73 |
+ ) |
|
74 |
+ |
|
75 |
+ |
|
76 |
+class CLIofPackageFormatter(logging.Formatter): |
|
77 |
+ """A [`logging.LogRecord`][] formatter for the CLI of a Python package. |
|
78 |
+ |
|
79 |
+ Assuming a package `PKG` and loggers within the same hierarchy |
|
80 |
+ `PKG`, format all log records from that hierarchy for proper user |
|
81 |
+ feedback on the console. Intended for use with [`click`][CLICK] and |
|
82 |
+ when `PKG` provides a command-line tool `PKG` and when logs from |
|
83 |
+ that package should show up as output of the command-line tool. |
|
84 |
+ |
|
85 |
+ Essentially, this prepends certain short strings to the log message |
|
86 |
+ lines to make them readable as standard error output. |
|
87 |
+ |
|
88 |
+ Because this log output is intended to be displayed on standard |
|
89 |
+ error as high-level diagnostic output, you are strongly discouraged |
|
90 |
+ from changing the output format to include more tokens besides the |
|
91 |
+ log message. Use a dedicated log file handler instead, without this |
|
92 |
+ formatter. |
|
93 |
+ |
|
94 |
+ [CLICK]: https://pypi.org/projects/click/ |
|
95 |
+ |
|
96 |
+ """ |
|
97 |
+ |
|
98 |
+ def __init__( |
|
99 |
+ self, |
|
100 |
+ *, |
|
101 |
+ prog_name: str = PROG_NAME, |
|
102 |
+ package_name: str | None = None, |
|
103 |
+ ) -> None: |
|
104 |
+ self.prog_name = prog_name |
|
105 |
+ self.package_name = ( |
|
106 |
+ package_name |
|
107 |
+ if package_name is not None |
|
108 |
+ else prog_name.lower().replace(' ', '_').replace('-', '_') |
|
109 |
+ ) |
|
110 |
+ |
|
111 |
+ def format(self, record: logging.LogRecord) -> str: |
|
112 |
+ """Format a log record suitably for standard error console output. |
|
113 |
+ |
|
114 |
+ Prepend the formatted string `"PROG_NAME: LABEL"` to each line |
|
115 |
+ of the message, where `PROG_NAME` is the program name, and |
|
116 |
+ `LABEL` depends on the record's level and on the logger name as |
|
117 |
+ follows: |
|
118 |
+ |
|
119 |
+ * For records at level [`logging.DEBUG`][], `LABEL` is |
|
120 |
+ `"Debug: "`. |
|
121 |
+ * For records at level [`logging.INFO`][], `LABEL` is the |
|
122 |
+ empty string. |
|
123 |
+ * For records at level [`logging.WARNING`][], `LABEL` is |
|
124 |
+ `"Deprecation warning: "` if the logger is named |
|
125 |
+ `PKG.deprecation` (where `PKG` is the package name), else |
|
126 |
+ `"Warning: "`. |
|
127 |
+ * For records at level [`logging.ERROR`][] and |
|
128 |
+ [`logging.CRITICAL`][] `"Error: "`, `LABEL` is the empty |
|
129 |
+ string. |
|
130 |
+ |
|
131 |
+ The level indication strings at level `WARNING` or above are |
|
132 |
+ highlighted. Use [`click.echo`][] to output them and remove |
|
133 |
+ color output if necessary. |
|
134 |
+ |
|
135 |
+ Args: |
|
136 |
+ record: A log record. |
|
137 |
+ |
|
138 |
+ Returns: |
|
139 |
+ A formatted log record. |
|
140 |
+ |
|
141 |
+ Raises: |
|
142 |
+ AssertionError: |
|
143 |
+ The log level is not supported. |
|
144 |
+ |
|
145 |
+ """ |
|
146 |
+ preliminary_result = record.getMessage() |
|
147 |
+ prefix = f'{self.prog_name}: ' |
|
148 |
+ if record.levelname == 'DEBUG': # pragma: no cover |
|
149 |
+ level_indicator = 'Debug: ' |
|
150 |
+ elif record.levelname == 'INFO': |
|
151 |
+ level_indicator = '' |
|
152 |
+ elif record.levelname == 'WARNING': |
|
153 |
+ level_indicator = ( |
|
154 |
+ f'{click.style("Deprecation warning", bold=True)}: ' |
|
155 |
+ if record.name.endswith('.deprecation') |
|
156 |
+ else f'{click.style("Warning", bold=True)}: ' |
|
157 |
+ ) |
|
158 |
+ elif record.levelname in {'ERROR', 'CRITICAL'}: |
|
159 |
+ level_indicator = '' |
|
160 |
+ else: # pragma: no cover |
|
161 |
+ msg = f'Unsupported logging level: {record.levelname}' |
|
162 |
+ raise AssertionError(msg) |
|
163 |
+ parts = [ |
|
164 |
+ ''.join( |
|
165 |
+ prefix + level_indicator + line |
|
166 |
+ for line in preliminary_result.splitlines(True) # noqa: FBT003 |
|
167 |
+ ) |
|
168 |
+ ] |
|
169 |
+ if record.exc_info: |
|
170 |
+ parts.append(self.formatException(record.exc_info) + '\n') |
|
171 |
+ return ''.join(parts) |
|
172 |
+ |
|
173 |
+ |
|
174 |
+class StandardCLILogging: |
|
175 |
+ """Set up CLI logging handlers upon instantiation.""" |
|
176 |
+ |
|
177 |
+ prog_name = PROG_NAME |
|
178 |
+ package_name = PROG_NAME.lower().replace(' ', '_').replace('-', '_') |
|
179 |
+ cli_formatter = CLIofPackageFormatter( |
|
180 |
+ prog_name=prog_name, package_name=package_name |
|
181 |
+ ) |
|
182 |
+ cli_handler = ClickEchoStderrHandler() |
|
183 |
+ cli_handler.addFilter(logging.Filter(name=package_name)) |
|
184 |
+ cli_handler.setFormatter(cli_formatter) |
|
185 |
+ cli_handler.setLevel(logging.WARNING) |
|
186 |
+ warnings_handler = ClickEchoStderrHandler() |
|
187 |
+ warnings_handler.addFilter(logging.Filter(name='py.warnings')) |
|
188 |
+ warnings_handler.setFormatter(cli_formatter) |
|
189 |
+ warnings_handler.setLevel(logging.WARNING) |
|
190 |
+ |
|
191 |
+ @classmethod |
|
192 |
+ def ensure_standard_logging(cls) -> StandardLoggingContextManager: |
|
193 |
+ """Return a context manager to ensure standard logging is set up.""" |
|
194 |
+ return StandardLoggingContextManager( |
|
195 |
+ handler=cls.cli_handler, |
|
196 |
+ root_logger=cls.package_name, |
|
197 |
+ ) |
|
198 |
+ |
|
199 |
+ @classmethod |
|
200 |
+ def ensure_standard_warnings_logging( |
|
201 |
+ cls, |
|
202 |
+ ) -> StandardWarningsLoggingContextManager: |
|
203 |
+ """Return a context manager to ensure warnings logging is set up.""" |
|
204 |
+ return StandardWarningsLoggingContextManager( |
|
205 |
+ handler=cls.warnings_handler, |
|
206 |
+ ) |
|
207 |
+ |
|
208 |
+ |
|
209 |
+class StandardLoggingContextManager: |
|
210 |
+ """A reentrant context manager setting up standard CLI logging. |
|
211 |
+ |
|
212 |
+ Ensures that the given handler (defaulting to the CLI logging |
|
213 |
+ handler) is added to the named logger (defaulting to the root |
|
214 |
+ logger), and if it had to be added, then that it will be removed |
|
215 |
+ upon exiting the context. |
|
216 |
+ |
|
217 |
+ Reentrant, but not thread safe, because it temporarily modifies |
|
218 |
+ global state. |
|
219 |
+ |
|
220 |
+ """ |
|
221 |
+ |
|
222 |
+ def __init__( |
|
223 |
+ self, |
|
224 |
+ handler: logging.Handler, |
|
225 |
+ root_logger: str | None = None, |
|
226 |
+ ) -> None: |
|
227 |
+ self.handler = handler |
|
228 |
+ self.root_logger_name = root_logger |
|
229 |
+ self.base_logger = logging.getLogger(self.root_logger_name) |
|
230 |
+ self.action_required: MutableSequence[bool] = collections.deque() |
|
231 |
+ |
|
232 |
+ def __enter__(self) -> Self: |
|
233 |
+ self.action_required.append( |
|
234 |
+ self.handler not in self.base_logger.handlers |
|
235 |
+ ) |
|
236 |
+ if self.action_required[-1]: |
|
237 |
+ self.base_logger.addHandler(self.handler) |
|
238 |
+ return self |
|
239 |
+ |
|
240 |
+ def __exit__( |
|
241 |
+ self, |
|
242 |
+ exc_type: type[BaseException] | None, |
|
243 |
+ exc_value: BaseException | None, |
|
244 |
+ exc_tb: types.TracebackType | None, |
|
245 |
+ ) -> Literal[False]: |
|
246 |
+ if self.action_required[-1]: |
|
247 |
+ self.base_logger.removeHandler(self.handler) |
|
248 |
+ self.action_required.pop() |
|
249 |
+ return False |
|
250 |
+ |
|
251 |
+ |
|
252 |
+class StandardWarningsLoggingContextManager(StandardLoggingContextManager): |
|
253 |
+ """A reentrant context manager setting up standard warnings logging. |
|
254 |
+ |
|
255 |
+ Ensures that warnings are being diverted to the logging system, and |
|
256 |
+ that the given handler (defaulting to the CLI logging handler) is |
|
257 |
+ added to the warnings logger. If the handler had to be added, then |
|
258 |
+ it will be removed upon exiting the context. |
|
259 |
+ |
|
260 |
+ Reentrant, but not thread safe, because it temporarily modifies |
|
261 |
+ global state. |
|
262 |
+ |
|
263 |
+ """ |
|
264 |
+ |
|
265 |
+ def __init__( |
|
266 |
+ self, |
|
267 |
+ handler: logging.Handler, |
|
268 |
+ ) -> None: |
|
269 |
+ super().__init__(handler=handler, root_logger='py.warnings') |
|
270 |
+ self.stack: MutableSequence[ |
|
271 |
+ tuple[ |
|
272 |
+ Callable[ |
|
273 |
+ [ |
|
274 |
+ type[BaseException] | None, |
|
275 |
+ BaseException | None, |
|
276 |
+ types.TracebackType | None, |
|
277 |
+ ], |
|
278 |
+ None, |
|
279 |
+ ], |
|
280 |
+ Callable[ |
|
281 |
+ [ |
|
282 |
+ str | Warning, |
|
283 |
+ type[Warning], |
|
284 |
+ str, |
|
285 |
+ int, |
|
286 |
+ TextIO | None, |
|
287 |
+ str | None, |
|
288 |
+ ], |
|
289 |
+ None, |
|
290 |
+ ], |
|
291 |
+ ] |
|
292 |
+ ] = collections.deque() |
|
293 |
+ |
|
294 |
+ def __enter__(self) -> Self: |
|
295 |
+ def showwarning( # noqa: PLR0913,PLR0917 |
|
296 |
+ message: str | Warning, |
|
297 |
+ category: type[Warning], |
|
298 |
+ filename: str, |
|
299 |
+ lineno: int, |
|
300 |
+ file: TextIO | None = None, |
|
301 |
+ line: str | None = None, |
|
302 |
+ ) -> None: |
|
303 |
+ if file is not None: # pragma: no cover |
|
304 |
+ self.stack[0][1]( |
|
305 |
+ message, category, filename, lineno, file, line |
|
306 |
+ ) |
|
307 |
+ else: |
|
308 |
+ logging.getLogger('py.warnings').warning( |
|
309 |
+ str( |
|
310 |
+ warnings.formatwarning( |
|
311 |
+ message, category, filename, lineno, line |
|
312 |
+ ) |
|
313 |
+ ) |
|
314 |
+ ) |
|
315 |
+ |
|
316 |
+ ctx = warnings.catch_warnings() |
|
317 |
+ exit_func = ctx.__exit__ |
|
318 |
+ ctx.__enter__() |
|
319 |
+ self.stack.append((exit_func, warnings.showwarning)) |
|
320 |
+ warnings.showwarning = showwarning |
|
321 |
+ return super().__enter__() |
|
322 |
+ |
|
323 |
+ def __exit__( |
|
324 |
+ self, |
|
325 |
+ exc_type: type[BaseException] | None, |
|
326 |
+ exc_value: BaseException | None, |
|
327 |
+ exc_tb: types.TracebackType | None, |
|
328 |
+ ) -> Literal[False]: |
|
329 |
+ ret = super().__exit__(exc_type, exc_value, exc_tb) |
|
330 |
+ val = self.stack.pop()[0](exc_type, exc_value, exc_tb) |
|
331 |
+ assert not val |
|
332 |
+ return ret |
|
333 |
+ |
|
334 |
+ |
|
335 |
+P = ParamSpec('P') |
|
336 |
+R = TypeVar('R') |
|
337 |
+ |
|
338 |
+ |
|
339 |
+def adjust_logging_level( |
|
340 |
+ ctx: click.Context, |
|
341 |
+ /, |
|
342 |
+ param: click.Parameter | None = None, |
|
343 |
+ value: int | None = None, |
|
344 |
+) -> None: |
|
345 |
+ """Change the logs that are emitted to standard error. |
|
346 |
+ |
|
347 |
+ This modifies the [`StandardCLILogging`][] settings such that log |
|
348 |
+ records at the respective level are emitted, based on the `param` |
|
349 |
+ and the `value`. |
|
350 |
+ |
|
351 |
+ """ |
|
352 |
+ # Note: If multiple options use this callback, then we will be |
|
353 |
+ # called multiple times. Ensure the runs are idempotent. |
|
354 |
+ if param is None or value is None or ctx.resilient_parsing: |
|
355 |
+ return |
|
356 |
+ StandardCLILogging.cli_handler.setLevel(value) |
|
357 |
+ logging.getLogger(StandardCLILogging.package_name).setLevel(value) |
|
358 |
+ |
|
359 |
+ |
|
360 |
+# Option parsing and grouping |
|
361 |
+# =========================== |
|
362 |
+ |
|
363 |
+ |
|
364 |
+class OptionGroupOption(click.Option): |
|
365 |
+ """A [`click.Option`][] with an associated group name and group epilog. |
|
366 |
+ |
|
367 |
+ Used by [`CommandWithHelpGroups`][] to print help sections. Each |
|
368 |
+ subclass contains its own group name and epilog. |
|
369 |
+ |
|
370 |
+ Attributes: |
|
371 |
+ option_group_name: |
|
372 |
+ The name of the option group. Used as a heading on the help |
|
373 |
+ text for options in this section. |
|
374 |
+ epilog: |
|
375 |
+ An epilog to print after listing the options in this |
|
376 |
+ section. |
|
377 |
+ |
|
378 |
+ """ |
|
379 |
+ |
|
380 |
+ option_group_name: object = '' |
|
381 |
+ """""" |
|
382 |
+ epilog: object = '' |
|
383 |
+ """""" |
|
384 |
+ |
|
385 |
+ def __init__(self, *args: Any, **kwargs: Any) -> None: # noqa: ANN401 |
|
386 |
+ if self.__class__ == __class__: # type: ignore[name-defined] |
|
387 |
+ raise NotImplementedError |
|
388 |
+ # Though click 8.1 mostly defers help text processing until the |
|
389 |
+ # `BaseCommand.format_*` methods are called, the Option |
|
390 |
+ # constructor still preprocesses the help text, and asserts that |
|
391 |
+ # the help text is a string. Work around this by removing the |
|
392 |
+ # help text from the constructor arguments and re-adding it, |
|
393 |
+ # unprocessed, after constructor finishes. |
|
394 |
+ unset = object() |
|
395 |
+ help = kwargs.pop('help', unset) # noqa: A001 |
|
396 |
+ super().__init__(*args, **kwargs) |
|
397 |
+ if help is not unset: # pragma: no branch |
|
398 |
+ self.help = help |
|
399 |
+ |
|
400 |
+ |
|
401 |
+class StandardOption(OptionGroupOption): |
|
402 |
+ pass |
|
403 |
+ |
|
404 |
+ |
|
405 |
+# Portions of this class are based directly on code from click 8.1. |
|
406 |
+# (This does not in general include docstrings, unless otherwise noted.) |
|
407 |
+# They are subject to the 3-clause BSD license in the following |
|
408 |
+# paragraphs. Modifications to their code are marked with respective |
|
409 |
+# comments; they too are released under the same license below. The |
|
410 |
+# original code did not contain any "noqa" or "pragma" comments. |
|
411 |
+# |
|
412 |
+# Copyright 2024 Pallets |
|
413 |
+# |
|
414 |
+# Redistribution and use in source and binary forms, with or |
|
415 |
+# without modification, are permitted provided that the |
|
416 |
+# following conditions are met: |
|
417 |
+# |
|
418 |
+# 1. Redistributions of source code must retain the above |
|
419 |
+# copyright notice, this list of conditions and the |
|
420 |
+# following disclaimer. |
|
421 |
+# |
|
422 |
+# 2. Redistributions in binary form must reproduce the above |
|
423 |
+# copyright notice, this list of conditions and the |
|
424 |
+# following disclaimer in the documentation and/or other |
|
425 |
+# materials provided with the distribution. |
|
426 |
+# |
|
427 |
+# 3. Neither the name of the copyright holder nor the names |
|
428 |
+# of its contributors may be used to endorse or promote |
|
429 |
+# products derived from this software without specific |
|
430 |
+# prior written permission. |
|
431 |
+# |
|
432 |
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND |
|
433 |
+# CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, |
|
434 |
+# INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF |
|
435 |
+# MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE |
|
436 |
+# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR |
|
437 |
+# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, |
|
438 |
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT |
|
439 |
+# NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; |
|
440 |
+# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) |
|
441 |
+# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN |
|
442 |
+# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR |
|
443 |
+# OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS |
|
444 |
+# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. |
|
445 |
+class CommandWithHelpGroups(click.Command): |
|
446 |
+ """A [`click.Command`][] with support for some help text customizations. |
|
447 |
+ |
|
448 |
+ Supports help/option groups, group epilogs, and help text objects |
|
449 |
+ (objects that stringify to help texts). The latter is primarily |
|
450 |
+ used to implement translations. |
|
451 |
+ |
|
452 |
+ Inspired by [a comment on `pallets/click#373`][CLICK_ISSUE] for |
|
453 |
+ help/option group support, and further modified to include group |
|
454 |
+ epilogs and help text objects. |
|
455 |
+ |
|
456 |
+ [CLICK_ISSUE]: https://github.com/pallets/click/issues/373#issuecomment-515293746 |
|
457 |
+ |
|
458 |
+ """ |
|
459 |
+ |
|
460 |
+ @staticmethod |
|
461 |
+ def _text(text: object, /) -> str: |
|
462 |
+ if isinstance(text, (list, tuple)): |
|
463 |
+ return '\n\n'.join(str(x) for x in text) |
|
464 |
+ return str(text) |
|
465 |
+ |
|
466 |
+ # This method is based on click 8.1; see the comment above the class |
|
467 |
+ # declaration for license details. |
|
468 |
+ def collect_usage_pieces(self, ctx: click.Context) -> list[str]: |
|
469 |
+ """Return the pieces for the usage string. |
|
470 |
+ |
|
471 |
+ Args: |
|
472 |
+ ctx: |
|
473 |
+ The click context. |
|
474 |
+ |
|
475 |
+ """ |
|
476 |
+ rv = [str(self.options_metavar)] if self.options_metavar else [] |
|
477 |
+ for param in self.get_params(ctx): |
|
478 |
+ rv.extend(str(x) for x in param.get_usage_pieces(ctx)) |
|
479 |
+ return rv |
|
480 |
+ |
|
481 |
+ # This method is based on click 8.1; see the comment above the class |
|
482 |
+ # declaration for license details. |
|
483 |
+ def get_help_option( |
|
484 |
+ self, |
|
485 |
+ ctx: click.Context, |
|
486 |
+ ) -> click.Option | None: |
|
487 |
+ """Return a standard help option object. |
|
488 |
+ |
|
489 |
+ Args: |
|
490 |
+ ctx: |
|
491 |
+ The click context. |
|
492 |
+ |
|
493 |
+ """ |
|
494 |
+ help_options = self.get_help_option_names(ctx) |
|
495 |
+ |
|
496 |
+ if not help_options or not self.add_help_option: # pragma: no cover |
|
497 |
+ return None |
|
498 |
+ |
|
499 |
+ def show_help( |
|
500 |
+ ctx: click.Context, |
|
501 |
+ param: click.Parameter, # noqa: ARG001 |
|
502 |
+ value: str, |
|
503 |
+ ) -> None: |
|
504 |
+ if value and not ctx.resilient_parsing: |
|
505 |
+ click.echo(ctx.get_help(), color=ctx.color) |
|
506 |
+ ctx.exit() |
|
507 |
+ |
|
508 |
+ # Modified from click 8.1: We use StandardOption and a non-str |
|
509 |
+ # object as the help string. |
|
510 |
+ return StandardOption( |
|
511 |
+ help_options, |
|
512 |
+ is_flag=True, |
|
513 |
+ is_eager=True, |
|
514 |
+ expose_value=False, |
|
515 |
+ callback=show_help, |
|
516 |
+ help=_msg.TranslatedString(_msg.Label.HELP_OPTION_HELP_TEXT), |
|
517 |
+ ) |
|
518 |
+ |
|
519 |
+ # This method is based on click 8.1; see the comment above the class |
|
520 |
+ # declaration for license details. |
|
521 |
+ def get_short_help_str( |
|
522 |
+ self, |
|
523 |
+ limit: int = 45, |
|
524 |
+ ) -> str: |
|
525 |
+ """Return the short help string for a command. |
|
526 |
+ |
|
527 |
+ If only a long help string is given, shorten it. |
|
528 |
+ |
|
529 |
+ Args: |
|
530 |
+ limit: |
|
531 |
+ The maximum width of the short help string. |
|
532 |
+ |
|
533 |
+ """ |
|
534 |
+ # Modification against click 8.1: Call `_text()` on `self.help` |
|
535 |
+ # to allow help texts to be general objects, not just strings. |
|
536 |
+ # Used to implement translatable strings, as objects that |
|
537 |
+ # stringify to the translation. |
|
538 |
+ if self.short_help: # pragma: no cover |
|
539 |
+ text = inspect.cleandoc(self._text(self.short_help)) |
|
540 |
+ elif self.help: |
|
541 |
+ text = click.utils.make_default_short_help( |
|
542 |
+ self._text(self.help), limit |
|
543 |
+ ) |
|
544 |
+ else: # pragma: no cover |
|
545 |
+ text = '' |
|
546 |
+ if self.deprecated: # pragma: no cover |
|
547 |
+ # Modification against click 8.1: The translated string is |
|
548 |
+ # looked up in the derivepassphrase message domain, not the |
|
549 |
+ # gettext default domain. |
|
550 |
+ text = str( |
|
551 |
+ _msg.TranslatedString(_msg.Label.DEPRECATED_COMMAND_LABEL) |
|
552 |
+ ).format(text=text) |
|
553 |
+ return text.strip() |
|
554 |
+ |
|
555 |
+ # This method is based on click 8.1; see the comment above the class |
|
556 |
+ # declaration for license details. |
|
557 |
+ def format_help_text( |
|
558 |
+ self, |
|
559 |
+ ctx: click.Context, |
|
560 |
+ formatter: click.HelpFormatter, |
|
561 |
+ ) -> None: |
|
562 |
+ """Format the help text prologue, if any. |
|
563 |
+ |
|
564 |
+ Args: |
|
565 |
+ ctx: |
|
566 |
+ The click context. |
|
567 |
+ formatter: |
|
568 |
+ The formatter for the `--help` listing. |
|
569 |
+ |
|
570 |
+ """ |
|
571 |
+ del ctx |
|
572 |
+ # Modification against click 8.1: Call `_text()` on `self.help` |
|
573 |
+ # to allow help texts to be general objects, not just strings. |
|
574 |
+ # Used to implement translatable strings, as objects that |
|
575 |
+ # stringify to the translation. |
|
576 |
+ text = ( |
|
577 |
+ inspect.cleandoc(self._text(self.help).partition('\f')[0]) |
|
578 |
+ if self.help is not None |
|
579 |
+ else '' |
|
580 |
+ ) |
|
581 |
+ if self.deprecated: # pragma: no cover |
|
582 |
+ # Modification against click 8.1: The translated string is |
|
583 |
+ # looked up in the derivepassphrase message domain, not the |
|
584 |
+ # gettext default domain. |
|
585 |
+ text = str( |
|
586 |
+ _msg.TranslatedString(_msg.Label.DEPRECATED_COMMAND_LABEL) |
|
587 |
+ ).format(text=text) |
|
588 |
+ if text: # pragma: no branch |
|
589 |
+ formatter.write_paragraph() |
|
590 |
+ with formatter.indentation(): |
|
591 |
+ formatter.write_text(text) |
|
592 |
+ |
|
593 |
+ # This method is based on click 8.1; see the comment above the class |
|
594 |
+ # declaration for license details. Consider the whole section |
|
595 |
+ # marked as modified; the code modifications are too numerous to |
|
596 |
+ # mark individually. |
|
597 |
+ def format_options( |
|
598 |
+ self, |
|
599 |
+ ctx: click.Context, |
|
600 |
+ formatter: click.HelpFormatter, |
|
601 |
+ ) -> None: |
|
602 |
+ r"""Format options on the help listing, grouped into sections. |
|
603 |
+ |
|
604 |
+ This is a callback for [`click.Command.get_help`][] that |
|
605 |
+ implements the `--help` listing, by calling appropriate methods |
|
606 |
+ of the `formatter`. We list all options (like the base |
|
607 |
+ implementation), but grouped into sections according to the |
|
608 |
+ concrete [`click.Option`][] subclass being used. If the option |
|
609 |
+ is an instance of some subclass of [`OptionGroupOption`][], then |
|
610 |
+ the section heading and the epilog are taken from the |
|
611 |
+ [`option_group_name`] [OptionGroupOption.option_group_name] and |
|
612 |
+ [`epilog`] [OptionGroupOption.epilog] attributes; otherwise, the |
|
613 |
+ section heading is "Options" (or "Other options" if there are |
|
614 |
+ other option groups) and the epilog is empty. |
|
615 |
+ |
|
616 |
+ We unconditionally call [`format_commands`][], and rely on it to |
|
617 |
+ act as a no-op if we aren't actually a [`click.MultiCommand`][]. |
|
618 |
+ |
|
619 |
+ Args: |
|
620 |
+ ctx: |
|
621 |
+ The click context. |
|
622 |
+ formatter: |
|
623 |
+ The formatter for the `--help` listing. |
|
624 |
+ |
|
625 |
+ """ |
|
626 |
+ default_group_name = '' |
|
627 |
+ help_records: dict[str, list[tuple[str, str]]] = {} |
|
628 |
+ epilogs: dict[str, str] = {} |
|
629 |
+ params = self.params[:] |
|
630 |
+ if ( # pragma: no branch |
|
631 |
+ (help_opt := self.get_help_option(ctx)) is not None |
|
632 |
+ and help_opt not in params |
|
633 |
+ ): |
|
634 |
+ params.append(help_opt) |
|
635 |
+ for param in params: |
|
636 |
+ rec = param.get_help_record(ctx) |
|
637 |
+ if rec is not None: |
|
638 |
+ rec = (rec[0], self._text(rec[1])) |
|
639 |
+ if isinstance(param, OptionGroupOption): |
|
640 |
+ group_name = self._text(param.option_group_name) |
|
641 |
+ epilogs.setdefault(group_name, self._text(param.epilog)) |
|
642 |
+ else: # pragma: no cover |
|
643 |
+ group_name = default_group_name |
|
644 |
+ help_records.setdefault(group_name, []).append(rec) |
|
645 |
+ if default_group_name in help_records: # pragma: no branch |
|
646 |
+ default_group = help_records.pop(default_group_name) |
|
647 |
+ default_group_label = ( |
|
648 |
+ _msg.Label.OTHER_OPTIONS_LABEL |
|
649 |
+ if len(default_group) > 1 |
|
650 |
+ else _msg.Label.OPTIONS_LABEL |
|
651 |
+ ) |
|
652 |
+ default_group_name = self._text( |
|
653 |
+ _msg.TranslatedString(default_group_label) |
|
654 |
+ ) |
|
655 |
+ help_records[default_group_name] = default_group |
|
656 |
+ for group_name, records in help_records.items(): |
|
657 |
+ with formatter.section(group_name): |
|
658 |
+ formatter.write_dl(records) |
|
659 |
+ epilog = inspect.cleandoc(epilogs.get(group_name, '')) |
|
660 |
+ if epilog: |
|
661 |
+ formatter.write_paragraph() |
|
662 |
+ with formatter.indentation(): |
|
663 |
+ formatter.write_text(epilog) |
|
664 |
+ self.format_commands(ctx, formatter) |
|
665 |
+ |
|
666 |
+ # This method is based on click 8.1; see the comment above the class |
|
667 |
+ # declaration for license details. Consider the whole section |
|
668 |
+ # marked as modified; the code modifications are too numerous to |
|
669 |
+ # mark individually. |
|
670 |
+ def format_commands( |
|
671 |
+ self, |
|
672 |
+ ctx: click.Context, |
|
673 |
+ formatter: click.HelpFormatter, |
|
674 |
+ ) -> None: |
|
675 |
+ """Format the subcommands, if any. |
|
676 |
+ |
|
677 |
+ If called on a command object that isn't derived from |
|
678 |
+ [`click.MultiCommand`][], then do nothing. |
|
679 |
+ |
|
680 |
+ Args: |
|
681 |
+ ctx: |
|
682 |
+ The click context. |
|
683 |
+ formatter: |
|
684 |
+ The formatter for the `--help` listing. |
|
685 |
+ |
|
686 |
+ """ |
|
687 |
+ if not isinstance(self, click.MultiCommand): |
|
688 |
+ return |
|
689 |
+ commands: list[tuple[str, click.Command]] = [] |
|
690 |
+ for subcommand in self.list_commands(ctx): |
|
691 |
+ cmd = self.get_command(ctx, subcommand) |
|
692 |
+ if cmd is None or cmd.hidden: # pragma: no cover |
|
693 |
+ continue |
|
694 |
+ commands.append((subcommand, cmd)) |
|
695 |
+ if commands: # pragma: no branch |
|
696 |
+ longest_command = max((cmd[0] for cmd in commands), key=len) |
|
697 |
+ limit = formatter.width - 6 - len(longest_command) |
|
698 |
+ rows: list[tuple[str, str]] = [] |
|
699 |
+ for subcommand, cmd in commands: |
|
700 |
+ help_str = self._text(cmd.get_short_help_str(limit) or '') |
|
701 |
+ rows.append((subcommand, help_str)) |
|
702 |
+ if rows: # pragma: no branch |
|
703 |
+ commands_label = self._text( |
|
704 |
+ _msg.TranslatedString(_msg.Label.COMMANDS_LABEL) |
|
705 |
+ ) |
|
706 |
+ with formatter.section(commands_label): |
|
707 |
+ formatter.write_dl(rows) |
|
708 |
+ |
|
709 |
+ # This method is based on click 8.1; see the comment above the class |
|
710 |
+ # declaration for license details. |
|
711 |
+ def format_epilog( |
|
712 |
+ self, |
|
713 |
+ ctx: click.Context, |
|
714 |
+ formatter: click.HelpFormatter, |
|
715 |
+ ) -> None: |
|
716 |
+ """Format the epilog, if any. |
|
717 |
+ |
|
718 |
+ Args: |
|
719 |
+ ctx: |
|
720 |
+ The click context. |
|
721 |
+ formatter: |
|
722 |
+ The formatter for the `--help` listing. |
|
723 |
+ |
|
724 |
+ """ |
|
725 |
+ del ctx |
|
726 |
+ if self.epilog: # pragma: no branch |
|
727 |
+ # Modification against click 8.1: Call `str()` on |
|
728 |
+ # `self.epilog` to allow help texts to be general objects, |
|
729 |
+ # not just strings. Used to implement translatable strings, |
|
730 |
+ # as objects that stringify to the translation. |
|
731 |
+ epilog = inspect.cleandoc(self._text(self.epilog)) |
|
732 |
+ formatter.write_paragraph() |
|
733 |
+ with formatter.indentation(): |
|
734 |
+ formatter.write_text(epilog) |
|
735 |
+ |
|
736 |
+ |
|
737 |
+# Portions of this class are based directly on code from click 8.1. |
|
738 |
+# (This does not in general include docstrings, unless otherwise noted.) |
|
739 |
+# They are subject to the 3-clause BSD license in the following |
|
740 |
+# paragraphs. Modifications to their code are marked with respective |
|
741 |
+# comments; they too are released under the same license below. The |
|
742 |
+# original code did not contain any "noqa" or "pragma" comments. |
|
743 |
+# |
|
744 |
+# Copyright 2024 Pallets |
|
745 |
+# |
|
746 |
+# Redistribution and use in source and binary forms, with or |
|
747 |
+# without modification, are permitted provided that the |
|
748 |
+# following conditions are met: |
|
749 |
+# |
|
750 |
+# 1. Redistributions of source code must retain the above |
|
751 |
+# copyright notice, this list of conditions and the |
|
752 |
+# following disclaimer. |
|
753 |
+# |
|
754 |
+# 2. Redistributions in binary form must reproduce the above |
|
755 |
+# copyright notice, this list of conditions and the |
|
756 |
+# following disclaimer in the documentation and/or other |
|
757 |
+# materials provided with the distribution. |
|
758 |
+# |
|
759 |
+# 3. Neither the name of the copyright holder nor the names |
|
760 |
+# of its contributors may be used to endorse or promote |
|
761 |
+# products derived from this software without specific |
|
762 |
+# prior written permission. |
|
763 |
+# |
|
764 |
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND |
|
765 |
+# CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, |
|
766 |
+# INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF |
|
767 |
+# MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE |
|
768 |
+# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR |
|
769 |
+# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, |
|
770 |
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT |
|
771 |
+# NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; |
|
772 |
+# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) |
|
773 |
+# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN |
|
774 |
+# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR |
|
775 |
+# OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS |
|
776 |
+# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. |
|
777 |
+# |
|
778 |
+# TODO(the-13th-letter): Remove this class and license block in v1.0. |
|
779 |
+# https://the13thletter.info/derivepassphrase/latest/upgrade-notes/#v1.0-implied-subcommands |
|
780 |
+class DefaultToVaultGroup(CommandWithHelpGroups, click.Group): |
|
781 |
+ """A helper class to implement the default-to-"vault"-subcommand behavior. |
|
782 |
+ |
|
783 |
+ Modifies internal [`click.MultiCommand`][] methods, and thus is both |
|
784 |
+ an implementation detail and a kludge. |
|
785 |
+ |
|
786 |
+ """ |
|
787 |
+ |
|
788 |
+ def resolve_command( |
|
789 |
+ self, ctx: click.Context, args: list[str] |
|
790 |
+ ) -> tuple[str | None, click.Command | None, list[str]]: |
|
791 |
+ """Resolve a command, defaulting to "vault" instead of erroring out.""" # noqa: DOC201 |
|
792 |
+ cmd_name = click.utils.make_str(args[0]) |
|
793 |
+ |
|
794 |
+ # Get the command |
|
795 |
+ cmd = self.get_command(ctx, cmd_name) |
|
796 |
+ |
|
797 |
+ # If we can't find the command but there is a normalization |
|
798 |
+ # function available, we try with that one. |
|
799 |
+ if ( # pragma: no cover |
|
800 |
+ cmd is None and ctx.token_normalize_func is not None |
|
801 |
+ ): |
|
802 |
+ cmd_name = ctx.token_normalize_func(cmd_name) |
|
803 |
+ cmd = self.get_command(ctx, cmd_name) |
|
804 |
+ |
|
805 |
+ # If we don't find the command we want to show an error message |
|
806 |
+ # to the user that it was not provided. However, there is |
|
807 |
+ # something else we should do: if the first argument looks like |
|
808 |
+ # an option we want to kick off parsing again for arguments to |
|
809 |
+ # resolve things like --help which now should go to the main |
|
810 |
+ # place. |
|
811 |
+ if cmd is None and not ctx.resilient_parsing: |
|
812 |
+ if click.parser.split_opt(cmd_name)[0]: |
|
813 |
+ self.parse_args(ctx, ctx.args) |
|
814 |
+ #### |
|
815 |
+ # BEGIN modifications for derivepassphrase |
|
816 |
+ # |
|
817 |
+ # Instead of calling ctx.fail here, default to "vault", and |
|
818 |
+ # issue a deprecation warning. |
|
819 |
+ deprecation = logging.getLogger(f'{PROG_NAME}.deprecation') |
|
820 |
+ deprecation.warning( |
|
821 |
+ _msg.TranslatedString( |
|
822 |
+ _msg.WarnMsgTemplate.V10_SUBCOMMAND_REQUIRED |
|
823 |
+ ) |
|
824 |
+ ) |
|
825 |
+ cmd_name = 'vault' |
|
826 |
+ cmd = self.get_command(ctx, cmd_name) |
|
827 |
+ assert cmd is not None, 'Mandatory subcommand "vault" missing!' |
|
828 |
+ args = [cmd_name, *args] |
|
829 |
+ # |
|
830 |
+ # END modifications for derivepassphrase |
|
831 |
+ #### |
|
832 |
+ return cmd_name if cmd else None, cmd, args[1:] |
|
833 |
+ |
|
834 |
+ |
|
835 |
+# TODO(the-13th-letter): Base this class on CommandWithHelpGroups and |
|
836 |
+# click.Group in v1.0. |
|
837 |
+# https://the13thletter.info/derivepassphrase/latest/upgrade-notes/#v1.0-implied-subcommands |
|
838 |
+class TopLevelCLIEntryPoint(DefaultToVaultGroup): |
|
839 |
+ """A minor variation of DefaultToVaultGroup for the top-level command. |
|
840 |
+ |
|
841 |
+ When called as a function, this sets up the environment properly |
|
842 |
+ before invoking the actual callbacks. Currently, this means setting |
|
843 |
+ up the logging subsystem and the delegation of Python warnings to |
|
844 |
+ the logging subsystem. |
|
845 |
+ |
|
846 |
+ The environment setup can be bypassed by calling the `.main` method |
|
847 |
+ directly. |
|
848 |
+ |
|
849 |
+ """ |
|
850 |
+ |
|
851 |
+ def __call__( # pragma: no cover |
|
852 |
+ self, |
|
853 |
+ *args: Any, # noqa: ANN401 |
|
854 |
+ **kwargs: Any, # noqa: ANN401 |
|
855 |
+ ) -> Any: # noqa: ANN401 |
|
856 |
+ """""" # noqa: D419 |
|
857 |
+ # Coverage testing is done with the `click.testing` module, |
|
858 |
+ # which does not use the `__call__` shortcut. So it is normal |
|
859 |
+ # that this function is never called, and thus should be |
|
860 |
+ # excluded from coverage. |
|
861 |
+ with ( |
|
862 |
+ StandardCLILogging.ensure_standard_logging(), |
|
863 |
+ StandardCLILogging.ensure_standard_warnings_logging(), |
|
864 |
+ ): |
|
865 |
+ return self.main(*args, **kwargs) |
|
866 |
+ |
|
867 |
+ |
|
868 |
+# Actual option groups and callbacks used by derivepassphrase |
|
869 |
+# =========================================================== |
|
870 |
+ |
|
871 |
+def color_forcing_callback( |
|
872 |
+ ctx: click.Context, |
|
873 |
+ param: click.Parameter, |
|
874 |
+ value: Any, # noqa: ANN401 |
|
875 |
+) -> None: |
|
876 |
+ """Force the `click` context to honor `NO_COLOR` and `FORCE_COLOR`.""" |
|
877 |
+ del param, value |
|
878 |
+ if os.environ.get('NO_COLOR'): |
|
879 |
+ ctx.color = False |
|
880 |
+ if os.environ.get('FORCE_COLOR'): |
|
881 |
+ ctx.color = True |
|
882 |
+ |
|
883 |
+ |
|
884 |
+def validate_occurrence_constraint( |
|
885 |
+ ctx: click.Context, |
|
886 |
+ param: click.Parameter, |
|
887 |
+ value: Any, # noqa: ANN401 |
|
888 |
+) -> int | None: |
|
889 |
+ """Check that the occurrence constraint is valid (int, 0 or larger). |
|
890 |
+ |
|
891 |
+ Args: |
|
892 |
+ ctx: The `click` context. |
|
893 |
+ param: The current command-line parameter. |
|
894 |
+ value: The parameter value to be checked. |
|
895 |
+ |
|
896 |
+ Returns: |
|
897 |
+ The parsed parameter value. |
|
898 |
+ |
|
899 |
+ Raises: |
|
900 |
+ click.BadParameter: The parameter value is invalid. |
|
901 |
+ |
|
902 |
+ """ |
|
903 |
+ del ctx # Unused. |
|
904 |
+ del param # Unused. |
|
905 |
+ if value is None: |
|
906 |
+ return value |
|
907 |
+ if isinstance(value, int): |
|
908 |
+ int_value = value |
|
909 |
+ else: |
|
910 |
+ try: |
|
911 |
+ int_value = int(value, 10) |
|
912 |
+ except ValueError as exc: |
|
913 |
+ raise click.BadParameter(NOT_AN_INTEGER) from exc |
|
914 |
+ if int_value < 0: |
|
915 |
+ raise click.BadParameter(NOT_A_NONNEGATIVE_INTEGER) |
|
916 |
+ return int_value |
|
917 |
+ |
|
918 |
+ |
|
919 |
+def validate_length( |
|
920 |
+ ctx: click.Context, |
|
921 |
+ param: click.Parameter, |
|
922 |
+ value: Any, # noqa: ANN401 |
|
923 |
+) -> int | None: |
|
924 |
+ """Check that the length is valid (int, 1 or larger). |
|
925 |
+ |
|
926 |
+ Args: |
|
927 |
+ ctx: The `click` context. |
|
928 |
+ param: The current command-line parameter. |
|
929 |
+ value: The parameter value to be checked. |
|
930 |
+ |
|
931 |
+ Returns: |
|
932 |
+ The parsed parameter value. |
|
933 |
+ |
|
934 |
+ Raises: |
|
935 |
+ click.BadParameter: The parameter value is invalid. |
|
936 |
+ |
|
937 |
+ """ |
|
938 |
+ del ctx # Unused. |
|
939 |
+ del param # Unused. |
|
940 |
+ if value is None: |
|
941 |
+ return value |
|
942 |
+ if isinstance(value, int): |
|
943 |
+ int_value = value |
|
944 |
+ else: |
|
945 |
+ try: |
|
946 |
+ int_value = int(value, 10) |
|
947 |
+ except ValueError as exc: |
|
948 |
+ raise click.BadParameter(NOT_AN_INTEGER) from exc |
|
949 |
+ if int_value < 1: |
|
950 |
+ raise click.BadParameter(NOT_A_POSITIVE_INTEGER) |
|
951 |
+ return int_value |
|
952 |
+ |
|
953 |
+ |
|
954 |
+def version_option_callback( |
|
955 |
+ ctx: click.Context, |
|
956 |
+ param: click.Parameter, |
|
957 |
+ value: bool, # noqa: FBT001 |
|
958 |
+) -> None: |
|
959 |
+ del param |
|
960 |
+ if value and not ctx.resilient_parsing: |
|
961 |
+ click.echo( |
|
962 |
+ str( |
|
963 |
+ _msg.TranslatedString( |
|
964 |
+ _msg.Label.VERSION_INFO_TEXT, |
|
965 |
+ PROG_NAME=PROG_NAME, |
|
966 |
+ __version__=__version__, |
|
967 |
+ ) |
|
968 |
+ ), |
|
969 |
+ ) |
|
970 |
+ ctx.exit() |
|
971 |
+ |
|
972 |
+ |
|
973 |
+def version_option(f: Callable[P, R]) -> Callable[P, R]: |
|
974 |
+ return click.option( |
|
975 |
+ '--version', |
|
976 |
+ is_flag=True, |
|
977 |
+ is_eager=True, |
|
978 |
+ expose_value=False, |
|
979 |
+ callback=version_option_callback, |
|
980 |
+ cls=StandardOption, |
|
981 |
+ help=_msg.TranslatedString(_msg.Label.VERSION_OPTION_HELP_TEXT), |
|
982 |
+ )(f) |
|
983 |
+ |
|
984 |
+ |
|
985 |
+color_forcing_pseudo_option = click.option( |
|
986 |
+ '--_pseudo-option-color-forcing', |
|
987 |
+ '_color_forcing', |
|
988 |
+ is_flag=True, |
|
989 |
+ is_eager=True, |
|
990 |
+ expose_value=False, |
|
991 |
+ hidden=True, |
|
992 |
+ callback=color_forcing_callback, |
|
993 |
+ help='(pseudo-option)', |
|
994 |
+) |
|
995 |
+ |
|
996 |
+ |
|
997 |
+class PassphraseGenerationOption(OptionGroupOption): |
|
998 |
+ """Passphrase generation options for the CLI.""" |
|
999 |
+ |
|
1000 |
+ option_group_name = _msg.TranslatedString( |
|
1001 |
+ _msg.Label.PASSPHRASE_GENERATION_LABEL |
|
1002 |
+ ) |
|
1003 |
+ epilog = _msg.TranslatedString( |
|
1004 |
+ _msg.Label.PASSPHRASE_GENERATION_EPILOG, |
|
1005 |
+ metavar=_msg.TranslatedString( |
|
1006 |
+ _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER |
|
1007 |
+ ), |
|
1008 |
+ ) |
|
1009 |
+ |
|
1010 |
+ |
|
1011 |
+class ConfigurationOption(OptionGroupOption): |
|
1012 |
+ """Configuration options for the CLI.""" |
|
1013 |
+ |
|
1014 |
+ option_group_name = _msg.TranslatedString(_msg.Label.CONFIGURATION_LABEL) |
|
1015 |
+ epilog = _msg.TranslatedString(_msg.Label.CONFIGURATION_EPILOG) |
|
1016 |
+ |
|
1017 |
+ |
|
1018 |
+class StorageManagementOption(OptionGroupOption): |
|
1019 |
+ """Storage management options for the CLI.""" |
|
1020 |
+ |
|
1021 |
+ option_group_name = _msg.TranslatedString( |
|
1022 |
+ _msg.Label.STORAGE_MANAGEMENT_LABEL |
|
1023 |
+ ) |
|
1024 |
+ epilog = _msg.TranslatedString( |
|
1025 |
+ _msg.Label.STORAGE_MANAGEMENT_EPILOG, |
|
1026 |
+ metavar=_msg.TranslatedString( |
|
1027 |
+ _msg.Label.STORAGE_MANAGEMENT_METAVAR_PATH |
|
1028 |
+ ), |
|
1029 |
+ ) |
|
1030 |
+ |
|
1031 |
+ |
|
1032 |
+class CompatibilityOption(OptionGroupOption): |
|
1033 |
+ """Compatibility and incompatibility options for the CLI.""" |
|
1034 |
+ |
|
1035 |
+ option_group_name = _msg.TranslatedString( |
|
1036 |
+ _msg.Label.COMPATIBILITY_OPTION_LABEL |
|
1037 |
+ ) |
|
1038 |
+ |
|
1039 |
+ |
|
1040 |
+class LoggingOption(OptionGroupOption): |
|
1041 |
+ """Logging options for the CLI.""" |
|
1042 |
+ |
|
1043 |
+ option_group_name = _msg.TranslatedString(_msg.Label.LOGGING_LABEL) |
|
1044 |
+ epilog = '' |
|
1045 |
+ |
|
1046 |
+ |
|
1047 |
+debug_option = click.option( |
|
1048 |
+ '--debug', |
|
1049 |
+ 'logging_level', |
|
1050 |
+ is_flag=True, |
|
1051 |
+ flag_value=logging.DEBUG, |
|
1052 |
+ expose_value=False, |
|
1053 |
+ callback=adjust_logging_level, |
|
1054 |
+ help=_msg.TranslatedString(_msg.Label.DEBUG_OPTION_HELP_TEXT), |
|
1055 |
+ cls=LoggingOption, |
|
1056 |
+) |
|
1057 |
+verbose_option = click.option( |
|
1058 |
+ '-v', |
|
1059 |
+ '--verbose', |
|
1060 |
+ 'logging_level', |
|
1061 |
+ is_flag=True, |
|
1062 |
+ flag_value=logging.INFO, |
|
1063 |
+ expose_value=False, |
|
1064 |
+ callback=adjust_logging_level, |
|
1065 |
+ help=_msg.TranslatedString(_msg.Label.VERBOSE_OPTION_HELP_TEXT), |
|
1066 |
+ cls=LoggingOption, |
|
1067 |
+) |
|
1068 |
+quiet_option = click.option( |
|
1069 |
+ '-q', |
|
1070 |
+ '--quiet', |
|
1071 |
+ 'logging_level', |
|
1072 |
+ is_flag=True, |
|
1073 |
+ flag_value=logging.ERROR, |
|
1074 |
+ expose_value=False, |
|
1075 |
+ callback=adjust_logging_level, |
|
1076 |
+ help=_msg.TranslatedString(_msg.Label.QUIET_OPTION_HELP_TEXT), |
|
1077 |
+ cls=LoggingOption, |
|
1078 |
+) |
|
1079 |
+ |
|
1080 |
+ |
|
1081 |
+def standard_logging_options(f: Callable[P, R]) -> Callable[P, R]: |
|
1082 |
+ """Decorate the function with standard logging click options. |
|
1083 |
+ |
|
1084 |
+ Adds the three click options `-v`/`--verbose`, `-q`/`--quiet` and |
|
1085 |
+ `--debug`, which calls back into the [`adjust_logging_level`][] |
|
1086 |
+ function (with different argument values). |
|
1087 |
+ |
|
1088 |
+ Args: |
|
1089 |
+ f: A callable to decorate. |
|
1090 |
+ |
|
1091 |
+ Returns: |
|
1092 |
+ The decorated callable. |
|
1093 |
+ |
|
1094 |
+ """ |
|
1095 |
+ return debug_option(verbose_option(quiet_option(f))) |
|
1096 |
+ |
|
1097 |
+ |
|
1098 |
+# Shell completion |
|
1099 |
+# ================ |
|
1100 |
+ |
|
1101 |
+# TODO(the-13th-letter): Remove this once upstream click's Zsh completion |
|
1102 |
+# script properly supports colons. |
|
1103 |
+# |
|
1104 |
+# https://github.com/pallets/click/pull/2846 |
|
1105 |
+class ZshComplete(click.shell_completion.ZshComplete): |
|
1106 |
+ """Zsh completion class that supports colons. |
|
1107 |
+ |
|
1108 |
+ `click`'s Zsh completion class (at least v8.1.7 and v8.1.8) uses |
|
1109 |
+ some completion helper functions (provided by Zsh) that parse each |
|
1110 |
+ completion item into value-description pairs, separated by a colon. |
|
1111 |
+ Other completion helper functions don't. Correspondingly, any |
|
1112 |
+ internal colons in the completion item's value sometimes need to be |
|
1113 |
+ escaped, and sometimes don't. |
|
1114 |
+ |
|
1115 |
+ The "right" way to fix this is to modify the Zsh completion script |
|
1116 |
+ to only use one type of serialization: either escaped, or unescaped. |
|
1117 |
+ However, the Zsh completion script itself may already be installed |
|
1118 |
+ in the user's Zsh settings, and we have no way of knowing that. |
|
1119 |
+ Therefore, it is better to change the `format_completion` method to |
|
1120 |
+ adaptively and "smartly" emit colon-escaped output or not, based on |
|
1121 |
+ whether the completion script will be using it. |
|
1122 |
+ |
|
1123 |
+ """ |
|
1124 |
+ |
|
1125 |
+ @override |
|
1126 |
+ def format_completion( |
|
1127 |
+ self, |
|
1128 |
+ item: click.shell_completion.CompletionItem, |
|
1129 |
+ ) -> str: |
|
1130 |
+ """Return a suitable serialization of the CompletionItem. |
|
1131 |
+ |
|
1132 |
+ This serialization ensures colons in the item value are properly |
|
1133 |
+ escaped if and only if the completion script will attempt to |
|
1134 |
+ pass a colon-separated key/description pair to the underlying |
|
1135 |
+ Zsh machinery. This is the case if and only if the help text is |
|
1136 |
+ non-degenerate. |
|
1137 |
+ |
|
1138 |
+ """ |
|
1139 |
+ help_ = item.help or '_' |
|
1140 |
+ value = item.value.replace(':', r'\:' if help_ != '_' else ':') |
|
1141 |
+ return f'{item.type}\n{value}\n{help_}' |
|
1142 |
+ |
|
1143 |
+ |
|
1144 |
+# Our ZshComplete class depends crucially on the exact shape of the Zsh |
|
1145 |
+# completion script. So only fix the completion formatter if the |
|
1146 |
+# completion script is still the same. |
|
1147 |
+# |
|
1148 |
+# (This Zsh script is part of click, and available under the |
|
1149 |
+# 3-clause-BSD license.) |
|
1150 |
+_ORIG_SOURCE_TEMPLATE = """\ |
|
1151 |
+#compdef %(prog_name)s |
|
1152 |
+ |
|
1153 |
+%(complete_func)s() { |
|
1154 |
+ local -a completions |
|
1155 |
+ local -a completions_with_descriptions |
|
1156 |
+ local -a response |
|
1157 |
+ (( ! $+commands[%(prog_name)s] )) && return 1 |
|
1158 |
+ |
|
1159 |
+ response=("${(@f)$(env COMP_WORDS="${words[*]}" COMP_CWORD=$((CURRENT-1)) \ |
|
1160 |
+%(complete_var)s=zsh_complete %(prog_name)s)}") |
|
1161 |
+ |
|
1162 |
+ for type key descr in ${response}; do |
|
1163 |
+ if [[ "$type" == "plain" ]]; then |
|
1164 |
+ if [[ "$descr" == "_" ]]; then |
|
1165 |
+ completions+=("$key") |
|
1166 |
+ else |
|
1167 |
+ completions_with_descriptions+=("$key":"$descr") |
|
1168 |
+ fi |
|
1169 |
+ elif [[ "$type" == "dir" ]]; then |
|
1170 |
+ _path_files -/ |
|
1171 |
+ elif [[ "$type" == "file" ]]; then |
|
1172 |
+ _path_files -f |
|
1173 |
+ fi |
|
1174 |
+ done |
|
1175 |
+ |
|
1176 |
+ if [ -n "$completions_with_descriptions" ]; then |
|
1177 |
+ _describe -V unsorted completions_with_descriptions -U |
|
1178 |
+ fi |
|
1179 |
+ |
|
1180 |
+ if [ -n "$completions" ]; then |
|
1181 |
+ compadd -U -V unsorted -a completions |
|
1182 |
+ fi |
|
1183 |
+} |
|
1184 |
+ |
|
1185 |
+if [[ $zsh_eval_context[-1] == loadautofunc ]]; then |
|
1186 |
+ # autoload from fpath, call function directly |
|
1187 |
+ %(complete_func)s "$@" |
|
1188 |
+else |
|
1189 |
+ # eval/source/. command, register function for later |
|
1190 |
+ compdef %(complete_func)s %(prog_name)s |
|
1191 |
+fi |
|
1192 |
+""" |
|
1193 |
+if ( |
|
1194 |
+ click.shell_completion.ZshComplete.source_template == _ORIG_SOURCE_TEMPLATE |
|
1195 |
+): # pragma: no cover |
|
1196 |
+ click.shell_completion.add_completion_class(ZshComplete) |
... | ... |
@@ -10,25 +10,15 @@ from __future__ import annotations |
10 | 10 |
|
11 | 11 |
import base64 |
12 | 12 |
import collections |
13 |
-import copy |
|
14 |
-import enum |
|
15 | 13 |
import functools |
16 |
-import inspect |
|
17 | 14 |
import json |
18 | 15 |
import logging |
19 | 16 |
import os |
20 |
-import pathlib |
|
21 |
-import shlex |
|
22 |
-import sys |
|
23 |
-import unicodedata |
|
24 |
-import warnings |
|
25 | 17 |
from typing import ( |
26 | 18 |
TYPE_CHECKING, |
27 |
- Callable, |
|
28 | 19 |
Literal, |
29 | 20 |
NoReturn, |
30 | 21 |
TextIO, |
31 |
- TypeVar, |
|
32 | 22 |
cast, |
33 | 23 |
) |
34 | 24 |
|
... | ... |
@@ -36,26 +26,15 @@ import click |
36 | 26 |
import click.shell_completion |
37 | 27 |
from typing_extensions import ( |
38 | 28 |
Any, |
39 |
- ParamSpec, |
|
40 |
- Self, |
|
41 |
- override, |
|
42 | 29 |
) |
43 | 30 |
|
44 | 31 |
import derivepassphrase as dpp |
45 | 32 |
from derivepassphrase import _types, exporter, ssh_agent, vault |
33 |
+from derivepassphrase._internals import cli_helpers, cli_machinery |
|
46 | 34 |
from derivepassphrase._internals import cli_messages as _msg |
47 | 35 |
|
48 |
-if sys.version_info >= (3, 11): |
|
49 |
- import tomllib |
|
50 |
-else: |
|
51 |
- import tomli as tomllib |
|
52 |
- |
|
53 | 36 |
if TYPE_CHECKING: |
54 |
- import socket |
|
55 |
- import types |
|
56 | 37 |
from collections.abc import ( |
57 |
- Iterator, |
|
58 |
- MutableSequence, |
|
59 | 38 |
Sequence, |
60 | 39 |
) |
61 | 40 |
|
... | ... |
@@ -65,1141 +44,6 @@ __version__ = dpp.__version__ |
65 | 44 |
__all__ = ('derivepassphrase',) |
66 | 45 |
|
67 | 46 |
PROG_NAME = _msg.PROG_NAME |
68 |
-KEY_DISPLAY_LENGTH = 50 |
|
69 |
- |
|
70 |
-# Error messages |
|
71 |
-_INVALID_VAULT_CONFIG = 'Invalid vault config' |
|
72 |
-_AGENT_COMMUNICATION_ERROR = 'Error communicating with the SSH agent' |
|
73 |
-_NO_SUITABLE_KEYS = 'No suitable SSH keys were found' |
|
74 |
-_EMPTY_SELECTION = 'Empty selection' |
|
75 |
-_NOT_AN_INTEGER = 'not an integer' |
|
76 |
-_NOT_A_NONNEGATIVE_INTEGER = 'not a non-negative integer' |
|
77 |
-_NOT_A_POSITIVE_INTEGER = 'not a positive integer' |
|
78 |
- |
|
79 |
- |
|
80 |
-# Logging |
|
81 |
-# ======= |
|
82 |
- |
|
83 |
- |
|
84 |
-class ClickEchoStderrHandler(logging.Handler): |
|
85 |
- """A [`logging.Handler`][] for `click` applications. |
|
86 |
- |
|
87 |
- Outputs log messages to [`sys.stderr`][] via [`click.echo`][]. |
|
88 |
- |
|
89 |
- """ |
|
90 |
- |
|
91 |
- def emit(self, record: logging.LogRecord) -> None: |
|
92 |
- """Emit a log record. |
|
93 |
- |
|
94 |
- Format the log record, then emit it via [`click.echo`][] to |
|
95 |
- [`sys.stderr`][]. |
|
96 |
- |
|
97 |
- """ |
|
98 |
- click.echo( |
|
99 |
- self.format(record), |
|
100 |
- err=True, |
|
101 |
- color=getattr(record, 'color', None), |
|
102 |
- ) |
|
103 |
- |
|
104 |
- |
|
105 |
-class CLIofPackageFormatter(logging.Formatter): |
|
106 |
- """A [`logging.LogRecord`][] formatter for the CLI of a Python package. |
|
107 |
- |
|
108 |
- Assuming a package `PKG` and loggers within the same hierarchy |
|
109 |
- `PKG`, format all log records from that hierarchy for proper user |
|
110 |
- feedback on the console. Intended for use with [`click`][CLICK] and |
|
111 |
- when `PKG` provides a command-line tool `PKG` and when logs from |
|
112 |
- that package should show up as output of the command-line tool. |
|
113 |
- |
|
114 |
- Essentially, this prepends certain short strings to the log message |
|
115 |
- lines to make them readable as standard error output. |
|
116 |
- |
|
117 |
- Because this log output is intended to be displayed on standard |
|
118 |
- error as high-level diagnostic output, you are strongly discouraged |
|
119 |
- from changing the output format to include more tokens besides the |
|
120 |
- log message. Use a dedicated log file handler instead, without this |
|
121 |
- formatter. |
|
122 |
- |
|
123 |
- [CLICK]: https://pypi.org/projects/click/ |
|
124 |
- |
|
125 |
- """ |
|
126 |
- |
|
127 |
- def __init__( |
|
128 |
- self, |
|
129 |
- *, |
|
130 |
- prog_name: str = PROG_NAME, |
|
131 |
- package_name: str | None = None, |
|
132 |
- ) -> None: |
|
133 |
- self.prog_name = prog_name |
|
134 |
- self.package_name = ( |
|
135 |
- package_name |
|
136 |
- if package_name is not None |
|
137 |
- else prog_name.lower().replace(' ', '_').replace('-', '_') |
|
138 |
- ) |
|
139 |
- |
|
140 |
- def format(self, record: logging.LogRecord) -> str: |
|
141 |
- """Format a log record suitably for standard error console output. |
|
142 |
- |
|
143 |
- Prepend the formatted string `"PROG_NAME: LABEL"` to each line |
|
144 |
- of the message, where `PROG_NAME` is the program name, and |
|
145 |
- `LABEL` depends on the record's level and on the logger name as |
|
146 |
- follows: |
|
147 |
- |
|
148 |
- * For records at level [`logging.DEBUG`][], `LABEL` is |
|
149 |
- `"Debug: "`. |
|
150 |
- * For records at level [`logging.INFO`][], `LABEL` is the |
|
151 |
- empty string. |
|
152 |
- * For records at level [`logging.WARNING`][], `LABEL` is |
|
153 |
- `"Deprecation warning: "` if the logger is named |
|
154 |
- `PKG.deprecation` (where `PKG` is the package name), else |
|
155 |
- `"Warning: "`. |
|
156 |
- * For records at level [`logging.ERROR`][] and |
|
157 |
- [`logging.CRITICAL`][] `"Error: "`, `LABEL` is the empty |
|
158 |
- string. |
|
159 |
- |
|
160 |
- The level indication strings at level `WARNING` or above are |
|
161 |
- highlighted. Use [`click.echo`][] to output them and remove |
|
162 |
- color output if necessary. |
|
163 |
- |
|
164 |
- Args: |
|
165 |
- record: A log record. |
|
166 |
- |
|
167 |
- Returns: |
|
168 |
- A formatted log record. |
|
169 |
- |
|
170 |
- Raises: |
|
171 |
- AssertionError: |
|
172 |
- The log level is not supported. |
|
173 |
- |
|
174 |
- """ |
|
175 |
- preliminary_result = record.getMessage() |
|
176 |
- prefix = f'{self.prog_name}: ' |
|
177 |
- if record.levelname == 'DEBUG': # pragma: no cover |
|
178 |
- level_indicator = 'Debug: ' |
|
179 |
- elif record.levelname == 'INFO': |
|
180 |
- level_indicator = '' |
|
181 |
- elif record.levelname == 'WARNING': |
|
182 |
- level_indicator = ( |
|
183 |
- f'{click.style("Deprecation warning", bold=True)}: ' |
|
184 |
- if record.name.endswith('.deprecation') |
|
185 |
- else f'{click.style("Warning", bold=True)}: ' |
|
186 |
- ) |
|
187 |
- elif record.levelname in {'ERROR', 'CRITICAL'}: |
|
188 |
- level_indicator = '' |
|
189 |
- else: # pragma: no cover |
|
190 |
- msg = f'Unsupported logging level: {record.levelname}' |
|
191 |
- raise AssertionError(msg) |
|
192 |
- parts = [ |
|
193 |
- ''.join( |
|
194 |
- prefix + level_indicator + line |
|
195 |
- for line in preliminary_result.splitlines(True) # noqa: FBT003 |
|
196 |
- ) |
|
197 |
- ] |
|
198 |
- if record.exc_info: |
|
199 |
- parts.append(self.formatException(record.exc_info) + '\n') |
|
200 |
- return ''.join(parts) |
|
201 |
- |
|
202 |
- |
|
203 |
-class StandardCLILogging: |
|
204 |
- """Set up CLI logging handlers upon instantiation.""" |
|
205 |
- |
|
206 |
- prog_name = PROG_NAME |
|
207 |
- package_name = PROG_NAME.lower().replace(' ', '_').replace('-', '_') |
|
208 |
- cli_formatter = CLIofPackageFormatter( |
|
209 |
- prog_name=prog_name, package_name=package_name |
|
210 |
- ) |
|
211 |
- cli_handler = ClickEchoStderrHandler() |
|
212 |
- cli_handler.addFilter(logging.Filter(name=package_name)) |
|
213 |
- cli_handler.setFormatter(cli_formatter) |
|
214 |
- cli_handler.setLevel(logging.WARNING) |
|
215 |
- warnings_handler = ClickEchoStderrHandler() |
|
216 |
- warnings_handler.addFilter(logging.Filter(name='py.warnings')) |
|
217 |
- warnings_handler.setFormatter(cli_formatter) |
|
218 |
- warnings_handler.setLevel(logging.WARNING) |
|
219 |
- |
|
220 |
- @classmethod |
|
221 |
- def ensure_standard_logging(cls) -> StandardLoggingContextManager: |
|
222 |
- """Return a context manager to ensure standard logging is set up.""" |
|
223 |
- return StandardLoggingContextManager( |
|
224 |
- handler=cls.cli_handler, |
|
225 |
- root_logger=cls.package_name, |
|
226 |
- ) |
|
227 |
- |
|
228 |
- @classmethod |
|
229 |
- def ensure_standard_warnings_logging( |
|
230 |
- cls, |
|
231 |
- ) -> StandardWarningsLoggingContextManager: |
|
232 |
- """Return a context manager to ensure warnings logging is set up.""" |
|
233 |
- return StandardWarningsLoggingContextManager( |
|
234 |
- handler=cls.warnings_handler, |
|
235 |
- ) |
|
236 |
- |
|
237 |
- |
|
238 |
-class StandardLoggingContextManager: |
|
239 |
- """A reentrant context manager setting up standard CLI logging. |
|
240 |
- |
|
241 |
- Ensures that the given handler (defaulting to the CLI logging |
|
242 |
- handler) is added to the named logger (defaulting to the root |
|
243 |
- logger), and if it had to be added, then that it will be removed |
|
244 |
- upon exiting the context. |
|
245 |
- |
|
246 |
- Reentrant, but not thread safe, because it temporarily modifies |
|
247 |
- global state. |
|
248 |
- |
|
249 |
- """ |
|
250 |
- |
|
251 |
- def __init__( |
|
252 |
- self, |
|
253 |
- handler: logging.Handler, |
|
254 |
- root_logger: str | None = None, |
|
255 |
- ) -> None: |
|
256 |
- self.handler = handler |
|
257 |
- self.root_logger_name = root_logger |
|
258 |
- self.base_logger = logging.getLogger(self.root_logger_name) |
|
259 |
- self.action_required: MutableSequence[bool] = collections.deque() |
|
260 |
- |
|
261 |
- def __enter__(self) -> Self: |
|
262 |
- self.action_required.append( |
|
263 |
- self.handler not in self.base_logger.handlers |
|
264 |
- ) |
|
265 |
- if self.action_required[-1]: |
|
266 |
- self.base_logger.addHandler(self.handler) |
|
267 |
- return self |
|
268 |
- |
|
269 |
- def __exit__( |
|
270 |
- self, |
|
271 |
- exc_type: type[BaseException] | None, |
|
272 |
- exc_value: BaseException | None, |
|
273 |
- exc_tb: types.TracebackType | None, |
|
274 |
- ) -> Literal[False]: |
|
275 |
- if self.action_required[-1]: |
|
276 |
- self.base_logger.removeHandler(self.handler) |
|
277 |
- self.action_required.pop() |
|
278 |
- return False |
|
279 |
- |
|
280 |
- |
|
281 |
-class StandardWarningsLoggingContextManager(StandardLoggingContextManager): |
|
282 |
- """A reentrant context manager setting up standard warnings logging. |
|
283 |
- |
|
284 |
- Ensures that warnings are being diverted to the logging system, and |
|
285 |
- that the given handler (defaulting to the CLI logging handler) is |
|
286 |
- added to the warnings logger. If the handler had to be added, then |
|
287 |
- it will be removed upon exiting the context. |
|
288 |
- |
|
289 |
- Reentrant, but not thread safe, because it temporarily modifies |
|
290 |
- global state. |
|
291 |
- |
|
292 |
- """ |
|
293 |
- |
|
294 |
- def __init__( |
|
295 |
- self, |
|
296 |
- handler: logging.Handler, |
|
297 |
- ) -> None: |
|
298 |
- super().__init__(handler=handler, root_logger='py.warnings') |
|
299 |
- self.stack: MutableSequence[ |
|
300 |
- tuple[ |
|
301 |
- Callable[ |
|
302 |
- [ |
|
303 |
- type[BaseException] | None, |
|
304 |
- BaseException | None, |
|
305 |
- types.TracebackType | None, |
|
306 |
- ], |
|
307 |
- None, |
|
308 |
- ], |
|
309 |
- Callable[ |
|
310 |
- [ |
|
311 |
- str | Warning, |
|
312 |
- type[Warning], |
|
313 |
- str, |
|
314 |
- int, |
|
315 |
- TextIO | None, |
|
316 |
- str | None, |
|
317 |
- ], |
|
318 |
- None, |
|
319 |
- ], |
|
320 |
- ] |
|
321 |
- ] = collections.deque() |
|
322 |
- |
|
323 |
- def __enter__(self) -> Self: |
|
324 |
- def showwarning( # noqa: PLR0913,PLR0917 |
|
325 |
- message: str | Warning, |
|
326 |
- category: type[Warning], |
|
327 |
- filename: str, |
|
328 |
- lineno: int, |
|
329 |
- file: TextIO | None = None, |
|
330 |
- line: str | None = None, |
|
331 |
- ) -> None: |
|
332 |
- if file is not None: # pragma: no cover |
|
333 |
- self.stack[0][1]( |
|
334 |
- message, category, filename, lineno, file, line |
|
335 |
- ) |
|
336 |
- else: |
|
337 |
- logging.getLogger('py.warnings').warning( |
|
338 |
- str( |
|
339 |
- warnings.formatwarning( |
|
340 |
- message, category, filename, lineno, line |
|
341 |
- ) |
|
342 |
- ) |
|
343 |
- ) |
|
344 |
- |
|
345 |
- ctx = warnings.catch_warnings() |
|
346 |
- exit_func = ctx.__exit__ |
|
347 |
- ctx.__enter__() |
|
348 |
- self.stack.append((exit_func, warnings.showwarning)) |
|
349 |
- warnings.showwarning = showwarning |
|
350 |
- return super().__enter__() |
|
351 |
- |
|
352 |
- def __exit__( |
|
353 |
- self, |
|
354 |
- exc_type: type[BaseException] | None, |
|
355 |
- exc_value: BaseException | None, |
|
356 |
- exc_tb: types.TracebackType | None, |
|
357 |
- ) -> Literal[False]: |
|
358 |
- ret = super().__exit__(exc_type, exc_value, exc_tb) |
|
359 |
- val = self.stack.pop()[0](exc_type, exc_value, exc_tb) |
|
360 |
- assert not val |
|
361 |
- return ret |
|
362 |
- |
|
363 |
- |
|
364 |
-P = ParamSpec('P') |
|
365 |
-R = TypeVar('R') |
|
366 |
- |
|
367 |
- |
|
368 |
-def adjust_logging_level( |
|
369 |
- ctx: click.Context, |
|
370 |
- /, |
|
371 |
- param: click.Parameter | None = None, |
|
372 |
- value: int | None = None, |
|
373 |
-) -> None: |
|
374 |
- """Change the logs that are emitted to standard error. |
|
375 |
- |
|
376 |
- This modifies the [`StandardCLILogging`][] settings such that log |
|
377 |
- records at the respective level are emitted, based on the `param` |
|
378 |
- and the `value`. |
|
379 |
- |
|
380 |
- """ |
|
381 |
- # Note: If multiple options use this callback, then we will be |
|
382 |
- # called multiple times. Ensure the runs are idempotent. |
|
383 |
- if param is None or value is None or ctx.resilient_parsing: |
|
384 |
- return |
|
385 |
- StandardCLILogging.cli_handler.setLevel(value) |
|
386 |
- logging.getLogger(StandardCLILogging.package_name).setLevel(value) |
|
387 |
- |
|
388 |
- |
|
389 |
-# Option parsing and grouping |
|
390 |
-# =========================== |
|
391 |
- |
|
392 |
- |
|
393 |
-class OptionGroupOption(click.Option): |
|
394 |
- """A [`click.Option`][] with an associated group name and group epilog. |
|
395 |
- |
|
396 |
- Used by [`CommandWithHelpGroups`][] to print help sections. Each |
|
397 |
- subclass contains its own group name and epilog. |
|
398 |
- |
|
399 |
- Attributes: |
|
400 |
- option_group_name: |
|
401 |
- The name of the option group. Used as a heading on the help |
|
402 |
- text for options in this section. |
|
403 |
- epilog: |
|
404 |
- An epilog to print after listing the options in this |
|
405 |
- section. |
|
406 |
- |
|
407 |
- """ |
|
408 |
- |
|
409 |
- option_group_name: object = '' |
|
410 |
- """""" |
|
411 |
- epilog: object = '' |
|
412 |
- """""" |
|
413 |
- |
|
414 |
- def __init__(self, *args: Any, **kwargs: Any) -> None: # noqa: ANN401 |
|
415 |
- if self.__class__ == __class__: # type: ignore[name-defined] |
|
416 |
- raise NotImplementedError |
|
417 |
- # Though click 8.1 mostly defers help text processing until the |
|
418 |
- # `BaseCommand.format_*` methods are called, the Option |
|
419 |
- # constructor still preprocesses the help text, and asserts that |
|
420 |
- # the help text is a string. Work around this by removing the |
|
421 |
- # help text from the constructor arguments and re-adding it, |
|
422 |
- # unprocessed, after constructor finishes. |
|
423 |
- unset = object() |
|
424 |
- help = kwargs.pop('help', unset) # noqa: A001 |
|
425 |
- super().__init__(*args, **kwargs) |
|
426 |
- if help is not unset: # pragma: no branch |
|
427 |
- self.help = help |
|
428 |
- |
|
429 |
- |
|
430 |
-class StandardOption(OptionGroupOption): |
|
431 |
- pass |
|
432 |
- |
|
433 |
- |
|
434 |
-# Portions of this class are based directly on code from click 8.1. |
|
435 |
-# (This does not in general include docstrings, unless otherwise noted.) |
|
436 |
-# They are subject to the 3-clause BSD license in the following |
|
437 |
-# paragraphs. Modifications to their code are marked with respective |
|
438 |
-# comments; they too are released under the same license below. The |
|
439 |
-# original code did not contain any "noqa" or "pragma" comments. |
|
440 |
-# |
|
441 |
-# Copyright 2024 Pallets |
|
442 |
-# |
|
443 |
-# Redistribution and use in source and binary forms, with or |
|
444 |
-# without modification, are permitted provided that the |
|
445 |
-# following conditions are met: |
|
446 |
-# |
|
447 |
-# 1. Redistributions of source code must retain the above |
|
448 |
-# copyright notice, this list of conditions and the |
|
449 |
-# following disclaimer. |
|
450 |
-# |
|
451 |
-# 2. Redistributions in binary form must reproduce the above |
|
452 |
-# copyright notice, this list of conditions and the |
|
453 |
-# following disclaimer in the documentation and/or other |
|
454 |
-# materials provided with the distribution. |
|
455 |
-# |
|
456 |
-# 3. Neither the name of the copyright holder nor the names |
|
457 |
-# of its contributors may be used to endorse or promote |
|
458 |
-# products derived from this software without specific |
|
459 |
-# prior written permission. |
|
460 |
-# |
|
461 |
-# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND |
|
462 |
-# CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, |
|
463 |
-# INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF |
|
464 |
-# MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE |
|
465 |
-# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR |
|
466 |
-# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, |
|
467 |
-# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT |
|
468 |
-# NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; |
|
469 |
-# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) |
|
470 |
-# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN |
|
471 |
-# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR |
|
472 |
-# OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS |
|
473 |
-# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. |
|
474 |
-class CommandWithHelpGroups(click.Command): |
|
475 |
- """A [`click.Command`][] with support for some help text customizations. |
|
476 |
- |
|
477 |
- Supports help/option groups, group epilogs, and help text objects |
|
478 |
- (objects that stringify to help texts). The latter is primarily |
|
479 |
- used to implement translations. |
|
480 |
- |
|
481 |
- Inspired by [a comment on `pallets/click#373`][CLICK_ISSUE] for |
|
482 |
- help/option group support, and further modified to include group |
|
483 |
- epilogs and help text objects. |
|
484 |
- |
|
485 |
- [CLICK_ISSUE]: https://github.com/pallets/click/issues/373#issuecomment-515293746 |
|
486 |
- |
|
487 |
- """ |
|
488 |
- |
|
489 |
- @staticmethod |
|
490 |
- def _text(text: object, /) -> str: |
|
491 |
- if isinstance(text, (list, tuple)): |
|
492 |
- return '\n\n'.join(str(x) for x in text) |
|
493 |
- return str(text) |
|
494 |
- |
|
495 |
- # This method is based on click 8.1; see the comment above the class |
|
496 |
- # declaration for license details. |
|
497 |
- def collect_usage_pieces(self, ctx: click.Context) -> list[str]: |
|
498 |
- """Return the pieces for the usage string. |
|
499 |
- |
|
500 |
- Args: |
|
501 |
- ctx: |
|
502 |
- The click context. |
|
503 |
- |
|
504 |
- """ |
|
505 |
- rv = [str(self.options_metavar)] if self.options_metavar else [] |
|
506 |
- for param in self.get_params(ctx): |
|
507 |
- rv.extend(str(x) for x in param.get_usage_pieces(ctx)) |
|
508 |
- return rv |
|
509 |
- |
|
510 |
- # This method is based on click 8.1; see the comment above the class |
|
511 |
- # declaration for license details. |
|
512 |
- def get_help_option( |
|
513 |
- self, |
|
514 |
- ctx: click.Context, |
|
515 |
- ) -> click.Option | None: |
|
516 |
- """Return a standard help option object. |
|
517 |
- |
|
518 |
- Args: |
|
519 |
- ctx: |
|
520 |
- The click context. |
|
521 |
- |
|
522 |
- """ |
|
523 |
- help_options = self.get_help_option_names(ctx) |
|
524 |
- |
|
525 |
- if not help_options or not self.add_help_option: # pragma: no cover |
|
526 |
- return None |
|
527 |
- |
|
528 |
- def show_help( |
|
529 |
- ctx: click.Context, |
|
530 |
- param: click.Parameter, # noqa: ARG001 |
|
531 |
- value: str, |
|
532 |
- ) -> None: |
|
533 |
- if value and not ctx.resilient_parsing: |
|
534 |
- click.echo(ctx.get_help(), color=ctx.color) |
|
535 |
- ctx.exit() |
|
536 |
- |
|
537 |
- # Modified from click 8.1: We use StandardOption and a non-str |
|
538 |
- # object as the help string. |
|
539 |
- return StandardOption( |
|
540 |
- help_options, |
|
541 |
- is_flag=True, |
|
542 |
- is_eager=True, |
|
543 |
- expose_value=False, |
|
544 |
- callback=show_help, |
|
545 |
- help=_msg.TranslatedString(_msg.Label.HELP_OPTION_HELP_TEXT), |
|
546 |
- ) |
|
547 |
- |
|
548 |
- # This method is based on click 8.1; see the comment above the class |
|
549 |
- # declaration for license details. |
|
550 |
- def get_short_help_str( |
|
551 |
- self, |
|
552 |
- limit: int = 45, |
|
553 |
- ) -> str: |
|
554 |
- """Return the short help string for a command. |
|
555 |
- |
|
556 |
- If only a long help string is given, shorten it. |
|
557 |
- |
|
558 |
- Args: |
|
559 |
- limit: |
|
560 |
- The maximum width of the short help string. |
|
561 |
- |
|
562 |
- """ |
|
563 |
- # Modification against click 8.1: Call `_text()` on `self.help` |
|
564 |
- # to allow help texts to be general objects, not just strings. |
|
565 |
- # Used to implement translatable strings, as objects that |
|
566 |
- # stringify to the translation. |
|
567 |
- if self.short_help: # pragma: no cover |
|
568 |
- text = inspect.cleandoc(self._text(self.short_help)) |
|
569 |
- elif self.help: |
|
570 |
- text = click.utils.make_default_short_help( |
|
571 |
- self._text(self.help), limit |
|
572 |
- ) |
|
573 |
- else: # pragma: no cover |
|
574 |
- text = '' |
|
575 |
- if self.deprecated: # pragma: no cover |
|
576 |
- # Modification against click 8.1: The translated string is |
|
577 |
- # looked up in the derivepassphrase message domain, not the |
|
578 |
- # gettext default domain. |
|
579 |
- text = str( |
|
580 |
- _msg.TranslatedString(_msg.Label.DEPRECATED_COMMAND_LABEL) |
|
581 |
- ).format(text=text) |
|
582 |
- return text.strip() |
|
583 |
- |
|
584 |
- # This method is based on click 8.1; see the comment above the class |
|
585 |
- # declaration for license details. |
|
586 |
- def format_help_text( |
|
587 |
- self, |
|
588 |
- ctx: click.Context, |
|
589 |
- formatter: click.HelpFormatter, |
|
590 |
- ) -> None: |
|
591 |
- """Format the help text prologue, if any. |
|
592 |
- |
|
593 |
- Args: |
|
594 |
- ctx: |
|
595 |
- The click context. |
|
596 |
- formatter: |
|
597 |
- The formatter for the `--help` listing. |
|
598 |
- |
|
599 |
- """ |
|
600 |
- del ctx |
|
601 |
- # Modification against click 8.1: Call `_text()` on `self.help` |
|
602 |
- # to allow help texts to be general objects, not just strings. |
|
603 |
- # Used to implement translatable strings, as objects that |
|
604 |
- # stringify to the translation. |
|
605 |
- text = ( |
|
606 |
- inspect.cleandoc(self._text(self.help).partition('\f')[0]) |
|
607 |
- if self.help is not None |
|
608 |
- else '' |
|
609 |
- ) |
|
610 |
- if self.deprecated: # pragma: no cover |
|
611 |
- # Modification against click 8.1: The translated string is |
|
612 |
- # looked up in the derivepassphrase message domain, not the |
|
613 |
- # gettext default domain. |
|
614 |
- text = str( |
|
615 |
- _msg.TranslatedString(_msg.Label.DEPRECATED_COMMAND_LABEL) |
|
616 |
- ).format(text=text) |
|
617 |
- if text: # pragma: no branch |
|
618 |
- formatter.write_paragraph() |
|
619 |
- with formatter.indentation(): |
|
620 |
- formatter.write_text(text) |
|
621 |
- |
|
622 |
- # This method is based on click 8.1; see the comment above the class |
|
623 |
- # declaration for license details. Consider the whole section |
|
624 |
- # marked as modified; the code modifications are too numerous to |
|
625 |
- # mark individually. |
|
626 |
- def format_options( |
|
627 |
- self, |
|
628 |
- ctx: click.Context, |
|
629 |
- formatter: click.HelpFormatter, |
|
630 |
- ) -> None: |
|
631 |
- r"""Format options on the help listing, grouped into sections. |
|
632 |
- |
|
633 |
- This is a callback for [`click.Command.get_help`][] that |
|
634 |
- implements the `--help` listing, by calling appropriate methods |
|
635 |
- of the `formatter`. We list all options (like the base |
|
636 |
- implementation), but grouped into sections according to the |
|
637 |
- concrete [`click.Option`][] subclass being used. If the option |
|
638 |
- is an instance of some subclass of [`OptionGroupOption`][], then |
|
639 |
- the section heading and the epilog are taken from the |
|
640 |
- [`option_group_name`] [OptionGroupOption.option_group_name] and |
|
641 |
- [`epilog`] [OptionGroupOption.epilog] attributes; otherwise, the |
|
642 |
- section heading is "Options" (or "Other options" if there are |
|
643 |
- other option groups) and the epilog is empty. |
|
644 |
- |
|
645 |
- We unconditionally call [`format_commands`][], and rely on it to |
|
646 |
- act as a no-op if we aren't actually a [`click.MultiCommand`][]. |
|
647 |
- |
|
648 |
- Args: |
|
649 |
- ctx: |
|
650 |
- The click context. |
|
651 |
- formatter: |
|
652 |
- The formatter for the `--help` listing. |
|
653 |
- |
|
654 |
- """ |
|
655 |
- default_group_name = '' |
|
656 |
- help_records: dict[str, list[tuple[str, str]]] = {} |
|
657 |
- epilogs: dict[str, str] = {} |
|
658 |
- params = self.params[:] |
|
659 |
- if ( # pragma: no branch |
|
660 |
- (help_opt := self.get_help_option(ctx)) is not None |
|
661 |
- and help_opt not in params |
|
662 |
- ): |
|
663 |
- params.append(help_opt) |
|
664 |
- for param in params: |
|
665 |
- rec = param.get_help_record(ctx) |
|
666 |
- if rec is not None: |
|
667 |
- rec = (rec[0], self._text(rec[1])) |
|
668 |
- if isinstance(param, OptionGroupOption): |
|
669 |
- group_name = self._text(param.option_group_name) |
|
670 |
- epilogs.setdefault(group_name, self._text(param.epilog)) |
|
671 |
- else: # pragma: no cover |
|
672 |
- group_name = default_group_name |
|
673 |
- help_records.setdefault(group_name, []).append(rec) |
|
674 |
- if default_group_name in help_records: # pragma: no branch |
|
675 |
- default_group = help_records.pop(default_group_name) |
|
676 |
- default_group_label = ( |
|
677 |
- _msg.Label.OTHER_OPTIONS_LABEL |
|
678 |
- if len(default_group) > 1 |
|
679 |
- else _msg.Label.OPTIONS_LABEL |
|
680 |
- ) |
|
681 |
- default_group_name = self._text( |
|
682 |
- _msg.TranslatedString(default_group_label) |
|
683 |
- ) |
|
684 |
- help_records[default_group_name] = default_group |
|
685 |
- for group_name, records in help_records.items(): |
|
686 |
- with formatter.section(group_name): |
|
687 |
- formatter.write_dl(records) |
|
688 |
- epilog = inspect.cleandoc(epilogs.get(group_name, '')) |
|
689 |
- if epilog: |
|
690 |
- formatter.write_paragraph() |
|
691 |
- with formatter.indentation(): |
|
692 |
- formatter.write_text(epilog) |
|
693 |
- self.format_commands(ctx, formatter) |
|
694 |
- |
|
695 |
- # This method is based on click 8.1; see the comment above the class |
|
696 |
- # declaration for license details. Consider the whole section |
|
697 |
- # marked as modified; the code modifications are too numerous to |
|
698 |
- # mark individually. |
|
699 |
- def format_commands( |
|
700 |
- self, |
|
701 |
- ctx: click.Context, |
|
702 |
- formatter: click.HelpFormatter, |
|
703 |
- ) -> None: |
|
704 |
- """Format the subcommands, if any. |
|
705 |
- |
|
706 |
- If called on a command object that isn't derived from |
|
707 |
- [`click.MultiCommand`][], then do nothing. |
|
708 |
- |
|
709 |
- Args: |
|
710 |
- ctx: |
|
711 |
- The click context. |
|
712 |
- formatter: |
|
713 |
- The formatter for the `--help` listing. |
|
714 |
- |
|
715 |
- """ |
|
716 |
- if not isinstance(self, click.MultiCommand): |
|
717 |
- return |
|
718 |
- commands: list[tuple[str, click.Command]] = [] |
|
719 |
- for subcommand in self.list_commands(ctx): |
|
720 |
- cmd = self.get_command(ctx, subcommand) |
|
721 |
- if cmd is None or cmd.hidden: # pragma: no cover |
|
722 |
- continue |
|
723 |
- commands.append((subcommand, cmd)) |
|
724 |
- if commands: # pragma: no branch |
|
725 |
- longest_command = max((cmd[0] for cmd in commands), key=len) |
|
726 |
- limit = formatter.width - 6 - len(longest_command) |
|
727 |
- rows: list[tuple[str, str]] = [] |
|
728 |
- for subcommand, cmd in commands: |
|
729 |
- help_str = self._text(cmd.get_short_help_str(limit) or '') |
|
730 |
- rows.append((subcommand, help_str)) |
|
731 |
- if rows: # pragma: no branch |
|
732 |
- commands_label = self._text( |
|
733 |
- _msg.TranslatedString(_msg.Label.COMMANDS_LABEL) |
|
734 |
- ) |
|
735 |
- with formatter.section(commands_label): |
|
736 |
- formatter.write_dl(rows) |
|
737 |
- |
|
738 |
- # This method is based on click 8.1; see the comment above the class |
|
739 |
- # declaration for license details. |
|
740 |
- def format_epilog( |
|
741 |
- self, |
|
742 |
- ctx: click.Context, |
|
743 |
- formatter: click.HelpFormatter, |
|
744 |
- ) -> None: |
|
745 |
- """Format the epilog, if any. |
|
746 |
- |
|
747 |
- Args: |
|
748 |
- ctx: |
|
749 |
- The click context. |
|
750 |
- formatter: |
|
751 |
- The formatter for the `--help` listing. |
|
752 |
- |
|
753 |
- """ |
|
754 |
- del ctx |
|
755 |
- if self.epilog: # pragma: no branch |
|
756 |
- # Modification against click 8.1: Call `str()` on |
|
757 |
- # `self.epilog` to allow help texts to be general objects, |
|
758 |
- # not just strings. Used to implement translatable strings, |
|
759 |
- # as objects that stringify to the translation. |
|
760 |
- epilog = inspect.cleandoc(self._text(self.epilog)) |
|
761 |
- formatter.write_paragraph() |
|
762 |
- with formatter.indentation(): |
|
763 |
- formatter.write_text(epilog) |
|
764 |
- |
|
765 |
- |
|
766 |
-def version_option_callback( |
|
767 |
- ctx: click.Context, |
|
768 |
- param: click.Parameter, |
|
769 |
- value: bool, # noqa: FBT001 |
|
770 |
-) -> None: |
|
771 |
- del param |
|
772 |
- if value and not ctx.resilient_parsing: |
|
773 |
- click.echo( |
|
774 |
- str( |
|
775 |
- _msg.TranslatedString( |
|
776 |
- _msg.Label.VERSION_INFO_TEXT, |
|
777 |
- PROG_NAME=PROG_NAME, |
|
778 |
- __version__=__version__, |
|
779 |
- ) |
|
780 |
- ), |
|
781 |
- ) |
|
782 |
- ctx.exit() |
|
783 |
- |
|
784 |
- |
|
785 |
-def version_option(f: Callable[P, R]) -> Callable[P, R]: |
|
786 |
- return click.option( |
|
787 |
- '--version', |
|
788 |
- is_flag=True, |
|
789 |
- is_eager=True, |
|
790 |
- expose_value=False, |
|
791 |
- callback=version_option_callback, |
|
792 |
- cls=StandardOption, |
|
793 |
- help=_msg.TranslatedString(_msg.Label.VERSION_OPTION_HELP_TEXT), |
|
794 |
- )(f) |
|
795 |
- |
|
796 |
- |
|
797 |
-def color_forcing_callback( |
|
798 |
- ctx: click.Context, |
|
799 |
- param: click.Parameter, |
|
800 |
- value: Any, # noqa: ANN401 |
|
801 |
-) -> None: |
|
802 |
- """Force the `click` context to honor `NO_COLOR` and `FORCE_COLOR`.""" |
|
803 |
- del param, value |
|
804 |
- if os.environ.get('NO_COLOR'): |
|
805 |
- ctx.color = False |
|
806 |
- if os.environ.get('FORCE_COLOR'): |
|
807 |
- ctx.color = True |
|
808 |
- |
|
809 |
- |
|
810 |
-color_forcing_pseudo_option = click.option( |
|
811 |
- '--_pseudo-option-color-forcing', |
|
812 |
- '_color_forcing', |
|
813 |
- is_flag=True, |
|
814 |
- is_eager=True, |
|
815 |
- expose_value=False, |
|
816 |
- hidden=True, |
|
817 |
- callback=color_forcing_callback, |
|
818 |
- help='(pseudo-option)', |
|
819 |
-) |
|
820 |
- |
|
821 |
- |
|
822 |
-class LoggingOption(OptionGroupOption): |
|
823 |
- """Logging options for the CLI.""" |
|
824 |
- |
|
825 |
- option_group_name = _msg.TranslatedString(_msg.Label.LOGGING_LABEL) |
|
826 |
- epilog = '' |
|
827 |
- |
|
828 |
- |
|
829 |
-debug_option = click.option( |
|
830 |
- '--debug', |
|
831 |
- 'logging_level', |
|
832 |
- is_flag=True, |
|
833 |
- flag_value=logging.DEBUG, |
|
834 |
- expose_value=False, |
|
835 |
- callback=adjust_logging_level, |
|
836 |
- help=_msg.TranslatedString(_msg.Label.DEBUG_OPTION_HELP_TEXT), |
|
837 |
- cls=LoggingOption, |
|
838 |
-) |
|
839 |
-verbose_option = click.option( |
|
840 |
- '-v', |
|
841 |
- '--verbose', |
|
842 |
- 'logging_level', |
|
843 |
- is_flag=True, |
|
844 |
- flag_value=logging.INFO, |
|
845 |
- expose_value=False, |
|
846 |
- callback=adjust_logging_level, |
|
847 |
- help=_msg.TranslatedString(_msg.Label.VERBOSE_OPTION_HELP_TEXT), |
|
848 |
- cls=LoggingOption, |
|
849 |
-) |
|
850 |
-quiet_option = click.option( |
|
851 |
- '-q', |
|
852 |
- '--quiet', |
|
853 |
- 'logging_level', |
|
854 |
- is_flag=True, |
|
855 |
- flag_value=logging.ERROR, |
|
856 |
- expose_value=False, |
|
857 |
- callback=adjust_logging_level, |
|
858 |
- help=_msg.TranslatedString(_msg.Label.QUIET_OPTION_HELP_TEXT), |
|
859 |
- cls=LoggingOption, |
|
860 |
-) |
|
861 |
- |
|
862 |
- |
|
863 |
-def standard_logging_options(f: Callable[P, R]) -> Callable[P, R]: |
|
864 |
- """Decorate the function with standard logging click options. |
|
865 |
- |
|
866 |
- Adds the three click options `-v`/`--verbose`, `-q`/`--quiet` and |
|
867 |
- `--debug`, which calls back into the [`adjust_logging_level`][] |
|
868 |
- function (with different argument values). |
|
869 |
- |
|
870 |
- Args: |
|
871 |
- f: A callable to decorate. |
|
872 |
- |
|
873 |
- Returns: |
|
874 |
- The decorated callable. |
|
875 |
- |
|
876 |
- """ |
|
877 |
- return debug_option(verbose_option(quiet_option(f))) |
|
878 |
- |
|
879 |
- |
|
880 |
-# Shell completion |
|
881 |
-# ================ |
|
882 |
- |
|
883 |
-# Use naive filename completion for the `path` argument of |
|
884 |
-# `derivepassphrase vault`'s `--import` and `--export` options, as well |
|
885 |
-# as the `path` argument of `derivepassphrase export vault`. The latter |
|
886 |
-# treats the pseudo-filename `VAULT_PATH` specially, but this is awkward |
|
887 |
-# to combine with standard filename completion, particularly in bash, so |
|
888 |
-# we would probably have to implement *all* completion (`VAULT_PATH` and |
|
889 |
-# filename completion) ourselves, lacking some niceties of bash's |
|
890 |
-# built-in completion (e.g., adding spaces or slashes depending on |
|
891 |
-# whether the completion is a directory or a complete filename). |
|
892 |
- |
|
893 |
- |
|
894 |
-def _shell_complete_path( |
|
895 |
- ctx: click.Context, |
|
896 |
- parameter: click.Parameter, |
|
897 |
- value: str, |
|
898 |
-) -> list[str | click.shell_completion.CompletionItem]: |
|
899 |
- """Request standard path completion for the `path` argument.""" # noqa: DOC201 |
|
900 |
- del ctx, parameter, value |
|
901 |
- return [click.shell_completion.CompletionItem('', type='file')] |
|
902 |
- |
|
903 |
- |
|
904 |
-# The standard `click` shell completion scripts serialize the completion |
|
905 |
-# items as newline-separated one-line entries, which get silently |
|
906 |
-# corrupted if the value contains newlines. Each shell imposes |
|
907 |
-# additional restrictions: Fish uses newlines in all internal completion |
|
908 |
-# helper scripts, so it is difficult, if not impossible, to register |
|
909 |
-# completion entries containing newlines if completion comes from within |
|
910 |
-# a Fish completion function (instead of a Fish builtin). Zsh's |
|
911 |
-# completion system supports descriptions for each completion item, and |
|
912 |
-# the completion helper functions parse every entry as a colon-separated |
|
913 |
-# 2-tuple of item and description, meaning any colon in the item value |
|
914 |
-# must be escaped. Finally, Bash requires the result array to be |
|
915 |
-# populated at the completion function's top-level scope, but for/while |
|
916 |
-# loops within pipelines do not run at top-level scope, and Bash *also* |
|
917 |
-# strips NUL characters from command substitution output, making it |
|
918 |
-# difficult to read in external data into an array in a cross-platform |
|
919 |
-# manner from entirely within Bash. |
|
920 |
-# |
|
921 |
-# We capitulate in front of these problems---most egregiously because of |
|
922 |
-# Fish---and ensure that completion items (in this case: service names) |
|
923 |
-# never contain ASCII control characters by refusing to offer such |
|
924 |
-# items as valid completions. On the other side, `derivepassphrase` |
|
925 |
-# will warn the user when configuring or importing a service with such |
|
926 |
-# a name that it will not be available for shell completion. |
|
927 |
- |
|
928 |
- |
|
929 |
-def _is_completable_item(obj: object) -> bool: |
|
930 |
- """Return whether the item is completable on the command-line. |
|
931 |
- |
|
932 |
- The item is completable if and only if it contains no ASCII control |
|
933 |
- characters (U+0000 through U+001F, and U+007F). |
|
934 |
- |
|
935 |
- """ |
|
936 |
- obj = str(obj) |
|
937 |
- forbidden = frozenset(chr(i) for i in range(32)) | {'\x7f'} |
|
938 |
- return not any(f in obj for f in forbidden) |
|
939 |
- |
|
940 |
- |
|
941 |
-def _shell_complete_service( |
|
942 |
- ctx: click.Context, |
|
943 |
- parameter: click.Parameter, |
|
944 |
- value: str, |
|
945 |
-) -> list[str | click.shell_completion.CompletionItem]: |
|
946 |
- """Return known vault service names as completion items. |
|
947 |
- |
|
948 |
- Service names are looked up in the vault configuration file. All |
|
949 |
- errors will be suppressed. Additionally, any service names deemed |
|
950 |
- not completable as per [`_is_completable_item`][] will be silently |
|
951 |
- skipped. |
|
952 |
- |
|
953 |
- """ |
|
954 |
- del ctx, parameter |
|
955 |
- try: |
|
956 |
- config = _load_config() |
|
957 |
- return sorted( |
|
958 |
- sv |
|
959 |
- for sv in config['services'] |
|
960 |
- if sv.startswith(value) and _is_completable_item(sv) |
|
961 |
- ) |
|
962 |
- except FileNotFoundError: |
|
963 |
- try: |
|
964 |
- config, _exc = _migrate_and_load_old_config() |
|
965 |
- return sorted( |
|
966 |
- sv |
|
967 |
- for sv in config['services'] |
|
968 |
- if sv.startswith(value) and _is_completable_item(sv) |
|
969 |
- ) |
|
970 |
- except FileNotFoundError: |
|
971 |
- return [] |
|
972 |
- except Exception: # noqa: BLE001 |
|
973 |
- return [] |
|
974 |
- |
|
975 |
- |
|
976 |
-class ZshComplete(click.shell_completion.ZshComplete): |
|
977 |
- """Zsh completion class that supports colons. |
|
978 |
- |
|
979 |
- `click`'s Zsh completion class (at least v8.1.7 and v8.1.8) uses |
|
980 |
- some completion helper functions (provided by Zsh) that parse each |
|
981 |
- completion item into value-description pairs, separated by a colon. |
|
982 |
- Other completion helper functions don't. Correspondingly, any |
|
983 |
- internal colons in the completion item's value sometimes need to be |
|
984 |
- escaped, and sometimes don't. |
|
985 |
- |
|
986 |
- The "right" way to fix this is to modify the Zsh completion script |
|
987 |
- to only use one type of serialization: either escaped, or unescaped. |
|
988 |
- However, the Zsh completion script itself may already be installed |
|
989 |
- in the user's Zsh settings, and we have no way of knowing that. |
|
990 |
- Therefore, it is better to change the `format_completion` method to |
|
991 |
- adaptively and "smartly" emit colon-escaped output or not, based on |
|
992 |
- whether the completion script will be using it. |
|
993 |
- |
|
994 |
- """ |
|
995 |
- |
|
996 |
- @override |
|
997 |
- def format_completion( |
|
998 |
- self, |
|
999 |
- item: click.shell_completion.CompletionItem, |
|
1000 |
- ) -> str: |
|
1001 |
- """Return a suitable serialization of the CompletionItem. |
|
1002 |
- |
|
1003 |
- This serialization ensures colons in the item value are properly |
|
1004 |
- escaped if and only if the completion script will attempt to |
|
1005 |
- pass a colon-separated key/description pair to the underlying |
|
1006 |
- Zsh machinery. This is the case if and only if the help text is |
|
1007 |
- non-degenerate. |
|
1008 |
- |
|
1009 |
- """ |
|
1010 |
- help_ = item.help or '_' |
|
1011 |
- value = item.value.replace(':', r'\:' if help_ != '_' else ':') |
|
1012 |
- return f'{item.type}\n{value}\n{help_}' |
|
1013 |
- |
|
1014 |
- |
|
1015 |
-# Our ZshComplete class depends crucially on the exact shape of the Zsh |
|
1016 |
-# completion script. So only fix the completion formatter if the |
|
1017 |
-# completion script is still the same. |
|
1018 |
-# |
|
1019 |
-# (This Zsh script is part of click, and available under the |
|
1020 |
-# 3-clause-BSD license.) |
|
1021 |
-_ORIG_SOURCE_TEMPLATE = """\ |
|
1022 |
-#compdef %(prog_name)s |
|
1023 |
- |
|
1024 |
-%(complete_func)s() { |
|
1025 |
- local -a completions |
|
1026 |
- local -a completions_with_descriptions |
|
1027 |
- local -a response |
|
1028 |
- (( ! $+commands[%(prog_name)s] )) && return 1 |
|
1029 |
- |
|
1030 |
- response=("${(@f)$(env COMP_WORDS="${words[*]}" COMP_CWORD=$((CURRENT-1)) \ |
|
1031 |
-%(complete_var)s=zsh_complete %(prog_name)s)}") |
|
1032 |
- |
|
1033 |
- for type key descr in ${response}; do |
|
1034 |
- if [[ "$type" == "plain" ]]; then |
|
1035 |
- if [[ "$descr" == "_" ]]; then |
|
1036 |
- completions+=("$key") |
|
1037 |
- else |
|
1038 |
- completions_with_descriptions+=("$key":"$descr") |
|
1039 |
- fi |
|
1040 |
- elif [[ "$type" == "dir" ]]; then |
|
1041 |
- _path_files -/ |
|
1042 |
- elif [[ "$type" == "file" ]]; then |
|
1043 |
- _path_files -f |
|
1044 |
- fi |
|
1045 |
- done |
|
1046 |
- |
|
1047 |
- if [ -n "$completions_with_descriptions" ]; then |
|
1048 |
- _describe -V unsorted completions_with_descriptions -U |
|
1049 |
- fi |
|
1050 |
- |
|
1051 |
- if [ -n "$completions" ]; then |
|
1052 |
- compadd -U -V unsorted -a completions |
|
1053 |
- fi |
|
1054 |
-} |
|
1055 |
- |
|
1056 |
-if [[ $zsh_eval_context[-1] == loadautofunc ]]; then |
|
1057 |
- # autoload from fpath, call function directly |
|
1058 |
- %(complete_func)s "$@" |
|
1059 |
-else |
|
1060 |
- # eval/source/. command, register function for later |
|
1061 |
- compdef %(complete_func)s %(prog_name)s |
|
1062 |
-fi |
|
1063 |
-""" |
|
1064 |
-if ( |
|
1065 |
- click.shell_completion.ZshComplete.source_template == _ORIG_SOURCE_TEMPLATE |
|
1066 |
-): # pragma: no cover |
|
1067 |
- click.shell_completion.add_completion_class(ZshComplete) |
|
1068 |
- |
|
1069 |
- |
|
1070 |
-# Top-level |
|
1071 |
-# ========= |
|
1072 |
- |
|
1073 |
- |
|
1074 |
-# Portions of this class are based directly on code from click 8.1. |
|
1075 |
-# (This does not in general include docstrings, unless otherwise noted.) |
|
1076 |
-# They are subject to the 3-clause BSD license in the following |
|
1077 |
-# paragraphs. Modifications to their code are marked with respective |
|
1078 |
-# comments; they too are released under the same license below. The |
|
1079 |
-# original code did not contain any "noqa" or "pragma" comments. |
|
1080 |
-# |
|
1081 |
-# Copyright 2024 Pallets |
|
1082 |
-# |
|
1083 |
-# Redistribution and use in source and binary forms, with or |
|
1084 |
-# without modification, are permitted provided that the |
|
1085 |
-# following conditions are met: |
|
1086 |
-# |
|
1087 |
-# 1. Redistributions of source code must retain the above |
|
1088 |
-# copyright notice, this list of conditions and the |
|
1089 |
-# following disclaimer. |
|
1090 |
-# |
|
1091 |
-# 2. Redistributions in binary form must reproduce the above |
|
1092 |
-# copyright notice, this list of conditions and the |
|
1093 |
-# following disclaimer in the documentation and/or other |
|
1094 |
-# materials provided with the distribution. |
|
1095 |
-# |
|
1096 |
-# 3. Neither the name of the copyright holder nor the names |
|
1097 |
-# of its contributors may be used to endorse or promote |
|
1098 |
-# products derived from this software without specific |
|
1099 |
-# prior written permission. |
|
1100 |
-# |
|
1101 |
-# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND |
|
1102 |
-# CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, |
|
1103 |
-# INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF |
|
1104 |
-# MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE |
|
1105 |
-# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR |
|
1106 |
-# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, |
|
1107 |
-# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT |
|
1108 |
-# NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; |
|
1109 |
-# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) |
|
1110 |
-# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN |
|
1111 |
-# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR |
|
1112 |
-# OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS |
|
1113 |
-# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. |
|
1114 |
-# |
|
1115 |
-# TODO(the-13th-letter): Remove this class and license block in v1.0. |
|
1116 |
-# https://the13thletter.info/derivepassphrase/latest/upgrade-notes/#v1.0-implied-subcommands |
|
1117 |
-class _DefaultToVaultGroup(CommandWithHelpGroups, click.Group): |
|
1118 |
- """A helper class to implement the default-to-"vault"-subcommand behavior. |
|
1119 |
- |
|
1120 |
- Modifies internal [`click.MultiCommand`][] methods, and thus is both |
|
1121 |
- an implementation detail and a kludge. |
|
1122 |
- |
|
1123 |
- """ |
|
1124 |
- |
|
1125 |
- def resolve_command( |
|
1126 |
- self, ctx: click.Context, args: list[str] |
|
1127 |
- ) -> tuple[str | None, click.Command | None, list[str]]: |
|
1128 |
- """Resolve a command, defaulting to "vault" instead of erroring out.""" # noqa: DOC201 |
|
1129 |
- cmd_name = click.utils.make_str(args[0]) |
|
1130 |
- |
|
1131 |
- # Get the command |
|
1132 |
- cmd = self.get_command(ctx, cmd_name) |
|
1133 |
- |
|
1134 |
- # If we can't find the command but there is a normalization |
|
1135 |
- # function available, we try with that one. |
|
1136 |
- if ( # pragma: no cover |
|
1137 |
- cmd is None and ctx.token_normalize_func is not None |
|
1138 |
- ): |
|
1139 |
- cmd_name = ctx.token_normalize_func(cmd_name) |
|
1140 |
- cmd = self.get_command(ctx, cmd_name) |
|
1141 |
- |
|
1142 |
- # If we don't find the command we want to show an error message |
|
1143 |
- # to the user that it was not provided. However, there is |
|
1144 |
- # something else we should do: if the first argument looks like |
|
1145 |
- # an option we want to kick off parsing again for arguments to |
|
1146 |
- # resolve things like --help which now should go to the main |
|
1147 |
- # place. |
|
1148 |
- if cmd is None and not ctx.resilient_parsing: |
|
1149 |
- if click.parser.split_opt(cmd_name)[0]: |
|
1150 |
- self.parse_args(ctx, ctx.args) |
|
1151 |
- #### |
|
1152 |
- # BEGIN modifications for derivepassphrase |
|
1153 |
- # |
|
1154 |
- # Instead of calling ctx.fail here, default to "vault", and |
|
1155 |
- # issue a deprecation warning. |
|
1156 |
- deprecation = logging.getLogger(f'{PROG_NAME}.deprecation') |
|
1157 |
- deprecation.warning( |
|
1158 |
- _msg.TranslatedString( |
|
1159 |
- _msg.WarnMsgTemplate.V10_SUBCOMMAND_REQUIRED |
|
1160 |
- ) |
|
1161 |
- ) |
|
1162 |
- cmd_name = 'vault' |
|
1163 |
- cmd = self.get_command(ctx, cmd_name) |
|
1164 |
- assert cmd is not None, 'Mandatory subcommand "vault" missing!' |
|
1165 |
- args = [cmd_name, *args] |
|
1166 |
- # |
|
1167 |
- # END modifications for derivepassphrase |
|
1168 |
- #### |
|
1169 |
- return cmd_name if cmd else None, cmd, args[1:] |
|
1170 |
- |
|
1171 |
- |
|
1172 |
-# TODO(the-13th-letter): Base this class on CommandWithHelpGroups and |
|
1173 |
-# click.Group in v1.0. |
|
1174 |
-# https://the13thletter.info/derivepassphrase/latest/upgrade-notes/#v1.0-implied-subcommands |
|
1175 |
-class _TopLevelCLIEntryPoint(_DefaultToVaultGroup): |
|
1176 |
- """A minor variation of _DefaultToVaultGroup for the top-level command. |
|
1177 |
- |
|
1178 |
- When called as a function, this sets up the environment properly |
|
1179 |
- before invoking the actual callbacks. Currently, this means setting |
|
1180 |
- up the logging subsystem and the delegation of Python warnings to |
|
1181 |
- the logging subsystem. |
|
1182 |
- |
|
1183 |
- The environment setup can be bypassed by calling the `.main` method |
|
1184 |
- directly. |
|
1185 |
- |
|
1186 |
- """ |
|
1187 |
- |
|
1188 |
- def __call__( # pragma: no cover |
|
1189 |
- self, |
|
1190 |
- *args: Any, # noqa: ANN401 |
|
1191 |
- **kwargs: Any, # noqa: ANN401 |
|
1192 |
- ) -> Any: # noqa: ANN401 |
|
1193 |
- """""" # noqa: D419 |
|
1194 |
- # Coverage testing is done with the `click.testing` module, |
|
1195 |
- # which does not use the `__call__` shortcut. So it is normal |
|
1196 |
- # that this function is never called, and thus should be |
|
1197 |
- # excluded from coverage. |
|
1198 |
- with ( |
|
1199 |
- StandardCLILogging.ensure_standard_logging(), |
|
1200 |
- StandardCLILogging.ensure_standard_warnings_logging(), |
|
1201 |
- ): |
|
1202 |
- return self.main(*args, **kwargs) |
|
1203 | 47 |
|
1204 | 48 |
|
1205 | 49 |
@click.group( |
... | ... |
@@ -1210,16 +54,16 @@ class _TopLevelCLIEntryPoint(_DefaultToVaultGroup): |
1210 | 54 |
}, |
1211 | 55 |
epilog=_msg.TranslatedString(_msg.Label.DERIVEPASSPHRASE_EPILOG_01), |
1212 | 56 |
invoke_without_command=True, |
1213 |
- cls=_TopLevelCLIEntryPoint, |
|
57 |
+ cls=cli_machinery.TopLevelCLIEntryPoint, |
|
1214 | 58 |
help=( |
1215 | 59 |
_msg.TranslatedString(_msg.Label.DERIVEPASSPHRASE_01), |
1216 | 60 |
_msg.TranslatedString(_msg.Label.DERIVEPASSPHRASE_02), |
1217 | 61 |
_msg.TranslatedString(_msg.Label.DERIVEPASSPHRASE_03), |
1218 | 62 |
), |
1219 | 63 |
) |
1220 |
-@version_option |
|
1221 |
-@color_forcing_pseudo_option |
|
1222 |
-@standard_logging_options |
|
64 |
+@cli_machinery.version_option |
|
65 |
+@cli_machinery.color_forcing_pseudo_option |
|
66 |
+@cli_machinery.standard_logging_options |
|
1223 | 67 |
@click.pass_context |
1224 | 68 |
def derivepassphrase(ctx: click.Context, /) -> None: |
1225 | 69 |
"""Derive a strong passphrase, deterministically, from a master secret. |
... | ... |
@@ -1265,16 +109,16 @@ def derivepassphrase(ctx: click.Context, /) -> None: |
1265 | 109 |
'allow_interspersed_args': False, |
1266 | 110 |
}, |
1267 | 111 |
invoke_without_command=True, |
1268 |
- cls=_DefaultToVaultGroup, |
|
112 |
+ cls=cli_machinery.DefaultToVaultGroup, |
|
1269 | 113 |
help=( |
1270 | 114 |
_msg.TranslatedString(_msg.Label.DERIVEPASSPHRASE_EXPORT_01), |
1271 | 115 |
_msg.TranslatedString(_msg.Label.DERIVEPASSPHRASE_EXPORT_02), |
1272 | 116 |
_msg.TranslatedString(_msg.Label.DERIVEPASSPHRASE_EXPORT_03), |
1273 | 117 |
), |
1274 | 118 |
) |
1275 |
-@version_option |
|
1276 |
-@color_forcing_pseudo_option |
|
1277 |
-@standard_logging_options |
|
119 |
+@cli_machinery.version_option |
|
120 |
+@cli_machinery.color_forcing_pseudo_option |
|
121 |
+@cli_machinery.standard_logging_options |
|
1278 | 122 |
@click.pass_context |
1279 | 123 |
def derivepassphrase_export(ctx: click.Context, /) -> None: |
1280 | 124 |
"""Export a foreign configuration to standard output. |
... | ... |
@@ -1314,7 +158,7 @@ def derivepassphrase_export(ctx: click.Context, /) -> None: |
1314 | 158 |
@derivepassphrase_export.command( |
1315 | 159 |
'vault', |
1316 | 160 |
context_settings={'help_option_names': ['-h', '--help']}, |
1317 |
- cls=CommandWithHelpGroups, |
|
161 |
+ cls=cli_machinery.CommandWithHelpGroups, |
|
1318 | 162 |
help=( |
1319 | 163 |
_msg.TranslatedString(_msg.Label.DERIVEPASSPHRASE_EXPORT_VAULT_01), |
1320 | 164 |
_msg.TranslatedString( |
... | ... |
@@ -1348,7 +192,7 @@ def derivepassphrase_export(ctx: click.Context, /) -> None: |
1348 | 192 |
_msg.Label.EXPORT_VAULT_FORMAT_METAVAR_FMT, |
1349 | 193 |
), |
1350 | 194 |
), |
1351 |
- cls=StandardOption, |
|
195 |
+ cls=cli_machinery.StandardOption, |
|
1352 | 196 |
) |
1353 | 197 |
@click.option( |
1354 | 198 |
'-k', |
... | ... |
@@ -1361,16 +205,16 @@ def derivepassphrase_export(ctx: click.Context, /) -> None: |
1361 | 205 |
_msg.Label.EXPORT_VAULT_KEY_DEFAULTS_HELP_TEXT, |
1362 | 206 |
), |
1363 | 207 |
), |
1364 |
- cls=StandardOption, |
|
208 |
+ cls=cli_machinery.StandardOption, |
|
1365 | 209 |
) |
1366 |
-@version_option |
|
1367 |
-@color_forcing_pseudo_option |
|
1368 |
-@standard_logging_options |
|
210 |
+@cli_machinery.version_option |
|
211 |
+@cli_machinery.color_forcing_pseudo_option |
|
212 |
+@cli_machinery.standard_logging_options |
|
1369 | 213 |
@click.argument( |
1370 | 214 |
'path', |
1371 | 215 |
metavar=_msg.TranslatedString(_msg.Label.EXPORT_VAULT_METAVAR_PATH), |
1372 | 216 |
required=True, |
1373 |
- shell_complete=_shell_complete_path, |
|
217 |
+ shell_complete=cli_helpers.shell_complete_path, |
|
1374 | 218 |
) |
1375 | 219 |
@click.pass_context |
1376 | 220 |
def derivepassphrase_export_vault( |
... | ... |
@@ -1468,712 +312,10 @@ def derivepassphrase_export_vault( |
1468 | 312 |
ctx.exit(1) |
1469 | 313 |
|
1470 | 314 |
|
1471 |
-# Vault |
|
1472 |
-# ===== |
|
1473 |
- |
|
1474 |
-_config_filename_table = { |
|
1475 |
- None: '.', |
|
1476 |
- 'vault': 'vault.json', |
|
1477 |
- 'user configuration': 'config.toml', |
|
1478 |
- # TODO(the-13th-letter): Remove the old settings.json file. |
|
1479 |
- # https://the13thletter.info/derivepassphrase/latest/upgrade-notes.html#v1.0-old-settings-file |
|
1480 |
- 'old settings.json': 'settings.json', |
|
1481 |
-} |
|
1482 |
- |
|
1483 |
- |
|
1484 |
-def _config_filename( |
|
1485 |
- subsystem: str | None = 'old settings.json', |
|
1486 |
-) -> pathlib.Path: |
|
1487 |
- """Return the filename of the configuration file for the subsystem. |
|
1488 |
- |
|
1489 |
- The (implicit default) file is currently named `settings.json`, |
|
1490 |
- located within the configuration directory as determined by the |
|
1491 |
- `DERIVEPASSPHRASE_PATH` environment variable, or by |
|
1492 |
- [`click.get_app_dir`][] in POSIX mode. Depending on the requested |
|
1493 |
- subsystem, this will usually be a different file within that |
|
1494 |
- directory. |
|
1495 |
- |
|
1496 |
- Args: |
|
1497 |
- subsystem: |
|
1498 |
- Name of the configuration subsystem whose configuration |
|
1499 |
- filename to return. If not given, return the old filename |
|
1500 |
- from before the subcommand migration. If `None`, return the |
|
1501 |
- configuration directory instead. |
|
1502 |
- |
|
1503 |
- Raises: |
|
1504 |
- AssertionError: |
|
1505 |
- An unknown subsystem was passed. |
|
1506 |
- |
|
1507 |
- Deprecated: |
|
1508 |
- Since v0.2.0: The implicit default subsystem and the old |
|
1509 |
- configuration filename are deprecated, and will be removed in v1.0. |
|
1510 |
- The subsystem will be mandatory to specify. |
|
1511 |
- |
|
1512 |
- """ |
|
1513 |
- path = pathlib.Path( |
|
1514 |
- os.getenv(PROG_NAME.upper() + '_PATH') |
|
1515 |
- or click.get_app_dir(PROG_NAME, force_posix=True) |
|
1516 |
- ) |
|
1517 |
- try: |
|
1518 |
- filename = _config_filename_table[subsystem] |
|
1519 |
- except (KeyError, TypeError): # pragma: no cover |
|
1520 |
- msg = f'Unknown configuration subsystem: {subsystem!r}' |
|
1521 |
- raise AssertionError(msg) from None |
|
1522 |
- return path / filename |
|
1523 |
- |
|
1524 |
- |
|
1525 |
-def _load_config() -> _types.VaultConfig: |
|
1526 |
- """Load a vault(1)-compatible config from the application directory. |
|
1527 |
- |
|
1528 |
- The filename is obtained via [`_config_filename`][]. This must be |
|
1529 |
- an unencrypted JSON file. |
|
1530 |
- |
|
1531 |
- Returns: |
|
1532 |
- The vault settings. See [`_types.VaultConfig`][] for details. |
|
1533 |
- |
|
1534 |
- Raises: |
|
1535 |
- OSError: |
|
1536 |
- There was an OS error accessing the file. |
|
1537 |
- ValueError: |
|
1538 |
- The data loaded from the file is not a vault(1)-compatible |
|
1539 |
- config. |
|
1540 |
- |
|
1541 |
- """ |
|
1542 |
- filename = _config_filename(subsystem='vault') |
|
1543 |
- with filename.open('rb') as fileobj: |
|
1544 |
- data = json.load(fileobj) |
|
1545 |
- if not _types.is_vault_config(data): |
|
1546 |
- raise ValueError(_INVALID_VAULT_CONFIG) |
|
1547 |
- return data |
|
1548 |
- |
|
1549 |
- |
|
1550 |
-# TODO(the-13th-letter): Remove this function. |
|
1551 |
-# https://the13thletter.info/derivepassphrase/latest/upgrade-notes.html#v1.0-old-settings-file |
|
1552 |
-def _migrate_and_load_old_config() -> tuple[ |
|
1553 |
- _types.VaultConfig, OSError | None |
|
1554 |
-]: |
|
1555 |
- """Load and migrate a vault(1)-compatible config. |
|
1556 |
- |
|
1557 |
- The (old) filename is obtained via [`_config_filename`][]. This |
|
1558 |
- must be an unencrypted JSON file. After loading, the file is |
|
1559 |
- migrated to the new standard filename. |
|
1560 |
- |
|
1561 |
- Returns: |
|
1562 |
- The vault settings, and an optional exception encountered during |
|
1563 |
- migration. See [`_types.VaultConfig`][] for details on the |
|
1564 |
- former. |
|
1565 |
- |
|
1566 |
- Raises: |
|
1567 |
- OSError: |
|
1568 |
- There was an OS error accessing the old file. |
|
1569 |
- ValueError: |
|
1570 |
- The data loaded from the file is not a vault(1)-compatible |
|
1571 |
- config. |
|
1572 |
- |
|
1573 |
- """ |
|
1574 |
- new_filename = _config_filename(subsystem='vault') |
|
1575 |
- old_filename = _config_filename(subsystem='old settings.json') |
|
1576 |
- with old_filename.open('rb') as fileobj: |
|
1577 |
- data = json.load(fileobj) |
|
1578 |
- if not _types.is_vault_config(data): |
|
1579 |
- raise ValueError(_INVALID_VAULT_CONFIG) |
|
1580 |
- try: |
|
1581 |
- old_filename.rename(new_filename) |
|
1582 |
- except OSError as exc: |
|
1583 |
- return data, exc |
|
1584 |
- else: |
|
1585 |
- return data, None |
|
1586 |
- |
|
1587 |
- |
|
1588 |
-def _save_config(config: _types.VaultConfig, /) -> None: |
|
1589 |
- """Save a vault(1)-compatible config to the application directory. |
|
1590 |
- |
|
1591 |
- The filename is obtained via [`_config_filename`][]. The config |
|
1592 |
- will be stored as an unencrypted JSON file. |
|
1593 |
- |
|
1594 |
- Args: |
|
1595 |
- config: |
|
1596 |
- vault configuration to save. |
|
1597 |
- |
|
1598 |
- Raises: |
|
1599 |
- OSError: |
|
1600 |
- There was an OS error accessing or writing the file. |
|
1601 |
- ValueError: |
|
1602 |
- The data cannot be stored as a vault(1)-compatible config. |
|
1603 |
- |
|
1604 |
- """ |
|
1605 |
- if not _types.is_vault_config(config): |
|
1606 |
- raise ValueError(_INVALID_VAULT_CONFIG) |
|
1607 |
- filename = _config_filename(subsystem='vault') |
|
1608 |
- filedir = filename.resolve().parent |
|
1609 |
- filedir.mkdir(parents=True, exist_ok=True) |
|
1610 |
- with filename.open('w', encoding='UTF-8') as fileobj: |
|
1611 |
- json.dump(config, fileobj) |
|
1612 |
- |
|
1613 |
- |
|
1614 |
-def _load_user_config() -> dict[str, Any]: |
|
1615 |
- """Load the user config from the application directory. |
|
1616 |
- |
|
1617 |
- The filename is obtained via [`_config_filename`][]. |
|
1618 |
- |
|
1619 |
- Returns: |
|
1620 |
- The user configuration, as a nested `dict`. |
|
1621 |
- |
|
1622 |
- Raises: |
|
1623 |
- OSError: |
|
1624 |
- There was an OS error accessing the file. |
|
1625 |
- ValueError: |
|
1626 |
- The data loaded from the file is not a valid configuration |
|
1627 |
- file. |
|
1628 |
- |
|
1629 |
- """ |
|
1630 |
- filename = _config_filename(subsystem='user configuration') |
|
1631 |
- with filename.open('rb') as fileobj: |
|
1632 |
- return tomllib.load(fileobj) |
|
1633 |
- |
|
1634 |
- |
|
1635 |
-def _get_suitable_ssh_keys( |
|
1636 |
- conn: ssh_agent.SSHAgentClient | socket.socket | None = None, / |
|
1637 |
-) -> Iterator[_types.SSHKeyCommentPair]: |
|
1638 |
- """Yield all SSH keys suitable for passphrase derivation. |
|
1639 |
- |
|
1640 |
- Suitable SSH keys are queried from the running SSH agent (see |
|
1641 |
- [`ssh_agent.SSHAgentClient.list_keys`][]). |
|
1642 |
- |
|
1643 |
- Args: |
|
1644 |
- conn: |
|
1645 |
- An optional connection hint to the SSH agent. See |
|
1646 |
- [`ssh_agent.SSHAgentClient.ensure_agent_subcontext`][]. |
|
1647 |
- |
|
1648 |
- Yields: |
|
1649 |
- Every SSH key from the SSH agent that is suitable for passphrase |
|
1650 |
- derivation. |
|
1651 |
- |
|
1652 |
- Raises: |
|
1653 |
- KeyError: |
|
1654 |
- `conn` was `None`, and the `SSH_AUTH_SOCK` environment |
|
1655 |
- variable was not found. |
|
1656 |
- NotImplementedError: |
|
1657 |
- `conn` was `None`, and this Python does not support |
|
1658 |
- [`socket.AF_UNIX`][], so the SSH agent client cannot be |
|
1659 |
- automatically set up. |
|
1660 |
- OSError: |
|
1661 |
- `conn` was a socket or `None`, and there was an error |
|
1662 |
- setting up a socket connection to the agent. |
|
1663 |
- LookupError: |
|
1664 |
- No keys usable for passphrase derivation are loaded into the |
|
1665 |
- SSH agent. |
|
1666 |
- RuntimeError: |
|
1667 |
- There was an error communicating with the SSH agent. |
|
1668 |
- ssh_agent.SSHAgentFailedError: |
|
1669 |
- The agent failed to supply a list of loaded keys. |
|
1670 |
- |
|
1671 |
- """ |
|
1672 |
- with ssh_agent.SSHAgentClient.ensure_agent_subcontext(conn) as client: |
|
1673 |
- try: |
|
1674 |
- all_key_comment_pairs = list(client.list_keys()) |
|
1675 |
- except EOFError as exc: # pragma: no cover |
|
1676 |
- raise RuntimeError(_AGENT_COMMUNICATION_ERROR) from exc |
|
1677 |
- suitable_keys = copy.copy(all_key_comment_pairs) |
|
1678 |
- for pair in all_key_comment_pairs: |
|
1679 |
- key, _comment = pair |
|
1680 |
- if vault.Vault.is_suitable_ssh_key(key, client=client): |
|
1681 |
- yield pair |
|
1682 |
- if not suitable_keys: # pragma: no cover |
|
1683 |
- raise LookupError(_NO_SUITABLE_KEYS) |
|
1684 |
- |
|
1685 |
- |
|
1686 |
-def _prompt_for_selection( |
|
1687 |
- items: Sequence[str | bytes], |
|
1688 |
- heading: str = 'Possible choices:', |
|
1689 |
- single_choice_prompt: str = 'Confirm this choice?', |
|
1690 |
- ctx: click.Context | None = None, |
|
1691 |
-) -> int: |
|
1692 |
- """Prompt user for a choice among the given items. |
|
1693 |
- |
|
1694 |
- Print the heading, if any, then present the items to the user. If |
|
1695 |
- there are multiple items, prompt the user for a selection, validate |
|
1696 |
- the choice, then return the list index of the selected item. If |
|
1697 |
- there is only a single item, request confirmation for that item |
|
1698 |
- instead, and return the correct index. |
|
1699 |
- |
|
1700 |
- Args: |
|
1701 |
- items: |
|
1702 |
- The list of items to choose from. |
|
1703 |
- heading: |
|
1704 |
- A heading for the list of items, to print immediately |
|
1705 |
- before. Defaults to a reasonable standard heading. If |
|
1706 |
- explicitly empty, print no heading. |
|
1707 |
- single_choice_prompt: |
|
1708 |
- The confirmation prompt if there is only a single possible |
|
1709 |
- choice. Defaults to a reasonable standard prompt. |
|
1710 |
- ctx: |
|
1711 |
- An optional `click` context, from which output device |
|
1712 |
- properties and color preferences will be queried. |
|
1713 |
- |
|
1714 |
- Returns: |
|
1715 |
- An index into the items sequence, indicating the user's |
|
1716 |
- selection. |
|
1717 |
- |
|
1718 |
- Raises: |
|
1719 |
- IndexError: |
|
1720 |
- The user made an invalid or empty selection, or requested an |
|
1721 |
- abort. |
|
1722 |
- |
|
1723 |
- """ |
|
1724 |
- n = len(items) |
|
1725 |
- color = ctx.color if ctx is not None else None |
|
1726 |
- if heading: |
|
1727 |
- click.echo(click.style(heading, bold=True), color=color) |
|
1728 |
- for i, x in enumerate(items, start=1): |
|
1729 |
- click.echo(click.style(f'[{i}]', bold=True), nl=False, color=color) |
|
1730 |
- click.echo(' ', nl=False, color=color) |
|
1731 |
- click.echo(x, color=color) |
|
1732 |
- if n > 1: |
|
1733 |
- choices = click.Choice([''] + [str(i) for i in range(1, n + 1)]) |
|
1734 |
- choice = click.prompt( |
|
1735 |
- f'Your selection? (1-{n}, leave empty to abort)', |
|
1736 |
- err=True, |
|
1737 |
- type=choices, |
|
1738 |
- show_choices=False, |
|
1739 |
- show_default=False, |
|
1740 |
- default='', |
|
1741 |
- ) |
|
1742 |
- if not choice: |
|
1743 |
- raise IndexError(_EMPTY_SELECTION) |
|
1744 |
- return int(choice) - 1 |
|
1745 |
- prompt_suffix = ( |
|
1746 |
- ' ' if single_choice_prompt.endswith(tuple('?.!')) else ': ' |
|
1747 |
- ) |
|
1748 |
- try: |
|
1749 |
- click.confirm( |
|
1750 |
- single_choice_prompt, |
|
1751 |
- prompt_suffix=prompt_suffix, |
|
1752 |
- err=True, |
|
1753 |
- abort=True, |
|
1754 |
- default=False, |
|
1755 |
- show_default=False, |
|
1756 |
- ) |
|
1757 |
- except click.Abort: |
|
1758 |
- raise IndexError(_EMPTY_SELECTION) from None |
|
1759 |
- return 0 |
|
1760 |
- |
|
1761 |
- |
|
1762 |
-def _select_ssh_key( |
|
1763 |
- conn: ssh_agent.SSHAgentClient | socket.socket | None = None, |
|
1764 |
- /, |
|
1765 |
- *, |
|
1766 |
- ctx: click.Context | None = None, |
|
1767 |
-) -> bytes | bytearray: |
|
1768 |
- """Interactively select an SSH key for passphrase derivation. |
|
1769 |
- |
|
1770 |
- Suitable SSH keys are queried from the running SSH agent (see |
|
1771 |
- [`ssh_agent.SSHAgentClient.list_keys`][]), then the user is prompted |
|
1772 |
- interactively (see [`click.prompt`][]) for a selection. |
|
1773 |
- |
|
1774 |
- Args: |
|
1775 |
- conn: |
|
1776 |
- An optional connection hint to the SSH agent. See |
|
1777 |
- [`ssh_agent.SSHAgentClient.ensure_agent_subcontext`][]. |
|
1778 |
- ctx: |
|
1779 |
- An `click` context, queried for output device properties and |
|
1780 |
- color preferences when issuing the prompt. |
|
1781 |
- |
|
1782 |
- Returns: |
|
1783 |
- The selected SSH key. |
|
1784 |
- |
|
1785 |
- Raises: |
|
1786 |
- KeyError: |
|
1787 |
- `conn` was `None`, and the `SSH_AUTH_SOCK` environment |
|
1788 |
- variable was not found. |
|
1789 |
- NotImplementedError: |
|
1790 |
- `conn` was `None`, and this Python does not support |
|
1791 |
- [`socket.AF_UNIX`][], so the SSH agent client cannot be |
|
1792 |
- automatically set up. |
|
1793 |
- OSError: |
|
1794 |
- `conn` was a socket or `None`, and there was an error |
|
1795 |
- setting up a socket connection to the agent. |
|
1796 |
- IndexError: |
|
1797 |
- The user made an invalid or empty selection, or requested an |
|
1798 |
- abort. |
|
1799 |
- LookupError: |
|
1800 |
- No keys usable for passphrase derivation are loaded into the |
|
1801 |
- SSH agent. |
|
1802 |
- RuntimeError: |
|
1803 |
- There was an error communicating with the SSH agent. |
|
1804 |
- SSHAgentFailedError: |
|
1805 |
- The agent failed to supply a list of loaded keys. |
|
1806 |
- """ |
|
1807 |
- suitable_keys = list(_get_suitable_ssh_keys(conn)) |
|
1808 |
- key_listing: list[str] = [] |
|
1809 |
- unstring_prefix = ssh_agent.SSHAgentClient.unstring_prefix |
|
1810 |
- for key, comment in suitable_keys: |
|
1811 |
- keytype = unstring_prefix(key)[0].decode('ASCII') |
|
1812 |
- key_str = base64.standard_b64encode(key).decode('ASCII') |
|
1813 |
- remaining_key_display_length = KEY_DISPLAY_LENGTH - 1 - len(keytype) |
|
1814 |
- key_extract = min( |
|
1815 |
- key_str, |
|
1816 |
- '...' + key_str[-remaining_key_display_length:], |
|
1817 |
- key=len, |
|
1818 |
- ) |
|
1819 |
- comment_str = comment.decode('UTF-8', errors='replace') |
|
1820 |
- key_listing.append(f'{keytype} {key_extract} {comment_str}') |
|
1821 |
- choice = _prompt_for_selection( |
|
1822 |
- key_listing, |
|
1823 |
- heading='Suitable SSH keys:', |
|
1824 |
- single_choice_prompt='Use this key?', |
|
1825 |
- ctx=ctx, |
|
1826 |
- ) |
|
1827 |
- return suitable_keys[choice].key |
|
1828 |
- |
|
1829 |
- |
|
1830 |
-def _prompt_for_passphrase() -> str: |
|
1831 |
- """Interactively prompt for the passphrase. |
|
1832 |
- |
|
1833 |
- Calls [`click.prompt`][] internally. Moved into a separate function |
|
1834 |
- mainly for testing/mocking purposes. |
|
1835 |
- |
|
1836 |
- Returns: |
|
1837 |
- The user input. |
|
1838 |
- |
|
1839 |
- """ |
|
1840 |
- return cast( |
|
1841 |
- 'str', |
|
1842 |
- click.prompt( |
|
1843 |
- 'Passphrase', |
|
1844 |
- default='', |
|
1845 |
- hide_input=True, |
|
1846 |
- show_default=False, |
|
1847 |
- err=True, |
|
1848 |
- ), |
|
1849 |
- ) |
|
1850 |
- |
|
1851 |
- |
|
1852 |
-def _toml_key(*parts: str) -> str: |
|
1853 |
- """Return a formatted TOML key, given its parts.""" |
|
1854 |
- |
|
1855 |
- def escape(string: str) -> str: |
|
1856 |
- translated = string.translate({ |
|
1857 |
- 0: r'\u0000', |
|
1858 |
- 1: r'\u0001', |
|
1859 |
- 2: r'\u0002', |
|
1860 |
- 3: r'\u0003', |
|
1861 |
- 4: r'\u0004', |
|
1862 |
- 5: r'\u0005', |
|
1863 |
- 6: r'\u0006', |
|
1864 |
- 7: r'\u0007', |
|
1865 |
- 8: r'\b', |
|
1866 |
- 9: r'\t', |
|
1867 |
- 10: r'\n', |
|
1868 |
- 11: r'\u000B', |
|
1869 |
- 12: r'\f', |
|
1870 |
- 13: r'\r', |
|
1871 |
- 14: r'\u000E', |
|
1872 |
- 15: r'\u000F', |
|
1873 |
- ord('"'): r'\"', |
|
1874 |
- ord('\\'): r'\\', |
|
1875 |
- 127: r'\u007F', |
|
1876 |
- }) |
|
1877 |
- return f'"{translated}"' if translated != string else string |
|
1878 |
- |
|
1879 |
- return '.'.join(map(escape, parts)) |
|
1880 |
- |
|
1881 |
- |
|
1882 |
-class _ORIGIN(enum.Enum): |
|
1883 |
- INTERACTIVE: str = 'interactive input' |
|
1884 |
- |
|
1885 |
- |
|
1886 |
-def _check_for_misleading_passphrase( |
|
1887 |
- key: tuple[str, ...] | _ORIGIN, |
|
1888 |
- value: dict[str, Any], |
|
1889 |
- *, |
|
1890 |
- main_config: dict[str, Any], |
|
1891 |
- ctx: click.Context | None = None, |
|
1892 |
-) -> None: |
|
1893 |
- form_key = 'unicode-normalization-form' |
|
1894 |
- default_form: str = main_config.get('vault', {}).get( |
|
1895 |
- f'default-{form_key}', 'NFC' |
|
1896 |
- ) |
|
1897 |
- form_dict: dict[str, dict] = main_config.get('vault', {}).get(form_key, {}) |
|
1898 |
- form: Any = ( |
|
1899 |
- default_form |
|
1900 |
- if isinstance(key, _ORIGIN) or key == ('global',) |
|
1901 |
- else form_dict.get(key[1], default_form) |
|
1902 |
- ) |
|
1903 |
- config_key = ( |
|
1904 |
- _toml_key('vault', key[1], form_key) |
|
1905 |
- if isinstance(key, tuple) and len(key) > 1 and key[1] in form_dict |
|
1906 |
- else f'vault.default-{form_key}' |
|
1907 |
- ) |
|
1908 |
- if form not in {'NFC', 'NFD', 'NFKC', 'NFKD'}: |
|
1909 |
- msg = f'Invalid value {form!r} for config key {config_key}' |
|
1910 |
- raise AssertionError(msg) |
|
1911 |
- logger = logging.getLogger(PROG_NAME) |
|
1912 |
- formatted_key = ( |
|
1913 |
- key.value if isinstance(key, _ORIGIN) else _types.json_path(key) |
|
1914 |
- ) |
|
1915 |
- if 'phrase' in value: |
|
1916 |
- phrase = value['phrase'] |
|
1917 |
- if not unicodedata.is_normalized(form, phrase): |
|
1918 |
- logger.warning( |
|
1919 |
- ( |
|
1920 |
- 'The %s passphrase is not %s-normalized. Its ' |
|
1921 |
- 'serialization as a byte string may not be what you ' |
|
1922 |
- 'expect it to be, even if it *displays* correctly. ' |
|
1923 |
- 'Please make sure to double-check any derived ' |
|
1924 |
- 'passphrases for unexpected results.' |
|
1925 |
- ), |
|
1926 |
- formatted_key, |
|
1927 |
- form, |
|
1928 |
- stacklevel=2, |
|
1929 |
- extra={'color': ctx.color if ctx is not None else None}, |
|
1930 |
- ) |
|
1931 |
- |
|
1932 |
- |
|
1933 |
-def _key_to_phrase( |
|
1934 |
- key_: str | bytes | bytearray, |
|
1935 |
- /, |
|
1936 |
- *, |
|
1937 |
- error_callback: Callable[..., NoReturn] = sys.exit, |
|
1938 |
-) -> bytes | bytearray: |
|
1939 |
- key = base64.standard_b64decode(key_) |
|
1940 |
- try: |
|
1941 |
- with ssh_agent.SSHAgentClient.ensure_agent_subcontext() as client: |
|
1942 |
- try: |
|
1943 |
- return vault.Vault.phrase_from_key(key, conn=client) |
|
1944 |
- except ssh_agent.SSHAgentFailedError as exc: |
|
1945 |
- try: |
|
1946 |
- keylist = client.list_keys() |
|
1947 |
- except ssh_agent.SSHAgentFailedError: |
|
1948 |
- pass |
|
1949 |
- except Exception as exc2: # noqa: BLE001 |
|
1950 |
- exc.__context__ = exc2 |
|
1951 |
- else: |
|
1952 |
- if not any( # pragma: no branch |
|
1953 |
- k == key for k, _ in keylist |
|
1954 |
- ): |
|
1955 |
- error_callback( |
|
1956 |
- _msg.TranslatedString( |
|
1957 |
- _msg.ErrMsgTemplate.SSH_KEY_NOT_LOADED |
|
1958 |
- ) |
|
1959 |
- ) |
|
1960 |
- error_callback( |
|
1961 |
- _msg.TranslatedString( |
|
1962 |
- _msg.ErrMsgTemplate.AGENT_REFUSED_SIGNATURE |
|
1963 |
- ), |
|
1964 |
- exc_info=exc, |
|
1965 |
- ) |
|
1966 |
- except KeyError: |
|
1967 |
- error_callback( |
|
1968 |
- _msg.TranslatedString(_msg.ErrMsgTemplate.NO_SSH_AGENT_FOUND) |
|
1969 |
- ) |
|
1970 |
- except NotImplementedError: |
|
1971 |
- error_callback(_msg.TranslatedString(_msg.ErrMsgTemplate.NO_AF_UNIX)) |
|
1972 |
- except OSError as exc: |
|
1973 |
- error_callback( |
|
1974 |
- _msg.TranslatedString( |
|
1975 |
- _msg.ErrMsgTemplate.CANNOT_CONNECT_TO_AGENT, |
|
1976 |
- error=exc.strerror, |
|
1977 |
- filename=exc.filename, |
|
1978 |
- ).maybe_without_filename() |
|
1979 |
- ) |
|
1980 |
- except RuntimeError as exc: |
|
1981 |
- error_callback( |
|
1982 |
- _msg.TranslatedString(_msg.ErrMsgTemplate.CANNOT_UNDERSTAND_AGENT), |
|
1983 |
- exc_info=exc, |
|
1984 |
- ) |
|
1985 |
- |
|
1986 |
- |
|
1987 |
-def _print_config_as_sh_script( |
|
1988 |
- config: _types.VaultConfig, |
|
1989 |
- /, |
|
1990 |
- *, |
|
1991 |
- outfile: TextIO, |
|
1992 |
- prog_name_list: Sequence[str], |
|
1993 |
-) -> None: |
|
1994 |
- service_keys = ( |
|
1995 |
- 'length', |
|
1996 |
- 'repeat', |
|
1997 |
- 'lower', |
|
1998 |
- 'upper', |
|
1999 |
- 'number', |
|
2000 |
- 'space', |
|
2001 |
- 'dash', |
|
2002 |
- 'symbol', |
|
2003 |
- ) |
|
2004 |
- print('#!/bin/sh -e', file=outfile) |
|
2005 |
- print(file=outfile) |
|
2006 |
- print(shlex.join([*prog_name_list, '--clear']), file=outfile) |
|
2007 |
- sv_obj_pairs: list[ |
|
2008 |
- tuple[ |
|
2009 |
- str | None, |
|
2010 |
- _types.VaultConfigGlobalSettings |
|
2011 |
- | _types.VaultConfigServicesSettings, |
|
2012 |
- ], |
|
2013 |
- ] = list(config['services'].items()) |
|
2014 |
- if config.get('global', {}): |
|
2015 |
- sv_obj_pairs.insert(0, (None, config['global'])) |
|
2016 |
- for sv, sv_obj in sv_obj_pairs: |
|
2017 |
- this_service_keys = tuple(k for k in service_keys if k in sv_obj) |
|
2018 |
- this_other_keys = tuple(k for k in sv_obj if k not in service_keys) |
|
2019 |
- if this_other_keys: |
|
2020 |
- other_sv_obj = {k: sv_obj[k] for k in this_other_keys} # type: ignore[literal-required] |
|
2021 |
- dumped_config = json.dumps( |
|
2022 |
- ( |
|
2023 |
- {'services': {sv: other_sv_obj}} |
|
2024 |
- if sv is not None |
|
2025 |
- else {'global': other_sv_obj, 'services': {}} |
|
2026 |
- ), |
|
2027 |
- ensure_ascii=False, |
|
2028 |
- indent=None, |
|
2029 |
- ) |
|
2030 |
- print( |
|
2031 |
- shlex.join([*prog_name_list, '--import', '-']) + " <<'HERE'", |
|
2032 |
- dumped_config, |
|
2033 |
- 'HERE', |
|
2034 |
- sep='\n', |
|
2035 |
- file=outfile, |
|
2036 |
- ) |
|
2037 |
- if not this_service_keys and not this_other_keys and sv: |
|
2038 |
- dumped_config = json.dumps( |
|
2039 |
- {'services': {sv: {}}}, |
|
2040 |
- ensure_ascii=False, |
|
2041 |
- indent=None, |
|
2042 |
- ) |
|
2043 |
- print( |
|
2044 |
- shlex.join([*prog_name_list, '--import', '-']) + " <<'HERE'", |
|
2045 |
- dumped_config, |
|
2046 |
- 'HERE', |
|
2047 |
- sep='\n', |
|
2048 |
- file=outfile, |
|
2049 |
- ) |
|
2050 |
- elif this_service_keys: |
|
2051 |
- tokens = [*prog_name_list, '--config'] |
|
2052 |
- for key in this_service_keys: |
|
2053 |
- tokens.extend([f'--{key}', str(sv_obj[key])]) # type: ignore[literal-required] |
|
2054 |
- if sv is not None: |
|
2055 |
- tokens.extend(['--', sv]) |
|
2056 |
- print(shlex.join(tokens), file=outfile) |
|
2057 |
- |
|
2058 |
- |
|
2059 |
-# Concrete option groups used by this command-line interface. |
|
2060 |
-class PassphraseGenerationOption(OptionGroupOption): |
|
2061 |
- """Passphrase generation options for the CLI.""" |
|
2062 |
- |
|
2063 |
- option_group_name = _msg.TranslatedString( |
|
2064 |
- _msg.Label.PASSPHRASE_GENERATION_LABEL |
|
2065 |
- ) |
|
2066 |
- epilog = _msg.TranslatedString( |
|
2067 |
- _msg.Label.PASSPHRASE_GENERATION_EPILOG, |
|
2068 |
- metavar=_msg.TranslatedString( |
|
2069 |
- _msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER |
|
2070 |
- ), |
|
2071 |
- ) |
|
2072 |
- |
|
2073 |
- |
|
2074 |
-class ConfigurationOption(OptionGroupOption): |
|
2075 |
- """Configuration options for the CLI.""" |
|
2076 |
- |
|
2077 |
- option_group_name = _msg.TranslatedString(_msg.Label.CONFIGURATION_LABEL) |
|
2078 |
- epilog = _msg.TranslatedString(_msg.Label.CONFIGURATION_EPILOG) |
|
2079 |
- |
|
2080 |
- |
|
2081 |
-class StorageManagementOption(OptionGroupOption): |
|
2082 |
- """Storage management options for the CLI.""" |
|
2083 |
- |
|
2084 |
- option_group_name = _msg.TranslatedString( |
|
2085 |
- _msg.Label.STORAGE_MANAGEMENT_LABEL |
|
2086 |
- ) |
|
2087 |
- epilog = _msg.TranslatedString( |
|
2088 |
- _msg.Label.STORAGE_MANAGEMENT_EPILOG, |
|
2089 |
- metavar=_msg.TranslatedString( |
|
2090 |
- _msg.Label.STORAGE_MANAGEMENT_METAVAR_PATH |
|
2091 |
- ), |
|
2092 |
- ) |
|
2093 |
- |
|
2094 |
- |
|
2095 |
-class CompatibilityOption(OptionGroupOption): |
|
2096 |
- """Compatibility and incompatibility options for the CLI.""" |
|
2097 |
- |
|
2098 |
- option_group_name = _msg.TranslatedString( |
|
2099 |
- _msg.Label.COMPATIBILITY_OPTION_LABEL |
|
2100 |
- ) |
|
2101 |
- |
|
2102 |
- |
|
2103 |
-def _validate_occurrence_constraint( |
|
2104 |
- ctx: click.Context, |
|
2105 |
- param: click.Parameter, |
|
2106 |
- value: Any, # noqa: ANN401 |
|
2107 |
-) -> int | None: |
|
2108 |
- """Check that the occurrence constraint is valid (int, 0 or larger). |
|
2109 |
- |
|
2110 |
- Args: |
|
2111 |
- ctx: The `click` context. |
|
2112 |
- param: The current command-line parameter. |
|
2113 |
- value: The parameter value to be checked. |
|
2114 |
- |
|
2115 |
- Returns: |
|
2116 |
- The parsed parameter value. |
|
2117 |
- |
|
2118 |
- Raises: |
|
2119 |
- click.BadParameter: The parameter value is invalid. |
|
2120 |
- |
|
2121 |
- """ |
|
2122 |
- del ctx # Unused. |
|
2123 |
- del param # Unused. |
|
2124 |
- if value is None: |
|
2125 |
- return value |
|
2126 |
- if isinstance(value, int): |
|
2127 |
- int_value = value |
|
2128 |
- else: |
|
2129 |
- try: |
|
2130 |
- int_value = int(value, 10) |
|
2131 |
- except ValueError as exc: |
|
2132 |
- raise click.BadParameter(_NOT_AN_INTEGER) from exc |
|
2133 |
- if int_value < 0: |
|
2134 |
- raise click.BadParameter(_NOT_A_NONNEGATIVE_INTEGER) |
|
2135 |
- return int_value |
|
2136 |
- |
|
2137 |
- |
|
2138 |
-def _validate_length( |
|
2139 |
- ctx: click.Context, |
|
2140 |
- param: click.Parameter, |
|
2141 |
- value: Any, # noqa: ANN401 |
|
2142 |
-) -> int | None: |
|
2143 |
- """Check that the length is valid (int, 1 or larger). |
|
2144 |
- |
|
2145 |
- Args: |
|
2146 |
- ctx: The `click` context. |
|
2147 |
- param: The current command-line parameter. |
|
2148 |
- value: The parameter value to be checked. |
|
2149 |
- |
|
2150 |
- Returns: |
|
2151 |
- The parsed parameter value. |
|
2152 |
- |
|
2153 |
- Raises: |
|
2154 |
- click.BadParameter: The parameter value is invalid. |
|
2155 |
- |
|
2156 |
- """ |
|
2157 |
- del ctx # Unused. |
|
2158 |
- del param # Unused. |
|
2159 |
- if value is None: |
|
2160 |
- return value |
|
2161 |
- if isinstance(value, int): |
|
2162 |
- int_value = value |
|
2163 |
- else: |
|
2164 |
- try: |
|
2165 |
- int_value = int(value, 10) |
|
2166 |
- except ValueError as exc: |
|
2167 |
- raise click.BadParameter(_NOT_AN_INTEGER) from exc |
|
2168 |
- if int_value < 1: |
|
2169 |
- raise click.BadParameter(_NOT_A_POSITIVE_INTEGER) |
|
2170 |
- return int_value |
|
2171 |
- |
|
2172 |
- |
|
2173 | 315 |
@derivepassphrase.command( |
2174 | 316 |
'vault', |
2175 | 317 |
context_settings={'help_option_names': ['-h', '--help']}, |
2176 |
- cls=CommandWithHelpGroups, |
|
318 |
+ cls=cli_machinery.CommandWithHelpGroups, |
|
2177 | 319 |
help=( |
2178 | 320 |
_msg.TranslatedString(_msg.Label.DERIVEPASSPHRASE_VAULT_01), |
2179 | 321 |
_msg.TranslatedString( |
... | ... |
@@ -2196,7 +338,7 @@ def _validate_length( |
2196 | 338 |
help=_msg.TranslatedString( |
2197 | 339 |
_msg.Label.DERIVEPASSPHRASE_VAULT_PHRASE_HELP_TEXT |
2198 | 340 |
), |
2199 |
- cls=PassphraseGenerationOption, |
|
341 |
+ cls=cli_machinery.PassphraseGenerationOption, |
|
2200 | 342 |
) |
2201 | 343 |
@click.option( |
2202 | 344 |
'-k', |
... | ... |
@@ -2206,7 +348,7 @@ def _validate_length( |
2206 | 348 |
help=_msg.TranslatedString( |
2207 | 349 |
_msg.Label.DERIVEPASSPHRASE_VAULT_KEY_HELP_TEXT |
2208 | 350 |
), |
2209 |
- cls=PassphraseGenerationOption, |
|
351 |
+ cls=cli_machinery.PassphraseGenerationOption, |
|
2210 | 352 |
) |
2211 | 353 |
@click.option( |
2212 | 354 |
'-l', |
... | ... |
@@ -2214,14 +356,14 @@ def _validate_length( |
2214 | 356 |
metavar=_msg.TranslatedString( |
2215 | 357 |
_msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER |
2216 | 358 |
), |
2217 |
- callback=_validate_length, |
|
359 |
+ callback=cli_machinery.validate_length, |
|
2218 | 360 |
help=_msg.TranslatedString( |
2219 | 361 |
_msg.Label.DERIVEPASSPHRASE_VAULT_LENGTH_HELP_TEXT, |
2220 | 362 |
metavar=_msg.TranslatedString( |
2221 | 363 |
_msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER |
2222 | 364 |
), |
2223 | 365 |
), |
2224 |
- cls=PassphraseGenerationOption, |
|
366 |
+ cls=cli_machinery.PassphraseGenerationOption, |
|
2225 | 367 |
) |
2226 | 368 |
@click.option( |
2227 | 369 |
'-r', |
... | ... |
@@ -2229,98 +371,98 @@ def _validate_length( |
2229 | 371 |
metavar=_msg.TranslatedString( |
2230 | 372 |
_msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER |
2231 | 373 |
), |
2232 |
- callback=_validate_occurrence_constraint, |
|
374 |
+ callback=cli_machinery.validate_occurrence_constraint, |
|
2233 | 375 |
help=_msg.TranslatedString( |
2234 | 376 |
_msg.Label.DERIVEPASSPHRASE_VAULT_REPEAT_HELP_TEXT, |
2235 | 377 |
metavar=_msg.TranslatedString( |
2236 | 378 |
_msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER |
2237 | 379 |
), |
2238 | 380 |
), |
2239 |
- cls=PassphraseGenerationOption, |
|
381 |
+ cls=cli_machinery.PassphraseGenerationOption, |
|
2240 | 382 |
) |
2241 | 383 |
@click.option( |
2242 | 384 |
'--lower', |
2243 | 385 |
metavar=_msg.TranslatedString( |
2244 | 386 |
_msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER |
2245 | 387 |
), |
2246 |
- callback=_validate_occurrence_constraint, |
|
388 |
+ callback=cli_machinery.validate_occurrence_constraint, |
|
2247 | 389 |
help=_msg.TranslatedString( |
2248 | 390 |
_msg.Label.DERIVEPASSPHRASE_VAULT_LOWER_HELP_TEXT, |
2249 | 391 |
metavar=_msg.TranslatedString( |
2250 | 392 |
_msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER |
2251 | 393 |
), |
2252 | 394 |
), |
2253 |
- cls=PassphraseGenerationOption, |
|
395 |
+ cls=cli_machinery.PassphraseGenerationOption, |
|
2254 | 396 |
) |
2255 | 397 |
@click.option( |
2256 | 398 |
'--upper', |
2257 | 399 |
metavar=_msg.TranslatedString( |
2258 | 400 |
_msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER |
2259 | 401 |
), |
2260 |
- callback=_validate_occurrence_constraint, |
|
402 |
+ callback=cli_machinery.validate_occurrence_constraint, |
|
2261 | 403 |
help=_msg.TranslatedString( |
2262 | 404 |
_msg.Label.DERIVEPASSPHRASE_VAULT_UPPER_HELP_TEXT, |
2263 | 405 |
metavar=_msg.TranslatedString( |
2264 | 406 |
_msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER |
2265 | 407 |
), |
2266 | 408 |
), |
2267 |
- cls=PassphraseGenerationOption, |
|
409 |
+ cls=cli_machinery.PassphraseGenerationOption, |
|
2268 | 410 |
) |
2269 | 411 |
@click.option( |
2270 | 412 |
'--number', |
2271 | 413 |
metavar=_msg.TranslatedString( |
2272 | 414 |
_msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER |
2273 | 415 |
), |
2274 |
- callback=_validate_occurrence_constraint, |
|
416 |
+ callback=cli_machinery.validate_occurrence_constraint, |
|
2275 | 417 |
help=_msg.TranslatedString( |
2276 | 418 |
_msg.Label.DERIVEPASSPHRASE_VAULT_NUMBER_HELP_TEXT, |
2277 | 419 |
metavar=_msg.TranslatedString( |
2278 | 420 |
_msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER |
2279 | 421 |
), |
2280 | 422 |
), |
2281 |
- cls=PassphraseGenerationOption, |
|
423 |
+ cls=cli_machinery.PassphraseGenerationOption, |
|
2282 | 424 |
) |
2283 | 425 |
@click.option( |
2284 | 426 |
'--space', |
2285 | 427 |
metavar=_msg.TranslatedString( |
2286 | 428 |
_msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER |
2287 | 429 |
), |
2288 |
- callback=_validate_occurrence_constraint, |
|
430 |
+ callback=cli_machinery.validate_occurrence_constraint, |
|
2289 | 431 |
help=_msg.TranslatedString( |
2290 | 432 |
_msg.Label.DERIVEPASSPHRASE_VAULT_SPACE_HELP_TEXT, |
2291 | 433 |
metavar=_msg.TranslatedString( |
2292 | 434 |
_msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER |
2293 | 435 |
), |
2294 | 436 |
), |
2295 |
- cls=PassphraseGenerationOption, |
|
437 |
+ cls=cli_machinery.PassphraseGenerationOption, |
|
2296 | 438 |
) |
2297 | 439 |
@click.option( |
2298 | 440 |
'--dash', |
2299 | 441 |
metavar=_msg.TranslatedString( |
2300 | 442 |
_msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER |
2301 | 443 |
), |
2302 |
- callback=_validate_occurrence_constraint, |
|
444 |
+ callback=cli_machinery.validate_occurrence_constraint, |
|
2303 | 445 |
help=_msg.TranslatedString( |
2304 | 446 |
_msg.Label.DERIVEPASSPHRASE_VAULT_DASH_HELP_TEXT, |
2305 | 447 |
metavar=_msg.TranslatedString( |
2306 | 448 |
_msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER |
2307 | 449 |
), |
2308 | 450 |
), |
2309 |
- cls=PassphraseGenerationOption, |
|
451 |
+ cls=cli_machinery.PassphraseGenerationOption, |
|
2310 | 452 |
) |
2311 | 453 |
@click.option( |
2312 | 454 |
'--symbol', |
2313 | 455 |
metavar=_msg.TranslatedString( |
2314 | 456 |
_msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER |
2315 | 457 |
), |
2316 |
- callback=_validate_occurrence_constraint, |
|
458 |
+ callback=cli_machinery.validate_occurrence_constraint, |
|
2317 | 459 |
help=_msg.TranslatedString( |
2318 | 460 |
_msg.Label.DERIVEPASSPHRASE_VAULT_SYMBOL_HELP_TEXT, |
2319 | 461 |
metavar=_msg.TranslatedString( |
2320 | 462 |
_msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER |
2321 | 463 |
), |
2322 | 464 |
), |
2323 |
- cls=PassphraseGenerationOption, |
|
465 |
+ cls=cli_machinery.PassphraseGenerationOption, |
|
2324 | 466 |
) |
2325 | 467 |
@click.option( |
2326 | 468 |
'-n', |
... | ... |
@@ -2333,7 +475,7 @@ def _validate_length( |
2333 | 475 |
_msg.Label.VAULT_METAVAR_SERVICE |
2334 | 476 |
), |
2335 | 477 |
), |
2336 |
- cls=ConfigurationOption, |
|
478 |
+ cls=cli_machinery.ConfigurationOption, |
|
2337 | 479 |
) |
2338 | 480 |
@click.option( |
2339 | 481 |
'-c', |
... | ... |
@@ -2346,7 +488,7 @@ def _validate_length( |
2346 | 488 |
_msg.Label.VAULT_METAVAR_SERVICE |
2347 | 489 |
), |
2348 | 490 |
), |
2349 |
- cls=ConfigurationOption, |
|
491 |
+ cls=cli_machinery.ConfigurationOption, |
|
2350 | 492 |
) |
2351 | 493 |
@click.option( |
2352 | 494 |
'-x', |
... | ... |
@@ -2359,7 +501,7 @@ def _validate_length( |
2359 | 501 |
_msg.Label.VAULT_METAVAR_SERVICE |
2360 | 502 |
), |
2361 | 503 |
), |
2362 |
- cls=ConfigurationOption, |
|
504 |
+ cls=cli_machinery.ConfigurationOption, |
|
2363 | 505 |
) |
2364 | 506 |
@click.option( |
2365 | 507 |
'--delete-globals', |
... | ... |
@@ -2367,7 +509,7 @@ def _validate_length( |
2367 | 509 |
help=_msg.TranslatedString( |
2368 | 510 |
_msg.Label.DERIVEPASSPHRASE_VAULT_DELETE_GLOBALS_HELP_TEXT, |
2369 | 511 |
), |
2370 |
- cls=ConfigurationOption, |
|
512 |
+ cls=cli_machinery.ConfigurationOption, |
|
2371 | 513 |
) |
2372 | 514 |
@click.option( |
2373 | 515 |
'-X', |
... | ... |
@@ -2377,7 +519,7 @@ def _validate_length( |
2377 | 519 |
help=_msg.TranslatedString( |
2378 | 520 |
_msg.Label.DERIVEPASSPHRASE_VAULT_DELETE_ALL_HELP_TEXT, |
2379 | 521 |
), |
2380 |
- cls=ConfigurationOption, |
|
522 |
+ cls=cli_machinery.ConfigurationOption, |
|
2381 | 523 |
) |
2382 | 524 |
@click.option( |
2383 | 525 |
'-e', |
... | ... |
@@ -2392,8 +534,8 @@ def _validate_length( |
2392 | 534 |
_msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER |
2393 | 535 |
), |
2394 | 536 |
), |
2395 |
- cls=StorageManagementOption, |
|
2396 |
- shell_complete=_shell_complete_path, |
|
537 |
+ cls=cli_machinery.StorageManagementOption, |
|
538 |
+ shell_complete=cli_helpers.shell_complete_path, |
|
2397 | 539 |
) |
2398 | 540 |
@click.option( |
2399 | 541 |
'-i', |
... | ... |
@@ -2408,8 +550,8 @@ def _validate_length( |
2408 | 550 |
_msg.Label.PASSPHRASE_GENERATION_METAVAR_NUMBER |
2409 | 551 |
), |
2410 | 552 |
), |
2411 |
- cls=StorageManagementOption, |
|
2412 |
- shell_complete=_shell_complete_path, |
|
553 |
+ cls=cli_machinery.StorageManagementOption, |
|
554 |
+ shell_complete=cli_helpers.shell_complete_path, |
|
2413 | 555 |
) |
2414 | 556 |
@click.option( |
2415 | 557 |
'--overwrite-existing/--merge-existing', |
... | ... |
@@ -2418,7 +560,7 @@ def _validate_length( |
2418 | 560 |
help=_msg.TranslatedString( |
2419 | 561 |
_msg.Label.DERIVEPASSPHRASE_VAULT_OVERWRITE_HELP_TEXT |
2420 | 562 |
), |
2421 |
- cls=CompatibilityOption, |
|
563 |
+ cls=cli_machinery.CompatibilityOption, |
|
2422 | 564 |
) |
2423 | 565 |
@click.option( |
2424 | 566 |
'--unset', |
... | ... |
@@ -2439,7 +581,7 @@ def _validate_length( |
2439 | 581 |
help=_msg.TranslatedString( |
2440 | 582 |
_msg.Label.DERIVEPASSPHRASE_VAULT_UNSET_HELP_TEXT |
2441 | 583 |
), |
2442 |
- cls=CompatibilityOption, |
|
584 |
+ cls=cli_machinery.CompatibilityOption, |
|
2443 | 585 |
) |
2444 | 586 |
@click.option( |
2445 | 587 |
'--export-as', |
... | ... |
@@ -2448,17 +590,17 @@ def _validate_length( |
2448 | 590 |
help=_msg.TranslatedString( |
2449 | 591 |
_msg.Label.DERIVEPASSPHRASE_VAULT_EXPORT_AS_HELP_TEXT |
2450 | 592 |
), |
2451 |
- cls=CompatibilityOption, |
|
593 |
+ cls=cli_machinery.CompatibilityOption, |
|
2452 | 594 |
) |
2453 |
-@version_option |
|
2454 |
-@color_forcing_pseudo_option |
|
2455 |
-@standard_logging_options |
|
595 |
+@cli_machinery.version_option |
|
596 |
+@cli_machinery.color_forcing_pseudo_option |
|
597 |
+@cli_machinery.standard_logging_options |
|
2456 | 598 |
@click.argument( |
2457 | 599 |
'service', |
2458 | 600 |
metavar=_msg.TranslatedString(_msg.Label.VAULT_METAVAR_SERVICE), |
2459 | 601 |
required=False, |
2460 | 602 |
default=None, |
2461 |
- shell_complete=_shell_complete_service, |
|
603 |
+ shell_complete=cli_helpers.shell_complete_service, |
|
2462 | 604 |
) |
2463 | 605 |
@click.pass_context |
2464 | 606 |
def derivepassphrase_vault( # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915 |
... | ... |
@@ -2590,14 +732,14 @@ def derivepassphrase_vault( # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915 |
2590 | 732 |
if isinstance(param, click.Option): |
2591 | 733 |
group: type[click.Option] |
2592 | 734 |
known_option_groups = [ |
2593 |
- PassphraseGenerationOption, |
|
2594 |
- ConfigurationOption, |
|
2595 |
- StorageManagementOption, |
|
2596 |
- LoggingOption, |
|
2597 |
- CompatibilityOption, |
|
2598 |
- StandardOption, |
|
735 |
+ cli_machinery.PassphraseGenerationOption, |
|
736 |
+ cli_machinery.ConfigurationOption, |
|
737 |
+ cli_machinery.StorageManagementOption, |
|
738 |
+ cli_machinery.LoggingOption, |
|
739 |
+ cli_machinery.CompatibilityOption, |
|
740 |
+ cli_machinery.StandardOption, |
|
2599 | 741 |
] |
2600 |
- if isinstance(param, OptionGroupOption): |
|
742 |
+ if isinstance(param, cli_machinery.OptionGroupOption): |
|
2601 | 743 |
for class_ in known_option_groups: |
2602 | 744 |
if isinstance(param, class_): |
2603 | 745 |
group = class_ |
... | ... |
@@ -2664,14 +806,14 @@ def derivepassphrase_vault( # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915 |
2664 | 806 |
|
2665 | 807 |
def get_config() -> _types.VaultConfig: |
2666 | 808 |
try: |
2667 |
- return _load_config() |
|
809 |
+ return cli_helpers.load_config() |
|
2668 | 810 |
except FileNotFoundError: |
2669 | 811 |
try: |
2670 |
- backup_config, exc = _migrate_and_load_old_config() |
|
812 |
+ backup_config, exc = cli_helpers.migrate_and_load_old_config() |
|
2671 | 813 |
except FileNotFoundError: |
2672 | 814 |
return {'services': {}} |
2673 |
- old_name = _config_filename(subsystem='old settings.json').name |
|
2674 |
- new_name = _config_filename(subsystem='vault').name |
|
815 |
+ old_name = cli_helpers.config_filename(subsystem='old settings.json').name |
|
816 |
+ new_name = cli_helpers.config_filename(subsystem='vault').name |
|
2675 | 817 |
deprecation.warning( |
2676 | 818 |
_msg.TranslatedString( |
2677 | 819 |
_msg.WarnMsgTemplate.V01_STYLE_CONFIG, |
... | ... |
@@ -2719,7 +861,7 @@ def derivepassphrase_vault( # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915 |
2719 | 861 |
|
2720 | 862 |
def put_config(config: _types.VaultConfig, /) -> None: |
2721 | 863 |
try: |
2722 |
- _save_config(config) |
|
864 |
+ cli_helpers.save_config(config) |
|
2723 | 865 |
except OSError as exc: |
2724 | 866 |
err( |
2725 | 867 |
_msg.TranslatedString( |
... | ... |
@@ -2740,7 +882,7 @@ def derivepassphrase_vault( # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915 |
2740 | 882 |
|
2741 | 883 |
def get_user_config() -> dict[str, Any]: |
2742 | 884 |
try: |
2743 |
- return _load_user_config() |
|
885 |
+ return cli_helpers.load_user_config() |
|
2744 | 886 |
except FileNotFoundError: |
2745 | 887 |
return {} |
2746 | 888 |
except OSError as exc: |
... | ... |
@@ -2764,19 +906,19 @@ def derivepassphrase_vault( # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915 |
2764 | 906 |
configuration: _types.VaultConfig |
2765 | 907 |
|
2766 | 908 |
check_incompatible_options('--phrase', '--key') |
2767 |
- for group in (ConfigurationOption, StorageManagementOption): |
|
909 |
+ for group in (cli_machinery.ConfigurationOption, cli_machinery.StorageManagementOption): |
|
2768 | 910 |
for opt in options_in_group[group]: |
2769 | 911 |
if opt != params_by_str['--config']: |
2770 |
- for other_opt in options_in_group[PassphraseGenerationOption]: |
|
912 |
+ for other_opt in options_in_group[cli_machinery.PassphraseGenerationOption]: |
|
2771 | 913 |
check_incompatible_options(opt, other_opt) |
2772 | 914 |
|
2773 |
- for group in (ConfigurationOption, StorageManagementOption): |
|
915 |
+ for group in (cli_machinery.ConfigurationOption, cli_machinery.StorageManagementOption): |
|
2774 | 916 |
for opt in options_in_group[group]: |
2775 |
- for other_opt in options_in_group[ConfigurationOption]: |
|
917 |
+ for other_opt in options_in_group[cli_machinery.ConfigurationOption]: |
|
2776 | 918 |
check_incompatible_options(opt, other_opt) |
2777 |
- for other_opt in options_in_group[StorageManagementOption]: |
|
919 |
+ for other_opt in options_in_group[cli_machinery.StorageManagementOption]: |
|
2778 | 920 |
check_incompatible_options(opt, other_opt) |
2779 |
- sv_or_global_options = options_in_group[PassphraseGenerationOption] |
|
921 |
+ sv_or_global_options = options_in_group[cli_machinery.PassphraseGenerationOption] |
|
2780 | 922 |
for param in sv_or_global_options: |
2781 | 923 |
if is_param_set(param) and not ( |
2782 | 924 |
service is not None or is_param_set(params_by_str['--config']) |
... | ... |
@@ -2799,7 +941,7 @@ def derivepassphrase_vault( # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915 |
2799 | 941 |
no_sv_options = [ |
2800 | 942 |
params_by_str['--delete-globals'], |
2801 | 943 |
params_by_str['--clear'], |
2802 |
- *options_in_group[StorageManagementOption], |
|
944 |
+ *options_in_group[cli_machinery.StorageManagementOption], |
|
2803 | 945 |
] |
2804 | 946 |
for param in no_sv_options: |
2805 | 947 |
if is_param_set(param) and service is not None: |
... | ... |
@@ -2948,7 +1090,7 @@ def derivepassphrase_vault( # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915 |
2948 | 1090 |
extra={'color': ctx.color}, |
2949 | 1091 |
) |
2950 | 1092 |
for service_name in sorted(maybe_config['services'].keys()): |
2951 |
- if not _is_completable_item(service_name): |
|
1093 |
+ if not cli_helpers.is_completable_item(service_name): |
|
2952 | 1094 |
logger.warning( |
2953 | 1095 |
_msg.TranslatedString( |
2954 | 1096 |
_msg.WarnMsgTemplate.SERVICE_NAME_INCOMPLETABLE, |
... | ... |
@@ -2957,14 +1099,14 @@ def derivepassphrase_vault( # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915 |
2957 | 1099 |
extra={'color': ctx.color}, |
2958 | 1100 |
) |
2959 | 1101 |
try: |
2960 |
- _check_for_misleading_passphrase( |
|
1102 |
+ cli_helpers.check_for_misleading_passphrase( |
|
2961 | 1103 |
('global',), |
2962 | 1104 |
cast('dict[str, Any]', maybe_config.get('global', {})), |
2963 | 1105 |
main_config=user_config, |
2964 | 1106 |
ctx=ctx, |
2965 | 1107 |
) |
2966 | 1108 |
for key, value in maybe_config['services'].items(): |
2967 |
- _check_for_misleading_passphrase( |
|
1109 |
+ cli_helpers.check_for_misleading_passphrase( |
|
2968 | 1110 |
('services', key), |
2969 | 1111 |
cast('dict[str, Any]', value), |
2970 | 1112 |
main_config=user_config, |
... | ... |
@@ -3059,7 +1201,7 @@ def derivepassphrase_vault( # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915 |
3059 | 1201 |
): |
3060 | 1202 |
prog_name_pieces.appendleft(this_ctx.parent.info_name) |
3061 | 1203 |
this_ctx = this_ctx.parent |
3062 |
- _print_config_as_sh_script( |
|
1204 |
+ cli_helpers.print_config_as_sh_script( |
|
3063 | 1205 |
configuration, |
3064 | 1206 |
outfile=outfile, |
3065 | 1207 |
prog_name_list=prog_name_pieces, |
... | ... |
@@ -3116,7 +1258,7 @@ def derivepassphrase_vault( # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915 |
3116 | 1258 |
if use_key: |
3117 | 1259 |
try: |
3118 | 1260 |
key = base64.standard_b64encode( |
3119 |
- _select_ssh_key(ctx=ctx) |
|
1261 |
+ cli_helpers.select_ssh_key(ctx=ctx) |
|
3120 | 1262 |
).decode('ASCII') |
3121 | 1263 |
except IndexError: |
3122 | 1264 |
err( |
... | ... |
@@ -3162,7 +1304,7 @@ def derivepassphrase_vault( # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915 |
3162 | 1304 |
exc_info=exc, |
3163 | 1305 |
) |
3164 | 1306 |
elif use_phrase: |
3165 |
- maybe_phrase = _prompt_for_passphrase() |
|
1307 |
+ maybe_phrase = cli_helpers.prompt_for_passphrase() |
|
3166 | 1308 |
if not maybe_phrase: |
3167 | 1309 |
err( |
3168 | 1310 |
_msg.TranslatedString( |
... | ... |
@@ -3183,7 +1325,7 @@ def derivepassphrase_vault( # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915 |
3183 | 1325 |
elif use_phrase: |
3184 | 1326 |
view['phrase'] = phrase |
3185 | 1327 |
try: |
3186 |
- _check_for_misleading_passphrase( |
|
1328 |
+ cli_helpers.check_for_misleading_passphrase( |
|
3187 | 1329 |
('services', service) if service else ('global',), |
3188 | 1330 |
{'phrase': phrase}, |
3189 | 1331 |
main_config=user_config, |
... | ... |
@@ -3225,7 +1367,7 @@ def derivepassphrase_vault( # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915 |
3225 | 1367 |
setting=setting, |
3226 | 1368 |
) |
3227 | 1369 |
raise click.UsageError(str(err_msg)) |
3228 |
- if not _is_completable_item(service): |
|
1370 |
+ if not cli_helpers.is_completable_item(service): |
|
3229 | 1371 |
logger.warning( |
3230 | 1372 |
_msg.TranslatedString( |
3231 | 1373 |
_msg.WarnMsgTemplate.SERVICE_NAME_INCOMPLETABLE, |
... | ... |
@@ -3257,8 +1399,8 @@ def derivepassphrase_vault( # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915 |
3257 | 1399 |
} |
3258 | 1400 |
if use_phrase: |
3259 | 1401 |
try: |
3260 |
- _check_for_misleading_passphrase( |
|
3261 |
- _ORIGIN.INTERACTIVE, |
|
1402 |
+ cli_helpers.check_for_misleading_passphrase( |
|
1403 |
+ cli_helpers.ORIGIN.INTERACTIVE, |
|
3262 | 1404 |
{'phrase': phrase}, |
3263 | 1405 |
main_config=user_config, |
3264 | 1406 |
ctx=ctx, |
... | ... |
@@ -3279,12 +1421,12 @@ def derivepassphrase_vault( # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915 |
3279 | 1421 |
# a key is given. Finally, if nothing is set, error out. |
3280 | 1422 |
if use_key or use_phrase: |
3281 | 1423 |
kwargs['phrase'] = ( |
3282 |
- _key_to_phrase(key, error_callback=err) |
|
1424 |
+ cli_helpers.key_to_phrase(key, error_callback=err) |
|
3283 | 1425 |
if use_key |
3284 | 1426 |
else phrase |
3285 | 1427 |
) |
3286 | 1428 |
elif kwargs.get('key'): |
3287 |
- kwargs['phrase'] = _key_to_phrase( |
|
1429 |
+ kwargs['phrase'] = cli_helpers.key_to_phrase( |
|
3288 | 1430 |
kwargs['key'], error_callback=err |
3289 | 1431 |
) |
3290 | 1432 |
elif kwargs.get('phrase'): |
... | ... |
@@ -27,6 +27,7 @@ from hypothesis import strategies |
27 | 27 |
from typing_extensions import NamedTuple, Self, assert_never |
28 | 28 |
|
29 | 29 |
from derivepassphrase import _types, cli, ssh_agent, vault |
30 |
+from derivepassphrase._internals import cli_helpers, cli_machinery |
|
30 | 31 |
|
31 | 32 |
__all__ = () |
32 | 33 |
|
... | ... |
@@ -1738,7 +1739,7 @@ def suitable_ssh_keys(conn: Any) -> Iterator[_types.SSHKeyCommentPair]: |
1738 | 1739 |
"""Return a two-item list of SSH test keys (key/comment pairs). |
1739 | 1740 |
|
1740 | 1741 |
Intended as a monkeypatching replacement for |
1741 |
- `cli._get_suitable_ssh_keys` to better script and test the |
|
1742 |
+ `cli_machinery.get_suitable_ssh_keys` to better script and test the |
|
1742 | 1743 |
interactive key selection. When used this way, `derivepassphrase` |
1743 | 1744 |
believes that only those two keys are loaded and suitable. |
1744 | 1745 |
|
... | ... |
@@ -1806,18 +1807,20 @@ def isolated_config( |
1806 | 1807 |
# https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
1807 | 1808 |
with contextlib.ExitStack() as stack: |
1808 | 1809 |
stack.enter_context(runner.isolated_filesystem()) |
1809 |
- stack.enter_context(cli.StandardCLILogging.ensure_standard_logging()) |
|
1810 | 1810 |
stack.enter_context( |
1811 |
- cli.StandardCLILogging.ensure_standard_warnings_logging() |
|
1811 |
+ cli_machinery.StandardCLILogging.ensure_standard_logging() |
|
1812 |
+ ) |
|
1813 |
+ stack.enter_context( |
|
1814 |
+ cli_machinery.StandardCLILogging.ensure_standard_warnings_logging() |
|
1812 | 1815 |
) |
1813 | 1816 |
cwd = str(pathlib.Path.cwd().resolve()) |
1814 | 1817 |
monkeypatch.setenv('HOME', cwd) |
1815 | 1818 |
monkeypatch.setenv('USERPROFILE', cwd) |
1816 | 1819 |
monkeypatch.delenv(env_name, raising=False) |
1817 |
- config_dir = cli._config_filename(subsystem=None) |
|
1820 |
+ config_dir = cli_helpers.config_filename(subsystem=None) |
|
1818 | 1821 |
config_dir.mkdir(parents=True, exist_ok=True) |
1819 | 1822 |
if isinstance(main_config_str, str): |
1820 |
- cli._config_filename('user configuration').write_text( |
|
1823 |
+ cli_helpers.config_filename('user configuration').write_text( |
|
1821 | 1824 |
main_config_str, encoding='UTF-8' |
1822 | 1825 |
) |
1823 | 1826 |
yield |
... | ... |
@@ -1855,7 +1858,7 @@ def isolated_vault_config( |
1855 | 1858 |
with isolated_config( |
1856 | 1859 |
monkeypatch=monkeypatch, runner=runner, main_config_str=main_config_str |
1857 | 1860 |
): |
1858 |
- config_filename = cli._config_filename(subsystem='vault') |
|
1861 |
+ config_filename = cli_helpers.config_filename(subsystem='vault') |
|
1859 | 1862 |
with config_filename.open('w', encoding='UTF-8') as outfile: |
1860 | 1863 |
json.dump(vault_config, outfile) |
1861 | 1864 |
yield |
... | ... |
@@ -28,6 +28,7 @@ from typing_extensions import Any, NamedTuple |
28 | 28 |
|
29 | 29 |
import tests |
30 | 30 |
from derivepassphrase import _types, cli, ssh_agent, vault |
31 |
+from derivepassphrase._internals import cli_helpers, cli_machinery |
|
31 | 32 |
|
32 | 33 |
if TYPE_CHECKING: |
33 | 34 |
from collections.abc import Callable, Iterable, Iterator, Sequence |
... | ... |
@@ -655,7 +656,7 @@ class TestCLI: |
655 | 656 |
) |
656 | 657 |
) |
657 | 658 |
monkeypatch.setattr( |
658 |
- cli, '_prompt_for_passphrase', tests.auto_prompt |
|
659 |
+ cli_helpers, 'prompt_for_passphrase', tests.auto_prompt |
|
659 | 660 |
) |
660 | 661 |
result_ = runner.invoke( |
661 | 662 |
cli.derivepassphrase_vault, |
... | ... |
@@ -687,7 +688,7 @@ class TestCLI: |
687 | 688 |
) |
688 | 689 |
) |
689 | 690 |
monkeypatch.setattr( |
690 |
- cli, '_prompt_for_passphrase', tests.auto_prompt |
|
691 |
+ cli_helpers, 'prompt_for_passphrase', tests.auto_prompt |
|
691 | 692 |
) |
692 | 693 |
result_ = runner.invoke( |
693 | 694 |
cli.derivepassphrase_vault, |
... | ... |
@@ -788,7 +789,7 @@ class TestCLI: |
788 | 789 |
) |
789 | 790 |
) |
790 | 791 |
monkeypatch.setattr( |
791 |
- cli, '_get_suitable_ssh_keys', tests.suitable_ssh_keys |
|
792 |
+ cli_helpers, 'get_suitable_ssh_keys', tests.suitable_ssh_keys |
|
792 | 793 |
) |
793 | 794 |
monkeypatch.setattr( |
794 | 795 |
vault.Vault, 'phrase_from_key', tests.phrase_from_key |
... | ... |
@@ -1080,7 +1081,7 @@ class TestCLI: |
1080 | 1081 |
) |
1081 | 1082 |
) |
1082 | 1083 |
monkeypatch.setattr( |
1083 |
- cli, '_prompt_for_passphrase', tests.auto_prompt |
|
1084 |
+ cli_helpers, 'prompt_for_passphrase', tests.auto_prompt |
|
1084 | 1085 |
) |
1085 | 1086 |
result_ = runner.invoke( |
1086 | 1087 |
cli.derivepassphrase_vault, |
... | ... |
@@ -1116,7 +1117,7 @@ class TestCLI: |
1116 | 1117 |
) |
1117 | 1118 |
) |
1118 | 1119 |
monkeypatch.setattr( |
1119 |
- cli, '_prompt_for_passphrase', tests.auto_prompt |
|
1120 |
+ cli_helpers, 'prompt_for_passphrase', tests.auto_prompt |
|
1120 | 1121 |
) |
1121 | 1122 |
result_ = runner.invoke( |
1122 | 1123 |
cli.derivepassphrase_vault, |
... | ... |
@@ -1158,7 +1159,7 @@ class TestCLI: |
1158 | 1159 |
) |
1159 | 1160 |
) |
1160 | 1161 |
monkeypatch.setattr( |
1161 |
- cli, '_prompt_for_passphrase', tests.auto_prompt |
|
1162 |
+ cli_helpers, 'prompt_for_passphrase', tests.auto_prompt |
|
1162 | 1163 |
) |
1163 | 1164 |
result_ = runner.invoke( |
1164 | 1165 |
cli.derivepassphrase_vault, |
... | ... |
@@ -1171,7 +1172,7 @@ class TestCLI: |
1171 | 1172 |
assert all(map(is_expected_warning, caplog.record_tuples)), ( |
1172 | 1173 |
'expected known error output' |
1173 | 1174 |
) |
1174 |
- assert cli._load_config() == { |
|
1175 |
+ assert cli_helpers.load_config() == { |
|
1175 | 1176 |
'global': {'length': 30}, |
1176 | 1177 |
'services': {}, |
1177 | 1178 |
}, 'requested configuration change was not applied' |
... | ... |
@@ -1188,7 +1189,7 @@ class TestCLI: |
1188 | 1189 |
assert all(map(is_expected_warning, caplog.record_tuples)), ( |
1189 | 1190 |
'expected known error output' |
1190 | 1191 |
) |
1191 |
- assert cli._load_config() == { |
|
1192 |
+ assert cli_helpers.load_config() == { |
|
1192 | 1193 |
'global': {'length': 30}, |
1193 | 1194 |
'services': {'': {'length': 40}}, |
1194 | 1195 |
}, 'requested configuration change was not applied' |
... | ... |
@@ -1263,7 +1264,7 @@ class TestCLI: |
1263 | 1264 |
input=json.dumps(config), |
1264 | 1265 |
catch_exceptions=False, |
1265 | 1266 |
) |
1266 |
- with cli._config_filename(subsystem='vault').open( |
|
1267 |
+ with cli_helpers.config_filename(subsystem='vault').open( |
|
1267 | 1268 |
encoding='UTF-8' |
1268 | 1269 |
) as infile: |
1269 | 1270 |
config2 = json.load(infile) |
... | ... |
@@ -1316,7 +1317,7 @@ class TestCLI: |
1316 | 1317 |
input=json.dumps(config), |
1317 | 1318 |
catch_exceptions=False, |
1318 | 1319 |
) |
1319 |
- with cli._config_filename(subsystem='vault').open( |
|
1320 |
+ with cli_helpers.config_filename(subsystem='vault').open( |
|
1320 | 1321 |
encoding='UTF-8' |
1321 | 1322 |
) as infile: |
1322 | 1323 |
config3 = json.load(infile) |
... | ... |
@@ -1403,10 +1404,10 @@ class TestCLI: |
1403 | 1404 |
vault_config={'services': {}}, |
1404 | 1405 |
) |
1405 | 1406 |
) |
1406 |
- cli._config_filename(subsystem='vault').write_text( |
|
1407 |
+ cli_helpers.config_filename(subsystem='vault').write_text( |
|
1407 | 1408 |
'This string is not valid JSON.\n', encoding='UTF-8' |
1408 | 1409 |
) |
1409 |
- dname = cli._config_filename(subsystem=None) |
|
1410 |
+ dname = cli_helpers.config_filename(subsystem=None) |
|
1410 | 1411 |
result_ = runner.invoke( |
1411 | 1412 |
cli.derivepassphrase_vault, |
1412 | 1413 |
['--import', os.fsdecode(dname)], |
... | ... |
@@ -1441,7 +1442,7 @@ class TestCLI: |
1441 | 1442 |
runner=runner, |
1442 | 1443 |
) |
1443 | 1444 |
) |
1444 |
- cli._config_filename(subsystem='vault').unlink(missing_ok=True) |
|
1445 |
+ cli_helpers.config_filename(subsystem='vault').unlink(missing_ok=True) |
|
1445 | 1446 |
result_ = runner.invoke( |
1446 | 1447 |
# Test parent context navigation by not calling |
1447 | 1448 |
# `cli.derivepassphrase_vault` directly. Used e.g. in |
... | ... |
@@ -1514,7 +1515,7 @@ class TestCLI: |
1514 | 1515 |
runner=runner, |
1515 | 1516 |
) |
1516 | 1517 |
) |
1517 |
- config_file = cli._config_filename(subsystem='vault') |
|
1518 |
+ config_file = cli_helpers.config_filename(subsystem='vault') |
|
1518 | 1519 |
config_file.unlink(missing_ok=True) |
1519 | 1520 |
config_file.mkdir(parents=True, exist_ok=True) |
1520 | 1521 |
result_ = runner.invoke( |
... | ... |
@@ -1552,7 +1553,7 @@ class TestCLI: |
1552 | 1553 |
runner=runner, |
1553 | 1554 |
) |
1554 | 1555 |
) |
1555 |
- dname = cli._config_filename(subsystem=None) |
|
1556 |
+ dname = cli_helpers.config_filename(subsystem=None) |
|
1556 | 1557 |
result_ = runner.invoke( |
1557 | 1558 |
cli.derivepassphrase_vault, |
1558 | 1559 |
['--export', os.fsdecode(dname), *export_options], |
... | ... |
@@ -1588,7 +1589,7 @@ class TestCLI: |
1588 | 1589 |
runner=runner, |
1589 | 1590 |
) |
1590 | 1591 |
) |
1591 |
- config_dir = cli._config_filename(subsystem=None) |
|
1592 |
+ config_dir = cli_helpers.config_filename(subsystem=None) |
|
1592 | 1593 |
with contextlib.suppress(FileNotFoundError): |
1593 | 1594 |
shutil.rmtree(config_dir) |
1594 | 1595 |
config_dir.write_text('Obstruction!!\n') |
... | ... |
@@ -1635,7 +1636,7 @@ contents go here |
1635 | 1636 |
) |
1636 | 1637 |
result = tests.ReadableResult.parse(result_) |
1637 | 1638 |
assert result.clean_exit(empty_stderr=True), 'expected clean exit' |
1638 |
- with cli._config_filename(subsystem='vault').open( |
|
1639 |
+ with cli_helpers.config_filename(subsystem='vault').open( |
|
1639 | 1640 |
encoding='UTF-8' |
1640 | 1641 |
) as infile: |
1641 | 1642 |
config = json.load(infile) |
... | ... |
@@ -1669,7 +1670,7 @@ contents go here |
1669 | 1670 |
) |
1670 | 1671 |
result = tests.ReadableResult.parse(result_) |
1671 | 1672 |
assert result.clean_exit(empty_stderr=True), 'expected clean exit' |
1672 |
- with cli._config_filename(subsystem='vault').open( |
|
1673 |
+ with cli_helpers.config_filename(subsystem='vault').open( |
|
1673 | 1674 |
encoding='UTF-8' |
1674 | 1675 |
) as infile: |
1675 | 1676 |
config = json.load(infile) |
... | ... |
@@ -1706,7 +1707,7 @@ contents go here |
1706 | 1707 |
) |
1707 | 1708 |
result = tests.ReadableResult.parse(result_) |
1708 | 1709 |
assert result.clean_exit(empty_stderr=True), 'expected clean exit' |
1709 |
- with cli._config_filename(subsystem='vault').open( |
|
1710 |
+ with cli_helpers.config_filename(subsystem='vault').open( |
|
1710 | 1711 |
encoding='UTF-8' |
1711 | 1712 |
) as infile: |
1712 | 1713 |
config = json.load(infile) |
... | ... |
@@ -1742,7 +1743,7 @@ contents go here |
1742 | 1743 |
assert result.error_exit(error='the user aborted the request'), ( |
1743 | 1744 |
'expected known error message' |
1744 | 1745 |
) |
1745 |
- with cli._config_filename(subsystem='vault').open( |
|
1746 |
+ with cli_helpers.config_filename(subsystem='vault').open( |
|
1746 | 1747 |
encoding='UTF-8' |
1747 | 1748 |
) as infile: |
1748 | 1749 |
config = json.load(infile) |
... | ... |
@@ -1816,7 +1817,7 @@ contents go here |
1816 | 1817 |
) |
1817 | 1818 |
) |
1818 | 1819 |
monkeypatch.setattr( |
1819 |
- cli, '_get_suitable_ssh_keys', tests.suitable_ssh_keys |
|
1820 |
+ cli_helpers, 'get_suitable_ssh_keys', tests.suitable_ssh_keys |
|
1820 | 1821 |
) |
1821 | 1822 |
result_ = runner.invoke( |
1822 | 1823 |
cli.derivepassphrase_vault, |
... | ... |
@@ -1826,7 +1827,7 @@ contents go here |
1826 | 1827 |
) |
1827 | 1828 |
result = tests.ReadableResult.parse(result_) |
1828 | 1829 |
assert result.clean_exit(), 'expected clean exit' |
1829 |
- with cli._config_filename(subsystem='vault').open( |
|
1830 |
+ with cli_helpers.config_filename(subsystem='vault').open( |
|
1830 | 1831 |
encoding='UTF-8' |
1831 | 1832 |
) as infile: |
1832 | 1833 |
config = json.load(infile) |
... | ... |
@@ -1884,7 +1885,7 @@ contents go here |
1884 | 1885 |
) |
1885 | 1886 |
) |
1886 | 1887 |
monkeypatch.setattr( |
1887 |
- cli, '_get_suitable_ssh_keys', tests.suitable_ssh_keys |
|
1888 |
+ cli_helpers, 'get_suitable_ssh_keys', tests.suitable_ssh_keys |
|
1888 | 1889 |
) |
1889 | 1890 |
result_ = runner.invoke( |
1890 | 1891 |
cli.derivepassphrase_vault, |
... | ... |
@@ -1919,7 +1920,7 @@ contents go here |
1919 | 1920 |
def raiser(*_args: Any, **_kwargs: Any) -> None: |
1920 | 1921 |
raise RuntimeError(custom_error) |
1921 | 1922 |
|
1922 |
- monkeypatch.setattr(cli, '_select_ssh_key', raiser) |
|
1923 |
+ monkeypatch.setattr(cli_helpers, 'select_ssh_key', raiser) |
|
1923 | 1924 |
result_ = runner.invoke( |
1924 | 1925 |
cli.derivepassphrase_vault, |
1925 | 1926 |
['--key', '--config'], |
... | ... |
@@ -2009,7 +2010,7 @@ contents go here |
2009 | 2010 |
) |
2010 | 2011 |
) |
2011 | 2012 |
tests.make_file_readonly( |
2012 |
- cli._config_filename(subsystem='vault'), |
|
2013 |
+ cli_helpers.config_filename(subsystem='vault'), |
|
2013 | 2014 |
try_race_free_implementation=try_race_free_implementation, |
2014 | 2015 |
) |
2015 | 2016 |
result_ = runner.invoke( |
... | ... |
@@ -2045,7 +2046,7 @@ contents go here |
2045 | 2046 |
del config |
2046 | 2047 |
raise RuntimeError(custom_error) |
2047 | 2048 |
|
2048 |
- monkeypatch.setattr(cli, '_save_config', raiser) |
|
2049 |
+ monkeypatch.setattr(cli_helpers, 'save_config', raiser) |
|
2049 | 2050 |
result_ = runner.invoke( |
2050 | 2051 |
cli.derivepassphrase_vault, |
2051 | 2052 |
['--config', '--length=15', '--', DUMMY_SERVICE], |
... | ... |
@@ -2267,7 +2268,7 @@ contents go here |
2267 | 2268 |
) |
2268 | 2269 |
) |
2269 | 2270 |
with contextlib.suppress(FileNotFoundError): |
2270 |
- shutil.rmtree(cli._config_filename(subsystem=None)) |
|
2271 |
+ shutil.rmtree(cli_helpers.config_filename(subsystem=None)) |
|
2271 | 2272 |
result_ = runner.invoke( |
2272 | 2273 |
cli.derivepassphrase_vault, |
2273 | 2274 |
['--config', '-p'], |
... | ... |
@@ -2279,7 +2280,7 @@ contents go here |
2279 | 2280 |
assert result.stderr == 'Passphrase:', ( |
2280 | 2281 |
'program unexpectedly failed?!' |
2281 | 2282 |
) |
2282 |
- with cli._config_filename(subsystem='vault').open( |
|
2283 |
+ with cli_helpers.config_filename(subsystem='vault').open( |
|
2283 | 2284 |
encoding='UTF-8' |
2284 | 2285 |
) as infile: |
2285 | 2286 |
config_readback = json.load(infile) |
... | ... |
@@ -2313,17 +2314,17 @@ contents go here |
2313 | 2314 |
runner=runner, |
2314 | 2315 |
) |
2315 | 2316 |
) |
2316 |
- save_config_ = cli._save_config |
|
2317 |
+ save_config_ = cli_helpers.save_config |
|
2317 | 2318 |
|
2318 | 2319 |
def obstruct_config_saving(*args: Any, **kwargs: Any) -> Any: |
2319 |
- config_dir = cli._config_filename(subsystem=None) |
|
2320 |
+ config_dir = cli_helpers.config_filename(subsystem=None) |
|
2320 | 2321 |
with contextlib.suppress(FileNotFoundError): |
2321 | 2322 |
shutil.rmtree(config_dir) |
2322 | 2323 |
config_dir.write_text('Obstruction!!\n') |
2323 |
- monkeypatch.setattr(cli, '_save_config', save_config_) |
|
2324 |
+ monkeypatch.setattr(cli_helpers, 'save_config', save_config_) |
|
2324 | 2325 |
return save_config_(*args, **kwargs) |
2325 | 2326 |
|
2326 |
- monkeypatch.setattr(cli, '_save_config', obstruct_config_saving) |
|
2327 |
+ monkeypatch.setattr(cli_helpers, 'save_config', obstruct_config_saving) |
|
2327 | 2328 |
result_ = runner.invoke( |
2328 | 2329 |
cli.derivepassphrase_vault, |
2329 | 2330 |
['--config', '-p'], |
... | ... |
@@ -2357,7 +2358,7 @@ contents go here |
2357 | 2358 |
del config |
2358 | 2359 |
raise RuntimeError(custom_error) |
2359 | 2360 |
|
2360 |
- monkeypatch.setattr(cli, '_save_config', raiser) |
|
2361 |
+ monkeypatch.setattr(cli_helpers, 'save_config', raiser) |
|
2361 | 2362 |
result_ = runner.invoke( |
2362 | 2363 |
cli.derivepassphrase_vault, |
2363 | 2364 |
['--config', '-p'], |
... | ... |
@@ -2730,7 +2731,7 @@ class TestCLIUtils: |
2730 | 2731 |
self, |
2731 | 2732 |
config: Any, |
2732 | 2733 |
) -> None: |
2733 |
- """`cli._load_config` works for valid configurations.""" |
|
2734 |
+ """[`cli_helpers.load_config`][] works for valid configurations.""" |
|
2734 | 2735 |
runner = click.testing.CliRunner(mix_stderr=False) |
2735 | 2736 |
# TODO(the-13th-letter): Rewrite using parenthesized |
2736 | 2737 |
# with-statements. |
... | ... |
@@ -2744,15 +2745,15 @@ class TestCLIUtils: |
2744 | 2745 |
vault_config=config, |
2745 | 2746 |
) |
2746 | 2747 |
) |
2747 |
- config_filename = cli._config_filename(subsystem='vault') |
|
2748 |
+ config_filename = cli_helpers.config_filename(subsystem='vault') |
|
2748 | 2749 |
with config_filename.open(encoding='UTF-8') as fileobj: |
2749 | 2750 |
assert json.load(fileobj) == config |
2750 |
- assert cli._load_config() == config |
|
2751 |
+ assert cli_helpers.load_config() == config |
|
2751 | 2752 |
|
2752 | 2753 |
def test_110_save_bad_config( |
2753 | 2754 |
self, |
2754 | 2755 |
) -> None: |
2755 |
- """`cli._save_config` fails for bad configurations.""" |
|
2756 |
+ """[`cli_helpers.save_config`][] fails for bad configurations.""" |
|
2756 | 2757 |
runner = click.testing.CliRunner(mix_stderr=False) |
2757 | 2758 |
# TODO(the-13th-letter): Rewrite using parenthesized |
2758 | 2759 |
# with-statements. |
... | ... |
@@ -2769,10 +2770,10 @@ class TestCLIUtils: |
2769 | 2770 |
stack.enter_context( |
2770 | 2771 |
pytest.raises(ValueError, match='Invalid vault config') |
2771 | 2772 |
) |
2772 |
- cli._save_config(None) # type: ignore[arg-type] |
|
2773 |
+ cli_helpers.save_config(None) # type: ignore[arg-type] |
|
2773 | 2774 |
|
2774 | 2775 |
def test_111_prompt_for_selection_multiple(self) -> None: |
2775 |
- """`cli._prompt_for_selection` works in the "multiple" case.""" |
|
2776 |
+ """[`cli_helpers.prompt_for_selection`][] works in the "multiple" case.""" |
|
2776 | 2777 |
|
2777 | 2778 |
@click.command() |
2778 | 2779 |
@click.option('--heading', default='Our menu:') |
... | ... |
@@ -2798,7 +2799,7 @@ class TestCLIUtils: |
2798 | 2799 |
'and a fried egg on top and spam' |
2799 | 2800 |
), |
2800 | 2801 |
] |
2801 |
- index = cli._prompt_for_selection(items, heading=heading) |
|
2802 |
+ index = cli_helpers.prompt_for_selection(items, heading=heading) |
|
2802 | 2803 |
click.echo('A fine choice: ', nl=False) |
2803 | 2804 |
click.echo(items[index]) |
2804 | 2805 |
click.echo('(Note: Vikings strictly optional.)') |
... | ... |
@@ -2849,14 +2850,14 @@ Your selection? (1-10, leave empty to abort):\x20 |
2849 | 2850 |
), 'expected known output' |
2850 | 2851 |
|
2851 | 2852 |
def test_112_prompt_for_selection_single(self) -> None: |
2852 |
- """`cli._prompt_for_selection` works in the "single" case.""" |
|
2853 |
+ """[`cli_helpers.prompt_for_selection`][] works in the "single" case.""" |
|
2853 | 2854 |
|
2854 | 2855 |
@click.command() |
2855 | 2856 |
@click.option('--item', default='baked beans') |
2856 | 2857 |
@click.argument('prompt') |
2857 | 2858 |
def driver(item: str, prompt: str) -> None: |
2858 | 2859 |
try: |
2859 |
- cli._prompt_for_selection( |
|
2860 |
+ cli_helpers.prompt_for_selection( |
|
2860 | 2861 |
[item], heading='', single_choice_prompt=prompt |
2861 | 2862 |
) |
2862 | 2863 |
except IndexError: |
... | ... |
@@ -2898,14 +2899,14 @@ Boo. |
2898 | 2899 |
def test_113_prompt_for_passphrase( |
2899 | 2900 |
self, |
2900 | 2901 |
) -> None: |
2901 |
- """`cli._prompt_for_passphrase` works.""" |
|
2902 |
+ """[`cli_helpers.prompt_for_passphrase`][] works.""" |
|
2902 | 2903 |
with pytest.MonkeyPatch.context() as monkeypatch: |
2903 | 2904 |
monkeypatch.setattr( |
2904 | 2905 |
click, |
2905 | 2906 |
'prompt', |
2906 | 2907 |
lambda *a, **kw: json.dumps({'args': a, 'kwargs': kw}), |
2907 | 2908 |
) |
2908 |
- res = json.loads(cli._prompt_for_passphrase()) |
|
2909 |
+ res = json.loads(cli_helpers.prompt_for_passphrase()) |
|
2909 | 2910 |
err_msg = 'missing arguments to passphrase prompt' |
2910 | 2911 |
assert 'args' in res, err_msg |
2911 | 2912 |
assert 'kwargs' in res, err_msg |
... | ... |
@@ -2926,17 +2927,17 @@ Boo. |
2926 | 2927 |
standard error prefixed with the program name. |
2927 | 2928 |
|
2928 | 2929 |
""" |
2929 |
- prog_name = cli.StandardCLILogging.prog_name |
|
2930 |
- package_name = cli.StandardCLILogging.package_name |
|
2930 |
+ prog_name = cli_machinery.StandardCLILogging.prog_name |
|
2931 |
+ package_name = cli_machinery.StandardCLILogging.package_name |
|
2931 | 2932 |
logger = logging.getLogger(package_name) |
2932 | 2933 |
deprecation_logger = logging.getLogger(f'{package_name}.deprecation') |
2933 |
- logging_cm = cli.StandardCLILogging.ensure_standard_logging() |
|
2934 |
+ logging_cm = cli_machinery.StandardCLILogging.ensure_standard_logging() |
|
2934 | 2935 |
with logging_cm: |
2935 | 2936 |
assert ( |
2936 | 2937 |
sum( |
2937 | 2938 |
1 |
2938 | 2939 |
for h in logger.handlers |
2939 |
- if h is cli.StandardCLILogging.cli_handler |
|
2940 |
+ if h is cli_machinery.StandardCLILogging.cli_handler |
|
2940 | 2941 |
) |
2941 | 2942 |
== 1 |
2942 | 2943 |
) |
... | ... |
@@ -2947,7 +2948,7 @@ Boo. |
2947 | 2948 |
sum( |
2948 | 2949 |
1 |
2949 | 2950 |
for h in logger.handlers |
2950 |
- if h is cli.StandardCLILogging.cli_handler |
|
2951 |
+ if h is cli_machinery.StandardCLILogging.cli_handler |
|
2951 | 2952 |
) |
2952 | 2953 |
== 1 |
2953 | 2954 |
) |
... | ... |
@@ -2963,7 +2964,7 @@ Boo. |
2963 | 2964 |
sum( |
2964 | 2965 |
1 |
2965 | 2966 |
for h in logger.handlers |
2966 |
- if h is cli.StandardCLILogging.cli_handler |
|
2967 |
+ if h is cli_machinery.StandardCLILogging.cli_handler |
|
2967 | 2968 |
) |
2968 | 2969 |
== 1 |
2969 | 2970 |
) |
... | ... |
@@ -2990,7 +2991,7 @@ Boo. |
2990 | 2991 |
actually emits to standard error. |
2991 | 2992 |
|
2992 | 2993 |
""" |
2993 |
- warnings_cm = cli.StandardCLILogging.ensure_standard_warnings_logging() |
|
2994 |
+ warnings_cm = cli_machinery.StandardCLILogging.ensure_standard_warnings_logging() |
|
2994 | 2995 |
THE_FUTURE = 'the future will be here sooner than you think' # noqa: N806 |
2995 | 2996 |
JUST_TESTING = 'just testing whether warnings work' # noqa: N806 |
2996 | 2997 |
with warnings_cm: |
... | ... |
@@ -2998,7 +2999,7 @@ Boo. |
2998 | 2999 |
sum( |
2999 | 3000 |
1 |
3000 | 3001 |
for h in logging.getLogger('py.warnings').handlers |
3001 |
- if h is cli.StandardCLILogging.warnings_handler |
|
3002 |
+ if h is cli_machinery.StandardCLILogging.warnings_handler |
|
3002 | 3003 |
) |
3003 | 3004 |
== 1 |
3004 | 3005 |
) |
... | ... |
@@ -3053,7 +3054,7 @@ Boo. |
3053 | 3054 |
""" |
3054 | 3055 |
prog_name_list = ('derivepassphrase', 'vault') |
3055 | 3056 |
with io.StringIO() as outfile: |
3056 |
- cli._print_config_as_sh_script( |
|
3057 |
+ cli_helpers.print_config_as_sh_script( |
|
3057 | 3058 |
config, outfile=outfile, prog_name_list=prog_name_list |
3058 | 3059 |
) |
3059 | 3060 |
script = outfile.getvalue() |
... | ... |
@@ -3073,7 +3074,7 @@ Boo. |
3073 | 3074 |
for result_ in vault_config_exporter_shell_interpreter(script): |
3074 | 3075 |
result = tests.ReadableResult.parse(result_) |
3075 | 3076 |
assert result.clean_exit() |
3076 |
- assert cli._load_config() == config |
|
3077 |
+ assert cli_helpers.load_config() == config |
|
3077 | 3078 |
|
3078 | 3079 |
@tests.hypothesis_settings_coverage_compatible |
3079 | 3080 |
@hypothesis.given( |
... | ... |
@@ -3345,7 +3346,7 @@ Boo. |
3345 | 3346 |
assert result.clean_exit(empty_stderr=True), ( |
3346 | 3347 |
'expected clean exit' |
3347 | 3348 |
) |
3348 |
- with cli._config_filename(subsystem='vault').open( |
|
3349 |
+ with cli_helpers.config_filename(subsystem='vault').open( |
|
3349 | 3350 |
encoding='UTF-8' |
3350 | 3351 |
) as infile: |
3351 | 3352 |
config_readback = json.load(infile) |
... | ... |
@@ -3363,8 +3364,8 @@ Boo. |
3363 | 3364 |
@pytest.mark.parametrize( |
3364 | 3365 |
['vfunc', 'input'], |
3365 | 3366 |
[ |
3366 |
- (cli._validate_occurrence_constraint, 20), |
|
3367 |
- (cli._validate_length, 20), |
|
3367 |
+ (cli_machinery.validate_occurrence_constraint, 20), |
|
3368 |
+ (cli_machinery.validate_length, 20), |
|
3368 | 3369 |
], |
3369 | 3370 |
) |
3370 | 3371 |
def test_210a_validate_constraints_manually( |
... | ... |
@@ -3383,7 +3384,7 @@ Boo. |
3383 | 3384 |
running_ssh_agent: tests.RunningSSHAgentInfo, |
3384 | 3385 |
conn_hint: str, |
3385 | 3386 |
) -> None: |
3386 |
- """`cli._get_suitable_ssh_keys` works.""" |
|
3387 |
+ """[`cli_helpers.get_suitable_ssh_keys`][] works.""" |
|
3387 | 3388 |
with pytest.MonkeyPatch.context() as monkeypatch: |
3388 | 3389 |
monkeypatch.setenv('SSH_AUTH_SOCK', running_ssh_agent.socket) |
3389 | 3390 |
monkeypatch.setattr( |
... | ... |
@@ -3403,7 +3404,7 @@ Boo. |
3403 | 3404 |
hint = None |
3404 | 3405 |
exception: Exception | None = None |
3405 | 3406 |
try: |
3406 |
- list(cli._get_suitable_ssh_keys(hint)) |
|
3407 |
+ list(cli_helpers.get_suitable_ssh_keys(hint)) |
|
3407 | 3408 |
except RuntimeError: # pragma: no cover |
3408 | 3409 |
pass |
3409 | 3410 |
except Exception as e: # noqa: BLE001 # pragma: no cover |
... | ... |
@@ -3418,7 +3419,7 @@ Boo. |
3418 | 3419 |
skip_if_no_af_unix_support: None, |
3419 | 3420 |
ssh_agent_client_with_test_keys_loaded: ssh_agent.SSHAgentClient, |
3420 | 3421 |
) -> None: |
3421 |
- """All errors in `cli._key_to_phrase` are handled.""" |
|
3422 |
+ """All errors in [`cli_helpers.key_to_phrase`][] are handled.""" |
|
3422 | 3423 |
|
3423 | 3424 |
class ErrCallback(BaseException): |
3424 | 3425 |
def __init__(self, *args: Any, **kwargs: Any) -> None: |
... | ... |
@@ -3454,19 +3455,19 @@ Boo. |
3454 | 3455 |
with pytest.raises( |
3455 | 3456 |
ErrCallback, match='not loaded into the agent' |
3456 | 3457 |
): |
3457 |
- cli._key_to_phrase(loaded_key, error_callback=err) |
|
3458 |
+ cli_helpers.key_to_phrase(loaded_key, error_callback=err) |
|
3458 | 3459 |
with monkeypatch.context() as mp: |
3459 | 3460 |
mp.setattr(ssh_agent.SSHAgentClient, 'list_keys', fail) |
3460 | 3461 |
with pytest.raises( |
3461 | 3462 |
ErrCallback, match='SSH agent failed to or refused to' |
3462 | 3463 |
): |
3463 |
- cli._key_to_phrase(loaded_key, error_callback=err) |
|
3464 |
+ cli_helpers.key_to_phrase(loaded_key, error_callback=err) |
|
3464 | 3465 |
with monkeypatch.context() as mp: |
3465 | 3466 |
mp.setattr(ssh_agent.SSHAgentClient, 'list_keys', fail_runtime) |
3466 | 3467 |
with pytest.raises( |
3467 | 3468 |
ErrCallback, match='SSH agent failed to or refused to' |
3468 | 3469 |
) as excinfo: |
3469 |
- cli._key_to_phrase(loaded_key, error_callback=err) |
|
3470 |
+ cli_helpers.key_to_phrase(loaded_key, error_callback=err) |
|
3470 | 3471 |
assert excinfo.value.kwargs |
3471 | 3472 |
assert isinstance( |
3472 | 3473 |
excinfo.value.kwargs['exc_info'], |
... | ... |
@@ -3482,25 +3483,25 @@ Boo. |
3482 | 3483 |
with pytest.raises( |
3483 | 3484 |
ErrCallback, match='Cannot find any running SSH agent' |
3484 | 3485 |
): |
3485 |
- cli._key_to_phrase(loaded_key, error_callback=err) |
|
3486 |
+ cli_helpers.key_to_phrase(loaded_key, error_callback=err) |
|
3486 | 3487 |
with monkeypatch.context() as mp: |
3487 | 3488 |
mp.setenv('SSH_AUTH_SOCK', os.environ['SSH_AUTH_SOCK'] + '~') |
3488 | 3489 |
with pytest.raises( |
3489 | 3490 |
ErrCallback, match='Cannot connect to the SSH agent' |
3490 | 3491 |
): |
3491 |
- cli._key_to_phrase(loaded_key, error_callback=err) |
|
3492 |
+ cli_helpers.key_to_phrase(loaded_key, error_callback=err) |
|
3492 | 3493 |
with monkeypatch.context() as mp: |
3493 | 3494 |
mp.delattr(socket, 'AF_UNIX', raising=True) |
3494 | 3495 |
with pytest.raises( |
3495 | 3496 |
ErrCallback, match='does not support UNIX domain sockets' |
3496 | 3497 |
): |
3497 |
- cli._key_to_phrase(loaded_key, error_callback=err) |
|
3498 |
+ cli_helpers.key_to_phrase(loaded_key, error_callback=err) |
|
3498 | 3499 |
with monkeypatch.context() as mp: |
3499 | 3500 |
mp.setattr(ssh_agent.SSHAgentClient, 'sign', fail_runtime) |
3500 | 3501 |
with pytest.raises( |
3501 | 3502 |
ErrCallback, match='violates the communications protocol' |
3502 | 3503 |
): |
3503 |
- cli._key_to_phrase(loaded_key, error_callback=err) |
|
3504 |
+ cli_helpers.key_to_phrase(loaded_key, error_callback=err) |
|
3504 | 3505 |
|
3505 | 3506 |
|
3506 | 3507 |
# TODO(the-13th-letter): Remove this class in v1.0. |
... | ... |
@@ -3544,10 +3545,10 @@ class TestCLITransition: |
3544 | 3545 |
runner=runner, |
3545 | 3546 |
) |
3546 | 3547 |
) |
3547 |
- cli._config_filename(subsystem='old settings.json').write_text( |
|
3548 |
+ cli_helpers.config_filename(subsystem='old settings.json').write_text( |
|
3548 | 3549 |
json.dumps(config, indent=2) + '\n', encoding='UTF-8' |
3549 | 3550 |
) |
3550 |
- assert cli._migrate_and_load_old_config()[0] == config |
|
3551 |
+ assert cli_helpers.migrate_and_load_old_config()[0] == config |
|
3551 | 3552 |
|
3552 | 3553 |
@pytest.mark.parametrize( |
3553 | 3554 |
'config', |
... | ... |
@@ -3585,10 +3586,10 @@ class TestCLITransition: |
3585 | 3586 |
runner=runner, |
3586 | 3587 |
) |
3587 | 3588 |
) |
3588 |
- cli._config_filename(subsystem='old settings.json').write_text( |
|
3589 |
+ cli_helpers.config_filename(subsystem='old settings.json').write_text( |
|
3589 | 3590 |
json.dumps(config, indent=2) + '\n', encoding='UTF-8' |
3590 | 3591 |
) |
3591 |
- assert cli._migrate_and_load_old_config() == (config, None) |
|
3592 |
+ assert cli_helpers.migrate_and_load_old_config() == (config, None) |
|
3592 | 3593 |
|
3593 | 3594 |
@pytest.mark.parametrize( |
3594 | 3595 |
'config', |
... | ... |
@@ -3626,13 +3627,13 @@ class TestCLITransition: |
3626 | 3627 |
runner=runner, |
3627 | 3628 |
) |
3628 | 3629 |
) |
3629 |
- cli._config_filename(subsystem='old settings.json').write_text( |
|
3630 |
+ cli_helpers.config_filename(subsystem='old settings.json').write_text( |
|
3630 | 3631 |
json.dumps(config, indent=2) + '\n', encoding='UTF-8' |
3631 | 3632 |
) |
3632 |
- cli._config_filename(subsystem='vault').mkdir( |
|
3633 |
+ cli_helpers.config_filename(subsystem='vault').mkdir( |
|
3633 | 3634 |
parents=True, exist_ok=True |
3634 | 3635 |
) |
3635 |
- config2, err = cli._migrate_and_load_old_config() |
|
3636 |
+ config2, err = cli_helpers.migrate_and_load_old_config() |
|
3636 | 3637 |
assert config2 == config |
3637 | 3638 |
assert isinstance(err, OSError) |
3638 | 3639 |
assert err.errno == errno.EISDIR |
... | ... |
@@ -3673,11 +3674,11 @@ class TestCLITransition: |
3673 | 3674 |
runner=runner, |
3674 | 3675 |
) |
3675 | 3676 |
) |
3676 |
- cli._config_filename(subsystem='old settings.json').write_text( |
|
3677 |
+ cli_helpers.config_filename(subsystem='old settings.json').write_text( |
|
3677 | 3678 |
json.dumps(config, indent=2) + '\n', encoding='UTF-8' |
3678 | 3679 |
) |
3679 |
- with pytest.raises(ValueError, match=cli._INVALID_VAULT_CONFIG): |
|
3680 |
- cli._migrate_and_load_old_config() |
|
3680 |
+ with pytest.raises(ValueError, match=cli_helpers.INVALID_VAULT_CONFIG): |
|
3681 |
+ cli_helpers.migrate_and_load_old_config() |
|
3681 | 3682 |
|
3682 | 3683 |
def test_200_forward_export_vault_path_parameter( |
3683 | 3684 |
self, |
... | ... |
@@ -3771,7 +3772,7 @@ class TestCLITransition: |
3771 | 3772 |
) |
3772 | 3773 |
) |
3773 | 3774 |
monkeypatch.setattr( |
3774 |
- cli, '_prompt_for_passphrase', tests.auto_prompt |
|
3775 |
+ cli_helpers, 'prompt_for_passphrase', tests.auto_prompt |
|
3775 | 3776 |
) |
3776 | 3777 |
result_ = runner.invoke( |
3777 | 3778 |
cli.derivepassphrase, |
... | ... |
@@ -3844,7 +3845,7 @@ class TestCLITransition: |
3844 | 3845 |
runner=runner, |
3845 | 3846 |
) |
3846 | 3847 |
) |
3847 |
- cli._config_filename(subsystem='old settings.json').write_text( |
|
3848 |
+ cli_helpers.config_filename(subsystem='old settings.json').write_text( |
|
3848 | 3849 |
json.dumps( |
3849 | 3850 |
{'services': {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS}}, |
3850 | 3851 |
indent=2, |
... | ... |
@@ -3883,7 +3884,7 @@ class TestCLITransition: |
3883 | 3884 |
runner=runner, |
3884 | 3885 |
) |
3885 | 3886 |
) |
3886 |
- cli._config_filename(subsystem='old settings.json').write_text( |
|
3887 |
+ cli_helpers.config_filename(subsystem='old settings.json').write_text( |
|
3887 | 3888 |
json.dumps( |
3888 | 3889 |
{'services': {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS}}, |
3889 | 3890 |
indent=2, |
... | ... |
@@ -3896,7 +3897,7 @@ class TestCLITransition: |
3896 | 3897 |
raise OSError( |
3897 | 3898 |
errno.EACCES, |
3898 | 3899 |
os.strerror(errno.EACCES), |
3899 |
- cli._config_filename(subsystem='vault'), |
|
3900 |
+ cli_helpers.config_filename(subsystem='vault'), |
|
3900 | 3901 |
) |
3901 | 3902 |
|
3902 | 3903 |
monkeypatch.setattr(os, 'replace', raiser) |
... | ... |
@@ -3933,11 +3934,11 @@ class TestCLITransition: |
3933 | 3934 |
vault_config=config, |
3934 | 3935 |
) |
3935 | 3936 |
) |
3936 |
- old_name = cli._config_filename(subsystem='old settings.json') |
|
3937 |
- new_name = cli._config_filename(subsystem='vault') |
|
3937 |
+ old_name = cli_helpers.config_filename(subsystem='old settings.json') |
|
3938 |
+ new_name = cli_helpers.config_filename(subsystem='vault') |
|
3938 | 3939 |
old_name.unlink(missing_ok=True) |
3939 | 3940 |
new_name.rename(old_name) |
3940 |
- assert cli._shell_complete_service( |
|
3941 |
+ assert cli_helpers.shell_complete_service( |
|
3941 | 3942 |
click.Context(cli.derivepassphrase), |
3942 | 3943 |
click.Argument(['some_parameter']), |
3943 | 3944 |
'', |
... | ... |
@@ -4182,7 +4183,7 @@ class ConfigManagementStateMachine(stateful.RuleBasedStateMachine): |
4182 | 4183 |
The amended configuration. |
4183 | 4184 |
|
4184 | 4185 |
""" |
4185 |
- cli._save_config(config) |
|
4186 |
+ cli_helpers.save_config(config) |
|
4186 | 4187 |
config_global = config.get('global', {}) |
4187 | 4188 |
maybe_unset = set(maybe_unset) - setting.keys() |
4188 | 4189 |
if overwrite: |
... | ... |
@@ -4211,7 +4212,7 @@ class ConfigManagementStateMachine(stateful.RuleBasedStateMachine): |
4211 | 4212 |
) |
4212 | 4213 |
result = tests.ReadableResult.parse(result_) |
4213 | 4214 |
assert result.clean_exit(empty_stderr=False) |
4214 |
- assert cli._load_config() == config |
|
4215 |
+ assert cli_helpers.load_config() == config |
|
4215 | 4216 |
return config |
4216 | 4217 |
|
4217 | 4218 |
@stateful.rule( |
... | ... |
@@ -4255,7 +4256,7 @@ class ConfigManagementStateMachine(stateful.RuleBasedStateMachine): |
4255 | 4256 |
The amended configuration. |
4256 | 4257 |
|
4257 | 4258 |
""" |
4258 |
- cli._save_config(config) |
|
4259 |
+ cli_helpers.save_config(config) |
|
4259 | 4260 |
config_service = config['services'].get(service, {}) |
4260 | 4261 |
maybe_unset = set(maybe_unset) - setting.keys() |
4261 | 4262 |
if overwrite: |
... | ... |
@@ -4285,7 +4286,7 @@ class ConfigManagementStateMachine(stateful.RuleBasedStateMachine): |
4285 | 4286 |
) |
4286 | 4287 |
result = tests.ReadableResult.parse(result_) |
4287 | 4288 |
assert result.clean_exit(empty_stderr=False) |
4288 |
- assert cli._load_config() == config |
|
4289 |
+ assert cli_helpers.load_config() == config |
|
4289 | 4290 |
return config |
4290 | 4291 |
|
4291 | 4292 |
@stateful.rule( |
... | ... |
@@ -4306,7 +4307,7 @@ class ConfigManagementStateMachine(stateful.RuleBasedStateMachine): |
4306 | 4307 |
The pruned configuration. |
4307 | 4308 |
|
4308 | 4309 |
""" |
4309 |
- cli._save_config(config) |
|
4310 |
+ cli_helpers.save_config(config) |
|
4310 | 4311 |
config.pop('global', None) |
4311 | 4312 |
result_ = self.runner.invoke( |
4312 | 4313 |
cli.derivepassphrase_vault, |
... | ... |
@@ -4316,7 +4317,7 @@ class ConfigManagementStateMachine(stateful.RuleBasedStateMachine): |
4316 | 4317 |
) |
4317 | 4318 |
result = tests.ReadableResult.parse(result_) |
4318 | 4319 |
assert result.clean_exit(empty_stderr=False) |
4319 |
- assert cli._load_config() == config |
|
4320 |
+ assert cli_helpers.load_config() == config |
|
4320 | 4321 |
return config |
4321 | 4322 |
|
4322 | 4323 |
@stateful.rule( |
... | ... |
@@ -4346,7 +4347,7 @@ class ConfigManagementStateMachine(stateful.RuleBasedStateMachine): |
4346 | 4347 |
|
4347 | 4348 |
""" |
4348 | 4349 |
config, service = config_and_service |
4349 |
- cli._save_config(config) |
|
4350 |
+ cli_helpers.save_config(config) |
|
4350 | 4351 |
config['services'].pop(service, None) |
4351 | 4352 |
result_ = self.runner.invoke( |
4352 | 4353 |
cli.derivepassphrase_vault, |
... | ... |
@@ -4356,7 +4357,7 @@ class ConfigManagementStateMachine(stateful.RuleBasedStateMachine): |
4356 | 4357 |
) |
4357 | 4358 |
result = tests.ReadableResult.parse(result_) |
4358 | 4359 |
assert result.clean_exit(empty_stderr=False) |
4359 |
- assert cli._load_config() == config |
|
4360 |
+ assert cli_helpers.load_config() == config |
|
4360 | 4361 |
return config |
4361 | 4362 |
|
4362 | 4363 |
@stateful.rule( |
... | ... |
@@ -4377,7 +4378,7 @@ class ConfigManagementStateMachine(stateful.RuleBasedStateMachine): |
4377 | 4378 |
The empty configuration. |
4378 | 4379 |
|
4379 | 4380 |
""" |
4380 |
- cli._save_config(config) |
|
4381 |
+ cli_helpers.save_config(config) |
|
4381 | 4382 |
config = {'services': {}} |
4382 | 4383 |
result_ = self.runner.invoke( |
4383 | 4384 |
cli.derivepassphrase_vault, |
... | ... |
@@ -4387,7 +4388,7 @@ class ConfigManagementStateMachine(stateful.RuleBasedStateMachine): |
4387 | 4388 |
) |
4388 | 4389 |
result = tests.ReadableResult.parse(result_) |
4389 | 4390 |
assert result.clean_exit(empty_stderr=False) |
4390 |
- assert cli._load_config() == config |
|
4391 |
+ assert cli_helpers.load_config() == config |
|
4391 | 4392 |
return config |
4392 | 4393 |
|
4393 | 4394 |
@stateful.rule( |
... | ... |
@@ -4418,7 +4419,7 @@ class ConfigManagementStateMachine(stateful.RuleBasedStateMachine): |
4418 | 4419 |
The imported or merged configuration. |
4419 | 4420 |
|
4420 | 4421 |
""" |
4421 |
- cli._save_config(base_config) |
|
4422 |
+ cli_helpers.save_config(base_config) |
|
4422 | 4423 |
config = ( |
4423 | 4424 |
self.fold_configs(config_to_import, base_config) |
4424 | 4425 |
if not overwrite |
... | ... |
@@ -4435,7 +4436,7 @@ class ConfigManagementStateMachine(stateful.RuleBasedStateMachine): |
4435 | 4436 |
assert tests.ReadableResult.parse(result_).clean_exit( |
4436 | 4437 |
empty_stderr=False |
4437 | 4438 |
) |
4438 |
- assert cli._load_config() == config |
|
4439 |
+ assert cli_helpers.load_config() == config |
|
4439 | 4440 |
return config |
4440 | 4441 |
|
4441 | 4442 |
def teardown(self) -> None: |
... | ... |
@@ -4481,9 +4482,10 @@ def zsh_format(item: click.shell_completion.CompletionItem) -> str: |
4481 | 4482 |
dictated by [`click`][]. Upstream `click` currently (v8.2.0) does |
4482 | 4483 |
not deal with colons in the value correctly when the help text is |
4483 | 4484 |
non-degenerate. Our formatter here does, provided the upstream |
4484 |
- `zsh` completion script is used; see the [`cli.ZshComplete`][] |
|
4485 |
- class. A request is underway to merge this change into upstream |
|
4486 |
- `click`; see [`pallets/click#2846`][PR2846]. |
|
4485 |
+ `zsh` completion script is used; see the |
|
4486 |
+ [`cli_machinery.ZshComplete`][] class. A request is underway to |
|
4487 |
+ merge this change into upstream `click`; see |
|
4488 |
+ [`pallets/click#2846`][PR2846]. |
|
4487 | 4489 |
|
4488 | 4490 |
[PR2846]: https://github.com/pallets/click/pull/2846 |
4489 | 4491 |
|
... | ... |
@@ -4579,7 +4581,7 @@ class TestShellCompletion: |
4579 | 4581 |
is_completable: bool, |
4580 | 4582 |
) -> None: |
4581 | 4583 |
"""Our `_is_completable_item` predicate for service names works.""" |
4582 |
- assert cli._is_completable_item(partial) == is_completable |
|
4584 |
+ assert cli_helpers.is_completable_item(partial) == is_completable |
|
4583 | 4585 |
|
4584 | 4586 |
@pytest.mark.parametrize( |
4585 | 4587 |
['command_prefix', 'incomplete', 'completions'], |
... | ... |
@@ -4820,7 +4822,7 @@ class TestShellCompletion: |
4820 | 4822 |
[ |
4821 | 4823 |
pytest.param( |
4822 | 4824 |
{'services': {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()}}, |
4823 |
- cli._shell_complete_service, |
|
4825 |
+ cli_helpers.shell_complete_service, |
|
4824 | 4826 |
['vault'], |
4825 | 4827 |
'', |
4826 | 4828 |
[DUMMY_SERVICE], |
... | ... |
@@ -4828,7 +4830,7 @@ class TestShellCompletion: |
4828 | 4830 |
), |
4829 | 4831 |
pytest.param( |
4830 | 4832 |
{'services': {}}, |
4831 |
- cli._shell_complete_service, |
|
4833 |
+ cli_helpers.shell_complete_service, |
|
4832 | 4834 |
['vault'], |
4833 | 4835 |
'', |
4834 | 4836 |
[], |
... | ... |
@@ -4841,7 +4843,7 @@ class TestShellCompletion: |
4841 | 4843 |
'newline\nin\nname': DUMMY_CONFIG_SETTINGS.copy(), |
4842 | 4844 |
} |
4843 | 4845 |
}, |
4844 |
- cli._shell_complete_service, |
|
4846 |
+ cli_helpers.shell_complete_service, |
|
4845 | 4847 |
['vault'], |
4846 | 4848 |
'', |
4847 | 4849 |
[DUMMY_SERVICE], |
... | ... |
@@ -4854,7 +4856,7 @@ class TestShellCompletion: |
4854 | 4856 |
'backspace\bin\bname': DUMMY_CONFIG_SETTINGS.copy(), |
4855 | 4857 |
} |
4856 | 4858 |
}, |
4857 |
- cli._shell_complete_service, |
|
4859 |
+ cli_helpers.shell_complete_service, |
|
4858 | 4860 |
['vault'], |
4859 | 4861 |
'', |
4860 | 4862 |
[DUMMY_SERVICE], |
... | ... |
@@ -4867,7 +4869,7 @@ class TestShellCompletion: |
4867 | 4869 |
'colon:in:name': DUMMY_CONFIG_SETTINGS.copy(), |
4868 | 4870 |
} |
4869 | 4871 |
}, |
4870 |
- cli._shell_complete_service, |
|
4872 |
+ cli_helpers.shell_complete_service, |
|
4871 | 4873 |
['vault'], |
4872 | 4874 |
'', |
4873 | 4875 |
sorted([DUMMY_SERVICE, 'colon:in:name']), |
... | ... |
@@ -4884,7 +4886,7 @@ class TestShellCompletion: |
4884 | 4886 |
'del\x7fin\x7fname': DUMMY_CONFIG_SETTINGS.copy(), |
4885 | 4887 |
} |
4886 | 4888 |
}, |
4887 |
- cli._shell_complete_service, |
|
4889 |
+ cli_helpers.shell_complete_service, |
|
4888 | 4890 |
['vault'], |
4889 | 4891 |
'', |
4890 | 4892 |
sorted([DUMMY_SERVICE, 'colon:in:name']), |
... | ... |
@@ -4892,7 +4894,7 @@ class TestShellCompletion: |
4892 | 4894 |
), |
4893 | 4895 |
pytest.param( |
4894 | 4896 |
{'services': {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()}}, |
4895 |
- cli._shell_complete_path, |
|
4897 |
+ cli_helpers.shell_complete_path, |
|
4896 | 4898 |
['vault', '--import'], |
4897 | 4899 |
'', |
4898 | 4900 |
[click.shell_completion.CompletionItem('', type='file')], |
... | ... |
@@ -4900,7 +4902,7 @@ class TestShellCompletion: |
4900 | 4902 |
), |
4901 | 4903 |
pytest.param( |
4902 | 4904 |
{'services': {}}, |
4903 |
- cli._shell_complete_path, |
|
4905 |
+ cli_helpers.shell_complete_path, |
|
4904 | 4906 |
['vault', '--import'], |
4905 | 4907 |
'', |
4906 | 4908 |
[click.shell_completion.CompletionItem('', type='file')], |
... | ... |
@@ -5166,7 +5168,7 @@ class TestShellCompletion: |
5166 | 5168 |
assert tests.warning_emitted( |
5167 | 5169 |
'not be available for completion', caplog.record_tuples |
5168 | 5170 |
), 'expected known warning message in stderr' |
5169 |
- assert cli._load_config() == config |
|
5171 |
+ assert cli_helpers.load_config() == config |
|
5170 | 5172 |
comp = self.Completions(['vault'], incomplete) |
5171 | 5173 |
assert frozenset(comp.get_words()) == completions |
5172 | 5174 |
|
... | ... |
@@ -5189,8 +5191,8 @@ class TestShellCompletion: |
5189 | 5191 |
}, |
5190 | 5192 |
) |
5191 | 5193 |
) |
5192 |
- cli._config_filename(subsystem='vault').unlink(missing_ok=True) |
|
5193 |
- assert not cli._shell_complete_service( |
|
5194 |
+ cli_helpers.config_filename(subsystem='vault').unlink(missing_ok=True) |
|
5195 |
+ assert not cli_helpers.shell_complete_service( |
|
5194 | 5196 |
click.Context(cli.derivepassphrase), |
5195 | 5197 |
click.Argument(['some_parameter']), |
5196 | 5198 |
'', |
... | ... |
@@ -5221,8 +5223,8 @@ class TestShellCompletion: |
5221 | 5223 |
def raiser(*_a: Any, **_kw: Any) -> NoReturn: |
5222 | 5224 |
raise exc_type('just being difficult') # noqa: EM101,TRY003 |
5223 | 5225 |
|
5224 |
- monkeypatch.setattr(cli, '_load_config', raiser) |
|
5225 |
- assert not cli._shell_complete_service( |
|
5226 |
+ monkeypatch.setattr(cli_helpers, 'load_config', raiser) |
|
5227 |
+ assert not cli_helpers.shell_complete_service( |
|
5226 | 5228 |
click.Context(cli.derivepassphrase), |
5227 | 5229 |
click.Argument(['some_parameter']), |
5228 | 5230 |
'', |
... | ... |
@@ -20,7 +20,8 @@ import pytest |
20 | 20 |
from hypothesis import strategies |
21 | 21 |
|
22 | 22 |
import tests |
23 |
-from derivepassphrase import _types, cli, ssh_agent, vault |
|
23 |
+from derivepassphrase import _types, ssh_agent, vault |
|
24 |
+from derivepassphrase._internals import cli_helpers, cli_machinery |
|
24 | 25 |
|
25 | 26 |
if TYPE_CHECKING: |
26 | 27 |
from collections.abc import Iterable |
... | ... |
@@ -576,8 +577,8 @@ class TestAgentInteraction: |
576 | 577 |
|
577 | 578 |
@click.command() |
578 | 579 |
def driver() -> None: |
579 |
- """Call `cli._select_ssh_key` directly, as a command.""" |
|
580 |
- key = cli._select_ssh_key() |
|
580 |
+ """Call [`cli_helpers.select_ssh_key`][] directly, as a command.""" |
|
581 |
+ key = cli_helpers.select_ssh_key() |
|
581 | 582 |
click.echo(base64.standard_b64encode(key).decode('ASCII')) |
582 | 583 |
|
583 | 584 |
# TODO(the-13th-letter): (Continued from above.) Update input |
584 | 585 |