Skip to content

Commit

Permalink
CLI now accepts components as a dict, such that the keys define names…
Browse files Browse the repository at this point in the history
… of the subcommands (#334).
  • Loading branch information
mauvilsa committed Aug 17, 2023
1 parent dcfd265 commit 68c1c1d
Show file tree
Hide file tree
Showing 4 changed files with 140 additions and 26 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ Added
^^^^^
- New option in ``dump`` for including link targets.
- Support ``decimal.Decimal`` as a type.
- ``CLI`` now accepts components as a dict, such that the keys define names of
the subcommands (`#334
<https://github.com/omni-us/jsonargparse/issues/334>`__).


v4.23.1 (2023-08-04)
Expand Down
38 changes: 38 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,44 @@ is given to :func:`.CLI`, to execute a method of a class, two levels of
class and the second the name of the method, i.e. ``example.py class
[init_arguments] method [arguments]``.

Arbitrary levels of sub-commands with custom names can be defined by providing a
``dict``. For example:

.. testcode::

class Raffle:
def __init__(self, prize: int):
self.prize = prize

def __call__(self, name: str):
return f"{name} won {self.prize}€!"

components = {
"weekday": {
"tier1": Raffle(prize=100),
"tier2": Raffle(prize=50),
},
"weekend": {
"tier1": Raffle(prize=300),
"tier2": Raffle(prize=75),
},
}

if __name__ == "__main__":
print(CLI(components))

Then in a shell:

.. code-block:: bash
$ python example.py weekend tier1 Lucky
Lucky won 300€!
.. doctest:: :hide:

>>> CLI(components, args=["weekend", "tier1", "Lucky"])
'Lucky won 300€!'

.. note::

The examples above are extremely simple, only defining parameters with
Expand Down
84 changes: 59 additions & 25 deletions jsonargparse/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,20 @@
from ._actions import ActionConfigFile, _ActionPrintConfig, remove_actions
from ._core import ArgumentParser
from ._deprecated import deprecation_warning_cli_return_parser
from ._namespace import Namespace, dict_to_namespace
from ._optionals import get_doc_short_description
from ._util import default_config_option_help

__all__ = ["CLI"]


ComponentType = Union[Callable, Type]
DictComponentsType = Dict[str, Union[ComponentType, "DictComponentsType"]]
ComponentsType = Optional[Union[ComponentType, List[ComponentType], DictComponentsType]]


def CLI(
components: Optional[Union[Callable, Type, List[Union[Callable, Type]]]] = None,
components: ComponentsType = None,
args: Optional[List[str]] = None,
config_help: str = default_config_option_help,
set_defaults: Optional[Dict[str, Any]] = None,
Expand Down Expand Up @@ -55,56 +61,62 @@ def CLI(
]
if len(components) == 0:
raise ValueError(
"Either components argument must be given or there must be at least one "
"Either components parameter must be given or there must be at least one "
"function or class among the locals in the context where CLI is called."
)

elif not isinstance(components, list):
components = [components]
if isinstance(components, list) and len(components) == 1:
components = components[0]

if len(components) == 0:
raise ValueError("components argument not allowed to be an empty list")
elif not components:
raise ValueError("components parameter expected to be non-empty")

unexpected = [c for c in components if not (inspect.isclass(c) or callable(c))]
if isinstance(components, list):
unexpected = [c for c in components if not (inspect.isclass(c) or callable(c))]
elif isinstance(components, dict):
ns = dict_to_namespace(components)
unexpected = [c for c in ns.values() if not (inspect.isclass(c) or callable(c))]
else:
unexpected = [c for c in [components] if not (inspect.isclass(c) or callable(c))]
if unexpected:
raise ValueError(f"Unexpected components, not class or function: {unexpected}")

parser = parser_class(default_meta=False, **kwargs)
parser.add_argument("--config", action=ActionConfigFile, help=config_help)

if len(components) == 1:
component = components[0]
_add_component_to_parser(component, parser, as_positional, fail_untyped, config_help)
if not isinstance(components, (list, dict)):
_add_component_to_parser(components, parser, as_positional, fail_untyped, config_help)
if set_defaults is not None:
parser.set_defaults(set_defaults)
if return_parser:
deprecation_warning_cli_return_parser()
return parser
cfg = parser.parse_args(args)
cfg_init = parser.instantiate_classes(cfg)
return _run_component(component, cfg_init)
return _run_component(components, cfg_init)

subcommands = parser.add_subcommands(required=True)
comp_dict = {c.__name__: c for c in components}
for name, component in comp_dict.items():
description = get_help_str(component, parser.logger)
subparser = parser_class(description=description)
subparser.add_argument("--config", action=ActionConfigFile, help=config_help)
subcommands.add_subcommand(name, subparser, help=get_help_str(component, parser.logger))
added_args = _add_component_to_parser(component, subparser, as_positional, fail_untyped, config_help)
if not added_args:
remove_actions(subparser, (ActionConfigFile, _ActionPrintConfig))
elif isinstance(components, list):
components = {c.__name__: c for c in components}

_add_subcommands(components, parser, config_help, as_positional, fail_untyped)

if set_defaults is not None:
parser.set_defaults(set_defaults)
if return_parser:
deprecation_warning_cli_return_parser()
return parser
cfg = parser.parse_args(args)
cfg_init = parser.instantiate_classes(cfg)
subcommand = cfg_init.pop("subcommand")
component = comp_dict[subcommand]
return _run_component(component, cfg_init.get(subcommand))
init = parser.instantiate_classes(cfg)
components_ns = dict_to_namespace(components)
subcommand = init.get("subcommand")
while isinstance(init.get(subcommand), Namespace) and isinstance(init[subcommand].get("subcommand"), str):
subsubcommand = subcommand + "." + init[subcommand].get("subcommand")
if subsubcommand in components_ns:
subcommand = subsubcommand
else:
break
component = components_ns[subcommand]
return _run_component(component, init.get(subcommand))


def get_help_str(component, logger):
Expand All @@ -114,6 +126,28 @@ def get_help_str(component, logger):
return help_str


def _add_subcommands(
components,
parser: ArgumentParser,
config_help: str,
as_positional: bool,
fail_untyped: bool,
) -> None:
subcommands = parser.add_subcommands(required=True)
for name, component in components.items():
description = get_help_str(component, parser.logger)
subparser = type(parser)(description=description)
subparser.add_argument("--config", action=ActionConfigFile, help=config_help)
if isinstance(component, dict):
subcommands.add_subcommand(name, subparser)
_add_subcommands(component, subparser, config_help, as_positional, fail_untyped)
else:
subcommands.add_subcommand(name, subparser, help=get_help_str(component, parser.logger))
added_args = _add_component_to_parser(component, subparser, as_positional, fail_untyped, config_help)
if not added_args:
remove_actions(subparser, (ActionConfigFile, _ActionPrintConfig))


def _add_component_to_parser(component, parser, as_positional, fail_untyped, config_help):
kwargs = dict(as_positional=as_positional, fail_untyped=fail_untyped, sub_configs=True)
if inspect.isclass(component):
Expand Down
41 changes: 40 additions & 1 deletion jsonargparse_tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ def get_cli_stdout(*args, **kwargs) -> str:
# failure cases


@pytest.mark.parametrize("components", [0, []])
@pytest.mark.parametrize("components", [0, [], {"x": 0}])
def test_unexpected_components(components):
with pytest.raises(ValueError):
CLI(components)
Expand Down Expand Up @@ -344,6 +344,45 @@ def test_dataclass_without_methods_parser_groups():
assert parser.groups == {}


# named components tests


def test_named_components_shallow():
components = {"cmd1": single_function, "cmd2": callable_instance}
assert 3.4 == CLI(components, args=["cmd1", "3.4"])
assert 5 == CLI(components, as_positional=False, args=["cmd2", "--x=5"])


def test_named_components_deep():
components = {
"lv1_a": {"lv2_x": single_function, "lv2_y": {"lv3_p": callable_instance}},
"lv1_b": {"lv2_z": {"lv3_q": Class1}},
}
kw = {"as_positional": False}
out = get_cli_stdout(components, args=["--help"], **kw)
assert " {lv1_a,lv1_b} ..." in out
out = get_cli_stdout(components, args=["lv1_a", "--help"], **kw)
assert " {lv2_x,lv2_y} ..." in out
out = get_cli_stdout(components, args=["lv1_a", "lv2_x", "--help"], **kw)
assert " --a1 A1" in out
out = get_cli_stdout(components, args=["lv1_a", "lv2_y", "--help"], **kw)
assert " {lv3_p} ..." in out
out = get_cli_stdout(components, args=["lv1_a", "lv2_y", "lv3_p", "--help"], **kw)
assert " --x X" in out
out = get_cli_stdout(components, args=["lv1_b", "--help"], **kw)
assert " {lv2_z} ..." in out
out = get_cli_stdout(components, args=["lv1_b", "lv2_z", "--help"], **kw)
assert " {lv3_q} ..." in out
out = get_cli_stdout(components, args=["lv1_b", "lv2_z", "lv3_q", "--help"], **kw)
assert " {method1} ..." in out
out = get_cli_stdout(components, args=["lv1_b", "lv2_z", "lv3_q", "method1", "--help"], **kw)
assert " --m1 M1" in out

assert 5.6 == CLI(components, args=["lv1_a", "lv2_x", "--a1=5.6"], **kw)
assert 7 == CLI(components, args=["lv1_a", "lv2_y", "lv3_p", "--x=7"], **kw)
assert ("w", 9) == CLI(components, args=["lv1_b", "lv2_z", "lv3_q", "--i1=w", "method1", "--m1=9"], **kw)


# config file tests


Expand Down

0 comments on commit 68c1c1d

Please sign in to comment.