From ae72a7e32197cc19026cc71faf8f9b98eecdfb1d Mon Sep 17 00:00:00 2001 From: Mauricio Villegas <5780272+mauvilsa@users.noreply.github.com> Date: Wed, 12 Aug 2026 08:30:33 +0200 Subject: [PATCH 1/6] Add --*.help and shtab completions for TypedDict keys --- CHANGELOG.rst | 18 ++++ DOCUMENTATION.rst | 30 +++++-- jsonargparse/_actions.py | 62 ++++++++------ jsonargparse/_completions.py | 15 +++- jsonargparse/_parameter_resolvers.py | 61 ++++++++----- jsonargparse/_typehints.py | 24 ++++-- jsonargparse_tests/test_shtab.py | 76 ++++++++++++++++- jsonargparse_tests/test_signatures.py | 47 +++++++++- jsonargparse_tests/test_typehints.py | 118 ++++++++++++++++++++++++++ 9 files changed, 384 insertions(+), 67 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 8dd43aa5..e59af18b 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -55,6 +55,20 @@ Added ``type[...]``, is now replaced by what it stands for: its PEP 696 ``default``, its constraints or its bound. Previously the value was accepted without any validation (`#953 `__). +- Arguments typed as a ``TypedDict`` now have a ``--*.help`` option that shows + the keys that are accepted, their types and their descriptions. It receives no + value, unless the ``TypedDict`` is in a union with other types that have a + help, in which case the value is the name of the typed dict (`#??? + `__). +- ``add_class_arguments`` now accepts a ``TypedDict``, adding one argument per + key, analogous to a dataclass. ``instantiate`` gives the corresponding dict + (`#??? `__). +- Descriptions of ``TypedDict`` keys taken from its docstring are now shown in + the help of ``**kwargs: Unpack[SomeTypedDict]`` parameters (`#??? + `__). +- ``shtab`` completion scripts now include the keys of a ``TypedDict`` + argument, e.g. ``--data.key``, and the values that these keys accept (`#??? + `__). Fixed ^^^^^ @@ -173,6 +187,10 @@ Fixed `__). - Missing deprecation warning for the ``--print_config`` to ``--print_%s`` change (`#955 `__). +- ``shtab`` completions of ``**kwargs: Unpack[SomeTypedDict]`` parameters + showing ``NotRequired[...]`` as the expected type and not completing the + values of the keys that are not required (`#??? + `__). Changed ^^^^^^^ diff --git a/DOCUMENTATION.rst b/DOCUMENTATION.rst index 09337828..748295eb 100644 --- a/DOCUMENTATION.rst +++ b/DOCUMENTATION.rst @@ -531,12 +531,19 @@ Some notes about this support are: - ``dict``, ``Mapping``, ``MutableMapping``, ``MappingProxyType``, ``OrderedDict``, and ``TypedDict`` are supported but only with ``str`` or ``int`` keys. ``Required`` and ``NotRequired`` are also supported for - fine-grained specification of required/optional ``TypedDict`` keys. - ``Unpack`` is supported with ``TypedDict`` for more precise ``**kwargs`` - typing as described in PEP `692 `__. - For more details see :ref:`dict-items`. A ``TypedDict`` can also be used as - the argument of ``type``, e.g. ``type[SomeTypedDict]``, in which case the - value is an import path to a class. Since ``TypedDict`` classes don't support + fine-grained specification of required/optional ``TypedDict`` keys. ``Unpack`` + is supported with ``TypedDict`` for more precise ``**kwargs`` typing as + described in PEP `692 `__. For more details + see :ref:`dict-items`. The keys that a ``TypedDict`` argument accepts are + shown by a ``--*.help`` option, e.g. ``--data.help``. This option receives no + value, unless the ``TypedDict`` is in a union with other types that have their + own help, in which case the value is the name of the typed dict, e.g. + ``--data.help SomeTypedDict``. A ``TypedDict`` is also accepted by + :meth:`add_class_arguments <.ArgumentParser.add_class_arguments>`, which adds + one argument per key and on :meth:`instantiate <.ArgumentParser.instantiate>` + gives the corresponding dict. A ``TypedDict`` can also be used as the argument + of ``type``, e.g. ``type[SomeTypedDict]``, in which case the value is an + import path to a class. Since ``TypedDict`` classes don't support ``issubclass``, the given class is accepted when it is structurally compatible, as specified in PEP `589 `__, i.e. it has all the keys of the expected ``TypedDict``, with the same types @@ -3383,6 +3390,17 @@ giving as guidance which of the subclasses accepts it. An example would be: $ example.py --cls other.module.SubclassA --cls.param2 Expected type: int; Accepted by subclasses: SubclassA +Analogously, for dataclass-like types and ``TypedDict``, the fields or keys are +completed, as well as the values that they accept, e.g.: + +.. code-block:: bash + + $ example.py --data. + --data.verbose --data.mode + $ example.py --data.verbose + Expected type: bool; 2/2 matched choices + true false + argcomplete ----------- diff --git a/jsonargparse/_actions.py b/jsonargparse/_actions.py index c254fc38..dfbf97c8 100644 --- a/jsonargparse/_actions.py +++ b/jsonargparse/_actions.py @@ -334,12 +334,6 @@ def check_type(self, value, parser): class _ActionHelpClassPath(NonParsingAction): sub_add_kwargs: dict[str, Any] = {} - @classmethod - def get_help_types(cls, typehint) -> tuple | None: - from ._typehints import get_subclass_or_closed_types - - return get_subclass_or_closed_types(typehint=typehint, also_lists=True, callable_return=True) - def __init__(self, typehint=None, **kwargs): if typehint is not None: self._typehint = typehint @@ -348,24 +342,33 @@ def __init__(self, typehint=None, **kwargs): super().__init__(**kwargs) def update_init_kwargs(self, kwargs): - from ._typehints import is_protocol + from ._typehints import get_help_types, is_protocol, is_typed_dict self._typehint = kwargs.pop("_typehint") - self._help_types = self.get_help_types(self._typehint) + self._help_types = get_help_types(self._typehint) assert self._help_types and all(isinstance(b, type) for b in self._help_types) - self._single_class = len(self._help_types) == 1 and is_subclasses_disabled(self._help_types[0]) + typed_dicts = [t for t in self._help_types if is_typed_dict(t)] + # a single type means that the help refers to it, so no value is expected + single_type = len(self._help_types) == 1 and (is_subclasses_disabled(self._help_types[0]) or bool(typed_dicts)) self._basename = iter_to_set_str(t.__name__ for t in self._help_types) if len(self._help_types) == 1: - kwargs["nargs"] = 0 if self._single_class else "?" + kwargs["nargs"] = 0 if single_type else "?" - if self._single_class: + if single_type: msg = "" else: kwargs["metavar"] = "CLASS_PATH_OR_NAME" self._kind = "subclass of" if any(is_protocol(b) for b in self._help_types): self._kind = "subclass or implementer of protocol" + if typed_dicts: + # a typed dict is given by name, since it doesn't accept a class path + if len(typed_dicts) == len(self._help_types): + kwargs["metavar"] = "NAME" + self._kind = "typed dict" + else: + self._kind = "class or typed dict" msg = f"the given {self._kind} " kwargs["default"] = SUPPRESS @@ -377,24 +380,31 @@ def __call__(self, *args, **kwargs): return type(self)(**kwargs) return self.print_help(args) + def resolve_help_type(self, value, option_string): + from ._typehints import implements_protocol, is_typed_dict, resolve_class_path_by_name + + if self.nargs == 0 or (self.nargs == "?" and value is None): + return self._help_types[0] + typed_dict = next((t for t in self._help_types if is_typed_dict(t) and t.__name__ == value), None) + if typed_dict: + return typed_dict + # typed dicts excluded since they don't have subclasses that a class path could refer to + class_types = tuple(t for t in self._help_types if not is_typed_dict(t)) + val_class = None + if class_types: + try: + val_class = import_object(resolve_class_path_by_name(class_types, value)) + except Exception as ex: + raise TypeError(f"{option_string}: {ex}") from ex + if not any(is_subclass(val_class, b) or implements_protocol(val_class, b) for b in class_types): + raise TypeError(f'{option_string}: "{value}" is not a {self._kind} {self._basename}') + return val_class + def print_help(self, call_args): - from ._typehints import ( - adapt_partial_callable_class, - implements_protocol, - resolve_class_path_by_name, - ) + from ._typehints import adapt_partial_callable_class parser, _, value, option_string = call_args - try: - if self.nargs == 0 or (self.nargs == "?" and value is None): - val_class = self._help_types[0] - else: - val_class = import_object(resolve_class_path_by_name(self._help_types, value)) - except Exception as ex: - raise TypeError(f"{option_string}: {ex}") from ex - - if not any(is_subclass(val_class, b) or implements_protocol(val_class, b) for b in self._help_types): - raise TypeError(f'{option_string}: Class "{value}" is not a {self._kind} {self._basename}') + val_class = self.resolve_help_type(value, option_string) dest = re.sub("\\.help$", "", self.dest) subparser = type(parser)(description=f"Help for {option_string}={get_import_path(val_class)}") val = Namespace(class_path=get_import_path(val_class)) diff --git a/jsonargparse/_completions.py b/jsonargparse/_completions.py index 71298e56..924fff18 100644 --- a/jsonargparse/_completions.py +++ b/jsonargparse/_completions.py @@ -27,9 +27,11 @@ callable_origin_types, get_all_subclass_paths, get_callable_return_type, + get_typed_dict_key_type, get_typehint_origin, is_single_subclass_or_closed_type, is_subclass, + is_typed_dict, type_to_str, ) from ._util import NoneType, Path, import_object, merge_config, unique @@ -358,9 +360,11 @@ def get_choices_state(typehint) -> tuple[list[str], bool, bool]: choices = add_subactions_and_get_subclass_choices(typehint, prefix, parser, skip, added_subclasses) return choices, True, False - if is_single_subclass_or_closed_type(typehint, origin) and is_subclasses_disabled(typehint): - # a closed type, e.g. a dataclass, only inlined as a group when not in a union, - # so its init args need to be added as options for them to be completed + if is_typed_dict(typehint) or ( + is_single_subclass_or_closed_type(typehint, origin) and is_subclasses_disabled(typehint) + ): + # a dataclass-like type is only inlined as a group when not in a union and a typed + # dict never is, so their init args or keys need to be added as options to complete them added_subclasses.add(typehint) add_subactions_and_get_subclass_choices(typehint, prefix, parser, skip, added_subclasses, closed_type=True) return [], False, True @@ -404,7 +408,8 @@ def add_subactions_and_get_subclass_choices( params = params[num_skip:] for param in params: if param.name not in skip: - init_args[param.name].append(param.annotation) + # the wrappers of typed dict keys only state requiredness, not the type to complete + init_args[param.name].append(get_typed_dict_key_type(param.annotation)) subclasses[param.name].append(name.rsplit(".", 1)[-1]) if prefix is not None: @@ -439,6 +444,8 @@ def get_help_class_choices(typehint) -> list[str]: for subtype in typehint.__args__: if inspect.isclass(subtype): choices.extend(get_help_class_choices(subtype)) + elif is_typed_dict(typehint): + choices = [typehint.__name__] # typed dicts don't accept a class path, only their name else: choices = get_all_subclass_paths(typehint) return choices diff --git a/jsonargparse/_parameter_resolvers.py b/jsonargparse/_parameter_resolvers.py index 5580dcad..14dc17e9 100644 --- a/jsonargparse/_parameter_resolvers.py +++ b/jsonargparse/_parameter_resolvers.py @@ -320,7 +320,8 @@ def replace_type_vars(annotation): param.annotation = replace_type_vars(param.annotation) -def unpack_typed_dict_kwargs(params: ParamList, kwargs_idx: int, logger=None) -> int: +def get_typed_dict_params(typed_dict, logger=None, **param_kwargs) -> ParamList: + """Parameters that correspond to the keys of a TypedDict.""" from ._typehints import ( NotRequired, get_typed_dict_annotations, @@ -328,33 +329,42 @@ def unpack_typed_dict_kwargs(params: ParamList, kwargs_idx: int, logger=None) -> not_required_types, ) + annotations = get_typed_dict_annotations(typed_dict, logger) + required_keys = get_typed_dict_required_keys(typed_dict, annotations) + doc_params = parse_docs(typed_dict, None, logger) + params = [] + for name, annotation in annotations.items(): + if name not in required_keys and get_typehint_origin(annotation) not in not_required_types: + # Mark optional keys (e.g. from total=False) as NotRequired so that they + # are added as non-required arguments. + annotation = NotRequired[annotation] + params.append( + ParamData( + name=name, + annotation=annotation, + default=inspect._empty, + kind=inspect._ParameterKind.KEYWORD_ONLY, + doc=doc_params.get(name), + **param_kwargs, + ) + ) + return params + + +def unpack_typed_dict_kwargs(params: ParamList, kwargs_idx: int, logger=None) -> int: kwargs = params[kwargs_idx] annotation = kwargs.annotation if is_unpack_typehint(annotation): params.pop(kwargs_idx) annotation_args: tuple = getattr(annotation, "__args__", ()) assert len(annotation_args) == 1, "Unpack requires a single type argument" - typed_dict = annotation_args[0] - dict_annotations = get_typed_dict_annotations(typed_dict, logger) - required_keys = get_typed_dict_required_keys(typed_dict, dict_annotations) - new_params = [] - for nm, annot in dict_annotations.items(): - if nm not in required_keys and get_typehint_origin(annot) not in not_required_types: - # Mark optional keys (e.g. from total=False) as NotRequired so that they - # are added as non-required arguments. - annot = NotRequired[annot] - new_params.append( - ParamData( - name=nm, - annotation=annot, - default=inspect._empty, - kind=inspect._ParameterKind.KEYWORD_ONLY, - doc=None, - component=kwargs.component, - parent=kwargs.parent, - origin=kwargs.origin, - ) - ) + new_params = get_typed_dict_params( + annotation_args[0], + logger, + component=kwargs.component, + parent=kwargs.parent, + origin=kwargs.origin, + ) # insert in-place assert kwargs_idx == len(params), "trailing params should yield a syntax error" params.extend(new_params) @@ -1144,8 +1154,13 @@ def get_signature_parameters( the parameters for ``__init__``. logger: Useful for debugging. Only logs at ``DEBUG`` level. """ - get_component_and_parent(function_or_class, method_or_property) # verify input + from ._typehints import is_typed_dict + logger = parse_logger(logger, "get_signature_parameters") + if method_or_property is None and is_typed_dict(function_or_class): + # a typed dict has no signature to inspect, its parameters correspond to its keys + return get_typed_dict_params(function_or_class, logger, component=function_or_class) + get_component_and_parent(function_or_class, method_or_property) # verify input params = None for get_parameters in [ get_parameters_from_pydantic_or_attrs, diff --git a/jsonargparse/_typehints.py b/jsonargparse/_typehints.py index 9c029ac4..0d272be6 100644 --- a/jsonargparse/_typehints.py +++ b/jsonargparse/_typehints.py @@ -407,7 +407,7 @@ def prepare_add_argument(args, kwargs, enable_path, container, logger, sub_add_k typehint = kwargs.pop("type") if args[0].startswith("--") and ActionTypeHint.supports_append(typehint): args = tuple(list(args) + [args[0] + "+"]) - if get_registered_type(typehint) is None and _ActionHelpClassPath.get_help_types(typehint): + if get_registered_type(typehint) is None and get_help_types(typehint): help_option = f"--{args[0]}.help" if args[0][0] != "-" else f"{args[0]}.help" help_action = container.add_argument(help_option, action=_ActionHelpClassPath(typehint=typehint)) if sub_add_kwargs: @@ -1028,6 +1028,10 @@ def resolve_module_annotations(module: str, annotations: dict, global_vars: dict return {k: resolve_forward_ref(v, global_vars) for k, v in annotations.items()} +def is_typed_dict(typehint) -> bool: + return type(typehint) in typed_dict_meta_types + + def get_typed_dict_annotations(typed_dict, logger=None) -> dict: from ._postponed_annotations import get_global_vars, update_module_global_vars @@ -1078,7 +1082,7 @@ def is_typed_dict_subtype(subtype, typed_dict, logger=None) -> bool: # TypedDicts don't support issubclass, so as specified in PEP 589 the check is done # structurally, i.e. the subtype must have all keys of the typed dict, with the same # types and requiredness. - if type(subtype) not in typed_dict_meta_types: + if not is_typed_dict(subtype): return False if subtype is typed_dict: return True @@ -1248,7 +1252,7 @@ def adapt_typehints( val = import_object(val) if typehint in {Type, type}: valid = isinstance(val, type) - elif type(subtypehints[0]) in typed_dict_meta_types: + elif is_typed_dict(subtypehints[0]): valid = is_typed_dict_subtype(val, subtypehints[0], logger) else: valid = is_subclass(val, subtypehints[0]) @@ -1399,7 +1403,7 @@ def adapt_typehints( else: kwargs["prev_val"] = None val[k] = adapt_typehints(v, subtypehints[1], **kwargs) - if type(typehint) in typed_dict_meta_types: + if is_typed_dict(typehint): dict_annotations = get_typed_dict_annotations(typehint, logger) required_keys = get_typed_dict_required_keys(typehint, dict_annotations) missing_keys = required_keys - val.keys() @@ -1930,6 +1934,16 @@ def get_subclass_or_closed_types(typehint, also_lists=False, callable_return=Fal return types or None +def is_single_help_type(typehint, typehint_origin): + return is_typed_dict(typehint) or is_single_subclass_or_closed_type(typehint, typehint_origin) + + +def get_help_types(typehint): + """Types in a type hint for which a --*.help option shows the accepted arguments.""" + types = tuple(yield_class_types(typehint, is_single=is_single_help_type, also_lists=True, callable_return=True)) + return types or None + + def get_subclass_names(typehint, callable_return=False): return tuple( t.__name__ @@ -2246,7 +2260,7 @@ def validate_subclass_spec_in_mapping(val, typehint, subtypehints, sub_add_kwarg """ if not get_parsing_setting("validate_subclass_spec_in_any") or not is_subclass_spec(val): return - if type(typehint) in typed_dict_meta_types: + if is_typed_dict(typehint): return if subtypehints is not None and not (subtypehints[1] == Any or isinstance(subtypehints[1], UnvalidatedType)): return diff --git a/jsonargparse_tests/test_shtab.py b/jsonargparse_tests/test_shtab.py index dc128b79..5b07f181 100644 --- a/jsonargparse_tests/test_shtab.py +++ b/jsonargparse_tests/test_shtab.py @@ -13,7 +13,7 @@ from importlib.util import find_spec from os import PathLike from pathlib import Path -from typing import Any, Callable, Literal, Optional, Union +from typing import Any, Callable, Literal, Optional, TypedDict, Union from unittest.mock import patch import pytest @@ -22,7 +22,7 @@ from jsonargparse._completions import get_shtab_script, norm_name from jsonargparse._optionals import pydantic_support from jsonargparse._parameter_resolvers import get_signature_parameters -from jsonargparse._typehints import type_to_str +from jsonargparse._typehints import Unpack, type_to_str from jsonargparse.typing import Path_drw, Path_fr from jsonargparse_tests.conftest import capture_logs, get_parse_args_stdout @@ -573,6 +573,78 @@ def test_bash_optional_dataclass_field_types(parser, subtests): ) +class AreaDict(TypedDict): + latitude: float + longitude: float + + +def test_bash_typed_dict_help_choices(parser): + parser.add_argument("--area", type=Union[AreaDict, Base]) + shtab_script = get_shtab_script(parser, "bash") + choices = get_bash_array(shtab_script, "_shtab_tool___area_help_choices") + assert choices == ["AreaDict", f"{__name__}.Base", f"{__name__}.SubA", f"{__name__}.SubB"] + + +class OptionsDict(TypedDict, total=False): + verbose: bool + mode: AXEnum + + +@pytest.mark.parametrize("options_type", [OptionsDict, Optional[OptionsDict]]) +def test_bash_typed_dict_keys(parser, options_type): + parser.add_argument("--opts", type=options_type) + shtab_script = get_shtab_script(parser, "bash") + options = get_bash_array(shtab_script, "_shtab_tool_option_strings") + assert {"--opts", "--opts.verbose", "--opts.mode"}.issubset(options) + + +def test_bash_typed_dict_key_types(parser, subtests): + parser.add_argument("--opts", type=OptionsDict) + assert_bash_typehint_completions( + subtests, + parser, + [ + ("opts.verbose", bool, "", ["true", "false"], "2/2"), + ("opts.mode", AXEnum, "X", ["XY", "XZ"], "2/3"), + ], + ) + + +def test_bash_typed_dict_in_union_key_types(parser, subtests): + parser.add_argument("--opts", type=Union[OptionsDict, Base]) + assert_bash_typehint_completions( + subtests, + parser, + [ + ("opts.verbose", bool, "", ["true", "false"], "2/2"), + ("opts.p1", int, "", [], "Base, SubA, SubB"), + ], + ) + + +if Unpack: + + class UnpackOptionsClass: + def __init__(self, **kwargs: Unpack[OptionsDict]): + pass # pragma: no cover + + +@pytest.mark.skipif(not Unpack, reason="Unpack introduced in python 3.11 or backported in typing_extensions") +def test_bash_unpack_typed_dict_key_types(parser, subtests): + parser.add_argument("--cls", type=UnpackOptionsClass) + shtab_script = get_shtab_script(parser, "bash") + options = get_bash_array(shtab_script, "_shtab_tool_option_strings") + assert {"--cls", "--cls.verbose", "--cls.mode"}.issubset(options) + assert_bash_typehint_completions( + subtests, + shtab_script, + [ + ("cls.verbose", bool, "", ["true", "false"], "UnpackOptionsClass"), + ("cls.mode", AXEnum, "X", ["XY", "XZ"], "UnpackOptionsClass"), + ], + ) + + def test_bash_callable_return_class(parser, subtests): parser.add_argument("--cls", type=Callable[[int], Base]) shtab_script = get_shtab_script(parser, "bash") diff --git a/jsonargparse_tests/test_signatures.py b/jsonargparse_tests/test_signatures.py index 5c8bb6e1..4008e271 100644 --- a/jsonargparse_tests/test_signatures.py +++ b/jsonargparse_tests/test_signatures.py @@ -4,7 +4,7 @@ import json import sys from pathlib import Path -from typing import Any, Dict, Generic, List, Optional, Tuple, TypeVar, Union +from typing import Any, Dict, Generic, List, Optional, Tuple, TypedDict, TypeVar, Union from unittest.mock import patch import pytest @@ -564,6 +564,51 @@ def test_add_class_and_action_parser(parser, subparser): assert init.nested.deep.leaf.p2 == "x" +class DataTypedDict(TypedDict): + """Typed dict short description. + + Args: + p1: p1 description + p2: p2 description + """ + + p1: int + p2: str + + +class NotTotalTypedDict(TypedDict, total=False): + p1: int + + +def test_add_class_typed_dict(parser): + added = parser.add_class_arguments(DataTypedDict, "data") + assert added == ["data.p1", "data.p2"] + cfg = parser.parse_args(["--data.p1=1", "--data.p2=x"]) + assert cfg.data == Namespace(p1=1, p2="x") + assert parser.instantiate(cfg).data == {"p1": 1, "p2": "x"} + assert json_or_yaml_load(parser.dump(cfg)) == {"data": {"p1": 1, "p2": "x"}} + with pytest.raises(ArgumentError, match="the following arguments are required: data.p1"): + parser.parse_args([]) + + +def test_add_class_typed_dict_not_total(parser): + parser.add_class_arguments(NotTotalTypedDict, "data") + cfg = parser.parse_args([]) + assert "data" not in cfg + assert parser.instantiate(cfg).data == {} + cfg = parser.parse_args(["--data.p1=2"]) + assert parser.instantiate(cfg).data == {"p1": 2} + + +@skip_if_docstring_parser_unavailable +def test_add_class_typed_dict_help(parser): + parser.add_class_arguments(DataTypedDict, "data") + help_str = get_parser_help(parser) + assert "Typed dict short description" in help_str + assert "p1 description (required, type: int)" in help_str + assert "p2 description (required, type: str)" in help_str + + # add_method_arguments tests diff --git a/jsonargparse_tests/test_typehints.py b/jsonargparse_tests/test_typehints.py index 6e8c9f28..b4a92bfd 100644 --- a/jsonargparse_tests/test_typehints.py +++ b/jsonargparse_tests/test_typehints.py @@ -78,6 +78,7 @@ json_or_yaml_dump, json_or_yaml_load, parser_modes, + skip_if_docstring_parser_unavailable, ) @@ -946,6 +947,110 @@ def test_typeddict_with_required_arg(parser): ctx.match("Expected a ") +# TypedDict --*.help tests + + +class HelpTypedDict(TypedDict): + """Data for the help. + + Args: + a: the a + b: the b + """ + + a: int + b: str + + +class HelpNotTotalTypedDict(TypedDict, total=False): + x: float + + +def test_typeddict_help(parser): + parser.add_argument("--data", type=HelpTypedDict) + help_str = get_parser_help(parser) + assert "--data.help" in help_str + assert "Show the help for HelpTypedDict and exit" in help_str + assert "CLASS_PATH_OR_NAME" not in help_str + help_str = get_parse_args_stdout(parser, ["--data.help"]) + assert f"Help for --data.help={__name__}.HelpTypedDict" in help_str + assert "--data.a A" in help_str + assert "(required, type: int)" in help_str + assert "--data.b B" in help_str + assert "(required, type: str)" in help_str + + +@skip_if_docstring_parser_unavailable +def test_typeddict_help_docstrings(parser): + parser.add_argument("--data", type=HelpTypedDict) + help_str = get_parse_args_stdout(parser, ["--data.help"]) + assert "Data for the help:" in help_str + assert "the a (required, type: int)" in help_str + assert "the b (required, type: str)" in help_str + + +def test_optional_typeddict_help_not_required_keys(parser): + parser.add_argument("--data", type=Optional[HelpNotTotalTypedDict]) + assert "--data.help" in get_parser_help(parser) + help_str = get_parse_args_stdout(parser, ["--data.help"]) + assert f"Help for --data.help={__name__}.HelpNotTotalTypedDict" in help_str + assert "--data.x X" in help_str + assert "(type: float)" in help_str + + +def test_list_typeddict_help(parser): + parser.add_argument("--data", type=List[HelpTypedDict]) + help_str = get_parse_args_stdout(parser, ["--data.help"]) + assert f"Help for --data.help={__name__}.HelpTypedDict" in help_str + assert "--data.a A" in help_str + + +class HelpTypedDictClass: + def __init__(self, data: Optional[HelpTypedDict] = None): + pass # pragma: no cover + + +def test_typeddict_class_parameter_help(parser): + parser.add_class_arguments(HelpTypedDictClass, "cls") + assert "--cls.data.help" in get_parser_help(parser) + help_str = get_parse_args_stdout(parser, ["--cls.data.help"]) + assert f"Help for --cls.data.help={__name__}.HelpTypedDict" in help_str + assert "--cls.data.a A" in help_str + assert "--cls.data.b B" in help_str + + +def test_typeddict_union_typeddicts_help(parser): + parser.add_argument("--val", type=Union[HelpTypedDict, HelpNotTotalTypedDict]) + help_str = get_parser_help(parser) + assert "--val.help NAME" in help_str + assert "Show the help for the given typed dict" in help_str + assert "HelpTypedDict" in help_str + assert "HelpNotTotalTypedDict" in help_str + help_str = get_parse_args_stdout(parser, ["--val.help=HelpNotTotalTypedDict"]) + assert f"Help for --val.help={__name__}.HelpNotTotalTypedDict" in help_str + assert "--val.x X" in help_str + + +def test_typeddict_union_class_help(parser): + parser.add_argument("--val", type=Union[HelpTypedDict, BaseC]) + help_str = get_parser_help(parser) + assert "--val.help CLASS_PATH_OR_NAME" in help_str + assert "Show the help for the given class or typed dict" in help_str + help_str = get_parse_args_stdout(parser, ["--val.help=HelpTypedDict"]) + assert f"Help for --val.help={__name__}.HelpTypedDict" in help_str + assert "--val.a A" in help_str + help_str = get_parse_args_stdout(parser, [f"--val.help={__name__}.SubC"]) + assert f"Help for --val.help={__name__}.SubC" in help_str + assert "--val.p P" in help_str + + +def test_typeddict_union_help_unexpected_name(parser): + parser.add_argument("--val", type=Union[HelpTypedDict, HelpNotTotalTypedDict]) + with pytest.raises(ArgumentError) as ctx: + parser.parse_args(["--val.help=Unexpected"]) + ctx.match('"Unexpected" is not a typed dict') + + # type[TypedDict] tests. TypedDicts don't support issubclass, so the check is structural. @@ -1405,6 +1510,10 @@ class MyTestInheritedUnpackClass(UnpackClass): def __init__(self, **kwargs) -> None: super().__init__(**kwargs) # pragma: no cover + class UnpackDocumentedClass: + def __init__(self, **kwargs: Unpack[HelpTypedDict]) -> None: + pass # pragma: no cover + @pytest.mark.skipif(not Unpack, reason="Unpack introduced in python 3.11 or backported in typing_extensions") @pytest.mark.parametrize(["init_args"], [({"a": 1},), ({"a": 2, "b": None},), ({"a": 3, "b": 1},)]) @@ -1438,6 +1547,15 @@ def test_unpack_typeddict_wrappers_removed_from_help(parser): assert "(type: int)" in help_str +@skip_if_docstring_parser_unavailable +@pytest.mark.skipif(not Unpack, reason="Unpack introduced in python 3.11 or backported in typing_extensions") +def test_unpack_typeddict_key_descriptions_in_help(parser): + parser.add_class_arguments(UnpackDocumentedClass, "cls") + help_str = get_parser_help(parser) + assert "the a (required, type: int)" in help_str + assert "the b (required, type: str)" in help_str + + @pytest.mark.skipif(not Unpack, reason="Unpack introduced in python 3.11 or backported in typing_extensions") @pytest.mark.parametrize(["init_args"], [({"a": 1},), ({"a": 2, "b": None},), ({"a": 3, "b": 1},)]) def test_valid_inherited_unpack_typeddict(parser, init_args): From 23fff254730c98c6cfde3aaf8f706299d4200bd1 Mon Sep 17 00:00:00 2001 From: Mauricio Villegas <5780272+mauvilsa@users.noreply.github.com> Date: Fri, 14 Aug 2026 11:39:12 +0200 Subject: [PATCH 2/6] Derive a type from the value to dump Any and Unvalidated values --- CHANGELOG.rst | 14 ++- DOCUMENTATION.rst | 22 +++-- jsonargparse/_typehints.py | 72 ++++++++++----- jsonargparse_tests/test_typehints.py | 133 ++++++++++++++++++++++++--- 4 files changed, 192 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index e59af18b..4d25b29a 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -98,11 +98,15 @@ Fixed - Signature parameters with a pydantic type nested in a container, e.g. ``list[HttpUrl]``, being skipped (`#948 `__). -- ``dump``, and thus ``--print_config``, failing when the value of an ``Any`` - typed argument is a class instance that the config format can't represent. Now - these values are serialized as an import path, or as a message that says that - it was not serializable, see :ref:`unvalidated-types` (`#948 - `__). +- ``dump``, and thus ``--print_config``, failing when the value of an ``Any`` or + ``Unvalidated<...>`` typed argument is of a type that the config format does + not represent, e.g. a class instance, or a ``set`` when the format is json. + Now a type is derived from the value and used to serialize it, class instances + are serialized as an import path or as a message that says that it was not + serializable, and a warning is raised when the dumped value does not + round-trip, see :ref:`unvalidated-types` (`#948 + `__, `#??? + `__). - ``AssertionError`` without a message when adding an argument typed as a subscripted user defined generic class, e.g. ``Optional[Strategy[T]]`` (`#950 `__). diff --git a/DOCUMENTATION.rst b/DOCUMENTATION.rst index 748295eb..58f2f26f 100644 --- a/DOCUMENTATION.rst +++ b/DOCUMENTATION.rst @@ -733,13 +733,21 @@ must still be a list, though its items are not validated. Likewise, in a ``Union`` only the subtypes that can't be validated accept any value, the others are still validated as usual. -Since there is no type to serialize with, a value of one of these parameters -that a config format can't represent, e.g. a default that is an arbitrary -object, is serialized in :meth:`dump <.ArgumentParser.dump>` and -``--print_config`` the same as the instances given for a :ref:`subclass type -`. That is, as an import path when the value can be imported back, -and otherwise as a message that says that it was not serializable, in which case -a warning is also raised. The same applies to arguments typed as ``Any``. +Since there is no type to serialize with, in :meth:`dump <.ArgumentParser.dump>` +and ``--print_config`` a type is derived from the value itself, so that the +value is serialized the same as it would be for an argument of that type. A +value of a type that jsonargparse doesn't support, e.g. a default that is an +arbitrary object, is serialized the same as the instances given for a +:ref:`subclass type `. That is, as an import path when the value +can be imported back, and otherwise as a message that says that it was not +serializable, in which case a warning is also raised. + +Parsing a dump back has no type to validate with either, so only the values that +the config formats represent round-trip. For instance, a ``set`` is serialized +as a list and parses back as a list, and an ``Enum`` member is serialized as its +name and parses back as a string. A warning is raised for each dumped value that +loses its type this way. All of the above equally applies to arguments typed as +``Any``. .. _restricted-numbers: diff --git a/jsonargparse/_typehints.py b/jsonargparse/_typehints.py index 0d272be6..c8ec30e2 100644 --- a/jsonargparse/_typehints.py +++ b/jsonargparse/_typehints.py @@ -12,7 +12,6 @@ from contextlib import contextmanager, suppress from contextvars import ContextVar from copy import deepcopy -from datetime import date, datetime from enum import Enum from functools import partial, reduce from importlib import import_module @@ -1185,13 +1184,14 @@ def adapt_typehints( if typehint == Any or isinstance(typehint, UnvalidatedType): type_val = type(val) if get_registered_type(type_val) or is_subclass(type_val, Enum): - val = adapt_typehints(val, type_val, **adapt_kwargs) + if not serialize: # when serializing this is done by serialize_unvalidated + val = adapt_typehints(val, type_val, **adapt_kwargs) elif isinstance(val, str): with suppress(*get_loader_exceptions()): val, _ = parse_value_or_config(val, enable_path=False, simple_types=True) val = adapt_classes_any(val, typehint, serialize, instantiate_classes, sub_add_kwargs, logger) if serialize: - val = serialize_unvalidated(val) + val = serialize_unvalidated(val, adapt_kwargs) # Literal elif typehint_origin in literal_types: @@ -2568,34 +2568,62 @@ def serialize_class_instance(val): return val -# The types that the config formats represent natively. Values of any other type require a -# serializer, which for the types that are not validated there is none, see serialize_unvalidated. -representable_types = (NoneType, bool, int, float, str, bytes, date, datetime) +def typehint_from_value(val): + """Derives a type hint from a value, so that adapt_typehints is able to serialize it. + Containers are derived as a type hint of ``Any`` items, so that the items are + serialized the same as the value itself. ``None`` is returned for the values + that no supported or registered type represents. + """ + if isinstance(val, dict): + return Dict[Any, Any] + if isinstance(val, list): + return List[Any] + if isinstance(val, tuple): + return Tuple[Any, ...] + if isinstance(val, (set, frozenset)): + return Set[Any] + type_val = type(val) + if type_val in leaf_types or get_registered_type(type_val) or is_subclass(type_val, Enum): + return type_val + return None + + +def serialize_unvalidated(val, adapt_kwargs): + """Serializes the value of a type that is not validated. -def serialize_unvalidated(val): - """Serializes the class instances in the value of a type that is not validated. + Values of an Any or Unvalidated type don't have a type hint to serialize + them with, so one is derived from the value itself and the serialization is + delegated to adapt_typehints. Values that no type represents are serialized + the same as the instances given for a subclass type, i.e. as an import path + when the value can be imported back, otherwise as a message that says that it + was not serializable. - Values of an Any or Unvalidated type don't have a serializer, so an instance - that a config format can't represent would make dump fail. Instead they are - serialized the same as the instances given for a subclass type, i.e. as an - import path when the value can be imported back, otherwise as a message that - says that it was not serializable. + Parsing back has no type hint either, thus a value only round-trips when the + config format represents its type. A warning is given when it doesn't. """ if isinstance(val, Namespace): # e.g. a subclass spec that adapt_classes_any already serialized for key, subval in val.items(branches=True, nested=False): - val[key] = serialize_unvalidated(subval) + val[key] = serialize_unvalidated(subval, adapt_kwargs) return val + typehint = typehint_from_value(val) + if typehint is None: + return serialize_class_instance(val) if isinstance(val, dict): - return {k: serialize_unvalidated(v) for k, v in val.items()} - if isinstance(val, (list, tuple)): - return [serialize_unvalidated(v) for v in val] - if isinstance(val, (set, frozenset)): - return {serialize_unvalidated(v) for v in val} - if isinstance(val, representable_types): - return val - return serialize_class_instance(val) + adapt_val = dict(val) # adapt_typehints serializes the items in place, so give it a copy + elif isinstance(val, list): + adapt_val = list(val) + else: + adapt_val = val + serialized = adapt_typehints(adapt_val, typehint, **adapt_kwargs) + if type(serialized) is not type(val): + warning( + f"Dump of a value that does not round-trip: a {type(val).__name__} is serialized as " + f"{type(serialized).__name__} and, since the type is not validated, parsing it back " + f"gives a {type(serialized).__name__}. Value: {val}" + ) + return serialized def callable_instances(cls: type): diff --git a/jsonargparse_tests/test_typehints.py b/jsonargparse_tests/test_typehints.py index b4a92bfd..2579e59f 100644 --- a/jsonargparse_tests/test_typehints.py +++ b/jsonargparse_tests/test_typehints.py @@ -9,6 +9,7 @@ import time import uuid from collections import OrderedDict, abc, deque +from contextlib import contextmanager from dataclasses import dataclass, field from datetime import date from enum import Enum @@ -43,7 +44,7 @@ Union, ) from unittest import mock -from warnings import catch_warnings +from warnings import catch_warnings, simplefilter import pytest @@ -60,7 +61,6 @@ is_optional, is_typed_dict_subtype, replace_unvalidatable_typehints, - serialize_unvalidated, type_to_str, ) from jsonargparse._util import get_import_path @@ -79,6 +79,7 @@ json_or_yaml_load, parser_modes, skip_if_docstring_parser_unavailable, + skip_if_no_pyyaml, ) @@ -239,10 +240,28 @@ def test_type_any(parser): assert "[[[" == parser.parse_args(["--any=[[["]).any +@contextmanager +def assert_dump_warnings(*expected): + """Asserts that the dump gives exactly one warning containing each of the given fragments.""" + with catch_warnings(record=True) as recorded: + simplefilter("always") # otherwise repeated identical warnings are only recorded once + yield + messages = [str(w.message) for w in recorded] + for fragment in set(expected): + assert sum(1 for m in messages if fragment in m) == expected.count(fragment), f"{fragment!r} in {messages}" + assert len(messages) == len(expected), messages + + +def serialized_as(from_type, to_type): + return f"a {from_type} is serialized as {to_type}" + + def test_type_any_dump(parser): parser.add_argument("--any", type=Any, default=EnumABC.B) cfg = parser.parse_args([]) - assert {"any": "B"} == json_or_yaml_load(parser.dump(cfg)) + with assert_dump_warnings(serialized_as("EnumABC", "str")): + dump = parser.dump(cfg) + assert {"any": "B"} == json_or_yaml_load(dump) class NotSerializable: @@ -261,12 +280,11 @@ def test_type_any_dump_not_serializable(parser): parser.add_argument("--items", type=Any, default=[NotSerializable(), 1]) parser.add_argument("--nested", type=Any, default={"a": (NotSerializable(),)}) cfg = parser.parse_args([]) - with catch_warnings(record=True) as w: + with assert_dump_warnings(*[unable_to_serialize] * 3, serialized_as("tuple", "list")): dump = json_or_yaml_load(parser.dump(cfg)) assert dump["any"] == unable_to_serialize assert dump["items"] == [unable_to_serialize, 1] assert dump["nested"] == {"a": [unable_to_serialize]} - assert unable_to_serialize in str(w[0].message) def test_type_any_dump_importable(parser): @@ -280,17 +298,102 @@ def test_type_any_dump_importable(parser): } -def test_serialize_unvalidated_containers(): +def test_type_any_dump_containers(parser): + # a type hint is derived from the value, so the containers are serialized as any + # other container and their items the same as any other unvalidated value import_path = f"{__name__}.not_serializable" - # the container types that a config format represents are kept, only the items serialized - assert serialize_unvalidated({"a": not_serializable}) == {"a": import_path} - assert serialize_unvalidated([not_serializable]) == [import_path] - assert serialize_unvalidated((not_serializable,)) == [import_path] - assert serialize_unvalidated({not_serializable}) == {import_path} - assert serialize_unvalidated(frozenset({not_serializable})) == {import_path} - # values that a config format represents natively are left as is - representable = [1, "a", 2.3, True, None, date(2020, 1, 2)] - assert serialize_unvalidated(representable) == representable + parser.add_argument("--dict", type=Any, default={"a": not_serializable}) + parser.add_argument("--list", type=Any, default=[not_serializable]) + parser.add_argument("--tuple", type=Any, default=(not_serializable,)) + parser.add_argument("--set", type=Any, default={not_serializable}) + parser.add_argument("--frozenset", type=Any, default=frozenset({not_serializable})) + cfg = parser.parse_args([]) + lost_types = [serialized_as(t, "list") for t in ["tuple", "set", "frozenset"]] + with assert_dump_warnings(*lost_types): + dump = json_or_yaml_load(parser.dump(cfg)) + assert dump == { + # the containers that a config format represents are kept + "dict": {"a": import_path}, + "list": [import_path], + # the ones it doesn't represent become a list + "tuple": [import_path], + "set": [import_path], + "frozenset": [import_path], + } + + +@skip_if_no_pyyaml +def test_type_any_dump_non_string_dict_keys(parser): + # the keys of a dict are not validated either, so they are not coerced to str + parser.add_argument("--any", type=Any, default={1: "a"}) + cfg = parser.parse_args([]) + assert parser.dump(cfg, format="yaml") == "any:\n 1: a\n" + + +def test_type_any_dump_set(parser): + # a set is not representable by the config formats, so it is dumped as a list + parser.add_argument("--set", type=Any, default={1}) + parser.add_argument("--frozen", type=Any, default=frozenset({2})) + parser.add_argument("--nested", type=Any, default={"a": {3}}) + cfg = parser.parse_args([]) + lost_types = [serialized_as("set", "list")] * 2 + [serialized_as("frozenset", "list")] + with assert_dump_warnings(*lost_types): + dump = json_or_yaml_load(parser.dump(cfg)) + assert dump == {"set": [1], "frozen": [2], "nested": {"a": [3]}} + + +def test_type_any_dump_registered_type(parser): + # registered types serialize the same as when they are the type of the argument + parser.add_argument("--path", type=Any, default=Path_fr(__file__)) + parser.add_argument("--bytes", type=Any, default=b"ab") + cfg = parser.parse_args([]) + with assert_dump_warnings(serialized_as("Path_fr", "str"), serialized_as("bytes", "str")): + dump = json_or_yaml_load(parser.dump(cfg)) + assert dump == {"path": __file__, "bytes": "YWI="} + + +def test_type_any_dump_date_not_serializable(parser): + # the loaders don't parse timestamps, so a date is not a type that the config + # formats represent, even though yaml is able to write one + parser.add_argument("--date", type=Any, default=date(2020, 1, 2)) + cfg = parser.parse_args([]) + with assert_dump_warnings("Unable to serialize instance 2020-01-02"): + dump = json_or_yaml_load(parser.dump(cfg)) + assert dump == {"date": "Unable to serialize instance 2020-01-02"} + + +def test_type_any_dump_not_round_trippable_warns(parser): + # there is no type hint to rebuild the value with, so warn when the type is lost + parser.add_argument("--set", type=Any, default={1}) + parser.add_argument("--enum", type=Any, default=EnumABC.B) + cfg = parser.parse_args([]) + with catch_warnings(record=True) as w: + parser.dump(cfg) + messages = [str(x.message) for x in w] + assert len(messages) == 2 + assert all("does not round-trip" in m for m in messages) + assert any(serialized_as("set", "list") in m and "Value: {1}" in m for m in messages) + assert any(serialized_as("EnumABC", "str") in m and "Value: EnumABC.B" in m for m in messages) + + +def test_type_any_dump_round_trippable_no_warn(parser): + # the values that the config formats represent parse back the same, so no warning + parser.add_argument("--any", type=Any, default={"a": [1, 2.3, "b", True, None]}) + cfg = parser.parse_args([]) + with assert_dump_warnings(): + dump = parser.dump(cfg) + assert json_or_yaml_load(dump) == {"any": {"a": [1, 2.3, "b", True, None]}} + + +def test_type_any_dump_does_not_modify_config(parser): + # serializing must not replace the items of the containers that the value is made of + default = ({"a": {1}},) + parser.add_argument("--any", type=Any, default=default) + cfg = parser.parse_args([]) + with assert_dump_warnings(serialized_as("tuple", "list"), serialized_as("set", "list")): + parser.dump(cfg) + assert cfg.any == default + assert default == ({"a": {1}},) def test_type_typehint_without_arg(parser): From c6a9d8a9da7eefd71ff0f7eef3b5643f04d9c1b4 Mon Sep 17 00:00:00 2001 From: Mauricio Villegas <5780272+mauvilsa@users.noreply.github.com> Date: Sat, 15 Aug 2026 10:35:46 +0200 Subject: [PATCH 3/6] Resolve postponed annotations of methods using the class namespace --- CHANGELOG.rst | 3 + DOCUMENTATION.rst | 4 +- jsonargparse/_postponed_annotations.py | 51 +++++- .../test_postponed_annotations.py | 158 +++++++++++++++++- 4 files changed, 209 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 4d25b29a..bb83f62d 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -195,6 +195,9 @@ Fixed showing ``NotRequired[...]`` as the expected type and not completing the values of the keys that are not required (`#??? `__). +- Postponed annotations of a method not resolving names that are defined in the + body of its class. Now the namespace of the class that defines the method is + used as locals (`#??? `__). Changed ^^^^^^^ diff --git a/DOCUMENTATION.rst b/DOCUMENTATION.rst index 58f2f26f..94b54599 100644 --- a/DOCUMENTATION.rst +++ b/DOCUMENTATION.rst @@ -515,7 +515,9 @@ Some notes about this support are: - Types that use components imported inside ``TYPE_CHECKING`` blocks are supported. -- Resolving of forward references in types is supported. +- Resolving of forward references in types is supported. This includes names + that are only defined in the body of the class that owns the method, e.g. a + nested class referred to without qualifying it. - Fully supported types are: ``str``, ``bool`` (more details in :ref:`boolean-arguments`), ``int``, ``float``, ``Decimal``, ``complex``, diff --git a/jsonargparse/_postponed_annotations.py b/jsonargparse/_postponed_annotations.py index 1f8d5203..689c4f50 100644 --- a/jsonargparse/_postponed_annotations.py +++ b/jsonargparse/_postponed_annotations.py @@ -5,7 +5,8 @@ import textwrap from dataclasses import is_dataclass from importlib import import_module -from typing import Any, ForwardRef, TypeAlias, Union, get_type_hints +from types import UnionType +from typing import Any, ForwardRef, TypeAlias, TypeVar, Union, get_type_hints from ._typehints import mapping_origin_types, sequence_origin_types, tuple_set_origin_types from ._util import get_typehint_origin @@ -268,10 +269,48 @@ def get_global_vars(obj: Any, logger: logging.Logger | None) -> dict: return global_vars -def get_types(obj: Any, logger: logging.Logger | None = None) -> dict: +def is_type_like(value: Any) -> bool: + """Whether a value could be used as a type annotation.""" + from ._optionals import is_alias_type + + return isinstance(value, (type, TypeVar, UnionType)) or hasattr(value, "__origin__") or bool(is_alias_type(value)) + + +def get_owner_class(component: Any) -> type | None: + """Returns the class in whose body a method is defined, resolved from its qualified name.""" + qualname = getattr(component, "__qualname__", "") + module = sys.modules.get(getattr(component, "__module__", None)) # type: ignore[arg-type] + parts = qualname.split(".")[:-1] + if not parts or "" in parts or module is None: + return None + owner: Any = module + for part in parts: + owner = getattr(owner, part, None) + if owner is None: + return None + return owner if inspect.isclass(owner) else None + + +def get_local_vars(owner: Any) -> dict: + """Returns the type-like names defined in the body of a class. + + The annotations of a method are evaluated in the scope of the body of the + class that defines it, so names defined there, e.g. a nested class, must be + resolvable. Only type-like values are collected, so that unrelated class + attributes never shadow a module global. + """ + if not inspect.isclass(owner): # None when the method has no owner, i.e. a plain function + return {} + return {key: value for key, value in vars(owner).items() if is_type_like(value)} + + +def get_types(obj: Any, logger: logging.Logger | None = None, parent: Any = None) -> dict: global_vars = get_global_vars(obj, logger) + # Locals are only needed for methods. For a class get_type_hints already uses as locals + # the namespace of each of the bases that the annotations come from. + local_vars = None if inspect.isclass(obj) else get_local_vars(get_owner_class(obj) or parent) try: - types = get_type_hints(obj, global_vars) + types = get_type_hints(obj, global_vars, local_vars) except Exception as ex1: types = ex1 if not isinstance(types, Exception) and all(not type_requires_eval(t) for t in types.values()): @@ -290,6 +329,7 @@ def get_types(obj: Any, logger: logging.Logger | None = None) -> dict: aliases = __builtins__.copy() # type: ignore[attr-defined] aliases.update(global_vars) + aliases.update(local_vars or {}) ex = None if isinstance(types, Exception): ex = types @@ -318,7 +358,7 @@ def evaluate_postponed_annotations(params, component, parent, logger): if is_dataclass(parent) and component.__name__ == "__init__": types = get_types(parent, logger) else: - types = get_types(component, logger) + types = get_types(component, logger, parent) except Exception as ex: logger.debug(f"Unable to evaluate types for {component}", exc_info=ex) return @@ -335,8 +375,9 @@ def get_return_type(component, logger=None): return_type = inspect.signature(component).return_annotation if type_requires_eval(return_type): global_vars = get_global_vars(component, logger) + local_vars = get_local_vars(get_owner_class(component)) try: - return_type = get_type_hints(component, global_vars)["return"] + return_type = get_type_hints(component, global_vars, local_vars)["return"] except Exception as ex: if logger: logger.debug(f"Unable to evaluate types for {component}", exc_info=ex) diff --git a/jsonargparse_tests/test_postponed_annotations.py b/jsonargparse_tests/test_postponed_annotations.py index abb6ccae..52cc5b82 100644 --- a/jsonargparse_tests/test_postponed_annotations.py +++ b/jsonargparse_tests/test_postponed_annotations.py @@ -9,7 +9,7 @@ from collections.abc import Callable from textwrap import dedent from types import GenericAlias, SimpleNamespace, UnionType -from typing import TYPE_CHECKING, Dict, ForwardRef, List, Optional, Tuple, Type, TypedDict, Union +from typing import TYPE_CHECKING, Dict, ForwardRef, List, Optional, Protocol, Tuple, Type, TypedDict, Union from unittest.mock import patch import pytest @@ -26,6 +26,8 @@ _enrich_globals_for_string_forward_refs, evaluate_postponed_annotations, get_global_vars, + get_owner_class, + get_return_type, get_types, type_requires_eval, ) @@ -594,6 +596,160 @@ def test_get_types_type_checking_dataclass_init_forward_ref(): assert types == {"p1": int, "p2": Optional[xml.dom.Node], "return": type(None)} +class ClassScopeNestedType: + @dataclasses.dataclass + class Params: + temperature: float = 0.0 + + def __init__(self, params: Optional[Params] = None): + self.params = params # pragma: no cover + + +def test_get_types_class_scope_nested_class(): + types = get_types(ClassScopeNestedType.__init__) + assert types == {"params": Optional[ClassScopeNestedType.Params]} + + +def test_parser_class_scope_nested_class(parser): + parser.add_class_arguments(ClassScopeNestedType, "o", sub_configs=True) + cfg = parser.parse_args(['--o.params={"temperature": 0.5}']) + assert cfg.o.params == Namespace(temperature=0.5) + with pytest.raises(ArgumentError, match="Option 'nonexistent' is not accepted"): + parser.parse_args(['--o.params={"nonexistent": 1}']) + + +def test_help_class_scope_nested_class(parser): + parser.add_class_arguments(ClassScopeNestedType, "o", sub_configs=True) + help_str = get_parser_help(parser) + assert "Unvalidated" not in help_str + assert f"type: {type_to_str(Optional[ClassScopeNestedType.Params])}" in help_str + + +class ClassScopeNestedTypeBase: + @dataclasses.dataclass + class Params: + temperature: float = 0.0 + + def __init__(self, params: Optional[Params] = None): + self.params = params # pragma: no cover + + +class ClassScopeNestedTypeSub(ClassScopeNestedTypeBase): + """Inherits the __init__ whose annotations are in the scope of the base's body.""" + + +def test_get_params_class_scope_nested_class_inherited(): + params = get_params(ClassScopeNestedTypeSub) + assert [p.name for p in params] == ["params"] + assert params[0].annotation == Optional[ClassScopeNestedTypeBase.Params] + + +class ClassScopeOverrideBase: + @dataclasses.dataclass + class Params: + temperature: float = 0.0 + + +class ClassScopeOverrideSub(ClassScopeOverrideBase): + @dataclasses.dataclass + class Params: + max_tokens: int = 0 + + def __init__(self, params: Optional[Params] = None): + self.params = params # pragma: no cover + + +def test_get_params_class_scope_nested_class_shadows_base(): + params = get_params(ClassScopeOverrideSub) + assert params[0].annotation == Optional[ClassScopeOverrideSub.Params] + + +@dataclasses.dataclass +class ClassScopeDataclass: + Params: "typing.ClassVar[type]" = ClassScopeNestedType.Params + params: Optional[Params] = None # type: ignore[valid-type] + + +def test_get_types_class_scope_dataclass(): + types = get_types(ClassScopeDataclass) + assert types["params"] == Optional[ClassScopeNestedType.Params] + + +class ClassScopeNonTypeAttribute: + Path_drw = "not a type" + + def __init__(self, path: Optional[Path_drw] = None): # type: ignore[valid-type] + self.path = path # pragma: no cover + + +def test_get_types_class_scope_non_type_attribute_does_not_shadow(): + types = get_types(ClassScopeNonTypeAttribute.__init__) + assert types == {"path": Optional[Path_drw]} + + +class ClassScopeTypeVarAttribute: + ScopedTypeVar = typing.TypeVar("ScopedTypeVar", bound=int) + + def __init__(self, num: Optional[ScopedTypeVar] = None): + self.num = num # pragma: no cover + + +def test_get_types_class_scope_type_var_attribute(): + types = get_types(ClassScopeTypeVarAttribute.__init__) + assert types == {"num": Optional[ClassScopeTypeVarAttribute.ScopedTypeVar]} + + +class ClassScopeAliasAttribute: + ScopedAlias = List["DefinedClass"] + + def __init__(self, items: Optional[ScopedAlias] = None): + self.items = items # pragma: no cover + + +def test_get_types_class_scope_alias_attribute(): + types = get_types(ClassScopeAliasAttribute.__init__) + assert types == {"items": Optional[List[DefinedClass]]} + + +class ClassScopeReturnType: + class Result: + pass + + def run(self) -> Result: + return self.Result() # pragma: no cover + + +def test_get_return_type_class_scope_nested_class(): + assert get_return_type(ClassScopeReturnType.run) is ClassScopeReturnType.Result + + +def test_get_owner_class_unresolvable_qualname(): + def method(p1: "int"): + return p1 # pragma: no cover + + method.__qualname__ = "NotInTheModule.method" + assert get_owner_class(method) is None + assert get_types(method) == {"p1": int} + + +class ClassScopeProtocol(Protocol): + class Options: + pass + + def run(self, options: Optional[Options] = None) -> Options: ... + + +class ClassScopeProtocolImpl: + def run(self, options: Optional[ClassScopeProtocol.Options] = None) -> ClassScopeProtocol.Options: + return options or ClassScopeProtocol.Options() # pragma: no cover + + +def test_protocol_class_scope_nested_class(parser): + parser.add_argument("--proto", type=ClassScopeProtocol) + cfg = parser.parse_args([f"--proto={__name__}.ClassScopeProtocolImpl"]) + assert cfg.proto.class_path == f"{__name__}.ClassScopeProtocolImpl" + + def function_source_unavailable(p1: List["TypeCheckingClass1"]): return p1 # pragma: no cover From b7a30fa23eda892c98dca9489b832491418f6d29 Mon Sep 17 00:00:00 2001 From: Mauricio Villegas <5780272+mauvilsa@users.noreply.github.com> Date: Sat, 15 Aug 2026 11:08:05 +0200 Subject: [PATCH 4/6] Enable subclasses by default for abstract dataclass-like types and reject abstract class paths --- CHANGELOG.rst | 12 ++++ DOCUMENTATION.rst | 14 +++- jsonargparse/_common.py | 11 ++- jsonargparse/_typehints.py | 22 ++++-- jsonargparse_tests/test_dataclasses.py | 30 +++++++++ jsonargparse_tests/test_pydantic.py | 92 ++++++++++++++++++++++++++ jsonargparse_tests/test_subclasses.py | 53 +++++++++++++++ 7 files changed, 226 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index bb83f62d..5ec44f4e 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -198,6 +198,12 @@ Fixed - Postponed annotations of a method not resolving names that are defined in the body of its class. Now the namespace of the class that defines the method is used as locals (`#??? `__). +- The ``class_path`` of an abstract class being accepted for a class typed + argument, only to fail on ``instantiate`` with ``TypeError: Can't instantiate + abstract class``. Now the parsing fails with ``Expected an instantiatable + class, but ... is abstract``, also when the ``class_path`` is implicit, i.e. + only init args given, and when the class is given by name (`#??? + `__). Changed ^^^^^^^ @@ -237,6 +243,12 @@ Changed option 'init_args....'``. Dataclass-like types now accept the same values whether or not they are added as a group, see :ref:`subclasses-disabled` (`#952 `__). +- Dataclass-like types that are abstract, i.e. that have abstract methods or + inherit from ``abc.ABC``, now have subclass support enabled by default. + Previously such a type was unusable, since the ``class_path`` of an + implementation was rejected and giving its fields directly failed on + ``instantiate``. See :ref:`subclasses-disabled` (`#??? + `__). Deprecated ^^^^^^^^^^ diff --git a/DOCUMENTATION.rst b/DOCUMENTATION.rst index 94b54599..d669fe4e 100644 --- a/DOCUMENTATION.rst +++ b/DOCUMENTATION.rst @@ -2301,6 +2301,12 @@ be accepted. In this case the config would be like: type a class. The accepted ``init_args`` would be the parameters of that function. +.. note:: + + Abstract classes, i.e. classes that have abstract methods, are not accepted + as ``class_path`` value, since they can't be instantiated. For the same + reason they are not included in the known subclasses shown in the help. + .. _sub-config-files: @@ -2732,6 +2738,11 @@ accepted values are the same. A subclass spec is accepted, though only with the "init_args": {"number": 8}}``. The ``class_path`` of a subclass is not accepted, unless subclass support is enabled for the type as described next. +Abstract dataclass-like types are an exception. A class that has abstract +methods or that inherits from ``abc.ABC`` is not intended to be instantiated +from its own fields, so for these types subclass support is enabled by default, +i.e. only the ``class_path`` of an implementation is accepted. + .. _enable-disable-subclasses: @@ -2753,7 +2764,8 @@ precedence over those in ``subclasses_disabled``. If a function name is given to ``subclasses_enabled``, it must correspond to a function previously registered in ``subclasses_disabled``; in this case, the effect is to unregister it. By default, the following disabling functions are registered: ``is_pure_dataclass``, -``is_pydantic_model``, ``is_attrs_class``, and ``is_final_class``. +``is_pydantic_model``, ``is_attrs_class``, and ``is_final_class``. These +functions are not applied to abstract classes, see above. Some examples. Since ``subclasses_enabled`` takes precedence, it is possible to keep subclass support disabled for dataclasses, but enable it for a specific diff --git a/jsonargparse/_common.py b/jsonargparse/_common.py index 8d30c3dd..a60cb7e6 100644 --- a/jsonargparse/_common.py +++ b/jsonargparse/_common.py @@ -1,3 +1,4 @@ +import abc import argparse import dataclasses import inspect @@ -360,6 +361,11 @@ def is_final_class(cls) -> bool: return getattr(cls, "__final__", False) +def is_abstract_class(cls) -> bool: + """Checks whether a class has abstract methods or is explicitly declared as an abstract base class.""" + return inspect.isabstract(cls) or abc.ABC in getattr(cls, "__bases__", ()) + + def is_generic_class(cls) -> bool: return isinstance(cls, _GenericAlias) and getattr(cls, "__module__", "") != "typing" @@ -411,7 +417,10 @@ def is_subclasses_disabled(cls) -> bool: return is_subclasses_disabled(cls.__origin__) if not inspect.isclass(cls): return False - subclass_disabled = any(selector(cls) for selector in subclasses_disabled_selectors.values()) + # abstract classes are not intended to be instantiated from their own fields, so only subclasses make sense + subclass_disabled = not is_abstract_class(cls) and any( + selector(cls) for selector in subclasses_disabled_selectors.values() + ) if not subclass_disabled: subclass_disabled = any(issubclass(cls, disable_type) for disable_type in subclasses_disabled_types) if subclass_disabled: diff --git a/jsonargparse/_typehints.py b/jsonargparse/_typehints.py index c8ec30e2..d1a6827d 100644 --- a/jsonargparse/_typehints.py +++ b/jsonargparse/_typehints.py @@ -1544,6 +1544,8 @@ def adapt_typehints( return val_class # importable instance if is_protocol(val_class): raise_unexpected_value(f"Expected an instantiatable class, but {val['class_path']} is a protocol") + if inspect.isabstract(val_class): + raise_unexpected_value(f"Expected an instantiatable class, but {val['class_path']} is abstract") if ( is_subclasses_disabled(typehint) and inspect.isclass(val_class) @@ -1974,7 +1976,7 @@ def adapt_partial_callable_class(callable_type, subclass_spec): return subclass_spec, partial_skip_args -def get_all_subclass_paths(cls: type) -> list[str]: +def get_all_subclass_paths(cls: type, include_abstract: bool = False) -> list[str]: subclass_list = [] def is_local(cl): @@ -1995,7 +1997,7 @@ def add_subclasses(cl): return if is_local(cl) or is_subclass(cl, _LazyInitBaseClass): return - if not (inspect.isabstract(cl) or is_private(class_path) or is_protocol(cl)): + if not ((inspect.isabstract(cl) and not include_abstract) or is_private(class_path) or is_protocol(cl)): if class_path in subclass_list: return subclass_list.append(class_path) @@ -2024,10 +2026,18 @@ def resolve_class_path_by_name(cls: type | tuple[type], name: str) -> str: if "." in class_path: break return class_path - subclass_dict = defaultdict(list) - for subclass in get_all_subclass_paths(cls): - subclass_name = subclass.rsplit(".", 1)[1] - subclass_dict[subclass_name].append(subclass) + + def get_subclass_dict(include_abstract: bool) -> dict: + subclass_dict = defaultdict(list) + for subclass in get_all_subclass_paths(cls, include_abstract=include_abstract): + subclass_name = subclass.rsplit(".", 1)[1] + subclass_dict[subclass_name].append(subclass) + return subclass_dict + + subclass_dict = get_subclass_dict(include_abstract=False) + if name not in subclass_dict: + # abstract classes are not valid choices, but resolving them gives a more informative error + subclass_dict = get_subclass_dict(include_abstract=True) if name in subclass_dict: name_subclasses = subclass_dict[name] if len(name_subclasses) > 1: diff --git a/jsonargparse_tests/test_dataclasses.py b/jsonargparse_tests/test_dataclasses.py index 7f2270f7..1b7457f9 100644 --- a/jsonargparse_tests/test_dataclasses.py +++ b/jsonargparse_tests/test_dataclasses.py @@ -1,5 +1,6 @@ from __future__ import annotations +import abc import dataclasses import json import sys @@ -855,6 +856,35 @@ def test_dataclass_subclasses_disabled(parser): parser.parse_args([f"--data={json.dumps(config)}"]) +@dataclasses.dataclass +class DataAbstract(abc.ABC): + p1: int = 1 + + @abc.abstractmethod + def run(self): ... + + +@dataclasses.dataclass +class DataAbstractImpl(DataAbstract): + p2: str = "-" + + def run(self): + return 1 # pragma: no cover + + +def test_dataclass_abstract_subclasses_enabled(parser): + parser.add_argument("--data", type=DataAbstract) + + help_str = get_parser_help(parser) + assert "--data.help" in help_str + assert f"known subclasses: {__name__}.DataAbstractImpl" in help_str + + config = {"class_path": f"{__name__}.DataAbstractImpl", "init_args": {"p2": "y"}} + cfg = parser.parse_args([f"--data={json.dumps(config)}"]) + init = parser.instantiate(cfg) + assert init.data == DataAbstractImpl(p1=1, p2="y") + + # same capabilities for a dataclass-like type and its optional counterpart diff --git a/jsonargparse_tests/test_pydantic.py b/jsonargparse_tests/test_pydantic.py index 17088668..708e7c1a 100644 --- a/jsonargparse_tests/test_pydantic.py +++ b/jsonargparse_tests/test_pydantic.py @@ -1,5 +1,6 @@ from __future__ import annotations +import abc import dataclasses import json import pathlib @@ -573,6 +574,97 @@ def test_model_argument_symmetry_subclasses_disabled(parser, optional): parser.parse_args([f"--cat={json.dumps(value)}"]) +# abstract models + + +if pydantic_support: + + class AbstractModel(pydantic.BaseModel, abc.ABC): + name: str = "n" + + @abc.abstractmethod + def run(self): ... + + class AbstractModelImpl(AbstractModel): + extra: int = 1 + + def run(self): + return 1 # pragma: no cover + + class DeclaredAbstractModel(pydantic.BaseModel, abc.ABC): + name: str = "n" + + class DeclaredAbstractModelImpl(DeclaredAbstractModel): + extra: int = 1 + + +def test_abstract_model_subclasses_enabled_by_default(parser, subtests): + parser.add_argument("--model", type=AbstractModel) + + with subtests.test("help"): + help_str = get_parser_help(parser) + assert "--model.help [CLASS_PATH_OR_NAME]" in help_str + assert f"known subclasses: {__name__}.AbstractModelImpl" in help_str + + with subtests.test("subclass class_path"): + value = {"class_path": f"{__name__}.AbstractModelImpl", "init_args": {"name": "a"}} + cfg = parser.parse_args([f"--model={json.dumps(value)}"]) + init = parser.instantiate(cfg) + assert isinstance(init.model, AbstractModelImpl) + assert init.model.name == "a" + + with subtests.test("own class_path"): + with pytest.raises(ArgumentError, match="Expected an instantiatable class, but .*AbstractModel is abstract"): + parser.parse_args([f"--model={__name__}.AbstractModel"]) + + with subtests.test("unrelated class_path"): + with pytest.raises(ArgumentError, match="does not correspond to a subclass of AbstractModel"): + parser.parse_args(["--model=calendar.Calendar"]) + + +def test_abstract_model_optional_subclasses_enabled_by_default(parser): + parser.add_argument("--model", type=Optional[AbstractModel]) + + value = {"class_path": f"{__name__}.AbstractModelImpl", "init_args": {"extra": 2}} + cfg = parser.parse_args([f"--model={json.dumps(value)}"]) + init = parser.instantiate(cfg) + assert isinstance(init.model, AbstractModelImpl) + assert init.model.extra == 2 + assert parser.parse_args(["--model=null"]).model is None + + +def test_add_subclass_arguments_abstract_model(parser): + parser.add_subclass_arguments(AbstractModel, "model") + + cfg = parser.parse_args([f"--model={__name__}.AbstractModelImpl", "--model.extra=4"]) + init = parser.instantiate(cfg) + assert isinstance(init.model, AbstractModelImpl) + assert init.model.extra == 4 + + +def test_abstract_model_subclasses_explicitly_disabled(parser, subclass_behavior): + set_parsing_settings(subclasses_disabled=[AbstractModel]) + parser.add_argument("--model", type=AbstractModel) + + value = {"class_path": f"{__name__}.AbstractModelImpl"} + with pytest.raises(ArgumentError, match="Subclasses are disabled for AbstractModel"): + parser.parse_args([f"--model={json.dumps(value)}"]) + + +def test_abc_declared_model_subclasses_enabled_by_default(parser): + parser.add_argument("--model", type=DeclaredAbstractModel) + + value = {"class_path": f"{__name__}.DeclaredAbstractModelImpl", "init_args": {"extra": 2}} + cfg = parser.parse_args([f"--model={json.dumps(value)}"]) + init = parser.instantiate(cfg) + assert isinstance(init.model, DeclaredAbstractModelImpl) + assert init.model.extra == 2 + + cfg = parser.parse_args([f"--model={__name__}.DeclaredAbstractModel"]) + init = parser.instantiate(cfg) + assert type(init.model) is DeclaredAbstractModel + + def test_convert_to_dict_closed_to_subclasses(): converted = convert_to_dict(person) assert converted == person_expected_dict diff --git a/jsonargparse_tests/test_subclasses.py b/jsonargparse_tests/test_subclasses.py index 45464dc7..cf49aad0 100644 --- a/jsonargparse_tests/test_subclasses.py +++ b/jsonargparse_tests/test_subclasses.py @@ -4,6 +4,7 @@ import os import textwrap import warnings +from abc import ABC, abstractmethod from calendar import Calendar from copy import deepcopy from dataclasses import dataclass @@ -240,6 +241,58 @@ def test_subclass_known_subclasses_multiple_bases(parser): assert class_path in help_str +# abstract class tests + + +class AbstractBase(ABC): + def __init__(self, p1: int = 1): + self.p1 = p1 + + @abstractmethod + def method(self): ... + + +class AbstractImpl(AbstractBase): + def method(self): + return "impl" # pragma: no cover + + +def test_subclass_abstract_known_subclasses(parser): + parser.add_argument("--op", type=AbstractBase) + help_str = get_parser_help(parser) + assert f"known subclasses: {__name__}.AbstractImpl" in help_str + assert f"{__name__}.AbstractBase" not in help_str + + +def test_subclass_abstract_class_path_not_accepted(parser): + parser.add_argument("--op", type=AbstractBase) + with pytest.raises(ArgumentError, match=f"Expected an instantiatable class, but {__name__}.AbstractBase"): + parser.parse_args([f"--op={__name__}.AbstractBase"]) + + +def test_subclass_abstract_class_name_not_accepted(parser): + parser.add_argument("--op", type=AbstractBase) + with pytest.raises(ArgumentError, match="Expected an instantiatable class, but AbstractBase is abstract"): + parser.parse_args(["--op=AbstractBase"]) + + +def test_subclass_abstract_implicit_class_path_not_accepted(parser): + parser.add_argument("--op", type=AbstractBase) + with pytest.raises(ArgumentError, match="is abstract"): + parser.parse_args(["--op.p1=2"]) + with pytest.raises(ArgumentError, match="is abstract"): + parser.parse_args(['--op={"p1": 2}']) + + +def test_subclass_abstract_concrete_subclass_accepted(parser): + parser.add_argument("--op", type=AbstractBase) + cfg = parser.parse_args([f"--op={__name__}.AbstractImpl", "--op.p1=2"]) + assert cfg.op == Namespace(class_path=f"{__name__}.AbstractImpl", init_args=Namespace(p1=2)) + init = parser.instantiate(cfg) + assert isinstance(init.op, AbstractImpl) + assert init.op.p1 == 2 + + class UntypedParams: def __init__(self, a1, a2=None): self.a1 = a1 # pragma: no cover From 2ecf72c5469afd3bf2f3444452d9cefbc1158633 Mon Sep 17 00:00:00 2001 From: Mauricio Villegas <5780272+mauvilsa@users.noreply.github.com> Date: Mon, 17 Aug 2026 06:26:23 +0200 Subject: [PATCH 5/6] Accept pydantic and attrs field aliases as option and config keys --- CHANGELOG.rst | 47 ++++--- DOCUMENTATION.rst | 40 ++++++ jsonargparse/_core.py | 3 + jsonargparse/_parameter_resolvers.py | 70 ++++++++++ jsonargparse/_signatures.py | 19 +++ jsonargparse/_typehints.py | 20 ++- jsonargparse_tests/test_attrs.py | 22 +++ jsonargparse_tests/test_pydantic.py | 201 +++++++++++++++++++++++++++ 8 files changed, 400 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 5ec44f4e..62cd5da1 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -58,17 +58,22 @@ Added - Arguments typed as a ``TypedDict`` now have a ``--*.help`` option that shows the keys that are accepted, their types and their descriptions. It receives no value, unless the ``TypedDict`` is in a union with other types that have a - help, in which case the value is the name of the typed dict (`#??? - `__). + help, in which case the value is the name of the typed dict (`#956 + `__). - ``add_class_arguments`` now accepts a ``TypedDict``, adding one argument per key, analogous to a dataclass. ``instantiate`` gives the corresponding dict - (`#??? `__). + (`#956 `__). - Descriptions of ``TypedDict`` keys taken from its docstring are now shown in - the help of ``**kwargs: Unpack[SomeTypedDict]`` parameters (`#??? - `__). + the help of ``**kwargs: Unpack[SomeTypedDict]`` parameters (`#956 + `__). - ``shtab`` completion scripts now include the keys of a ``TypedDict`` - argument, e.g. ``--data.key``, and the values that these keys accept (`#??? - `__). + argument, e.g. ``--data.key``, and the values that these keys accept (`#956 + `__). +- Pydantic's ``alias`` and ``validation_alias`` and attrs' ``alias`` are now + accepted as option and config keys, so that a parser accepts the same names as + the class itself. The parsed namespace and dumps use the name that the class + accepts, see :ref:`parameter-aliases` (`#956 + `__). Fixed ^^^^^ @@ -105,8 +110,8 @@ Fixed are serialized as an import path or as a message that says that it was not serializable, and a warning is raised when the dumped value does not round-trip, see :ref:`unvalidated-types` (`#948 - `__, `#??? - `__). + `__, `#956 + `__). - ``AssertionError`` without a message when adding an argument typed as a subscripted user defined generic class, e.g. ``Optional[Strategy[T]]`` (`#950 `__). @@ -193,17 +198,27 @@ Fixed change (`#955 `__). - ``shtab`` completions of ``**kwargs: Unpack[SomeTypedDict]`` parameters showing ``NotRequired[...]`` as the expected type and not completing the - values of the keys that are not required (`#??? - `__). + values of the keys that are not required (`#956 + `__). - Postponed annotations of a method not resolving names that are defined in the body of its class. Now the namespace of the class that defines the method is - used as locals (`#??? `__). + used as locals (`#956 `__). - The ``class_path`` of an abstract class being accepted for a class typed argument, only to fail on ``instantiate`` with ``TypeError: Can't instantiate abstract class``. Now the parsing fails with ``Expected an instantiatable class, but ... is abstract``, also when the ``class_path`` is implicit, i.e. - only init args given, and when the class is given by name (`#??? - `__). + only init args given, and when the class is given by name (`#956 + `__). +- Values silently discarded on instantiation for pydantic fields that have an + alias and don't accept the attribute name, i.e. models and dataclasses without + ``populate_by_name``. Now the alias is the accepted name, see + :ref:`parameter-aliases` (`#956 + `__). +- attrs fields whose ``__init__`` parameter name differs from the attribute + name, i.e. an explicit ``alias`` or a private attribute, failing to + instantiate with ``TypeError: got an unexpected keyword argument`` or not + being configurable at all (`#956 + `__). Changed ^^^^^^^ @@ -247,8 +262,8 @@ Changed inherit from ``abc.ABC``, now have subclass support enabled by default. Previously such a type was unusable, since the ``class_path`` of an implementation was rejected and giving its fields directly failed on - ``instantiate``. See :ref:`subclasses-disabled` (`#??? - `__). + ``instantiate``. See :ref:`subclasses-disabled` (`#956 + `__). Deprecated ^^^^^^^^^^ diff --git a/DOCUMENTATION.rst b/DOCUMENTATION.rst index d669fe4e..edde8c4c 100644 --- a/DOCUMENTATION.rst +++ b/DOCUMENTATION.rst @@ -2153,6 +2153,46 @@ the stubs. In these cases in the parser help the default is shown as ``Unknown`` and not included in :meth:`get_defaults <.ArgumentParser.get_defaults>` or the output of ``--print_config``. +.. _parameter-aliases: + +Parameter aliases +^^^^^^^^^^^^^^^^^ + +Pydantic and attrs allow giving a field a name that is different from the +attribute name, an alias: pydantic's ``alias``/``validation_alias`` and attrs' +``alias``. The resolvers take these aliases into account, so that a parser +accepts the same names as the class itself. + +When the framework accepts both names, e.g. a pydantic model with +``populate_by_name``, the alias is accepted as an additional option and config +key. The attribute name is the one used in the parsed namespace, in +``--print_config`` and in dumps: + +.. doctest:: parameter_aliases + + >>> from pydantic import BaseModel, ConfigDict, Field + + >>> class Client(BaseModel): + ... model_config = ConfigDict(populate_by_name=True) + ... api_key: str = Field(default="", alias="key") + ... + + >>> parser = ArgumentParser() + >>> parser.add_class_arguments(Client, "client") # doctest: +IGNORE_RESULT + >>> parser.parse_args(["--client.key=abc"]) + Namespace(client=Namespace(api_key='abc')) + +When the framework only accepts the alias, e.g. the same model without +``populate_by_name``, the alias is the name used everywhere, since giving the +attribute name would not instantiate the class as expected. + +Aliases are not supported for a parameter whose type is a subclasses-disabled +type added as a group of arguments, since then the name is a prefix of several +arguments instead of a single option string. In this case only the attribute +name is accepted. Enabling subclasses for the type, see +:ref:`enable-disable-subclasses`, makes it a single argument, and then its alias +is accepted as well. + .. _dependency-injection: diff --git a/jsonargparse/_core.py b/jsonargparse/_core.py index b92a0244..5553dda4 100644 --- a/jsonargparse/_core.py +++ b/jsonargparse/_core.py @@ -1393,6 +1393,9 @@ def _apply_actions( if action_dest not in cfg and key.endswith("+"): append = True cfg[action_dest] = cfg.pop(key) + elif action_dest != key and key in cfg: + # the key is an alias of the action's dest, i.e. another accepted name for it + cfg[action_dest] = cfg.pop(key) value = cfg[action_dest] if skip_fn and skip_fn(value): continue diff --git a/jsonargparse/_parameter_resolvers.py b/jsonargparse/_parameter_resolvers.py index 14dc17e9..ac483b38 100644 --- a/jsonargparse/_parameter_resolvers.py +++ b/jsonargparse/_parameter_resolvers.py @@ -44,6 +44,10 @@ class ParamData: component: Callable | type | tuple | None = None parent: type | tuple | None = None origin: str | tuple | None = None + # ``name`` is always the name that the component accepts, i.e. what parsing gives in the + # namespace and what instantiation uses. ``aliases`` are additional names that the component + # accepts for the same parameter, only used to accept more option and config keys. + aliases: tuple[str, ...] | None = None ParamList = list[ParamData] @@ -998,6 +1002,55 @@ def get_field_data_attrs(field, name, doc_params): } +# Some frameworks accept for a field a name different from the attribute name, an alias. A +# get_field_names function receives a field and the attribute name, and returns the name that the +# component accepts, i.e. the one used in the namespace and for instantiation, and a tuple of +# additional names accepted for the same field, or None. Frameworks in which an alias replaces the +# attribute name return the alias as the name and no aliases. Frameworks in which an alias is an +# additional name return the attribute name and the aliases. + + +def get_field_names_default(field, name, cls) -> tuple[str, tuple[str, ...] | None]: + return name, None + + +def to_field_names(name: str, aliases: list[str], name_accepted: bool) -> tuple[str, tuple[str, ...] | None]: + aliases = unique(a for a in aliases if isinstance(a, str) and a != name) + if not aliases: + return name, None + if name_accepted: + return name, tuple(aliases) + return aliases[0], tuple(aliases[1:]) or None + + +def get_field_names_pydantic2_field_info(field_info, name, config) -> tuple[str, tuple[str, ...] | None]: + aliases: list = [] + if config.get("validate_by_alias", True): + validation_alias = getattr(field_info, "validation_alias", None) + if isinstance(validation_alias, str): + aliases = [validation_alias] + elif validation_alias is not None: + # AliasChoices, its choices can also be AliasPath which is not supported + aliases = list(getattr(validation_alias, "choices", [])) + else: + aliases = [field_info.alias] + name_accepted = bool(config.get("validate_by_name", config.get("populate_by_name", False))) + return to_field_names(name, aliases, name_accepted) + + +def get_field_names_pydantic2_model(field, name, cls) -> tuple[str, tuple[str, ...] | None]: + return get_field_names_pydantic2_field_info(field, name, cls.model_config) + + +def get_field_names_pydantic2_dataclass(field, name, cls) -> tuple[str, tuple[str, ...] | None]: + return get_field_names_pydantic2_field_info(cls.__pydantic_fields__[name], name, cls.__pydantic_config__) + + +def get_field_names_attrs(field, name, cls) -> tuple[str, tuple[str, ...] | None]: + # attrs' alias replaces the name of the parameter in __init__ + return field.alias or name, None + + def is_init_field_pydantic2_dataclass(field) -> bool: from pydantic.fields import FieldInfo @@ -1023,6 +1076,7 @@ def get_parameters_from_pydantic_or_attrs( function_or_class = get_unaliased_type(function_or_class) fields_iterator = get_field_data = None + get_field_names = get_field_names_default if pydantic_support: pydantic_model = is_pydantic_model(function_or_class) if pydantic_model == 1: @@ -1032,11 +1086,13 @@ def get_parameters_from_pydantic_or_attrs( elif pydantic_model > 1: fields_iterator = function_or_class.model_fields.items() get_field_data = get_field_data_pydantic2_model + get_field_names = get_field_names_pydantic2_model is_init_field = lambda _: True elif dataclasses.is_dataclass(function_or_class) and hasattr(function_or_class, "__pydantic_fields__"): fields_iterator = dataclasses.fields(function_or_class) fields_iterator = {v.name: v for v in fields_iterator}.items() get_field_data = get_field_data_pydantic2_dataclass + get_field_names = get_field_names_pydantic2_dataclass is_init_field = is_init_field_pydantic2_dataclass if not fields_iterator and attrs_support: @@ -1045,12 +1101,14 @@ def get_parameters_from_pydantic_or_attrs( if attrs.has(function_or_class): fields_iterator = {f.name: f for f in attrs.fields(function_or_class)}.items() get_field_data = get_field_data_attrs + get_field_names = get_field_names_attrs is_init_field = is_init_field_attrs if not fields_iterator or not get_field_data: return None params = [] + field_names = [] doc_params = parse_docs(function_or_class, None, logger) for name, field in fields_iterator: if is_init_field(field): @@ -1062,11 +1120,23 @@ def get_parameters_from_pydantic_or_attrs( **get_field_data(field, name, doc_params), ) ) + field_names.append(get_field_names(field, name, function_or_class)) + # after the attribute names have been used to resolve the annotations evaluate_postponed_annotations(params, function_or_class, None, logger) + set_param_names_and_aliases(params, field_names) return params +def set_param_names_and_aliases(params: ParamList, field_names: list) -> None: + names = {name for name, _ in field_names} + for param, (name, aliases) in zip(params, field_names): + param.name = name + if aliases: + # an alias that is the name of another field would be ambiguous + param.aliases = tuple(a for a in aliases if a not in names) or None + + def get_parameters_from_ast( function_or_class: Callable | type, method_or_property: str | None, diff --git a/jsonargparse/_signatures.py b/jsonargparse/_signatures.py index 4c985bc6..119bc725 100644 --- a/jsonargparse/_signatures.py +++ b/jsonargparse/_signatures.py @@ -432,6 +432,8 @@ def _add_signature_parameter( subclasses_disabled = is_subclasses_disabled(annotation) dest = (nested_key + "." if nested_key else "") + name args = [dest if is_required and as_positional and not is_non_positional else "--" + dest] + if param.aliases and args[0].startswith("--"): + args += self._get_alias_args(param, nested_key, container, subclasses_disabled, src) if param.origin: parser = container if not isinstance(container, ArgumentParser): @@ -493,6 +495,23 @@ def _add_signature_parameter( f" type. Parameter '{name}' from '{src}' does not specify a type." ) + def _get_alias_args(self, param, nested_key, container, subclasses_disabled, src) -> list[str]: + """Option strings for the aliases of a parameter, i.e. other names accepted for it.""" + skip_message = f'Skipping aliases of parameter "{param.name}" from "{src}" because of: ' + if subclasses_disabled: + self.logger.debug( + skip_message + "aliases are not supported for subclasses-disabled types added as a group of arguments." + ) + return [] + prefix = f"--{nested_key}." if nested_key else "--" + alias_args = [] + for alias in param.aliases: + if f"{prefix}{alias}" in container._option_string_actions: + self.logger.debug(skip_message + f"alias '{alias}' conflicts with an already added argument.") + else: + alias_args.append(f"{prefix}{alias}") + return alias_args + def add_subclass_arguments( self, baseclass: type | tuple[type, ...], diff --git a/jsonargparse/_typehints.py b/jsonargparse/_typehints.py index d1a6827d..806e4a9b 100644 --- a/jsonargparse/_typehints.py +++ b/jsonargparse/_typehints.py @@ -405,7 +405,7 @@ def prepare_add_argument(args, kwargs, enable_path, container, logger, sub_add_k return args typehint = kwargs.pop("type") if args[0].startswith("--") and ActionTypeHint.supports_append(typehint): - args = tuple(list(args) + [args[0] + "+"]) + args = tuple(list(args) + [f"{a}+" for a in args if a.startswith("--")]) if get_registered_type(typehint) is None and get_help_types(typehint): help_option = f"--{args[0]}.help" if args[0][0] != "-" else f"{args[0]}.help" help_action = container.add_argument(help_option, action=_ActionHelpClassPath(typehint=typehint)) @@ -654,13 +654,15 @@ def __call__(self, *args, **kwargs): return ActionTypeHint(**kwargs) parser, cfg, val, opt_str = args if not (self.nargs == "?" and val is None): - if isinstance(opt_str, str) and opt_str.startswith(f"--{self.dest}."): - if opt_str.startswith(f"--{self.dest}.init_args."): - sub_opt = opt_str[len(f"--{self.dest}.init_args.") :] + # the option string can be an alias of the dest, i.e. another accepted name for it + option = self.get_option_string_base(opt_str) + if option: + if opt_str.startswith(f"{option}.init_args."): + sub_opt = opt_str[len(f"{option}.init_args.") :] else: - sub_opt = opt_str[len(f"--{self.dest}.") :] + sub_opt = opt_str[len(f"{option}.") :] val = NestedArg(key=sub_opt, val=val) - append = opt_str == f"--{self.dest}+" + append = isinstance(opt_str, str) and opt_str.endswith("+") and opt_str[:-1] in self.option_strings val = self._check_type_(val, append=append, cfg=cfg, mode=parser.parser_mode) if is_subclass_spec(val): prev_val = cfg.get(self.dest) @@ -673,6 +675,12 @@ def __call__(self, *args, **kwargs): cfg.update(val, self.dest) return None + def get_option_string_base(self, opt_str) -> str | None: + """Returns the option string of which opt_str is a sub-option, e.g. '--x' for '--x.y'.""" + if not isinstance(opt_str, str): + return None + return next((o for o in self.option_strings if opt_str.startswith(f"{o}.")), None) + def _check_type(self, value, append=False, cfg=None, mode=None): islist = _is_action_value_list(self) if not islist: diff --git a/jsonargparse_tests/test_attrs.py b/jsonargparse_tests/test_attrs.py index 9ce02d2a..65db05ed 100644 --- a/jsonargparse_tests/test_attrs.py +++ b/jsonargparse_tests/test_attrs.py @@ -47,6 +47,11 @@ class AttrsWithNestedDataclassNoDefault: p1: float subfield: AttrsSubField + @attrs.define + class AttrsAlias: + p1: int = attrs.field(default=1, alias="why") + _p2: str = "-" + @attrs.define class AttrsAttrDocsBase: p1: str = "-" @@ -107,3 +112,20 @@ def test_attribute_docstrings_inherited(self, parser): help_str = get_parser_help(parser) assert "p1 description (type: str, default: -)" in help_str assert "p2 description (type: int, default: 2)" in help_str + + def test_field_alias(self, parser): + parser.add_class_arguments(AttrsAlias, "d") + help_str = get_parser_help(parser) + assert "--d.why" in help_str + assert "--d.p1" not in help_str + cfg = parser.parse_args(["--d.why=2"]) + assert cfg.d == Namespace(why=2, p2="-") + init = parser.instantiate(cfg) + assert init.d == AttrsAlias(2, "-") + + def test_private_attribute_init_name(self, parser): + parser.add_class_arguments(AttrsAlias, "d") + cfg = parser.parse_args(["--d.p2=x"]) + assert cfg.d == Namespace(why=1, p2="x") + init = parser.instantiate(cfg) + assert init.d._p2 == "x" diff --git a/jsonargparse_tests/test_pydantic.py b/jsonargparse_tests/test_pydantic.py index 708e7c1a..fd9449da 100644 --- a/jsonargparse_tests/test_pydantic.py +++ b/jsonargparse_tests/test_pydantic.py @@ -20,6 +20,7 @@ ) from jsonargparse._signatures import convert_to_dict from jsonargparse_tests.conftest import ( + capture_logs, get_parse_args_stdout, get_parser_help, json_or_yaml_load, @@ -673,3 +674,203 @@ def test_convert_to_dict_closed_to_subclasses(): def test_convert_to_dict_subclasses_enabled(enable_subclasses): converted = convert_to_dict(person) assert converted == person_expected_subclass_dict + + +if pydantic_support > 1: + + class AliasByName(pydantic.BaseModel): + """Both the attribute name and the alias are accepted.""" + + model_config = pydantic.ConfigDict(populate_by_name=True) + attr_name: str = pydantic.Field("", alias="alias_name") + + class AliasOnly(pydantic.BaseModel): + """Only the alias is accepted.""" + + attr_name: str = pydantic.Field("", alias="alias_name") + + class TakesAliasByName: + def __init__(self, model: AliasByName = AliasByName(alias_name="")): + self.model = model # pragma: no cover + + class AliasInner(pydantic.BaseModel): + a: int = 1 + + class AliasVariants(pydantic.BaseModel): + model_config = pydantic.ConfigDict(populate_by_name=True) + elems: Optional[List[str]] = pydantic.Field(None, alias="el") + inner: Optional[AliasInner] = pydantic.Field(None, alias="in_") + p1: int = pydantic.Field(1, alias="p2") + p2: int = 2 + nested: AliasByName = pydantic.Field(default_factory=lambda: AliasByName(alias_name=""), alias="nest") + + +@skip_if_pydantic_v1 +def test_pydantic_alias_as_additional_name(parser, subtests): + parser.add_class_arguments(AliasByName, "m", instantiate=True) + + with subtests.test("parse alias"): + assert parser.parse_args(["--m.alias_name=abc"]).m == Namespace(attr_name="abc") + + with subtests.test("parse attribute name"): + assert parser.parse_args(["--m.attr_name=abc"]).m == Namespace(attr_name="abc") + + with subtests.test("parse config"): + assert parser.parse_string('{"m": {"alias_name": "abc"}}').m == Namespace(attr_name="abc") + + with subtests.test("parse object"): + assert parser.parse_object({"m": {"alias_name": "abc"}}).m == Namespace(attr_name="abc") + + with subtests.test("instantiate"): + init = parser.instantiate(parser.parse_args(["--m.alias_name=abc"])) + assert isinstance(init.m, AliasByName) + assert init.m.attr_name == "abc" + + with subtests.test("dump uses the attribute name"): + dump = json_or_yaml_load(parser.dump(parser.parse_args(["--m.alias_name=abc"]))) + assert dump == {"m": {"attr_name": "abc"}} + + with subtests.test("help shows both names"): + help_str = get_parser_help(parser) + assert "--m.attr_name" in help_str + assert "--m.alias_name" in help_str + + +@skip_if_pydantic_v1 +def test_pydantic_alias_env_vars(subtests): + parser = ArgumentParser(exit_on_error=False, env_prefix="APP", default_env=True) + parser.add_class_arguments(AliasByName, "m") + + with subtests.test("attribute name"): + assert parser.parse_env({"APP_M__ATTR_NAME": "abc"}).m == Namespace(attr_name="abc") + + with subtests.test("alias has no env var"): + assert parser.parse_env({"APP_M__ALIAS_NAME": "abc"}).m == Namespace(attr_name="") + + +@skip_if_pydantic_v1 +def test_pydantic_alias_in_signature_parameter(parser, subtests): + parser.add_class_arguments(TakesAliasByName, "r") + parser.add_argument("--cls", type=TakesAliasByName) + + with subtests.test("class arguments"): + assert parser.parse_args(["--r.model.alias_name=abc"]).r.model == Namespace(attr_name="abc") + + with subtests.test("subclass init args"): + value = {"class_path": f"{__name__}.TakesAliasByName", "init_args": {"model": {"alias_name": "abc"}}} + cfg = parser.parse_args([f"--cls={json.dumps(value)}"]) + assert cfg.cls.init_args.model == Namespace(attr_name="abc") + + +@skip_if_pydantic_v1 +def test_pydantic_alias_replaces_name(parser, subtests): + parser.add_class_arguments(AliasOnly, "m", instantiate=True) + + with subtests.test("parse alias"): + assert parser.parse_args(["--m.alias_name=abc"]).m == Namespace(alias_name="abc") + + with subtests.test("attribute name not accepted"): + with pytest.raises(ArgumentError, match="unrecognized arguments: --m.attr_name=abc"): + parser.parse_args(["--m.attr_name=abc"]) + + with subtests.test("instantiate"): + init = parser.instantiate(parser.parse_args(["--m.alias_name=abc"])) + assert isinstance(init.m, AliasOnly) + assert init.m.attr_name == "abc" + + +@skip_if_pydantic_v1 +def test_pydantic_validation_alias(parser, subtests): + Model = pydantic.create_model( + "ModelValidationAlias", + __config__=pydantic.ConfigDict(populate_by_name=True), + p1=(str, pydantic.Field("", validation_alias="v1")), + p2=(str, pydantic.Field("", validation_alias=pydantic.AliasChoices("c1", "c2"))), + ) + parser.add_class_arguments(Model, "m") + + with subtests.test("validation_alias string"): + assert parser.parse_args(["--m.v1=x"]).m.p1 == "x" + assert parser.parse_args(["--m.p1=x"]).m.p1 == "x" + + with subtests.test("validation_alias AliasChoices"): + for option in ["--m.c1=x", "--m.c2=x", "--m.p2=x"]: + assert parser.parse_args([option]).m.p2 == "x" + + +@skip_if_pydantic_v1 +def test_pydantic_validate_by_alias_false(parser): + Model = pydantic.create_model( + "ModelValidateByAliasFalse", + __config__=pydantic.ConfigDict(validate_by_name=True, validate_by_alias=False), + p1=(str, pydantic.Field("", alias="a1")), + ) + parser.add_class_arguments(Model, "m") + assert parser.parse_args(["--m.p1=x"]).m == Namespace(p1="x") + with pytest.raises(ArgumentError, match="unrecognized arguments: --m.a1=x"): + parser.parse_args(["--m.a1=x"]) + + +@skip_if_pydantic_v1 +def test_pydantic_dataclass_alias_as_additional_name(parser): + @pydantic.dataclasses.dataclass(config=pydantic.ConfigDict(populate_by_name=True)) + class DataAliasByName: + attr_name: str = pydantic.Field("", alias="alias_name") + + parser.add_class_arguments(DataAliasByName, "d", instantiate=True) + cfg = parser.parse_args(["--d.alias_name=abc"]) + assert cfg.d == Namespace(attr_name="abc") + assert parser.instantiate(cfg).d.attr_name == "abc" + + +@skip_if_pydantic_v1 +def test_pydantic_dataclass_alias_replaces_name(parser): + @pydantic.dataclasses.dataclass + class DataAliasOnly: + attr_name: str = pydantic.Field("", alias="alias_name") + + parser.add_class_arguments(DataAliasOnly, "d", instantiate=True) + cfg = parser.parse_args(["--d.alias_name=abc"]) + assert cfg.d == Namespace(alias_name="abc") + assert parser.instantiate(cfg).d.attr_name == "abc" + + +@skip_if_pydantic_v1 +def test_pydantic_alias_variants(parser, subtests): + parser.add_class_arguments(AliasVariants, "m", sub_configs=True) + + with subtests.test("append to a list through the alias"): + cfg = parser.parse_args(['--m.el=["a"]', "--m.el+=b"]) + assert cfg.m.elems == ["a", "b"] + + with subtests.test("nested arg through the alias"): + cfg = parser.parse_args(["--m.in_.a=3"]) + assert cfg.m.inner == Namespace(a=3) + + with subtests.test("alias equal to another field name is skipped"): + cfg = parser.parse_args(["--m.p2=7"]) + assert cfg.m.p1 == 1 + assert cfg.m.p2 == 7 + + +@skip_if_pydantic_v1 +def test_pydantic_alias_of_group_field_skipped(parser, logger): + parser.logger = logger + with capture_logs(logger) as logs: + parser.add_class_arguments(AliasVariants, "m", sub_configs=True) + assert 'Skipping aliases of parameter "nested"' in logs.getvalue() + assert "not supported for subclasses-disabled types added as a group" in logs.getvalue() + assert parser.parse_args(["--m.nested.alias_name=abc"]).m.nested == Namespace(attr_name="abc") + with pytest.raises(ArgumentError, match="unrecognized arguments: --m.nest.alias_name=abc"): + parser.parse_args(["--m.nest.alias_name=abc"]) + + +@skip_if_pydantic_v1 +def test_pydantic_alias_conflicting_with_added_argument_skipped(parser, logger): + parser.logger = logger + parser.add_argument("--m.el", type=int, default=0) + with capture_logs(logger) as logs: + parser.add_class_arguments(AliasVariants, "m", sub_configs=True) + assert 'Skipping aliases of parameter "elems"' in logs.getvalue() + assert "conflicts with an already added argument" in logs.getvalue() + assert parser.parse_args(["--m.el=3"]).m.el == 3 From 1bc9be6fe134758c1a8ef08ceb4761943e2d5ba9 Mon Sep 17 00:00:00 2001 From: Mauricio Villegas <5780272+mauvilsa@users.noreply.github.com> Date: Mon, 17 Aug 2026 07:19:50 +0200 Subject: [PATCH 6/6] Fix doctest in workflow --- .github/workflows/tests.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 387d314c..f8bc4244 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -241,7 +241,7 @@ jobs: enable-cache: true cache-suffix: doctest cache-dependency-glob: pyproject.toml - - run: uv pip install -e .[all,shtab,doc] + - run: uv pip install -e .[all,test,shtab,doc] - name: Run doc tests run: sphinx-build -M doctest sphinx sphinx/_build sphinx/index.rst