Split off cli_helpers and cli_machinery internal modules
Marco Ricci

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.
... ...
@@ -0,0 +1,3 @@
1
+::: derivepassphrase._internals.cli_helpers
2
+    options:
3
+      heading_level: 1
... ...
@@ -0,0 +1,3 @@
1
+::: derivepassphrase._internals.cli_machinery
2
+    options:
3
+      heading_level: 1
... ...
@@ -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