Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

CLI components defined as a dict with keys being subcommand names #346

Merged
merged 1 commit into from
Aug 17, 2023
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
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