xtr-console 1.0.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.
Files changed (45) hide show
  1. xtr_console/__init__.py +76 -0
  2. xtr_console/application.py +365 -0
  3. xtr_console/command/__init__.py +27 -0
  4. xtr_console/command/command_arguments.py +23 -0
  5. xtr_console/command/command_callable.py +23 -0
  6. xtr_console/command/command_descriptor.py +130 -0
  7. xtr_console/command/command_invoker_interface.py +31 -0
  8. xtr_console/command/command_selection.py +46 -0
  9. xtr_console/command/command_signature.py +258 -0
  10. xtr_console/command/commands_locator.py +48 -0
  11. xtr_console/command/commands_locator_interface.py +27 -0
  12. xtr_console/command/default_command_invoker.py +80 -0
  13. xtr_console/command/default_registry.py +18 -0
  14. xtr_console/decorator/__init__.py +5 -0
  15. xtr_console/decorator/as_command.py +112 -0
  16. xtr_console/exception/__init__.py +31 -0
  17. xtr_console/exception/application_already_wired_error.py +21 -0
  18. xtr_console/exception/command_signature_error.py +20 -0
  19. xtr_console/exception/console_error.py +14 -0
  20. xtr_console/exception/duplicate_command_error.py +20 -0
  21. xtr_console/exception/event_loop_running_error.py +18 -0
  22. xtr_console/exception/invalid_command_name_error.py +20 -0
  23. xtr_console/exception/invalid_command_result_error.py +21 -0
  24. xtr_console/exception/invalid_default_error.py +23 -0
  25. xtr_console/exception/missing_container_error.py +22 -0
  26. xtr_console/exception/unregistered_command_error.py +21 -0
  27. xtr_console/exit_code.py +19 -0
  28. xtr_console/global_options.py +148 -0
  29. xtr_console/integration/__init__.py +4 -0
  30. xtr_console/integration/wireup.py +243 -0
  31. xtr_console/integration/xtr_logging.py +42 -0
  32. xtr_console/py.typed +0 -0
  33. xtr_console/style/__init__.py +5 -0
  34. xtr_console/style/console_style.py +321 -0
  35. xtr_console/style/decoration.py +35 -0
  36. xtr_console/style/exception_renderer.py +61 -0
  37. xtr_console/style/progress_columns.py +35 -0
  38. xtr_console/tester/__init__.py +6 -0
  39. xtr_console/tester/application_tester.py +97 -0
  40. xtr_console/tester/command_tester.py +62 -0
  41. xtr_console/verbosity.py +57 -0
  42. xtr_console-1.0.0.dist-info/METADATA +771 -0
  43. xtr_console-1.0.0.dist-info/RECORD +45 -0
  44. xtr_console-1.0.0.dist-info/WHEEL +4 -0
  45. xtr_console-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,76 @@
1
+ """Async-native console applications: commands declared once, run on one event loop.
2
+
3
+ A command is a function, or a class whose instances are callable, declared
4
+ with :func:`as_command`. An :class:`Application` parses the command line,
5
+ supplies a :class:`ConsoleStyle` to any parameter asking for one, and runs
6
+ the command — with its dependencies from a container, when one is wired
7
+ through :mod:`xtr_console.integration.wireup`.
8
+
9
+ The core has no dependency-injection container of its own, and imports none.
10
+ """
11
+
12
+ from importlib.metadata import version
13
+
14
+ from .application import Application, Hook
15
+ from .command import (
16
+ CommandArguments,
17
+ CommandCallable,
18
+ CommandDescriptor,
19
+ CommandInvokerInterface,
20
+ CommandSignature,
21
+ CommandsLocator,
22
+ CommandsLocatorInterface,
23
+ DefaultCommandInvoker,
24
+ default_registry,
25
+ )
26
+ from .decorator import as_command
27
+ from .exception import (
28
+ ApplicationAlreadyWiredError,
29
+ CommandSignatureError,
30
+ ConsoleError,
31
+ DuplicateCommandError,
32
+ EventLoopRunningError,
33
+ InvalidCommandNameError,
34
+ InvalidCommandResultError,
35
+ InvalidDefaultError,
36
+ MissingContainerError,
37
+ UnregisteredCommandError,
38
+ )
39
+ from .exit_code import ExitCode
40
+ from .style import ConsoleStyle
41
+ from .tester import ApplicationTester, CommandTester
42
+ from .verbosity import SHELL_VERBOSITY, Verbosity
43
+
44
+ __version__ = version("xtr-console")
45
+
46
+ __all__ = [
47
+ "SHELL_VERBOSITY",
48
+ "Application",
49
+ "ApplicationAlreadyWiredError",
50
+ "ApplicationTester",
51
+ "CommandArguments",
52
+ "CommandCallable",
53
+ "CommandDescriptor",
54
+ "CommandInvokerInterface",
55
+ "CommandSignature",
56
+ "CommandSignatureError",
57
+ "CommandTester",
58
+ "CommandsLocator",
59
+ "CommandsLocatorInterface",
60
+ "ConsoleError",
61
+ "ConsoleStyle",
62
+ "DefaultCommandInvoker",
63
+ "DuplicateCommandError",
64
+ "EventLoopRunningError",
65
+ "ExitCode",
66
+ "Hook",
67
+ "InvalidCommandNameError",
68
+ "InvalidCommandResultError",
69
+ "InvalidDefaultError",
70
+ "MissingContainerError",
71
+ "UnregisteredCommandError",
72
+ "Verbosity",
73
+ "__version__",
74
+ "as_command",
75
+ "default_registry",
76
+ ]
@@ -0,0 +1,365 @@
1
+ """A console application: every declared command behind one entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import inspect
7
+ import os
8
+ import sys
9
+ from contextlib import AsyncExitStack
10
+ from typing import TYPE_CHECKING, Final, Literal, TypeAlias, cast, final
11
+
12
+ from cyclopts import App, Group, Parameter
13
+ from cyclopts.exceptions import CycloptsError
14
+ from cyclopts.help import DefaultFormatter, PanelSpec, TableSpec
15
+ from rich import box
16
+ from rich.markup import escape
17
+ from rich.text import Text
18
+
19
+ from .command import CommandSelection, CommandSignature, DefaultCommandInvoker, default_registry
20
+ from .command.command_descriptor import function_of
21
+ from .exception import EventLoopRunningError, InvalidCommandResultError
22
+ from .exit_code import ExitCode
23
+ from .global_options import GlobalOptions, list_global_options
24
+ from .style import ConsoleStyle
25
+ from .style.console_style import block
26
+ from .style.exception_renderer import render_exception
27
+ from .verbosity import SHELL_VERBOSITY, Verbosity
28
+
29
+ if TYPE_CHECKING:
30
+ from collections.abc import Awaitable, Callable, Sequence
31
+
32
+ from cyclopts.help.protocols import HelpFormatter
33
+ from rich.padding import Padding
34
+
35
+ from .command import (
36
+ CommandArguments,
37
+ CommandDescriptor,
38
+ CommandInvokerInterface,
39
+ CommandsLocatorInterface,
40
+ )
41
+
42
+ __all__ = ["Application", "ConfigureHook", "Hook"]
43
+
44
+ Hook: TypeAlias = "Callable[[], Awaitable[None] | None]"
45
+ """Run before or after a command; sync or async."""
46
+
47
+ ConfigureHook: TypeAlias = "Callable[[ConsoleStyle], Awaitable[None] | None]"
48
+ """Run with a command's style, its global options applied; sync or async."""
49
+
50
+ _THEME: Final = {
51
+ "cyclopts.border": "yellow",
52
+ "cyclopts.name": "green",
53
+ "cyclopts.default": "yellow",
54
+ "cyclopts.choices": "yellow",
55
+ "cyclopts.env_var": "yellow",
56
+ }
57
+
58
+
59
+ @final
60
+ class Application:
61
+ """Every declared command, parsed and run on one event loop.
62
+
63
+ Commands come from the registry ``@as_command`` fills — import the
64
+ modules declaring them first. The startup hooks, the command and the
65
+ shutdown hooks run on the same loop, so a resource opened on startup is
66
+ usable by the command and closed on shutdown.
67
+
68
+ Every command takes the global options — ``-v``/``-vv``/``-vvv``, ``-q``,
69
+ ``--silent``, ``-n``, ``--ansi``/``--no-ansi`` — anywhere on its command
70
+ line; they set the :class:`ConsoleStyle` it writes through.
71
+
72
+ With ``catch_exceptions`` on, an exception escaping a command is reported
73
+ — its message, or with ``-v`` its traceback — and the run ends with
74
+ :attr:`ExitCode.FAILURE`; off, it propagates, as a test usually wants.
75
+ """
76
+
77
+ __slots__ = (
78
+ "_backend",
79
+ "_catch_exceptions",
80
+ "_commands",
81
+ "_configure_hooks",
82
+ "_description",
83
+ "_help_formatter",
84
+ "_invoker",
85
+ "_name",
86
+ "_shutdown",
87
+ "_startup",
88
+ "_version",
89
+ )
90
+
91
+ def __init__( # noqa: PLR0913 — settings, all optional, all by keyword but the name.
92
+ self,
93
+ name: str = "console",
94
+ version: str | None = None,
95
+ *,
96
+ description: str | None = None,
97
+ commands: CommandsLocatorInterface | None = None,
98
+ help_formatter: HelpFormatter | None = None,
99
+ catch_exceptions: bool = True,
100
+ backend: Literal["asyncio", "trio"] = "asyncio",
101
+ ) -> None:
102
+ """Configure the application; nothing is parsed or built until it runs."""
103
+ self._name = name
104
+ self._version = version
105
+ self._description = description
106
+ self._commands = commands if commands is not None else default_registry()
107
+ self._help_formatter = help_formatter if help_formatter is not None else _help_formatter()
108
+ self._catch_exceptions = catch_exceptions
109
+ self._backend: Literal["asyncio", "trio"] = backend
110
+ self._invoker: CommandInvokerInterface = DefaultCommandInvoker()
111
+ self._configure_hooks: list[ConfigureHook] = []
112
+ self._startup: list[Hook] = []
113
+ self._shutdown: list[Hook] = []
114
+
115
+ @property
116
+ def name(self) -> str:
117
+ """Return the name the application is invoked by."""
118
+ return self._name
119
+
120
+ @property
121
+ def commands(self) -> CommandsLocatorInterface:
122
+ """Return the registry the commands are read from."""
123
+ return self._commands
124
+
125
+ def on_configure(self, hook: ConfigureHook) -> None:
126
+ """Run ``hook`` with the command's style, before the startup hooks.
127
+
128
+ The style has the global options applied — the place to make anything
129
+ else follow ``-v``, ``-q`` or ``--no-ansi``, a logger among them.
130
+ """
131
+ self._configure_hooks.append(hook)
132
+
133
+ def on_startup(self, hook: Hook) -> None:
134
+ """Run ``hook`` before the command, on the command's event loop."""
135
+ self._startup.append(hook)
136
+
137
+ def on_shutdown(self, hook: Hook) -> None:
138
+ """Run ``hook`` after the command, even if it raised."""
139
+ self._shutdown.append(hook)
140
+
141
+ @property
142
+ def invoker(self) -> CommandInvokerInterface:
143
+ """Return what builds and calls the commands."""
144
+ return self._invoker
145
+
146
+ def use_invoker(self, invoker: CommandInvokerInterface) -> None:
147
+ """Have ``invoker`` build and call commands — a container, typically."""
148
+ self._invoker = invoker
149
+
150
+ def run(self, argv: Sequence[str] | None = None) -> int:
151
+ """Run the command ``argv`` names on a fresh event loop; return its exit code.
152
+
153
+ ``argv`` defaults to ``sys.argv[1:]``. Hand the result to the shell::
154
+
155
+ raise SystemExit(application.run())
156
+
157
+ The verbosity starts from ``SHELL_VERBOSITY``, and what the run settles
158
+ on is written back to it, for the processes the command starts.
159
+
160
+ Raises:
161
+ EventLoopRunningError: If called from async code; await
162
+ :meth:`run_async` there.
163
+ """
164
+ if _loop_running():
165
+ raise EventLoopRunningError
166
+ style = _terminal_style()
167
+ selection = self._configure(argv, style)
168
+ os.environ[SHELL_VERBOSITY] = str(style.verbosity.shell_level)
169
+ app = self._build(style, selection.commands)
170
+ try:
171
+ code = cast("int", app(selection.tokens, exit_on_error=False))
172
+ except CycloptsError:
173
+ return ExitCode.INVALID
174
+ except SystemExit as stop:
175
+ return _exit_code_of(stop, style)
176
+ return code
177
+
178
+ async def run_async(
179
+ self, argv: Sequence[str] | None = None, *, style: ConsoleStyle | None = None
180
+ ) -> int:
181
+ """Run the command ``argv`` names on the running loop; return its exit code.
182
+
183
+ Output goes through ``style``, a fresh :class:`ConsoleStyle` on the
184
+ terminal when omitted — its verbosity read from ``SHELL_VERBOSITY``. A
185
+ style given keeps its own until a global option changes it. A command
186
+ that exits — ``sys.exit(3)``, or Ctrl-C, which exits ``130`` — ends the
187
+ run with that code rather than leaving the process.
188
+ """
189
+ style = style if style is not None else _terminal_style()
190
+ selection = self._configure(argv, style)
191
+ app = self._build(style, selection.commands)
192
+ try:
193
+ code = cast("int", await app.run_async(selection.tokens, exit_on_error=False))
194
+ except CycloptsError:
195
+ return ExitCode.INVALID
196
+ except SystemExit as stop:
197
+ return _exit_code_of(stop, style)
198
+ return code
199
+
200
+ def _configure(self, argv: Sequence[str] | None, style: ConsoleStyle) -> CommandSelection:
201
+ """Apply the global options in ``argv`` to ``style``; select from what is left."""
202
+ options = GlobalOptions.parse(list(argv) if argv is not None else sys.argv[1:])
203
+ options.apply(style)
204
+ return CommandSelection.of(self._commands.commands(), options.remaining)
205
+
206
+ def _build(self, style: ConsoleStyle, commands: Sequence[CommandDescriptor]) -> App:
207
+ """Build the parser for ``commands``."""
208
+ header = self._header()
209
+ app = App(
210
+ name=self._name,
211
+ help=self._description,
212
+ help_format="rich",
213
+ help_prologue=header,
214
+ help_formatter=self._help_formatter,
215
+ help_flags=("--help", "-h"),
216
+ version=header if self._version is not None else None,
217
+ version_format="rich",
218
+ version_flags=("--version", "-V") if self._version is not None else (),
219
+ error_formatter=_error_block,
220
+ default_parameter=Parameter(negative=()),
221
+ group_commands=Group("Available commands", sort_key=1, theme=_THEME),
222
+ group_arguments=Group("Arguments", sort_key=1, theme=_THEME),
223
+ group_parameters=Group("Options", sort_key=2, theme=_THEME),
224
+ result_action="return_int_as_exit_code_else_zero",
225
+ backend=self._backend,
226
+ console=style.console,
227
+ error_console=style.error_console,
228
+ )
229
+ options = Group("Options", sort_key=0, theme=_THEME)
230
+ for flag in ("--help", "--version") if self._version is not None else ("--help",):
231
+ app[flag].group = (options,)
232
+ list_global_options(app, options)
233
+ namespaces: dict[str, Group] = {}
234
+ for command in commands:
235
+ namespace = command.namespace
236
+ group = (
237
+ namespaces.setdefault(namespace, Group(namespace, sort_key=3, theme=_THEME))
238
+ if namespace is not None
239
+ else app.group_commands
240
+ )
241
+ _ = app.command(
242
+ self._entry_point(command, CommandSignature.of(command), style),
243
+ name=command.name,
244
+ alias=command.aliases,
245
+ group=group,
246
+ show=not command.hidden,
247
+ help=command.description,
248
+ )
249
+ return app
250
+
251
+ def _entry_point(
252
+ self, command: CommandDescriptor, signature: CommandSignature, style: ConsoleStyle
253
+ ) -> Callable[..., Awaitable[object]]:
254
+ """Return what the parser calls: the command line in, the command run.
255
+
256
+ It presents the parameters the command line fills, and nothing else;
257
+ the style and anything a container supplies are added on the way in.
258
+ What the command returns goes back to the parser, which reads the
259
+ exit code from it.
260
+ """
261
+
262
+ async def entry(*args: object, **kwargs: object) -> object:
263
+ bound = signature.command_line.bind(*args, **kwargs)
264
+ return await self._execute(command, signature.arguments(bound, style), signature, style)
265
+
266
+ target = command.target
267
+ documented = function_of(target) if isinstance(target, type) else target
268
+ entry.__name__ = command.name
269
+ entry.__doc__ = inspect.getdoc(documented) or inspect.getdoc(target)
270
+ entry.__dict__["__signature__"] = signature.command_line
271
+ return entry
272
+
273
+ async def _execute(
274
+ self,
275
+ command: CommandDescriptor,
276
+ arguments: CommandArguments,
277
+ signature: CommandSignature,
278
+ style: ConsoleStyle,
279
+ ) -> object:
280
+ """Run the configure and startup hooks, the command, then the shutdown hooks.
281
+
282
+ Every shutdown hook runs, in the order added, once startup has begun —
283
+ whether a startup hook, the command or another shutdown hook raised.
284
+ Whatever raised is one failure, each error chained to the one before.
285
+ """
286
+ try:
287
+ async with AsyncExitStack() as shutdown:
288
+ for hook in reversed(self._shutdown):
289
+ _ = shutdown.push_async_callback(_call, hook)
290
+ for configure in self._configure_hooks:
291
+ configured = configure(style)
292
+ if inspect.isawaitable(configured):
293
+ await configured
294
+ for hook in self._startup:
295
+ await _call(hook)
296
+ return _exit_code(
297
+ command, await self._invoker.invoke(command, signature, arguments)
298
+ )
299
+ except Exception as error:
300
+ if not self._catch_exceptions:
301
+ raise
302
+ render_exception(error, style)
303
+ return ExitCode.FAILURE
304
+
305
+ def _header(self) -> str:
306
+ name = f"[green]{escape(self._name)}[/green]"
307
+ return name if self._version is None else f"{name} [yellow]{escape(self._version)}[/yellow]"
308
+
309
+
310
+ def _terminal_style() -> ConsoleStyle:
311
+ """Return a style on the terminal, as verbose as ``SHELL_VERBOSITY`` says."""
312
+ return ConsoleStyle(verbosity=Verbosity.from_shell(os.environ.get(SHELL_VERBOSITY)))
313
+
314
+
315
+ def _help_formatter() -> DefaultFormatter:
316
+ """Lay help out as open sections, without the boxes."""
317
+ return DefaultFormatter(
318
+ panel_spec=PanelSpec(box=box.SIMPLE, padding=(0, 0)),
319
+ table_spec=TableSpec(show_header=False, box=None, padding=(0, 2)),
320
+ )
321
+
322
+
323
+ def _error_block(error: CycloptsError) -> Padding:
324
+ return block("ERROR", Text(str(error)), "white on red")
325
+
326
+
327
+ async def _call(hook: Hook) -> None:
328
+ result = hook()
329
+ if inspect.isawaitable(result):
330
+ await result
331
+
332
+
333
+ def _exit_code(command: CommandDescriptor, result: object) -> int:
334
+ """Return ``result`` as the exit code it must be.
335
+
336
+ Raises:
337
+ InvalidCommandResultError: If it is not an ``int`` — or is a ``bool``.
338
+ """
339
+ if not isinstance(result, int) or isinstance(result, bool):
340
+ raise InvalidCommandResultError(command.name, type(result).__qualname__)
341
+ return result
342
+
343
+
344
+ def _loop_running() -> bool:
345
+ try:
346
+ _ = asyncio.get_running_loop()
347
+ except RuntimeError:
348
+ return False
349
+ return True
350
+
351
+
352
+ def _exit_code_of(stop: SystemExit, style: ConsoleStyle) -> int:
353
+ """Read the exit code the way Python does when a process exits.
354
+
355
+ ``None`` is success and an ``int`` is the code; anything else is a
356
+ message, printed to standard error, and a failure.
357
+ """
358
+ match stop.code:
359
+ case None:
360
+ return ExitCode.SUCCESS
361
+ case int():
362
+ return stop.code
363
+ case message:
364
+ style.error_console.print(str(message), markup=False, highlight=False)
365
+ return ExitCode.FAILURE
@@ -0,0 +1,27 @@
1
+ """Declaring commands, and turning a parsed command line into a call."""
2
+
3
+ from .command_arguments import CommandArguments
4
+ from .command_callable import CommandCallable
5
+ from .command_descriptor import CommandDescriptor, CommandTarget, default_name_of
6
+ from .command_invoker_interface import CommandInvokerInterface
7
+ from .command_selection import CommandSelection
8
+ from .command_signature import CommandSignature
9
+ from .commands_locator import CommandsLocator
10
+ from .commands_locator_interface import CommandsLocatorInterface
11
+ from .default_command_invoker import DefaultCommandInvoker
12
+ from .default_registry import default_registry
13
+
14
+ __all__ = [
15
+ "CommandArguments",
16
+ "CommandCallable",
17
+ "CommandDescriptor",
18
+ "CommandInvokerInterface",
19
+ "CommandSelection",
20
+ "CommandSignature",
21
+ "CommandTarget",
22
+ "CommandsLocator",
23
+ "CommandsLocatorInterface",
24
+ "DefaultCommandInvoker",
25
+ "default_name_of",
26
+ "default_registry",
27
+ ]
@@ -0,0 +1,23 @@
1
+ """What a command is called with."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from collections.abc import Mapping
10
+
11
+ __all__ = ["CommandArguments"]
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class CommandArguments:
16
+ """The positional and keyword arguments a command is called with.
17
+
18
+ Everything the command line and the console supply. Parameters a
19
+ container fills are not here; the container adds them itself.
20
+ """
21
+
22
+ args: tuple[object, ...] = ()
23
+ kwargs: Mapping[str, object] = field(default_factory=dict[str, object])
@@ -0,0 +1,23 @@
1
+ """What a command class's instances must be: callable, returning an exit code."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any, Protocol
6
+
7
+ if TYPE_CHECKING:
8
+ from collections.abc import Awaitable
9
+
10
+ __all__ = ["CommandCallable"]
11
+
12
+
13
+ class CommandCallable(Protocol):
14
+ """An object whose call returns an exit code, now or once awaited.
15
+
16
+ ``*args: Any, **kwargs: Any`` is how a type checker spells "any
17
+ signature": a ``__call__`` taking any parameters matches, and only the
18
+ return type is held to ``int``.
19
+ """
20
+
21
+ def __call__(self, *args: Any, **kwargs: Any) -> int | Awaitable[int]:
22
+ """Run the command and return its exit code."""
23
+ ...
@@ -0,0 +1,130 @@
1
+ """A declared command: what runs, and what it is called."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ import re
7
+ from collections.abc import Callable
8
+ from dataclasses import dataclass
9
+ from typing import Final, TypeAlias, cast
10
+
11
+ from xtr_console.exception import (
12
+ CommandSignatureError,
13
+ DuplicateCommandError,
14
+ InvalidCommandNameError,
15
+ )
16
+
17
+ __all__ = ["CommandDescriptor", "CommandTarget", "call_of", "default_name_of", "function_of"]
18
+
19
+ CommandTarget: TypeAlias = Callable[..., object] | type
20
+ """A command function, or a class whose instances are the command."""
21
+
22
+ _NAMESPACE_SEPARATOR: Final = ":"
23
+ _WORD_BOUNDARY: Final = re.compile(r"(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])")
24
+ _CLASS_SUFFIX: Final = "Command"
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class CommandDescriptor:
29
+ """A declared command.
30
+
31
+ ``target`` is what was declared: a function, or a class whose instances
32
+ are callable — built only when its command runs. ``name`` is what the
33
+ command is invoked by; a ``namespace:`` prefix groups it with its
34
+ siblings in the command list. ``description`` replaces the summary the
35
+ docstring would otherwise supply.
36
+
37
+ Raises:
38
+ InvalidCommandNameError: If the name or an alias is empty, holds
39
+ whitespace, or starts with ``-``.
40
+ DuplicateCommandError: If the command repeats one of its own names.
41
+ CommandSignatureError: If ``target`` is a class that defines no
42
+ ``__call__``, or is a generator — calling one runs nothing.
43
+ """
44
+
45
+ target: CommandTarget
46
+ name: str
47
+ aliases: tuple[str, ...] = ()
48
+ description: str | None = None
49
+ hidden: bool = False
50
+
51
+ def __post_init__(self) -> None:
52
+ """Refuse what the command line could not call, or could not type."""
53
+ for name in self.names:
54
+ _check_name(name)
55
+ for position, name in enumerate(self.names):
56
+ if name in self.names[:position]:
57
+ raise DuplicateCommandError(name, self.name)
58
+ called = function_of(self.target)
59
+ if called is None:
60
+ raise CommandSignatureError(self.name, "a command class must define __call__")
61
+ if inspect.isgeneratorfunction(called) or inspect.isasyncgenfunction(called):
62
+ raise CommandSignatureError(self.name, "a command cannot be a generator")
63
+
64
+ @property
65
+ def names(self) -> tuple[str, ...]:
66
+ """Return the name, then every alias."""
67
+ return (self.name, *self.aliases)
68
+
69
+ @property
70
+ def namespace(self) -> str | None:
71
+ """Return what precedes the first ``:`` in the name, if anything does."""
72
+ namespace, separator, _ = self.name.partition(_NAMESPACE_SEPARATOR)
73
+ return namespace if separator else None
74
+
75
+
76
+ def default_name_of(target: CommandTarget) -> str:
77
+ """Name a command after what declares it.
78
+
79
+ A function ``create_user`` becomes ``create-user``; a class
80
+ ``ImportUsersCommand`` becomes ``import-users``.
81
+ """
82
+ name = cast("str", getattr(target, "__name__", type(target).__name__))
83
+ if isinstance(target, type):
84
+ name = name.removesuffix(_CLASS_SUFFIX) or name
85
+ name = _WORD_BOUNDARY.sub("_", name)
86
+ return name.strip("_").lower().replace("_", "-")
87
+
88
+
89
+ def call_of(command_type: type) -> Callable[..., object] | None:
90
+ """Return the ``__call__`` ``command_type`` defines, if any.
91
+
92
+ Not ``getattr``: every class inherits ``type.__call__`` from its
93
+ metaclass — the thing that makes ``Thing()`` build one — so asking a class
94
+ for its ``__call__`` always finds something.
95
+ """
96
+ for ancestor in command_type.__mro__[:-1]:
97
+ found: Callable[..., object] | None = ancestor.__dict__.get("__call__")
98
+ if found is not None:
99
+ return found
100
+ return None
101
+
102
+
103
+ def function_of(target: CommandTarget) -> Callable[..., object] | None:
104
+ """Return the function that runs when ``target``'s command runs.
105
+
106
+ For a class, its ``__call__`` — unwrapped when it is a ``staticmethod``
107
+ or a ``classmethod``; for a callable object, its type's ``__call__``.
108
+ """
109
+ if not isinstance(target, type):
110
+ return target if inspect.isroutine(target) else call_of(type(target))
111
+ called = call_of(target)
112
+ if isinstance(called, (staticmethod, classmethod)):
113
+ unwrapped: Callable[..., object] = called.__func__
114
+ return unwrapped
115
+ return called
116
+
117
+
118
+ def _check_name(name: str) -> None:
119
+ """Refuse a name nobody could type as a command.
120
+
121
+ Raises:
122
+ InvalidCommandNameError: If it is empty, holds whitespace, or
123
+ starts with ``-`` — the command line would read it as an option.
124
+ """
125
+ if not name:
126
+ raise InvalidCommandNameError(name, "it is empty")
127
+ if any(character.isspace() for character in name):
128
+ raise InvalidCommandNameError(name, "it holds whitespace")
129
+ if name.startswith("-"):
130
+ raise InvalidCommandNameError(name, "it starts with '-', like an option")
@@ -0,0 +1,31 @@
1
+ """The contract for running a command once the command line is parsed."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
6
+
7
+ if TYPE_CHECKING:
8
+ from .command_arguments import CommandArguments
9
+ from .command_descriptor import CommandDescriptor
10
+ from .command_signature import CommandSignature
11
+
12
+ __all__ = ["CommandInvokerInterface"]
13
+
14
+
15
+ @runtime_checkable
16
+ class CommandInvokerInterface(Protocol):
17
+ """Builds what a command needs and calls it.
18
+
19
+ The seam a dependency-injection container plugs into: the application
20
+ parses the command line, and the invoker decides how the command and its
21
+ remaining parameters come to be.
22
+ """
23
+
24
+ async def invoke(
25
+ self,
26
+ command: CommandDescriptor,
27
+ signature: CommandSignature,
28
+ arguments: CommandArguments,
29
+ ) -> object:
30
+ """Run ``command`` with ``arguments`` and return what it returned."""
31
+ ...