strictcli 0.40.0__tar.gz → 0.41.0__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.
- {strictcli-0.40.0 → strictcli-0.41.0}/PKG-INFO +215 -50
- strictcli-0.41.0/README.md +641 -0
- {strictcli-0.40.0 → strictcli-0.41.0}/pyproject.toml +1 -1
- strictcli-0.41.0/scripts/migrate_presence.py +202 -0
- {strictcli-0.40.0 → strictcli-0.41.0}/strictcli/__init__.py +10456 -4764
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/flagship_app.py +1 -1
- strictcli-0.41.0/tests/test_arg_decorator_order.py +120 -0
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_arg_default.py +34 -15
- strictcli-0.41.0/tests/test_arg_default_validation.py +60 -0
- strictcli-0.41.0/tests/test_argv_value_order.py +209 -0
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_at_prefix.py +7 -7
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_call.py +27 -16
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_check_schema.py +55 -0
- strictcli-0.41.0/tests/test_choice_records.py +425 -0
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_choices.py +14 -10
- strictcli-0.41.0/tests/test_choices_none.py +174 -0
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_classification.py +1 -1
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_command_help_suggestion.py +2 -2
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_command_tags.py +1 -1
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_compound_types.py +159 -103
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_config.py +77 -60
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_config_fields.py +28 -12
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_config_set_bugs.py +23 -23
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_confirm.py +7 -7
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_connection_env.py +64 -4
- strictcli-0.41.0/tests/test_constraints.py +1969 -0
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_context.py +6 -6
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_deep_nesting.py +1 -1
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_dump_schema.py +715 -136
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_e2e.py +6 -6
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_effects_bypass_check.py +12 -10
- strictcli-0.41.0/tests/test_flat_pre_typed.py +891 -0
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_float_format.py +3 -3
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_float_type.py +9 -5
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_global_flags.py +2 -2
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_guard_v2.py +2 -2
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_help.py +3 -3
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_hermetic.py +2 -2
- strictcli-0.41.0/tests/test_infra_env.py +912 -0
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_int_type.py +7 -3
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_invoke.py +76 -53
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_machine_mode.py +50 -15
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_mcp.py +68 -6
- strictcli-0.41.0/tests/test_member_spelling.py +688 -0
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_nesting.py +2 -2
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_parser.py +85 -5
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_passthrough.py +20 -11
- strictcli-0.41.0/tests/test_presence.py +1035 -0
- strictcli-0.41.0/tests/test_provenance.py +216 -0
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_provenance_phase2.py +2 -2
- strictcli-0.41.0/tests/test_record_pre_typed.py +331 -0
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_registration.py +3 -3
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_repeatable.py +53 -41
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_reserved_global_flags.py +3 -3
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_reserved_quartet.py +43 -8
- strictcli-0.41.0/tests/test_selectors.py +2166 -0
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_tool_export.py +179 -31
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_trace_store.py +2 -1
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_typed_args.py +57 -57
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_unique.py +9 -4
- strictcli-0.41.0/tests/test_update.py +1328 -0
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_validate.py +29 -5
- strictcli-0.41.0/tests/test_value_sweep_order.py +302 -0
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_variadic.py +21 -16
- {strictcli-0.40.0 → strictcli-0.41.0}/uv.lock +1 -1
- strictcli-0.40.0/README.md +0 -476
- strictcli-0.40.0/tests/test_arg_default_validation.py +0 -79
- strictcli-0.40.0/tests/test_choices_none.py +0 -163
- strictcli-0.40.0/tests/test_dependencies.py +0 -668
- strictcli-0.40.0/tests/test_infra_env.py +0 -258
- strictcli-0.40.0/tests/test_mutex.py +0 -623
- strictcli-0.40.0/tests/test_provenance.py +0 -212
- {strictcli-0.40.0 → strictcli-0.41.0}/.claude/settings.json +0 -0
- {strictcli-0.40.0 → strictcli-0.41.0}/.github/workflows/ci.yml +0 -0
- {strictcli-0.40.0 → strictcli-0.41.0}/.github/workflows/publish.yml +0 -0
- {strictcli-0.40.0 → strictcli-0.41.0}/.gitignore +0 -0
- {strictcli-0.40.0 → strictcli-0.41.0}/.rlsbl/config.json +0 -0
- {strictcli-0.40.0 → strictcli-0.41.0}/.rlsbl/lint/python.toml +0 -0
- {strictcli-0.40.0 → strictcli-0.41.0}/.rlsbl/managed-files.json +0 -0
- {strictcli-0.40.0 → strictcli-0.41.0}/CLAUDE.md +0 -0
- {strictcli-0.40.0 → strictcli-0.41.0}/LICENSE +0 -0
- {strictcli-0.40.0 → strictcli-0.41.0}/scripts/add_effect_classification.py +0 -0
- {strictcli-0.40.0 → strictcli-0.41.0}/scripts/add_forwarding_declaration.py +0 -0
- {strictcli-0.40.0 → strictcli-0.41.0}/strictcli/py.typed +0 -0
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/conftest.py +0 -0
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_auto_version.py +0 -0
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_check_command.py +0 -0
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_check_discovery.py +0 -0
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_check_provider.py +0 -0
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_check_public_api.py +0 -0
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_check_runner.py +0 -0
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_check_types.py +0 -0
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_claimed_rendering.py +0 -0
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_config_file_path.py +0 -0
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_coverage.py +0 -0
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_deprecated.py +0 -0
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_dry_run_unsupported.py +0 -0
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_effects.py +0 -0
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_env.py +0 -0
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_exit_codes.py +0 -0
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_flag_sets.py +0 -0
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_flagship_preview.py +0 -0
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_float_vectors.py +0 -0
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_global_flag_conflict_position.py +0 -0
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_keyword_flags.py +0 -0
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_owns_stdout.py +0 -0
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_payload_schema.py +0 -0
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_tagdsl.py +0 -0
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_toml_loading.py +0 -0
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_ulid_vectors.py +0 -0
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_utilities.py +0 -0
- {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_visibility.py +0 -0
- {strictcli-0.40.0 → strictcli-0.41.0}/todo/.defer/deferred.md +0 -0
- {strictcli-0.40.0 → strictcli-0.41.0}/todo/.done/keyword-collision-in-flag-param-name.md +0 -0
- {strictcli-0.40.0 → strictcli-0.41.0}/todo/.done/original-idea.md +0 -0
- {strictcli-0.40.0 → strictcli-0.41.0}/todo/.done/public-check-runner-api.md +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: strictcli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.41.0
|
|
4
4
|
Summary: A strict CLI framework for Python
|
|
5
5
|
Project-URL: Homepage, https://github.com/smm-h/strictcli
|
|
6
6
|
Project-URL: Repository, https://github.com/smm-h/strictcli
|
|
@@ -23,6 +23,16 @@ A strict CLI framework for Python.
|
|
|
23
23
|
|
|
24
24
|
strictcli makes you declare everything -- every command, flag, argument, and environment variable must have help text or the framework errors at registration time. Four types only: `str`, `bool`, `int`, `float`. No magic type inference, no implicit defaults.
|
|
25
25
|
|
|
26
|
+
There are Go and TypeScript implementations too, and this one is not a port of
|
|
27
|
+
either. The surface here is Python's own -- decorators, keyword arguments, a
|
|
28
|
+
`Flag` dataclass, `ValueError` at registration -- and some of the enforcement
|
|
29
|
+
exists only in Python, because only Python can see it (a handler parameter
|
|
30
|
+
bound to an optional flag must default to `None`, since anything else
|
|
31
|
+
re-introduces the sentinel the declaration removed). What the three
|
|
32
|
+
implementations hold identical is behavior: the same semantics, the same help
|
|
33
|
+
bytes, the same schema, and the same error sentence with Python's spellings
|
|
34
|
+
inside it.
|
|
35
|
+
|
|
26
36
|
## Installation
|
|
27
37
|
|
|
28
38
|
```
|
|
@@ -45,7 +55,7 @@ import strictcli
|
|
|
45
55
|
app = strictcli.App("greet", version="1.0.0", help="A greeting app")
|
|
46
56
|
|
|
47
57
|
@app.command("hello", help="Say hello", effect="read_only")
|
|
48
|
-
@strictcli.flag("name", type=str, help="Who to greet")
|
|
58
|
+
@strictcli.flag("name", type=str, help="Who to greet", presence="required")
|
|
49
59
|
@strictcli.flag("loud", type=bool, default=False, help="Shout it")
|
|
50
60
|
def hello(ctx, name, loud):
|
|
51
61
|
msg = f"Hello, {name}!"
|
|
@@ -91,44 +101,68 @@ def migrate(ctx):
|
|
|
91
101
|
|
|
92
102
|
Invoked as `myapp db schema migrate`.
|
|
93
103
|
|
|
104
|
+
### The presence declaration
|
|
105
|
+
|
|
106
|
+
Every flag and every positional argument declares **exactly one** of three facts about itself. Declaring none of them does not register; declaring two does not register.
|
|
107
|
+
|
|
108
|
+
| Fact | Spelling | Delivered when nothing supplies a value |
|
|
109
|
+
|------|----------|------------------------------------------|
|
|
110
|
+
| required | `presence="required"` | nothing -- the parse fails with `flag '--x' is required` |
|
|
111
|
+
| optional | `presence="optional"` | `None` |
|
|
112
|
+
| default | `default=<value>` | the declared value |
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
@strictcli.flag("target", type=str, help="Deploy target", presence="required")
|
|
116
|
+
@strictcli.flag("note", type=str, help="A note", presence="optional")
|
|
117
|
+
@strictcli.flag("retries", type=int, help="Retry count", default=3)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Nothing about presence is inferred. A bool with no declaration is not "false by default", a `list[T]` with no declaration is not "empty by default" -- an empty collection is declared with `default=[]` or `default={}` -- and `default=None` is not a spelling of optionality: it is refused at registration with a redirect to `presence="optional"`, which is what delivers `None`.
|
|
121
|
+
|
|
122
|
+
Requiredness is satisfied by any source that supplies a value: a command-line token, an environment variable, a config file, or an `Implies` injection. An optional flag makes real tri-state bools possible (`--x` true, `--no-x` false, absent absent), and it makes `""` a value again instead of an absence sentinel.
|
|
123
|
+
|
|
124
|
+
`ctx.provided(name)` answers whether the **invocation** caused a value: true for `cli`, `env`, `config` and `implied`, false for `default` and `infra`. `ctx.source(name)` still answers the narrower question of which origin it was.
|
|
125
|
+
|
|
126
|
+
A handler parameter bound to an optional flag or argument must default to `None` if it defaults to anything, so the sentinel the declaration removed cannot come back one line later. A choice flag declares `required` or a default and never `optional` -- an absent selection is a choice nobody named, so it is named as a choice of its own.
|
|
127
|
+
|
|
94
128
|
### Four flag types
|
|
95
129
|
|
|
96
130
|
`str`, `bool`, `int`, and `float`. No magic coercion -- parse errors are clear and immediate.
|
|
97
131
|
|
|
98
132
|
```python
|
|
99
|
-
@strictcli.flag("port", type=int, help="Port number")
|
|
100
|
-
@strictcli.flag("threshold", type=float, help="Score threshold")
|
|
133
|
+
@strictcli.flag("port", type=int, help="Port number", presence="required")
|
|
134
|
+
@strictcli.flag("threshold", type=float, help="Score threshold", presence="optional")
|
|
101
135
|
@strictcli.flag("cache", type=bool, default=True, help="Reuse the build cache")
|
|
102
136
|
@strictcli.flag("output", type=str, help="Output path", default="out.txt")
|
|
103
137
|
```
|
|
104
138
|
|
|
105
|
-
Bool flags support `--flag` / `--no-flag` negation (disable with `negatable=False`)
|
|
139
|
+
Bool flags support `--flag` / `--no-flag` negation (disable with `negatable=False`). A bool declared `presence="required"` must be passed as `--flag` or `--no-flag`; one declared `presence="optional"` is real tri-state. Float parsing rejects NaN and Inf.
|
|
106
140
|
|
|
107
141
|
### Compound types
|
|
108
142
|
|
|
109
143
|
`list[T]` and `dict[str, T]` for collecting multiple values.
|
|
110
144
|
|
|
111
145
|
```python
|
|
112
|
-
@strictcli.flag("tags", type=list[str], help="Tags to apply", unique=True)
|
|
113
|
-
@strictcli.flag("env", type=dict[str, str], help="Environment variables")
|
|
146
|
+
@strictcli.flag("tags", type=list[str], help="Tags to apply", unique=True, default=[])
|
|
147
|
+
@strictcli.flag("env", type=dict[str, str], help="Environment variables", default={})
|
|
114
148
|
```
|
|
115
149
|
|
|
116
150
|
List flags accept `--tags a --tags b`. Dict flags accept `--env KEY=VALUE` pairs or JSON objects.
|
|
117
151
|
|
|
118
152
|
### Positional arguments
|
|
119
153
|
|
|
120
|
-
Two equivalent declaration forms.
|
|
154
|
+
Two equivalent declaration forms. Every argument declares its presence exactly as a flag does: `presence="required"`, `presence="optional"`, or a `default=`. A variadic argument always delivers a list, so it declares `required` (at least one value) or `optional` (possibly none) and never a default.
|
|
121
155
|
|
|
122
156
|
```python
|
|
123
157
|
# Decorator form
|
|
124
158
|
@app.command("show", help="Show a file", effect="read_only")
|
|
125
|
-
@strictcli.arg("path", help="File to show")
|
|
159
|
+
@strictcli.arg("path", help="File to show", presence="required")
|
|
126
160
|
def show(ctx, path): ...
|
|
127
161
|
|
|
128
162
|
# Inline form
|
|
129
163
|
@app.command("copy", help="Copy files", effect="mutating", args=[
|
|
130
|
-
strictcli.Arg(name="src", help="Source"),
|
|
131
|
-
strictcli.Arg(name="dst", help="Destination"),
|
|
164
|
+
strictcli.Arg(name="src", help="Source", presence="required"),
|
|
165
|
+
strictcli.Arg(name="dst", help="Destination", presence="required"),
|
|
132
166
|
])
|
|
133
167
|
def copy(ctx, src, dst): ...
|
|
134
168
|
```
|
|
@@ -164,8 +198,8 @@ Reusable bundles of flags shared across commands.
|
|
|
164
198
|
auth_flags = strictcli.FlagSet(
|
|
165
199
|
name="auth",
|
|
166
200
|
flags=[
|
|
167
|
-
strictcli.Flag(name="token", type=str, help="Auth token",
|
|
168
|
-
strictcli.Flag(name="insecure", type=bool,
|
|
201
|
+
strictcli.Flag(name="token", type=str, help="Auth token", presence="required"),
|
|
202
|
+
strictcli.Flag(name="insecure", type=bool, presence="optional", help="Skip TLS verification"),
|
|
169
203
|
],
|
|
170
204
|
)
|
|
171
205
|
|
|
@@ -173,43 +207,153 @@ auth_flags = strictcli.FlagSet(
|
|
|
173
207
|
def deploy(ctx, token, insecure): ...
|
|
174
208
|
```
|
|
175
209
|
|
|
176
|
-
###
|
|
210
|
+
### Choice flags: a choice is a declaration scope
|
|
177
211
|
|
|
178
|
-
|
|
212
|
+
A **choice flag** elects exactly one of its declared choices, and each choice
|
|
213
|
+
owns the flags that exist only while it is elected. A flag supplied outside its
|
|
214
|
+
elected scope is a distinct parse error naming both sides -- never "unknown
|
|
215
|
+
flag" -- and the elected value reaches the handler as one tagged record that
|
|
216
|
+
`match` consumes exhaustively.
|
|
179
217
|
|
|
180
218
|
```python
|
|
181
|
-
@
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
219
|
+
@strictcli.choice("email", help="deliver the notification as an email message")
|
|
220
|
+
class Email:
|
|
221
|
+
subject: str = strictcli.sub_flag(help="subject line", presence="required")
|
|
222
|
+
recipient: str = strictcli.sub_flag(help="destination address", presence="required")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@strictcli.choice("sms", help="deliver the notification as a text message")
|
|
226
|
+
class Sms:
|
|
227
|
+
phone_number: str = strictcli.sub_flag(help="destination number", presence="required")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@app.command("send", help="Send one notification", effect="mutating")
|
|
231
|
+
@strictcli.choice_flag("via", help="Delivery channel", short="v", presence="required",
|
|
232
|
+
elect_by="selector-token", choices=[Email, Sms])
|
|
233
|
+
def send(ctx, via: Email | Sms):
|
|
234
|
+
match via:
|
|
235
|
+
case Email(subject=subject, recipient=recipient): ...
|
|
236
|
+
case Sms(phone_number=number): ...
|
|
237
|
+
case _: assert_never(via)
|
|
188
238
|
```
|
|
189
239
|
|
|
190
|
-
|
|
240
|
+
`notify send --via email --subject hi` parses; `notify send --via sms --subject hi`
|
|
241
|
+
says *`--subject` is only valid under `--via email`*. Order is irrelevant --
|
|
242
|
+
nothing is interpreted until every token is collected -- and a choice flag may be
|
|
243
|
+
declared inside a choice's scope to any depth (`strictcli.sub_choice_flag`).
|
|
244
|
+
|
|
245
|
+
`elect_by` is mandatory and has no default. `elect_by="member-flags"` spells each
|
|
246
|
+
choice as its own flag instead (`--profile work` / `--all-profiles`), with no
|
|
247
|
+
selector token ever typed; a member's payload is declared
|
|
248
|
+
`value: str = strictcli.member_value(help=...)`.
|
|
249
|
+
|
|
250
|
+
Scoped flags are never top-level handler arguments, at any depth, so every
|
|
251
|
+
declared top-level key is still always present. Inside the record,
|
|
252
|
+
`strictcli.provided(via, "subject")` answers whether the invocation caused a
|
|
253
|
+
field's value.
|
|
254
|
+
|
|
255
|
+
### Constraints
|
|
256
|
+
|
|
257
|
+
Four relationship types, all passed via `constraints=[...]`, and every one of
|
|
258
|
+
them declares a **mandatory name** -- the name is what a violation prints and
|
|
259
|
+
what `--help` shows, and it is what lets one constraint be a member of another.
|
|
260
|
+
|
|
261
|
+
- `AtLeastOne(name, members)` -- at least one member is engaged. Members may
|
|
262
|
+
co-occur; it has no upper bound and is never exclusivity
|
|
263
|
+
- `AllOrNone(name, members)` -- either every member is engaged or none is.
|
|
264
|
+
Nothing engaged is vacuously satisfied
|
|
265
|
+
- `Requires(name, flag=..., depends_on=...)` -- one-way dependency
|
|
266
|
+
- `Implies(name, flag=..., implies=..., value=...)` -- auto-set a bool flag when
|
|
267
|
+
another is provided; explicit contradictions are parse errors
|
|
268
|
+
|
|
269
|
+
A **member** is a `Member(name, when=...)` record naming a command flag, a
|
|
270
|
+
positional arg, or another named at-least-one or all-or-none (nesting is a
|
|
271
|
+
cycle-checked DAG). A bare string is refused. `when` is the closed election
|
|
272
|
+
vocabulary -- `"present"` (the default; the value was provided by the
|
|
273
|
+
invocation, never by a declared default), `"true"` (bool only), `"non_empty"`
|
|
274
|
+
(strings and collections) -- and a **bool member must declare it explicitly**,
|
|
275
|
+
so `--no-all` can never engage a constraint while selecting nothing.
|
|
191
276
|
|
|
192
|
-
|
|
277
|
+
```python
|
|
278
|
+
@app.command("rewrite", help="Rewrite author identity", effect="mutating",
|
|
279
|
+
constraints=[
|
|
280
|
+
strictcli.AllOrNone("author-name", [
|
|
281
|
+
strictcli.Member("old-name"), strictcli.Member("new-name"),
|
|
282
|
+
]),
|
|
283
|
+
strictcli.AllOrNone("author-email", [
|
|
284
|
+
strictcli.Member("old-email"), strictcli.Member("new-email"),
|
|
285
|
+
]),
|
|
286
|
+
strictcli.AtLeastOne("author-change", [
|
|
287
|
+
strictcli.Member("author-name"),
|
|
288
|
+
strictcli.Member("author-email"),
|
|
289
|
+
]),
|
|
290
|
+
])
|
|
291
|
+
@strictcli.flag("old-name", type=str, presence="optional", help="Current name")
|
|
292
|
+
@strictcli.flag("new-name", type=str, presence="optional", help="New name")
|
|
293
|
+
@strictcli.flag("old-email", type=str, presence="optional", help="Current email")
|
|
294
|
+
@strictcli.flag("new-email", type=str, presence="optional", help="New email")
|
|
295
|
+
def rewrite(ctx, old_name, new_name, old_email, new_email): ...
|
|
296
|
+
```
|
|
193
297
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
-
|
|
298
|
+
```
|
|
299
|
+
Constraints:
|
|
300
|
+
author-name all or none of --old-name, --new-name
|
|
301
|
+
author-email all or none of --old-email, --new-email
|
|
302
|
+
author-change at least one of (--old-name with --new-name), (--old-email with --new-email)
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
Children are evaluated before parents, so an operator who typed one half of a
|
|
306
|
+
pair is told the pair is incomplete rather than that the whole selection is
|
|
307
|
+
missing. No member of a co-occurrence constraint may declare
|
|
308
|
+
`presence="required"` -- a member the invocation must always supply leaves the
|
|
309
|
+
constraint nothing to decide.
|
|
310
|
+
|
|
311
|
+
Constraints can only reference flags and args you declared, and they operate at
|
|
312
|
+
root scope only, so the reserved quartet (`dry-run`, `approve-consequential`,
|
|
313
|
+
`quiet`, `verbose`) can never appear in one and a scoped flag is a registration
|
|
314
|
+
error.
|
|
315
|
+
|
|
316
|
+
### Update commands
|
|
317
|
+
|
|
318
|
+
A command that changes some properties of one resource and leaves the rest alone
|
|
319
|
+
declares what it updates with `update_of=`. `UpdateOf` is a frozen, keyword-only
|
|
320
|
+
record whose first field is the resource name:
|
|
197
321
|
|
|
198
322
|
```python
|
|
199
|
-
@app.command("
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
@strictcli.flag("
|
|
205
|
-
@strictcli.flag("
|
|
206
|
-
@strictcli.flag("
|
|
207
|
-
@strictcli.flag("
|
|
208
|
-
def
|
|
323
|
+
@app.command("update-record", help="Change one DNS record in place", effect="mutating",
|
|
324
|
+
update_of=strictcli.UpdateOf("dns-record", write_mode="sparse",
|
|
325
|
+
identity=["zone", "record-id"],
|
|
326
|
+
properties=["content", "ttl", "proxied"]))
|
|
327
|
+
@strictcli.flag("zone", type=str, presence="required", help="Zone the record belongs to")
|
|
328
|
+
@strictcli.flag("record-id", type=str, presence="required", help="Identifier of the record")
|
|
329
|
+
@strictcli.flag("content", type=str, presence="optional", help="Record content")
|
|
330
|
+
@strictcli.flag("ttl", type=int, presence="optional", nullable=True, help="Time to live in seconds")
|
|
331
|
+
@strictcli.flag("proxied", type=bool, presence="optional", help="Whether the record is proxied")
|
|
332
|
+
def update_record(ctx, zone, record_id, content, ttl, proxied): ...
|
|
209
333
|
```
|
|
210
334
|
|
|
211
|
-
|
|
212
|
-
|
|
335
|
+
`write_mode` is `"sparse"` or `"full_replace"` and carries no default. A property
|
|
336
|
+
declares `presence="optional"` and nothing else -- absence *is* untouched -- and
|
|
337
|
+
the framework refuses an invocation that supplies none of them:
|
|
338
|
+
|
|
339
|
+
```
|
|
340
|
+
error: update "dns-record": at least one property is required: --content, --ttl, --proxied
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
Because absence must never resolve to a value nobody stated, **no flag or arg on
|
|
344
|
+
a `mutating` command may declare a value default** (`default=[]` and `default={}`
|
|
345
|
+
stay legal, as does every default on a `read_only` command). Inside an update
|
|
346
|
+
`--no-proxied` writes `False`; a `nullable` property mints `--unset-<prop>`,
|
|
347
|
+
answered by `ctx.unset(name)`. Every run that reports what it does renders the
|
|
348
|
+
write set -- one unnumbered line in the would-do log, and a `writes` member on
|
|
349
|
+
the machine envelope:
|
|
350
|
+
|
|
351
|
+
```
|
|
352
|
+
$ mytool --dry-run update-record --zone z1 --record-id r7 --content hi --unset-ttl
|
|
353
|
+
DRY RUN — no changes were made. Would do:
|
|
354
|
+
writes: content; clears: ttl (other properties unchanged)
|
|
355
|
+
1. net: PATCH https://api.example.com/zones/z1/dns_records/r7
|
|
356
|
+
```
|
|
213
357
|
|
|
214
358
|
### Global flags
|
|
215
359
|
|
|
@@ -244,23 +388,33 @@ are forwarded to the child byte-for-byte, so `myapp run deploy --dry-run` passes
|
|
|
244
388
|
Flags that accumulate values across multiple occurrences. Requires explicit `unique=True` or `unique=False`.
|
|
245
389
|
|
|
246
390
|
```python
|
|
247
|
-
@strictcli.flag("tag", type=str, help="Add a tag", repeatable=True, unique=True)
|
|
391
|
+
@strictcli.flag("tag", type=str, help="Add a tag", repeatable=True, unique=True, default=[])
|
|
248
392
|
```
|
|
249
393
|
|
|
250
394
|
### Choices
|
|
251
395
|
|
|
252
|
-
Restrict flag values to an allowed set.
|
|
396
|
+
Restrict flag values to an allowed set. Every entry is a record, and its help is
|
|
397
|
+
optional:
|
|
253
398
|
|
|
254
399
|
```python
|
|
255
|
-
@strictcli.flag("format", type=str, help="Output format",
|
|
400
|
+
@strictcli.flag("format", type=str, help="Output format", presence="required",
|
|
401
|
+
choices=[strictcli.Choice("json", help="one JSON document"),
|
|
402
|
+
strictcli.Choice("csv"),
|
|
403
|
+
strictcli.Choice("xml")])
|
|
256
404
|
```
|
|
257
405
|
|
|
406
|
+
Help renders on one line (`[choices: json, csv, xml]`) until an entry carries
|
|
407
|
+
help, at which point the whole flag renders as an indented block.
|
|
408
|
+
|
|
409
|
+
The boundary against a choice flag is structural, not a matter of taste: **need a
|
|
410
|
+
scope or member spelling -> choice flag; a plain constrained value -> choices.**
|
|
411
|
+
|
|
258
412
|
### Custom validation
|
|
259
413
|
|
|
260
414
|
Per-flag validation functions.
|
|
261
415
|
|
|
262
416
|
```python
|
|
263
|
-
@strictcli.flag("port", type=int, help="Port number", validate=lambda v: 1 <= v <= 65535)
|
|
417
|
+
@strictcli.flag("port", type=int, help="Port number", validate=lambda v: 1 <= v <= 65535, presence="required")
|
|
264
418
|
```
|
|
265
419
|
|
|
266
420
|
### Deprecated commands
|
|
@@ -285,7 +439,7 @@ def internal_debug(ctx): ...
|
|
|
285
439
|
|
|
286
440
|
### JSON config file support
|
|
287
441
|
|
|
288
|
-
Reads `~/.config/{name}/config.json` (or TOML). Auto-registers `config show/set/path/edit` subcommands.
|
|
442
|
+
Reads `~/.config/{name}/config.json` (or TOML). Auto-registers `config show/set/path/edit` subcommands, where `config set <key> --value <v>` writes under a required selector over a value, a clear (`--clear`) and a reset to the declared default (`--default`).
|
|
289
443
|
|
|
290
444
|
```python
|
|
291
445
|
app = strictcli.App("myapp", version="1.0.0", help="My app", config=True)
|
|
@@ -307,7 +461,8 @@ operations (`run`, `spawn`, `write`, `mkdir`, `remove`, `rename`, `chmod`,
|
|
|
307
461
|
`http`) are recorded rather than performed and rendered as a would-do log.
|
|
308
462
|
|
|
309
463
|
Four flag names are owned by the framework and cannot be declared at any level
|
|
310
|
-
(app flags, command flags, flag sets,
|
|
464
|
+
(app flags, command flags, flag sets, and flags declared inside a choice's
|
|
465
|
+
scope at any depth). They arrive on the context,
|
|
311
466
|
never as handler kwargs:
|
|
312
467
|
|
|
313
468
|
| Flag | Context property |
|
|
@@ -353,7 +508,9 @@ without either flag is a hard error rather than a hang. Declaring
|
|
|
353
508
|
|
|
354
509
|
### Schema dump
|
|
355
510
|
|
|
356
|
-
`--dump-schema` is auto-injected on every app. Writes `.strictcli/schema.json` describing the full CLI structure (commands, flags, args, groups, checks). Every command entry carries its `effect`; `consequential`, `dry_run_supported` and `dry_run_unsupported_reason` are emitted only when declared.
|
|
511
|
+
`--dump-schema` is auto-injected on every app. Writes `.strictcli/schema.json` at `schema_version: 2` describing the full CLI structure (commands, flags, args, groups, checks). Every command entry carries its `effect`; `consequential`, `dry_run_supported` and `dry_run_unsupported_reason` are emitted only when declared.
|
|
512
|
+
|
|
513
|
+
Every flag and arg entry carries a `value_schema`: a real JSON Schema fragment from a closed subset of `type`, `items`, `additionalProperties` and `enum`, using JSON Schema's own type names. Arity is part of the value's shape, so a repeatable scalar flag and a `list[T]` flag publish the identical array fragment. A choice flag carries no fragment -- its value is a variant the subset cannot express -- and publishes its nested `choices` and scopes instead, each scoped entry a full flag entry, with `elect_by` marking the spelling. A value flag's `choices=` splits in two: the values as an `enum` inside the fragment, and the value-plus-help records beside it under `choices`. Keys are emitted in a declared order at every depth and the document is written in one canonical encoding, so a schema file written by this implementation and one written by the Go or TypeScript implementation for the same declaration are byte-identical.
|
|
357
514
|
|
|
358
515
|
### Check system
|
|
359
516
|
|
|
@@ -441,10 +598,12 @@ paths have no TTY contract, so a consequential command is dispatched directly.
|
|
|
441
598
|
| `Flag` | Flag declaration |
|
|
442
599
|
| `Arg` | Positional argument |
|
|
443
600
|
| `FlagSet` | Reusable flag bundle |
|
|
444
|
-
| `
|
|
445
|
-
| `
|
|
601
|
+
| `Choice` | One entry of a `choices=` value flag: a value with optional help |
|
|
602
|
+
| `AtLeastOne` | At least one member must be engaged |
|
|
603
|
+
| `AllOrNone` | Every member is engaged, or none is |
|
|
446
604
|
| `Requires` | One flag depends on another |
|
|
447
605
|
| `Implies` | Auto-set a bool flag from another |
|
|
606
|
+
| `Member` | One operand of a co-occurrence constraint |
|
|
448
607
|
| `Result` | Return type of `app.test()` |
|
|
449
608
|
| `Tool` | LLM tool descriptor |
|
|
450
609
|
| `CheckRunResult` | Check execution result with wall-clock timing |
|
|
@@ -457,8 +616,14 @@ paths have no TTY contract, so a consequential command is dispatched directly.
|
|
|
457
616
|
| Decorator | Description |
|
|
458
617
|
|-----------|-------------|
|
|
459
618
|
| `@app.command(name, help=..., effect=...)` | Register a command (`effect` is mandatory) |
|
|
460
|
-
| `@strictcli.flag(name, type=, help
|
|
461
|
-
| `@strictcli.arg(name, help
|
|
619
|
+
| `@strictcli.flag(name, type=, help=..., presence=/default=)` | Declare a flag (presence is mandatory) |
|
|
620
|
+
| `@strictcli.arg(name, help=..., presence=/default=)` | Declare a positional argument (presence is mandatory) |
|
|
621
|
+
| `@strictcli.choice_flag(name, help=..., choices=, elect_by=, presence=/default=)` | Declare a choice flag (`elect_by` is mandatory) |
|
|
622
|
+
| `@strictcli.choice(name, help=...)` | Declare one choice of a choice flag, and its scope |
|
|
623
|
+
| `strictcli.sub_flag(help=..., presence=/default=, ...)` | Declare one flag of a choice's scope, inside the choice class body (the field name is the flag name) |
|
|
624
|
+
| `strictcli.sub_choice_flag(help=..., choices=, elect_by=, ...)` | Declare a nested choice flag inside a choice's scope |
|
|
625
|
+
| `strictcli.member_value(help=...)` | Declare a member-spelled choice's own payload, delivered under the reserved name `value` |
|
|
626
|
+
| `strictcli.provided(record, name)` | Whether the invocation caused a scoped field's value, asked of the delivered record |
|
|
462
627
|
| `@app.error_check(name)` / `@app.warn_check(name)` | Register a check handler |
|
|
463
628
|
|
|
464
629
|
### App methods
|