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.
- xtr_console/__init__.py +76 -0
- xtr_console/application.py +365 -0
- xtr_console/command/__init__.py +27 -0
- xtr_console/command/command_arguments.py +23 -0
- xtr_console/command/command_callable.py +23 -0
- xtr_console/command/command_descriptor.py +130 -0
- xtr_console/command/command_invoker_interface.py +31 -0
- xtr_console/command/command_selection.py +46 -0
- xtr_console/command/command_signature.py +258 -0
- xtr_console/command/commands_locator.py +48 -0
- xtr_console/command/commands_locator_interface.py +27 -0
- xtr_console/command/default_command_invoker.py +80 -0
- xtr_console/command/default_registry.py +18 -0
- xtr_console/decorator/__init__.py +5 -0
- xtr_console/decorator/as_command.py +112 -0
- xtr_console/exception/__init__.py +31 -0
- xtr_console/exception/application_already_wired_error.py +21 -0
- xtr_console/exception/command_signature_error.py +20 -0
- xtr_console/exception/console_error.py +14 -0
- xtr_console/exception/duplicate_command_error.py +20 -0
- xtr_console/exception/event_loop_running_error.py +18 -0
- xtr_console/exception/invalid_command_name_error.py +20 -0
- xtr_console/exception/invalid_command_result_error.py +21 -0
- xtr_console/exception/invalid_default_error.py +23 -0
- xtr_console/exception/missing_container_error.py +22 -0
- xtr_console/exception/unregistered_command_error.py +21 -0
- xtr_console/exit_code.py +19 -0
- xtr_console/global_options.py +148 -0
- xtr_console/integration/__init__.py +4 -0
- xtr_console/integration/wireup.py +243 -0
- xtr_console/integration/xtr_logging.py +42 -0
- xtr_console/py.typed +0 -0
- xtr_console/style/__init__.py +5 -0
- xtr_console/style/console_style.py +321 -0
- xtr_console/style/decoration.py +35 -0
- xtr_console/style/exception_renderer.py +61 -0
- xtr_console/style/progress_columns.py +35 -0
- xtr_console/tester/__init__.py +6 -0
- xtr_console/tester/application_tester.py +97 -0
- xtr_console/tester/command_tester.py +62 -0
- xtr_console/verbosity.py +57 -0
- xtr_console-1.0.0.dist-info/METADATA +771 -0
- xtr_console-1.0.0.dist-info/RECORD +45 -0
- xtr_console-1.0.0.dist-info/WHEEL +4 -0
- xtr_console-1.0.0.dist-info/licenses/LICENSE +21 -0
xtr_console/__init__.py
ADDED
|
@@ -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
|
+
...
|