Skip to content

Commit 7fa1e7f

Browse files
committed
feat: subcommands in the config file
1 parent 1f2f340 commit 7fa1e7f

15 files changed

+1869
-147
lines changed

docs/Changelog.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## unreleased
44
* feat: [`run`][mininterface.run] add_config flag
5+
* feat: [subcommands](Supported-types.md/#dataclasses-union-subcommand) allowed in the config file
56

67
## 1.1.4 (2025-10-13)
78
* enh: Python 3.14 compatible

docs/Config-file.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@ By default, we try to find one in the current working dir, whose name stem is th
88

99
## Search order by highest priority
1010

11-
* `$ program.py --config PATH` with `run(add_config=True)` will load `PATH`
12-
* `$ MININTERFACE_CONFIG=PATH program.py` will load `PATH`
13-
* `$ program.py` with `run(config_file=PATH)` will load `PATH`
14-
* `$ program.py` with `run(config_file=True)` will load `program.yaml`
11+
* will load `conf.yaml`: `$ program.py --config conf.yaml` with `run(add_config=True)`
12+
* will load `conf.yaml`: `$ MININTERFACE_CONFIG=conf.yaml program.py`
13+
* will load `conf.yaml`: `$ program.py` with `run(config_file=conf.yaml)`
14+
* will load `program.yaml`: `$ program.py` with `run(config_file=True)`
1515

1616
## Basic example
1717

docs/Supported-types.md

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,7 @@ run(Env)
243243

244244
![SelectTag multiple](asset/selecttag-multiple.avif)
245245

246-
### Nested dataclasses or their unions (subcommands)
246+
### Dataclass (→ subgroup)
247247

248248
You can nest the classes to create a subgroup:
249249

@@ -261,6 +261,15 @@ run(Env)
261261

262262
![Nested dataclass](asset/nested-dataclass.avif)
263263

264+
In the config file, it behaves like a dict:
265+
266+
```yaml
267+
val:
268+
text: text from a config file
269+
```
270+
271+
### Dataclasses union (→ subcommand)
272+
264273
You can union the classes to create subcommands:
265274
266275
```python
@@ -323,14 +332,21 @@ First, we've chosen `Console`, then `Console rich`.
323332

324333
??? "Shorter CLI notation"
325334

326-
Instead of plain `Env`, we can annotate it
335+
This is how in looks in CLI:
336+
337+
```bash
338+
$ ./program.py --help
339+
usage: program.py [-h] [-v] {val:message,val:console}
340+
```
341+
342+
Is that too long for you? Instead of plain `Env`, use the settings:
327343

328344
```python
329345
from mininterface.settings import CliSettings
330346
m = run(Env, settings=CliSettings(omit_subcommand_prefixes=True))
331347
```
332348

333-
In the background, it does the same thing as applying an annotation from the underlying CLI library `tyro`.
349+
In the background, it does the same thing as applying an annotation marker from the underlying CLI library `tyro`.
334350

335351
```python
336352
from tyro.conf import OmitSubcommandPrefixes
@@ -344,11 +360,16 @@ First, we've chosen `Console`, then `Console rich`.
344360
usage: program.py [-h] [-v] {message,console}
345361
```
346362

347-
Without:
348-
```bash
349-
$ ./program.py --help
350-
usage: program.py [-h] [-v] {val:message,val:console}
351-
```
363+
In the config file, put a dict where subcommands are in the kebab case. Here, we define some config defaults for `ConsoleRich`, leaving `Message` and `ConsolePlain` without config defaults.
364+
365+
```yaml
366+
val:
367+
console:
368+
bot-id: id-two
369+
style:
370+
console-rich:
371+
color: green
372+
```
352373
353374
### Well-known objects
354375

mininterface/_lib/cli_parser.py

Lines changed: 129 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
#
44
from dataclasses import asdict
55
from functools import reduce
6+
from io import StringIO
7+
from multiprocessing import Value
68
import sys
79
from collections import deque
8-
from contextlib import ExitStack
10+
from contextlib import ExitStack, redirect_stderr, redirect_stdout
911
from typing import Annotated, Optional, Sequence, Type, Union
1012
from unittest.mock import patch
1113

@@ -21,10 +23,11 @@
2123
flatten,
2224
)
2325
from .dataclass_creation import (
24-
ChosenSubcommand,
2526
_unwrap_annotated,
2627
choose_subcommand,
2728
create_with_missing,
29+
get_chosen,
30+
pop_from_passage,
2831
to_kebab_case,
2932
)
3033
from .form_dict import EnvClass, TagDict, dataclass_to_tagdict, MissingTagValue, dict_added_main
@@ -68,14 +71,17 @@ def assure_args(args: Optional[Sequence[str]] = None):
6871
args = []
6972
return args
7073

74+
def _subcommands_default_appliable(kwargs, _crawling):
75+
if len(_crawling.get()):
76+
return kwargs.get("subcommands_default")
7177

7278
def parse_cli(
7379
env_or_list: Type[EnvClass] | list[Type[EnvClass]],
7480
kwargs: dict,
7581
m: "Mininterface",
7682
cf: Optional[CliFlags] = None,
7783
ask_for_missing: bool = True,
78-
args: Optional[Sequence[str]] = None,
84+
args: Optional[Sequence[str]] = None, # NOTE no more Optional, change the arg order
7985
ask_on_empty_cli: Optional[bool] = None,
8086
cli_settings: Optional[CliSettings] = None,
8187
_crawled=None,
@@ -89,6 +95,7 @@ def parse_cli(
8995
"""
9096
# Xint: The depth we crawled into. The number of subcommands in args.
9197
# NOTE ask_on_empty_cli might reveal all fields (in cli_parser), not just wrongs. Eg. when using a subparser `$ prog run`, reveal all subparsers.
98+
_req_fields = _req_fields or {}
9299

93100
if isinstance(env_or_list, list):
94101
# We have to convert the list of possible classes (subcommands) to union for tyro.
@@ -149,17 +156,72 @@ def annot(type_form):
149156
warn(f"Cannot apply {annotations} on Python <= 3.11.")
150157
return type_form
151158

159+
160+
#
161+
# --- Begin to launch tyro.cli ---
162+
# This will be divided into four sections.
163+
# (A) First parse section
164+
# (B) Re-parse with subcommand-config ensured section
165+
# (C) The dialog missing section
166+
# (D) The nothing was missing section
167+
#
168+
enforce_dialog = False
169+
""" When subcommand-chooser was raised (hence the CLI input was not completely working and without mininterface it would raise an error),
170+
we make sure we display whole CLI overview form at the end."""
171+
152172
try:
153173
with ExitStack() as stack:
154174
[stack.enter_context(p) for p in patches] # apply just the chosen mocks
175+
176+
# --- (A) First parse section ---
177+
178+
# Let me explain this awful structure.
179+
# If we have subcommanded-config file, we first need the tyro to do the parsing as it leaks the crawled path (through the subcommands).
180+
# Then, we can fill the kwargs['default'] from the subcommanded-config and do the second parsing with some field filled up.
181+
buffer = StringIO()
182+
helponly = False
155183
try:
156-
env = cli(annot(type_form), args=args, registry=_custom_registry, **kwargs)
157-
except BaseException:
158-
# Why this exception handling? Try putting this out and test_strange_error_mitigation fails.
159-
if len(env_classes) > 1 and kwargs.get("default"):
160-
env = cli(annot(kwargs["default"].__class__), args=args[1:], registry=_custom_registry, **kwargs)
184+
# Why redirect_stdout? Help-text shows the defaults, which also uses the subcommanded-config.
185+
with redirect_stdout(buffer):
186+
try:
187+
# Standard way.
188+
env = cli(annot(type_form), args=args, registry=_custom_registry, **kwargs)
189+
except BaseException:
190+
# Why this exception handling? Try putting this out and test_strange_error_mitigation fails.
191+
if len(env_classes) > 1 and kwargs.get("default"):
192+
env = cli(annot(kwargs["default"].__class__), args=args[1:], registry=_custom_registry, **kwargs)
193+
else:
194+
raise
195+
except SystemExit as exception:
196+
# This catch handling is just for the subcommanded-config.
197+
# Not raising this exception means it worked well and we re-parse with the subcommand-config data just below.
198+
if _crawled is None and exception.code == 0 and _subcommands_default_appliable(kwargs, _crawling):
199+
# Help-text exception, continue here and try again with subcommands. As it raises SystemExit first,
200+
# it will raise SystemExit in the second run too.
201+
helponly = True
202+
elif _crawled is None and _subcommands_default_appliable(kwargs, _crawling) and exception.code == 2 and failed_fields.get():
203+
# Some fields are missing, directly try again. If it raises again
204+
# (some fields are really missing which cannot be filled from the subcommanded-config),
205+
# it will immediately raise again and trigger the (C) dialog missing section.
206+
# If it worked (and no fields are missing), we continue here without triggering the (C) dialog missing section.
207+
_crawled = True
208+
env, enforce_dialog = _try_with_subcommands(kwargs, m, args, type_form, env_classes, _custom_registry, annot, _req_fields)
161209
else:
210+
# This is either a recurrent call from the (C) dialog missing section (and thus subcommand-config re-parsing was done),
211+
# or there is no subcommand-config data and thus we continue as if this exception handling did not happen.
212+
if content := buffer.getvalue():
213+
print(content)
162214
raise
215+
216+
# --- (B) Re-parse with subcommand-config ensured section ---
217+
218+
# Re-parse with subcommand-config.
219+
# It either raises (if it raised before and subcommand-config did not bring the missing fields) or works well if it worked well before.
220+
if _crawled is None and _subcommands_default_appliable(kwargs, _crawling):
221+
# Why not catching enforce_dialog here? As we are here, calling tyro.cli worked for the first time.
222+
# For sure then, there were no choose_subcommand dialog, subcommands for sure are all written in the CLI.
223+
env, _ = _try_with_subcommands(kwargs, None if helponly else m, args, type_form, env_classes, _custom_registry, annot, _req_fields)
224+
163225
# Why setting m.env instead of putting into into a constructor of a new get_interface() call?
164226
# 1. Getting the interface is a costly operation
165227
# 2. There is this bug so that we need to use single interface:
@@ -170,17 +232,18 @@ def annot(type_form):
170232
# m = get_interface("gui")
171233
# m.select([1,2,3])
172234
m.env = env
173-
except BaseException as exception:
174-
if ask_for_missing and getattr(exception, "code", None) == 2 and failed_fields.get():
235+
except SystemExit as exception:
236+
# --- (C) The dialog missing section ---
237+
# Some fields are needed to be filled up.
238+
if ask_for_missing and exception.code == 2 and failed_fields.get():
175239
env = _dialog_missing(
176240
env_classes, kwargs, m, cf, ask_for_missing, args, cli_settings, _crawled, _req_fields
177241
)
178242

179243
if final_call:
180244
# Ask for the wrong fields
181-
# Why first_attempt? We display the wrong-fields-form only once.
245+
# Why final_call? We display the wrong-fields-form only once in the `parse_cli` uppermost call.
182246
_ensure_command_init(env, m)
183-
184247
try:
185248
m.form(env)
186249
except Cancelled as e:
@@ -201,6 +264,7 @@ def annot(type_form):
201264
# Parsing wrong fields failed. The program ends with a nice tyro message.
202265
raise
203266
else:
267+
# --- (D) The nothing was missing section ---
204268
dialog_raised = False
205269
if final_call:
206270
_ensure_command_init(env, m)
@@ -219,14 +283,44 @@ def annot(type_form):
219283

220284
# Empty CLI → GUI edit
221285
subcommand_count = len(_crawling.get())
222-
if not dialog_raised and ask_on_empty_cli and len(sys.argv) <= 1 + subcommand_count:
286+
if not dialog_raised and (ask_on_empty_cli and len(args) <= subcommand_count) or enforce_dialog:
223287
# Raise a dialog if the command line is empty.
224288
# This still means empty because 'run' and 'message' are just subcommands: `program.py run message`
225289
m.form()
226290
dialog_raised = True
227291

228292
return env, dialog_raised
229293

294+
def _try_with_subcommands(kwargs, m, args, type_form, env_classes, _custom_registry, annot, _req_fields):
295+
""" This awful method is here to re-parse the tyro.cli with the subcommand-config """
296+
297+
failed_fields.set([])
298+
old_defs = kwargs.get("default", {})
299+
if old_defs:
300+
old_defs = asdict(old_defs)
301+
passage = [cl_name for _, cl_name, _ in _crawling.get()]
302+
303+
if len(env_classes) > 1:
304+
if len(passage):
305+
env, cl_name = pop_from_passage(passage, env_classes)
306+
if not old_defs:
307+
old_defs = kwargs["subcommands_default_union"][cl_name]
308+
subc = kwargs["subcommands_default"].get(cl_name)
309+
else: # we should never come here
310+
raise ValueError("Subcommands parsing failed")
311+
else:
312+
env = env_classes[0]
313+
subc = kwargs["subcommands_default"]
314+
kwargs["default"] = create_with_missing(env, old_defs, _req_fields, m, subc=subc, subc_passage=passage)
315+
dialog_used = False
316+
if hasattr(m, "__subcommand_dialog_used"):
317+
delattr(m, "__subcommand_dialog_used")
318+
dialog_used = True
319+
320+
env = cli(annot(type_form), args=args, registry=_custom_registry, **kwargs)
321+
322+
return env, dialog_used
323+
230324

231325
def _apply_patches(cf: Optional[CliFlags], ask_for_missing, env_classes, kwargs):
232326
patches = []
@@ -272,7 +366,7 @@ def _dialog_missing(
272366
args: Optional[Sequence[str]],
273367
cli_settings,
274368
crawled,
275-
req_fields: Optional[TagDict],
369+
req_fields: TagDict,
276370
) -> EnvClass:
277371
"""Some required arguments are missing. Determine which and ask for them.
278372
@@ -288,10 +382,8 @@ def _dialog_missing(
288382
* env – Tyro's merge of CLI and kwargs["default"].
289383
290384
"""
291-
req_fields = req_fields or {}
292-
293385
# There are multiple dataclasses, query which is chosen
294-
m, env_cl = _ensure_chosen_env(env_classes, args, m)
386+
env_cl = _ensure_chosen_env(env_classes, args, m, kwargs)
295387

296388
if crawled is None:
297389
# This is the first correction attempt.
@@ -302,16 +394,12 @@ def _dialog_missing(
302394
# So in further run, there is no need to rebuild the data. We just process new failed_fields reported by tyro.
303395

304396
# Merge with the config file defaults.
305-
disk = d = asdict(dc) if (dc := kwargs.get("default")) else {}
306-
crawled = [None]
307-
for _, val, field_name in _crawling.get():
308-
# NOTE this might be ameliorated so that config file can define subcommands too, now we throw everything out
309-
subd = {}
310-
d[field_name] = ChosenSubcommand(val, subd)
311-
d = subd
312-
crawled.append(val)
313-
314-
kwargs["default"] = create_with_missing(env_cl, disk, req_fields, m)
397+
if len(env_classes) > 1:
398+
disk = kwargs.get("subcommands_default_union", {})
399+
else:
400+
disk = asdict(dc) if (dc := kwargs.get("default")) else {}
401+
crawled = True
402+
kwargs["default"] = create_with_missing(env_cl, disk, req_fields, m, subc=kwargs.get("subcommands_default"), subc_passage=[cl_name for _, cl_name, _ in _crawling.get()])
315403

316404
missing_req = _fetch_currently_failed(req_fields)
317405
""" Fields required and missing from CLI """
@@ -341,10 +429,15 @@ def _dialog_missing(
341429
return env
342430

343431

344-
def _ensure_chosen_env(env_classes, args, m):
432+
def _ensure_chosen_env(env_classes, args, m, kwargs):
433+
# NOTE by preference, handling subclasses union should be done
434+
# by making an arbitrary dataclass, having single subcommands attribute.
435+
# That way, all the mendling with the env_classes list would disappear from many places in the code as
436+
# we already support subclasses in attribute – and this awful function would disappear.
345437
env = None
346438
if len(env_classes) == 1:
347439
env = env_classes[0]
440+
return env
348441
elif len(args):
349442
env = next(
350443
(env for env in env_classes if to_kebab_case(env.__name__) == args[0]),
@@ -356,7 +449,14 @@ def _ensure_chosen_env(env_classes, args, m):
356449
env = choose_subcommand(env_classes, m)
357450
if not env:
358451
raise NotImplementedError("This case of nested dataclasses is not implemented. Raise an issue please.")
359-
return m, env
452+
453+
cl_name = to_kebab_case(env.__name__)
454+
if kwargs.get("subcommands_default"):
455+
kwargs["subcommands_default"] = kwargs["subcommands_default"].get(cl_name)
456+
if kwargs.get("subcommands_default_union"):
457+
kwargs["subcommands_default_union"] = kwargs["subcommands_default_union"].get(cl_name)
458+
459+
return env
360460

361461

362462
def _fetch_currently_failed(requireds) -> TagDict:

0 commit comments

Comments
 (0)