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 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()