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 |