Skip to content

Commit 5d7ecb4

Browse files
authored
Fix multiple subcommand settings in default_config_files and fail for subcommand chosen (#819)
1 parent 64630c1 commit 5d7ecb4

File tree

6 files changed

+69
-10
lines changed

6 files changed

+69
-10
lines changed

CHANGELOG.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,16 @@ Fixed
3030
inheritance (`#815 <https://github.com/omni-us/jsonargparse/pull/815>`__).
3131
- ``default_env=True`` conflicting with ``default_config_files`` (`#818
3232
<https://github.com/omni-us/jsonargparse/pull/818>`__).
33+
- ``default_config_files`` with settings for multiple subcommands not working
34+
correctly (`#819 <https://github.com/omni-us/jsonargparse/pull/819>`__).
3335

3436
Changed
3537
^^^^^^^
3638
- List of paths types now show in the help the supported options for providing
3739
paths like ``'["PATH1",...]' | LIST_OF_PATHS_FILE | -`` (`#816
3840
<https://github.com/omni-us/jsonargparse/pull/816>`__).
41+
- Providing a choice of subcommand in ``default_config_files`` is now an error
42+
(`#819 <https://github.com/omni-us/jsonargparse/pull/819>`__).
3943

4044

4145
v4.44.0 (2025-11-25)

jsonargparse/_actions.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from contextvars import ContextVar
1010
from typing import Any, Optional, Union
1111

12-
from ._common import Action, NonParsingAction, is_not_subclass_type, is_subclass, parser_context
12+
from ._common import Action, NonParsingAction, is_not_subclass_type, is_subclass, parser_context, parsing_defaults
1313
from ._loaders_dumpers import get_loader_exceptions, load_value
1414
from ._namespace import Namespace, NSKeyError, split_key, split_key_root
1515
from ._optionals import _get_config_read_mode, ruamel_support
@@ -721,7 +721,7 @@ def get_subcommands(
721721
return None, None
722722
action = parser._subcommands_action
723723

724-
require_single = single_subcommand.get()
724+
require_single = single_subcommand.get() and not parsing_defaults.get()
725725

726726
# Get subcommand settings keys
727727
subcommand_keys = [k for k in action.choices if isinstance(cfg.get(prefix + k), Namespace)]
@@ -731,12 +731,14 @@ def get_subcommands(
731731
dest = prefix + action.dest
732732
if dest in cfg and cfg.get(dest) is not None:
733733
subcommand = cfg[dest]
734+
if parsing_defaults.get():
735+
raise NSKeyError(f"A specific subcommand can't be provided in defaults, got '{subcommand}'")
734736
elif len(subcommand_keys) > 0 and (fail_no_subcommand or require_single):
735737
cfg[dest] = subcommand = subcommand_keys[0]
736738
if len(subcommand_keys) > 1:
737739
warnings.warn(
738-
f'Multiple subcommand settings provided ({", ".join(subcommand_keys)}) without an '
739-
f'explicit "{dest}" key. Subcommand "{subcommand}" will be used.'
740+
f"Multiple subcommand settings provided ({', '.join(subcommand_keys)}) without an "
741+
f"explicit '{dest}' key. Subcommand '{subcommand}' will be used."
740742
)
741743

742744
# Remove extra subcommand settings

jsonargparse/_common.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ def __call__(self, class_type: type[ClassType], *args, **kwargs) -> ClassType:
5858
parser_capture: ContextVar[bool] = ContextVar("parser_capture", default=False)
5959
defaults_cache: ContextVar[Optional[Namespace]] = ContextVar("defaults_cache", default=None)
6060
lenient_check: ContextVar[Union[bool, str]] = ContextVar("lenient_check", default=False)
61+
parsing_defaults: ContextVar[bool] = ContextVar("parsing_defaults", default=False)
6162
load_value_mode: ContextVar[Optional[str]] = ContextVar("load_value_mode", default=None)
6263
class_instantiators: ContextVar[Optional[InstantiatorsDictType]] = ContextVar("class_instantiators", default=None)
6364
nested_links: ContextVar[list[dict]] = ContextVar("nested_links", default=[])
@@ -70,6 +71,7 @@ def __call__(self, class_type: type[ClassType], *args, **kwargs) -> ClassType:
7071
"parser_capture": parser_capture,
7172
"defaults_cache": defaults_cache,
7273
"lenient_check": lenient_check,
74+
"parsing_defaults": parsing_defaults,
7375
"load_value_mode": load_value_mode,
7476
"class_instantiators": class_instantiators,
7577
"nested_links": nested_links,

jsonargparse/_core.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -457,7 +457,7 @@ def parse_args( # type: ignore[override]
457457
skip_validation=skip_validation,
458458
)
459459

460-
except (TypeError, KeyError) as ex:
460+
except (TypeError, KeyError, argparse.ArgumentError) as ex:
461461
self.error(str(ex), ex)
462462

463463
self._logger.debug("Parsed command line arguments: %s", args)
@@ -1011,7 +1011,7 @@ def get_defaults(self, skip_validation: bool = False, **kwargs) -> Namespace:
10111011
default_config_file_content = default_config_file.get_content()
10121012
if not default_config_file_content.strip():
10131013
continue
1014-
with change_to_path_dir(default_config_file), parser_context(parent_parser=self):
1014+
with change_to_path_dir(default_config_file), parser_context(parent_parser=self, parsing_defaults=True):
10151015
cfg_file = self._load_config_parser_mode(default_config_file_content, prev_cfg=cfg)
10161016
cfg = self.merge_config(cfg_file, cfg)
10171017
try:
@@ -1022,10 +1022,12 @@ def get_defaults(self, skip_validation: bool = False, **kwargs) -> Namespace:
10221022
defaults=False,
10231023
skip_validation=skip_validation,
10241024
skip_required=True,
1025+
fail_no_subcommand=False,
10251026
)
10261027
except (TypeError, KeyError, argparse.ArgumentError) as ex:
10271028
raise argument_error(
1028-
f'Problem in default config file "{default_config_file}": {ex.args[0]}'
1029+
f"Problem in default config file '{default_config_file}': {ex.args[0]}",
1030+
default_config_file=str(default_config_file),
10291031
) from ex
10301032
meta = cfg.get("__default_config__")
10311033
if isinstance(meta, list):
@@ -1054,6 +1056,8 @@ def error(self, message: str, ex: Optional[Exception] = None) -> NoReturn:
10541056
raise argument_error(message) from ex
10551057

10561058
parser = getattr(ex, "subcommand_parser", None) or self
1059+
if getattr(ex, "default_config_file", None):
1060+
parser.default_config_files = []
10571061
parser.print_usage(sys.stderr)
10581062

10591063
help_action = next((a for a in parser._actions if isinstance(a, argparse._HelpAction)), None)

jsonargparse/_util.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,11 @@
4343
default_config_option_help = "Path to a configuration file."
4444

4545

46-
def argument_error(message: str) -> ArgumentError:
47-
return ArgumentError(None, message)
46+
def argument_error(message: str, default_config_file: Optional[str] = None) -> ArgumentError:
47+
ex = ArgumentError(None, message)
48+
if default_config_file:
49+
ex.default_config_file = default_config_file # type: ignore[attr-defined]
50+
return ex
4851

4952

5053
class JsonargparseWarning(UserWarning):

jsonargparse_tests/test_subcommands.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ def test_subcommands_parse_string_first_implicit_subcommand(subcommands_parser):
160160
with warnings.catch_warnings(record=True) as w:
161161
cfg = subcommands_parser.parse_string('{"a": {"ap1": "ap1_cfg"}, "b": {"nums": {"val1": 2}}}')
162162
assert len(w) == 1
163-
assert 'Subcommand "a" will be used' in str(w[0].message)
163+
assert "Subcommand 'a' will be used" in str(w[0].message)
164164
assert cfg.subcommand == "a"
165165
assert "b" not in cfg
166166

@@ -436,3 +436,47 @@ def test_subsubcommand_default_config_files(parser, subparser, subsubparser, def
436436
assert cfg.clone(with_meta=False) == Namespace(
437437
val0=123, subcommand="cmd1", cmd1=Namespace(val1=456, subcommand="cmd2", cmd2=Namespace(val2=789))
438438
)
439+
440+
441+
def test_subcommands_in_default_config_files(parser, subtests, tmp_cwd):
442+
parser.default_config_files = ["defaults.json"]
443+
subs = parser.add_subcommands(required=True, dest="sub")
444+
sub1 = ArgumentParser()
445+
sub1.add_argument("--sub1val")
446+
subs.add_subcommand("sub1", sub1)
447+
sub2 = ArgumentParser()
448+
sub2.add_argument("--sub2val")
449+
subs.add_subcommand("sub2", sub2)
450+
451+
defaults: dict = {
452+
"sub1": {"sub1val": 2},
453+
"sub2": {"sub2val": 3},
454+
}
455+
Path("defaults.json").write_text(json.dumps(defaults))
456+
457+
with subtests.test("choose subcommand defaults"):
458+
cfg = parser.parse_args(["sub1"])
459+
assert cfg.sub == "sub1"
460+
assert cfg.sub1 == Namespace(sub1val=2)
461+
assert "sub2" not in cfg
462+
cfg = parser.parse_args(["sub2"])
463+
assert cfg.sub == "sub2"
464+
assert cfg.sub2 == Namespace(sub2val=3)
465+
assert "sub1" not in cfg
466+
467+
with subtests.test("implicit subcommand defaults"):
468+
with warnings.catch_warnings(record=True) as w:
469+
cfg = parser.parse_args([])
470+
assert "Subcommand 'sub1' will be used" in str(w[0].message)
471+
assert cfg.sub == "sub1"
472+
assert cfg.sub1 == Namespace(sub1val=2)
473+
assert "sub2" not in cfg
474+
475+
with subtests.test("no subcommand in defaults"):
476+
defaults["sub"] = "sub2"
477+
Path("defaults.json").write_text(json.dumps(defaults))
478+
err = get_parse_args_stderr(parser, [])
479+
assert (
480+
"Problem in default config file 'defaults.json': A specific "
481+
"subcommand can't be provided in defaults, got 'sub2'"
482+
) in err

0 commit comments

Comments
 (0)