33#
44from dataclasses import asdict
55from functools import reduce
6+ from io import StringIO
7+ from multiprocessing import Value
68import sys
79from collections import deque
8- from contextlib import ExitStack
10+ from contextlib import ExitStack , redirect_stderr , redirect_stdout
911from typing import Annotated , Optional , Sequence , Type , Union
1012from unittest .mock import patch
1113
2123 flatten ,
2224)
2325from .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)
3033from .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
7278def 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
231325def _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
362462def _fetch_currently_failed (requireds ) -> TagDict :
0 commit comments