diff --git a/CHANGELOG.rst b/CHANGELOG.rst index d25e4570..ea3e4678 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -12,9 +12,24 @@ The semantic versioning only considers the public API as described in paths are considered internals and can change in minor and patch releases. -v4.34.1 (2024-12-02) +v4.35.0 (2024-12-??) -------------------- +Changed +^^^^^^^ +- Argument groups created from dataclass-like that have zero configurable + arguments no longer adds a config loader (`#634 + `__). + +Deprecated +^^^^^^^^^^ +- ``add_dataclass_arguments`` is deprecated and will be removed in v5.0.0. + Instead use ``add_class_arguments`` (`#634 + `__). + + +v4.34.1 (2024-12-02) + Fixed ^^^^^ - List of dataclass with nested dataclass attribute fails to parse (`#625 diff --git a/jsonargparse/_actions.py b/jsonargparse/_actions.py index 938d4946..8190c9d2 100644 --- a/jsonargparse/_actions.py +++ b/jsonargparse/_actions.py @@ -9,7 +9,7 @@ from contextvars import ContextVar from typing import Any, Dict, List, Optional, Tuple, Type, Union -from ._common import Action, get_class_instantiator, is_subclass, parser_context +from ._common import Action, is_subclass, parser_context from ._loaders_dumpers import get_loader_exceptions, load_value from ._namespace import Namespace, NSKeyError, split_key, split_key_root from ._optionals import get_config_read_mode @@ -333,10 +333,6 @@ def _load_config(self, value, parser): def check_type(self, value, parser): return self._load_config(value, parser) - def instantiate_classes(self, value): - instantiator_fn = get_class_instantiator() - return instantiator_fn(self.basetype, **value) - class _ActionHelpClassPath(Action): sub_add_kwargs: Dict[str, Any] = {} diff --git a/jsonargparse/_core.py b/jsonargparse/_core.py index 548e5b66..8f35c566 100644 --- a/jsonargparse/_core.py +++ b/jsonargparse/_core.py @@ -124,7 +124,7 @@ def add_argument(self, *args, enable_path: bool = False, **kwargs): if "type" in kwargs: if is_dataclass_like(kwargs["type"]): nested_key = args[0].lstrip("-") - self.add_dataclass_arguments(kwargs.pop("type"), nested_key, **kwargs) + self.add_class_arguments(kwargs.pop("type"), nested_key, **kwargs) return _find_action(parser, nested_key) if ActionTypeHint.is_supported_typehint(kwargs["type"]): args = ActionTypeHint.prepare_add_argument( @@ -176,6 +176,33 @@ def add_argument_group(self, *args, name: Optional[str] = None, **kwargs) -> "_A parser.groups[name] = group # type: ignore[union-attr] return group + def set_defaults(self, *args: Dict[str, Any], **kwargs: Any) -> None: + """Sets default values from dictionary or keyword arguments. + + Args: + *args: Dictionary defining the default values to set. + **kwargs: Sets default values based on keyword arguments. + + Raises: + KeyError: If key not defined in the parser. + """ + for arg in args: + for dest, default in arg.items(): + action = _find_action(self, dest) + if action is None: + raise NSKeyError(f'No action for key "{dest}" to set its default.') + elif isinstance(action, ActionConfigFile): + ActionConfigFile.set_default_error() + elif isinstance(action, _ActionConfigLoad): + default = {f"{dest}.{k}": v for k, v in default.items()} + self.set_defaults(default) + continue + if isinstance(action, ActionTypeHint): + default = action.normalize_default(default) + self._defaults[dest] = action.default = default + if kwargs: + self.set_defaults(kwargs) + class _ArgumentGroup(ActionsContainer, argparse._ArgumentGroup): """Extension of argparse._ArgumentGroup to support additional functionalities.""" @@ -884,29 +911,6 @@ def save_paths(cfg): ## Methods related to defaults ## - def set_defaults(self, *args: Dict[str, Any], **kwargs: Any) -> None: - """Sets default values from dictionary or keyword arguments. - - Args: - *args: Dictionary defining the default values to set. - **kwargs: Sets default values based on keyword arguments. - - Raises: - KeyError: If key not defined in the parser. - """ - for arg in args: - for dest, default in arg.items(): - action = _find_action(self, dest) - if action is None: - raise NSKeyError(f'No action for key "{dest}" to set its default.') - elif isinstance(action, ActionConfigFile): - ActionConfigFile.set_default_error() - if isinstance(action, ActionTypeHint): - default = action.normalize_default(default) - self._defaults[dest] = action.default = default - if kwargs: - self.set_defaults(kwargs) - def _get_default_config_files(self) -> List[Tuple[Optional[str], Path]]: default_config_files = [] @@ -1179,7 +1183,7 @@ def instantiate_classes( cfg = strip_meta(cfg) for component in components: ActionLink.apply_instantiation_links(self, cfg, target=component.dest) - if isinstance(component, (ActionTypeHint, _ActionConfigLoad)): + if isinstance(component, ActionTypeHint): try: value, parent, key = cfg.get_value_and_parent(component.dest) except (KeyError, AttributeError): diff --git a/jsonargparse/_deprecated.py b/jsonargparse/_deprecated.py index bb3a615c..ab05a391 100644 --- a/jsonargparse/_deprecated.py +++ b/jsonargparse/_deprecated.py @@ -520,6 +520,17 @@ def error_handler(self, error_handler): else: raise ValueError("error_handler can be either a Callable or None.") + @deprecated( + """ + add_dataclass_arguments was deprecated in v4.35.0 and will be removed in + v5.0.0. Instead use add_class_arguments. + """ + ) + def add_dataclass_arguments(self, *args, **kwargs): + if "title" in kwargs: + kwargs["help"] = kwargs.pop("title") + return self.add_class_arguments(*args, **kwargs) + ParserError = ArgumentError diff --git a/jsonargparse/_signatures.py b/jsonargparse/_signatures.py index efd57e21..ee27720d 100644 --- a/jsonargparse/_signatures.py +++ b/jsonargparse/_signatures.py @@ -4,7 +4,6 @@ import inspect import re from argparse import SUPPRESS, ArgumentParser -from contextlib import suppress from typing import Any, Callable, List, Optional, Set, Tuple, Type, Union from ._actions import _ActionConfigLoad @@ -14,6 +13,7 @@ get_generic_origin, get_unaliased_type, is_dataclass_like, + is_final_class, is_subclass, ) from ._namespace import Namespace @@ -53,7 +53,7 @@ def add_class_arguments( nested_key: Optional[str] = None, as_group: bool = True, as_positional: bool = False, - default: Optional[Union[dict, Namespace, LazyInitBaseClass]] = None, + default: Optional[Union[dict, Namespace, LazyInitBaseClass, Type]] = None, skip: Optional[Set[Union[str, int]]] = None, instantiate: bool = True, fail_untyped: bool = True, @@ -82,16 +82,27 @@ def add_class_arguments( ValueError: When not given a class. ValueError: When there are required parameters without at least one valid type. """ - if not inspect.isclass(get_generic_origin(get_unaliased_type(theclass))): + unaliased_class_type = get_unaliased_type(theclass) + if not inspect.isclass(get_generic_origin(unaliased_class_type)): raise ValueError(f"Expected 'theclass' parameter to be a class type, got: {theclass}") if not ( isinstance(default, (NoneType, dict, Namespace)) - or (isinstance(default, LazyInitBaseClass) and isinstance(default, theclass)) + or (isinstance(default, LazyInitBaseClass) and isinstance(default, unaliased_class_type)) + or ( + not is_final_class(default.__class__) + and is_dataclass_like(default.__class__) + and isinstance(default, unaliased_class_type) + ) ): raise ValueError( - f"Expected 'default' parameter to be a dict, Namespace or lazy instance of the class, got: {default}" + f"Expected 'default' to be dict, Namespace, lazy instance or dataclass-like, got: {default}" ) - linked_targets = get_private_kwargs(kwargs, linked_targets=None) + linked_targets, help, _ = get_private_kwargs( + kwargs, + linked_targets=None, + help=None, + required=None, # Ignored because provided when adding signatures, remove with dataclass inheritance support + ) added_args = self._add_signature_arguments( theclass, @@ -104,6 +115,7 @@ def add_class_arguments( sub_configs=sub_configs, instantiate=instantiate, linked_targets=linked_targets, + help=help, ) if default: @@ -112,6 +124,8 @@ def add_class_arguments( defaults = default if isinstance(default, LazyInitBaseClass): defaults = default.lazy_get_init_args().as_dict() + elif is_dataclass_like(default.__class__): + defaults = dataclass_to_dict(default) elif isinstance(default, Namespace): defaults = default.as_dict() if defaults: @@ -230,6 +244,7 @@ def _add_signature_arguments( sub_configs: bool = False, instantiate: bool = True, linked_targets: Optional[Set[str]] = None, + help: Optional[str] = None, ) -> List[str]: """Adds arguments from parameters of objects based on signatures and docstrings. @@ -273,7 +288,10 @@ def _add_signature_arguments( ) ## Create group if requested ## - doc_group = get_doc_short_description(function_or_class, method_name, logger=self.logger) + if help is not None: + doc_group = help + else: + doc_group = get_doc_short_description(function_or_class, method_name, logger=self.logger) component = getattr(function_or_class, method_name) if method_name else function_or_class container = self._create_group_if_requested( component, @@ -424,67 +442,6 @@ def _add_signature_parameter( f" type. Parameter '{name}' from '{src}' does not specify a type." ) - def add_dataclass_arguments( - self, - theclass: Type, - nested_key: str, - default: Optional[Union[Type, dict]] = None, - as_group: bool = True, - fail_untyped: bool = True, - **kwargs, - ) -> List[str]: - """Adds arguments from a dataclass based on its field types and docstrings. - - Args: - theclass: Class from which to add arguments. - nested_key: Key for nested namespace. - default: Value for defaults. Must be instance of or kwargs for theclass. - as_group: Whether arguments should be added to a new argument group. - fail_untyped: Whether to raise exception if a required parameter does not have a type. - - Returns: - The list of arguments added. - - Raises: - ValueError: When not given a dataclass. - ValueError: When default is not instance of or kwargs for theclass. - """ - if not is_dataclass_like(theclass): - raise ValueError(f'Expected "theclass" argument to be a dataclass-like, given {theclass}') - - doc_group = get_doc_short_description(theclass, logger=self.logger) - for key in ["help", "title"]: - if key in kwargs and kwargs[key] is not None: - doc_group = kwargs.pop(key) - group = self._create_group_if_requested(theclass, nested_key, as_group, doc_group, config_load_type=theclass) - - defaults = {} - if default is not None: - if isinstance(default, dict): - with suppress(TypeError): - default = theclass(**default) - if not isinstance(default, get_unaliased_type(theclass)): - raise ValueError( - f'Expected "default" argument to be an instance of "{theclass.__name__}" ' - f"or its kwargs dict, given {default}" - ) - defaults = dataclass_to_dict(default) - - added_args: List[str] = [] - param_kwargs = {k: v for k, v in kwargs.items() if k == "sub_configs"} - for param in get_signature_parameters(theclass, None, logger=self.logger): - self._add_signature_parameter( - group, - nested_key, - param, - added_args, - fail_untyped=fail_untyped, - default=defaults.get(param.name, inspect_empty), - **param_kwargs, - ) - - return added_args - def add_subclass_arguments( self, baseclass: Union[Type, Tuple[Type, ...]], @@ -618,8 +575,6 @@ def dataclass_to_dict(value) -> dict: pydantic_model = is_pydantic_model(type(value)) if pydantic_model: return value.dict() if pydantic_model == 1 else value.model_dump() - if isinstance(value, LazyInitBaseClass): - return value.lazy_get_init_data().as_dict() return dataclasses.asdict(value) diff --git a/jsonargparse/_typehints.py b/jsonargparse/_typehints.py index cd7ebc58..4ac35b3a 100644 --- a/jsonargparse/_typehints.py +++ b/jsonargparse/_typehints.py @@ -1592,8 +1592,6 @@ def lazy_get_init_args(self) -> Namespace: def lazy_get_init_data(self): init_args = self.lazy_get_init_args() - if is_dataclass_like(self._lazy_class_type): - return init_args init = Namespace(class_path=get_import_path(self._lazy_class_type)) if len(self._lazy_kwargs) > 0: init["init_args"] = init_args diff --git a/jsonargparse_tests/test_dataclass_like.py b/jsonargparse_tests/test_dataclass_like.py index ff736d0d..bce6865a 100644 --- a/jsonargparse_tests/test_dataclass_like.py +++ b/jsonargparse_tests/test_dataclass_like.py @@ -15,6 +15,7 @@ compose_dataclasses, lazy_instance, ) +from jsonargparse._namespace import NSKeyError from jsonargparse._optionals import ( attrs_support, docstring_parser_support, @@ -58,16 +59,16 @@ class DataClassB: """ b1: PositiveFloat = PositiveFloat(3.0) # type: ignore[valid-type] - b2: DataClassA = DataClassA() + b2: DataClassA = DataClassA(a2="x") class MixedClass(int, DataClassA): """MixedClass description""" -def test_add_dataclass_arguments(parser, subtests): - parser.add_dataclass_arguments(DataClassA, "a", default=DataClassA(), title="CustomA title") - parser.add_dataclass_arguments(DataClassB, "b", default=DataClassB()) +def test_add_class_arguments(parser, subtests): + parser.add_class_arguments(DataClassA, "a", default=DataClassA(), help="CustomA title") + parser.add_class_arguments(DataClassB, "b", default=DataClassB()) with subtests.test("get_defaults"): cfg = parser.get_defaults() @@ -96,11 +97,11 @@ def test_add_dataclass_arguments(parser, subtests): with subtests.test("add failures"): with pytest.raises(ValueError): - parser.add_dataclass_arguments(1, "c") + parser.add_class_arguments(1, "c") + with pytest.raises(NSKeyError): + parser.add_class_arguments(DataClassB, "c", default=DataClassB(b2=DataClassB())) with pytest.raises(ValueError): - parser.add_dataclass_arguments(DataClassB, "c", default=DataClassB(b2=DataClassB())) - with pytest.raises(ValueError): - parser.add_dataclass_arguments(MixedClass, "c") + parser.add_class_arguments(MixedClass, "c") @dataclasses.dataclass @@ -115,7 +116,7 @@ class NestedDefaultsB: def test_add_dataclass_nested_defaults(parser): - parser.add_dataclass_arguments(NestedDefaultsB, "data") + parser.add_class_arguments(NestedDefaultsB, "data") cfg = parser.parse_args(["--data.a=[{}]"]) assert cfg.data == Namespace(a=[Namespace(x=[], v=1)]) @@ -218,7 +219,7 @@ def test_list_append_defaults(parser): def test_add_argument_dataclass_type(parser): parser.add_argument("--b", type=DataClassB, default=DataClassB(b1=7.0)) cfg = parser.get_defaults() - assert {"b1": 7.0, "b2": {"a1": 1, "a2": "2"}} == cfg.b.as_dict() + assert {"b1": 7.0, "b2": {"a1": 1, "a2": "x"}} == cfg.b.as_dict() init = parser.instantiate_classes(cfg) assert isinstance(init.b, DataClassB) assert isinstance(init.b.b2, DataClassA) @@ -254,11 +255,13 @@ class DataInitFalse: def test_dataclass_field_init_false(parser): - added = parser.add_dataclass_arguments(DataInitFalse, "d") + added = parser.add_class_arguments(DataInitFalse, "data") assert added == [] assert parser.get_defaults() == Namespace() - cfg = parser.parse_args(["--d", "{}"]) - assert cfg.d == Namespace() + cfg = parser.parse_args([]) + assert cfg == Namespace() + init = parser.instantiate_classes(cfg) + assert isinstance(init.data, DataInitFalse) @dataclasses.dataclass @@ -513,7 +516,7 @@ class SpecificData: def test_nested_generic_dataclass(parser): - parser.add_dataclass_arguments(SpecificData, "x") + parser.add_class_arguments(SpecificData, "x") help_str = get_parser_help(parser).lower() assert "--x.y.g1 g1 (required, type: float)" in help_str assert "--x.y.g2 [item,...] (required, type: tuple[float, float])" in help_str @@ -853,11 +856,11 @@ def test_dataclass_field_init_false(self, parser): parser.add_argument("--data", type=PydanticDataFieldInitFalse) help_str = get_parser_help(parser) assert "--data.p1" not in help_str - cfg = parser.parse_args(["--data", "{}"]) - assert cfg.data == Namespace() + cfg = parser.parse_args([]) + assert cfg == Namespace() - cfg = parser.instantiate_classes(cfg) - assert cfg.data.p1 == "-" + init = parser.instantiate_classes(cfg) + assert init.data.p1 == "-" def test_dataclass_stdlib_field(self, parser): parser.add_argument("--data", type=PydanticDataStdlibField) @@ -969,9 +972,9 @@ def test_field_init_false(self, parser): # not included in config object or its value is None.') parser.add_argument("--data", type=AttrsFieldInitFalse) - cfg = parser.parse_args(["--data", "{}"]) + cfg = parser.parse_args([]) help_str = get_parser_help(parser) assert "--data.p1" not in help_str - assert cfg.data == Namespace() - cfg = parser.instantiate_classes(cfg) - assert cfg.data.p1 == {} + assert cfg == Namespace() + init = parser.instantiate_classes(cfg) + assert init.data.p1 == {} diff --git a/jsonargparse_tests/test_deprecated.py b/jsonargparse_tests/test_deprecated.py index 41e544a2..da522cd7 100644 --- a/jsonargparse_tests/test_deprecated.py +++ b/jsonargparse_tests/test_deprecated.py @@ -1,5 +1,6 @@ from __future__ import annotations +import dataclasses import os import pathlib from calendar import Calendar @@ -10,6 +11,7 @@ from warnings import catch_warnings import pytest +import yaml from jsonargparse import ( CLI, @@ -32,7 +34,7 @@ shown_deprecation_warnings, usage_and_exit_error_handler, ) -from jsonargparse._optionals import jsonnet_support, url_support +from jsonargparse._optionals import docstring_parser_support, jsonnet_support, url_support from jsonargparse._util import argument_error from jsonargparse_tests.conftest import ( get_parser_help, @@ -40,6 +42,7 @@ skip_if_docstring_parser_unavailable, skip_if_requests_unavailable, ) +from jsonargparse_tests.test_dataclass_like import DataClassA from jsonargparse_tests.test_jsonnet import example_2_jsonnet @@ -575,3 +578,28 @@ def test_action_jsonnet_ext_vars(parser): assert 9 == len(cfg.jsonnet["records"]) assert "#8" == cfg.jsonnet["records"][-2]["ref"] assert 15.5 == cfg.jsonnet["records"][-2]["val"] + + +def test_add_dataclass_arguments(parser, subtests): + with catch_warnings(record=True) as w: + parser.add_dataclass_arguments(DataClassA, "a", default=DataClassA(), title="CustomA title") + assert_deprecation_warn( + w, + message="add_dataclass_arguments was deprecated", + code='parser.add_dataclass_arguments(DataClassA, "a", default=DataClassA(), title="CustomA title")', + ) + + with subtests.test("get_defaults"): + cfg = parser.get_defaults() + assert dataclasses.asdict(DataClassA()) == cfg["a"].as_dict() + dump = yaml.safe_load(parser.dump(cfg)) + assert dataclasses.asdict(DataClassA()) == dump["a"] + + with subtests.test("instantiate_classes"): + init = parser.instantiate_classes(cfg) + assert isinstance(init["a"], DataClassA) + + with subtests.test("docstrings in help"): + help_str = get_parser_help(parser) + if docstring_parser_support: + assert "CustomA title:" in help_str diff --git a/jsonargparse_tests/test_postponed_annotations.py b/jsonargparse_tests/test_postponed_annotations.py index afbdccb4..d7cee429 100644 --- a/jsonargparse_tests/test_postponed_annotations.py +++ b/jsonargparse_tests/test_postponed_annotations.py @@ -338,7 +338,7 @@ def test_get_types_dataclass_pep585(parser): types = get_types(Data585) list_int = List[int] if sys.version_info < (3, 9) else list[int] assert types == {"a": list_int, "b": str} - parser.add_dataclass_arguments(Data585, "data") + parser.add_class_arguments(Data585, "data") cfg = parser.parse_args(["--data.a=[1, 2]"]) assert cfg.data == Namespace(a=[1, 2], b="x") @@ -350,6 +350,6 @@ def __init__(self, b: Path_drw, **kwargs): # type: ignore[valid-type] def test_add_dataclass_with_init_pep585(parser, tmp_cwd): - parser.add_dataclass_arguments(DataWithInit585, "data") + parser.add_class_arguments(DataWithInit585, "data") cfg = parser.parse_args(["--data.a=[1, 2]", "--data.b=."]) assert cfg.data == Namespace(a=[1, 2], b=Path_drw("."))