cohaerum-cli 0.1.0__tar.gz
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.
- cohaerum_cli-0.1.0/.gitignore +1 -0
- cohaerum_cli-0.1.0/PKG-INFO +5 -0
- cohaerum_cli-0.1.0/pyproject.toml +12 -0
- cohaerum_cli-0.1.0/pyrightconfig.json +7 -0
- cohaerum_cli-0.1.0/src/cohaerum_cli/__init__.py +5 -0
- cohaerum_cli-0.1.0/src/cohaerum_cli/auto.py +4 -0
- cohaerum_cli-0.1.0/src/cohaerum_cli/provider.py +99 -0
- cohaerum_cli-0.1.0/src/cohaerum_cli/runner.py +630 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__pycache__/
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
3
|
+
from threading import RLock
|
|
4
|
+
from types import MappingProxyType
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from cohaerum.api import CLI
|
|
8
|
+
|
|
9
|
+
from .runner import run
|
|
10
|
+
|
|
11
|
+
_COMMAND_COMPONENT = re.compile(
|
|
12
|
+
r"^[A-Za-z][A-Za-z0-9_-]*$"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CLIProvider(CLI):
|
|
17
|
+
"""
|
|
18
|
+
In-memory command registry and CLI dispatcher.
|
|
19
|
+
|
|
20
|
+
Commands are registered when application modules containing
|
|
21
|
+
decorated functions are imported.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self) -> None:
|
|
25
|
+
self._commands: dict[
|
|
26
|
+
str,
|
|
27
|
+
Callable[..., Any],
|
|
28
|
+
] = {}
|
|
29
|
+
self._lock = RLock()
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def commands(
|
|
33
|
+
self,
|
|
34
|
+
) -> Mapping[str, Callable[..., Any]]:
|
|
35
|
+
return MappingProxyType(self._commands)
|
|
36
|
+
|
|
37
|
+
def register[**P, R](
|
|
38
|
+
self,
|
|
39
|
+
name: str,
|
|
40
|
+
function: Callable[P, R],
|
|
41
|
+
) -> None:
|
|
42
|
+
self._validate_name(name)
|
|
43
|
+
|
|
44
|
+
if not callable(function):
|
|
45
|
+
raise TypeError(
|
|
46
|
+
f"implementation for command {name!r} "
|
|
47
|
+
"must be callable"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
with self._lock:
|
|
51
|
+
if name in self._commands:
|
|
52
|
+
existing = self._commands[name]
|
|
53
|
+
|
|
54
|
+
raise RuntimeError(
|
|
55
|
+
f"command {name!r} is already registered by "
|
|
56
|
+
f"{existing.__module__}."
|
|
57
|
+
f"{existing.__qualname__}"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
self._commands[name] = function
|
|
61
|
+
|
|
62
|
+
def run(
|
|
63
|
+
self,
|
|
64
|
+
argv: Sequence[str] | None = None,
|
|
65
|
+
) -> int:
|
|
66
|
+
return run(self.commands, argv)
|
|
67
|
+
|
|
68
|
+
@staticmethod
|
|
69
|
+
def _validate_name(name: str) -> None:
|
|
70
|
+
if not isinstance(name, str):
|
|
71
|
+
raise TypeError(
|
|
72
|
+
"command name must be a string"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
if not name:
|
|
76
|
+
raise ValueError(
|
|
77
|
+
"command name must not be empty"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
components = name.split(".")
|
|
81
|
+
|
|
82
|
+
for component in components:
|
|
83
|
+
if not component:
|
|
84
|
+
raise ValueError(
|
|
85
|
+
f"command name {name!r} contains "
|
|
86
|
+
"an empty component"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
if _COMMAND_COMPONENT.fullmatch(
|
|
90
|
+
component
|
|
91
|
+
) is None:
|
|
92
|
+
raise ValueError(
|
|
93
|
+
f"invalid command component "
|
|
94
|
+
f"{component!r} in {name!r}"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
__all__ = ["CLIProvider"]
|
|
99
|
+
|
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import asyncio
|
|
3
|
+
import difflib
|
|
4
|
+
import inspect
|
|
5
|
+
import sys
|
|
6
|
+
import types
|
|
7
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import (
|
|
11
|
+
IO,
|
|
12
|
+
Annotated,
|
|
13
|
+
Any,
|
|
14
|
+
Literal,
|
|
15
|
+
Union,
|
|
16
|
+
get_args,
|
|
17
|
+
get_origin,
|
|
18
|
+
get_type_hints,
|
|
19
|
+
)
|
|
20
|
+
from uuid import UUID
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def run(
|
|
24
|
+
commands: Mapping[str, Callable[..., Any]],
|
|
25
|
+
argv: Sequence[str] | None = None,
|
|
26
|
+
) -> int:
|
|
27
|
+
"""
|
|
28
|
+
Resolve and invoke one command from ``argv``.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
arguments = (
|
|
32
|
+
list(sys.argv[1:])
|
|
33
|
+
if argv is None
|
|
34
|
+
else list(argv)
|
|
35
|
+
)
|
|
36
|
+
program = Path(sys.argv[0]).name
|
|
37
|
+
|
|
38
|
+
if not arguments:
|
|
39
|
+
_print_global_help(program, commands)
|
|
40
|
+
return 0
|
|
41
|
+
|
|
42
|
+
if arguments == ["--help"] or arguments == ["-h"]:
|
|
43
|
+
_print_global_help(program, commands)
|
|
44
|
+
return 0
|
|
45
|
+
|
|
46
|
+
if arguments[0] in commands:
|
|
47
|
+
return _invoke(
|
|
48
|
+
program,
|
|
49
|
+
arguments[0],
|
|
50
|
+
commands[arguments[0]],
|
|
51
|
+
list(arguments[1:]),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# Prefer the longest matching command. This permits both
|
|
55
|
+
# "user" and "user.create" to be registered.
|
|
56
|
+
for count in range(len(arguments), 0, -1):
|
|
57
|
+
name = ".".join(arguments[:count])
|
|
58
|
+
|
|
59
|
+
if name in commands:
|
|
60
|
+
return _invoke(
|
|
61
|
+
program,
|
|
62
|
+
name,
|
|
63
|
+
commands[name],
|
|
64
|
+
list(arguments[count:]),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# No full command matched; the arguments may name a group
|
|
68
|
+
# such as "object" when "object.get" is registered.
|
|
69
|
+
match = _match_group(commands, arguments)
|
|
70
|
+
|
|
71
|
+
if match is not None:
|
|
72
|
+
group, rest = match
|
|
73
|
+
return _group(program, commands, group, rest)
|
|
74
|
+
|
|
75
|
+
_unknown_command(program, commands, arguments)
|
|
76
|
+
return 2
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _print_global_help(
|
|
80
|
+
program: str,
|
|
81
|
+
commands: Mapping[str, Callable[..., Any]],
|
|
82
|
+
) -> None:
|
|
83
|
+
print(
|
|
84
|
+
f"usage: {program} COMMAND [ARGUMENT ...]\n"
|
|
85
|
+
"\n"
|
|
86
|
+
"Invoke a registered command.\n"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
if not commands:
|
|
90
|
+
print("No commands are registered.")
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
print("commands:")
|
|
94
|
+
|
|
95
|
+
entries: list[tuple[str, str]] = []
|
|
96
|
+
|
|
97
|
+
for top in sorted(
|
|
98
|
+
{name.split(".", 1)[0] for name in commands}
|
|
99
|
+
):
|
|
100
|
+
if top in commands:
|
|
101
|
+
entries.append((top, _summary(commands[top])))
|
|
102
|
+
else:
|
|
103
|
+
entries.append(
|
|
104
|
+
(top, ", ".join(_children(commands, top)))
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
width = max(len(name) for name, _ in entries)
|
|
108
|
+
|
|
109
|
+
for name, summary in entries:
|
|
110
|
+
if summary:
|
|
111
|
+
print(f" {name:<{width}} {summary}")
|
|
112
|
+
else:
|
|
113
|
+
print(f" {name}")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _summary(
|
|
117
|
+
function: Callable[..., Any],
|
|
118
|
+
) -> str:
|
|
119
|
+
documentation = inspect.getdoc(function)
|
|
120
|
+
|
|
121
|
+
if not documentation:
|
|
122
|
+
return ""
|
|
123
|
+
|
|
124
|
+
return documentation.splitlines()[0]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _children(
|
|
128
|
+
commands: Mapping[str, Callable[..., Any]],
|
|
129
|
+
group: str,
|
|
130
|
+
) -> list[str]:
|
|
131
|
+
prefix = group + "."
|
|
132
|
+
|
|
133
|
+
return sorted(
|
|
134
|
+
{
|
|
135
|
+
name[len(prefix):].split(".", 1)[0]
|
|
136
|
+
for name in commands
|
|
137
|
+
if name.startswith(prefix)
|
|
138
|
+
}
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _child_summaries(
|
|
143
|
+
commands: Mapping[str, Callable[..., Any]],
|
|
144
|
+
group: str,
|
|
145
|
+
) -> dict[str, str]:
|
|
146
|
+
summaries: dict[str, str] = {}
|
|
147
|
+
|
|
148
|
+
for child in _children(commands, group):
|
|
149
|
+
name = group + "." + child
|
|
150
|
+
|
|
151
|
+
if name in commands:
|
|
152
|
+
summaries[child] = _summary(commands[name])
|
|
153
|
+
else:
|
|
154
|
+
summaries[child] = ", ".join(_children(commands, name))
|
|
155
|
+
|
|
156
|
+
return summaries
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _print_group_help(
|
|
160
|
+
program: str,
|
|
161
|
+
commands: Mapping[str, Callable[..., Any]],
|
|
162
|
+
group: str,
|
|
163
|
+
file: IO[str] | None = None,
|
|
164
|
+
) -> None:
|
|
165
|
+
if file is None:
|
|
166
|
+
file = sys.stdout
|
|
167
|
+
|
|
168
|
+
print(
|
|
169
|
+
f"usage: {program} {group.replace('.', ' ')} "
|
|
170
|
+
f"<command> [ARGUMENT ...]\n"
|
|
171
|
+
"\n"
|
|
172
|
+
f"Subcommands of '{group}':\n",
|
|
173
|
+
file=file,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
summaries = _child_summaries(commands, group)
|
|
177
|
+
width = max((len(name) for name in summaries), default=0)
|
|
178
|
+
|
|
179
|
+
for name, summary in summaries.items():
|
|
180
|
+
if summary:
|
|
181
|
+
print(
|
|
182
|
+
f" {name:<{width}} {summary}",
|
|
183
|
+
file=file,
|
|
184
|
+
)
|
|
185
|
+
else:
|
|
186
|
+
print(f" {name}", file=file)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _match_group(
|
|
190
|
+
commands: Mapping[str, Callable[..., Any]],
|
|
191
|
+
arguments: Sequence[str],
|
|
192
|
+
) -> tuple[str, list[str]] | None:
|
|
193
|
+
match: tuple[str, list[str]] | None = None
|
|
194
|
+
|
|
195
|
+
for count in range(1, len(arguments) + 1):
|
|
196
|
+
prefix = ".".join(arguments[:count])
|
|
197
|
+
|
|
198
|
+
if any(
|
|
199
|
+
name.startswith(prefix + ".")
|
|
200
|
+
for name in commands
|
|
201
|
+
):
|
|
202
|
+
match = (
|
|
203
|
+
prefix,
|
|
204
|
+
list(arguments[count:]),
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
return match
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _group(
|
|
211
|
+
program: str,
|
|
212
|
+
commands: Mapping[str, Callable[..., Any]],
|
|
213
|
+
group: str,
|
|
214
|
+
rest: Sequence[str],
|
|
215
|
+
) -> int:
|
|
216
|
+
if not rest:
|
|
217
|
+
_print_group_help(
|
|
218
|
+
program,
|
|
219
|
+
commands,
|
|
220
|
+
group,
|
|
221
|
+
file=sys.stderr,
|
|
222
|
+
)
|
|
223
|
+
return 2
|
|
224
|
+
|
|
225
|
+
if rest == ["--help"] or rest == ["-h"]:
|
|
226
|
+
_print_group_help(program, commands, group)
|
|
227
|
+
return 0
|
|
228
|
+
|
|
229
|
+
children = _children(commands, group)
|
|
230
|
+
suggestion = _suggest(rest[0], children)
|
|
231
|
+
|
|
232
|
+
message = (
|
|
233
|
+
f"unknown command: {' '.join([group, *rest])}"
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
if suggestion is not None:
|
|
237
|
+
message += f"\ndid you mean '{group} {suggestion}'?"
|
|
238
|
+
|
|
239
|
+
message += (
|
|
240
|
+
f"\ntry '{program} {group.replace('.', ' ')} --help'"
|
|
241
|
+
" for a list of subcommands."
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
parser = argparse.ArgumentParser(
|
|
245
|
+
prog=program,
|
|
246
|
+
add_help=False,
|
|
247
|
+
)
|
|
248
|
+
parser.error(message)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _unknown_command(
|
|
252
|
+
program: str,
|
|
253
|
+
commands: Mapping[str, Callable[..., Any]],
|
|
254
|
+
arguments: Sequence[str],
|
|
255
|
+
) -> None:
|
|
256
|
+
tops = sorted(
|
|
257
|
+
{name.split(".", 1)[0] for name in commands}
|
|
258
|
+
)
|
|
259
|
+
suggestion = _suggest(
|
|
260
|
+
arguments[0],
|
|
261
|
+
tops + sorted(commands),
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
message = f"unknown command: {' '.join(arguments)}"
|
|
265
|
+
|
|
266
|
+
if suggestion is not None:
|
|
267
|
+
message += f"\ndid you mean '{suggestion}'?"
|
|
268
|
+
|
|
269
|
+
message += (
|
|
270
|
+
f"\navailable: {', '.join(tops)}"
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
parser = argparse.ArgumentParser(
|
|
274
|
+
prog=program,
|
|
275
|
+
add_help=False,
|
|
276
|
+
)
|
|
277
|
+
parser.error(message)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _suggest(
|
|
281
|
+
value: str,
|
|
282
|
+
candidates: Sequence[str],
|
|
283
|
+
) -> str | None:
|
|
284
|
+
matches = difflib.get_close_matches(
|
|
285
|
+
value,
|
|
286
|
+
sorted(set(candidates)),
|
|
287
|
+
n=1,
|
|
288
|
+
cutoff=0.6,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
return matches[0] if matches else None
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _invoke(
|
|
295
|
+
program: str,
|
|
296
|
+
command_name: str,
|
|
297
|
+
function: Callable[..., Any],
|
|
298
|
+
arguments: Sequence[str],
|
|
299
|
+
) -> int:
|
|
300
|
+
parser, positional_names, keyword_names = (
|
|
301
|
+
_create_command_parser(
|
|
302
|
+
program,
|
|
303
|
+
command_name,
|
|
304
|
+
function,
|
|
305
|
+
)
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
values = vars(parser.parse_args(arguments))
|
|
309
|
+
|
|
310
|
+
positional = [
|
|
311
|
+
values[name]
|
|
312
|
+
for name in positional_names
|
|
313
|
+
]
|
|
314
|
+
keyword = {
|
|
315
|
+
name: values[name]
|
|
316
|
+
for name in keyword_names
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
result = function(*positional, **keyword)
|
|
320
|
+
|
|
321
|
+
if inspect.isawaitable(result):
|
|
322
|
+
result = asyncio.run(result)
|
|
323
|
+
|
|
324
|
+
if result is None:
|
|
325
|
+
return 0
|
|
326
|
+
|
|
327
|
+
if isinstance(result, int) and not isinstance(
|
|
328
|
+
result,
|
|
329
|
+
bool,
|
|
330
|
+
):
|
|
331
|
+
return result
|
|
332
|
+
|
|
333
|
+
print(result)
|
|
334
|
+
return 0
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _create_command_parser(
|
|
338
|
+
program: str,
|
|
339
|
+
command_name: str,
|
|
340
|
+
function: Callable[..., Any],
|
|
341
|
+
) -> tuple[
|
|
342
|
+
argparse.ArgumentParser,
|
|
343
|
+
list[str],
|
|
344
|
+
list[str],
|
|
345
|
+
]:
|
|
346
|
+
signature = inspect.signature(function)
|
|
347
|
+
|
|
348
|
+
try:
|
|
349
|
+
annotations = get_type_hints(
|
|
350
|
+
function,
|
|
351
|
+
include_extras=True,
|
|
352
|
+
)
|
|
353
|
+
except Exception as error:
|
|
354
|
+
raise TypeError(
|
|
355
|
+
f"cannot resolve annotations for command "
|
|
356
|
+
f"{command_name!r}"
|
|
357
|
+
) from error
|
|
358
|
+
|
|
359
|
+
parser = argparse.ArgumentParser(
|
|
360
|
+
prog=(
|
|
361
|
+
f"{program} "
|
|
362
|
+
f"{command_name.replace('.', ' ')}"
|
|
363
|
+
),
|
|
364
|
+
description=inspect.getdoc(function),
|
|
365
|
+
formatter_class=(
|
|
366
|
+
argparse.RawDescriptionHelpFormatter
|
|
367
|
+
),
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
positional_names: list[str] = []
|
|
371
|
+
keyword_names: list[str] = []
|
|
372
|
+
|
|
373
|
+
for parameter in signature.parameters.values():
|
|
374
|
+
if parameter.kind is (
|
|
375
|
+
inspect.Parameter.VAR_POSITIONAL
|
|
376
|
+
):
|
|
377
|
+
raise TypeError(
|
|
378
|
+
f"command {command_name!r} uses "
|
|
379
|
+
f"unsupported *{parameter.name}"
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
if parameter.kind is (
|
|
383
|
+
inspect.Parameter.VAR_KEYWORD
|
|
384
|
+
):
|
|
385
|
+
raise TypeError(
|
|
386
|
+
f"command {command_name!r} uses "
|
|
387
|
+
f"unsupported **{parameter.name}"
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
annotation = annotations.get(
|
|
391
|
+
parameter.name,
|
|
392
|
+
parameter.annotation,
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
if annotation is inspect.Parameter.empty:
|
|
396
|
+
annotation = str
|
|
397
|
+
|
|
398
|
+
converter, choices = _converter(annotation)
|
|
399
|
+
|
|
400
|
+
if parameter.kind in (
|
|
401
|
+
inspect.Parameter.POSITIONAL_ONLY,
|
|
402
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
403
|
+
):
|
|
404
|
+
_add_positional(
|
|
405
|
+
parser,
|
|
406
|
+
parameter,
|
|
407
|
+
converter,
|
|
408
|
+
choices,
|
|
409
|
+
)
|
|
410
|
+
positional_names.append(parameter.name)
|
|
411
|
+
continue
|
|
412
|
+
|
|
413
|
+
if parameter.kind is (
|
|
414
|
+
inspect.Parameter.KEYWORD_ONLY
|
|
415
|
+
):
|
|
416
|
+
_add_option(
|
|
417
|
+
parser,
|
|
418
|
+
parameter,
|
|
419
|
+
annotation,
|
|
420
|
+
converter,
|
|
421
|
+
choices,
|
|
422
|
+
)
|
|
423
|
+
keyword_names.append(parameter.name)
|
|
424
|
+
continue
|
|
425
|
+
|
|
426
|
+
raise TypeError(
|
|
427
|
+
f"unsupported parameter kind for "
|
|
428
|
+
f"{parameter.name!r}"
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
return parser, positional_names, keyword_names
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _add_positional(
|
|
435
|
+
parser: argparse.ArgumentParser,
|
|
436
|
+
parameter: inspect.Parameter,
|
|
437
|
+
converter: Callable[[str], Any],
|
|
438
|
+
choices: Sequence[Any] | None,
|
|
439
|
+
) -> None:
|
|
440
|
+
options: dict[str, Any] = {
|
|
441
|
+
"type": converter,
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if choices is not None:
|
|
445
|
+
options["choices"] = choices
|
|
446
|
+
|
|
447
|
+
if parameter.default is not inspect.Parameter.empty:
|
|
448
|
+
options["nargs"] = "?"
|
|
449
|
+
options["default"] = parameter.default
|
|
450
|
+
|
|
451
|
+
parser.add_argument(
|
|
452
|
+
parameter.name,
|
|
453
|
+
**options,
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _add_option(
|
|
458
|
+
parser: argparse.ArgumentParser,
|
|
459
|
+
parameter: inspect.Parameter,
|
|
460
|
+
annotation: Any,
|
|
461
|
+
converter: Callable[[str], Any],
|
|
462
|
+
choices: Sequence[Any] | None,
|
|
463
|
+
) -> None:
|
|
464
|
+
option = (
|
|
465
|
+
"--"
|
|
466
|
+
+ parameter.name.replace("_", "-")
|
|
467
|
+
)
|
|
468
|
+
required = (
|
|
469
|
+
parameter.default
|
|
470
|
+
is inspect.Parameter.empty
|
|
471
|
+
)
|
|
472
|
+
default = (
|
|
473
|
+
None
|
|
474
|
+
if required
|
|
475
|
+
else parameter.default
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
base_annotation = _normalize_optional(
|
|
479
|
+
_strip_annotated(annotation)
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
if base_annotation is bool:
|
|
483
|
+
parser.add_argument(
|
|
484
|
+
option,
|
|
485
|
+
dest=parameter.name,
|
|
486
|
+
action=argparse.BooleanOptionalAction,
|
|
487
|
+
required=required,
|
|
488
|
+
default=default,
|
|
489
|
+
)
|
|
490
|
+
return
|
|
491
|
+
|
|
492
|
+
options: dict[str, Any] = {
|
|
493
|
+
"dest": parameter.name,
|
|
494
|
+
"type": converter,
|
|
495
|
+
"required": required,
|
|
496
|
+
"default": default,
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if choices is not None:
|
|
500
|
+
options["choices"] = choices
|
|
501
|
+
|
|
502
|
+
parser.add_argument(option, **options)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _converter(
|
|
506
|
+
annotation: Any,
|
|
507
|
+
) -> tuple[
|
|
508
|
+
Callable[[str], Any],
|
|
509
|
+
Sequence[Any] | None,
|
|
510
|
+
]:
|
|
511
|
+
annotation = _strip_annotated(annotation)
|
|
512
|
+
annotation = _normalize_optional(annotation)
|
|
513
|
+
|
|
514
|
+
origin = get_origin(annotation)
|
|
515
|
+
|
|
516
|
+
if origin is Literal:
|
|
517
|
+
values = get_args(annotation)
|
|
518
|
+
|
|
519
|
+
if not values:
|
|
520
|
+
raise TypeError(
|
|
521
|
+
"empty Literal is not supported"
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
value_types = {
|
|
525
|
+
type(value)
|
|
526
|
+
for value in values
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
if len(value_types) != 1:
|
|
530
|
+
raise TypeError(
|
|
531
|
+
"Literal parameters must contain "
|
|
532
|
+
"one value type"
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
value_type = next(iter(value_types))
|
|
536
|
+
converter, _ = _converter(value_type)
|
|
537
|
+
|
|
538
|
+
return converter, values
|
|
539
|
+
|
|
540
|
+
if annotation in (Any, str):
|
|
541
|
+
return str, None
|
|
542
|
+
|
|
543
|
+
if annotation is int:
|
|
544
|
+
return int, None
|
|
545
|
+
|
|
546
|
+
if annotation is float:
|
|
547
|
+
return float, None
|
|
548
|
+
|
|
549
|
+
if annotation is bool:
|
|
550
|
+
return _parse_bool, None
|
|
551
|
+
|
|
552
|
+
if annotation is Path:
|
|
553
|
+
return Path, None
|
|
554
|
+
|
|
555
|
+
if annotation is UUID:
|
|
556
|
+
return UUID, None
|
|
557
|
+
|
|
558
|
+
if (
|
|
559
|
+
inspect.isclass(annotation)
|
|
560
|
+
and issubclass(annotation, Enum)
|
|
561
|
+
):
|
|
562
|
+
return annotation, list(annotation)
|
|
563
|
+
|
|
564
|
+
raise TypeError(
|
|
565
|
+
"unsupported CLI parameter annotation: "
|
|
566
|
+
f"{annotation!r}"
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _strip_annotated(annotation: Any) -> Any:
|
|
571
|
+
if get_origin(annotation) is Annotated:
|
|
572
|
+
return get_args(annotation)[0]
|
|
573
|
+
|
|
574
|
+
return annotation
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _normalize_optional(annotation: Any) -> Any:
|
|
578
|
+
origin = get_origin(annotation)
|
|
579
|
+
|
|
580
|
+
if origin not in (Union, types.UnionType):
|
|
581
|
+
return annotation
|
|
582
|
+
|
|
583
|
+
arguments = get_args(annotation)
|
|
584
|
+
non_none = [
|
|
585
|
+
argument
|
|
586
|
+
for argument in arguments
|
|
587
|
+
if argument is not type(None)
|
|
588
|
+
]
|
|
589
|
+
|
|
590
|
+
if len(non_none) == len(arguments):
|
|
591
|
+
raise TypeError(
|
|
592
|
+
f"unsupported union annotation: "
|
|
593
|
+
f"{annotation!r}"
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
if len(non_none) != 1:
|
|
597
|
+
raise TypeError(
|
|
598
|
+
f"unsupported union annotation: "
|
|
599
|
+
f"{annotation!r}"
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
return non_none[0]
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _parse_bool(value: str) -> bool:
|
|
606
|
+
normalized = value.casefold()
|
|
607
|
+
|
|
608
|
+
if normalized in {
|
|
609
|
+
"1",
|
|
610
|
+
"true",
|
|
611
|
+
"yes",
|
|
612
|
+
"on",
|
|
613
|
+
}:
|
|
614
|
+
return True
|
|
615
|
+
|
|
616
|
+
if normalized in {
|
|
617
|
+
"0",
|
|
618
|
+
"false",
|
|
619
|
+
"no",
|
|
620
|
+
"off",
|
|
621
|
+
}:
|
|
622
|
+
return False
|
|
623
|
+
|
|
624
|
+
raise argparse.ArgumentTypeError(
|
|
625
|
+
f"expected a boolean value, got {value!r}"
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
__all__ = ["run"]
|
|
630
|
+
|