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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.