Skip to content

Commit 5fed4ed

Browse files
authored
Added new ActionFail for arguments that should fail parsing with a given error message (#759)
1 parent d291ce0 commit 5fed4ed

File tree

6 files changed

+103
-4
lines changed

6 files changed

+103
-4
lines changed

CHANGELOG.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ Added
2121
<https://github.com/omni-us/jsonargparse/pull/753>`__).
2222
- Support callable protocols for instance factory dependency injection (`#758
2323
<https://github.com/omni-us/jsonargparse/pull/758>`__).
24+
- New ``ActionFail`` for arguments that should fail parsing with a given error
25+
message (`#759 <https://github.com/omni-us/jsonargparse/pull/759>`__).
2426

2527
Fixed
2628
^^^^^

DOCUMENTATION.rst

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,37 @@ a function, it is recommended to implement a type, see :ref:`custom-types`.
398398
parser.add_argument("--int_or_off", type=int_or_off)
399399

400400

401+
Always fail arguments
402+
---------------------
403+
404+
In scenarios where an argument should be included in the parser but should
405+
always fail parsing, there is the :class:`.ActionFail` action. For example a use
406+
case can be an optional feature that is only accessible if a specific package is
407+
installed:
408+
409+
.. testsetup:: always-fail
410+
411+
parser = ArgumentParser()
412+
some_package_installed = False
413+
414+
.. testcode:: always-fail
415+
416+
from jsonargparse import ActionFail
417+
418+
if some_package_installed:
419+
parser.add_argument("--module", type=SomeClass)
420+
else:
421+
parser.add_argument(
422+
"--module",
423+
action=ActionFail(message="install 'package' to enable %(option)s"),
424+
help="Option unavailable due to missing 'package'",
425+
)
426+
427+
With this setup, if an argument is provided as ``--module=...`` or in a nested
428+
form like ``--module.child=...``, the parsing will fail and display the
429+
configured error message.
430+
431+
401432
.. _type-hints:
402433

403434
Type hints

jsonargparse/_actions.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,9 @@
2929

3030
__all__ = [
3131
"ActionConfigFile",
32-
"ActionYesNo",
32+
"ActionFail",
3333
"ActionParser",
34+
"ActionYesNo",
3435
]
3536

3637

@@ -65,7 +66,7 @@ def _find_action_and_subcommand(
6566
fallback_action = None
6667
for action in actions:
6768
if action.dest == dest or f"--{dest}" in action.option_strings:
68-
if isinstance(action, _ActionConfigLoad):
69+
if isinstance(action, (_ActionConfigLoad, ActionFail)):
6970
fallback_action = action
7071
else:
7172
return action, None
@@ -435,6 +436,34 @@ def get_args_after_opt(self, args):
435436
return args[num + 1 :]
436437

437438

439+
class ActionFail(Action):
440+
"""Action that always fails parsing with a given error."""
441+
442+
def __init__(self, message: str = "option unavailable", **kwargs):
443+
"""Initializer for ActionFail instance.
444+
445+
Args:
446+
message: Text for the error to show. Use `%(option)s`/`%(value)s` to include the option and/or value.
447+
"""
448+
if len(kwargs) == 0:
449+
self._message = message
450+
else:
451+
self._message = kwargs.pop("_message")
452+
kwargs["default"] = SUPPRESS
453+
kwargs["required"] = False
454+
if kwargs["option_strings"] == []:
455+
kwargs["nargs"] = "?"
456+
super().__init__(**kwargs)
457+
458+
def __call__(self, *args, **kwargs):
459+
"""Always fails with given message."""
460+
if len(args) == 0:
461+
kwargs["_message"] = self._message
462+
return ActionFail(**kwargs)
463+
parser, _, value, option = args
464+
parser.error(self._message % {"value": value, "option": option})
465+
466+
438467
class ActionYesNo(Action):
439468
"""Paired options --[yes_prefix]opt, --[no_prefix]opt to set True or False respectively."""
440469

jsonargparse/_core.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ def add_argument(self, *args, enable_path: bool = False, **kwargs):
151151
ActionJsonnet._check_ext_vars_action(parser, action)
152152
if is_meta_key(action.dest):
153153
raise ValueError(f'Argument with destination name "{action.dest}" not allowed.')
154-
if action.option_strings == [] and "default" in kwargs:
154+
if action.option_strings == [] and "default" in kwargs and kwargs["default"] is not argparse.SUPPRESS:
155155
raise ValueError("Positional arguments not allowed to have a default value.")
156156
validate_default(self, action)
157157
if action.help is None:

jsonargparse/_typehints.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
from ._actions import (
4040
Action,
4141
ActionConfigFile,
42+
ActionFail,
4243
_ActionHelpClassPath,
4344
_ActionPrintConfig,
4445
_find_action,
@@ -405,7 +406,7 @@ def parse_argv_item(arg_string):
405406
action = _find_parent_action(parser, arg_base[2:])
406407

407408
typehint = typehint_from_action(action)
408-
if typehint:
409+
if typehint or isinstance(action, ActionFail):
409410
if parse_optional_num_return == 4:
410411
return action, arg_base, sep, explicit_arg
411412
elif parse_optional_num_return == 1:

jsonargparse_tests/test_actions.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
import pytest
66

77
from jsonargparse import (
8+
SUPPRESS,
9+
ActionFail,
810
ActionParser,
911
ActionYesNo,
1012
ArgumentError,
@@ -60,6 +62,40 @@ def test_action_config_file_argument_errors(parser, tmp_cwd):
6062
pytest.raises(ArgumentError, lambda: parser.parse_args(["--cfg", '{"k":"v"}']))
6163

6264

65+
# ActionFail tests
66+
67+
68+
def test_action_fail_optional(parser):
69+
parser.add_argument(
70+
"--unavailable",
71+
action=ActionFail(message="needs package xyz"),
72+
help="Option not available due to missing package xyz",
73+
)
74+
help_str = get_parser_help(parser)
75+
assert "--unavailable" in help_str
76+
assert "Option not available due to missing package xyz" in help_str
77+
defaults = parser.get_defaults()
78+
assert "unavailable" not in defaults
79+
with pytest.raises(ArgumentError, match="needs package xyz"):
80+
parser.parse_args(["--unavailable=x"])
81+
with pytest.raises(ArgumentError, match="needs package xyz"):
82+
parser.parse_args(["--unavailable.child=x"])
83+
84+
85+
def test_action_fail_positional(parser):
86+
parser.add_argument(
87+
"unexpected",
88+
action=ActionFail(message="unexpected positional: %(value)s"),
89+
help=SUPPRESS,
90+
)
91+
parser.add_argument("--something", default="else")
92+
help_str = get_parser_help(parser)
93+
assert "unexpected" not in help_str
94+
assert parser.parse_args([]) == Namespace(something="else")
95+
with pytest.raises(ArgumentError, match="unexpected positional: given_value"):
96+
parser.parse_args(["given_value"])
97+
98+
6399
# ActionYesNo tests
64100

65101

0 commit comments

Comments
 (0)