Use the logging system to emit warnings and error messages
Marco Ricci

Marco Ricci commited on 2024-12-07 09:30:38
Zeige 1 geänderte Dateien mit 521 Einfügungen und 118 Löschungen.


Employ the Python standard library module `logging` to issue warning
and error messages, as well as (partially new) debug messages.
Operational warning messages and operational deprecation messages are
handled via the `derivepassphrase` and `derivepassphrase.deprecation`
loggers with a custom handler that prints the resulting formatted
message to standard error.  (Errors, info and debug messages use the
same system, but differ slightly in the formatting.)

Most of the rewriting of warnings and error messages to fit this new
machinery is pretty straight-forward.  The messy parts are the
integration of the `logging` machinery and the `warnings` machinery into
`click`, which isn't really designed with this in mind.  Concretely,
this entails writing a custom logging handler that uses `click.echo`,
a custom formatter that prefixes messages with the program name (and
"warning", "deprecation warning" or "debug" as necessary), a singleton
configuration of this handler and formatter, various wrappers around
standard `click` infrastructure to ensure that this integration code
actually runs during normal operation, and finally, new command-line
options to control the level of output on standard error.
... ...
@@ -2,6 +2,8 @@
2 2
 #
3 3
 # SPDX-License-Identifier: MIT
4 4
 
5
+# ruff: noqa: TRY400
6
+
5 7
 """Command-line interface for derivepassphrase."""
6 8
 
7 9
 from __future__ import annotations
... ...
@@ -16,17 +18,22 @@ import json
16 18
 import logging
17 19
 import os
18 20
 import unicodedata
21
+import warnings
19 22
 from typing import (
20 23
     TYPE_CHECKING,
24
+    Callable,
21 25
     Literal,
22 26
     NoReturn,
23 27
     TextIO,
28
+    TypeVar,
24 29
     cast,
25 30
 )
26 31
 
27 32
 import click
28 33
 from typing_extensions import (
29 34
     Any,
35
+    ParamSpec,
36
+    Self,
30 37
     assert_never,
31 38
 )
32 39
 
... ...
@@ -39,6 +46,7 @@ if TYPE_CHECKING:
39 46
     import types
40 47
     from collections.abc import (
41 48
         Iterator,
49
+        MutableSequence,
42 50
         Sequence,
43 51
     )
44 52
 
... ...
@@ -57,6 +65,333 @@ _NO_USABLE_KEYS = 'No usable SSH keys were found'
57 65
 _EMPTY_SELECTION = 'Empty selection'
58 66
 
59 67
 
68
+# Logging
69
+# =======
70
+
71
+
72
+class ClickEchoStderrHandler(logging.Handler):
73
+    """A [`logging.Handler`][] for `click` applications.
74
+
75
+    Outputs log messages to [`sys.stderr`][] via [`click.echo`][].
76
+
77
+    """
78
+
79
+    def emit(self, record: logging.LogRecord) -> None:
80
+        """Emit a log record.
81
+
82
+        Format the log record, then emit it via [`click.echo`][] to
83
+        [`sys.stderr`][].
84
+
85
+        """
86
+        click.echo(self.format(record), err=True)
87
+
88
+
89
+class CLIofPackageFormatter(logging.Formatter):
90
+    """A [`logging.LogRecord`][] formatter for the CLI of a Python package.
91
+
92
+    Assuming a package `PKG` and loggers within the same hierarchy
93
+    `PKG`, format all log records from that hierarchy for proper user
94
+    feedback on the console.  Intended for use with [`click`][CLICK] and
95
+    when `PKG` provides a command-line tool `PKG` and when logs from
96
+    that package should show up as output of the command-line tool.
97
+
98
+    Essentially, this prepends certain short strings to the log message
99
+    lines to make them readable as standard error output.
100
+
101
+    Because this log output is intended to be displayed on standard
102
+    error as high-level diagnostic output, you are strongly discouraged
103
+    from changing the output format to include more tokens besides the
104
+    log message.  Use a dedicated log file handler instead, without this
105
+    formatter.
106
+
107
+    [CLICK]: https://pypi.org/projects/click/
108
+
109
+    """
110
+
111
+    def __init__(
112
+        self,
113
+        *,
114
+        prog_name: str = PROG_NAME,
115
+        package_name: str | None = None,
116
+    ) -> None:
117
+        self.prog_name = prog_name
118
+        self.package_name = (
119
+            package_name
120
+            if package_name is not None
121
+            else prog_name.lower().replace(' ', '_').replace('-', '_')
122
+        )
123
+
124
+    def format(self, record: logging.LogRecord) -> str:
125
+        """Format a log record suitably for standard error console output.
126
+
127
+        Prepend the formatted string `"PROG_NAME: LABEL"` to each line
128
+        of the message, where `PROG_NAME` is the program name, and
129
+        `LABEL` depends on the record's level and on the logger name as
130
+        follows:
131
+
132
+          * For records at level [`logging.DEBUG`][], `LABEL` is
133
+            `"Debug: "`.
134
+          * For records at level [`logging.INFO`][], `LABEL` is the
135
+            empty string.
136
+          * For records at level [`logging.WARNING`][], `LABEL` is
137
+            `"Deprecation warning: "` if the logger is named
138
+            `PKG.deprecation` (where `PKG` is the package name), else
139
+            `"Warning: "`.
140
+          * For records at level [`logging.ERROR`][] and
141
+            [`logging.CRITICAL`][] `"Error: "`, `LABEL` is `"ERROR: "`.
142
+
143
+        The level indication strings at level `WARNING` or above are
144
+        highlighted.  Use [`click.echo`][] to output them and remove
145
+        color output if necessary.
146
+
147
+        Args:
148
+            record: A log record.
149
+
150
+        Returns:
151
+            A formatted log record.
152
+
153
+        Raises:
154
+            AssertionError:
155
+                The log level is not supported.
156
+
157
+        """
158
+        preliminary_result = record.getMessage()
159
+        prefix = f'{self.prog_name}: '
160
+        if record.levelname == 'DEBUG':  # pragma: no cover
161
+            level_indicator = 'Debug: '
162
+        elif record.levelname == 'INFO':
163
+            level_indicator = ''
164
+        elif record.levelname == 'WARNING':
165
+            level_indicator = (
166
+                f'{click.style("Deprecation warning", bold=True)}: '
167
+                if record.name.endswith('.deprecation')
168
+                else f'{click.style("Warning", bold=True)}: '
169
+            )
170
+        elif record.levelname in {'ERROR', 'CRITICAL'}:
171
+            level_indicator = ''
172
+        else:  # pragma: no cover
173
+            msg = f'Unsupported logging level: {record.levelname}'
174
+            raise AssertionError(msg)
175
+        return ''.join(
176
+            prefix + level_indicator + line
177
+            for line in preliminary_result.splitlines(True)  # noqa: FBT003
178
+        )
179
+
180
+
181
+class StandardCLILogging:
182
+    """Set up CLI logging handlers upon instantiation."""
183
+
184
+    prog_name = PROG_NAME
185
+    package_name = PROG_NAME.lower().replace(' ', '_').replace('-', '_')
186
+    cli_formatter = CLIofPackageFormatter(
187
+        prog_name=prog_name, package_name=package_name
188
+    )
189
+    cli_handler = ClickEchoStderrHandler()
190
+    cli_handler.addFilter(logging.Filter(name=package_name))
191
+    cli_handler.setFormatter(cli_formatter)
192
+    cli_handler.setLevel(logging.WARNING)
193
+    warnings_handler = ClickEchoStderrHandler()
194
+    warnings_handler.addFilter(logging.Filter(name='py.warnings'))
195
+    warnings_handler.setFormatter(cli_formatter)
196
+    warnings_handler.setLevel(logging.WARNING)
197
+
198
+    @classmethod
199
+    def ensure_standard_logging(cls) -> StandardLoggingContextManager:
200
+        """Return a context manager to ensure standard logging is set up."""
201
+        return StandardLoggingContextManager(
202
+            handler=cls.cli_handler,
203
+            root_logger=cls.package_name,
204
+        )
205
+
206
+    @classmethod
207
+    def ensure_standard_warnings_logging(
208
+        cls,
209
+    ) -> StandardWarningsLoggingContextManager:
210
+        """Return a context manager to ensure warnings logging is set up."""
211
+        return StandardWarningsLoggingContextManager(
212
+            handler=cls.warnings_handler,
213
+        )
214
+
215
+
216
+class StandardLoggingContextManager:
217
+    """A reentrant context manager setting up standard CLI logging.
218
+
219
+    Ensures that the given handler (defaulting to the CLI logging
220
+    handler) is added to the named logger (defaulting to the root
221
+    logger), and if it had to be added, then that it will be removed
222
+    upon exiting the context.
223
+
224
+    Reentrant, but not thread safe, because it temporarily modifies
225
+    global state.
226
+
227
+    """
228
+
229
+    def __init__(
230
+        self,
231
+        handler: logging.Handler,
232
+        root_logger: str | None = None,
233
+    ) -> None:
234
+        self.handler = handler
235
+        self.root_logger_name = root_logger
236
+        self.base_logger = logging.getLogger(self.root_logger_name)
237
+        self.action_required: MutableSequence[bool] = collections.deque()
238
+
239
+    def __enter__(self) -> Self:
240
+        self.action_required.append(
241
+            self.handler not in self.base_logger.handlers
242
+        )
243
+        if self.action_required[-1]:
244
+            self.base_logger.addHandler(self.handler)
245
+        return self
246
+
247
+    def __exit__(
248
+        self,
249
+        exc_type: type[BaseException] | None,
250
+        exc_value: BaseException | None,
251
+        exc_tb: types.TracebackType | None,
252
+    ) -> Literal[False]:
253
+        if self.action_required[-1]:
254
+            self.base_logger.removeHandler(self.handler)
255
+        self.action_required.pop()
256
+        return False
257
+
258
+
259
+class StandardWarningsLoggingContextManager(StandardLoggingContextManager):
260
+    """A reentrant context manager setting up standard warnings logging.
261
+
262
+    Ensures that warnings are being diverted to the logging system, and
263
+    that the given handler (defaulting to the CLI logging handler) is
264
+    added to the warnings logger. If the handler had to be added, then
265
+    it will be removed upon exiting the context.
266
+
267
+    Reentrant, but not thread safe, because it temporarily modifies
268
+    global state.
269
+
270
+    """
271
+
272
+    def __init__(
273
+        self,
274
+        handler: logging.Handler,
275
+    ) -> None:
276
+        super().__init__(handler=handler, root_logger='py.warnings')
277
+        self.stack: MutableSequence[
278
+            tuple[
279
+                Callable[
280
+                    [
281
+                        type[BaseException] | None,
282
+                        BaseException | None,
283
+                        types.TracebackType | None,
284
+                    ],
285
+                    None,
286
+                ],
287
+                Callable[
288
+                    [
289
+                        str | Warning,
290
+                        type[Warning],
291
+                        str,
292
+                        int,
293
+                        TextIO | None,
294
+                        str | None,
295
+                    ],
296
+                    None,
297
+                ],
298
+            ]
299
+        ] = collections.deque()
300
+
301
+    def __enter__(self) -> Self:
302
+        def showwarning(  # noqa: PLR0913,PLR0917
303
+            message: str | Warning,
304
+            category: type[Warning],
305
+            filename: str,
306
+            lineno: int,
307
+            file: TextIO | None = None,
308
+            line: str | None = None,
309
+        ) -> None:
310
+            if file is not None:  # pragma: no cover
311
+                self.stack[0][1](
312
+                    message, category, filename, lineno, file, line
313
+                )
314
+            else:
315
+                logging.getLogger('py.warnings').warning(
316
+                    str(
317
+                        warnings.formatwarning(
318
+                            message, category, filename, lineno, line
319
+                        )
320
+                    )
321
+                )
322
+
323
+        ctx = warnings.catch_warnings()
324
+        exit_func = ctx.__exit__
325
+        ctx.__enter__()
326
+        self.stack.append((exit_func, warnings.showwarning))
327
+        warnings.showwarning = showwarning
328
+        return super().__enter__()
329
+
330
+    def __exit__(
331
+        self,
332
+        exc_type: type[BaseException] | None,
333
+        exc_value: BaseException | None,
334
+        exc_tb: types.TracebackType | None,
335
+    ) -> Literal[False]:
336
+        ret = super().__exit__(exc_type, exc_value, exc_tb)
337
+        val = self.stack.pop()[0](exc_type, exc_value, exc_tb)
338
+        assert not val
339
+        return ret
340
+
341
+
342
+P = ParamSpec('P')
343
+R = TypeVar('R')
344
+
345
+
346
+def log_debug(
347
+    ctx: click.Context,
348
+    /,
349
+    param: click.Parameter | None = None,
350
+    value: Any = None,  # noqa: ANN401
351
+) -> None:
352
+    """Request that DEBUG-level logs be emitted to standard error.
353
+
354
+    This modifies the [`StandardCLILogging`][] settings such that log
355
+    records at level [`logging.DEBUG`][] and [`logging.INFO`][] are
356
+    emitted as well.
357
+
358
+    """
359
+    del ctx, param, value
360
+    StandardCLILogging.cli_handler.setLevel(logging.DEBUG)
361
+
362
+
363
+def log_info(
364
+    ctx: click.Context,
365
+    /,
366
+    param: click.Parameter | None = None,
367
+    value: Any = None,  # noqa: ANN401
368
+) -> None:
369
+    """Request that INFO-level logs be emitted to standard error.
370
+
371
+    This modifies the [`StandardCLILogging`][] settings such that log
372
+    records at level [`logging.INFO`][] are emitted as well.
373
+
374
+    """
375
+    del ctx, param, value
376
+    StandardCLILogging.cli_handler.setLevel(logging.INFO)
377
+
378
+
379
+def silence_warnings(
380
+    ctx: click.Context,
381
+    /,
382
+    param: click.Parameter | None = None,
383
+    value: Any = None,  # noqa: ANN401
384
+) -> None:
385
+    """Request that WARNING-level logs not be emitted to standard error.
386
+
387
+    This modifies the [`StandardCLILogging`][] settings such that log
388
+    records at level [`logging.WARNING`][] and below are *not* emitted.
389
+
390
+    """
391
+    del ctx, param, value
392
+    StandardCLILogging.cli_handler.setLevel(logging.ERROR)
393
+
394
+
60 395
 # Option parsing and grouping
61 396
 # ===========================
62 397
 
... ...
@@ -156,6 +491,59 @@ class CommandWithHelpGroups(click.Command):
156 491
                     formatter.write_text(epilog)
157 492
 
158 493
 
494
+class LoggingOption(OptionGroupOption):
495
+    """Logging options for the CLI."""
496
+
497
+    option_group_name = 'Logging'
498
+    epilog = ''
499
+
500
+
501
+def standard_logging_options(f: Callable[P, R]) -> Callable[P, R]:
502
+    """Decorate the function with standard logging click options.
503
+
504
+    Adds the three click options `-v`/`--verbose`, `-q`/`--quiet` and
505
+    `--debug`, which issue callbacks to the [`log_info`][],
506
+    [`silence_warnings`][] and [`log_debug`][] functions, respectively.
507
+
508
+    Args:
509
+        f: A callable to decorate.
510
+
511
+    Returns:
512
+        The decorated callable.
513
+
514
+    """
515
+    dec1 = click.option(
516
+        '-q',
517
+        '--quiet',
518
+        is_flag=True,
519
+        is_eager=True,
520
+        expose_value=False,
521
+        callback=silence_warnings,
522
+        help='suppress even warnings, emit only errors',
523
+        cls=LoggingOption,
524
+    )
525
+    dec2 = click.option(
526
+        '-v',
527
+        '--verbose',
528
+        is_flag=True,
529
+        is_eager=True,
530
+        expose_value=False,
531
+        callback=log_info,
532
+        help='emit extra/progress information to standard error',
533
+        cls=LoggingOption,
534
+    )
535
+    dec3 = click.option(
536
+        '--debug',
537
+        is_flag=True,
538
+        is_eager=True,
539
+        expose_value=False,
540
+        callback=log_debug,
541
+        help='also emit debug information (implies --verbose)',
542
+        cls=LoggingOption,
543
+    )
544
+    return dec1(dec2(dec3(f)))
545
+
546
+
159 547
 # Top-level
160 548
 # =========
161 549
 
... ...
@@ -204,17 +592,13 @@ class _DefaultToVaultGroup(click.Group):
204 592
                 self.parse_args(ctx, ctx.args)
205 593
             # Instead of calling ctx.fail here, default to "vault", and
206 594
             # issue a deprecation warning.
207
-            click.echo(
208
-                (
209
-                    f'{PROG_NAME}: Deprecation warning: A subcommand will be '
210
-                    f'required in v1.0. See --help for available subcommands.'
211
-                ),
212
-                err=True,
213
-            )
214
-            click.echo(
215
-                f'{PROG_NAME}: Warning: Defaulting to subcommand "vault".',
216
-                err=True,
595
+            logger = logging.getLogger(PROG_NAME)
596
+            deprecation = logging.getLogger(f'{PROG_NAME}.deprecation')
597
+            deprecation.warning(
598
+                'A subcommand will be required in v1.0. '
599
+                'See --help for available subcommands.'
217 600
             )
601
+            logger.warning('Defaulting to subcommand "vault".')
218 602
             cmd_name = 'vault'
219 603
             cmd = self.get_command(ctx, cmd_name)
220 604
             assert cmd is not None, 'Mandatory subcommand "vault" missing!'
... ...
@@ -222,6 +606,36 @@ class _DefaultToVaultGroup(click.Group):
222 606
         return cmd_name if cmd else None, cmd, args[1:]  # noqa: DOC201
223 607
 
224 608
 
609
+class _TopLevelCLIEntryPoint(_DefaultToVaultGroup):
610
+    """A minor variation of _DefaultToVaultGroup for the top-level command.
611
+
612
+    When called as a function, this sets up the environment properly
613
+    before invoking the actual callbacks.  Currently, this means setting
614
+    up the logging subsystem and the delegation of Python warnings to
615
+    the logging subsystem.
616
+
617
+    The environment setup can be bypassed by calling the `.main` method
618
+    directly.
619
+
620
+    """
621
+
622
+    def __call__(  # pragma: no cover
623
+        self,
624
+        *args: Any,  # noqa: ANN401
625
+        **kwargs: Any,  # noqa: ANN401
626
+    ) -> Any:  # noqa: ANN401
627
+        """"""  # noqa: D419
628
+        # Coverage testing is done with the `click.testing` module,
629
+        # which does not use the `__call__` shortcut.  So it is normal
630
+        # that this function is never called, and thus should be
631
+        # excluded from coverage.
632
+        with (
633
+            StandardCLILogging.ensure_standard_logging(),
634
+            StandardCLILogging.ensure_standard_warnings_logging(),
635
+        ):
636
+            return self.main(*args, **kwargs)
637
+
638
+
225 639
 @click.group(
226 640
     context_settings={
227 641
         'help_option_names': ['-h', '--help'],
... ...
@@ -235,9 +649,10 @@ class _DefaultToVaultGroup(click.Group):
235 649
         `C:\Users\<user>\AppData\Roaming\Derivepassphrase` on Windows.
236 650
     """,
237 651
     invoke_without_command=True,
238
-    cls=_DefaultToVaultGroup,
652
+    cls=_TopLevelCLIEntryPoint,
239 653
 )
240 654
 @click.version_option(version=dpp.__version__, prog_name=PROG_NAME)
655
+@standard_logging_options
241 656
 @click.pass_context
242 657
 def derivepassphrase(ctx: click.Context, /) -> None:
243 658
     """Derive a strong passphrase, deterministically, from a master secret.
... ...
@@ -269,18 +684,14 @@ def derivepassphrase(ctx: click.Context, /) -> None:
269 684
     [CLICK]: https://pypi.org/package/click/
270 685
 
271 686
     """  # noqa: D301
687
+    logger = logging.getLogger(PROG_NAME)
688
+    deprecation = logging.getLogger(f'{PROG_NAME}.deprecation')
272 689
     if ctx.invoked_subcommand is None:
273
-        click.echo(
274
-            (
275
-                f'{PROG_NAME}: Deprecation warning: A subcommand will be '
276
-                f'required in v1.0. See --help for available subcommands.'
277
-            ),
278
-            err=True,
279
-        )
280
-        click.echo(
281
-            f'{PROG_NAME}: Warning: Defaulting to subcommand "vault".',
282
-            err=True,
690
+        deprecation.warning(
691
+            'A subcommand will be required in v1.0. '
692
+            'See --help for available subcommands.'
283 693
         )
694
+        logger.warning('Defaulting to subcommand "vault".')
284 695
         # See definition of click.Group.invoke, non-chained case.
285 696
         with ctx:
286 697
             sub_ctx = derivepassphrase_vault.make_context(
... ...
@@ -306,6 +717,7 @@ def derivepassphrase(ctx: click.Context, /) -> None:
306 717
     cls=_DefaultToVaultGroup,
307 718
 )
308 719
 @click.version_option(version=dpp.__version__, prog_name=PROG_NAME)
720
+@standard_logging_options
309 721
 @click.pass_context
310 722
 def derivepassphrase_export(ctx: click.Context, /) -> None:
311 723
     """Export a foreign configuration to standard output.
... ...
@@ -329,18 +741,14 @@ def derivepassphrase_export(ctx: click.Context, /) -> None:
329 741
     [CLICK]: https://pypi.org/package/click/
330 742
 
331 743
     """  # noqa: D301
744
+    logger = logging.getLogger(PROG_NAME)
745
+    deprecation = logging.getLogger(f'{PROG_NAME}.deprecation')
332 746
     if ctx.invoked_subcommand is None:
333
-        click.echo(
334
-            (
335
-                f'{PROG_NAME}: Deprecation warning: A subcommand will be '
336
-                f'required in v1.0. See --help for available subcommands.'
337
-            ),
338
-            err=True,
339
-        )
340
-        click.echo(
341
-            f'{PROG_NAME}: Warning: Defaulting to subcommand "vault".',
342
-            err=True,
747
+        deprecation.warning(
748
+            'A subcommand will be required in v1.0. '
749
+            'See --help for available subcommands.'
343 750
         )
751
+        logger.warning('Defaulting to subcommand "vault".')
344 752
         # See definition of click.Group.invoke, non-chained case.
345 753
         with ctx:
346 754
             sub_ctx = derivepassphrase_export_vault.make_context(
... ...
@@ -397,6 +805,7 @@ def _load_data(
397 805
     'vault',
398 806
     context_settings={'help_option_names': ['-h', '--help']},
399 807
 )
808
+@standard_logging_options
400 809
 @click.option(
401 810
     '-f',
402 811
     '--format',
... ...
@@ -441,7 +850,7 @@ def derivepassphrase_export_vault(
441 850
     named `VAULT_PATH`.)
442 851
 
443 852
     """
444
-    logging.basicConfig()
853
+    logger = logging.getLogger(PROG_NAME)
445 854
     if path in {'VAULT_PATH', b'VAULT_PATH'}:
446 855
         path = exporter.get_vault_path()
447 856
     if key is None:
... ...
@@ -457,40 +866,32 @@ def derivepassphrase_export_vault(
457 866
             ValueError,
458 867
             RuntimeError,
459 868
         ):
460
-            logging.info('Cannot load as %s: %s', fmt, path)
869
+            logger.info('Cannot load as %s: %s', fmt, path)
461 870
             continue
462 871
         except OSError as exc:
463
-            click.echo(
464
-                (
465
-                    f'{PROG_NAME}: ERROR: Cannot parse {path!r} as '
466
-                    f'a valid config: {exc.strerror}: {exc.filename!r}'
467
-                ),
468
-                err=True,
872
+            logger.error(
873
+                'Cannot parse %r as a valid config: %s: %r',
874
+                path,
875
+                exc.strerror,
876
+                exc.filename,
469 877
             )
470 878
             ctx.exit(1)
471 879
         except ModuleNotFoundError:
472 880
             # TODO(the-13th-letter): Use backslash continuation.
473 881
             # https://github.com/nedbat/coveragepy/issues/1836
474
-            msg = f"""
475
-{PROG_NAME}: ERROR: Cannot load the required Python module "cryptography".
476
-{PROG_NAME}: INFO: pip users: see the "export" extra.
477
-""".lstrip('\n')
478
-            click.echo(msg, nl=False, err=True)
882
+            logger.error(
883
+                'Cannot load the required Python module "cryptography".'
884
+            )
885
+            logger.info('pip users: see the "export" extra.')
479 886
             ctx.exit(1)
480 887
         else:
481 888
             if not _types.is_vault_config(config):
482
-                click.echo(
483
-                    f'{PROG_NAME}: ERROR: Invalid vault config: {config!r}',
484
-                    err=True,
485
-                )
889
+                logger.error('Invalid vault config: %r', config)
486 890
                 ctx.exit(1)
487 891
             click.echo(json.dumps(config, indent=2, sort_keys=True))
488 892
             break
489 893
     else:
490
-        click.echo(
491
-            f'{PROG_NAME}: ERROR: Cannot parse {path!r} as a valid config.',
492
-            err=True,
493
-        )
894
+        logger.error('Cannot parse %r as a valid config.', path)
494 895
         ctx.exit(1)
495 896
 
496 897
 
... ...
@@ -848,6 +1249,7 @@ def _check_for_misleading_passphrase(
848 1249
     *,
849 1250
     form: Literal['NFC', 'NFD', 'NFKC', 'NFKD'] = 'NFC',
850 1251
 ) -> None:
1252
+    logger = logging.getLogger(PROG_NAME)
851 1253
     if 'phrase' in value:
852 1254
         phrase = value['phrase']
853 1255
         if not unicodedata.is_normalized(form, phrase):
... ...
@@ -856,13 +1258,14 @@ def _check_for_misleading_passphrase(
856 1258
                 if isinstance(key, _ORIGIN)
857 1259
                 else _types.json_path(key)
858 1260
             )
859
-            click.echo(
1261
+            logger.warning(
860 1262
                 (
861
-                    f'{PROG_NAME}: Warning: the {formatted_key} '
862
-                    f'passphrase is not {form}-normalized. Make sure to '
863
-                    f'double-check this is really the passphrase you want.'
1263
+                    'the %s passphrase is not %s-normalized. '
1264
+                    'Make sure to double-check this is really the '
1265
+                    'passphrase you want.'
864 1266
                 ),
865
-                err=True,
1267
+                formatted_key,
1268
+                form,
866 1269
             )
867 1270
 
868 1271
 
... ...
@@ -1128,6 +1531,7 @@ DEFAULT_NOTES_MARKER = '# - - - - - >8 - - - - -'
1128 1531
     cls=StorageManagementOption,
1129 1532
 )
1130 1533
 @click.version_option(version=dpp.__version__, prog_name=PROG_NAME)
1534
+@standard_logging_options
1131 1535
 @click.argument('service', required=False)
1132 1536
 @click.pass_context
1133 1537
 def derivepassphrase_vault(  # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915
... ...
@@ -1245,6 +1649,8 @@ def derivepassphrase_vault(  # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915
1245 1649
             input is supported.
1246 1650
 
1247 1651
     """  # noqa: D301
1652
+    logger = logging.getLogger(PROG_NAME)
1653
+    deprecation = logging.getLogger(PROG_NAME + '.deprecation')
1248 1654
     options_in_group: dict[type[click.Option], list[click.Option]] = {}
1249 1655
     params_by_str: dict[str, click.Parameter] = {}
1250 1656
     for param in ctx.command.params:
... ...
@@ -1257,6 +1663,8 @@ def derivepassphrase_vault(  # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915
1257 1663
                 group = ConfigurationOption
1258 1664
             elif isinstance(param, StorageManagementOption):
1259 1665
                 group = StorageManagementOption
1666
+            elif isinstance(param, LoggingOption):
1667
+                group = LoggingOption
1260 1668
             elif isinstance(param, OptionGroupOption):
1261 1669
                 raise AssertionError(  # noqa: DOC501,TRY003,TRY004
1262 1670
                     f'Unknown option group for {param!r}'  # noqa: EM102
... ...
@@ -1291,8 +1699,10 @@ def derivepassphrase_vault(  # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915
1291 1699
                     opt_str, f'mutually exclusive with {other_str}', ctx=ctx
1292 1700
                 )
1293 1701
 
1294
-    def err(msg: str) -> NoReturn:
1295
-        click.echo(f'{PROG_NAME}: {msg}', err=True)
1702
+    def err(msg: Any, *args: Any, **kwargs: Any) -> NoReturn:  # noqa: ANN401
1703
+        stacklevel = kwargs.pop('stacklevel', 1)
1704
+        stacklevel += 1
1705
+        logger.error(msg, *args, stacklevel=stacklevel, **kwargs)
1296 1706
         ctx.exit(1)
1297 1707
 
1298 1708
     def get_config() -> _types.VaultConfig:
... ...
@@ -1305,41 +1715,38 @@ def derivepassphrase_vault(  # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915
1305 1715
                 return {'services': {}}
1306 1716
             old_name = os.path.basename(_config_filename())
1307 1717
             new_name = os.path.basename(_config_filename(subsystem='vault'))
1308
-            click.echo(
1718
+            deprecation.warning(
1309 1719
                 (
1310
-                    f'{PROG_NAME}: Using deprecated v0.1-style config file '
1311
-                    f'{old_name!r}, instead of v0.2-style {new_name!r}.  '
1312
-                    f'Support for v0.1-style config filenames will be '
1313
-                    f'removed in v1.0.'
1720
+                    'Using deprecated v0.1-style config file %r, '
1721
+                    'instead of v0.2-style %r.  '
1722
+                    'Support for v0.1-style config filenames will be '
1723
+                    'removed in v1.0.'
1314 1724
                 ),
1315
-                err=True,
1725
+                old_name,
1726
+                new_name,
1316 1727
             )
1317 1728
             if isinstance(exc, OSError):
1318
-                click.echo(
1319
-                    (
1320
-                        f'{PROG_NAME}: Warning: Failed to migrate to '
1321
-                        f'{new_name!r}: {exc.strerror}: {exc.filename!r}'
1322
-                    ),
1323
-                    err=True,
1729
+                logger.warning(
1730
+                    'Failed to migrate to %r: %s: %r',
1731
+                    new_name,
1732
+                    exc.strerror,
1733
+                    exc.filename,
1324 1734
                 )
1325 1735
             else:
1326
-                click.echo(
1327
-                    f'{PROG_NAME}: Successfully migrated to {new_name!r}.',
1328
-                    err=True,
1329
-                )
1736
+                logger.info('Successfully migrated to %r.', new_name)
1330 1737
             return backup_config
1331 1738
         except OSError as e:
1332
-            err(f'Cannot load config: {e.strerror}: {e.filename!r}')
1739
+            err('Cannot load config: %s: %r', e.strerror, e.filename)
1333 1740
         except Exception as e:  # noqa: BLE001
1334
-            err(f'Cannot load config: {e}')
1741
+            err('Cannot load config: %s', str(e), exc_info=e)
1335 1742
 
1336 1743
     def put_config(config: _types.VaultConfig, /) -> None:
1337 1744
         try:
1338 1745
             _save_config(config)
1339 1746
         except OSError as exc:
1340
-            err(f'Cannot store config: {exc.strerror}: {exc.filename!r}')
1747
+            err('Cannot store config: %s: %r', exc.strerror, exc.filename)
1341 1748
         except Exception as exc:  # noqa: BLE001
1342
-            err(f'Cannot store config: {exc}')
1749
+            err('Cannot store config: %s', str(exc), exc_info=exc)
1343 1750
 
1344 1751
     configuration: _types.VaultConfig
1345 1752
 
... ...
@@ -1384,14 +1791,11 @@ def derivepassphrase_vault(  # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915
1384 1791
             raise click.UsageError(msg)
1385 1792
 
1386 1793
     if service == '':  # noqa: PLC1901
1387
-        click.echo(
1388
-            (
1389
-                f'{PROG_NAME}: Warning: An empty SERVICE is not '
1390
-                f'supported by vault(1).  For compatibility, this will be '
1391
-                f'treated as if SERVICE was not supplied, i.e., it will '
1392
-                f'error out, or operate on global settings.'
1393
-            ),
1394
-            err=True,
1794
+        logger.warning(
1795
+            'An empty SERVICE is not supported by vault(1).  '
1796
+            'For compatibility, this will be treated as if SERVICE '
1797
+            'was not supplied, i.e., it will error out, or '
1798
+            'operate on global settings.'
1395 1799
         )
1396 1800
 
1397 1801
     if edit_notes:
... ...
@@ -1440,40 +1844,41 @@ def derivepassphrase_vault(  # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915
1440 1844
             with infile:
1441 1845
                 maybe_config = json.load(infile)
1442 1846
         except json.JSONDecodeError as e:
1443
-            err(f'Cannot load config: cannot decode JSON: {e}')
1847
+            err('Cannot load config: cannot decode JSON: %s', e)
1444 1848
         except OSError as e:
1445
-            err(f'Cannot load config: {e.strerror}: {e.filename!r}')
1849
+            err('Cannot load config: %s: %r', e.strerror, e.filename)
1446 1850
         cleaned = _types.clean_up_falsy_vault_config_values(maybe_config)
1447 1851
         if not _types.is_vault_config(maybe_config):
1448
-            err(f'Cannot load config: {_INVALID_VAULT_CONFIG}')
1852
+            err('Cannot load config: %s', _INVALID_VAULT_CONFIG)
1449 1853
         assert cleaned is not None
1450 1854
         for step in cleaned:
1451 1855
             # These are never fatal errors, because the semantics of
1452 1856
             # vault upon encountering these settings are ill-specified,
1453 1857
             # but not ill-defined.
1454 1858
             if step.action == 'replace':
1455
-                err_msg = (
1456
-                    f'{PROG_NAME}: Warning: Replacing invalid value '
1457
-                    f'{json.dumps(step.old_value)} for key '
1458
-                    f'{_types.json_path(step.path)} with '
1459
-                    f'{json.dumps(step.new_value)}.'
1859
+                logger.warning(
1860
+                    'Replacing invalid value %s for key %s with %s.',
1861
+                    json.dumps(step.old_value),
1862
+                    _types.json_path(step.path),
1863
+                    json.dumps(step.new_value),
1460 1864
                 )
1461 1865
             else:
1462
-                err_msg = (
1463
-                    f'{PROG_NAME}: Warning: Removing ineffective setting '
1464
-                    f'{_types.json_path(step.path)} = '
1465
-                    f'{json.dumps(step.old_value)}.'
1866
+                logger.warning(
1867
+                    'Removing ineffective setting %s = %s.',
1868
+                    _types.json_path(step.path),
1869
+                    json.dumps(step.old_value),
1466 1870
                 )
1467
-            click.echo(err_msg, err=True)
1468 1871
         if '' in maybe_config['services']:
1469
-            err_msg = (
1470
-                f'{PROG_NAME}: Warning: An empty SERVICE is not '
1471
-                f'supported by vault(1), and the empty-string service '
1472
-                f'settings will be inaccessible and ineffective.  '
1473
-                f'To ensure that vault(1) and {PROG_NAME} see the settings, '
1474
-                f'move them into the "global" section.'
1475
-            )
1476
-            click.echo(err_msg, err=True)
1872
+            logger.warning(
1873
+                (
1874
+                    'An empty SERVICE is not supported by vault(1), '
1875
+                    'and the empty-string service settings will be '
1876
+                    'inaccessible and ineffective.  To ensure that '
1877
+                    'vault(1) and %s see the settings, move them '
1878
+                    'into the "global" section.'
1879
+                ),
1880
+                PROG_NAME,
1881
+            )
1477 1882
         form = cast(
1478 1883
             Literal['NFC', 'NFD', 'NFKC', 'NFKD'],
1479 1884
             maybe_config.get('global', {}).get(
... ...
@@ -1526,7 +1931,7 @@ def derivepassphrase_vault(  # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915
1526 1931
             with outfile:
1527 1932
                 json.dump(configuration, outfile)
1528 1933
         except OSError as e:
1529
-            err(f'Cannot store config: {e.strerror}: {e.filename!r}')
1934
+            err('Cannot store config: %s: %r', e.strerror, e.filename)
1530 1935
     else:
1531 1936
         configuration = get_config()
1532 1937
         # This block could be type checked more stringently, but this
... ...
@@ -1573,10 +1978,7 @@ def derivepassphrase_vault(  # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915
1573 1978
                     'this Python version does not support UNIX domain sockets'
1574 1979
                 )
1575 1980
             except OSError as e:
1576
-                err(
1577
-                    f'Cannot connect to SSH agent: {e.strerror}: '
1578
-                    f'{e.filename!r}'
1579
-                )
1981
+                err('Cannot connect to SSH agent: %s', e.strerror)
1580 1982
             except (
1581 1983
                 LookupError,
1582 1984
                 RuntimeError,
... ...
@@ -1606,12 +2008,13 @@ def derivepassphrase_vault(  # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915
1606 2008
                     {'phrase': phrase},
1607 2009
                 )
1608 2010
                 if 'key' in settings:
1609
-                    err_msg = (
1610
-                        f'{PROG_NAME}: Warning: Setting a {settings_type} '
1611
-                        f'passphrase is ineffective because a key is also '
1612
-                        f'set.'
2011
+                    logger.warning(
2012
+                        (
2013
+                            'Setting a %s passphrase is ineffective '
2014
+                            'because a key is also set.'
2015
+                        ),
2016
+                        settings_type,
1613 2017
                     )
1614
-                    click.echo(err_msg, err=True)
1615 2018
             if not view.maps[0]:
1616 2019
                 settings_type = 'service' if service else 'global'
1617 2020
                 msg = (
1618 2021