diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 98ed853a..09bdad9f 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -35,6 +35,11 @@ Fixed - ``TypedDict`` keys inherited from a base defined in a different module not resolving the types only imported in the ``TYPE_CHECKING`` block of that module (`#936 `__). +- ``dump`` with ``with_comments=True``, i.e. ``--print_config=comments``, not + adding any comment for the ``init_args`` of subclasses and for the fields of + dataclass-like types that are not added as a group, including when nested in + lists and dicts (`#939 + `__). Changed ^^^^^^^ diff --git a/DOCUMENTATION.rst b/DOCUMENTATION.rst index 73850ad8..8d8d4b9b 100644 --- a/DOCUMENTATION.rst +++ b/DOCUMENTATION.rst @@ -1352,9 +1352,12 @@ Parsers that have an ``action="config"`` argument also include a with a large set of options to create an initial config file including all default values. If the `ruamel.yaml `__ package is installed, the config can be printed having the help descriptions -content as YAML comments by using ``--print_config=comments``. Another option is -``--print_config=skip_unset`` which skips entries whose value is the configured -unset value (see :ref:`unset-values`). +content as YAML comments by using ``--print_config=comments``. The comments +include the descriptions of the groups and arguments of the parser, and for +values that correspond to a class, e.g. the ``init_args`` of a subclass or the +fields of a dataclass, the descriptions from the respective class. Another +option is ``--print_config=skip_unset`` which skips entries whose value is the +configured unset value (see :ref:`unset-values`). From within Python it is also possible to serialize a config object by using either the :meth:`dump <.ArgumentParser.dump>` or :meth:`save diff --git a/jsonargparse/_formatters.py b/jsonargparse/_formatters.py index 7790ea51..c291c7ce 100644 --- a/jsonargparse/_formatters.py +++ b/jsonargparse/_formatters.py @@ -24,6 +24,8 @@ from ._common import ( defaults_cache, get_optionals_as_positionals_actions, + get_unaliased_type, + is_subclasses_disabled, parent_parser, supports_optionals_as_positionals, ) @@ -33,7 +35,13 @@ from ._optionals import import_ruamel from ._subcommands import ActionSubCommands, find_action from ._type_checking import ArgumentParser, ruamelCommentedMap -from ._typehints import ActionTypeHint, type_to_str +from ._typehints import ( + ActionTypeHint, + get_optional_arg, + get_subclass_or_closed_types, + is_subclass_spec, + type_to_str, +) __all__ = ["DefaultHelpFormatter"] @@ -50,6 +58,77 @@ class PercentTemplate(Template): """ # type: ignore[assignment] +def get_subparsers(parser: "ArgumentParser", prefix: str = "") -> dict[str | None, "ArgumentParser"]: + """Returns the given parser and all its subcommand parsers, keyed by subcommand key.""" + parsers: dict[str | None, ArgumentParser] = {} + if parser._subcommands_action is not None: + for key, subparser in parser._subcommands_action._name_parser_map.items(): + full_key = (prefix + "." if prefix else "") + key + parsers[full_key] = subparser + parsers.update(get_subparsers(subparser, prefix=full_key)) + parsers[None] = parser + return parsers + + +def get_group_titles(parser: "ArgumentParser") -> dict: + """Returns a map of parser keys to the title of the group they belong to.""" + group_titles: dict = {} + for parser_key, subparser in get_subparsers(parser).items(): + group_titles[parser_key] = subparser.description + prefix = "" if parser_key is None else parser_key + "." + for group in subparser._action_groups: + actions = filter_non_parsing_actions(group._group_actions) + skip_types = (_ActionConfigLoad, ActionConfigFile, ActionSubCommands) + actions = [a for a in actions if not isinstance(a, skip_types)] + keys = {re.sub(r"\.?[^.]+$", "", a.dest) for a in actions if "." in a.dest} + for key in keys: + group_titles[prefix + key] = group.title + return group_titles + + +def get_class_group_title(class_parser: "ArgumentParser") -> str | None: + """Returns the title of the group that a class parser adds for the class itself. + + The group for the class is the first one added, the rest correspond to nested keys. + """ + groups = list((class_parser.groups or {}).values()) + return groups[0].title if groups else None + + +def get_class_parser(class_type, action: Action | None) -> "ArgumentParser | None": + """Returns a parser for the arguments of a class, or None if not possible.""" + sub_add_kwargs = dict(getattr(action, "sub_add_kwargs", None) or {}) + sub_add_kwargs.pop("linked_targets", None) + try: + return ActionTypeHint.get_class_parser(class_type, sub_add_kwargs=sub_add_kwargs) + except Exception: + return None + + +def get_closed_type_parser(typehint, action: Action | None) -> "ArgumentParser | None": + """Returns a parser for the arguments of a closed type, e.g. a dataclass, given its type hint.""" + types = get_subclass_or_closed_types(typehint, also_lists=True, callable_return=True) + if types and len(types) == 1 and is_subclasses_disabled(types[0]): + return get_class_parser(types[0], action) + return None + + +def get_mapping_value_typehint(typehint): + """Returns the type of the values of a mapping type hint, or None if not a mapping.""" + if not ActionTypeHint.is_mapping_typehint(typehint): + return None + args = getattr(get_optional_arg(get_unaliased_type(typehint)), "__args__", ()) + return args[1] if len(args) == 2 else None + + +def remove_leading_blank_line(cfg: ruamelCommentedMap) -> None: + """Removes the blank line that comments add before the first key, needed for items of a list.""" + key = next(iter(cfg.keys()), None) + comments = cfg.ca.items.get(key, [None, None])[1] if key is not None else None + if comments and comments[0].value == "\n": + del comments[0] + + class YAMLCommentFormatter: """Formatter class for adding YAML comments to configuration files.""" @@ -64,56 +143,127 @@ def add_yaml_comments(self, cfg: str) -> str: yaml = ruyaml.YAML() cfg = yaml.load(cfg) - def get_parsers(parser: ArgumentParser, prefix="") -> dict[str | None, ArgumentParser]: - parsers = {} - if parser._subparsers is not None: - for key, subparser in parser._subparsers._group_actions[0].choices.items(): # type: ignore[union-attr] - full_key = (prefix + "." if prefix else "") + key - parsers[full_key] = subparser - parsers.update(get_parsers(subparser, prefix=full_key)) - parsers[None] = parser - return parsers - parser = parent_parser.get() assert isinstance(parser, ArgumentParser) - parsers = get_parsers(parser) - - group_titles = {} - for parser_n_key, parser_n in parsers.items(): - group_titles[parser_n_key] = parser_n.description - prefix = "" if parser_n_key is None else parser_n_key + "." - for group in parser_n._action_groups: - actions = filter_non_parsing_actions(group._group_actions) - actions = [ - a for a in actions if not isinstance(a, (_ActionConfigLoad, ActionConfigFile, ActionSubCommands)) - ] - keys = {re.sub(r"\.?[^.]+$", "", a.dest) for a in actions if "." in a.dest} - for key in keys: - group_titles[prefix + key] = group.title - - def set_comments(cfg, prefix="", depth=0): - for key in cfg.keys(): - full_key = (prefix + "." if prefix else "") + key - action = find_action(parser, full_key) - text = None - if full_key in group_titles and isinstance(cfg[key], dict): - text = group_titles[full_key] - elif action is not None and action.help != SUPPRESS: - text = self.help_formatter._expand_help(action) - if isinstance(cfg[key], dict): - if text: - self.set_yaml_group_comment(text, cfg, key, depth) - set_comments(cfg[key], full_key, depth + 1) - elif text: - self.set_yaml_argument_comment(text, cfg, key, depth) - - if parser.description is not None: - self.set_yaml_start_comment(parser.description, cfg) - set_comments(cfg) + if isinstance(cfg, dict): + if parser.description is not None: + self.set_yaml_start_comment(parser.description, cfg) + self.set_comments(cfg, parser, get_group_titles(parser)) out = StringIO() yaml.dump(cfg, out) return out.getvalue() + def set_comments( + self, + cfg: ruamelCommentedMap, + parser: "ArgumentParser", + group_titles: dict, + prefix: str = "", + depth: int = 0, + ) -> None: + """Sets the comments for all the keys of a config object. + + Args: + cfg: The ruamel.yaml object. + parser: The parser from which help content is obtained. + group_titles: Map of parser keys to the title of the group they belong to. + prefix: The parser key that corresponds to the given config object. + depth: The nested level of the keys of the config object. + """ + for key in cfg.keys(): + full_key = (prefix + "." if prefix else "") + key + action = find_action(parser, full_key) + value = cfg[key] + text = None + if full_key in group_titles and isinstance(value, dict): + text = group_titles[full_key] + elif action is not None and action.help != SUPPRESS: + text = self.help_formatter._expand_help(action) + if isinstance(value, dict): + if text: + self.set_yaml_group_comment(text, cfg, key, depth) + self.set_dict_comments(value, action, depth + 1, parser, group_titles, full_key) + else: + if text: + self.set_yaml_argument_comment(text, cfg, key, depth) + if isinstance(value, list): + self.set_list_comments(value, action, depth + 1) + + def set_dict_comments( + self, + cfg: ruamelCommentedMap, + action: Action | None, + depth: int, + parser: "ArgumentParser | None" = None, + group_titles: dict | None = None, + prefix: str = "", + typehint=None, + ) -> None: + """Sets the comments for the keys of a config object nested in another one. + + Args: + cfg: The ruamel.yaml object. + action: The action that corresponds to the given config object, if any. + depth: The nested level of the keys of the config object. + parser: The parser in which the keys are searched when not a class type. + group_titles: Map of parser keys to the title of the group they belong to. + prefix: The parser key that corresponds to the given config object. + typehint: The type of the config object, defaults to the type of the action. + """ + if is_subclass_spec(cfg): + self.set_subclass_comments(cfg, action, depth) + return + if typehint is None and isinstance(action, ActionTypeHint): + typehint = action._typehint + class_parser = get_closed_type_parser(typehint, action) + value_typehint = get_mapping_value_typehint(typehint) + if class_parser is not None: + self.set_comments(cfg, class_parser, get_group_titles(class_parser), depth=depth) + elif value_typehint is not None: + for value in cfg.values(): + if isinstance(value, dict): + self.set_dict_comments(value, action, depth + 1, typehint=value_typehint) + elif isinstance(value, list): + self.set_list_comments(value, action, depth + 1, typehint=value_typehint) + elif parser is not None: + assert group_titles is not None + self.set_comments(cfg, parser, group_titles, prefix, depth) + + def set_subclass_comments(self, cfg: ruamelCommentedMap, action: Action | None, depth: int) -> None: + """Sets the comments for the init args of a subclass spec. + + Args: + cfg: The ruamel.yaml object that has the class_path key. + action: The action that corresponds to the given config object, if any. + depth: The nested level of the keys of the config object. + """ + init_args = cfg.get("init_args") + if not isinstance(init_args, dict): + return + class_parser = get_class_parser(cfg["class_path"], action) + if class_parser is None: + return + title = get_class_group_title(class_parser) + if title: + self.set_yaml_group_comment(title, cfg, "init_args", depth) + self.set_comments(init_args, class_parser, get_group_titles(class_parser), depth=depth + 1) + + def set_list_comments(self, cfg: list, action: Action | None, depth: int, typehint=None) -> None: + """Sets the comments for the class types that are items of a list. + + Args: + cfg: The ruamel.yaml object. + action: The action that corresponds to the given list. + depth: The nested level of the keys of the items of the list. + typehint: The type of the list, defaults to the type of the action. + """ + for item in cfg: + if isinstance(item, dict): + self.set_dict_comments(item, action, depth, typehint=typehint) + remove_leading_blank_line(item) + elif isinstance(item, list): + self.set_list_comments(item, action, depth + 1, typehint=typehint) + def set_yaml_start_comment( self, text: str, diff --git a/jsonargparse/_typehints.py b/jsonargparse/_typehints.py index 72e7d103..de3c55a5 100644 --- a/jsonargparse/_typehints.py +++ b/jsonargparse/_typehints.py @@ -1542,11 +1542,13 @@ def is_instance_factory_protocol(class_type, logger=None): def is_subclass_spec(val): - is_class = isinstance(val, (dict, Namespace)) and "class_path" in val - if is_class: - keys = getattr(val, "__dict__", val).keys() - is_class = len(set(keys) - _subclass_spec_keys) == 0 - return is_class + if isinstance(val, Namespace): + keys = val.__dict__.keys() # only the top level keys of the namespace + elif isinstance(val, dict): + keys = val.keys() + else: + return False + return "class_path" in keys and len(set(keys) - _subclass_spec_keys) == 0 def subclass_spec_as_namespace(val, prev_val=None): diff --git a/jsonargparse_tests/conftest.py b/jsonargparse_tests/conftest.py index 4178ce50..a13b5620 100644 --- a/jsonargparse_tests/conftest.py +++ b/jsonargparse_tests/conftest.py @@ -13,7 +13,7 @@ import pytest -from jsonargparse import ArgumentParser, set_parsing_settings +from jsonargparse import SUPPRESS, ActionParser, ArgumentParser, set_parsing_settings from jsonargparse._loaders_dumpers import json_compact_dump, json_load, yaml_dump, yaml_load from jsonargparse._optionals import ( docstring_parser_support, @@ -22,6 +22,7 @@ jsonschema_support, omegaconf_support, pyyaml_available, + ruamel_support, toml_load_available, url_support, ) @@ -71,6 +72,11 @@ reason="docstring-parser package is required", ) +skip_if_yaml_comments_unavailable = pytest.mark.skipif( + not (ruamel_support and docstring_parser_support), + reason="ruamel.yaml and docstring-parser packages are required", +) + skip_if_requests_unavailable = pytest.mark.skipif( not url_support, reason="requests package is required", @@ -160,6 +166,18 @@ def example_parser() -> ArgumentParser: return parser +@pytest.fixture +def print_parser(parser, subparser) -> ArgumentParser: + parser.description = "cli tool" + parser.add_argument("--cfg", action="config") + parser.add_argument("--v0", help=SUPPRESS, default="0") + parser.add_argument("--v1", help="Option v1.", default=1) + parser.add_argument("--g1.v2", help="Option v2.", default="2") + subparser.add_argument("--v3") + parser.add_argument("--g2", action=ActionParser(parser=subparser)) + return parser + + @pytest.fixture def parsing_settings_patch(): with patch.dict("jsonargparse._common.parsing_settings"): diff --git a/jsonargparse_tests/test_cli.py b/jsonargparse_tests/test_cli.py index 13521119..a835aa64 100644 --- a/jsonargparse_tests/test_cli.py +++ b/jsonargparse_tests/test_cli.py @@ -14,9 +14,14 @@ from jsonargparse import CLI, auto_cli, auto_parser, capture_parser, lazy_instance from jsonargparse._namespace import Namespace -from jsonargparse._optionals import docstring_parser_support, ruamel_support +from jsonargparse._optionals import docstring_parser_support from jsonargparse.typing import final -from jsonargparse_tests.conftest import json_or_yaml_dump, json_or_yaml_load, skip_if_docstring_parser_unavailable +from jsonargparse_tests.conftest import ( + json_or_yaml_dump, + json_or_yaml_load, + skip_if_docstring_parser_unavailable, + skip_if_yaml_comments_unavailable, +) def get_cli_stdout(*args, **kwargs) -> str: @@ -399,8 +404,7 @@ def test_function_and_class_print_config_before_subcommands(): assert {"Cmd2": {"i1": "d", "method2": {"m2": 0}}} == json_or_yaml_load(out) -@skip_if_docstring_parser_unavailable -@pytest.mark.skipif(not ruamel_support, reason="ruamel.yaml package is required") +@skip_if_yaml_comments_unavailable def test_function_and_class_print_config_comments(): out = get_cli_stdout([cmd1, Cmd2, cmd3], args=["--print_config=comments", "Cmd2", "method2"]) assert "# Description of Cmd2" in out diff --git a/jsonargparse_tests/test_core.py b/jsonargparse_tests/test_core.py index 12172035..a6e0fb7f 100644 --- a/jsonargparse_tests/test_core.py +++ b/jsonargparse_tests/test_core.py @@ -26,7 +26,7 @@ ) from jsonargparse._formatters import get_env_var from jsonargparse._namespace import NSKeyError -from jsonargparse._optionals import jsonnet_support, jsonschema_support, pyyaml_available, ruamel_support +from jsonargparse._optionals import jsonnet_support, jsonschema_support, pyyaml_available from jsonargparse.typing import Path_fc, Path_fr, path_type from jsonargparse_tests.conftest import ( capture_logs, @@ -37,7 +37,6 @@ json_or_yaml_dump, json_or_yaml_load, responses_activate, - skip_if_docstring_parser_unavailable, skip_if_fsspec_unavailable, skip_if_no_pyyaml, skip_if_not_posix, @@ -635,24 +634,6 @@ def test_dump_order(parser, subtests): assert dump == "\n".join(v + ": " + str(n) for n, v in args.items()) + "\n" -def test_dump_comments_not_supported(parser): - parser.parser_mode = "json" - parser.add_argument("--op", type=int, default=1) - cfg = parser.get_defaults() - with pytest.raises(ValueError, match="Dumping with comments is not supported for format 'json'"): - parser.dump(cfg, with_comments=True) - - -@skip_if_no_pyyaml -def test_dump_comments_missing_ruamel(parser): - parser.add_argument("--op", type=int, default=1) - cfg = parser.get_defaults() - with patch.dict("jsonargparse._loaders_dumpers.dumpers") as dumpers: - dumpers.pop("yaml_comments", None) - with pytest.raises(ValueError, match="ruamel.yaml is required for dumping YAML with comments"): - parser.dump(cfg, with_comments=True) - - @pytest.fixture def parser_schema_jsonnet(parser, example_parser): parser.add_argument("--cfg", action="config") @@ -884,18 +865,6 @@ def test_save_fsspec(example_parser): ctx.match("multifile=True not supported") -@pytest.fixture -def print_parser(parser, subparser): - parser.description = "cli tool" - parser.add_argument("--cfg", action="config") - parser.add_argument("--v0", help=SUPPRESS, default="0") - parser.add_argument("--v1", help="Option v1.", default=1) - parser.add_argument("--g1.v2", help="Option v2.", default="2") - subparser.add_argument("--v3") - parser.add_argument("--g2", action=ActionParser(parser=subparser)) - return parser - - def test_print_config_normal(print_parser): out = get_parse_args_stdout(print_parser, ["--print_config"]) assert json_or_yaml_load(out) == {"g1": {"v2": "2"}, "g2": {"v3": None}, "v1": 1} @@ -906,25 +875,6 @@ def test_print_config_skip_unset(print_parser): assert json_or_yaml_load(out) == {"g1": {"v2": "2"}, "g2": {}, "v1": 1} -@pytest.mark.skipif(not ruamel_support, reason="ruamel.yaml package is required") -@skip_if_docstring_parser_unavailable -def test_print_config_comments(print_parser): - help_str = get_parser_help(print_parser) - assert "comments," in help_str - out = get_parse_args_stdout(print_parser, ["--print_config=comments"]) - assert "# cli tool" in out - assert "# Option v1. (default: 1)" in out - assert "# Option v2. (default: 2)" in out - - -@pytest.mark.skipif(ruamel_support, reason="ruamel.yaml package should not be installed") -def test_print_config_comments_unavailable(print_parser): - help_str = get_parser_help(print_parser) - assert "comments," not in help_str - with pytest.raises(ArgumentError, match='Invalid option "comments"'): - get_parse_args_stdout(print_parser, ["--print_config=comments"]) - - def test_print_config_invalid_flag(print_parser): with pytest.raises(ArgumentError) as ctx: print_parser.parse_args(["--print_config=invalid"]) diff --git a/jsonargparse_tests/test_optionals.py b/jsonargparse_tests/test_optionals.py index afe0126e..af5071eb 100644 --- a/jsonargparse_tests/test_optionals.py +++ b/jsonargparse_tests/test_optionals.py @@ -4,7 +4,7 @@ import pytest -from jsonargparse import set_parsing_settings +from jsonargparse import ArgumentError, set_parsing_settings from jsonargparse._optionals import ( _get_config_read_mode, docstring_parser_support, @@ -24,6 +24,8 @@ ) from jsonargparse.typing import is_final_class from jsonargparse_tests.conftest import ( + get_parse_args_stdout, + get_parser_help, skip_if_docstring_parser_unavailable, skip_if_fsspec_unavailable, skip_if_requests_unavailable, @@ -141,6 +143,14 @@ def test_ruamel_support_false(): ctx.match("test_ruamel_support_false") +@pytest.mark.skipif(ruamel_support, reason="ruamel.yaml package should not be installed") +def test_print_config_comments_unavailable(print_parser): + help_str = get_parser_help(print_parser) + assert "comments," not in help_str + with pytest.raises(ArgumentError, match='Invalid option "comments"'): + get_parse_args_stdout(print_parser, ["--print_config=comments"]) + + # config read mode tests diff --git a/jsonargparse_tests/test_yaml_comments.py b/jsonargparse_tests/test_yaml_comments.py new file mode 100644 index 00000000..05b5e0f3 --- /dev/null +++ b/jsonargparse_tests/test_yaml_comments.py @@ -0,0 +1,542 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from textwrap import dedent, indent +from typing import Any, Callable, Dict, List, Optional, Union +from unittest.mock import patch + +import pytest + +from jsonargparse import ArgumentParser +from jsonargparse_tests.conftest import ( + get_parse_args_stdout, + get_parser_help, + json_or_yaml_load, + skip_if_yaml_comments_unavailable, +) + +pytestmark = skip_if_yaml_comments_unavailable + + +def test_dump_comments_not_supported(parser): + parser.parser_mode = "json" + parser.add_argument("--op", type=int, default=1) + cfg = parser.get_defaults() + with pytest.raises(ValueError, match="Dumping with comments is not supported for format 'json'"): + parser.dump(cfg, with_comments=True) + + +def test_dump_comments_missing_ruamel(parser): + parser.add_argument("--op", type=int, default=1) + cfg = parser.get_defaults() + with patch.dict("jsonargparse._loaders_dumpers.dumpers") as dumpers: + dumpers.pop("yaml_comments", None) + with pytest.raises(ValueError, match="ruamel.yaml is required for dumping YAML with comments"): + parser.dump(cfg, with_comments=True) + + +def test_print_config_comments(print_parser): + help_str = get_parser_help(print_parser) + assert "comments," in help_str + out = get_parse_args_stdout(print_parser, ["--print_config=comments"]) + assert "# cli tool" in out + assert "# Option v1. (default: 1)" in out + assert "# Option v2. (default: 2)" in out + + +def get_dump(parser: ArgumentParser, args: list) -> str: + cfg = parser.parse_args(args) + dump = parser.dump(cfg, with_comments=True) + assert json_or_yaml_load(dump) == json_or_yaml_load(parser.dump(cfg)) + return dump + + +def block(text: str, depth: int = 0) -> str: + """Dedents an expected dump block and indents it to the given nested level.""" + return indent(dedent(text), " " * depth) + + +class Optimizer: + def __init__(self, lr: float = 0.1): + """Base optimizer. + + Args: + lr: Learning rate. + """ + self.lr = lr # pragma: no cover + + +class SGD(Optimizer): + def __init__(self, momentum: float = 0.9, **kwargs): + """Stochastic gradient descent. + + Args: + momentum: Momentum factor. + """ + super().__init__(**kwargs) # pragma: no cover + + +class NoParams(Optimizer): + """Optimizer without parameters.""" + + def __init__(self): + pass # pragma: no cover + + +class NoDescription(Optimizer): + def __init__(self, gamma: float = 0.1, **kwargs): + """ + Args: + gamma: Decay factor. + """ + super().__init__(**kwargs) # pragma: no cover + + +@dataclass +class Data: + """Data settings. + + Args: + path: Path to the data. + batch_size: Number of samples per batch. + """ + + path: str = "data" + batch_size: int = 4 + + +@dataclass +class Nested: + """Nested settings. + + Args: + data: The data settings. + seed: Random seed. + """ + + data: Data = field(default_factory=Data) + seed: int = 1 + + +class Model: + def __init__(self, optimizer: Optimizer = SGD(), name: str = "model"): + """A model. + + Args: + optimizer: The optimizer to use. + name: Name of the model. + """ + + +class Trainer: + def __init__(self, data: Data = Data(), epochs: int = 1): + """A trainer. + + Args: + data: The data settings. + epochs: Number of epochs. + """ + + +# subclass tests + + +def test_subclass_init_args(parser): + parser.add_argument("--optimizer", type=Optimizer, help="The optimizer.") + dump = get_dump(parser, [f"--optimizer={__name__}.SGD"]) + expected = block( + f""" + optimizer: + class_path: {__name__}.SGD + + # Stochastic gradient descent + init_args: + + # Momentum factor. (type: float, default: 0.9) + momentum: 0.9 + + # Learning rate. (type: float, default: 0.1) + lr: 0.1 + """ + ) + assert expected in dump + assert "# The optimizer. (type: " in dump + + +def test_subclass_without_init_args(parser): + parser.add_argument("--optimizer", type=Optimizer, help="The optimizer.") + dump = get_dump(parser, [f"--optimizer={__name__}.NoParams"]) + assert "init_args" not in dump + assert "class_path:" in dump + + +def test_subclass_nested_in_init_args(parser): + parser.add_argument("--model", type=Model, help="The model.") + dump = get_dump(parser, [f"--model={__name__}.Model"]) + expected = block( + f""" + model: + class_path: {__name__}.Model + + # A model + init_args: + """ + ) + assert expected in dump + expected = block( + f""" + optimizer: + class_path: {__name__}.SGD + + # Stochastic gradient descent + init_args: + + # Momentum factor. (type: float, default: 0.9) + momentum: 0.9 + + # Learning rate. (type: float, default: 0.1) + lr: 0.1 + + # Name of the model. (type: str, default: model) + name: model + """, + depth=2, + ) + assert expected in dump + + +def test_subclass_in_list(parser): + parser.add_argument("--optimizers", type=List[Optimizer], help="The optimizers.") + dump = get_dump(parser, [f"--optimizers=[{__name__}.SGD,{__name__}.Optimizer]"]) + assert "# The optimizers. (type: " in dump + expected = block( + f""" + optimizers: + - class_path: {__name__}.SGD + + # Stochastic gradient descent + init_args: + + # Momentum factor. (type: float, default: 0.9) + momentum: 0.9 + + # Learning rate. (type: float, default: 0.1) + lr: 0.1 + - class_path: {__name__}.Optimizer + + # Base optimizer + init_args: + + # Learning rate. (type: float, default: 0.1) + lr: 0.1 + """ + ) + assert expected in dump + + +def test_subclass_in_nested_list(parser): + parser.add_argument("--optimizers", type=List[List[Optimizer]], help="The optimizers.") + dump = get_dump(parser, [f'--optimizers=[[{{"class_path": "{__name__}.SGD", "init_args": {{"momentum": 0.5}}}}]]']) + expected = block( + f""" + optimizers: + - - class_path: {__name__}.SGD + + # Stochastic gradient descent + init_args: + + # Momentum factor. (type: float, default: 0.9) + momentum: 0.5 + """ + ) + assert expected in dump + + +def test_subclass_in_dict(parser): + parser.add_argument("--optimizers", type=Dict[str, Optimizer], help="The optimizers.") + dump = get_dump(parser, [f'--optimizers={{"one": "{__name__}.SGD"}}']) + expected = block( + f""" + optimizers: + one: + class_path: {__name__}.SGD + + # Stochastic gradient descent + init_args: + + # Momentum factor. (type: float, default: 0.9) + momentum: 0.9 + + # Learning rate. (type: float, default: 0.1) + lr: 0.1 + """ + ) + assert expected in dump + + +def test_subclass_in_dict_of_lists(parser): + parser.add_argument("--optimizers", type=Dict[str, List[Optimizer]], help="The optimizers.") + spec = f'{{"class_path": "{__name__}.SGD", "init_args": {{"momentum": 0.5}}}}' + dump = get_dump(parser, [f'--optimizers={{"one": [{spec}]}}']) + expected = block( + f""" + optimizers: + one: + - class_path: {__name__}.SGD + + # Stochastic gradient descent + init_args: + + # Momentum factor. (type: float, default: 0.9) + momentum: 0.5 + + # Learning rate. (type: float, default: 0.1) + lr: 0.1 + """ + ) + assert expected in dump + + +def test_subclass_without_class_description(parser): + parser.add_argument("--optimizer", type=Optimizer, help="The optimizer.") + dump = get_dump(parser, [f"--optimizer={__name__}.NoDescription"]) + expected = block( + f""" + optimizer: + class_path: {__name__}.NoDescription + + # + init_args: + + # Decay factor. (type: float, default: 0.1) + gamma: 0.1 + + # Learning rate. (type: float, default: 0.1) + lr: 0.1 + """ + ) + assert expected in dump + + +def test_subclass_in_union(parser): + parser.add_argument("--optimizer", type=Union[int, Optimizer], help="The optimizer.") + dump = get_dump(parser, [f"--optimizer={__name__}.SGD"]) + assert "# Stochastic gradient descent\n init_args:" in dump + assert "# Momentum factor. (type: float, default: 0.9)" in dump + + +def test_subclass_in_any(parser): + parser.add_argument("--optimizer", type=Any, help="The optimizer.") + dump = get_dump(parser, [f'--optimizer={{"class_path": "{__name__}.SGD"}}']) + assert "# Stochastic gradient descent\n init_args:" in dump + assert "# Momentum factor. (type: float, default: 0.9)" in dump + + +def test_add_subclass_arguments(parser): + parser.add_subclass_arguments(Optimizer, "optimizer", help="The optimizer.") + dump = get_dump(parser, [f"--optimizer={__name__}.SGD"]) + assert "# The optimizer. (type: " in dump + assert "# Stochastic gradient descent\n init_args:" in dump + assert "# Momentum factor. (type: float, default: 0.9)" in dump + + +def test_callable_return_type(parser): + parser.add_argument("--optimizer", type=Callable[[float], Optimizer], help="The optimizer.") + dump = get_dump(parser, [f"--optimizer={__name__}.SGD"]) + expected = block( + f""" + optimizer: + class_path: {__name__}.SGD + + # Stochastic gradient descent + init_args: + + # Learning rate. (type: float, default: 0.1) + lr: 0.1 + """ + ) + assert expected in dump + assert "momentum" not in dump + + +def test_subclass_skipped_init_args(parser): + parser.add_subclass_arguments(Optimizer, "optimizer", skip={"lr"}, help="The optimizer.") + dump = get_dump(parser, [f"--optimizer={__name__}.SGD"]) + assert "# Momentum factor. (type: float, default: 0.9)" in dump + assert "lr" not in dump + + +def test_unresolvable_class_path(parser): + parser.add_argument("--data", type=dict, help="Some dict.") + dump = get_dump(parser, ['--data={"class_path": "not.a.real.Class", "init_args": {"x": 1}}']) + assert "# Some dict. (type: " in dump + expected = block( + """ + data: + class_path: not.a.real.Class + init_args: + x: 1 + """ + ) + assert expected in dump + + +# dataclass-like tests + + +def test_dataclass_as_type(parser): + parser.add_argument("--data", type=Data, help="The data.") + dump = get_dump(parser, []) + expected = block( + """ + # The data + data: + + # Path to the data. (type: str, default: data) + path: data + + # Number of samples per batch. (type: int, default: 4) + batch_size: 4 + """ + ) + assert expected in dump + + +def test_dataclass_in_optional(parser): + parser.add_argument("--data", type=Optional[Data], help="The data.") + dump = get_dump(parser, ["--data={}"]) + expected = block( + """ + data: + + # Path to the data. (type: str, default: data) + path: data + + # Number of samples per batch. (type: int, default: 4) + batch_size: 4 + """ + ) + assert expected in dump + assert "# The data. (type: " in dump + + +def test_dataclass_in_list(parser): + parser.add_argument("--data", type=List[Data], help="The data.") + dump = get_dump(parser, ["--data=[{}]"]) + expected = block( + """ + data: + - + # Path to the data. (type: str, default: data) + path: data + + # Number of samples per batch. (type: int, default: 4) + batch_size: 4 + """ + ) + assert expected in dump + + +def test_dataclass_in_dict(parser): + parser.add_argument("--data", type=Dict[str, Data], help="The data.") + dump = get_dump(parser, ['--data={"one": {}}']) + expected = block( + """ + data: + one: + + # Path to the data. (type: str, default: data) + path: data + + # Number of samples per batch. (type: int, default: 4) + batch_size: 4 + """ + ) + assert expected in dump + + +def test_dataclass_nested_in_dataclass(parser): + parser.add_argument("--nested", type=Optional[Nested], help="The nested.") + dump = get_dump(parser, ["--nested={}"]) + expected = block( + """ + nested: + + # The data settings + data: + + # Path to the data. (type: str, default: data) + path: data + + # Number of samples per batch. (type: int, default: 4) + batch_size: 4 + + # Random seed. (type: int, default: 1) + seed: 1 + """ + ) + assert expected in dump + + +def test_dataclass_in_subclass_init_args(parser): + parser.add_argument("--trainer", type=Trainer, help="The trainer.") + dump = get_dump(parser, [f"--trainer={__name__}.Trainer"]) + expected = block( + """ + # A trainer + init_args: + + # The data settings + data: + + # Path to the data. (type: str, default: data) + path: data + + # Number of samples per batch. (type: int, default: 4) + batch_size: 4 + + # Number of epochs. (type: int, default: 1) + epochs: 1 + """, + depth=1, + ) + assert expected in dump + + +# subcommand tests + + +def test_subcommand_subclass(parser, subparser): + parser.description = "the tool" + subparser.description = "fit command" + subparser.add_argument("--optimizer", type=Optimizer, help="The optimizer.") + subcommands = parser.add_subcommands() + subcommands.add_subcommand("fit", subparser) + dump = get_dump(parser, ["fit", f"--optimizer={__name__}.SGD"]) + expected = block( + f""" + optimizer: + class_path: {__name__}.SGD + + # Stochastic gradient descent + init_args: + + # Momentum factor. (type: float, default: 0.9) + momentum: 0.9 + + # Learning rate. (type: float, default: 0.1) + lr: 0.1 + """, + depth=1, + ) + assert dump.startswith("# the tool\n") + assert "\n# fit command\nfit:\n" in dump + assert expected in dump + + +def test_print_config_comments_subclass(parser): + parser.add_argument("--cfg", action="config") + parser.add_argument("--optimizer", type=Optimizer, help="The optimizer.") + out = get_parse_args_stdout(parser, [f"--optimizer={__name__}.SGD", "--print_config=comments"]) + assert "# Stochastic gradient descent\n init_args:" in out + assert "# Momentum factor. (type: float, default: 0.9)" in out diff --git a/pyproject.toml b/pyproject.toml index 3e95605a..af576dfe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -224,7 +224,8 @@ commands = [testenv:pydantic-v1] extras = coverage -deps = ./jsonargparse_tests +# editable, so that changes in the tests source are picked up without recreating the env +deps = -e ./jsonargparse_tests commands = # Test with pydantic<2 python -c "\ @@ -256,7 +257,8 @@ commands = [testenv:without-future-annotations] extras = test,coverage,all -deps = ./jsonargparse_tests +# editable, so that changes in the tests source are picked up without recreating the env +deps = -e ./jsonargparse_tests allowlist_externals = sh commands = sh -c "\