protocli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
protocli/__init__.py
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""Signature-driven CLI dispatcher.
|
|
2
|
+
|
|
3
|
+
Command values are either module paths (``str``) for lazy import, or callables
|
|
4
|
+
for direct invocation. Module-backed commands support nested dispatch (set a
|
|
5
|
+
module-level ``_dispatcher``) or signature-driven argument parsing.
|
|
6
|
+
|
|
7
|
+
Argument handling: if the leaf's ``main`` (or a callable target) takes no
|
|
8
|
+
parameters, the dispatcher intercepts ``-h``/``--help`` and prints
|
|
9
|
+
``Usage: <prog> [args...]`` + the docstring. If it takes parameters, the
|
|
10
|
+
dispatcher introspects the signature and builds an ``argparse`` parser from
|
|
11
|
+
it. Signature → CLI shape:
|
|
12
|
+
|
|
13
|
+
- ``name: str`` → required positional
|
|
14
|
+
- ``name: str | None = None`` → optional positional (``nargs="?"``)
|
|
15
|
+
- ``*files: str`` → variadic positional (``nargs="*"``)
|
|
16
|
+
- ``*, force: bool = False`` → ``--force`` (``store_true``); bools must be
|
|
17
|
+
keyword-only with default ``False`` (name with
|
|
18
|
+
negation built in if needed, e.g. ``no_color``)
|
|
19
|
+
- ``*, n: int = 5`` → ``--n N`` with default
|
|
20
|
+
- ``*, m: Literal["a","b"]`` → ``--m`` restricted to choices
|
|
21
|
+
- ``*, xs: list[float] = []`` → ``--xs 1.5,3.5`` (comma-separated; ``""`` → [];
|
|
22
|
+
keyword-only, like bools)
|
|
23
|
+
|
|
24
|
+
Argparse owns ``--help`` and error messages for signature-dispatched leaves.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import argparse
|
|
28
|
+
import importlib
|
|
29
|
+
import inspect
|
|
30
|
+
import pkgutil
|
|
31
|
+
import sys
|
|
32
|
+
import types
|
|
33
|
+
import typing
|
|
34
|
+
from collections.abc import Callable
|
|
35
|
+
|
|
36
|
+
__version__ = "0.1.0"
|
|
37
|
+
|
|
38
|
+
_SKIP = frozenset({"__main__", "__init__", "constants", "cli"})
|
|
39
|
+
DISP_VAR = "_dispatcher"
|
|
40
|
+
COMP_FUN = "get_completions"
|
|
41
|
+
_HELP_FLAGS = (["-h"], ["--help"])
|
|
42
|
+
|
|
43
|
+
# Sentinel a leaf's ``get_completions`` may return (as the sole candidate) to
|
|
44
|
+
# request the shell's native pathname completion — handles ``~``, ``/``, ``$VAR``
|
|
45
|
+
# and absolute/relative paths, which a static candidate list cannot. Recognised
|
|
46
|
+
# by the ``_proto_complete`` bash function; keep the two literals in sync.
|
|
47
|
+
FILE_COMPLETION = "\x1bFILES"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _unwrap_annotation(ann: object) -> tuple[object, list | None]:
|
|
51
|
+
"""Return ``(base_type, choices_or_None)``.
|
|
52
|
+
|
|
53
|
+
Handles ``Literal[...]`` (returns element type + values as choices) and
|
|
54
|
+
``Optional[T]``/``T | None`` (returns ``T``, no choices).
|
|
55
|
+
"""
|
|
56
|
+
origin = typing.get_origin(ann)
|
|
57
|
+
args = typing.get_args(ann)
|
|
58
|
+
if origin is typing.Literal:
|
|
59
|
+
return type(args[0]), list(args)
|
|
60
|
+
if origin is typing.Union or origin is types.UnionType:
|
|
61
|
+
non_none = [a for a in args if a is not type(None)]
|
|
62
|
+
if len(non_none) == 1:
|
|
63
|
+
return _unwrap_annotation(non_none[0])
|
|
64
|
+
return ann, None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _csv_of(elem: type) -> Callable[[str], list]:
|
|
68
|
+
"""Parser for a comma-separated ``list[elem]`` flag value; ``""`` → []."""
|
|
69
|
+
|
|
70
|
+
def parse(s: str) -> list:
|
|
71
|
+
return [elem(e) for e in s.split(",") if e]
|
|
72
|
+
|
|
73
|
+
return parse
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _build_parser(prog: str, fn: Callable) -> argparse.ArgumentParser:
|
|
77
|
+
"""Generate an argparse parser from ``fn``'s signature."""
|
|
78
|
+
sig = inspect.signature(fn)
|
|
79
|
+
hints = typing.get_type_hints(fn)
|
|
80
|
+
doc = (fn.__doc__ or "").strip().split("\n", 1)[0]
|
|
81
|
+
parser = argparse.ArgumentParser(prog=prog, description=doc)
|
|
82
|
+
for name, param in sig.parameters.items():
|
|
83
|
+
raw_ann = hints.get(name, str)
|
|
84
|
+
ann, choices = _unwrap_annotation(raw_ann)
|
|
85
|
+
kwargs: dict = {}
|
|
86
|
+
if choices is not None:
|
|
87
|
+
kwargs["choices"] = choices
|
|
88
|
+
kind = param.kind
|
|
89
|
+
if kind is inspect.Parameter.VAR_POSITIONAL:
|
|
90
|
+
kwargs["type"] = ann
|
|
91
|
+
kwargs["nargs"] = "*"
|
|
92
|
+
parser.add_argument(name, **kwargs)
|
|
93
|
+
continue
|
|
94
|
+
if ann is bool:
|
|
95
|
+
if kind is not inspect.Parameter.KEYWORD_ONLY or param.default is not False:
|
|
96
|
+
raise TypeError(
|
|
97
|
+
f"bool param {name!r} must be keyword-only with default False"
|
|
98
|
+
)
|
|
99
|
+
kwargs["action"] = "store_true"
|
|
100
|
+
parser.add_argument(f"--{name.replace('_', '-')}", **kwargs)
|
|
101
|
+
continue
|
|
102
|
+
if typing.get_origin(ann) is list:
|
|
103
|
+
if kind is not inspect.Parameter.KEYWORD_ONLY:
|
|
104
|
+
raise TypeError(f"list param {name!r} must be keyword-only")
|
|
105
|
+
elem = (typing.get_args(ann) or (str,))[0]
|
|
106
|
+
kwargs["type"] = _csv_of(elem)
|
|
107
|
+
kwargs["default"] = param.default
|
|
108
|
+
parser.add_argument(f"--{name.replace('_', '-')}", **kwargs)
|
|
109
|
+
continue
|
|
110
|
+
kwargs["type"] = ann
|
|
111
|
+
if kind is inspect.Parameter.KEYWORD_ONLY:
|
|
112
|
+
kwargs["default"] = param.default
|
|
113
|
+
parser.add_argument(f"--{name.replace('_', '-')}", **kwargs)
|
|
114
|
+
elif param.default is not inspect.Parameter.empty:
|
|
115
|
+
kwargs["default"] = param.default
|
|
116
|
+
kwargs["nargs"] = "?"
|
|
117
|
+
parser.add_argument(name, **kwargs)
|
|
118
|
+
else:
|
|
119
|
+
parser.add_argument(name, **kwargs)
|
|
120
|
+
return parser
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _sig_help(prog: str, fn: Callable) -> list[str] | None:
|
|
124
|
+
"""Return ``[parser.format_help().rstrip()]`` if ``fn`` has parameters."""
|
|
125
|
+
if not inspect.signature(fn).parameters:
|
|
126
|
+
return None
|
|
127
|
+
return [_build_parser(prog, fn).format_help().rstrip()]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _sig_completions(fn: Callable, rest: list[str]) -> list[str]:
|
|
131
|
+
"""Derive shell-completion candidates from ``fn``'s signature.
|
|
132
|
+
|
|
133
|
+
Returns the keyword-only flag names; if the previous token is a flag whose
|
|
134
|
+
annotation is ``Literal[...]``, returns those choices instead.
|
|
135
|
+
"""
|
|
136
|
+
sig = inspect.signature(fn)
|
|
137
|
+
if not sig.parameters:
|
|
138
|
+
return []
|
|
139
|
+
hints = typing.get_type_hints(fn)
|
|
140
|
+
if rest:
|
|
141
|
+
last = rest[-1]
|
|
142
|
+
for name, param in sig.parameters.items():
|
|
143
|
+
if param.kind is not inspect.Parameter.KEYWORD_ONLY:
|
|
144
|
+
continue
|
|
145
|
+
if last != f"--{name.replace('_', '-')}":
|
|
146
|
+
continue
|
|
147
|
+
_, choices = _unwrap_annotation(hints.get(name, str))
|
|
148
|
+
return choices or []
|
|
149
|
+
return [
|
|
150
|
+
f"--{name.replace('_', '-')}"
|
|
151
|
+
for name, param in sig.parameters.items()
|
|
152
|
+
if param.kind is inspect.Parameter.KEYWORD_ONLY
|
|
153
|
+
]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class Dispatcher:
|
|
157
|
+
"""Unified CLI dispatcher.
|
|
158
|
+
|
|
159
|
+
Construct directly with an explicit command dict, or via ``from_package``
|
|
160
|
+
for auto-discovery. Dict values that are ``str`` are treated as module
|
|
161
|
+
paths (lazy-imported on dispatch); callables are invoked directly. Leaf
|
|
162
|
+
``main()`` / callable targets either take no params (dispatcher handles
|
|
163
|
+
``--help`` via docstring) or accept signature-driven args (argparse owns
|
|
164
|
+
``--help`` and parsing).
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
def __init__(self, prog: str, commands: dict[str, str | Callable]):
|
|
168
|
+
self._prog = prog
|
|
169
|
+
self._commands = commands
|
|
170
|
+
|
|
171
|
+
@classmethod
|
|
172
|
+
def from_package(cls, package: str, prog: str | None = None) -> "Dispatcher":
|
|
173
|
+
mod = importlib.import_module(package)
|
|
174
|
+
commands: dict[str, str | Callable] = {
|
|
175
|
+
m.name: f"{package}.{m.name}"
|
|
176
|
+
for m in pkgutil.iter_modules(mod.__path__)
|
|
177
|
+
if m.name not in _SKIP and not m.name.startswith("_")
|
|
178
|
+
}
|
|
179
|
+
return cls(prog=prog or package.rsplit(".", 1)[-1], commands=commands)
|
|
180
|
+
|
|
181
|
+
def commands(self) -> list[str]:
|
|
182
|
+
return sorted(self._commands)
|
|
183
|
+
|
|
184
|
+
def tree(self) -> dict[str, dict | None]:
|
|
185
|
+
"""Return nested command tree. Leaves are ``None``."""
|
|
186
|
+
result: dict[str, dict | None] = {}
|
|
187
|
+
for cmd in self.commands():
|
|
188
|
+
target = self._commands[cmd]
|
|
189
|
+
if callable(target):
|
|
190
|
+
result[cmd] = None
|
|
191
|
+
continue
|
|
192
|
+
try:
|
|
193
|
+
mod = importlib.import_module(target)
|
|
194
|
+
except ImportError:
|
|
195
|
+
result[cmd] = None
|
|
196
|
+
continue
|
|
197
|
+
child = getattr(mod, DISP_VAR, None)
|
|
198
|
+
result[cmd] = child.tree() if child is not None else None
|
|
199
|
+
return result
|
|
200
|
+
|
|
201
|
+
def _get_doc(self, cmd: str) -> str:
|
|
202
|
+
target = self._commands[cmd]
|
|
203
|
+
if callable(target):
|
|
204
|
+
return (target.__doc__ or "").strip().split("\n")[0]
|
|
205
|
+
try:
|
|
206
|
+
mod = importlib.import_module(target)
|
|
207
|
+
return (mod.__doc__ or "").strip().split("\n")[0]
|
|
208
|
+
except ImportError:
|
|
209
|
+
return ""
|
|
210
|
+
|
|
211
|
+
def _help_lines(self) -> list[str]:
|
|
212
|
+
cmds = self.commands()
|
|
213
|
+
lines = [f"Usage: {self._prog} <command> [args...]"]
|
|
214
|
+
if cmds:
|
|
215
|
+
width = max(len(c) for c in cmds)
|
|
216
|
+
lines.append("Commands:")
|
|
217
|
+
for cmd in cmds:
|
|
218
|
+
doc = self._get_doc(cmd)
|
|
219
|
+
suffix = f" {doc}" if doc else ""
|
|
220
|
+
lines.append(f" {cmd:<{width}}{suffix}")
|
|
221
|
+
return lines
|
|
222
|
+
|
|
223
|
+
def _collect_help_all(self, sections: list[list[str]]) -> None:
|
|
224
|
+
sections.append(self._help_lines())
|
|
225
|
+
for cmd in self.commands():
|
|
226
|
+
prog = f"{self._prog} {cmd}"
|
|
227
|
+
target = self._commands[cmd]
|
|
228
|
+
if callable(target):
|
|
229
|
+
sig = _sig_help(prog, target)
|
|
230
|
+
if sig:
|
|
231
|
+
sections.append(sig)
|
|
232
|
+
continue
|
|
233
|
+
try:
|
|
234
|
+
mod = importlib.import_module(target)
|
|
235
|
+
except ImportError:
|
|
236
|
+
continue
|
|
237
|
+
child = getattr(mod, DISP_VAR, None)
|
|
238
|
+
if child is not None:
|
|
239
|
+
child._collect_help_all(sections)
|
|
240
|
+
continue
|
|
241
|
+
sig = _sig_help(prog, mod.main)
|
|
242
|
+
if sig:
|
|
243
|
+
sections.append(sig)
|
|
244
|
+
continue
|
|
245
|
+
complete_fn = getattr(mod, COMP_FUN, None)
|
|
246
|
+
if complete_fn:
|
|
247
|
+
subs = complete_fn([])
|
|
248
|
+
if subs:
|
|
249
|
+
sections.append([prog, f" {', '.join(subs)}"])
|
|
250
|
+
|
|
251
|
+
@staticmethod
|
|
252
|
+
def _print_leaf_help(prog: str, doc: str | None) -> None:
|
|
253
|
+
print(f"Usage: {prog} [args...]")
|
|
254
|
+
text = (doc or "").strip()
|
|
255
|
+
if text:
|
|
256
|
+
print(text)
|
|
257
|
+
|
|
258
|
+
def _invoke(
|
|
259
|
+
self, prog: str, fn: Callable, rest: list[str], doc: str | None
|
|
260
|
+
) -> None:
|
|
261
|
+
sys.argv = [prog, *rest]
|
|
262
|
+
if not inspect.signature(fn).parameters:
|
|
263
|
+
if rest[:1] in _HELP_FLAGS:
|
|
264
|
+
self._print_leaf_help(prog, doc)
|
|
265
|
+
return
|
|
266
|
+
fn()
|
|
267
|
+
return
|
|
268
|
+
parser = _build_parser(prog, fn)
|
|
269
|
+
parsed = parser.parse_args(rest)
|
|
270
|
+
call_args, call_kwargs = [], {}
|
|
271
|
+
for pname, param in inspect.signature(fn).parameters.items():
|
|
272
|
+
val = getattr(parsed, pname)
|
|
273
|
+
if param.kind is inspect.Parameter.VAR_POSITIONAL:
|
|
274
|
+
call_args.extend(val)
|
|
275
|
+
elif param.kind is inspect.Parameter.KEYWORD_ONLY:
|
|
276
|
+
call_kwargs[pname] = val
|
|
277
|
+
else:
|
|
278
|
+
call_args.append(val)
|
|
279
|
+
fn(*call_args, **call_kwargs)
|
|
280
|
+
|
|
281
|
+
def run(self) -> None:
|
|
282
|
+
argv = sys.argv[1:]
|
|
283
|
+
cmds = self.commands()
|
|
284
|
+
|
|
285
|
+
if not argv or [argv[0]] in _HELP_FLAGS:
|
|
286
|
+
print("\n".join(self._help_lines()))
|
|
287
|
+
return
|
|
288
|
+
|
|
289
|
+
if argv[0] == "--help-all":
|
|
290
|
+
sections: list[list[str]] = []
|
|
291
|
+
self._collect_help_all(sections)
|
|
292
|
+
print("\n\n".join("\n".join(s) for s in sections))
|
|
293
|
+
return
|
|
294
|
+
|
|
295
|
+
if argv[0] == "--complete":
|
|
296
|
+
for line in self.get_completions(argv[1:]):
|
|
297
|
+
print(line)
|
|
298
|
+
return
|
|
299
|
+
|
|
300
|
+
cmd, *rest = argv
|
|
301
|
+
if cmd not in cmds:
|
|
302
|
+
print(f"Unknown command: {cmd}", file=sys.stderr)
|
|
303
|
+
raise SystemExit(1)
|
|
304
|
+
|
|
305
|
+
target = self._commands[cmd]
|
|
306
|
+
prog = f"{self._prog} {cmd}"
|
|
307
|
+
|
|
308
|
+
if callable(target):
|
|
309
|
+
self._invoke(prog, target, rest, target.__doc__)
|
|
310
|
+
return
|
|
311
|
+
|
|
312
|
+
mod = importlib.import_module(target)
|
|
313
|
+
child = getattr(mod, DISP_VAR, None)
|
|
314
|
+
if child is not None:
|
|
315
|
+
sys.argv = [prog, *rest]
|
|
316
|
+
child.run()
|
|
317
|
+
return
|
|
318
|
+
self._invoke(prog, mod.main, rest, mod.__doc__)
|
|
319
|
+
|
|
320
|
+
def get_completions(self, args: list[str]) -> list[str]:
|
|
321
|
+
cmds = self.commands()
|
|
322
|
+
if not args:
|
|
323
|
+
return cmds
|
|
324
|
+
cmd, *rest = args
|
|
325
|
+
if cmd not in cmds:
|
|
326
|
+
return []
|
|
327
|
+
target = self._commands[cmd]
|
|
328
|
+
if callable(target):
|
|
329
|
+
return _sig_completions(target, rest)
|
|
330
|
+
mod = importlib.import_module(target)
|
|
331
|
+
child = getattr(mod, DISP_VAR, None)
|
|
332
|
+
if child is not None:
|
|
333
|
+
return child.get_completions(rest)
|
|
334
|
+
complete_fn = getattr(mod, COMP_FUN, None)
|
|
335
|
+
if complete_fn is not None:
|
|
336
|
+
return complete_fn(rest)
|
|
337
|
+
return _sig_completions(mod.main, rest)
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import types
|
|
3
|
+
import typing
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from protocli import (
|
|
8
|
+
FILE_COMPLETION,
|
|
9
|
+
Dispatcher,
|
|
10
|
+
_build_parser,
|
|
11
|
+
_unwrap_annotation,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_unwrap_annotation() -> None:
|
|
16
|
+
assert _unwrap_annotation(int) == (int, None)
|
|
17
|
+
assert _unwrap_annotation(typing.Literal["a", "b"]) == (str, ["a", "b"])
|
|
18
|
+
assert _unwrap_annotation(typing.Optional[int]) == (int, None)
|
|
19
|
+
assert _unwrap_annotation(str | None) == (str, None)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_parser_shapes() -> None:
|
|
23
|
+
def fn(
|
|
24
|
+
name: str,
|
|
25
|
+
opt: str | None = None,
|
|
26
|
+
*files: str,
|
|
27
|
+
force: bool = False,
|
|
28
|
+
n: int = 5,
|
|
29
|
+
mode: typing.Literal["a", "b"] = "a",
|
|
30
|
+
) -> None: ...
|
|
31
|
+
|
|
32
|
+
parser = _build_parser("prog", fn)
|
|
33
|
+
ns = parser.parse_args(["x", "y", "f1", "f2", "--force", "--n", "7", "--mode", "b"])
|
|
34
|
+
assert (ns.name, ns.opt, ns.files) == ("x", "y", ["f1", "f2"])
|
|
35
|
+
assert (ns.force, ns.n, ns.mode) == (True, 7, "b")
|
|
36
|
+
ns = parser.parse_args(["x"])
|
|
37
|
+
assert (ns.opt, ns.files, ns.force, ns.n, ns.mode) == (None, [], False, 5, "a")
|
|
38
|
+
with pytest.raises(SystemExit):
|
|
39
|
+
parser.parse_args(["x", "--mode", "c"])
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_list_flags() -> None:
|
|
43
|
+
def fn(*, bins: list[float] = [], procs: list[int] = [16, 1]) -> None: ...
|
|
44
|
+
|
|
45
|
+
parser = _build_parser("prog", fn)
|
|
46
|
+
ns = parser.parse_args(["--bins", "1.5,3.5", "--procs", "4"])
|
|
47
|
+
assert ns.bins == [1.5, 3.5] and ns.procs == [4]
|
|
48
|
+
ns = parser.parse_args(["--bins", ""])
|
|
49
|
+
assert ns.bins == [] and ns.procs == [16, 1]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_list_must_be_keyword_only() -> None:
|
|
53
|
+
def fn(bins: list[float] = []) -> None: ...
|
|
54
|
+
|
|
55
|
+
with pytest.raises(TypeError, match="keyword-only"):
|
|
56
|
+
_build_parser("prog", fn)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_bool_constraints() -> None:
|
|
60
|
+
def positional(flag: bool) -> None: ...
|
|
61
|
+
|
|
62
|
+
def defaulted_true(*, flag: bool = True) -> None: ...
|
|
63
|
+
|
|
64
|
+
for fn in (positional, defaulted_true):
|
|
65
|
+
with pytest.raises(TypeError, match="keyword-only with default False"):
|
|
66
|
+
_build_parser("prog", fn)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _run(disp: Dispatcher, *argv: str, monkeypatch) -> None:
|
|
70
|
+
monkeypatch.setattr(sys, "argv", ["prog", *argv])
|
|
71
|
+
disp.run()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_callable_dispatch(monkeypatch) -> None:
|
|
75
|
+
calls = []
|
|
76
|
+
|
|
77
|
+
def greet(name: str, *, loud: bool = False) -> None:
|
|
78
|
+
calls.append((name, loud))
|
|
79
|
+
|
|
80
|
+
disp = Dispatcher("prog", {"greet": greet})
|
|
81
|
+
_run(disp, "greet", "ada", "--loud", monkeypatch=monkeypatch)
|
|
82
|
+
assert calls == [("ada", True)]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_module_and_nested_dispatch(monkeypatch, capsys) -> None:
|
|
86
|
+
leaf = types.ModuleType("fake_leaf")
|
|
87
|
+
leaf.__doc__ = "Leaf docs."
|
|
88
|
+
calls = []
|
|
89
|
+
leaf.main = lambda: calls.append("ran")
|
|
90
|
+
sys.modules["fake_leaf"] = leaf
|
|
91
|
+
|
|
92
|
+
inner = types.ModuleType("fake_nested")
|
|
93
|
+
inner.ping = lambda: calls.append("pong")
|
|
94
|
+
inner._dispatcher = Dispatcher("prog nest", {"ping": inner.ping})
|
|
95
|
+
sys.modules["fake_nested"] = inner
|
|
96
|
+
|
|
97
|
+
disp = Dispatcher("prog", {"leaf": "fake_leaf", "nest": "fake_nested"})
|
|
98
|
+
_run(disp, "leaf", monkeypatch=monkeypatch)
|
|
99
|
+
_run(disp, "nest", "ping", monkeypatch=monkeypatch)
|
|
100
|
+
assert calls == ["ran", "pong"]
|
|
101
|
+
assert disp.tree() == {"leaf": None, "nest": {"ping": None}}
|
|
102
|
+
|
|
103
|
+
_run(disp, "leaf", "-h", monkeypatch=monkeypatch)
|
|
104
|
+
out = capsys.readouterr().out
|
|
105
|
+
assert "Usage: prog leaf [args...]" in out and "Leaf docs." in out
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_unknown_command_exits(monkeypatch) -> None:
|
|
109
|
+
disp = Dispatcher("prog", {})
|
|
110
|
+
with pytest.raises(SystemExit):
|
|
111
|
+
_run(disp, "ghost", monkeypatch=monkeypatch)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_top_help_lists_commands(monkeypatch, capsys) -> None:
|
|
115
|
+
def doer() -> None:
|
|
116
|
+
"""Does things."""
|
|
117
|
+
|
|
118
|
+
disp = Dispatcher("prog", {"do": doer})
|
|
119
|
+
_run(disp, monkeypatch=monkeypatch)
|
|
120
|
+
out = capsys.readouterr().out
|
|
121
|
+
assert "Usage: prog <command>" in out and "do Does things." in out
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_completions() -> None:
|
|
125
|
+
def fn(*, mode: typing.Literal["x", "y"] = "x", n: int = 1) -> None: ...
|
|
126
|
+
|
|
127
|
+
disp = Dispatcher("prog", {"cmd": fn})
|
|
128
|
+
assert disp.get_completions([]) == ["cmd"]
|
|
129
|
+
assert sorted(disp.get_completions(["cmd"])) == ["--mode", "--n"]
|
|
130
|
+
assert disp.get_completions(["cmd", "--mode"]) == ["x", "y"]
|
|
131
|
+
assert disp.get_completions(["ghost"]) == []
|
|
132
|
+
assert FILE_COMPLETION.startswith("\x1b")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_from_package(tmp_path, monkeypatch) -> None:
|
|
136
|
+
pkg = tmp_path / "fakepkg"
|
|
137
|
+
pkg.mkdir()
|
|
138
|
+
(pkg / "__init__.py").write_text("")
|
|
139
|
+
(pkg / "hello.py").write_text('"""Say hi."""\ndef main():\n print("hi")\n')
|
|
140
|
+
(pkg / "_private.py").write_text("def main():\n pass\n")
|
|
141
|
+
monkeypatch.syspath_prepend(str(tmp_path))
|
|
142
|
+
|
|
143
|
+
disp = Dispatcher.from_package("fakepkg")
|
|
144
|
+
assert disp.commands() == ["hello"]
|
|
145
|
+
monkeypatch.setattr(sys, "argv", ["fakepkg", "hello"])
|
|
146
|
+
disp.run()
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: protocli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Signature-driven CLI dispatcher: typed function signatures become argparse parsers, modules become lazily-imported subcommands, with nested dispatch, --help-all and shell completions.
|
|
5
|
+
Project-URL: Homepage, https://github.com/endremborza/protocli
|
|
6
|
+
Author-email: Endre Márk Borza <endremborza@gmail.com>
|
|
7
|
+
License: Copyright 2026 Endre Márk Borza
|
|
8
|
+
|
|
9
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# protocli
|
|
19
|
+
|
|
20
|
+
[](https://pypi.org/project/protocli/)
|
|
21
|
+
|
|
22
|
+
Signature-driven CLI dispatcher: typed function signatures become argparse parsers, modules become lazily-imported subcommands, with nested dispatch, `--help-all` and shell completions.
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
# mytool/__main__.py
|
|
26
|
+
from protocli import Dispatcher
|
|
27
|
+
|
|
28
|
+
Dispatcher(
|
|
29
|
+
"mytool",
|
|
30
|
+
{
|
|
31
|
+
"greet": greet, # a callable: its signature IS the parser
|
|
32
|
+
"db": "mytool.db", # a module path: lazy-imported on dispatch
|
|
33
|
+
},
|
|
34
|
+
).run()
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
A callable (or a module's `main`) maps to CLI arguments by signature:
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
def greet(
|
|
41
|
+
name: str, # required positional
|
|
42
|
+
title: str | None = None, # optional positional
|
|
43
|
+
*rest: str, # variadic
|
|
44
|
+
loud: bool = False, # --loud (keyword-only, default False)
|
|
45
|
+
times: int = 1, # --times N
|
|
46
|
+
lang: Literal["en", "hu"] = "en", # --lang restricted to choices
|
|
47
|
+
tags: list[str] = [], # --tags a,b,c (comma-separated)
|
|
48
|
+
): ...
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
A module can instead expose its own nested `_dispatcher = Dispatcher(...)`,
|
|
52
|
+
or a `get_completions(rest)` hook for custom shell completion (return
|
|
53
|
+
`[FILE_COMPLETION]` to request native pathname completion).
|
|
54
|
+
|
|
55
|
+
`mytool --help-all` prints every parser in the tree; `mytool --complete <args…>`
|
|
56
|
+
emits completion candidates for shell integration.
|
|
57
|
+
|
|
58
|
+
Auto-discovery over a package's public modules:
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
Dispatcher.from_package("mytool").run()
|
|
62
|
+
```
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
protocli/__init__.py,sha256=quPZbR5ugsPI3t2zTLsdPlhjf463cTPeNp1sqqbJidc,12578
|
|
2
|
+
protocli/tests/test_dispatcher.py,sha256=5Zr3FOGvKtg4ZYAgT_uw6V0zZU8waFgSwG_qvP-x5dk,4729
|
|
3
|
+
protocli-0.1.0.dist-info/METADATA,sha256=VieX6jr-KQbRSku2wXjFP8NXfWvHuRAyVMdcelaoBTY,3110
|
|
4
|
+
protocli-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
5
|
+
protocli-0.1.0.dist-info/licenses/LICENSE,sha256=NrHeFb1pfm6nQHXGcuumvUALSfJjJv9EZTv4rK-jwR0,1056
|
|
6
|
+
protocli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Copyright 2026 Endre Márk Borza
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
4
|
+
|
|
5
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|