strictcli 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.
strictcli/__init__.py
ADDED
|
@@ -0,0 +1,738 @@
|
|
|
1
|
+
"""A strict, zero-dependency CLI framework for Python."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__version__ = "0.1.0"
|
|
6
|
+
|
|
7
|
+
__all__ = ["App", "Flag", "Arg", "Tag", "Result", "flag", "arg"]
|
|
8
|
+
|
|
9
|
+
import contextlib
|
|
10
|
+
import inspect
|
|
11
|
+
import io
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Callable
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# Sentinel for distinguishing "not provided" from actual values
|
|
19
|
+
class _MissingSentinel:
|
|
20
|
+
def __repr__(self) -> str:
|
|
21
|
+
return "_MISSING"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
_MISSING = _MissingSentinel()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _HelpRequested(Exception):
|
|
28
|
+
"""Raised when --help or -h is encountered."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, target: object) -> None:
|
|
31
|
+
self.target = target
|
|
32
|
+
super().__init__()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class _VersionRequested(Exception):
|
|
36
|
+
"""Raised when --version or -v is encountered."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _ParseError(Exception):
|
|
40
|
+
"""Raised for user-facing parse errors."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _require_non_empty_str(value: str, field_name: str, class_name: str) -> None:
|
|
44
|
+
if not isinstance(value, str) or not value.strip():
|
|
45
|
+
raise ValueError(f"{class_name}.{field_name} must be a non-empty string")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class Flag:
|
|
50
|
+
"""Represents a --flag declaration."""
|
|
51
|
+
|
|
52
|
+
name: str
|
|
53
|
+
type: type
|
|
54
|
+
help: str
|
|
55
|
+
short: str | None = None
|
|
56
|
+
default: object = None
|
|
57
|
+
env: str | None = None
|
|
58
|
+
prefixed: bool = True
|
|
59
|
+
negatable: bool = True
|
|
60
|
+
|
|
61
|
+
def __post_init__(self) -> None:
|
|
62
|
+
_require_non_empty_str(self.help, "help", "Flag")
|
|
63
|
+
if self.type not in (str, bool):
|
|
64
|
+
raise ValueError(f"Flag.type must be str or bool, got {self.type!r}")
|
|
65
|
+
# Resolve _MISSING sentinels based on type
|
|
66
|
+
if isinstance(self.default, _MissingSentinel):
|
|
67
|
+
if self.type is bool:
|
|
68
|
+
self.default = False
|
|
69
|
+
else:
|
|
70
|
+
# str with _MISSING default means required (no default)
|
|
71
|
+
self.default = None
|
|
72
|
+
elif self.type is bool and self.default is None:
|
|
73
|
+
self.default = False
|
|
74
|
+
if isinstance(self.negatable, _MissingSentinel):
|
|
75
|
+
self.negatable = self.type is bool
|
|
76
|
+
elif self.type is str:
|
|
77
|
+
# negatable is only meaningful for bool flags
|
|
78
|
+
self.negatable = False
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class Arg:
|
|
83
|
+
"""Represents a positional argument."""
|
|
84
|
+
|
|
85
|
+
name: str
|
|
86
|
+
help: str
|
|
87
|
+
required: bool = True
|
|
88
|
+
|
|
89
|
+
def __post_init__(self) -> None:
|
|
90
|
+
_require_non_empty_str(self.help, "help", "Arg")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class Tag:
|
|
95
|
+
"""A reusable bundle of flags."""
|
|
96
|
+
|
|
97
|
+
name: str
|
|
98
|
+
flags: list[Flag] = field(default_factory=list)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass
|
|
102
|
+
class Command:
|
|
103
|
+
"""A leaf command with a handler."""
|
|
104
|
+
|
|
105
|
+
name: str
|
|
106
|
+
help: str
|
|
107
|
+
handler: Callable
|
|
108
|
+
flags: list[Flag] = field(default_factory=list)
|
|
109
|
+
args: list[Arg] = field(default_factory=list)
|
|
110
|
+
tags: list[Tag] = field(default_factory=list)
|
|
111
|
+
|
|
112
|
+
def __post_init__(self) -> None:
|
|
113
|
+
_require_non_empty_str(self.help, "help", "Command")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass
|
|
117
|
+
class Group:
|
|
118
|
+
"""A container for nested commands (one nesting level)."""
|
|
119
|
+
|
|
120
|
+
name: str
|
|
121
|
+
help: str
|
|
122
|
+
commands: dict[str, Command] = field(default_factory=dict)
|
|
123
|
+
env_prefix: str | None = None
|
|
124
|
+
|
|
125
|
+
def __post_init__(self) -> None:
|
|
126
|
+
_require_non_empty_str(self.help, "help", "Group")
|
|
127
|
+
|
|
128
|
+
def command(
|
|
129
|
+
self,
|
|
130
|
+
name: str,
|
|
131
|
+
*,
|
|
132
|
+
help: str,
|
|
133
|
+
args: list[Arg] | None = None,
|
|
134
|
+
tags: list[Tag] | None = None,
|
|
135
|
+
) -> Callable:
|
|
136
|
+
"""Decorator to register a command within this group."""
|
|
137
|
+
|
|
138
|
+
def decorator(func: Callable) -> Callable:
|
|
139
|
+
cmd = _build_and_validate_command(
|
|
140
|
+
name, help=help, handler=func, args=args, tags=tags, env_prefix=self.env_prefix
|
|
141
|
+
)
|
|
142
|
+
self.commands[name] = cmd
|
|
143
|
+
return func
|
|
144
|
+
|
|
145
|
+
return decorator
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass
|
|
149
|
+
class Result:
|
|
150
|
+
"""Returned by app.test()."""
|
|
151
|
+
|
|
152
|
+
stdout: str
|
|
153
|
+
stderr: str
|
|
154
|
+
exit_code: int
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass
|
|
158
|
+
class App:
|
|
159
|
+
"""The root CLI application."""
|
|
160
|
+
|
|
161
|
+
name: str
|
|
162
|
+
version: str
|
|
163
|
+
help: str
|
|
164
|
+
env_prefix: str | None = None
|
|
165
|
+
_commands: dict[str, Command] = field(default_factory=dict)
|
|
166
|
+
_groups: dict[str, Group] = field(default_factory=dict)
|
|
167
|
+
|
|
168
|
+
def __post_init__(self) -> None:
|
|
169
|
+
_require_non_empty_str(self.help, "help", "App")
|
|
170
|
+
|
|
171
|
+
def command(
|
|
172
|
+
self,
|
|
173
|
+
name: str,
|
|
174
|
+
*,
|
|
175
|
+
help: str,
|
|
176
|
+
args: list[Arg] | None = None,
|
|
177
|
+
tags: list[Tag] | None = None,
|
|
178
|
+
) -> Callable:
|
|
179
|
+
"""Decorator to register a top-level command."""
|
|
180
|
+
|
|
181
|
+
def decorator(func: Callable) -> Callable:
|
|
182
|
+
cmd = _build_and_validate_command(
|
|
183
|
+
name,
|
|
184
|
+
help=help,
|
|
185
|
+
handler=func,
|
|
186
|
+
args=args,
|
|
187
|
+
tags=tags,
|
|
188
|
+
env_prefix=self.env_prefix,
|
|
189
|
+
)
|
|
190
|
+
self._commands[name] = cmd
|
|
191
|
+
return func
|
|
192
|
+
|
|
193
|
+
return decorator
|
|
194
|
+
|
|
195
|
+
def group(self, name: str, *, help: str) -> Group:
|
|
196
|
+
"""Create and register a command group."""
|
|
197
|
+
grp = Group(name=name, help=help, env_prefix=self.env_prefix)
|
|
198
|
+
self._groups[name] = grp
|
|
199
|
+
return grp
|
|
200
|
+
|
|
201
|
+
def _parse(self, argv: list[str]) -> tuple[Command, dict[str, object]]:
|
|
202
|
+
"""Parse argv (without program name) into a resolved Command and kwargs."""
|
|
203
|
+
|
|
204
|
+
# Step 1: intercept app-level --help/-h and --version/-v
|
|
205
|
+
if not argv or argv == ["--help"] or argv == ["-h"]:
|
|
206
|
+
raise _HelpRequested(target=self)
|
|
207
|
+
if argv == ["--version"] or argv == ["-v"]:
|
|
208
|
+
raise _VersionRequested()
|
|
209
|
+
|
|
210
|
+
# Step 2: route to command or group
|
|
211
|
+
token = argv[0]
|
|
212
|
+
rest = argv[1:]
|
|
213
|
+
|
|
214
|
+
if token in self._groups:
|
|
215
|
+
group = self._groups[token]
|
|
216
|
+
if not rest or rest == ["--help"] or rest == ["-h"]:
|
|
217
|
+
raise _HelpRequested(target=group)
|
|
218
|
+
sub_token = rest[0]
|
|
219
|
+
rest = rest[1:]
|
|
220
|
+
if sub_token not in group.commands:
|
|
221
|
+
raise _ParseError(f"unknown command '{sub_token}'")
|
|
222
|
+
cmd = group.commands[sub_token]
|
|
223
|
+
elif token in self._commands:
|
|
224
|
+
cmd = self._commands[token]
|
|
225
|
+
else:
|
|
226
|
+
raise _ParseError(f"unknown command '{token}'")
|
|
227
|
+
|
|
228
|
+
# Check for command-level --help/-h
|
|
229
|
+
if rest == ["--help"] or rest == ["-h"]:
|
|
230
|
+
raise _HelpRequested(target=cmd)
|
|
231
|
+
|
|
232
|
+
# Step 3: parse remaining tokens for the resolved command
|
|
233
|
+
return _parse_command(cmd, rest)
|
|
234
|
+
|
|
235
|
+
def _find_command_prefix(self, cmd: Command) -> str:
|
|
236
|
+
"""Find the group prefix for a command (for help formatting)."""
|
|
237
|
+
for group in self._groups.values():
|
|
238
|
+
if cmd in group.commands.values():
|
|
239
|
+
return f"{group.name} "
|
|
240
|
+
return ""
|
|
241
|
+
|
|
242
|
+
def run(self) -> None:
|
|
243
|
+
"""Run the CLI application, reading from sys.argv."""
|
|
244
|
+
argv = sys.argv[1:]
|
|
245
|
+
try:
|
|
246
|
+
cmd, kwargs = self._parse(argv)
|
|
247
|
+
except _HelpRequested as e:
|
|
248
|
+
if isinstance(e.target, App):
|
|
249
|
+
print(_format_app_help(self))
|
|
250
|
+
elif isinstance(e.target, Group):
|
|
251
|
+
print(_format_group_help(self, e.target))
|
|
252
|
+
elif isinstance(e.target, Command):
|
|
253
|
+
prefix = self._find_command_prefix(e.target)
|
|
254
|
+
print(_format_command_help(self, e.target, prefix))
|
|
255
|
+
sys.exit(0)
|
|
256
|
+
except _VersionRequested:
|
|
257
|
+
print(_format_version(self))
|
|
258
|
+
sys.exit(0)
|
|
259
|
+
except _ParseError as e:
|
|
260
|
+
print(f"error: {e}", file=sys.stderr)
|
|
261
|
+
print(f"try '{self.name} --help'", file=sys.stderr)
|
|
262
|
+
sys.exit(1)
|
|
263
|
+
else:
|
|
264
|
+
cmd.handler(**kwargs)
|
|
265
|
+
sys.exit(0)
|
|
266
|
+
|
|
267
|
+
def test(self, argv: list[str]) -> Result:
|
|
268
|
+
"""Run the CLI with given argv, capturing output and exit code."""
|
|
269
|
+
stdout_buf = io.StringIO()
|
|
270
|
+
stderr_buf = io.StringIO()
|
|
271
|
+
exit_code = 0
|
|
272
|
+
|
|
273
|
+
try:
|
|
274
|
+
cmd, kwargs = self._parse(argv)
|
|
275
|
+
except _HelpRequested as e:
|
|
276
|
+
if isinstance(e.target, App):
|
|
277
|
+
stdout_buf.write(_format_app_help(self) + "\n")
|
|
278
|
+
elif isinstance(e.target, Group):
|
|
279
|
+
stdout_buf.write(_format_group_help(self, e.target) + "\n")
|
|
280
|
+
elif isinstance(e.target, Command):
|
|
281
|
+
prefix = self._find_command_prefix(e.target)
|
|
282
|
+
stdout_buf.write(_format_command_help(self, e.target, prefix) + "\n")
|
|
283
|
+
except _VersionRequested:
|
|
284
|
+
stdout_buf.write(_format_version(self) + "\n")
|
|
285
|
+
except _ParseError as e:
|
|
286
|
+
stderr_buf.write(f"error: {e}\n")
|
|
287
|
+
stderr_buf.write(f"try '{self.name} --help'\n")
|
|
288
|
+
exit_code = 1
|
|
289
|
+
else:
|
|
290
|
+
with contextlib.redirect_stdout(stdout_buf), contextlib.redirect_stderr(stderr_buf):
|
|
291
|
+
try:
|
|
292
|
+
cmd.handler(**kwargs)
|
|
293
|
+
except SystemExit as e:
|
|
294
|
+
exit_code = e.code if isinstance(e.code, int) else (1 if e.code else 0)
|
|
295
|
+
|
|
296
|
+
return Result(
|
|
297
|
+
stdout=stdout_buf.getvalue(),
|
|
298
|
+
stderr=stderr_buf.getvalue(),
|
|
299
|
+
exit_code=exit_code,
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _parse_command(cmd: Command, tokens: list[str]) -> tuple[Command, dict[str, object]]:
|
|
304
|
+
"""Parse tokens against a resolved command's flags and args."""
|
|
305
|
+
|
|
306
|
+
# Build flag lookup dicts
|
|
307
|
+
long_lookup: dict[str, Flag] = {} # --flag-name -> Flag
|
|
308
|
+
short_lookup: dict[str, Flag] = {} # -x -> Flag
|
|
309
|
+
negation_lookup: dict[str, Flag] = {} # --no-flag-name -> Flag
|
|
310
|
+
|
|
311
|
+
for f in cmd.flags:
|
|
312
|
+
long_lookup[f"--{f.name}"] = f
|
|
313
|
+
if f.short:
|
|
314
|
+
short_lookup[f"-{f.short}"] = f
|
|
315
|
+
if f.type is bool and f.negatable:
|
|
316
|
+
negation_lookup[f"--no-{f.name}"] = f
|
|
317
|
+
|
|
318
|
+
# Track which flags were set by CLI args
|
|
319
|
+
cli_set: dict[str, object] = {} # flag.name -> value
|
|
320
|
+
positionals: list[str] = []
|
|
321
|
+
|
|
322
|
+
i = 0
|
|
323
|
+
stop_flags = False # set when -- is encountered
|
|
324
|
+
|
|
325
|
+
while i < len(tokens):
|
|
326
|
+
tok = tokens[i]
|
|
327
|
+
|
|
328
|
+
if stop_flags or not tok.startswith("-") or tok == "-":
|
|
329
|
+
positionals.append(tok)
|
|
330
|
+
i += 1
|
|
331
|
+
continue
|
|
332
|
+
|
|
333
|
+
if tok == "--":
|
|
334
|
+
stop_flags = True
|
|
335
|
+
i += 1
|
|
336
|
+
continue
|
|
337
|
+
|
|
338
|
+
# --flag=value form
|
|
339
|
+
if tok.startswith("--") and "=" in tok:
|
|
340
|
+
eq_pos = tok.index("=")
|
|
341
|
+
flag_part = tok[:eq_pos]
|
|
342
|
+
value_part = tok[eq_pos + 1 :]
|
|
343
|
+
|
|
344
|
+
if flag_part in long_lookup:
|
|
345
|
+
f = long_lookup[flag_part]
|
|
346
|
+
if f.type is bool:
|
|
347
|
+
raise _ParseError(
|
|
348
|
+
f"flag '{flag_part}' is a boolean flag and does not take a value"
|
|
349
|
+
)
|
|
350
|
+
cli_set[f.name] = value_part
|
|
351
|
+
elif flag_part in negation_lookup:
|
|
352
|
+
raise _ParseError(
|
|
353
|
+
f"flag '{flag_part}' is a boolean negation and does not take a value"
|
|
354
|
+
)
|
|
355
|
+
else:
|
|
356
|
+
raise _ParseError(f"unknown flag '{flag_part}'")
|
|
357
|
+
i += 1
|
|
358
|
+
continue
|
|
359
|
+
|
|
360
|
+
# --no-flag negation
|
|
361
|
+
if tok in negation_lookup:
|
|
362
|
+
f = negation_lookup[tok]
|
|
363
|
+
cli_set[f.name] = False
|
|
364
|
+
i += 1
|
|
365
|
+
continue
|
|
366
|
+
|
|
367
|
+
# --flag (long form without =)
|
|
368
|
+
if tok.startswith("--"):
|
|
369
|
+
if tok in long_lookup:
|
|
370
|
+
f = long_lookup[tok]
|
|
371
|
+
if f.type is bool:
|
|
372
|
+
cli_set[f.name] = True
|
|
373
|
+
i += 1
|
|
374
|
+
else:
|
|
375
|
+
# str flag: consume next token as value
|
|
376
|
+
if i + 1 < len(tokens) and not tokens[i + 1].startswith("-"):
|
|
377
|
+
cli_set[f.name] = tokens[i + 1]
|
|
378
|
+
i += 2
|
|
379
|
+
else:
|
|
380
|
+
raise _ParseError(f"flag '{tok}' requires a value")
|
|
381
|
+
else:
|
|
382
|
+
raise _ParseError(f"unknown flag '{tok}'")
|
|
383
|
+
continue
|
|
384
|
+
|
|
385
|
+
# -x (short form)
|
|
386
|
+
if tok.startswith("-") and len(tok) == 2:
|
|
387
|
+
if tok in short_lookup:
|
|
388
|
+
f = short_lookup[tok]
|
|
389
|
+
if f.type is bool:
|
|
390
|
+
cli_set[f.name] = True
|
|
391
|
+
i += 1
|
|
392
|
+
else:
|
|
393
|
+
# str flag: consume next token as value
|
|
394
|
+
if i + 1 < len(tokens) and not tokens[i + 1].startswith("-"):
|
|
395
|
+
cli_set[f.name] = tokens[i + 1]
|
|
396
|
+
i += 2
|
|
397
|
+
else:
|
|
398
|
+
raise _ParseError(f"flag '{tok}' requires a value")
|
|
399
|
+
else:
|
|
400
|
+
raise _ParseError(f"unknown flag '{tok}'")
|
|
401
|
+
continue
|
|
402
|
+
|
|
403
|
+
# Unknown flag-like token
|
|
404
|
+
raise _ParseError(f"unknown flag '{tok}'")
|
|
405
|
+
|
|
406
|
+
# Step 4: resolve env vars for flags not set by CLI
|
|
407
|
+
for f in cmd.flags:
|
|
408
|
+
if f.name in cli_set:
|
|
409
|
+
continue
|
|
410
|
+
if f.env is not None:
|
|
411
|
+
env_val = os.environ.get(f.env)
|
|
412
|
+
if env_val is not None:
|
|
413
|
+
if f.type is bool:
|
|
414
|
+
lower = env_val.lower()
|
|
415
|
+
if lower in ("1", "true", "yes"):
|
|
416
|
+
cli_set[f.name] = True
|
|
417
|
+
elif lower in ("0", "false", "no"):
|
|
418
|
+
cli_set[f.name] = False
|
|
419
|
+
else:
|
|
420
|
+
raise _ParseError(
|
|
421
|
+
f"invalid boolean value {env_val!r} for env var "
|
|
422
|
+
f"'{f.env}' (flag '--{f.name}')"
|
|
423
|
+
)
|
|
424
|
+
else:
|
|
425
|
+
cli_set[f.name] = env_val
|
|
426
|
+
|
|
427
|
+
# Step 5: apply defaults
|
|
428
|
+
for f in cmd.flags:
|
|
429
|
+
if f.name in cli_set:
|
|
430
|
+
continue
|
|
431
|
+
if f.type is bool:
|
|
432
|
+
# Bool flags always have a default (False unless overridden)
|
|
433
|
+
cli_set[f.name] = f.default
|
|
434
|
+
elif f.default is not None:
|
|
435
|
+
cli_set[f.name] = f.default
|
|
436
|
+
else:
|
|
437
|
+
# str flag with no default and no value: required
|
|
438
|
+
raise _ParseError(f"flag '--{f.name}' is required")
|
|
439
|
+
|
|
440
|
+
# Step 6: resolve positional args
|
|
441
|
+
arg_values: dict[str, str] = {}
|
|
442
|
+
for idx, a in enumerate(cmd.args):
|
|
443
|
+
if idx < len(positionals):
|
|
444
|
+
arg_values[a.name] = positionals[idx]
|
|
445
|
+
elif a.required:
|
|
446
|
+
raise _ParseError(f"missing required argument '{a.name}'")
|
|
447
|
+
if len(positionals) > len(cmd.args):
|
|
448
|
+
raise _ParseError(f"unexpected argument '{positionals[len(cmd.args)]}'")
|
|
449
|
+
|
|
450
|
+
# Step 7: build kwargs dict
|
|
451
|
+
kwargs: dict[str, object] = {}
|
|
452
|
+
for f in cmd.flags:
|
|
453
|
+
kwargs[_flag_param_name(f.name)] = cli_set[f.name]
|
|
454
|
+
for a in cmd.args:
|
|
455
|
+
if a.name in arg_values:
|
|
456
|
+
kwargs[a.name] = arg_values[a.name]
|
|
457
|
+
|
|
458
|
+
return cmd, kwargs
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _flag_param_name(flag_name: str) -> str:
|
|
462
|
+
"""Convert a flag name like '--dry-run' to a Python parameter name 'dry_run'."""
|
|
463
|
+
return flag_name.lstrip("-").replace("-", "_")
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _build_and_validate_command(
|
|
467
|
+
name: str,
|
|
468
|
+
*,
|
|
469
|
+
help: str,
|
|
470
|
+
handler: Callable,
|
|
471
|
+
args: list[Arg] | None,
|
|
472
|
+
tags: list[Tag] | None,
|
|
473
|
+
env_prefix: str | None,
|
|
474
|
+
) -> Command:
|
|
475
|
+
"""Build a Command from a decorated handler, validate everything."""
|
|
476
|
+
if not help or not help.strip():
|
|
477
|
+
raise ValueError(f"command {name!r}: missing help text")
|
|
478
|
+
|
|
479
|
+
# Collect flags attached by @strictcli.flag decorators
|
|
480
|
+
decorator_flags: list[Flag] = list(getattr(handler, "_strictcli_flags", []))
|
|
481
|
+
# Collect args attached by @strictcli.arg decorators
|
|
482
|
+
decorator_args: list[Arg] = list(getattr(handler, "_strictcli_args", []))
|
|
483
|
+
|
|
484
|
+
# Merge explicit args parameter
|
|
485
|
+
all_args = list(args) if args else []
|
|
486
|
+
all_args.extend(decorator_args)
|
|
487
|
+
|
|
488
|
+
# Merge tags into flags
|
|
489
|
+
resolved_tags = list(tags) if tags else []
|
|
490
|
+
tag_flags: list[Flag] = []
|
|
491
|
+
for tag in resolved_tags:
|
|
492
|
+
tag_flags.extend(tag.flags)
|
|
493
|
+
|
|
494
|
+
# All flags: decorator flags + tag flags
|
|
495
|
+
all_flags = decorator_flags + tag_flags
|
|
496
|
+
|
|
497
|
+
# Validate: no duplicate flag names
|
|
498
|
+
seen_flag_names: set[str] = set()
|
|
499
|
+
for f in all_flags:
|
|
500
|
+
if f.name in seen_flag_names:
|
|
501
|
+
raise ValueError(f"command {name!r}: duplicate flag name {f.name!r}")
|
|
502
|
+
seen_flag_names.add(f.name)
|
|
503
|
+
|
|
504
|
+
# Validate: no duplicate arg names
|
|
505
|
+
seen_arg_names: set[str] = set()
|
|
506
|
+
for a in all_args:
|
|
507
|
+
if a.name in seen_arg_names:
|
|
508
|
+
raise ValueError(f"command {name!r}: duplicate arg name {a.name!r}")
|
|
509
|
+
seen_arg_names.add(a.name)
|
|
510
|
+
|
|
511
|
+
# Validate: flag help text
|
|
512
|
+
for f in all_flags:
|
|
513
|
+
if not f.help or not f.help.strip():
|
|
514
|
+
raise ValueError(
|
|
515
|
+
f"command {name!r}: flag {f.name!r} missing help text"
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
# Validate: env prefix
|
|
519
|
+
if env_prefix is not None:
|
|
520
|
+
for f in all_flags:
|
|
521
|
+
if f.env is not None and f.prefixed:
|
|
522
|
+
expected_prefix = f"{env_prefix}_"
|
|
523
|
+
if not f.env.startswith(expected_prefix):
|
|
524
|
+
raise ValueError(
|
|
525
|
+
f"command {name!r}: env var {f.env!r} for flag {f.name!r} "
|
|
526
|
+
f"must start with {expected_prefix!r} (or set prefixed=False)"
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
# Validate: handler signature matches declared flags and args
|
|
530
|
+
sig = inspect.signature(handler)
|
|
531
|
+
param_names = set(sig.parameters.keys())
|
|
532
|
+
|
|
533
|
+
expected_names: set[str] = set()
|
|
534
|
+
for f in all_flags:
|
|
535
|
+
expected_names.add(_flag_param_name(f.name))
|
|
536
|
+
for a in all_args:
|
|
537
|
+
expected_names.add(a.name)
|
|
538
|
+
|
|
539
|
+
# Check each flag has a matching parameter
|
|
540
|
+
for f in all_flags:
|
|
541
|
+
pname = _flag_param_name(f.name)
|
|
542
|
+
if pname not in param_names:
|
|
543
|
+
raise ValueError(
|
|
544
|
+
f"command {name!r}: handler missing parameter {pname!r} "
|
|
545
|
+
f"for flag {f.name!r}"
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
# Check each arg has a matching parameter
|
|
549
|
+
for a in all_args:
|
|
550
|
+
if a.name not in param_names:
|
|
551
|
+
raise ValueError(
|
|
552
|
+
f"command {name!r}: handler missing parameter {a.name!r} "
|
|
553
|
+
f"for arg {a.name!r}"
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
# Check for extra parameters
|
|
557
|
+
extra = param_names - expected_names
|
|
558
|
+
if extra:
|
|
559
|
+
extra_name = sorted(extra)[0]
|
|
560
|
+
raise ValueError(
|
|
561
|
+
f"command {name!r}: handler has extra parameter {extra_name!r} "
|
|
562
|
+
f"not matching any flag or arg"
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
return Command(
|
|
566
|
+
name=name,
|
|
567
|
+
help=help,
|
|
568
|
+
handler=handler,
|
|
569
|
+
flags=all_flags,
|
|
570
|
+
args=all_args,
|
|
571
|
+
tags=resolved_tags,
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def flag(
|
|
576
|
+
name: str,
|
|
577
|
+
*,
|
|
578
|
+
short: str | None = None,
|
|
579
|
+
type: type = str,
|
|
580
|
+
default: object = _MISSING,
|
|
581
|
+
help: str,
|
|
582
|
+
env: str | None = None,
|
|
583
|
+
prefixed: bool = True,
|
|
584
|
+
negatable: object = _MISSING,
|
|
585
|
+
) -> Callable:
|
|
586
|
+
"""Module-level decorator to attach a Flag to a command handler."""
|
|
587
|
+
|
|
588
|
+
def decorator(func: Callable) -> Callable:
|
|
589
|
+
f = Flag(
|
|
590
|
+
name=name,
|
|
591
|
+
short=short,
|
|
592
|
+
type=type,
|
|
593
|
+
default=default,
|
|
594
|
+
help=help,
|
|
595
|
+
env=env,
|
|
596
|
+
prefixed=prefixed,
|
|
597
|
+
negatable=negatable,
|
|
598
|
+
)
|
|
599
|
+
if not hasattr(func, "_strictcli_flags"):
|
|
600
|
+
func._strictcli_flags = []
|
|
601
|
+
func._strictcli_flags.append(f)
|
|
602
|
+
return func
|
|
603
|
+
|
|
604
|
+
return decorator
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def arg(name: str, *, help: str, required: bool = True) -> Callable:
|
|
608
|
+
"""Module-level decorator to attach an Arg to a command handler."""
|
|
609
|
+
|
|
610
|
+
def decorator(func: Callable) -> Callable:
|
|
611
|
+
a = Arg(name=name, help=help, required=required)
|
|
612
|
+
if not hasattr(func, "_strictcli_args"):
|
|
613
|
+
func._strictcli_args = []
|
|
614
|
+
func._strictcli_args.append(a)
|
|
615
|
+
return func
|
|
616
|
+
|
|
617
|
+
return decorator
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
# ---------------------------------------------------------------------------
|
|
621
|
+
# Help text formatters
|
|
622
|
+
# ---------------------------------------------------------------------------
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _format_version(app: App) -> str:
|
|
626
|
+
"""Format version string: '{name} {version}'."""
|
|
627
|
+
return f"{app.name} {app.version}"
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _format_app_help(app: App) -> str:
|
|
631
|
+
"""Format app-level help shown when the user runs 'myapp --help'."""
|
|
632
|
+
lines: list[str] = [f"{app.name} v{app.version} -- {app.help}"]
|
|
633
|
+
|
|
634
|
+
if app._commands:
|
|
635
|
+
lines.append("")
|
|
636
|
+
lines.append("Commands:")
|
|
637
|
+
names = list(app._commands.keys())
|
|
638
|
+
max_len = max(len(n) for n in names)
|
|
639
|
+
for name in names:
|
|
640
|
+
cmd = app._commands[name]
|
|
641
|
+
padding = max_len - len(name) + 4
|
|
642
|
+
lines.append(f" {name}{' ' * padding}{cmd.help}")
|
|
643
|
+
|
|
644
|
+
if app._groups:
|
|
645
|
+
lines.append("")
|
|
646
|
+
lines.append("Groups:")
|
|
647
|
+
names = list(app._groups.keys())
|
|
648
|
+
max_len = max(len(n) for n in names)
|
|
649
|
+
for name in names:
|
|
650
|
+
grp = app._groups[name]
|
|
651
|
+
padding = max_len - len(name) + 4
|
|
652
|
+
lines.append(f" {name}{' ' * padding}{grp.help}")
|
|
653
|
+
|
|
654
|
+
lines.append("")
|
|
655
|
+
lines.append(f"Use '{app.name} <command> --help' for more information.")
|
|
656
|
+
|
|
657
|
+
return "\n".join(lines)
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _format_group_help(app: App, group: Group) -> str:
|
|
661
|
+
"""Format group-level help shown when the user runs 'myapp group --help'."""
|
|
662
|
+
lines: list[str] = [f"{app.name} {group.name} -- {group.help}"]
|
|
663
|
+
|
|
664
|
+
if group.commands:
|
|
665
|
+
lines.append("")
|
|
666
|
+
lines.append("Commands:")
|
|
667
|
+
names = list(group.commands.keys())
|
|
668
|
+
max_len = max(len(n) for n in names)
|
|
669
|
+
for name in names:
|
|
670
|
+
cmd = group.commands[name]
|
|
671
|
+
padding = max_len - len(name) + 4
|
|
672
|
+
lines.append(f" {name}{' ' * padding}{cmd.help}")
|
|
673
|
+
|
|
674
|
+
lines.append("")
|
|
675
|
+
lines.append(
|
|
676
|
+
f"Use '{app.name} {group.name} <command> --help' for more information."
|
|
677
|
+
)
|
|
678
|
+
|
|
679
|
+
return "\n".join(lines)
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def _build_flag_spec(f: Flag) -> str:
|
|
683
|
+
"""Build the left-column spec string for a flag (e.g. '--target, -t <str>')."""
|
|
684
|
+
parts: list[str] = []
|
|
685
|
+
if f.type is bool and f.negatable:
|
|
686
|
+
parts.append(f"--{f.name}, --no-{f.name}")
|
|
687
|
+
if f.short:
|
|
688
|
+
parts.append(f"-{f.short}")
|
|
689
|
+
else:
|
|
690
|
+
parts.append(f"--{f.name}")
|
|
691
|
+
if f.short:
|
|
692
|
+
parts.append(f"-{f.short}")
|
|
693
|
+
spec = ", ".join(parts)
|
|
694
|
+
if f.type is str:
|
|
695
|
+
spec += " <str>"
|
|
696
|
+
return spec
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def _build_flag_meta(f: Flag) -> str:
|
|
700
|
+
"""Build the bracketed metadata suffix for a flag."""
|
|
701
|
+
meta_parts: list[str] = []
|
|
702
|
+
if f.env is not None:
|
|
703
|
+
meta_parts.append(f"env: {f.env}")
|
|
704
|
+
if f.type is bool:
|
|
705
|
+
meta_parts.append(f"default: {'true' if f.default else 'false'}")
|
|
706
|
+
elif f.default is not None:
|
|
707
|
+
meta_parts.append(f"default: {f.default}")
|
|
708
|
+
else:
|
|
709
|
+
meta_parts.append("required")
|
|
710
|
+
return " [" + "] [".join(meta_parts) + "]"
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def _format_command_help(app: App, cmd: Command, prefix: str = "") -> str:
|
|
714
|
+
"""Format command-level help shown when the user runs 'myapp cmd --help'."""
|
|
715
|
+
lines: list[str] = [f"{app.name} {prefix}{cmd.name} -- {cmd.help}"]
|
|
716
|
+
|
|
717
|
+
if cmd.args:
|
|
718
|
+
lines.append("")
|
|
719
|
+
lines.append("Arguments:")
|
|
720
|
+
max_len = max(len(a.name) for a in cmd.args)
|
|
721
|
+
for a in cmd.args:
|
|
722
|
+
padding = max_len - len(a.name) + 4
|
|
723
|
+
help_text = a.help
|
|
724
|
+
if not a.required:
|
|
725
|
+
help_text += " (optional)"
|
|
726
|
+
lines.append(f" {a.name}{' ' * padding}{help_text}")
|
|
727
|
+
|
|
728
|
+
if cmd.flags:
|
|
729
|
+
lines.append("")
|
|
730
|
+
lines.append("Flags:")
|
|
731
|
+
specs = [_build_flag_spec(f) for f in cmd.flags]
|
|
732
|
+
max_spec = max(len(s) for s in specs)
|
|
733
|
+
for f, spec in zip(cmd.flags, specs):
|
|
734
|
+
padding = max_spec - len(spec) + 4
|
|
735
|
+
meta = _build_flag_meta(f)
|
|
736
|
+
lines.append(f" {spec}{' ' * padding}{f.help}{meta}")
|
|
737
|
+
|
|
738
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: strictcli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A strict, zero-dependency CLI framework for Python
|
|
5
|
+
Project-URL: Homepage, https://github.com/smm-h/strictcli
|
|
6
|
+
Project-URL: Repository, https://github.com/smm-h/strictcli
|
|
7
|
+
Author-email: "S. M. Hosseini" <m.hosseini@veliu.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: argparse,cli,command-line,framework,rlsbl,strictcli
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
16
|
+
Requires-Python: >=3.11
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# strictcli
|
|
20
|
+
|
|
21
|
+
A strict, zero-dependency CLI framework for Python.
|
|
22
|
+
|
|
23
|
+
strictcli makes you declare everything -- every command, flag, argument, and environment variable must have help text or the framework errors at registration time. Types are `str` and `bool` only; there is no magic type inference. Environment variables are first-class, with prefix enforcement to keep your config namespace clean.
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
uv add strictcli
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Or:
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
pip install strictcli
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Requires Python 3.11+.
|
|
38
|
+
|
|
39
|
+
## Quickstart
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
# greet.py
|
|
43
|
+
import strictcli
|
|
44
|
+
|
|
45
|
+
app = strictcli.App(name="greet", version="0.1.0", help="a friendly greeter")
|
|
46
|
+
|
|
47
|
+
@app.command("hello", help="say hello", args=[strictcli.Arg(name="name", help="who to greet")])
|
|
48
|
+
@strictcli.flag("loud", short="l", type=bool, help="shout the greeting")
|
|
49
|
+
def hello(name, loud):
|
|
50
|
+
msg = f"Hello, {name}!"
|
|
51
|
+
if loud:
|
|
52
|
+
msg = msg.upper()
|
|
53
|
+
print(msg)
|
|
54
|
+
|
|
55
|
+
app.run()
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
$ python greet.py hello World
|
|
60
|
+
Hello, World!
|
|
61
|
+
|
|
62
|
+
$ python greet.py hello --loud World
|
|
63
|
+
HELLO, WORLD!
|
|
64
|
+
|
|
65
|
+
$ python greet.py hello --help
|
|
66
|
+
greet hello -- say hello
|
|
67
|
+
|
|
68
|
+
Arguments:
|
|
69
|
+
name who to greet
|
|
70
|
+
|
|
71
|
+
Flags:
|
|
72
|
+
--loud, --no-loud, -l shout the greeting [default: false]
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Commands and Groups
|
|
76
|
+
|
|
77
|
+
Register top-level commands with `@app.command`:
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
app = strictcli.App(name="myapp", version="1.0.0", help="manage deployments")
|
|
81
|
+
|
|
82
|
+
@app.command("status", help="show current status")
|
|
83
|
+
def status():
|
|
84
|
+
print("all systems go")
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Create groups for two-level nesting with `app.group`:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
db = app.group("db", help="manage databases")
|
|
91
|
+
|
|
92
|
+
@db.command("migrate", help="run database migrations")
|
|
93
|
+
@strictcli.flag("dry-run", type=bool, help="preview without applying")
|
|
94
|
+
def migrate(dry_run):
|
|
95
|
+
if dry_run:
|
|
96
|
+
print("would run migrations")
|
|
97
|
+
else:
|
|
98
|
+
print("running migrations")
|
|
99
|
+
|
|
100
|
+
@db.command("seed", help="populate with sample data")
|
|
101
|
+
@strictcli.flag("count", type=str, help="number of records", default="100")
|
|
102
|
+
def seed(count):
|
|
103
|
+
print(f"seeding {count} records")
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
$ myapp db migrate --dry-run
|
|
108
|
+
would run migrations
|
|
109
|
+
|
|
110
|
+
$ myapp db seed --count 500
|
|
111
|
+
seeding 500 records
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Flags
|
|
115
|
+
|
|
116
|
+
Declare flags with the `@strictcli.flag` decorator. Every flag must have `help` text.
|
|
117
|
+
|
|
118
|
+
### String flags
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
@app.command("build", help="build the project")
|
|
122
|
+
@strictcli.flag("output", short="o", type=str, help="output directory", default="dist")
|
|
123
|
+
def build(output):
|
|
124
|
+
print(f"building to {output}")
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
String flags accept values via `--output dist` or `--output=dist`. A string flag without a `default` is required.
|
|
128
|
+
|
|
129
|
+
### Bool flags
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
@app.command("deploy", help="deploy the app")
|
|
133
|
+
@strictcli.flag("force", short="f", type=bool, help="skip confirmation")
|
|
134
|
+
def deploy(force):
|
|
135
|
+
if force:
|
|
136
|
+
print("deploying without confirmation")
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Bool flags default to `False`. Pass `--force` to set `True`, or `--no-force` to explicitly set `False`. The `--no-` negation form is available by default for all bool flags; disable it with `negatable=False`.
|
|
140
|
+
|
|
141
|
+
### Short aliases
|
|
142
|
+
|
|
143
|
+
Any flag can have a one-character short alias:
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
@strictcli.flag("verbose", short="v", type=bool, help="verbose output")
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
This allows both `--verbose` and `-v`.
|
|
150
|
+
|
|
151
|
+
### Required vs optional
|
|
152
|
+
|
|
153
|
+
- `str` flags with no `default` are required -- the parser errors if missing.
|
|
154
|
+
- `str` flags with a `default` are optional.
|
|
155
|
+
- `bool` flags always default to `False`.
|
|
156
|
+
|
|
157
|
+
## Arguments
|
|
158
|
+
|
|
159
|
+
Positional arguments are declared with `strictcli.Arg`. There are two equivalent forms.
|
|
160
|
+
|
|
161
|
+
Using the `args=` parameter:
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
@app.command("copy", help="copy files", args=[
|
|
165
|
+
strictcli.Arg(name="src", help="source path"),
|
|
166
|
+
strictcli.Arg(name="dst", help="destination path"),
|
|
167
|
+
])
|
|
168
|
+
def copy(src, dst):
|
|
169
|
+
print(f"copying {src} to {dst}")
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Using the `@strictcli.arg` decorator:
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
@app.command("show", help="show a file")
|
|
176
|
+
@strictcli.arg("path", help="file to show")
|
|
177
|
+
def show(path):
|
|
178
|
+
print(f"showing {path}")
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Arguments are matched in order. Use `required=False` for optional arguments. The `--` separator stops flag parsing, so everything after it becomes positional:
|
|
182
|
+
|
|
183
|
+
```
|
|
184
|
+
$ myapp cmd -- --not-a-flag
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
## Environment Variables
|
|
188
|
+
|
|
189
|
+
Flags can be backed by environment variables with the `env` parameter:
|
|
190
|
+
|
|
191
|
+
```python
|
|
192
|
+
app = strictcli.App(name="myapp", version="1.0.0", help="my app", env_prefix="MYAPP")
|
|
193
|
+
|
|
194
|
+
@app.command("deploy", help="deploy the app")
|
|
195
|
+
@strictcli.flag("region", type=str, help="cloud region", env="MYAPP_REGION", default="us-east-1")
|
|
196
|
+
def deploy(region):
|
|
197
|
+
print(f"deploying to {region}")
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### Prefix enforcement
|
|
201
|
+
|
|
202
|
+
When `env_prefix` is set on the App, all env vars must start with that prefix. This is validated at registration time:
|
|
203
|
+
|
|
204
|
+
```python
|
|
205
|
+
# This raises ValueError: env var 'REGION' must start with 'MYAPP_'
|
|
206
|
+
@strictcli.flag("region", type=str, help="region", env="REGION", default="x")
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
### External env vars
|
|
210
|
+
|
|
211
|
+
Use `prefixed=False` for env vars outside your app's namespace:
|
|
212
|
+
|
|
213
|
+
```python
|
|
214
|
+
@strictcli.flag("token", type=str, help="auth token", env="GITHUB_TOKEN", prefixed=False, default="")
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
### Priority
|
|
218
|
+
|
|
219
|
+
Values resolve in this order: CLI argument > environment variable > default. If none of the three provides a value, the parser errors.
|
|
220
|
+
|
|
221
|
+
### Bool env vars
|
|
222
|
+
|
|
223
|
+
Bool flags from env vars accept `1`, `true`, `yes` (case-insensitive) for `True` and `0`, `false`, `no` for `False`. Any other value is an error.
|
|
224
|
+
|
|
225
|
+
## Tags
|
|
226
|
+
|
|
227
|
+
Tags are reusable bundles of flags that can be applied to multiple commands:
|
|
228
|
+
|
|
229
|
+
```python
|
|
230
|
+
auth_tag = strictcli.Tag(
|
|
231
|
+
name="auth",
|
|
232
|
+
flags=[
|
|
233
|
+
strictcli.Flag(name="token", type=str, help="auth token", env="MYAPP_TOKEN", default=""),
|
|
234
|
+
strictcli.Flag(name="insecure", type=bool, help="skip TLS verification"),
|
|
235
|
+
],
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
@app.command("deploy", help="deploy the app", tags=[auth_tag])
|
|
239
|
+
def deploy(token, insecure):
|
|
240
|
+
print(f"token={'set' if token else 'unset'}, insecure={insecure}")
|
|
241
|
+
|
|
242
|
+
@app.command("status", help="check status", tags=[auth_tag])
|
|
243
|
+
def status(token, insecure):
|
|
244
|
+
print(f"checking status")
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
Both commands now have `--token` and `--insecure` flags. Tag flags appear in help output and are parsed like any other flag.
|
|
248
|
+
|
|
249
|
+
## Help Output
|
|
250
|
+
|
|
251
|
+
Help is auto-generated at three levels. Pass `--help` or `-h` at any level, or invoke the app with no arguments.
|
|
252
|
+
|
|
253
|
+
**App level** (`myapp --help`):
|
|
254
|
+
|
|
255
|
+
```
|
|
256
|
+
myapp v1.0.0 -- manage deployments
|
|
257
|
+
|
|
258
|
+
Commands:
|
|
259
|
+
deploy deploy the application
|
|
260
|
+
|
|
261
|
+
Groups:
|
|
262
|
+
db manage databases
|
|
263
|
+
|
|
264
|
+
Use 'myapp <command> --help' for more information.
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
**Group level** (`myapp db --help`):
|
|
268
|
+
|
|
269
|
+
```
|
|
270
|
+
myapp db -- manage databases
|
|
271
|
+
|
|
272
|
+
Commands:
|
|
273
|
+
migrate run database migrations
|
|
274
|
+
seed populate with sample data
|
|
275
|
+
|
|
276
|
+
Use 'myapp db <command> --help' for more information.
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
**Command level** (`myapp deploy --help`):
|
|
280
|
+
|
|
281
|
+
```
|
|
282
|
+
myapp deploy -- deploy the application
|
|
283
|
+
|
|
284
|
+
Arguments:
|
|
285
|
+
target deployment target
|
|
286
|
+
|
|
287
|
+
Flags:
|
|
288
|
+
--region, -r <str> cloud region [env: MYAPP_REGION] [default: us-east-1]
|
|
289
|
+
--force, --no-force, -f skip confirmation prompt [default: false]
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
Version: `--version` or `-v` prints `myapp 1.0.0`.
|
|
293
|
+
|
|
294
|
+
## Testing
|
|
295
|
+
|
|
296
|
+
`app.test(argv)` runs the CLI in-process and returns a `Result` with captured output:
|
|
297
|
+
|
|
298
|
+
```python
|
|
299
|
+
result = app.test(["deploy", "--force", "production"])
|
|
300
|
+
|
|
301
|
+
assert result.exit_code == 0
|
|
302
|
+
assert "deploying" in result.stdout
|
|
303
|
+
assert result.stderr == ""
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
The `Result` dataclass has three fields: `stdout`, `stderr`, and `exit_code`.
|
|
307
|
+
|
|
308
|
+
## Strict by Design
|
|
309
|
+
|
|
310
|
+
strictcli is opinionated about strictness:
|
|
311
|
+
|
|
312
|
+
- **Help is mandatory.** Every command, flag, and argument must have help text. Missing help raises `ValueError` at registration time, not at runtime.
|
|
313
|
+
- **Only str and bool.** No int, float, or list types. Parse them yourself in the handler -- it is one line of code and makes the conversion visible.
|
|
314
|
+
- **Handler signatures are validated.** Every declared flag and arg must have a matching parameter in the handler function, and vice versa. Extra or missing parameters raise `ValueError`.
|
|
315
|
+
- **Env var prefixes are enforced.** If you set `env_prefix="MYAPP"`, every env-backed flag must use that prefix (or explicitly opt out with `prefixed=False`).
|
|
316
|
+
- **No hidden defaults.** Required flags fail loudly. Bool flags default to `False`. Everything else must be declared.
|
|
317
|
+
|
|
318
|
+
If you want automatic type coercion, subcommand hierarchies deeper than two levels, or rich terminal formatting, consider [argparse](https://docs.python.org/3/library/argparse.html), [click](https://click.palletsprojects.com/), or [typer](https://typer.tiangolo.com/).
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
strictcli/__init__.py,sha256=UMs_HktnCwfJ4gMZ7bHQmufDJYpU2SbgQc6PfVHX13s,23541
|
|
2
|
+
strictcli-0.1.0.dist-info/METADATA,sha256=-8RaDu9LzvaD86ljAoYXSK0JjoGEk_uZzl5-JKZN6MM,9167
|
|
3
|
+
strictcli-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
4
|
+
strictcli-0.1.0.dist-info/licenses/LICENSE,sha256=6ViJKrwd1dmR_KbVKOaV0zXyV3PTc9Lgvl9KlQfY-NU,1062
|
|
5
|
+
strictcli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 smm-h
|
|
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.
|