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 |