slick-cli 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.
- slick_cli/__init__.py +40 -0
- slick_cli/app.py +255 -0
- slick_cli/docstrings.py +81 -0
- slick_cli/errors.py +29 -0
- slick_cli/params.py +295 -0
- slick_cli/py.typed +0 -0
- slick_cli/style.py +166 -0
- slick_cli/testing.py +51 -0
- slick_cli-0.1.0.dist-info/METADATA +243 -0
- slick_cli-0.1.0.dist-info/RECORD +13 -0
- slick_cli-0.1.0.dist-info/WHEEL +5 -0
- slick_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- slick_cli-0.1.0.dist-info/top_level.txt +1 -0
slick_cli/__init__.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""slick_cli: ergonomic, type-hint driven command-line apps on the standard library.
|
|
2
|
+
|
|
3
|
+
Quick example::
|
|
4
|
+
|
|
5
|
+
from slick_cli import App, echo
|
|
6
|
+
|
|
7
|
+
app = App("hello")
|
|
8
|
+
|
|
9
|
+
@app.command
|
|
10
|
+
def greet(name: str, count: int = 1, shout: bool = False) -> None:
|
|
11
|
+
'''Greet someone.'''
|
|
12
|
+
for _ in range(count):
|
|
13
|
+
echo(name.upper() if shout else name)
|
|
14
|
+
|
|
15
|
+
if __name__ == "__main__":
|
|
16
|
+
app()
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from .app import App, Command, run
|
|
20
|
+
from .errors import CliError, abort
|
|
21
|
+
from .params import Arg
|
|
22
|
+
from .style import confirm, echo, secho, should_color, style, unstyle
|
|
23
|
+
|
|
24
|
+
__version__ = "0.1.0"
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"App",
|
|
28
|
+
"Arg",
|
|
29
|
+
"CliError",
|
|
30
|
+
"Command",
|
|
31
|
+
"abort",
|
|
32
|
+
"confirm",
|
|
33
|
+
"echo",
|
|
34
|
+
"run",
|
|
35
|
+
"secho",
|
|
36
|
+
"should_color",
|
|
37
|
+
"style",
|
|
38
|
+
"unstyle",
|
|
39
|
+
"__version__",
|
|
40
|
+
]
|
slick_cli/app.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Applications, commands and dispatch."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import inspect
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from typing import Any, Callable, NoReturn, TypeVar, overload
|
|
10
|
+
|
|
11
|
+
from .docstrings import parse_docstring
|
|
12
|
+
from .errors import CliError
|
|
13
|
+
from .params import Param, params_from_function
|
|
14
|
+
from .style import echo, style
|
|
15
|
+
|
|
16
|
+
__all__ = ["App", "Command", "run"]
|
|
17
|
+
|
|
18
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
19
|
+
|
|
20
|
+
_TARGET = "_slick_target"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _HelpFormatter(argparse.RawDescriptionHelpFormatter):
|
|
24
|
+
"""Keeps docstring line breaks and gives the help column a bit more room."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, prog: str) -> None:
|
|
27
|
+
super().__init__(prog, max_help_position=30)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Command:
|
|
31
|
+
"""A function exposed as a CLI command.
|
|
32
|
+
|
|
33
|
+
Normally created through :meth:`App.command`; the decorated function itself
|
|
34
|
+
is returned unchanged, so it stays directly callable and testable.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
func: Callable[..., Any],
|
|
40
|
+
name: str | None = None,
|
|
41
|
+
*,
|
|
42
|
+
help: str | None = None,
|
|
43
|
+
aliases: Sequence[str] = (),
|
|
44
|
+
) -> None:
|
|
45
|
+
doc = parse_docstring(func.__doc__)
|
|
46
|
+
self.func = func
|
|
47
|
+
self.name = name or func.__name__.strip("_").replace("_", "-")
|
|
48
|
+
self.help = help or doc.summary
|
|
49
|
+
self.description = "\n\n".join(p for p in (self.help, doc.description) if p)
|
|
50
|
+
self.aliases = tuple(aliases)
|
|
51
|
+
self.params: list[Param] = params_from_function(func, doc.params)
|
|
52
|
+
|
|
53
|
+
def configure(self, parser: argparse.ArgumentParser) -> None:
|
|
54
|
+
"""Add this command's arguments to ``parser`` and mark it as the target."""
|
|
55
|
+
for param in self.params:
|
|
56
|
+
param.add_to(parser)
|
|
57
|
+
parser.set_defaults(**{_TARGET: (self, parser)})
|
|
58
|
+
|
|
59
|
+
def invoke(self, namespace: argparse.Namespace) -> Any:
|
|
60
|
+
"""Call the function with values taken from a parsed ``namespace``."""
|
|
61
|
+
args: list[Any] = []
|
|
62
|
+
kwargs: dict[str, Any] = {}
|
|
63
|
+
for param in self.params:
|
|
64
|
+
value = param.resolve(namespace)
|
|
65
|
+
if param.kind == "varargs":
|
|
66
|
+
args.extend(value)
|
|
67
|
+
elif param.call_kind is inspect.Parameter.KEYWORD_ONLY:
|
|
68
|
+
kwargs[param.name] = value
|
|
69
|
+
else:
|
|
70
|
+
args.append(value)
|
|
71
|
+
return self.func(*args, **kwargs)
|
|
72
|
+
|
|
73
|
+
def __repr__(self) -> str:
|
|
74
|
+
return f"Command({self.name!r})"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class App:
|
|
78
|
+
"""A command-line application: a named collection of commands and sub-apps.
|
|
79
|
+
|
|
80
|
+
Example::
|
|
81
|
+
|
|
82
|
+
app = App("tool", version="1.0")
|
|
83
|
+
|
|
84
|
+
@app.command
|
|
85
|
+
def hello(name: str, count: int = 1) -> None:
|
|
86
|
+
'''Say hello.'''
|
|
87
|
+
for _ in range(count):
|
|
88
|
+
echo(f"Hello, {name}!")
|
|
89
|
+
|
|
90
|
+
if __name__ == "__main__":
|
|
91
|
+
app()
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def __init__(self, name: str | None = None, help: str | None = None, *, version: str | None = None) -> None:
|
|
95
|
+
self.name = name
|
|
96
|
+
self.help = help
|
|
97
|
+
self.version = version
|
|
98
|
+
self.commands: dict[str, Command | App] = {}
|
|
99
|
+
|
|
100
|
+
# -- registration -----------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
@overload
|
|
103
|
+
def command(self, func: F, /) -> F: ...
|
|
104
|
+
|
|
105
|
+
@overload
|
|
106
|
+
def command(
|
|
107
|
+
self, *, name: str | None = None, help: str | None = None, aliases: Sequence[str] = ()
|
|
108
|
+
) -> Callable[[F], F]: ...
|
|
109
|
+
|
|
110
|
+
def command(
|
|
111
|
+
self,
|
|
112
|
+
func: F | None = None,
|
|
113
|
+
/,
|
|
114
|
+
*,
|
|
115
|
+
name: str | None = None,
|
|
116
|
+
help: str | None = None,
|
|
117
|
+
aliases: Sequence[str] = (),
|
|
118
|
+
) -> F | Callable[[F], F]:
|
|
119
|
+
"""Register a function as a command. Usable as ``@app.command`` or ``@app.command(...)``.
|
|
120
|
+
|
|
121
|
+
The command name defaults to the function name with ``_`` replaced by ``-``.
|
|
122
|
+
The function is returned unchanged.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
def register(f: F) -> F:
|
|
126
|
+
cmd = Command(f, name, help=help, aliases=aliases)
|
|
127
|
+
self._add(cmd.name, cmd)
|
|
128
|
+
return f
|
|
129
|
+
|
|
130
|
+
return register(func) if func is not None else register
|
|
131
|
+
|
|
132
|
+
def group(self, name: str, help: str | None = None) -> App:
|
|
133
|
+
"""Create, register and return a nested :class:`App` for subcommands."""
|
|
134
|
+
sub = App(name, help)
|
|
135
|
+
self._add(name, sub)
|
|
136
|
+
return sub
|
|
137
|
+
|
|
138
|
+
def add_app(self, app: App, name: str | None = None) -> App:
|
|
139
|
+
"""Mount an existing :class:`App` as a subcommand group and return it."""
|
|
140
|
+
mount = name or app.name
|
|
141
|
+
if not mount:
|
|
142
|
+
raise ValueError("a mounted App needs a name")
|
|
143
|
+
self._add(mount, app)
|
|
144
|
+
return app
|
|
145
|
+
|
|
146
|
+
def _taken_names(self) -> set[str]:
|
|
147
|
+
names = set(self.commands)
|
|
148
|
+
for entry in self.commands.values():
|
|
149
|
+
if isinstance(entry, Command):
|
|
150
|
+
names.update(entry.aliases)
|
|
151
|
+
return names
|
|
152
|
+
|
|
153
|
+
def _add(self, name: str, entry: Command | App) -> None:
|
|
154
|
+
aliases = entry.aliases if isinstance(entry, Command) else ()
|
|
155
|
+
taken = self._taken_names()
|
|
156
|
+
for candidate in (name, *aliases):
|
|
157
|
+
if candidate in taken:
|
|
158
|
+
raise ValueError(f"command name {candidate!r} is already registered")
|
|
159
|
+
self.commands[name] = entry
|
|
160
|
+
|
|
161
|
+
# -- parsing and dispatch ---------------------------------------------------
|
|
162
|
+
|
|
163
|
+
def build_parser(self, prog: str | None = None) -> argparse.ArgumentParser:
|
|
164
|
+
"""Build the full :class:`argparse.ArgumentParser` tree for this app."""
|
|
165
|
+
parser = argparse.ArgumentParser(prog=prog or self.name, description=self.help, formatter_class=_HelpFormatter)
|
|
166
|
+
self._configure(parser)
|
|
167
|
+
return parser
|
|
168
|
+
|
|
169
|
+
def _configure(self, parser: argparse.ArgumentParser) -> None:
|
|
170
|
+
if self.version:
|
|
171
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {self.version}")
|
|
172
|
+
parser.set_defaults(**{_TARGET: (self, parser)})
|
|
173
|
+
if not self.commands:
|
|
174
|
+
return
|
|
175
|
+
subparsers = parser.add_subparsers(title="commands", metavar="COMMAND")
|
|
176
|
+
for name, entry in self.commands.items():
|
|
177
|
+
if isinstance(entry, App):
|
|
178
|
+
sub = subparsers.add_parser(
|
|
179
|
+
name, help=entry.help, description=entry.help, formatter_class=_HelpFormatter
|
|
180
|
+
)
|
|
181
|
+
entry._configure(sub)
|
|
182
|
+
else:
|
|
183
|
+
sub = subparsers.add_parser(
|
|
184
|
+
name,
|
|
185
|
+
aliases=list(entry.aliases),
|
|
186
|
+
help=entry.help,
|
|
187
|
+
description=entry.description,
|
|
188
|
+
formatter_class=_HelpFormatter,
|
|
189
|
+
)
|
|
190
|
+
entry.configure(sub)
|
|
191
|
+
|
|
192
|
+
def run(self, argv: Sequence[str] | None = None) -> int:
|
|
193
|
+
"""Parse ``argv`` (default ``sys.argv[1:]``), run the command, return an exit code.
|
|
194
|
+
|
|
195
|
+
Never raises ``SystemExit``; see :func:`run` for exit-code rules.
|
|
196
|
+
"""
|
|
197
|
+
return _dispatch(self.build_parser(), argv)
|
|
198
|
+
|
|
199
|
+
def main(self, argv: Sequence[str] | None = None) -> NoReturn:
|
|
200
|
+
"""Like :meth:`run`, then exit the process with the resulting code."""
|
|
201
|
+
sys.exit(self.run(argv))
|
|
202
|
+
|
|
203
|
+
__call__ = main
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _exit_code(result: Any) -> int:
|
|
207
|
+
if result is None:
|
|
208
|
+
return 0
|
|
209
|
+
if isinstance(result, bool):
|
|
210
|
+
return 0 if result else 1
|
|
211
|
+
if isinstance(result, int):
|
|
212
|
+
return result
|
|
213
|
+
echo(result)
|
|
214
|
+
return 0
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _dispatch(parser: argparse.ArgumentParser, argv: Sequence[str] | None) -> int:
|
|
218
|
+
args = list(sys.argv[1:] if argv is None else argv)
|
|
219
|
+
try:
|
|
220
|
+
namespace = parser.parse_args(args)
|
|
221
|
+
target, target_parser = getattr(namespace, _TARGET)
|
|
222
|
+
if isinstance(target, App): # a group was invoked without a subcommand
|
|
223
|
+
target_parser.print_help()
|
|
224
|
+
return 0
|
|
225
|
+
return _exit_code(target.invoke(namespace))
|
|
226
|
+
except SystemExit as exc: # argparse --help / --version / usage errors
|
|
227
|
+
code = exc.code
|
|
228
|
+
return code if isinstance(code, int) else (0 if code is None else 1)
|
|
229
|
+
except CliError as exc:
|
|
230
|
+
echo(style("Error: ", fg="red", bold=True) + exc.message, err=True)
|
|
231
|
+
return exc.exit_code
|
|
232
|
+
except KeyboardInterrupt:
|
|
233
|
+
echo("\n" + style("Aborted!", fg="red"), err=True)
|
|
234
|
+
return 130
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def run(
|
|
238
|
+
func: Callable[..., Any],
|
|
239
|
+
argv: Sequence[str] | None = None,
|
|
240
|
+
*,
|
|
241
|
+
prog: str | None = None,
|
|
242
|
+
version: str | None = None,
|
|
243
|
+
) -> int:
|
|
244
|
+
"""Run a single function as a whole CLI (no subcommands) and return the exit code.
|
|
245
|
+
|
|
246
|
+
Exit codes: a command returning ``None`` exits 0; an ``int`` is used as-is;
|
|
247
|
+
``True``/``False`` map to 0/1; any other value is printed and exits 0.
|
|
248
|
+
:class:`~slick_cli.CliError` exits with its code, usage errors with 2.
|
|
249
|
+
"""
|
|
250
|
+
command = Command(func)
|
|
251
|
+
parser = argparse.ArgumentParser(prog=prog, description=command.description, formatter_class=_HelpFormatter)
|
|
252
|
+
if version:
|
|
253
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {version}")
|
|
254
|
+
command.configure(parser)
|
|
255
|
+
return _dispatch(parser, argv)
|
slick_cli/docstrings.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Minimal Google-style docstring parsing for command and parameter help."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
|
|
9
|
+
__all__ = ["DocInfo", "parse_docstring"]
|
|
10
|
+
|
|
11
|
+
_PARAM_SECTIONS = {"args", "arguments", "parameters", "params"}
|
|
12
|
+
_DROPPED_SECTIONS = {"returns", "return", "raises", "yields", "yield"}
|
|
13
|
+
_SECTION_RE = re.compile(r"^([A-Za-z][A-Za-z ]*):\s*$")
|
|
14
|
+
_PARAM_RE = re.compile(r"^\*{0,2}(\w+)\s*(?:\([^)]*\))?\s*:\s*(.*)$")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class DocInfo:
|
|
19
|
+
"""The parts of a docstring that matter for CLI help."""
|
|
20
|
+
|
|
21
|
+
summary: str = ""
|
|
22
|
+
description: str = ""
|
|
23
|
+
params: dict[str, str] = field(default_factory=dict)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _indent(line: str) -> int:
|
|
27
|
+
return len(line) - len(line.lstrip())
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def parse_docstring(doc: str | None) -> DocInfo:
|
|
31
|
+
"""Split a docstring into summary, description and per-parameter help.
|
|
32
|
+
|
|
33
|
+
The summary is the first paragraph (joined onto one line). Entries in an
|
|
34
|
+
``Args:``/``Arguments:``/``Parameters:`` section become parameter help;
|
|
35
|
+
``Returns:``/``Raises:``/``Yields:`` sections are dropped; everything else
|
|
36
|
+
is kept verbatim as the description.
|
|
37
|
+
"""
|
|
38
|
+
text = inspect.cleandoc(doc or "")
|
|
39
|
+
if not text:
|
|
40
|
+
return DocInfo()
|
|
41
|
+
|
|
42
|
+
body: list[str] = []
|
|
43
|
+
params: dict[str, str] = {}
|
|
44
|
+
section: str | None = None # "params", "drop", or None for body text
|
|
45
|
+
entry_indent: int | None = None
|
|
46
|
+
current: str | None = None
|
|
47
|
+
|
|
48
|
+
for line in text.splitlines():
|
|
49
|
+
header = _SECTION_RE.match(line)
|
|
50
|
+
if header and _indent(line) == 0:
|
|
51
|
+
name = header.group(1).strip().lower()
|
|
52
|
+
if name in _PARAM_SECTIONS:
|
|
53
|
+
section, entry_indent, current = "params", None, None
|
|
54
|
+
continue
|
|
55
|
+
if name in _DROPPED_SECTIONS:
|
|
56
|
+
section = "drop"
|
|
57
|
+
continue
|
|
58
|
+
if section is not None:
|
|
59
|
+
if not line.strip():
|
|
60
|
+
continue
|
|
61
|
+
if _indent(line) == 0: # dedent ends the section
|
|
62
|
+
section = None
|
|
63
|
+
elif section == "drop":
|
|
64
|
+
continue
|
|
65
|
+
else:
|
|
66
|
+
indent = _indent(line)
|
|
67
|
+
if entry_indent is None:
|
|
68
|
+
entry_indent = indent
|
|
69
|
+
match = _PARAM_RE.match(line.strip())
|
|
70
|
+
if indent <= entry_indent and match:
|
|
71
|
+
current = match.group(1)
|
|
72
|
+
params[current] = match.group(2).strip()
|
|
73
|
+
elif current is not None:
|
|
74
|
+
params[current] = (params[current] + " " + line.strip()).strip()
|
|
75
|
+
continue
|
|
76
|
+
body.append(line)
|
|
77
|
+
|
|
78
|
+
paragraphs = "\n".join(body).strip().split("\n\n", 1)
|
|
79
|
+
summary = " ".join(part.strip() for part in paragraphs[0].splitlines())
|
|
80
|
+
description = paragraphs[1].strip() if len(paragraphs) > 1 else ""
|
|
81
|
+
return DocInfo(summary=summary, description=description, params=params)
|
slick_cli/errors.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Exceptions used to stop a command cleanly with a message and exit code."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import NoReturn
|
|
6
|
+
|
|
7
|
+
__all__ = ["CliError", "abort"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CliError(Exception):
|
|
11
|
+
"""An expected, user-facing failure.
|
|
12
|
+
|
|
13
|
+
Raising this from a command prints ``Error: <message>`` to stderr (in red when
|
|
14
|
+
the terminal supports it) and exits with :attr:`exit_code` instead of showing
|
|
15
|
+
a traceback.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, message: str, exit_code: int = 1) -> None:
|
|
19
|
+
super().__init__(message)
|
|
20
|
+
self.message = message
|
|
21
|
+
self.exit_code = exit_code
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def abort(message: str, exit_code: int = 1) -> NoReturn:
|
|
25
|
+
"""Stop the current command with ``message`` and ``exit_code``.
|
|
26
|
+
|
|
27
|
+
Shorthand for ``raise CliError(message, exit_code)``.
|
|
28
|
+
"""
|
|
29
|
+
raise CliError(message, exit_code)
|
slick_cli/params.py
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""Turn a function signature into argparse arguments, driven by type hints.
|
|
2
|
+
|
|
3
|
+
Mapping rules (see the README for the user-facing table):
|
|
4
|
+
|
|
5
|
+
* parameter without a default -> positional argument
|
|
6
|
+
* parameter with a default -> ``--option``
|
|
7
|
+
* keyword-only parameter, no default -> required ``--option``
|
|
8
|
+
* ``bool`` -> ``--flag/--no-flag``
|
|
9
|
+
* ``list[T]`` / ``tuple[T, ...]`` -> positional taking 1+ values, or a
|
|
10
|
+
repeatable option (``--tag a --tag b``)
|
|
11
|
+
* ``*args: T`` -> positional taking 0+ values
|
|
12
|
+
* ``Literal[...]`` / ``Enum`` -> choices
|
|
13
|
+
* ``T | None`` -> same as ``T``
|
|
14
|
+
* any other callable type (``int``, ``float``, ``Path``, ...) converts the string
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import collections.abc
|
|
21
|
+
import enum
|
|
22
|
+
import inspect
|
|
23
|
+
import os
|
|
24
|
+
import types
|
|
25
|
+
import typing
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from typing import Annotated, Any, Callable, Literal, Union, get_args, get_origin
|
|
28
|
+
|
|
29
|
+
from .errors import CliError
|
|
30
|
+
|
|
31
|
+
__all__ = ["Arg", "Param", "params_from_function"]
|
|
32
|
+
|
|
33
|
+
_EMPTY = inspect.Parameter.empty
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class Arg:
|
|
38
|
+
"""Extra CLI metadata for one parameter, attached with ``typing.Annotated``.
|
|
39
|
+
|
|
40
|
+
Example::
|
|
41
|
+
|
|
42
|
+
def greet(name: str, count: Annotated[int, Arg(short="-c", help="Repeat")] = 1): ...
|
|
43
|
+
|
|
44
|
+
Attributes:
|
|
45
|
+
help: Help text (overrides the docstring entry).
|
|
46
|
+
short: A short flag such as ``"-c"`` (options and flags only).
|
|
47
|
+
name: Long option name to use instead of the parameter name.
|
|
48
|
+
metavar: Placeholder shown in usage, e.g. ``"FILE"``.
|
|
49
|
+
env: Environment variable read when the option is not given.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
help: str | None = None
|
|
53
|
+
short: str | None = None
|
|
54
|
+
name: str | None = None
|
|
55
|
+
metavar: str | None = None
|
|
56
|
+
env: str | None = None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
_TRUE = {"1", "true", "yes", "y", "on"}
|
|
60
|
+
_FALSE = {"0", "false", "no", "n", "off", ""}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def parse_bool(value: str) -> bool:
|
|
64
|
+
"""Parse common spellings of true/false (``yes``, ``0``, ``on``...)."""
|
|
65
|
+
lowered = value.strip().lower()
|
|
66
|
+
if lowered in _TRUE:
|
|
67
|
+
return True
|
|
68
|
+
if lowered in _FALSE:
|
|
69
|
+
return False
|
|
70
|
+
raise argparse.ArgumentTypeError(f"invalid boolean value: {value!r}")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
parse_bool.__name__ = "bool"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _unwrap_annotated(hint: Any) -> tuple[Any, Arg | None]:
|
|
77
|
+
if get_origin(hint) is Annotated:
|
|
78
|
+
base, *extras = get_args(hint)
|
|
79
|
+
arg = next((e for e in extras if isinstance(e, Arg)), None)
|
|
80
|
+
return base, arg
|
|
81
|
+
return hint, None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _unwrap_optional(hint: Any) -> Any:
|
|
85
|
+
if get_origin(hint) in (Union, types.UnionType):
|
|
86
|
+
members = [a for a in get_args(hint) if a is not type(None)]
|
|
87
|
+
if len(members) == 1:
|
|
88
|
+
return members[0]
|
|
89
|
+
return hint
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _sequence_item(hint: Any) -> Any | None:
|
|
93
|
+
"""Return the item type if ``hint`` is a list-like type, else ``None``."""
|
|
94
|
+
origin = get_origin(hint)
|
|
95
|
+
if hint in (list, tuple) or origin in (list, collections.abc.Sequence):
|
|
96
|
+
args = get_args(hint)
|
|
97
|
+
return args[0] if args else str
|
|
98
|
+
if origin is tuple:
|
|
99
|
+
args = get_args(hint)
|
|
100
|
+
if len(args) == 2 and args[1] is Ellipsis:
|
|
101
|
+
return args[0]
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _choice_converter(values: list[Any], labels: list[str]) -> Callable[[str], Any]:
|
|
106
|
+
def convert(raw: str) -> Any:
|
|
107
|
+
for value, label in zip(values, labels):
|
|
108
|
+
if raw == label or raw.lower() == label.lower():
|
|
109
|
+
return value
|
|
110
|
+
raise argparse.ArgumentTypeError(f"invalid choice: {raw!r} (choose from {', '.join(labels)})")
|
|
111
|
+
|
|
112
|
+
convert.__name__ = "choice"
|
|
113
|
+
return convert
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _converter(hint: Any) -> tuple[Callable[[str], Any], str | None]:
|
|
117
|
+
"""Return ``(convert, choices_metavar)`` for a scalar type hint."""
|
|
118
|
+
hint = _unwrap_optional(hint)
|
|
119
|
+
if hint in (_EMPTY, Any, str):
|
|
120
|
+
return str, None
|
|
121
|
+
if hint is bool:
|
|
122
|
+
return parse_bool, None
|
|
123
|
+
if get_origin(hint) is Literal:
|
|
124
|
+
values = list(get_args(hint))
|
|
125
|
+
labels = [str(v) for v in values]
|
|
126
|
+
return _choice_converter(values, labels), "{" + ",".join(labels) + "}"
|
|
127
|
+
if isinstance(hint, type) and issubclass(hint, enum.Enum):
|
|
128
|
+
members = list(hint)
|
|
129
|
+
labels = [str(m.value) if isinstance(m.value, (str, int)) else m.name.lower() for m in members]
|
|
130
|
+
convert = _choice_converter(members, labels)
|
|
131
|
+
|
|
132
|
+
def convert_enum(raw: str) -> Any:
|
|
133
|
+
# Accept member names too, e.g. RED for Color.RED = "red".
|
|
134
|
+
by_name = {m.name.lower(): m for m in members}
|
|
135
|
+
if raw.lower() in by_name and raw not in labels:
|
|
136
|
+
return by_name[raw.lower()]
|
|
137
|
+
return convert(raw)
|
|
138
|
+
|
|
139
|
+
convert_enum.__name__ = "choice"
|
|
140
|
+
return convert_enum, "{" + ",".join(labels) + "}"
|
|
141
|
+
if callable(hint):
|
|
142
|
+
return hint, None
|
|
143
|
+
raise TypeError(f"unsupported parameter type: {hint!r}")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _format_default(value: Any) -> str:
|
|
147
|
+
if isinstance(value, enum.Enum):
|
|
148
|
+
return str(value.value)
|
|
149
|
+
if isinstance(value, (list, tuple)):
|
|
150
|
+
return " ".join(str(v) for v in value) or "[]"
|
|
151
|
+
return str(value)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass
|
|
155
|
+
class Param:
|
|
156
|
+
"""One function parameter and how it maps to the command line."""
|
|
157
|
+
|
|
158
|
+
name: str
|
|
159
|
+
kind: str # "positional" | "option" | "flag" | "varargs"
|
|
160
|
+
convert: Callable[[str], Any]
|
|
161
|
+
default: Any = _EMPTY
|
|
162
|
+
multiple: bool = False
|
|
163
|
+
help: str | None = None
|
|
164
|
+
arg: Arg = Arg()
|
|
165
|
+
choices_metavar: str | None = None
|
|
166
|
+
call_kind: inspect._ParameterKind = inspect.Parameter.POSITIONAL_OR_KEYWORD
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def required(self) -> bool:
|
|
170
|
+
"""True if the user must supply a value (no default, not a flag)."""
|
|
171
|
+
return self.default is _EMPTY and self.kind not in ("flag", "varargs")
|
|
172
|
+
|
|
173
|
+
@property
|
|
174
|
+
def flags(self) -> list[str]:
|
|
175
|
+
"""Option strings, e.g. ``["-c", "--count"]`` (empty for positionals)."""
|
|
176
|
+
if self.kind in ("positional", "varargs"):
|
|
177
|
+
return []
|
|
178
|
+
long_name = (self.arg.name or self.name).lstrip("-").replace("_", "-")
|
|
179
|
+
return ([self.arg.short] if self.arg.short else []) + [f"--{long_name}"]
|
|
180
|
+
|
|
181
|
+
def _help_text(self) -> str:
|
|
182
|
+
parts = [self.help or ""]
|
|
183
|
+
if self.kind == "flag":
|
|
184
|
+
if self.default not in (_EMPTY, False):
|
|
185
|
+
parts.append(f"(default: {_format_default(self.default)})")
|
|
186
|
+
elif self.default not in (_EMPTY, None):
|
|
187
|
+
parts.append(f"(default: {_format_default(self.default)})")
|
|
188
|
+
if self.arg.env:
|
|
189
|
+
parts.append(f"[env: {self.arg.env}]")
|
|
190
|
+
return " ".join(p for p in parts if p).replace("%", "%%")
|
|
191
|
+
|
|
192
|
+
def add_to(self, parser: argparse.ArgumentParser) -> None:
|
|
193
|
+
"""Register this parameter on ``parser``."""
|
|
194
|
+
kwargs: dict[str, Any] = {"help": self._help_text() or None}
|
|
195
|
+
metavar = self.arg.metavar or self.choices_metavar
|
|
196
|
+
if self.kind in ("positional", "varargs"):
|
|
197
|
+
kwargs["type"] = self.convert
|
|
198
|
+
kwargs["metavar"] = metavar or self.name.upper()
|
|
199
|
+
if self.kind == "varargs":
|
|
200
|
+
kwargs["nargs"] = "*"
|
|
201
|
+
elif self.multiple:
|
|
202
|
+
kwargs["nargs"] = "+"
|
|
203
|
+
parser.add_argument(self.name, **kwargs)
|
|
204
|
+
return
|
|
205
|
+
|
|
206
|
+
# Options are absent from the namespace unless given, so defaults and
|
|
207
|
+
# environment variables can be resolved after parsing (see `resolve`).
|
|
208
|
+
kwargs["dest"] = self.name
|
|
209
|
+
kwargs["default"] = argparse.SUPPRESS
|
|
210
|
+
if self.kind == "flag":
|
|
211
|
+
kwargs["action"] = argparse.BooleanOptionalAction
|
|
212
|
+
else:
|
|
213
|
+
kwargs["type"] = self.convert
|
|
214
|
+
kwargs["metavar"] = metavar or self.name.upper()
|
|
215
|
+
if self.multiple:
|
|
216
|
+
kwargs["action"] = "append"
|
|
217
|
+
if self.required and not self.arg.env:
|
|
218
|
+
kwargs["required"] = True
|
|
219
|
+
parser.add_argument(*self.flags, **kwargs)
|
|
220
|
+
|
|
221
|
+
def resolve(self, namespace: argparse.Namespace) -> Any:
|
|
222
|
+
"""Return the final Python value for this parameter after parsing."""
|
|
223
|
+
if hasattr(namespace, self.name):
|
|
224
|
+
return getattr(namespace, self.name)
|
|
225
|
+
if self.arg.env and self.arg.env in os.environ:
|
|
226
|
+
raw = os.environ[self.arg.env]
|
|
227
|
+
try:
|
|
228
|
+
if self.kind == "flag":
|
|
229
|
+
return parse_bool(raw)
|
|
230
|
+
if self.multiple:
|
|
231
|
+
return [self.convert(item.strip()) for item in raw.split(",") if item.strip()]
|
|
232
|
+
return self.convert(raw)
|
|
233
|
+
except (ValueError, TypeError, argparse.ArgumentTypeError) as exc:
|
|
234
|
+
raise CliError(f"invalid value for {self.arg.env}: {exc}", exit_code=2) from None
|
|
235
|
+
if self.kind == "flag":
|
|
236
|
+
return False if self.default is _EMPTY else self.default
|
|
237
|
+
if self.kind == "varargs":
|
|
238
|
+
return []
|
|
239
|
+
if self.required:
|
|
240
|
+
flag = self.flags[-1] if self.flags else self.name
|
|
241
|
+
hint = f" (or set {self.arg.env})" if self.arg.env else ""
|
|
242
|
+
raise CliError(f"missing required option {flag}{hint}", exit_code=2)
|
|
243
|
+
return self.default
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def params_from_function(func: Callable[..., Any], doc_params: dict[str, str] | None = None) -> list[Param]:
|
|
247
|
+
"""Build :class:`Param` objects for every parameter of ``func``.
|
|
248
|
+
|
|
249
|
+
Raises:
|
|
250
|
+
TypeError: For ``**kwargs`` or an annotation that cannot convert strings.
|
|
251
|
+
"""
|
|
252
|
+
doc_params = doc_params or {}
|
|
253
|
+
try:
|
|
254
|
+
hints = typing.get_type_hints(func, include_extras=True)
|
|
255
|
+
except Exception: # unresolved forward references: fall back to raw annotations
|
|
256
|
+
hints = getattr(func, "__annotations__", {})
|
|
257
|
+
|
|
258
|
+
result: list[Param] = []
|
|
259
|
+
for p in inspect.signature(func).parameters.values():
|
|
260
|
+
if p.kind is p.VAR_KEYWORD:
|
|
261
|
+
raise TypeError(f"{func.__name__}: **{p.name} is not supported by slick_cli commands")
|
|
262
|
+
hint, arg = _unwrap_annotated(hints.get(p.name, _EMPTY))
|
|
263
|
+
arg = arg or Arg()
|
|
264
|
+
hint = _unwrap_optional(hint)
|
|
265
|
+
help_text = arg.help or doc_params.get(p.name)
|
|
266
|
+
|
|
267
|
+
item = _sequence_item(hint)
|
|
268
|
+
multiple = item is not None and p.kind is not p.VAR_POSITIONAL
|
|
269
|
+
scalar = item if multiple else hint
|
|
270
|
+
if p.kind is p.VAR_POSITIONAL:
|
|
271
|
+
kind = "varargs"
|
|
272
|
+
elif scalar is bool and not multiple:
|
|
273
|
+
kind = "flag"
|
|
274
|
+
elif p.default is _EMPTY and p.kind is not p.KEYWORD_ONLY:
|
|
275
|
+
kind = "positional"
|
|
276
|
+
else:
|
|
277
|
+
kind = "option"
|
|
278
|
+
if kind in ("positional", "varargs") and arg.short:
|
|
279
|
+
raise TypeError(f"{func.__name__}: positional parameter {p.name!r} cannot have a short flag")
|
|
280
|
+
|
|
281
|
+
convert, choices_metavar = _converter(scalar)
|
|
282
|
+
result.append(
|
|
283
|
+
Param(
|
|
284
|
+
name=p.name,
|
|
285
|
+
kind=kind,
|
|
286
|
+
convert=convert,
|
|
287
|
+
default=p.default,
|
|
288
|
+
multiple=multiple,
|
|
289
|
+
help=help_text,
|
|
290
|
+
arg=arg,
|
|
291
|
+
choices_metavar=choices_metavar,
|
|
292
|
+
call_kind=p.kind,
|
|
293
|
+
)
|
|
294
|
+
)
|
|
295
|
+
return result
|
slick_cli/py.typed
ADDED
|
File without changes
|
slick_cli/style.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""ANSI styling and terminal output helpers.
|
|
2
|
+
|
|
3
|
+
Color is emitted only when it will be understood: output streams that are not a
|
|
4
|
+
TTY get plain text, ``NO_COLOR`` (https://no-color.org) disables color, and
|
|
5
|
+
``FORCE_COLOR`` enables it even when piping.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import sys
|
|
13
|
+
from typing import IO, Any
|
|
14
|
+
|
|
15
|
+
__all__ = ["COLORS", "style", "unstyle", "should_color", "echo", "secho", "confirm"]
|
|
16
|
+
|
|
17
|
+
#: Supported color names mapped to their ANSI foreground codes.
|
|
18
|
+
COLORS: dict[str, int] = {
|
|
19
|
+
"black": 30,
|
|
20
|
+
"red": 31,
|
|
21
|
+
"green": 32,
|
|
22
|
+
"yellow": 33,
|
|
23
|
+
"blue": 34,
|
|
24
|
+
"magenta": 35,
|
|
25
|
+
"cyan": 36,
|
|
26
|
+
"white": 37,
|
|
27
|
+
"bright_black": 90,
|
|
28
|
+
"bright_red": 91,
|
|
29
|
+
"bright_green": 92,
|
|
30
|
+
"bright_yellow": 93,
|
|
31
|
+
"bright_blue": 94,
|
|
32
|
+
"bright_magenta": 95,
|
|
33
|
+
"bright_cyan": 96,
|
|
34
|
+
"bright_white": 97,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
|
|
38
|
+
_RESET = "\x1b[0m"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _color_code(name: str, *, background: bool) -> int:
|
|
42
|
+
try:
|
|
43
|
+
code = COLORS[name]
|
|
44
|
+
except KeyError:
|
|
45
|
+
known = ", ".join(COLORS)
|
|
46
|
+
raise ValueError(f"unknown color {name!r}; expected one of: {known}") from None
|
|
47
|
+
return code + 10 if background else code
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def style(
|
|
51
|
+
text: object,
|
|
52
|
+
fg: str | None = None,
|
|
53
|
+
bg: str | None = None,
|
|
54
|
+
*,
|
|
55
|
+
bold: bool = False,
|
|
56
|
+
dim: bool = False,
|
|
57
|
+
italic: bool = False,
|
|
58
|
+
underline: bool = False,
|
|
59
|
+
) -> str:
|
|
60
|
+
"""Wrap ``text`` in ANSI escape codes.
|
|
61
|
+
|
|
62
|
+
Styling is always applied here; whether it reaches the terminal is decided by
|
|
63
|
+
:func:`echo`, which strips codes for streams that should not get color.
|
|
64
|
+
|
|
65
|
+
Raises:
|
|
66
|
+
ValueError: If ``fg`` or ``bg`` is not a name in :data:`COLORS`.
|
|
67
|
+
"""
|
|
68
|
+
codes: list[int] = []
|
|
69
|
+
if bold:
|
|
70
|
+
codes.append(1)
|
|
71
|
+
if dim:
|
|
72
|
+
codes.append(2)
|
|
73
|
+
if italic:
|
|
74
|
+
codes.append(3)
|
|
75
|
+
if underline:
|
|
76
|
+
codes.append(4)
|
|
77
|
+
if fg is not None:
|
|
78
|
+
codes.append(_color_code(fg, background=False))
|
|
79
|
+
if bg is not None:
|
|
80
|
+
codes.append(_color_code(bg, background=True))
|
|
81
|
+
if not codes:
|
|
82
|
+
return str(text)
|
|
83
|
+
prefix = "\x1b[" + ";".join(str(c) for c in codes) + "m"
|
|
84
|
+
return f"{prefix}{text}{_RESET}"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def unstyle(text: str) -> str:
|
|
88
|
+
"""Remove all ANSI styling codes from ``text``."""
|
|
89
|
+
return _ANSI_RE.sub("", text)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def should_color(stream: IO[str]) -> bool:
|
|
93
|
+
"""Decide whether ``stream`` should receive ANSI color codes.
|
|
94
|
+
|
|
95
|
+
``NO_COLOR`` wins over ``FORCE_COLOR``; otherwise color is used for TTYs only.
|
|
96
|
+
"""
|
|
97
|
+
if os.environ.get("NO_COLOR"):
|
|
98
|
+
return False
|
|
99
|
+
if os.environ.get("FORCE_COLOR"):
|
|
100
|
+
return True
|
|
101
|
+
isatty = getattr(stream, "isatty", None)
|
|
102
|
+
try:
|
|
103
|
+
return bool(isatty and isatty())
|
|
104
|
+
except ValueError: # closed stream
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def echo(message: object = "", *, err: bool = False, nl: bool = True, color: bool | None = None) -> None:
|
|
109
|
+
"""Print ``message`` to stdout (or stderr with ``err=True``).
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
message: Anything; converted with ``str()``.
|
|
113
|
+
err: Write to stderr instead of stdout.
|
|
114
|
+
nl: Append a newline.
|
|
115
|
+
color: Force styling on (True) or off (False); ``None`` auto-detects
|
|
116
|
+
with :func:`should_color`.
|
|
117
|
+
"""
|
|
118
|
+
stream = sys.stderr if err else sys.stdout
|
|
119
|
+
text = str(message)
|
|
120
|
+
use_color = should_color(stream) if color is None else color
|
|
121
|
+
if not use_color:
|
|
122
|
+
text = unstyle(text)
|
|
123
|
+
stream.write(text + ("\n" if nl else ""))
|
|
124
|
+
stream.flush()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def secho(
|
|
128
|
+
message: object = "",
|
|
129
|
+
*,
|
|
130
|
+
err: bool = False,
|
|
131
|
+
nl: bool = True,
|
|
132
|
+
color: bool | None = None,
|
|
133
|
+
**styles: Any,
|
|
134
|
+
) -> None:
|
|
135
|
+
"""Style ``message`` with :func:`style` and print it with :func:`echo`.
|
|
136
|
+
|
|
137
|
+
Example: ``secho("done", fg="green", bold=True)``.
|
|
138
|
+
"""
|
|
139
|
+
echo(style(message, **styles), err=err, nl=nl, color=color)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
_YES = {"y", "yes", "true", "1"}
|
|
143
|
+
_NO = {"n", "no", "false", "0"}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def confirm(prompt: str, default: bool = False) -> bool:
|
|
147
|
+
"""Ask a yes/no question on stdin and return the answer.
|
|
148
|
+
|
|
149
|
+
An empty answer (or end of input) returns ``default``. Invalid answers
|
|
150
|
+
re-ask the question.
|
|
151
|
+
"""
|
|
152
|
+
suffix = " [Y/n]: " if default else " [y/N]: "
|
|
153
|
+
while True:
|
|
154
|
+
echo(prompt + suffix, nl=False)
|
|
155
|
+
try:
|
|
156
|
+
answer = input().strip().lower()
|
|
157
|
+
except EOFError:
|
|
158
|
+
echo()
|
|
159
|
+
return default
|
|
160
|
+
if not answer:
|
|
161
|
+
return default
|
|
162
|
+
if answer in _YES:
|
|
163
|
+
return True
|
|
164
|
+
if answer in _NO:
|
|
165
|
+
return False
|
|
166
|
+
echo("Please answer y or n.", err=True)
|
slick_cli/testing.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Helpers for testing CLIs built with slick_cli, without spawning processes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
9
|
+
from contextlib import redirect_stderr, redirect_stdout
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any
|
|
12
|
+
from unittest import mock
|
|
13
|
+
|
|
14
|
+
from .app import App, run
|
|
15
|
+
|
|
16
|
+
__all__ = ["Result", "invoke"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Result:
|
|
21
|
+
"""Captured outcome of :func:`invoke`."""
|
|
22
|
+
|
|
23
|
+
exit_code: int
|
|
24
|
+
stdout: str
|
|
25
|
+
stderr: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def invoke(
|
|
29
|
+
target: App | Callable[..., Any],
|
|
30
|
+
args: Sequence[str] = (),
|
|
31
|
+
*,
|
|
32
|
+
env: Mapping[str, str] | None = None,
|
|
33
|
+
input: str | None = None,
|
|
34
|
+
) -> Result:
|
|
35
|
+
"""Run an :class:`App` (or a plain function, via :func:`slick_cli.run`) in-process.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
target: The app or function to run.
|
|
39
|
+
args: Command-line arguments, excluding the program name.
|
|
40
|
+
env: Extra environment variables set for the duration of the call.
|
|
41
|
+
input: Text fed to stdin (for :func:`slick_cli.confirm`).
|
|
42
|
+
"""
|
|
43
|
+
out, err = io.StringIO(), io.StringIO()
|
|
44
|
+
stdin = io.StringIO(input) if input is not None else sys.stdin
|
|
45
|
+
with mock.patch.dict(os.environ, dict(env or {})), mock.patch.object(sys, "stdin", stdin):
|
|
46
|
+
with redirect_stdout(out), redirect_stderr(err):
|
|
47
|
+
if isinstance(target, App):
|
|
48
|
+
code = target.run(list(args))
|
|
49
|
+
else:
|
|
50
|
+
code = run(target, list(args), prog=getattr(target, "__name__", None))
|
|
51
|
+
return Result(code, out.getvalue(), err.getvalue())
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: slick-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Slick, type-hint driven command-line apps built on the Python standard library.
|
|
5
|
+
Author: nehz
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: cli,command-line,argparse,decorator,type-hints,terminal
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Classifier: Topic :: Software Development :: User Interfaces
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# slick-cli
|
|
27
|
+
|
|
28
|
+
**Write a function, get a command-line app.** `slick-cli` turns ordinary, type-hinted
|
|
29
|
+
Python functions into polished CLIs: arguments, options, flags, choices, subcommands,
|
|
30
|
+
help text and colored output, with **zero dependencies** (it is a thin, friendly layer
|
|
31
|
+
over `argparse`).
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from slick_cli import App, echo
|
|
35
|
+
|
|
36
|
+
app = App("greeter", "Friendly greetings.", version="1.0")
|
|
37
|
+
|
|
38
|
+
@app.command
|
|
39
|
+
def hello(name: str, count: int = 1, shout: bool = False) -> None:
|
|
40
|
+
"""Greet someone.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
name: Who to greet.
|
|
44
|
+
count: How many times.
|
|
45
|
+
shout: Use UPPERCASE.
|
|
46
|
+
"""
|
|
47
|
+
for _ in range(count):
|
|
48
|
+
echo(name.upper() if shout else f"Hello, {name}!")
|
|
49
|
+
|
|
50
|
+
if __name__ == "__main__":
|
|
51
|
+
app()
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
```console
|
|
55
|
+
$ python greeter.py hello Ada --count 2
|
|
56
|
+
Hello, Ada!
|
|
57
|
+
Hello, Ada!
|
|
58
|
+
$ python greeter.py hello --help
|
|
59
|
+
usage: greeter hello [-h] [--count COUNT] [--shout | --no-shout] NAME
|
|
60
|
+
...
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Features
|
|
64
|
+
|
|
65
|
+
- **Decorator-based commands**: `@app.command` registers a function and returns it unchanged, so it stays directly callable and testable.
|
|
66
|
+
- **Type hints drive parsing**: `int`, `float`, `Path`, `bool` flags, `list[T]`, `*args`, `Literal[...]` and `Enum` choices, and `T | None`.
|
|
67
|
+
- **Help from docstrings**: the summary, description and Google-style `Args:` entries become `--help` output, with defaults and env vars listed.
|
|
68
|
+
- **Subcommand groups**: nest apps with `app.group(...)` or mount an existing one with `app.add_app(...)`; command aliases are supported.
|
|
69
|
+
- **Per-parameter tweaks** via `Annotated[T, Arg(...)]`: short flags, custom names, metavars and environment-variable fallbacks.
|
|
70
|
+
- **Colored output that behaves**: `style`/`secho` emit ANSI color only to TTYs, and respect `NO_COLOR` and `FORCE_COLOR`.
|
|
71
|
+
- **Clean errors and exit codes**: `abort("msg")` prints `Error: msg` and exits non-zero, with no traceback. Return values become exit codes.
|
|
72
|
+
- **In-process testing**: `slick_cli.testing.invoke` captures exit code, stdout and stderr.
|
|
73
|
+
- Standard library only, Python 3.10+, fully typed (`py.typed`).
|
|
74
|
+
|
|
75
|
+
## Install
|
|
76
|
+
|
|
77
|
+
```console
|
|
78
|
+
pip install slick-cli
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Or from a checkout: `pip install .`
|
|
82
|
+
|
|
83
|
+
## Quickstart
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
# todo.py
|
|
87
|
+
from pathlib import Path
|
|
88
|
+
from typing import Annotated, Literal
|
|
89
|
+
|
|
90
|
+
from slick_cli import App, Arg, abort, secho
|
|
91
|
+
|
|
92
|
+
app = App("todo", "A tiny todo manager.")
|
|
93
|
+
FILE = Path("todo.txt")
|
|
94
|
+
|
|
95
|
+
@app.command
|
|
96
|
+
def add(text: list[str], *, priority: Literal["low", "high"] = "low") -> None:
|
|
97
|
+
"""Add an item."""
|
|
98
|
+
with FILE.open("a") as f:
|
|
99
|
+
f.write(f"[{priority}] {' '.join(text)}\n")
|
|
100
|
+
secho("added", fg="green")
|
|
101
|
+
|
|
102
|
+
@app.command(name="list", aliases=["ls"])
|
|
103
|
+
def list_items(*, high_only: Annotated[bool, Arg(short="-H")] = False) -> None:
|
|
104
|
+
"""Show items."""
|
|
105
|
+
if not FILE.exists():
|
|
106
|
+
abort("nothing to do yet")
|
|
107
|
+
for line in FILE.read_text().splitlines():
|
|
108
|
+
if not high_only or line.startswith("[high]"):
|
|
109
|
+
print(line)
|
|
110
|
+
|
|
111
|
+
if __name__ == "__main__":
|
|
112
|
+
app()
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
```console
|
|
116
|
+
$ python todo.py add buy milk --priority high
|
|
117
|
+
added
|
|
118
|
+
$ python todo.py ls -H
|
|
119
|
+
[high] buy milk
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
A runnable tour lives in [`examples/demo.py`](examples/demo.py):
|
|
123
|
+
|
|
124
|
+
```console
|
|
125
|
+
python3 examples/demo.py --help
|
|
126
|
+
python3 examples/demo.py hello Ada --count 2 --shout --color magenta
|
|
127
|
+
python3 examples/demo.py sum 1.5 2 3.25 -p 1
|
|
128
|
+
python3 examples/demo.py files ls --path . --ext .py
|
|
129
|
+
DEMO_TOKEN=secret python3 examples/demo.py deploy prod -y
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## How parameters map to the command line
|
|
133
|
+
|
|
134
|
+
| Python parameter | Command line |
|
|
135
|
+
|----------------------------------------|------------------------------------------------|
|
|
136
|
+
| `name: str` (no default) | positional `NAME` |
|
|
137
|
+
| `count: int = 1` (has a default) | option `--count COUNT` |
|
|
138
|
+
| `*, user: str` (keyword-only, no default) | required option `--user USER` |
|
|
139
|
+
| `verbose: bool = False` | flag `--verbose / --no-verbose` |
|
|
140
|
+
| `files: list[Path]` (no default) | positional taking one or more values |
|
|
141
|
+
| `*, tag: list[str] \| None = None` | repeatable option `--tag a --tag b` |
|
|
142
|
+
| `*rest: int` | positional taking zero or more values |
|
|
143
|
+
| `mode: Literal["fast", "slow"]` | choices `{fast,slow}` |
|
|
144
|
+
| `color: Color` (an `Enum`) | choices from member values (names also accepted, case-insensitive) |
|
|
145
|
+
| `out: Path \| None = None` | same as `Path`; `None` when omitted |
|
|
146
|
+
| `when: SomeType` | `SomeType(string)` is used as the converter |
|
|
147
|
+
|
|
148
|
+
Underscores in names become dashes (`dry_run` becomes `--dry-run`). Unannotated
|
|
149
|
+
parameters are strings. A `bool` with no default is a flag defaulting to `False`.
|
|
150
|
+
`**kwargs` is rejected with `TypeError`.
|
|
151
|
+
|
|
152
|
+
## API overview
|
|
153
|
+
|
|
154
|
+
Everything below is importable from `slick_cli` unless noted.
|
|
155
|
+
|
|
156
|
+
### `App(name=None, help=None, *, version=None)`
|
|
157
|
+
|
|
158
|
+
A collection of commands and nested groups.
|
|
159
|
+
|
|
160
|
+
- `@app.command` / `@app.command(name=None, *, help=None, aliases=())`: register a function. The name defaults to the function name with `_` replaced by `-` (leading/trailing underscores stripped). `help` defaults to the docstring summary. Duplicate names or aliases raise `ValueError`.
|
|
161
|
+
- `app.group(name, help=None) -> App`: create and mount a nested app for subcommands.
|
|
162
|
+
- `app.add_app(other, name=None) -> App`: mount an existing app (uses `other.name` if `name` is omitted).
|
|
163
|
+
- `app.run(argv=None) -> int`: parse `argv` (default `sys.argv[1:]`), run the command, return the exit code. Never raises `SystemExit`.
|
|
164
|
+
- `app.main(argv=None)` and `app(argv=None)`: `run`, then `sys.exit` with the code.
|
|
165
|
+
- `app.build_parser(prog=None) -> argparse.ArgumentParser`: the underlying parser, if you need it.
|
|
166
|
+
- `app.commands`: dict of registered `Command` objects and nested `App`s, by name.
|
|
167
|
+
|
|
168
|
+
Running an app (or a group) with no command prints its help and exits 0.
|
|
169
|
+
`--version` is added when `version` is set.
|
|
170
|
+
|
|
171
|
+
### `run(func, argv=None, *, prog=None, version=None) -> int`
|
|
172
|
+
|
|
173
|
+
Run a single function as a whole CLI with no subcommands.
|
|
174
|
+
|
|
175
|
+
### Exit codes
|
|
176
|
+
|
|
177
|
+
| Outcome | Exit code |
|
|
178
|
+
|-------------------------------------------------|----------------|
|
|
179
|
+
| command returns `None` | `0` |
|
|
180
|
+
| command returns an `int` | that int |
|
|
181
|
+
| command returns `True` / `False` | `0` / `1` |
|
|
182
|
+
| command returns anything else | printed, `0` |
|
|
183
|
+
| `CliError(msg, code)` / `abort(msg, code)` | `code` (default `1`) |
|
|
184
|
+
| usage error (bad value, missing argument) | `2` |
|
|
185
|
+
| `Ctrl-C` | `130` |
|
|
186
|
+
|
|
187
|
+
### `Arg(help=None, short=None, name=None, metavar=None, env=None)`
|
|
188
|
+
|
|
189
|
+
Per-parameter metadata, attached with `typing.Annotated`:
|
|
190
|
+
|
|
191
|
+
```python
|
|
192
|
+
def login(*, user: Annotated[str, Arg(short="-u", env="APP_USER", help="Account name")]): ...
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
- `help` overrides the docstring entry.
|
|
196
|
+
- `short` adds a short flag (options and flags only).
|
|
197
|
+
- `name` replaces the long option name.
|
|
198
|
+
- `metavar` sets the usage placeholder.
|
|
199
|
+
- `env` names an environment variable used when the option is not given. Booleans accept `1/true/yes/on` and `0/false/no/off`. List options split the variable on commas. A bad value is a usage error (exit 2).
|
|
200
|
+
|
|
201
|
+
### Errors
|
|
202
|
+
|
|
203
|
+
- `CliError(message, exit_code=1)`: raise it for expected failures. It prints `Error: <message>` to stderr.
|
|
204
|
+
- `abort(message, exit_code=1)`: shorthand that raises `CliError`.
|
|
205
|
+
|
|
206
|
+
### Output
|
|
207
|
+
|
|
208
|
+
- `style(text, fg=None, bg=None, *, bold=False, dim=False, italic=False, underline=False) -> str`: wrap text in ANSI codes. The colors are `black red green yellow blue magenta cyan white`, each also with a `bright_` prefix. An unknown color raises `ValueError`.
|
|
209
|
+
- `unstyle(text) -> str`: strip ANSI codes.
|
|
210
|
+
- `echo(message="", *, err=False, nl=True, color=None)`: print to stdout or stderr. Styling is stripped unless `should_color(stream)` is true or `color=True` is passed.
|
|
211
|
+
- `secho(message="", *, err=False, nl=True, color=None, **styles)`: `style` and `echo` in one call.
|
|
212
|
+
- `should_color(stream) -> bool`: returns `False` if `NO_COLOR` is set, otherwise `True` if `FORCE_COLOR` is set, otherwise whether `stream` is a TTY.
|
|
213
|
+
- `confirm(prompt, default=False) -> bool`: a yes/no prompt on stdin. An empty answer or end of input returns `default`.
|
|
214
|
+
|
|
215
|
+
### Lower-level pieces
|
|
216
|
+
|
|
217
|
+
- `Command(func, name=None, *, help=None, aliases=())`: the object behind each registered command. It has `.name`, `.help`, `.description`, `.aliases` and `.params`.
|
|
218
|
+
- `slick_cli.docstrings.parse_docstring(doc) -> DocInfo` returns `summary`, `description` and `params`.
|
|
219
|
+
|
|
220
|
+
### Testing: `slick_cli.testing`
|
|
221
|
+
|
|
222
|
+
```python
|
|
223
|
+
from slick_cli.testing import invoke
|
|
224
|
+
|
|
225
|
+
result = invoke(app, ["hello", "Ada"], env={"APP_USER": "me"}, input="y\n")
|
|
226
|
+
assert result.exit_code == 0
|
|
227
|
+
assert result.stdout == "Hello, Ada!\n"
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
`invoke(target, args=(), *, env=None, input=None) -> Result` accepts an `App` or a plain
|
|
231
|
+
function. `Result` has `exit_code`, `stdout` and `stderr`.
|
|
232
|
+
|
|
233
|
+
## Development
|
|
234
|
+
|
|
235
|
+
```console
|
|
236
|
+
PYTHONPATH=src python3 -m unittest discover -s tests -v
|
|
237
|
+
# or, if pytest is installed:
|
|
238
|
+
python3 -m pytest
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
## License
|
|
242
|
+
|
|
243
|
+
MIT
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
slick_cli/__init__.py,sha256=kZ1TlW-UCGjuWO5DAV_AhyTCifv5ifokBIJCPHqnURA,800
|
|
2
|
+
slick_cli/app.py,sha256=QxnaUSLWUJ4akfLGndzKqusGkdGBF5jE2IKUOyOZjVc,8977
|
|
3
|
+
slick_cli/docstrings.py,sha256=N7KOo-y_1fRboLKJNRsOlECDmdBz3stDv3HSIKzEFCs,2904
|
|
4
|
+
slick_cli/errors.py,sha256=knZq9uOaSAv8dCTOsXlFDVFDE6v_hCCvS3rdEuzd3-E,837
|
|
5
|
+
slick_cli/params.py,sha256=Ya559blCfxizMGr9dN6DOM71vA5WKZtMel_HKOGErHw,11089
|
|
6
|
+
slick_cli/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
slick_cli/style.py,sha256=jvTppM8LbZkzFFOH_OfYU0CZeSN5-sbDTcaNm794a-M,4548
|
|
8
|
+
slick_cli/testing.py,sha256=HbJR_0KUGrNHB1hwkvItt-vzQ9aJHI8YR098MtQDymw,1583
|
|
9
|
+
slick_cli-0.1.0.dist-info/licenses/LICENSE,sha256=pAfYREEW9GAy7cnK20OXjDn7ofJahYny9GCuIZQTDAA,1061
|
|
10
|
+
slick_cli-0.1.0.dist-info/METADATA,sha256=xRuJSrcDWq-creEnhWfk6BywXg6Sn2-uVWY8iD1fihw,10291
|
|
11
|
+
slick_cli-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
12
|
+
slick_cli-0.1.0.dist-info/top_level.txt,sha256=1k0vQfOCH81DDXbq1U38iGqEbrpIsSm_7aZibLGR_fg,10
|
|
13
|
+
slick_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 nehz
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
slick_cli
|