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.
@@ -0,0 +1 @@
1
+ __pycache__/
@@ -0,0 +1,5 @@
1
+ Metadata-Version: 2.5
2
+ Name: cohaerum-cli
3
+ Version: 0.1.0
4
+ Requires-Python: >=3.12
5
+ Requires-Dist: cohaerum
@@ -0,0 +1,12 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.21"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "cohaerum-cli"
7
+ version = "0.1.0"
8
+ requires-python = ">=3.12"
9
+
10
+ dependencies = [
11
+ "cohaerum",
12
+ ]
@@ -0,0 +1,7 @@
1
+ {
2
+ "venvPath": "../..",
3
+ "venv": ".venv",
4
+ "include": ["src"],
5
+ "exclude": [".venv", "__pycache__"],
6
+ "pythonVersion": "3.12"
7
+ }
@@ -0,0 +1,5 @@
1
+ from .provider import CLIProvider as Provider
2
+
3
+ name = "cli"
4
+
5
+ __all__ = ["Provider", "name"]
@@ -0,0 +1,4 @@
1
+ from cohaerum import register_factory
2
+ from .provider import CLIProvider
3
+
4
+ register_factory("cli", CLIProvider)
@@ -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
+