Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ Added
<https://github.com/omni-us/jsonargparse/pull/753>`__).
- Support callable protocols for instance factory dependency injection (`#758
<https://github.com/omni-us/jsonargparse/pull/758>`__).
- New ``ActionFail`` for arguments that should fail parsing with a given error
message (`#759 <https://github.com/omni-us/jsonargparse/pull/759>`__).

Fixed
^^^^^
Expand Down
31 changes: 31 additions & 0 deletions DOCUMENTATION.rst
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,37 @@ a function, it is recommended to implement a type, see :ref:`custom-types`.
parser.add_argument("--int_or_off", type=int_or_off)


Always fail arguments
---------------------

In scenarios where an argument should be included in the parser but should
always fail parsing, there is the :class:`.ActionFail` action. For example a use
case can be an optional feature that is only accessible if a specific package is
installed:

.. testsetup:: always-fail

parser = ArgumentParser()
some_package_installed = False

.. testcode:: always-fail

from jsonargparse import ActionFail

if some_package_installed:
parser.add_argument("--module", type=SomeClass)
else:
parser.add_argument(
"--module",
action=ActionFail(message="install 'package' to enable %(option)s"),
help="Option unavailable due to missing 'package'",
)

With this setup, if an argument is provided as ``--module=...`` or in a nested
form like ``--module.child=...``, the parsing will fail and display the
configured error message.


.. _type-hints:

Type hints
Expand Down
33 changes: 31 additions & 2 deletions jsonargparse/_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,9 @@

__all__ = [
"ActionConfigFile",
"ActionYesNo",
"ActionFail",
"ActionParser",
"ActionYesNo",
]


Expand Down Expand Up @@ -65,7 +66,7 @@
fallback_action = None
for action in actions:
if action.dest == dest or f"--{dest}" in action.option_strings:
if isinstance(action, _ActionConfigLoad):
if isinstance(action, (_ActionConfigLoad, ActionFail)):
fallback_action = action
else:
return action, None
Expand Down Expand Up @@ -435,6 +436,34 @@
return args[num + 1 :]


class ActionFail(Action):
"""Action that always fails parsing with a given error."""

def __init__(self, message: str = "option unavailable", **kwargs):
"""Initializer for ActionFail instance.

Args:
message: Text for the error to show. Use `%(option)s`/`%(value)s` to include the option and/or value.
"""
if len(kwargs) == 0:
self._message = message
else:
self._message = kwargs.pop("_message")
kwargs["default"] = SUPPRESS
kwargs["required"] = False
if kwargs["option_strings"] == []:
kwargs["nargs"] = "?"
super().__init__(**kwargs)

def __call__(self, *args, **kwargs):
"""Always fails with given message."""
if len(args) == 0:
kwargs["_message"] = self._message
return ActionFail(**kwargs)
parser, _, value, option = args
parser.error(self._message % {"value": value, "option": option})


class ActionYesNo(Action):
"""Paired options --[yes_prefix]opt, --[no_prefix]opt to set True or False respectively."""

Expand Down
2 changes: 1 addition & 1 deletion jsonargparse/_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ def add_argument(self, *args, enable_path: bool = False, **kwargs):
ActionJsonnet._check_ext_vars_action(parser, action)
if is_meta_key(action.dest):
raise ValueError(f'Argument with destination name "{action.dest}" not allowed.')
if action.option_strings == [] and "default" in kwargs:
if action.option_strings == [] and "default" in kwargs and kwargs["default"] is not argparse.SUPPRESS:
raise ValueError("Positional arguments not allowed to have a default value.")
validate_default(self, action)
if action.help is None:
Expand Down
3 changes: 2 additions & 1 deletion jsonargparse/_typehints.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
from ._actions import (
Action,
ActionConfigFile,
ActionFail,
_ActionHelpClassPath,
_ActionPrintConfig,
_find_action,
Expand Down Expand Up @@ -405,7 +406,7 @@ def parse_argv_item(arg_string):
action = _find_parent_action(parser, arg_base[2:])

typehint = typehint_from_action(action)
if typehint:
if typehint or isinstance(action, ActionFail):
if parse_optional_num_return == 4:
return action, arg_base, sep, explicit_arg
elif parse_optional_num_return == 1:
Expand Down
36 changes: 36 additions & 0 deletions jsonargparse_tests/test_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import pytest

from jsonargparse import (
SUPPRESS,
ActionFail,
ActionParser,
ActionYesNo,
ArgumentError,
Expand Down Expand Up @@ -60,6 +62,40 @@ def test_action_config_file_argument_errors(parser, tmp_cwd):
pytest.raises(ArgumentError, lambda: parser.parse_args(["--cfg", '{"k":"v"}']))


# ActionFail tests


def test_action_fail_optional(parser):
parser.add_argument(
"--unavailable",
action=ActionFail(message="needs package xyz"),
help="Option not available due to missing package xyz",
)
help_str = get_parser_help(parser)
assert "--unavailable" in help_str
assert "Option not available due to missing package xyz" in help_str
defaults = parser.get_defaults()
assert "unavailable" not in defaults
with pytest.raises(ArgumentError, match="needs package xyz"):
parser.parse_args(["--unavailable=x"])
with pytest.raises(ArgumentError, match="needs package xyz"):
parser.parse_args(["--unavailable.child=x"])


def test_action_fail_positional(parser):
parser.add_argument(
"unexpected",
action=ActionFail(message="unexpected positional: %(value)s"),
help=SUPPRESS,
)
parser.add_argument("--something", default="else")
help_str = get_parser_help(parser)
assert "unexpected" not in help_str
assert parser.parse_args([]) == Namespace(something="else")
with pytest.raises(ArgumentError, match="unexpected positional: given_value"):
parser.parse_args(["given_value"])


# ActionYesNo tests


Expand Down