pyclig 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyclig/__init__.py +44 -0
- pyclig/_core.py +440 -0
- pyclig/_exit.py +32 -0
- pyclig/_logging.py +143 -0
- pyclig/_output.py +105 -0
- pyclig/_run.py +397 -0
- pyclig/options.py +67 -0
- pyclig/py.typed +0 -0
- pyclig/testing.py +98 -0
- pyclig-0.1.0.dist-info/METADATA +202 -0
- pyclig-0.1.0.dist-info/RECORD +14 -0
- pyclig-0.1.0.dist-info/WHEEL +4 -0
- pyclig-0.1.0.dist-info/licenses/COPYRIGHT +15 -0
- pyclig-0.1.0.dist-info/licenses/LICENSE +201 -0
pyclig/__init__.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""A click wrapper with Command Line Interface Guidelines (CLIG) paradigms.
|
|
2
|
+
|
|
3
|
+
License:
|
|
4
|
+
SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from pyclig import options
|
|
8
|
+
from pyclig._core import Command, Context, Group, group, pass_context
|
|
9
|
+
from pyclig._exit import ExitCode
|
|
10
|
+
from pyclig._output import hanging, info, notice
|
|
11
|
+
from pyclig._run import run
|
|
12
|
+
|
|
13
|
+
__all__: list[str] = [
|
|
14
|
+
"Command",
|
|
15
|
+
"Context",
|
|
16
|
+
"ExitCode",
|
|
17
|
+
"Group",
|
|
18
|
+
"group",
|
|
19
|
+
"hanging",
|
|
20
|
+
"info",
|
|
21
|
+
"notice",
|
|
22
|
+
"options",
|
|
23
|
+
"pass_context",
|
|
24
|
+
"run",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def __getattr__(name: str) -> str:
|
|
29
|
+
"""Resolve `__version__` on first access, not at import.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
name: The attribute name.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
The installed version, or `0.0.0+unknown` without metadata.
|
|
36
|
+
|
|
37
|
+
Raises:
|
|
38
|
+
AttributeError: For any other name.
|
|
39
|
+
"""
|
|
40
|
+
if name == "__version__":
|
|
41
|
+
from pyclig._run import version
|
|
42
|
+
|
|
43
|
+
return version("pyclig")
|
|
44
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
pyclig/_core.py
ADDED
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
"""click classes applying the CLIG conventions to a command group.
|
|
2
|
+
|
|
3
|
+
A `pyclig.group()` gets, on top of click:
|
|
4
|
+
|
|
5
|
+
- per-run Rich consoles (`ctx.out`, `ctx.err`), created when the run starts
|
|
6
|
+
- `-d/--debug` on every command, accepted before or after the command name
|
|
7
|
+
- `-v/--version`, read from the distribution metadata only when asked
|
|
8
|
+
- `USAGE:` & upper-case headings, an `EXAMPLES:` section & concise help on
|
|
9
|
+
a bare invocation
|
|
10
|
+
- the runner, which maps every outcome to an exit code (see `pyclig._run`)
|
|
11
|
+
|
|
12
|
+
License:
|
|
13
|
+
SPDX-License-Identifier: Apache-2.0
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import functools
|
|
17
|
+
import sys
|
|
18
|
+
from collections.abc import Callable, Iterable, Sequence
|
|
19
|
+
from typing import Any, Concatenate, NoReturn, ParamSpec, TypeVar
|
|
20
|
+
|
|
21
|
+
import click
|
|
22
|
+
from rich.console import Console
|
|
23
|
+
|
|
24
|
+
from pyclig import _logging, _run
|
|
25
|
+
|
|
26
|
+
P = ParamSpec("P")
|
|
27
|
+
R = TypeVar("R")
|
|
28
|
+
|
|
29
|
+
_STATE = "pyclig.state"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class HelpFormatter(click.HelpFormatter):
|
|
33
|
+
"""Help with `USAGE:` on its own line & upper-case section headings."""
|
|
34
|
+
|
|
35
|
+
def write_usage(
|
|
36
|
+
self, prog: str, args: str = "", prefix: str | None = None
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Write the usage block, headed `USAGE:` on its own line.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
prog: The command path, e.g. `dkais list`.
|
|
42
|
+
args: The usage pieces after it.
|
|
43
|
+
prefix: The heading, defaulting to `USAGE:`.
|
|
44
|
+
"""
|
|
45
|
+
self.write(f"{'':>{self.current_indent}}{prefix or 'USAGE:'}\n")
|
|
46
|
+
with self.indentation():
|
|
47
|
+
super().write_usage(prog, args, prefix="")
|
|
48
|
+
|
|
49
|
+
def write_heading(self, heading: str) -> None:
|
|
50
|
+
"""Write a section heading in upper case, e.g. `OPTIONS:`.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
heading: The heading.
|
|
54
|
+
"""
|
|
55
|
+
super().write_heading(heading.upper())
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Context(click.Context):
|
|
59
|
+
"""A click context with the run's STDOUT & STDERR consoles.
|
|
60
|
+
|
|
61
|
+
NOTE: The consoles live in `ctx.meta`, which every context of a run
|
|
62
|
+
shares, so `ctx.obj` stays free for the application.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
formatter_class = HelpFormatter
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
*args: Any,
|
|
70
|
+
pyclig_state: _run.State | None = None,
|
|
71
|
+
**kwargs: Any,
|
|
72
|
+
) -> None:
|
|
73
|
+
"""Create the context.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
*args: Positional `click.Context` arguments.
|
|
77
|
+
pyclig_state: The run's state, given to the root context.
|
|
78
|
+
**kwargs: Keyword `click.Context` arguments.
|
|
79
|
+
"""
|
|
80
|
+
super().__init__(*args, **kwargs)
|
|
81
|
+
if pyclig_state is not None:
|
|
82
|
+
self.meta[_STATE] = pyclig_state
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def out(self) -> Console:
|
|
86
|
+
"""The STDOUT console, for the command's output."""
|
|
87
|
+
return _require_state(self).out
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def err(self) -> Console:
|
|
91
|
+
"""The STDERR console, for messages, logs & live displays."""
|
|
92
|
+
return _require_state(self).err
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _state(ctx: click.Context) -> _run.State | None:
|
|
96
|
+
"""Return the run's state, if the run was started by `Group.main`."""
|
|
97
|
+
state = ctx.meta.get(_STATE)
|
|
98
|
+
return state if isinstance(state, _run.State) else None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _require_state(ctx: click.Context) -> _run.State:
|
|
102
|
+
"""Return the run's state, or raise when there is none."""
|
|
103
|
+
state = _state(ctx)
|
|
104
|
+
if state is None:
|
|
105
|
+
raise RuntimeError("the consoles exist only within a pyclig.Group run")
|
|
106
|
+
return state
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _package(ctx: click.Context) -> str:
|
|
110
|
+
"""Return the application package of the run."""
|
|
111
|
+
state = _state(ctx)
|
|
112
|
+
return state.package if state else ctx.find_root().info_name or ""
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def pass_context(f: Callable[Concatenate[Context, P], R]) -> Callable[P, R]:
|
|
116
|
+
"""Pass the current `pyclig.Context` as the first argument.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
f: The command function.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
The wrapped function.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
def new_func(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
126
|
+
ctx = click.get_current_context()
|
|
127
|
+
if not isinstance(ctx, Context):
|
|
128
|
+
raise TypeError(
|
|
129
|
+
f"{f.__name__} must be a command of a pyclig.Group, "
|
|
130
|
+
"not of a plain click.Group"
|
|
131
|
+
)
|
|
132
|
+
return f(ctx, *args, **kwargs)
|
|
133
|
+
|
|
134
|
+
return functools.update_wrapper(new_func, f)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _debug(ctx: click.Context, param: click.Parameter, value: bool) -> None:
|
|
138
|
+
"""Switch the run's logging to `DEBUG` as soon as `-d` is parsed."""
|
|
139
|
+
state = _state(ctx)
|
|
140
|
+
if value and state and not state.debug and not ctx.resilient_parsing:
|
|
141
|
+
state.debug = True
|
|
142
|
+
_logging.configure(state.package, state.err, debug=True)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _show_version(ctx: click.Context) -> str:
|
|
146
|
+
"""Return the `-v/--version` line, e.g. `dkais 0.1.0`."""
|
|
147
|
+
return f"{ctx.find_root().info_name} {_run.version(_package(ctx))}"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class Command(click.Command):
|
|
151
|
+
"""A click command with `-d/--debug` & the pyclig help layout.
|
|
152
|
+
|
|
153
|
+
NOTE: `-d` is eager, so it takes effect even when a later option is
|
|
154
|
+
invalid. Usage errors raised while parsing are bound to this command, so
|
|
155
|
+
they show its usage.
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
context_class = Context
|
|
159
|
+
|
|
160
|
+
def __init__(self, name: str | None = None, **kwargs: Any) -> None:
|
|
161
|
+
"""Create the command.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
name: The command name.
|
|
165
|
+
**kwargs: Keyword `click.Command` arguments;
|
|
166
|
+
`options_metavar` defaults to `[options]`.
|
|
167
|
+
"""
|
|
168
|
+
kwargs.setdefault("options_metavar", "[options]")
|
|
169
|
+
super().__init__(name, **kwargs)
|
|
170
|
+
self.params.append(
|
|
171
|
+
click.Option(
|
|
172
|
+
["-d", "--debug"],
|
|
173
|
+
is_flag=True,
|
|
174
|
+
is_eager=True,
|
|
175
|
+
expose_value=False,
|
|
176
|
+
callback=_debug,
|
|
177
|
+
help="Show debug logging output.",
|
|
178
|
+
)
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]:
|
|
182
|
+
"""Parse the arguments, binding a parse-time usage error to `ctx`.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
ctx: The command's context.
|
|
186
|
+
args: The arguments to parse.
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
The arguments left over for a subcommand.
|
|
190
|
+
"""
|
|
191
|
+
try:
|
|
192
|
+
return super().parse_args(ctx, args)
|
|
193
|
+
except click.UsageError as error:
|
|
194
|
+
if error.ctx is None:
|
|
195
|
+
error.ctx = ctx
|
|
196
|
+
raise
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class Group(Command, click.Group):
|
|
200
|
+
"""A click group running the CLIG conventions (see module docstring).
|
|
201
|
+
|
|
202
|
+
NOTE: Subcommands default to `pyclig.Command` & nested groups to
|
|
203
|
+
`pyclig.Group`, so `@cli.command()` & `@cli.group()` inherit `-d`.
|
|
204
|
+
|
|
205
|
+
Attributes:
|
|
206
|
+
examples: Example invocations, listed under `EXAMPLES:`.
|
|
207
|
+
errors: Expected exception types, reported without a traceback.
|
|
208
|
+
"""
|
|
209
|
+
|
|
210
|
+
command_class = Command
|
|
211
|
+
group_class = type
|
|
212
|
+
|
|
213
|
+
def __init__(
|
|
214
|
+
self,
|
|
215
|
+
name: str | None = None,
|
|
216
|
+
commands: Any = None,
|
|
217
|
+
*,
|
|
218
|
+
examples: Iterable[str] = (),
|
|
219
|
+
errors: _run.Errors = (),
|
|
220
|
+
invoke_without_command: bool = True,
|
|
221
|
+
subcommand_metavar: str | None = "<command>",
|
|
222
|
+
context_settings: dict[str, Any] | None = None,
|
|
223
|
+
**kwargs: Any,
|
|
224
|
+
) -> None:
|
|
225
|
+
"""Create the group.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
name: The group name.
|
|
229
|
+
commands: The initial subcommands, by name or as a sequence.
|
|
230
|
+
examples: Example invocations, listed under `EXAMPLES:`.
|
|
231
|
+
errors: Expected exception types, reported without a traceback.
|
|
232
|
+
invoke_without_command: Run without a subcommand, printing the
|
|
233
|
+
concise help.
|
|
234
|
+
subcommand_metavar: The command placeholder in the usage line.
|
|
235
|
+
context_settings: Defaults for the context; `-h` & `--help`
|
|
236
|
+
show help unless `help_option_names` is given.
|
|
237
|
+
**kwargs: Keyword `click.Group` arguments.
|
|
238
|
+
"""
|
|
239
|
+
settings = {"help_option_names": ["-h", "--help"]}
|
|
240
|
+
super().__init__(
|
|
241
|
+
name,
|
|
242
|
+
commands=commands,
|
|
243
|
+
invoke_without_command=invoke_without_command,
|
|
244
|
+
subcommand_metavar=subcommand_metavar,
|
|
245
|
+
context_settings=settings | (context_settings or {}),
|
|
246
|
+
**kwargs,
|
|
247
|
+
)
|
|
248
|
+
self.examples = tuple(examples)
|
|
249
|
+
self.errors = errors
|
|
250
|
+
|
|
251
|
+
def list_commands(self, ctx: click.Context) -> list[str]:
|
|
252
|
+
"""List the subcommands in the order they were added.
|
|
253
|
+
|
|
254
|
+
NOTE: click sorts them by name; the order a CLI defines them in is
|
|
255
|
+
usually its workflow, e.g. `list`, `download`, `process`.
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
ctx: The group's context.
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
The subcommand names.
|
|
262
|
+
"""
|
|
263
|
+
return list(self.commands)
|
|
264
|
+
|
|
265
|
+
def main(
|
|
266
|
+
self,
|
|
267
|
+
args: Sequence[str] | None = None,
|
|
268
|
+
prog_name: str | None = None,
|
|
269
|
+
complete_var: str | None = None,
|
|
270
|
+
standalone_mode: bool = True,
|
|
271
|
+
windows_expand_args: bool = False,
|
|
272
|
+
**extra: Any,
|
|
273
|
+
) -> NoReturn:
|
|
274
|
+
"""Run the CLI with fresh consoles & logging, then exit.
|
|
275
|
+
|
|
276
|
+
NOTE: Always exits with the mapped code; `standalone_mode` is
|
|
277
|
+
accepted for click compatibility only. `windows_expand_args`
|
|
278
|
+
defaults to False, so `*`, `~` & `%VAR%` reach commands as typed on
|
|
279
|
+
every platform. Use `pyclig.testing.invoke` to run in-process.
|
|
280
|
+
|
|
281
|
+
Args:
|
|
282
|
+
args: The arguments, defaulting to `sys.argv[1:]`.
|
|
283
|
+
prog_name: The program name, defaulting to the group name.
|
|
284
|
+
complete_var: The shell completion environment variable.
|
|
285
|
+
standalone_mode: Ignored.
|
|
286
|
+
windows_expand_args: Expand globs & variables on Windows.
|
|
287
|
+
**extra: Keyword `click.Context` arguments.
|
|
288
|
+
"""
|
|
289
|
+
make_consoles = extra.pop("pyclig_consoles", _run.make_consoles)
|
|
290
|
+
_run.configure_streams()
|
|
291
|
+
out, err = make_consoles()
|
|
292
|
+
prog = prog_name or self.name or "main"
|
|
293
|
+
package = _run.package_of(self.callback, default=prog)
|
|
294
|
+
state = _run.State(out, err, package, self.errors)
|
|
295
|
+
click_main = super().main
|
|
296
|
+
with _logging.scoped(package):
|
|
297
|
+
_logging.configure(package, err, debug=False)
|
|
298
|
+
code = _run.execute(
|
|
299
|
+
lambda: click_main(
|
|
300
|
+
args,
|
|
301
|
+
prog,
|
|
302
|
+
complete_var,
|
|
303
|
+
standalone_mode=False,
|
|
304
|
+
windows_expand_args=windows_expand_args,
|
|
305
|
+
pyclig_state=state,
|
|
306
|
+
**extra,
|
|
307
|
+
),
|
|
308
|
+
state,
|
|
309
|
+
prog=prog,
|
|
310
|
+
)
|
|
311
|
+
sys.exit(code)
|
|
312
|
+
|
|
313
|
+
def invoke(self, ctx: click.Context) -> Any:
|
|
314
|
+
"""Invoke the group, printing concise help when no command is given.
|
|
315
|
+
|
|
316
|
+
NOTE: Ctrl-C, `EOFError` & broken pipes are carried past click,
|
|
317
|
+
which would print a blank line, report "Aborted!" or exit silently.
|
|
318
|
+
|
|
319
|
+
Args:
|
|
320
|
+
ctx: The group's context.
|
|
321
|
+
|
|
322
|
+
Returns:
|
|
323
|
+
The subcommand's return value.
|
|
324
|
+
|
|
325
|
+
Raises:
|
|
326
|
+
Interrupted: On Ctrl-C.
|
|
327
|
+
Escaped: Carrying an `EOFError` or a pipe `OSError`.
|
|
328
|
+
"""
|
|
329
|
+
try:
|
|
330
|
+
result = super().invoke(ctx)
|
|
331
|
+
except KeyboardInterrupt as error:
|
|
332
|
+
raise _run.Interrupted from error
|
|
333
|
+
except EOFError as error:
|
|
334
|
+
raise _run.Escaped(error) from error
|
|
335
|
+
except OSError as error:
|
|
336
|
+
if error.errno in _run.PIPE_ERRNOS:
|
|
337
|
+
raise _run.Escaped(error) from error
|
|
338
|
+
raise
|
|
339
|
+
if ctx.invoked_subcommand is None:
|
|
340
|
+
click.echo(self.get_concise_help(ctx))
|
|
341
|
+
return result
|
|
342
|
+
|
|
343
|
+
def get_concise_help(self, ctx: click.Context) -> str:
|
|
344
|
+
"""Return the concise help printed for a bare invocation.
|
|
345
|
+
|
|
346
|
+
Args:
|
|
347
|
+
ctx: The group's context.
|
|
348
|
+
|
|
349
|
+
Returns:
|
|
350
|
+
The summary & version, description, usage, commands, examples
|
|
351
|
+
& a `--help` hint.
|
|
352
|
+
"""
|
|
353
|
+
formatter = ctx.make_formatter()
|
|
354
|
+
header = _show_version(ctx)
|
|
355
|
+
formatter.write_text(
|
|
356
|
+
f"{self.short_help} [{header}]" if self.short_help else header
|
|
357
|
+
)
|
|
358
|
+
self.format_help_text(ctx, formatter)
|
|
359
|
+
formatter.write_paragraph()
|
|
360
|
+
self.format_usage(ctx, formatter)
|
|
361
|
+
self.format_commands(ctx, formatter)
|
|
362
|
+
self.format_examples(ctx, formatter)
|
|
363
|
+
hint = _run.help_hint(ctx)
|
|
364
|
+
if hint:
|
|
365
|
+
formatter.write_paragraph()
|
|
366
|
+
formatter.write_text(hint)
|
|
367
|
+
return formatter.getvalue().rstrip("\n")
|
|
368
|
+
|
|
369
|
+
def format_examples(
|
|
370
|
+
self, ctx: click.Context, formatter: click.HelpFormatter
|
|
371
|
+
) -> None:
|
|
372
|
+
"""Write the `EXAMPLES:` section, one invocation per line.
|
|
373
|
+
|
|
374
|
+
Args:
|
|
375
|
+
ctx: The group's context.
|
|
376
|
+
formatter: The help formatter.
|
|
377
|
+
"""
|
|
378
|
+
if not self.examples:
|
|
379
|
+
return
|
|
380
|
+
with formatter.section("Examples"):
|
|
381
|
+
for example in self.examples:
|
|
382
|
+
formatter.write(f"{'':>{formatter.current_indent}}{example}\n")
|
|
383
|
+
|
|
384
|
+
def format_epilog(
|
|
385
|
+
self, ctx: click.Context, formatter: click.HelpFormatter
|
|
386
|
+
) -> None:
|
|
387
|
+
"""Write the `EXAMPLES:` section, then the epilog.
|
|
388
|
+
|
|
389
|
+
Args:
|
|
390
|
+
ctx: The group's context.
|
|
391
|
+
formatter: The help formatter.
|
|
392
|
+
"""
|
|
393
|
+
self.format_examples(ctx, formatter)
|
|
394
|
+
super().format_epilog(ctx, formatter)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def group(
|
|
398
|
+
name: str | None = None,
|
|
399
|
+
*,
|
|
400
|
+
examples: Iterable[str] = (),
|
|
401
|
+
errors: _run.Errors = (),
|
|
402
|
+
**kwargs: Any,
|
|
403
|
+
) -> Callable[[Callable[..., Any]], Group]:
|
|
404
|
+
"""Create a `pyclig.Group` with `-v/--version` from a function.
|
|
405
|
+
|
|
406
|
+
```python
|
|
407
|
+
@pyclig.group(
|
|
408
|
+
"dkais",
|
|
409
|
+
short_help="DMA AIS data manager",
|
|
410
|
+
examples=["dkais list --start 2026-10"],
|
|
411
|
+
errors=(exceptions.Error,),
|
|
412
|
+
)
|
|
413
|
+
def cli() -> None:
|
|
414
|
+
\"\"\"Download DMA AIS archives and convert them to Parquet.\"\"\"
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
Args:
|
|
418
|
+
name: The program name, e.g. `dkais`.
|
|
419
|
+
examples: Example invocations, listed under `EXAMPLES:`.
|
|
420
|
+
errors: Expected exception types, reported as `error: message` &
|
|
421
|
+
exit 1 - the traceback is shown only with `-d`.
|
|
422
|
+
**kwargs: Keyword `click.Group` arguments, e.g. `short_help` (the
|
|
423
|
+
concise help summary) & `epilog`.
|
|
424
|
+
|
|
425
|
+
Returns:
|
|
426
|
+
A decorator turning the group callback into a `Group`.
|
|
427
|
+
"""
|
|
428
|
+
|
|
429
|
+
def decorator(f: Callable[..., Any]) -> Group:
|
|
430
|
+
f = click.custom_version_option(
|
|
431
|
+
_show_version,
|
|
432
|
+
"-v",
|
|
433
|
+
"--version",
|
|
434
|
+
help="Show the version and exit.",
|
|
435
|
+
)(f)
|
|
436
|
+
return click.group(
|
|
437
|
+
name, cls=Group, examples=examples, errors=errors, **kwargs
|
|
438
|
+
)(f)
|
|
439
|
+
|
|
440
|
+
return decorator
|
pyclig/_exit.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Process exit codes shared by every `pyclig` application.
|
|
2
|
+
|
|
3
|
+
The codes let a script tell a usage mistake from a failed run, a crash or a
|
|
4
|
+
Ctrl-C. `USAGE` is click's own code for usage errors; `INTERNAL` is sysexits
|
|
5
|
+
`EX_SOFTWARE`.
|
|
6
|
+
|
|
7
|
+
License:
|
|
8
|
+
SPDX-License-Identifier: Apache-2.0
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from enum import IntEnum
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ExitCode(IntEnum):
|
|
15
|
+
"""A process exit code.
|
|
16
|
+
|
|
17
|
+
NOTE: Members pass `int` type checks (`IntEnum`), so a command can return
|
|
18
|
+
one & `sys.exit` accepts it.
|
|
19
|
+
|
|
20
|
+
Attributes:
|
|
21
|
+
OK: Success.
|
|
22
|
+
FAILURE: The command ran but failed, e.g. a download error.
|
|
23
|
+
USAGE: The command line was invalid.
|
|
24
|
+
INTERNAL: An unexpected exception, i.e. a bug.
|
|
25
|
+
INTERRUPT: Interrupted by Ctrl-C (128 + `SIGINT`).
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
OK = 0
|
|
29
|
+
FAILURE = 1
|
|
30
|
+
USAGE = 2
|
|
31
|
+
INTERNAL = 70
|
|
32
|
+
INTERRUPT = 130
|
pyclig/_logging.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Logging for a CLI run, bound to the run's STDERR console.
|
|
2
|
+
|
|
3
|
+
Without `-d`, records render as `warning:` / `error:` notices
|
|
4
|
+
(`EventHandler`). With `-d`, a `RichHandler` adds levels, times & call sites.
|
|
5
|
+
|
|
6
|
+
NOTE: Both handlers write through the console they are given. Pass the same
|
|
7
|
+
STDERR console to any `Progress` or `Status`, so Rich redraws the live
|
|
8
|
+
display around log lines instead of printing through it. The logger's state
|
|
9
|
+
is restored when the run ends, so repeated in-process runs (tests) never
|
|
10
|
+
stack handlers. Library code should only ever add a `NullHandler`, in its
|
|
11
|
+
`__init__.py`:
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
logging.getLogger(__name__).addHandler(logging.NullHandler())
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
License:
|
|
18
|
+
SPDX-License-Identifier: Apache-2.0
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import contextlib
|
|
22
|
+
import logging
|
|
23
|
+
from collections.abc import Generator
|
|
24
|
+
from datetime import datetime
|
|
25
|
+
|
|
26
|
+
from rich.console import Console
|
|
27
|
+
from rich.text import Text
|
|
28
|
+
|
|
29
|
+
from pyclig._output import info, notice, print_exception
|
|
30
|
+
|
|
31
|
+
DATEFMT = "[%Y-%m-%dT%H:%M:%S%z]"
|
|
32
|
+
"""The `-d` handler's time format, in local time with its UTC offset."""
|
|
33
|
+
|
|
34
|
+
_MARKER = "_pyclig_handler"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class EventHandler(logging.Handler):
|
|
38
|
+
"""Render log records as `warning:` / `error:` lines.
|
|
39
|
+
|
|
40
|
+
NOTE: Records below `WARNING` render as dim context lines. A record with
|
|
41
|
+
`exc_info` (`logger.exception`) is followed by its traceback.
|
|
42
|
+
|
|
43
|
+
Attributes:
|
|
44
|
+
console: The console records are printed to, usually STDERR.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(self, console: Console, level: int = logging.NOTSET) -> None:
|
|
48
|
+
"""Create the handler.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
console: The console to print records to.
|
|
52
|
+
level: The handler's level.
|
|
53
|
+
"""
|
|
54
|
+
super().__init__(level)
|
|
55
|
+
self.console = console
|
|
56
|
+
|
|
57
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
58
|
+
"""Print the record as a notice, then any traceback.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
record: The log record.
|
|
62
|
+
"""
|
|
63
|
+
try:
|
|
64
|
+
message = record.getMessage()
|
|
65
|
+
if record.levelno >= logging.ERROR:
|
|
66
|
+
notice(self.console, level="error", message=message)
|
|
67
|
+
elif record.levelno >= logging.WARNING:
|
|
68
|
+
notice(self.console, level="warning", message=message)
|
|
69
|
+
else:
|
|
70
|
+
info(self.console, message=message)
|
|
71
|
+
if record.exc_info and record.exc_info[1] is not None:
|
|
72
|
+
print_exception(self.console, record.exc_info[1])
|
|
73
|
+
except Exception:
|
|
74
|
+
self.handleError(record)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def configure(name: str, console: Console, *, debug: bool) -> None:
|
|
78
|
+
"""Attach a CLI handler, bound to `console`, to the `name` logger.
|
|
79
|
+
|
|
80
|
+
NOTE: Replaces any handler a previous call added & keeps the rest (e.g.
|
|
81
|
+
the library `NullHandler`). Records stop at this logger
|
|
82
|
+
(`propagate = False`), so nothing is printed twice by a root handler.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
name: The logger name, usually the application package.
|
|
86
|
+
console: The STDERR console of the run.
|
|
87
|
+
debug: Use the verbose `RichHandler` at `DEBUG`, else the
|
|
88
|
+
`EventHandler` at `WARNING`.
|
|
89
|
+
"""
|
|
90
|
+
logger = logging.getLogger(name)
|
|
91
|
+
_remove_handlers(logger)
|
|
92
|
+
handler: logging.Handler
|
|
93
|
+
if debug:
|
|
94
|
+
from rich.logging import RichHandler
|
|
95
|
+
|
|
96
|
+
handler = RichHandler(
|
|
97
|
+
console=console,
|
|
98
|
+
show_path=True,
|
|
99
|
+
rich_tracebacks=True,
|
|
100
|
+
markup=False,
|
|
101
|
+
omit_repeated_times=False,
|
|
102
|
+
log_time_format=_format_time,
|
|
103
|
+
)
|
|
104
|
+
handler.setFormatter(logging.Formatter("[%(funcName)s] %(message)s"))
|
|
105
|
+
else:
|
|
106
|
+
handler = EventHandler(console)
|
|
107
|
+
setattr(handler, _MARKER, True)
|
|
108
|
+
logger.addHandler(handler)
|
|
109
|
+
logger.setLevel(logging.DEBUG if debug else logging.WARNING)
|
|
110
|
+
logger.propagate = False
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@contextlib.contextmanager
|
|
114
|
+
def scoped(name: str) -> Generator[None, None, None]:
|
|
115
|
+
"""Restore the `name` logger's handlers, level & propagation on exit.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
name: The logger name.
|
|
119
|
+
|
|
120
|
+
Yields:
|
|
121
|
+
None, while the run configures the logger.
|
|
122
|
+
"""
|
|
123
|
+
logger = logging.getLogger(name)
|
|
124
|
+
saved = logger.handlers[:], logger.level, logger.propagate
|
|
125
|
+
try:
|
|
126
|
+
yield
|
|
127
|
+
finally:
|
|
128
|
+
_remove_handlers(logger)
|
|
129
|
+
logger.handlers[:] = saved[0]
|
|
130
|
+
logger.setLevel(saved[1])
|
|
131
|
+
logger.propagate = saved[2]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _format_time(when: datetime) -> Text:
|
|
135
|
+
"""Format a record's time with `DATEFMT`, adding the local UTC offset."""
|
|
136
|
+
return Text(when.astimezone().strftime(DATEFMT))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _remove_handlers(logger: logging.Logger) -> None:
|
|
140
|
+
"""Remove & close the handlers `configure` added to `logger`."""
|
|
141
|
+
for handler in [h for h in logger.handlers if getattr(h, _MARKER, False)]:
|
|
142
|
+
logger.removeHandler(handler)
|
|
143
|
+
handler.close()
|