cli-components 2026.10.2__tar.gz → 2026.10.3__tar.gz

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.
Files changed (22) hide show
  1. {cli_components-2026.10.2/src/cli_components.egg-info → cli_components-2026.10.3}/PKG-INFO +3 -1
  2. {cli_components-2026.10.2 → cli_components-2026.10.3}/README.md +2 -0
  3. cli_components-2026.10.3/docs/examples/notes_cli.py +228 -0
  4. {cli_components-2026.10.2 → cli_components-2026.10.3}/docs/examples/project_cli.py +34 -3
  5. cli_components-2026.10.3/docs/usage.md +310 -0
  6. {cli_components-2026.10.2 → cli_components-2026.10.3}/pyproject.toml +1 -1
  7. {cli_components-2026.10.2 → cli_components-2026.10.3}/src/cli_components/__init__.py +4 -0
  8. cli_components-2026.10.3/src/cli_components/cli.py +330 -0
  9. {cli_components-2026.10.2 → cli_components-2026.10.3/src/cli_components.egg-info}/PKG-INFO +3 -1
  10. {cli_components-2026.10.2 → cli_components-2026.10.3}/src/cli_components.egg-info/SOURCES.txt +2 -0
  11. cli_components-2026.10.3/tests/test_cli_options.py +294 -0
  12. cli_components-2026.10.2/docs/usage.md +0 -93
  13. cli_components-2026.10.2/src/cli_components/cli.py +0 -183
  14. {cli_components-2026.10.2 → cli_components-2026.10.3}/LICENSE +0 -0
  15. {cli_components-2026.10.2 → cli_components-2026.10.3}/MANIFEST.in +0 -0
  16. {cli_components-2026.10.2 → cli_components-2026.10.3}/docs/releasing.md +0 -0
  17. {cli_components-2026.10.2 → cli_components-2026.10.3}/scripts/release_assets.py +0 -0
  18. {cli_components-2026.10.2 → cli_components-2026.10.3}/setup.cfg +0 -0
  19. {cli_components-2026.10.2 → cli_components-2026.10.3}/src/cli_components.egg-info/dependency_links.txt +0 -0
  20. {cli_components-2026.10.2 → cli_components-2026.10.3}/src/cli_components.egg-info/top_level.txt +0 -0
  21. {cli_components-2026.10.2 → cli_components-2026.10.3}/tests/test_cli.py +0 -0
  22. {cli_components-2026.10.2 → cli_components-2026.10.3}/tests/test_release_assets.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cli-components
3
- Version: 2026.10.2
3
+ Version: 2026.10.3
4
4
  Summary: Composable argparse commands and nested CLI modules
5
5
  License-Expression: AGPL-3.0-only
6
6
  Requires-Python: >=3.11
@@ -23,6 +23,8 @@ Examples:
23
23
  python3 docs/examples/project_cli.py --help
24
24
  python3 docs/examples/project_cli.py --daemon docker project new demo -v
25
25
  python3 docs/examples/project_cli.py project template compile --force
26
+ python3 docs/examples/notes_cli.py find --text release --tags docs --match all
27
+ python3 docs/examples/notes_cli.py tag clear --all
26
28
  ```
27
29
 
28
30
  Import the package as `cli_components`.
@@ -13,6 +13,8 @@ Examples:
13
13
  python3 docs/examples/project_cli.py --help
14
14
  python3 docs/examples/project_cli.py --daemon docker project new demo -v
15
15
  python3 docs/examples/project_cli.py project template compile --force
16
+ python3 docs/examples/notes_cli.py find --text release --tags docs --match all
17
+ python3 docs/examples/notes_cli.py tag clear --all
16
18
  ```
17
19
 
18
20
  Import the package as `cli_components`.
@@ -0,0 +1,228 @@
1
+ """Runnable example: a small notes manager built with cli-components.
2
+
3
+ It works on built-in sample notes and only prints what it would change, so it
4
+ is safe to run. It shows:
5
+
6
+ - root options handled by a `sub_command_func` before the selected command runs
7
+ - list positionals (`nargs="*"`), optional positionals (`nargs="?"`), `choices`
8
+ with defaults, a required option, `type` and `dest`
9
+ - command groups (`tag ...`) whose parent has no `func`, so it prints its own help
10
+ - an exclusive group pairing a list positional with an "all" flag
11
+ - a post-parse check reported in argparse's usage-error format
12
+ - a reusable `run()` helper that dispatches a selection
13
+
14
+ Try:
15
+
16
+ python3 docs/examples/notes_cli.py list
17
+ python3 docs/examples/notes_cli.py list --status open
18
+ python3 docs/examples/notes_cli.py find --text release --tags docs --match all
19
+ python3 docs/examples/notes_cli.py --store work.json add "Plan sprint" sprint planning
20
+ python3 docs/examples/notes_cli.py set --id 3 --status done
21
+ python3 docs/examples/notes_cli.py tag rename docs documentation
22
+ python3 docs/examples/notes_cli.py tag clear --all
23
+ python3 docs/examples/notes_cli.py tag
24
+ """
25
+
26
+ from argparse import RawDescriptionHelpFormatter
27
+ from typing import Any
28
+
29
+ from cli_components import (
30
+ ARGUMENTS,
31
+ FUNC,
32
+ LEAF,
33
+ SUB_COMMANDS,
34
+ SUB_COMMAND_ARGS,
35
+ SUB_COMMAND_FUNC,
36
+ Argument,
37
+ Command,
38
+ ExclusiveGroup,
39
+ Module,
40
+ Parser,
41
+ )
42
+
43
+ NOTES: list[dict[str, Any]] = [
44
+ {"id": 1, "title": "Release checklist", "tags": ["release", "docs"], "status": "open"},
45
+ {"id": 2, "title": "Rename option", "tags": ["api"], "status": "done"},
46
+ {"id": 3, "title": "Write examples", "tags": ["docs"], "status": "open"},
47
+ ]
48
+ STATUSES = ["open", "done", "archived"]
49
+
50
+ EPILOG = """examples:
51
+ notes list --status open
52
+ notes find --text release --tags docs --match all
53
+ notes tag clear --all"""
54
+
55
+
56
+ # Callbacks. Each takes the argument names of its command as keywords.
57
+
58
+ def configure(store: str, quiet: bool) -> None:
59
+ """Root `sub_command_func`: runs before whichever command was selected."""
60
+ if not quiet:
61
+ print(f"[store: {store}]")
62
+
63
+
64
+ def show(note: dict[str, Any]) -> None:
65
+ print(f"{note['id']:>3} {note['status']:<8} {note['title']} "
66
+ f"({', '.join(note['tags'])})")
67
+
68
+
69
+ def list_notes(status: str) -> None:
70
+ for note in NOTES:
71
+ if status == "all" or note["status"] == status:
72
+ show(note)
73
+
74
+
75
+ def find_notes(text: str | None, tags: list[str] | None, match: str) -> None:
76
+ for note in NOTES:
77
+ checks = []
78
+ if text is not None:
79
+ checks.append(text.lower() in note["title"].lower())
80
+ if tags:
81
+ checks.append(any(tag in note["tags"] for tag in tags))
82
+ if checks and (all(checks) if match == "all" else any(checks)):
83
+ show(note)
84
+
85
+
86
+ def add_note(title: str, tags: list[str]) -> None:
87
+ print(f"would add note {len(NOTES) + 1}: {title!r} tagged {tags or 'nothing'}")
88
+
89
+
90
+ def set_note(note_id: int, status: str | None, title: str | None) -> None:
91
+ changes = []
92
+ if status is not None:
93
+ changes.append(f"status -> {status}")
94
+ if title is not None:
95
+ changes.append(f"title -> {title!r}")
96
+ print(f"would update note {note_id}: {', '.join(changes)}")
97
+
98
+
99
+ def rename_tag(old: str, new: str) -> None:
100
+ count = sum(old in note["tags"] for note in NOTES)
101
+ print(f"would rename tag {old!r} to {new!r} on {count} note(s)")
102
+
103
+
104
+ def clear_tags(tags: list[str], all_tags: bool) -> None:
105
+ print("would remove every tag" if all_tags else f"would remove tags {tags}")
106
+
107
+
108
+ def build_parser() -> Parser:
109
+ return Parser(
110
+ prog_name="notes",
111
+ description="Keep track of short notes.",
112
+ epilog=EPILOG,
113
+ formatter_class=RawDescriptionHelpFormatter,
114
+ arguments=[
115
+ Argument("s", "store", "Notes file to use", "store", default="notes.json",
116
+ help_text_placeholder_value="FILE"),
117
+ Argument("q", "quiet", "Do not print the store name", "store_true"),
118
+ ],
119
+ sub_command_func=configure,
120
+ modules=[Module(
121
+ module_dest="command",
122
+ help_text="Command to run",
123
+ commands=[
124
+ Command(
125
+ name="list",
126
+ help_text="List notes",
127
+ arguments=[Argument(None, "status", "Only notes with this status",
128
+ "store", default="all",
129
+ choices=["all", *STATUSES])],
130
+ func=list_notes,
131
+ ),
132
+ Command(
133
+ name="find",
134
+ help_text="Find notes by text or tag",
135
+ arguments=[
136
+ Argument(None, "text", "Text the title contains", "store"),
137
+ Argument("t", "tags", "Tags to look for", "store", nargs="*"),
138
+ Argument("m", "match", "Match any or all of the filters",
139
+ "store", default="any", choices=["any", "all"]),
140
+ ],
141
+ func=find_notes,
142
+ ),
143
+ Command(
144
+ name="add",
145
+ help_text="Add a note",
146
+ arguments=[
147
+ Argument(None, "title", "Note title", "store", positional=True),
148
+ Argument(None, "tags", "Tags for the note", "store",
149
+ positional=True, nargs="*"),
150
+ ],
151
+ func=add_note,
152
+ ),
153
+ Command(
154
+ name="set",
155
+ help_text="Change a note",
156
+ arguments=[
157
+ Argument(None, "id", "Note to change", "store", type=int,
158
+ required=True, dest="note_id",
159
+ help_text_placeholder_value="ID"),
160
+ Argument(None, "status", "New status", "store",
161
+ choices=STATUSES),
162
+ Argument(None, "title", "New title", "store"),
163
+ ],
164
+ func=set_note,
165
+ ),
166
+ Command(
167
+ name="tag",
168
+ help_text="Manage tags",
169
+ # No func: `notes tag` on its own prints this group's help.
170
+ sub_modules=[Module(
171
+ module_dest="tag_command",
172
+ help_text="Tag operation",
173
+ commands=[
174
+ Command(
175
+ name="rename",
176
+ help_text="Rename a tag on every note",
177
+ arguments=[
178
+ Argument(None, "old", "Current tag", "store",
179
+ positional=True),
180
+ Argument(None, "new", "New tag", "store",
181
+ positional=True),
182
+ ],
183
+ func=rename_tag,
184
+ ),
185
+ Command(
186
+ name="clear",
187
+ help_text="Remove tags from every note",
188
+ exclusive_groups=[ExclusiveGroup([
189
+ Argument(None, "tags", "Tags to remove", "store",
190
+ positional=True, nargs="*"),
191
+ Argument("a", "all", "Remove every tag",
192
+ "store_true", dest="all_tags"),
193
+ ], required=True)],
194
+ func=clear_tags,
195
+ ),
196
+ ],
197
+ )],
198
+ ),
199
+ ],
200
+ )],
201
+ )
202
+
203
+
204
+ def run(parser: Parser, argv: list[str] | None = None) -> None:
205
+ """Parse argv and run the selection: ancestors first, then the command."""
206
+ selection = parser.evaluate(argv)
207
+
208
+ # Checks argparse cannot express, reported in its usage-error format.
209
+ if selection[FUNC] is set_note:
210
+ values = selection[ARGUMENTS]
211
+ if values["note_id"] not in {note["id"] for note in NOTES}:
212
+ selection[LEAF].error(f"argument --id: no note with id {values['note_id']}")
213
+ if values["status"] is None and values["title"] is None:
214
+ selection[LEAF].error("give --status, --title or both")
215
+
216
+ for ancestor in selection[SUB_COMMANDS].values():
217
+ if ancestor[SUB_COMMAND_FUNC] is not None:
218
+ ancestor[SUB_COMMAND_FUNC](**ancestor[SUB_COMMAND_ARGS])
219
+
220
+ if selection[FUNC] is None:
221
+ # A group (or the root) was chosen without a command: show its help.
222
+ selection[LEAF].argparser.print_help()
223
+ else:
224
+ selection[FUNC](**selection[ARGUMENTS])
225
+
226
+
227
+ if __name__ == "__main__":
228
+ run(build_parser())
@@ -1,24 +1,35 @@
1
- """Runnable nested CLI inspired by data-ops-python's dope.py."""
1
+ """Runnable example: a project manager with nested commands."""
2
+
3
+ from argparse import RawDescriptionHelpFormatter
4
+ from pathlib import Path
2
5
 
3
6
  from cli_components import (
4
7
  ARGUMENTS,
5
8
  FUNC,
9
+ LEAF,
6
10
  SUB_COMMANDS,
7
11
  SUB_COMMAND_ARGS,
8
12
  SUB_COMMAND_FUNC,
9
13
  Argument,
10
14
  Command,
15
+ ExclusiveGroup,
11
16
  Module,
12
17
  Parser,
13
18
  )
14
19
 
20
+ EPILOG = """examples:
21
+ example --daemon docker project new demo --verbose
22
+ example project new demo --template-file templates/web.toml"""
23
+
15
24
 
16
25
  def select_daemon(daemon: str) -> None:
17
26
  print(f"daemon={daemon}")
18
27
 
19
28
 
20
- def new_project(name: str, verbose: bool) -> None:
21
- print(f"new project: {name} (verbose={verbose})")
29
+ def new_project(name: str, verbose: bool, template: str | None,
30
+ template_file: Path | None) -> None:
31
+ source = template_file or template or "default"
32
+ print(f"new project: {name} (verbose={verbose}, template={source})")
22
33
 
23
34
 
24
35
  def compile_template(force: bool) -> None:
@@ -29,18 +40,33 @@ def main() -> None:
29
40
  parser = Parser(
30
41
  prog_name="example",
31
42
  description="Example project manager",
43
+ epilog=EPILOG,
44
+ formatter_class=RawDescriptionHelpFormatter,
32
45
  arguments=[Argument("d", "daemon", "Container runtime", "store",
33
46
  default="podman", choices=["podman", "docker"])],
34
47
  sub_command_func=select_daemon,
35
48
  modules=[Module(
36
49
  module_dest="module",
50
+ required=True,
37
51
  commands=[Command(
38
52
  name="project",
53
+ help_text="Manage projects",
39
54
  sub_modules=[Module(
40
55
  module_dest="project_action",
56
+ required=True,
41
57
  commands=[
42
58
  Command(
43
59
  name="new",
60
+ help_text="Create a project",
61
+ description="Create a project from a named template "
62
+ "or a template file.",
63
+ exclusive_groups=[ExclusiveGroup([
64
+ Argument("t", "template", "Template name", "store",
65
+ help_text_placeholder_value="NAME"),
66
+ Argument(None, "template-file", "Template file",
67
+ "store", type=Path,
68
+ help_text_placeholder_value="PATH"),
69
+ ])],
44
70
  arguments=[
45
71
  Argument(None, "name", "Project name", "store",
46
72
  positional=True),
@@ -50,8 +76,10 @@ def main() -> None:
50
76
  ),
51
77
  Command(
52
78
  name="template",
79
+ help_text="Work with templates",
53
80
  sub_modules=[Module(
54
81
  module_dest="template_action",
82
+ required=True,
55
83
  commands=[Command(
56
84
  name="compile",
57
85
  arguments=[Argument("f", "force", "Force build",
@@ -67,6 +95,9 @@ def main() -> None:
67
95
  )
68
96
 
69
97
  selection = parser.evaluate()
98
+ template_file = selection[ARGUMENTS].get("template_file")
99
+ if template_file is not None and template_file.suffix != ".toml":
100
+ selection[LEAF].error("--template-file must be a .toml file")
70
101
  for ancestor in selection[SUB_COMMANDS].values():
71
102
  callback = ancestor[SUB_COMMAND_FUNC]
72
103
  if callback is not None:
@@ -0,0 +1,310 @@
1
+ # Using cli-components
2
+
3
+ `Parser` represents the CLI root, `Module` creates an `argparse` subparser group,
4
+ `Command` adds a choice to that group, and `Argument` adds an option or positional
5
+ argument to a command. A typical CLI is a root parser with a module of commands,
6
+ some of which contain further modules of their own, as in `notes tag rename` or
7
+ `example project template compile`.
8
+
9
+ ## Complete examples
10
+
11
+ Both examples are executable once the package is installed with
12
+ `python3 -m pip install -e .`.
13
+
14
+ [docs/examples/notes_cli.py](examples/notes_cli.py) is a small notes manager.
15
+ It uses sample data and only prints what it would change, so it is safe to run.
16
+ It shows most of the patterns below in one place: root options, list and
17
+ optional positionals, `choices`, a required option, command groups, an
18
+ exclusive group, a post-parse check and a reusable dispatch helper.
19
+
20
+ ```sh
21
+ python3 docs/examples/notes_cli.py --help
22
+ python3 docs/examples/notes_cli.py list --status open
23
+ python3 docs/examples/notes_cli.py find --text release --tags docs --match all
24
+ python3 docs/examples/notes_cli.py --store work.json add "Plan sprint" sprint planning
25
+ python3 docs/examples/notes_cli.py set --id 3 --status done
26
+ python3 docs/examples/notes_cli.py tag clear --all
27
+ python3 docs/examples/notes_cli.py tag
28
+ ```
29
+
30
+ [docs/examples/project_cli.py](examples/project_cli.py) is a project manager
31
+ with three levels of commands, required subcommands and a root option that
32
+ picks a container runtime:
33
+
34
+ ```sh
35
+ python3 docs/examples/project_cli.py --help
36
+ python3 docs/examples/project_cli.py project new --help
37
+ python3 docs/examples/project_cli.py --daemon docker project new demo --verbose
38
+ python3 docs/examples/project_cli.py project template compile --force
39
+ ```
40
+
41
+ The `new` invocation prints `daemon=docker` followed by
42
+ `new project: demo (verbose=True, template=default)`. Root options go
43
+ **before** the subcommand; options for a command go after its name.
44
+
45
+ ## Constructing a CLI
46
+
47
+ ```python
48
+ from cli_components import Argument, Command, Module, Parser
49
+
50
+ parser = Parser(
51
+ prog_name="hello",
52
+ description="Greeting utility",
53
+ modules=[Module(
54
+ module_dest="operation",
55
+ commands=[Command(
56
+ name="greet",
57
+ arguments=[
58
+ Argument(None, "person", "Who to greet", "store", positional=True),
59
+ Argument("l", "loud", "Use uppercase", "store_true"),
60
+ ],
61
+ func=lambda person, loud: print(f"Hello, {person}!".upper() if loud
62
+ else f"Hello, {person}!"),
63
+ )],
64
+ )],
65
+ )
66
+
67
+ selection = parser.evaluate() # or parser.evaluate(["greet", "Ada", "-l"])
68
+ if selection["func"] is not None:
69
+ selection["func"](**selection["arguments"])
70
+ ```
71
+
72
+ Run this as `hello greet Ada -l` (or pass that argument list to `evaluate`).
73
+ `flag=None` means no short option; a non-positional argument still has a long
74
+ option. A hyphenated `name` such as `"project-name"` becomes `--project-name`
75
+ on the command line and `project_name` in the callback keyword arguments.
76
+ `default`, `choices`, `nargs`, `required`, `type`, and `dest` map to their
77
+ `argparse` equivalents, and `help_text_placeholder_value` maps to argparse's
78
+ `metavar` (see [Argument options](#argument-options)).
79
+ For positional arguments, `required` is implicit in `argparse` (unless `nargs`
80
+ makes them optional). Each nested `Module` needs a distinct `module_dest`.
81
+
82
+ ## Selecting and executing callbacks
83
+
84
+ `evaluate()` only parses; it does **not** run callbacks. It returns a dictionary
85
+ with the exported keys `PATH`, `COMMAND`, `FUNC`, `ARGUMENTS`, `SUB_COMMANDS`,
86
+ and `LEAF`:
87
+
88
+ ```python
89
+ {
90
+ "path": ["hello", "greet"], # root to selected leaf
91
+ "command": "greet",
92
+ "func": greet_callback, # the leaf's func, or None
93
+ "arguments": {"person": "Ada", "loud": True},
94
+ "sub_commands": { # ancestors, root first
95
+ "hello": {"sc_args": {}, "sc_func": None},
96
+ },
97
+ "leaf": <Command greet>, # the selected Command object
98
+ }
99
+ ```
100
+
101
+ A common way to dispatch is to call each ancestor's `sub_command_func` with its
102
+ own `sc_args` (root first), then the leaf `func` with `arguments`:
103
+
104
+ ```python
105
+ from cli_components import ARGUMENTS, FUNC, SUB_COMMANDS, SUB_COMMAND_ARGS, SUB_COMMAND_FUNC
106
+
107
+ for ancestor in selection[SUB_COMMANDS].values():
108
+ if ancestor[SUB_COMMAND_FUNC] is not None:
109
+ ancestor[SUB_COMMAND_FUNC](**ancestor[SUB_COMMAND_ARGS])
110
+ if selection[FUNC] is not None:
111
+ selection[FUNC](**selection[ARGUMENTS])
112
+ ```
113
+
114
+ When no subcommand is chosen, the root `Parser` is the selected command
115
+ (unless the module is [required](#required-subcommands)). The same applies
116
+ one level down: `notes tag` on its own selects `tag`. If the selected command
117
+ has no `func`, a good default is to print that command's help:
118
+
119
+ ```python
120
+ from cli_components import ARGUMENTS, FUNC, LEAF, SUB_COMMANDS, SUB_COMMAND_ARGS, SUB_COMMAND_FUNC
121
+
122
+
123
+ def run(parser, argv=None):
124
+ selection = parser.evaluate(argv)
125
+ for ancestor in selection[SUB_COMMANDS].values():
126
+ if ancestor[SUB_COMMAND_FUNC] is not None:
127
+ ancestor[SUB_COMMAND_FUNC](**ancestor[SUB_COMMAND_ARGS])
128
+ if selection[FUNC] is None:
129
+ selection[LEAF].argparser.print_help() # e.g. `notes tag`
130
+ else:
131
+ selection[FUNC](**selection[ARGUMENTS])
132
+ ```
133
+
134
+ `argparse` handles invalid choices and missing required arguments using its
135
+ normal usage message and `SystemExit(2)`.
136
+
137
+ ## Common patterns
138
+
139
+ **Options for the whole CLI.** Put them on the `Parser` and give it a
140
+ `sub_command_func`; it receives those options as keywords and runs before the
141
+ selected command (when you dispatch as above). Root options go before the
142
+ subcommand on the command line: `notes --store work.json list`.
143
+
144
+ ```python
145
+ Parser("notes", "Keep track of short notes.",
146
+ arguments=[Argument("s", "store", "Notes file to use", "store",
147
+ default="notes.json")],
148
+ sub_command_func=configure, # def configure(store): ...
149
+ modules=[...])
150
+ ```
151
+
152
+ **Several values.** `nargs="*"` collects zero or more values into a list,
153
+ `nargs="+"` one or more, and an integer exactly that many:
154
+
155
+ ```python
156
+ Argument(None, "title", "Note title", "store", positional=True)
157
+ Argument(None, "tags", "Tags for the note", "store", positional=True, nargs="*")
158
+ # notes add "Plan sprint" sprint planning -> title="Plan sprint", tags=["sprint", "planning"]
159
+ ```
160
+
161
+ **An optional positional.** `nargs="?"` makes a positional optional; it is
162
+ `None` (or its `default`) when left out:
163
+
164
+ ```python
165
+ Argument(None, "name", "Project to deploy (default: current)", "store",
166
+ positional=True, nargs="?")
167
+ ```
168
+
169
+ **A fixed set of values.** Combine `choices` with a `default`:
170
+
171
+ ```python
172
+ Argument("m", "match", "Match any or all of the filters", "store",
173
+ default="any", choices=["any", "all"])
174
+ ```
175
+
176
+ **A required option.** Options are optional unless `required=True`. Use `type`
177
+ to convert the value and `dest` to give the callback a clearer keyword:
178
+
179
+ ```python
180
+ Argument(None, "id", "Note to change", "store", type=int, required=True,
181
+ dest="note_id") # --id 3 -> note_id=3
182
+ ```
183
+
184
+ **Some values, or all of them.** An exclusive group can pair a list positional
185
+ with an "all" flag, so exactly one of them must be given:
186
+
187
+ ```python
188
+ ExclusiveGroup([
189
+ Argument(None, "tags", "Tags to remove", "store", positional=True, nargs="*"),
190
+ Argument("a", "all", "Remove every tag", "store_true", dest="all_tags"),
191
+ ], required=True)
192
+ # notes tag clear old stale -> tags=["old", "stale"], all_tags=False
193
+ # notes tag clear --all -> tags=[], all_tags=True
194
+ ```
195
+
196
+ **A group of related commands.** Give a `Command` a `sub_modules` list instead
197
+ of a `func`. Either make its module `required=True`, so `notes tag` alone is a
198
+ usage error, or leave it optional and print the group's help, as in `run()`
199
+ above.
200
+
201
+ ## Argument options
202
+
203
+ ```python
204
+ from pathlib import Path
205
+
206
+ Argument(None, "row", "Row number", "store", type=int)
207
+ Argument("o", "output", "Output file", "store", type=Path, default="out.csv")
208
+ Argument(None, "cell", "Cell to edit", "store", type=int, nargs=2,
209
+ help_text_placeholder_value=("ROW", "COLUMN"))
210
+ Argument(None, "output-file", "Output file", "store", dest="target")
211
+ ```
212
+
213
+ - `type` converts and validates the value while parsing. If the callable raises
214
+ `ValueError`, `TypeError` or `argparse.ArgumentTypeError`, argparse prints its
215
+ normal usage error and exits with status 2. argparse also passes string
216
+ defaults through `type`, so `default="out.csv"` arrives as `Path("out.csv")`.
217
+ - `help_text_placeholder_value` (argparse's `metavar`) is the placeholder
218
+ shown for the value in help, usage lines and error messages. It only changes
219
+ how the argument is displayed, not the parsed value or the callback keyword.
220
+ A tuple gives one name per value and must match `nargs`; the example above
221
+ shows as `--cell ROW COLUMN` and still arrives as `cell=[row, column]`.
222
+ Tuple placeholders on *positional* arguments need Python 3.12 or newer (argparse
223
+ cannot format them on 3.11, so cli-components raises `ValueError`).
224
+ - `dest` changes the key in `arguments` (and the callback keyword) without
225
+ changing the option name. For a positional, the original `name` is still
226
+ shown in help.
227
+ - `default` is passed through whenever it is given, including `None`, `0`,
228
+ `""` and `False`. Leave it out to keep argparse's own default (for example
229
+ `False` for `store_true`).
230
+
231
+ ## Help pages
232
+
233
+ Every `Command` gets its own `--help` page. `help_text` is the one-line summary
234
+ in the parent's command list; `description` is shown at the top of the
235
+ command's own page and defaults to `help_text`. `epilog` is shown at the end.
236
+
237
+ ```python
238
+ from argparse import RawDescriptionHelpFormatter
239
+
240
+ SIGNATURE = """eval signature:
241
+ f(row, column, value) -> new value"""
242
+
243
+ Parser("tool", "CSV tool", epilog=SIGNATURE,
244
+ formatter_class=RawDescriptionHelpFormatter,
245
+ modules=[Module("cmd", commands=[
246
+ Command("apply", help_text="Apply a script",
247
+ description="Apply a Python expression to selected cells.",
248
+ epilog=SIGNATURE),
249
+ ])])
250
+ ```
251
+
252
+ The default formatter re-wraps descriptions and epilogs into paragraphs. Use
253
+ `RawDescriptionHelpFormatter` to keep line breaks. A `Command` without its own
254
+ `formatter_class` uses its parent's, so setting it on the `Parser` is enough.
255
+
256
+ ## Required subcommands
257
+
258
+ By default, leaving out the subcommand selects the parent command. With
259
+ `Module(..., required=True)`, argparse reports
260
+ `the following arguments are required: <module_dest>` and exits with status 2.
261
+
262
+ ## Mutually exclusive arguments
263
+
264
+ ```python
265
+ from cli_components import ExclusiveGroup
266
+
267
+ Command("apply", exclusive_groups=[
268
+ ExclusiveGroup([
269
+ Argument(None, "script", "Inline script", "store"),
270
+ Argument(None, "script-file", "Script file", "store", type=Path),
271
+ ], required=True),
272
+ ExclusiveGroup([
273
+ Argument(None, "row", "Row", "store", type=int),
274
+ Argument(None, "column", "Column", "store", type=int),
275
+ Argument(None, "cell", "Cell", "store", type=int, nargs=2,
276
+ help_text_placeholder_value=("ROW", "COLUMN")),
277
+ ], required=True),
278
+ ])
279
+ ```
280
+
281
+ Each group maps to `add_mutually_exclusive_group`. With `required=True`, exactly
282
+ one member must be given; otherwise at most one. Group members appear in
283
+ `arguments` like any other argument, with `None` for the ones not given.
284
+ Commands and the `Parser` both accept `exclusive_groups`.
285
+
286
+ - Members cannot set `required=True`; put it on the group.
287
+ - Positional members need `nargs="?"` or `nargs="*"`.
288
+ - Keep member defaults as `None` (the default). argparse decides whether a
289
+ member was given by checking `value is not default`, so with `default=0`
290
+ an explicit `--row 0` would not count as given.
291
+
292
+ ## Reporting your own usage errors
293
+
294
+ For checks that argparse cannot express, report them after parsing in the same
295
+ format. `evaluate()` returns the selected `Command` under `LEAF`:
296
+
297
+ ```python
298
+ from cli_components import ARGUMENTS, LEAF
299
+
300
+ selection = parser.evaluate()
301
+ if selection[ARGUMENTS].get("row", 1) > 1000:
302
+ selection[LEAF].error("--row must be 1000 or less") # exits with status 2
303
+ ```
304
+
305
+ `Command.error(message)` prints that command's usage line (for example
306
+ `usage: tool apply ...`) and the message to stderr, then exits with status 2.
307
+ `Command.print_usage(file=None)` prints the usage line only. Both work on the
308
+ `Parser` at any time; on other commands they work once `evaluate()` has built
309
+ the parser (before that, `RuntimeError`). The underlying
310
+ `argparse.ArgumentParser` is available as the `argparser` property.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cli-components"
7
- version = "2026.10.2"
7
+ version = "2026.10.3"
8
8
  description = "Composable argparse commands and nested CLI modules"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"