Skip to content

Commit 3347996

Browse files
authored
Improved error messages when not accepted options are given (#809)
1 parent d89a32b commit 3347996

12 files changed

Lines changed: 93 additions & 42 deletions

CHANGELOG.rst

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,17 @@ The semantic versioning only considers the public API as described in
1212
paths are considered internals and can change in minor and patch releases.
1313

1414

15+
v4.44.0 (unreleased)
16+
--------------------
17+
18+
Changed
19+
^^^^^^^
20+
- Improved error messages when not accepted options are given, referencing which
21+
parser/subcommand the option was given to. Also suggests running ``--help``
22+
for the corresponding parser/subcommand (`#809
23+
<https://github.com/omni-us/jsonargparse/pull/809>`__).
24+
25+
1526
v4.43.0 (2025-11-11)
1627
--------------------
1728

jsonargparse/_core.py

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1084,7 +1084,15 @@ def error(self, message: str, ex: Optional[Exception] = None) -> NoReturn:
10841084
elif debug_mode_active():
10851085
self._logger.debug("Debug enabled, thus raising exception instead of exit.")
10861086
raise argument_error(message) from ex
1087-
self.print_usage(sys.stderr)
1087+
1088+
parser = getattr(ex, "subcommand_parser", None) or self
1089+
parser.print_usage(sys.stderr)
1090+
1091+
help_action = next((a for a in parser._actions if isinstance(a, argparse._HelpAction)), None)
1092+
if help_action:
1093+
prog = parser.prog.replace(" [options]", "")
1094+
sys.stderr.write(f"tip: For details of accepted options run: {prog} {help_action.option_strings[-1]}\n")
1095+
10881096
sys.stderr.write(f"error: {message}\n")
10891097
self.exit(2)
10901098

@@ -1123,7 +1131,7 @@ def check_required(cfg, parser, prefix):
11231131
raise TypeError
11241132
except (KeyError, TypeError) as ex:
11251133
raise TypeError(
1126-
f'Key "{prefix}{reqkey}" is required but not included in config object or its value is None.'
1134+
f"Option '{prefix}{reqkey}' is required but not provided or its value is None."
11271135
) from ex
11281136
subcommand, subparser = _ActionSubCommands.get_subcommand(parser, cfg, fail_no_subcommand=False)
11291137
if subcommand is not None and subparser is not None:
@@ -1156,24 +1164,25 @@ def check_values(cfg):
11561164
else:
11571165
if isinstance(parent_action, _ActionSubCommands) and "." in key:
11581166
subcommand, subkey = split_key_root(key)
1159-
raise NSKeyError(f"Subcommand '{subcommand}' does not accept nested key '{subkey}'")
1167+
ex = NSKeyError(f"Subcommand '{subcommand}' does not accept option '{subkey}'")
1168+
ex.subcommand_parser = parent_action._name_parser_map[subcommand]
1169+
raise ex
11601170
group_key = next((g for g in self.groups if key.startswith(g + ".")), None)
11611171
if group_key:
11621172
subkey = key[len(group_key) + 1 :]
1163-
raise NSKeyError(f"Group '{group_key}' does not accept nested key '{subkey}'")
1164-
raise NSKeyError(f"Key '{key}' is not expected")
1173+
raise NSKeyError(f"Group '{group_key}' does not accept option '{subkey}'")
1174+
if self._subcommands_action:
1175+
if cfg.get(self._subcommands_action.dest):
1176+
subcommand = f"'{cfg[self._subcommands_action.dest]}'"
1177+
else:
1178+
subcommand = f"{{{list(self._subcommands_action.choices)[0]},...}}"
1179+
raise NSKeyError(f"Option '{key}' is not accepted before subcommand {subcommand}")
1180+
raise NSKeyError(f"Option '{key}' is not accepted")
11651181

1166-
try:
1167-
with parser_context(load_value_mode=self.parser_mode):
1168-
check_values(cfg)
1169-
if not skip_required and not lenient_check.get():
1170-
check_required(cfg, self, prefix)
1171-
except (TypeError, KeyError) as ex:
1172-
prefix = "Validation failed: "
1173-
message = ex.args[0]
1174-
if prefix not in message:
1175-
message = prefix + message
1176-
raise type(ex)(message) from ex
1182+
with parser_context(load_value_mode=self.parser_mode):
1183+
check_values(cfg)
1184+
if not skip_required and not lenient_check.get():
1185+
check_required(cfg, self, prefix)
11771186

11781187
def add_instantiator(
11791188
self,

jsonargparse/_formatters.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -201,8 +201,9 @@ def _get_help_string(self, action: Action) -> str:
201201
help_str += action.extra_help()
202202
return action_help + (" (" + help_str + ")" if help_str else "")
203203

204-
def _format_usage(self, *args, **kwargs) -> str:
205-
usage = super()._format_usage(*args, **kwargs)
204+
def _format_usage(self, usage, actions, *args, **kwargs) -> str:
205+
actions = filter_non_parsing_actions(actions)
206+
usage = super()._format_usage(usage, actions, *args, **kwargs)
206207

207208
parser = parent_parser.get()
208209
if not parser:

jsonargparse_tests/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,7 @@ def get_parse_args_stdout(parser: ArgumentParser, args: List[str]) -> str:
223223
def get_parse_args_stderr(parser: ArgumentParser, args: List[str]) -> str:
224224
err = StringIO()
225225
with patch.object(parser, "exit_on_error", return_value=True):
226-
with redirect_stderr(err), pytest.raises(SystemExit):
226+
with patch.dict(os.environ, {"COLUMNS": columns}), redirect_stderr(err), pytest.raises(SystemExit):
227227
parser.parse_args(args)
228228
return err.getvalue()
229229

jsonargparse_tests/test_actions.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -303,7 +303,7 @@ def test_action_parser_required_argument(parser, subparser):
303303
assert "1" == parser.parse_args(["--op2.op1=1"]).op2.op1
304304
with pytest.raises(ArgumentError) as ctx:
305305
parser.parse_args([])
306-
ctx.match('"op2.op1" is required')
306+
ctx.match("'op2.op1' is required")
307307

308308

309309
def test_action_parser_init_failures(parser, subparser):

jsonargparse_tests/test_cli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ def test_single_class_missing_required_init():
188188
err = StringIO()
189189
with redirect_stderr(err), pytest.raises(SystemExit):
190190
auto_cli(Class1, args=['--config={"method1": {"m1": 2}}'])
191-
assert '"i1" is required' in err.getvalue()
191+
assert "'i1' is required" in err.getvalue()
192192

193193

194194
def test_single_class_invalid_method_parameter():

jsonargparse_tests/test_core.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ def test_parse_args_positional_nargs_questionmark(parser):
125125
parser.add_argument("pos2", nargs="?")
126126
with pytest.raises(ArgumentError) as ctx:
127127
parser.parse_args([])
128-
ctx.match('"pos1" is required')
128+
ctx.match("'pos1' is required")
129129
assert parser.parse_args(["v1"]) == Namespace(pos1="v1", pos2=None)
130130
assert parser.parse_args(["v1", "v2"]) == Namespace(pos1="v1", pos2="v2")
131131

@@ -135,7 +135,7 @@ def test_parse_args_positional_nargs_plus(parser):
135135
parser.add_argument("pos2", nargs="+")
136136
with pytest.raises(ArgumentError) as ctx:
137137
parser.parse_args(["v1"])
138-
ctx.match('"pos2" is required')
138+
ctx.match("'pos2' is required")
139139
assert parser.parse_args(["v1", "v2", "v3"]) == Namespace(pos1="v1", pos2=["v2", "v3"])
140140

141141

@@ -448,7 +448,7 @@ def test_non_positional_required(parser, subtests):
448448

449449
with subtests.test("help"):
450450
help_str = get_parser_help(parser)
451-
assert "[-h] --req1 REQ1 --lev1.req2 REQ2" in help_str
451+
assert "--req1 REQ1 --lev1.req2 REQ2" in help_str
452452
assert "--lev1.req2 REQ2 (required)" in help_str
453453

454454
with subtests.test("parse_env"):

jsonargparse_tests/test_dataclasses.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -244,7 +244,7 @@ def test_add_argument_dataclass_unexpected_keys(parser):
244244
invalid = {
245245
"class_path": f"{__name__}.DataClassB",
246246
}
247-
with pytest.raises(ArgumentError, match="Group 'b' does not accept nested key 'class_path'"):
247+
with pytest.raises(ArgumentError, match="Group 'b' does not accept option 'class_path'"):
248248
parser.parse_args([f"--b={json.dumps(invalid)}"])
249249

250250

@@ -257,7 +257,7 @@ class DataRequiredAttr:
257257
def test_add_argument_dataclass_type_required_attr(parser):
258258
parser.add_argument("--b", type=DataRequiredAttr)
259259
assert Namespace(a1="v", a2=1.2) == parser.parse_args(["--b.a1=v"]).b
260-
with pytest.raises(ArgumentError, match='Key "b.a1" is required'):
260+
with pytest.raises(ArgumentError, match="Option 'b.a1' is required"):
261261
parser.parse_args([])
262262

263263

@@ -744,7 +744,7 @@ def test_dataclass_not_subclass(parser):
744744
assert "--data.help" not in help_str
745745

746746
config = {"class_path": f"{__name__}.DataSub", "init_args": {"p2": "y"}}
747-
with pytest.raises(ArgumentError, match="Group 'data' does not accept nested key 'init_args.p2'"):
747+
with pytest.raises(ArgumentError, match="Group 'data' does not accept option 'init_args.p2'"):
748748
parser.parse_args([f"--data={json.dumps(config)}"])
749749

750750

@@ -854,7 +854,7 @@ def test_dataclass_nested_not_subclass(parser):
854854
}
855855
},
856856
}
857-
with pytest.raises(ArgumentError, match="Group 'data' does not accept nested key 'init_args.p1'"):
857+
with pytest.raises(ArgumentError, match="Group 'data' does not accept option 'init_args.p1'"):
858858
parser.parse_args([f"--parent={json.dumps(config)}"])
859859

860860

jsonargparse_tests/test_formatters.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,4 +170,4 @@ def test_help_subcommands_with_default_env(parser):
170170
def test_format_usage(parser):
171171
parser.add_argument("--v1")
172172
with patch.dict(os.environ, {"COLUMNS": "200"}):
173-
assert parser.format_usage() == "usage: app [-h] [--v1 V1]\n"
173+
assert parser.format_usage() == "usage: app [--v1 V1]\n"

jsonargparse_tests/test_signatures.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ def test_add_class_without_nesting(parser):
124124

125125
with pytest.raises(ArgumentError) as ctx:
126126
parser.parse_args([])
127-
ctx.match('"c3_a0" is required')
127+
ctx.match("Option 'c3_a0' is required")
128128

129129
if docstring_parser_support:
130130
assert "Class3 short description" == parser.groups["Class3"].title
@@ -196,7 +196,7 @@ def test_add_class_without_parameters(parser):
196196
config = {"no_params": {"class_path": f"{__name__}.NoParams"}}
197197
with pytest.raises(ArgumentError) as ctx:
198198
parser.parse_args([f"--cfg={json.dumps(config)}"])
199-
ctx.match("Group 'no_params' does not accept nested key 'class_path'")
199+
ctx.match("Group 'no_params' does not accept option 'class_path'")
200200

201201

202202
class NestedWithParams:
@@ -710,5 +710,5 @@ def test_add_function_positional_and_keyword_only_parameters(parser):
710710
assert cfg == Namespace(a=1, b=2, c=3, d=4)
711711
with pytest.raises(ArgumentError, match="Unrecognized arguments: --b=2"):
712712
parser.parse_args(["1", "--b=2", "--c=3", "--d=4"])
713-
with pytest.raises(ArgumentError, match='Key "c" is required'):
713+
with pytest.raises(ArgumentError, match="Option 'c' is required"):
714714
parser.parse_args(["1", "2", "--d=4"])

0 commit comments

Comments
 (0)