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.
- {cli_components-2026.10.2/src/cli_components.egg-info → cli_components-2026.10.3}/PKG-INFO +3 -1
- {cli_components-2026.10.2 → cli_components-2026.10.3}/README.md +2 -0
- cli_components-2026.10.3/docs/examples/notes_cli.py +228 -0
- {cli_components-2026.10.2 → cli_components-2026.10.3}/docs/examples/project_cli.py +34 -3
- cli_components-2026.10.3/docs/usage.md +310 -0
- {cli_components-2026.10.2 → cli_components-2026.10.3}/pyproject.toml +1 -1
- {cli_components-2026.10.2 → cli_components-2026.10.3}/src/cli_components/__init__.py +4 -0
- cli_components-2026.10.3/src/cli_components/cli.py +330 -0
- {cli_components-2026.10.2 → cli_components-2026.10.3/src/cli_components.egg-info}/PKG-INFO +3 -1
- {cli_components-2026.10.2 → cli_components-2026.10.3}/src/cli_components.egg-info/SOURCES.txt +2 -0
- cli_components-2026.10.3/tests/test_cli_options.py +294 -0
- cli_components-2026.10.2/docs/usage.md +0 -93
- cli_components-2026.10.2/src/cli_components/cli.py +0 -183
- {cli_components-2026.10.2 → cli_components-2026.10.3}/LICENSE +0 -0
- {cli_components-2026.10.2 → cli_components-2026.10.3}/MANIFEST.in +0 -0
- {cli_components-2026.10.2 → cli_components-2026.10.3}/docs/releasing.md +0 -0
- {cli_components-2026.10.2 → cli_components-2026.10.3}/scripts/release_assets.py +0 -0
- {cli_components-2026.10.2 → cli_components-2026.10.3}/setup.cfg +0 -0
- {cli_components-2026.10.2 → cli_components-2026.10.3}/src/cli_components.egg-info/dependency_links.txt +0 -0
- {cli_components-2026.10.2 → cli_components-2026.10.3}/src/cli_components.egg-info/top_level.txt +0 -0
- {cli_components-2026.10.2 → cli_components-2026.10.3}/tests/test_cli.py +0 -0
- {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.
|
|
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
|
|
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
|
|
21
|
-
|
|
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.
|