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 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)
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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