From 078dcab84ba7de2f5463cc2fd46cbd01f14dbc5f Mon Sep 17 00:00:00 2001 From: Mauricio Villegas <5780272+mauvilsa@users.noreply.github.com> Date: Tue, 4 Aug 2026 06:42:20 +0200 Subject: [PATCH] Show unresolved types in help as Unresolved<...> --- CHANGELOG.rst | 10 ++- jsonargparse/_signatures.py | 4 +- jsonargparse/_typehints.py | 60 ++++++++++++-- .../test_postponed_annotations.py | 79 +++++++++++++++---- 4 files changed, 123 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 8dfe29a8..b1c8b615 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -47,10 +47,12 @@ Fixed Changed ^^^^^^^ - Signature parameters with a type hint that fails to resolve, e.g. a missing - import or a typo in a postponed annotation, are now accepted with the - unresolved parts replaced by ``Any``, instead of the parameter being skipped - or, when mandatory and ``fail_untyped=True``, raising a ``ValueError`` (`#936 - `__). + import or a typo in a postponed annotation, are now accepted instead of the + parameter being skipped or, when mandatory and ``fail_untyped=True``, raising + a ``ValueError``. The unresolved parts accept any value without validation and + are shown in the help as ``Unresolved<...>``, making evident which type failed + to resolve (`#936 `__, + `#944 `__). - ``Required`` and ``NotRequired`` given as the type of an argument are no longer shown in the help. Now they must agree with whether the argument is required, otherwise adding the argument fails (`#937 diff --git a/jsonargparse/_signatures.py b/jsonargparse/_signatures.py index 4460b8b7..f7bd4417 100644 --- a/jsonargparse/_signatures.py +++ b/jsonargparse/_signatures.py @@ -346,8 +346,8 @@ def _add_signature_parameter( self.logger.debug( f'Unable to resolve the type of parameter "{name}" from ' f'"{get_parameter_origins(param.component, param.parent)}": {annotation}. ' - "The unresolved parts are replaced with Any, so the parameter is accepted " - "but its value is not validated." + "The unresolved parts are shown in the help as Unresolved<...> and accept " + "any value, so the parameter is accepted but its value is not validated." ) annotation = unresolved_replaced if default == inspect_empty: diff --git a/jsonargparse/_typehints.py b/jsonargparse/_typehints.py index de3c55a5..dec3b93d 100644 --- a/jsonargparse/_typehints.py +++ b/jsonargparse/_typehints.py @@ -35,6 +35,7 @@ TypedDict, TypeVar, Union, + get_args, ) from ._actions import ( @@ -397,6 +398,7 @@ def is_supported_typehint(typehint, full=False): supported = ( typehint in root_types + or isinstance(typehint, UnresolvedType) or get_typehint_origin(typehint) in root_types or get_registered_type(typehint) is not None or is_subclass(typehint, Enum) @@ -870,15 +872,48 @@ def resolve_forward_ref(ref, global_vars=None): return aliases.get(ref.__forward_arg__, ref) +class UnresolvedType: + """Type hint that stands in for one that failed to resolve, accepting any value. + + Instances are used as the type hint, keeping what the source code has, such that the + help shows it as Unresolved<...> instead of the Any that makes the value accepted. + """ + + def __init__(self, typehint): + if isinstance(typehint, ForwardRef): + self.name = typehint.__forward_arg__ + elif isinstance(typehint, str): + self.name = typehint + else: + # unresolved subtypes are kept as ForwardRef or str, named here as in the source code + name = re.sub(r"ForwardRef\('([^']*)'\)", r"\1", str(typehint)) + self.name = re.sub(r"'([^']*)'", r"\1", name) + + def __call__(self): + """Not called, only needed because python<3.11 requires the args of e.g. Optional to be callable.""" + + def __repr__(self): + # module names stripped as done by type_to_str, which otherwise would mangle this repr + return f"Unresolved<{strip_module_names(self.name)}>" + + def __eq__(self, other): + return isinstance(other, UnresolvedType) and other.name == self.name + + def __hash__(self): + return hash((UnresolvedType, self.name)) + + def replace_unresolved_forward_refs(typehint): - """Replaces the unresolved forward references of a type hint with Any. + """Replaces the unresolved forward references of a type hint with UnresolvedType. Postponed annotations that fail to resolve, e.g. because of a missing import or a - typo, remain as a string or a ForwardRef. Replacing only the unresolved parts with - Any keeps the parameter usable, though without validation, instead of discarding it. + typo, remain as a string or a ForwardRef. Replacing only the unresolved parts with a + type hint that accepts any value keeps the parameter usable, though without + validation, instead of discarding it. What failed to resolve is kept so that the help + shows it, making it evident that the value is not validated as the type in the code. """ if isinstance(typehint, (str, ForwardRef)): - return Any + return UnresolvedType(typehint) if get_typehint_origin(typehint) in literal_types: return typehint # the args of a Literal are values, not types args = getattr(typehint, "__args__", None) @@ -890,9 +925,14 @@ def replace_unresolved_forward_refs(typehint): try: if hasattr(typehint, "copy_with"): return typehint.copy_with(new_args) + subscript_args = get_args(typehint) + if subscript_args and isinstance(subscript_args[0], list): + # a Callable that has its parameters flattened in __args__, e.g. the __args__ of + # Callable[[int], str] are (int, str), while subscripting needs them as a list + return get_typehint_origin(typehint)[[*new_args[:-1]], new_args[-1]] return get_typehint_origin(typehint)[new_args] except Exception: - return Any + return UnresolvedType(typehint) def resolve_module_annotations(module: str, annotations: dict, global_vars: dict, logger=None) -> dict: @@ -981,8 +1021,8 @@ def adapt_typehints( typehint_origin = get_typehint_origin(typehint) or typehint unset_sentinel = get_parsing_setting("unset_sentinel") - # Any - if typehint == Any: + # Any and unresolved, i.e. no validation + if typehint == Any or isinstance(typehint, UnresolvedType): type_val = type(val) if get_registered_type(type_val) or is_subclass(type_val, Enum): val = adapt_typehints(val, type_val, **adapt_kwargs) @@ -1995,10 +2035,14 @@ def typehint_from_action(action_or_typehint): return action_or_typehint +def strip_module_names(string: str) -> str: + return re.sub(r"[A-Za-z0-9_<>.]+\.", "", string) + + def type_to_str(obj): if obj in {bool, tuple} or is_subclass(obj, (int, float, str, Path, Enum)): return obj.__name__ - return re.sub(r"[A-Za-z0-9_<>.]+\.", "", str(obj)).replace("NoneType", "null") + return strip_module_names(str(obj)).replace("NoneType", "null") def literal_to_str(val): diff --git a/jsonargparse_tests/test_postponed_annotations.py b/jsonargparse_tests/test_postponed_annotations.py index c517f5e6..cd9a3ca4 100644 --- a/jsonargparse_tests/test_postponed_annotations.py +++ b/jsonargparse_tests/test_postponed_annotations.py @@ -5,10 +5,11 @@ import importlib.util import os import sys +import typing from collections.abc import Callable from textwrap import dedent from types import SimpleNamespace -from typing import TYPE_CHECKING, Any, Dict, ForwardRef, List, Optional, Tuple, Type, TypedDict, Union +from typing import TYPE_CHECKING, Dict, ForwardRef, List, Optional, Tuple, Type, TypedDict, Union from unittest.mock import patch import pytest @@ -31,12 +32,14 @@ from jsonargparse._typehints import ( Required, Unpack, + UnresolvedType, get_typed_dict_annotations, get_typed_dict_required_keys, replace_unresolved_forward_refs, + type_to_str, ) from jsonargparse.typing import Path_drw -from jsonargparse_tests.conftest import capture_logs, source_unavailable +from jsonargparse_tests.conftest import capture_logs, get_parser_help, source_unavailable from jsonargparse_tests.different_module_type_checking import DifferentModuleTypeCheckingTypedDict from jsonargparse_tests.test_dataclasses import DifferentModuleBaseData @@ -336,23 +339,25 @@ def __init__(self, **kwargs: Unpack[UnresolvableTypedDict]) -> None: @pytest.mark.skipif(not Unpack, reason="Unpack introduced in python 3.11 or backported in typing_extensions") def test_typed_dict_unresolvable_key_unpack(parser): added = parser.add_class_arguments(UnresolvableTypedDictClass, "cls") - assert added == ["cls.num", "cls.typo"] # the unresolvable key falls back to Any + assert added == ["cls.num", "cls.typo"] # the unresolvable key becomes unresolved cfg = parser.parse_args(["--cls.num=1", "--cls.typo=abc"]) assert cfg.cls == Namespace(num=1, typo="abc") - # being Any, the unresolvable key accepts any value without validation + # being unresolved, the key accepts any value without validation cfg = parser.parse_args(["--cls.num=1", '--cls.typo={"x": [1, 2]}']) assert cfg.cls.typo == {"x": [1, 2]} # and it remains not required, since the key is not required assert parser.parse_args(["--cls.num=1"]).cls == Namespace(num=1) + help_str = get_parser_help(parser, strip=True) + assert "--cls.typo TYPO (type: Unresolved" in help_str def function_unresolvable_annotation(num: int = 1, typo: "MisspelledType" = None): # type: ignore[name-defined] # noqa: F821 return num # pragma: no cover -def test_function_unresolvable_annotation_falls_back_to_any(parser): +def test_function_unresolvable_annotation_accepts_any_value(parser): added = parser.add_function_arguments(function_unresolvable_annotation, "fn") - assert added == ["fn.num", "fn.typo"] # the unresolvable annotation falls back to Any + assert added == ["fn.num", "fn.typo"] # the unresolvable annotation accepts any value cfg = parser.parse_args(["--fn.typo=abc"]) assert cfg.fn == Namespace(num=1, typo="abc") @@ -365,10 +370,22 @@ def test_unresolvable_annotation_debug_log(parser, logger): assert "typo" in logs.getvalue() +def test_unresolvable_annotation_help(parser): + parser.add_function_arguments(function_unresolvable_annotation, "fn") + help_str = get_parser_help(parser, strip=True) + # the help shows the type that failed to resolve, making evident that it is not validated + if sys.version_info < (3, 14): + optional = "Optional[Unresolved]" + else: + optional = "Unresolved | None" + assert f"--fn.typo TYPO (type: {optional}, default: null)" in help_str + + # When only a subtype fails to resolve, the rest of the type hint is kept so that what is # resolvable is still validated. How the type hint is rebuilt depends on its kind, i.e. -# typing generic aliases have copy_with, the builtin ones are subscripted again, and -# collections.abc.Callable does not accept an Any argument, so it falls back entirely to Any. +# typing generic aliases have copy_with, and the others are subscripted again with the +# replaced args. A Callable needs its parameters given back as a list, since in __args__ +# they are flattened, i.e. Callable[[int], str].__args__ is (int, str). def function_unresolvable_subtype( @@ -379,21 +396,51 @@ def function_unresolvable_subtype( return p1, p2, p3 # pragma: no cover -def test_unresolvable_subtype_replaced_with_any(): +def test_unresolvable_subtype_replaced_with_unresolved(): + unresolved = UnresolvedType("MisspelledType") annotations = {p.name: p.annotation for p in get_params(function_unresolvable_subtype)} - assert replace_unresolved_forward_refs(annotations["p1"]) == List[Any] - assert replace_unresolved_forward_refs(annotations["p2"]) == list[Any] - assert replace_unresolved_forward_refs(annotations["p3"]) is Any + assert replace_unresolved_forward_refs(annotations["p1"]) == List[unresolved] + assert replace_unresolved_forward_refs(annotations["p2"]) == list[unresolved] + assert replace_unresolved_forward_refs(annotations["p3"]) == Callable[[unresolved], int] + # both spellings of Callable give the same, even though only the typing one has copy_with + typing_callable = typing.Callable[[ForwardRef("MisspelledType")], int] + assert replace_unresolved_forward_refs(typing_callable) == typing.Callable[[unresolved], int] def test_unresolvable_subtype_parse(parser): added = parser.add_function_arguments(function_unresolvable_subtype, "fn") assert added == ["fn.p1", "fn.p2", "fn.p3"] - cfg = parser.parse_args(["--fn.p1=[1]", '--fn.p2=["a"]', "--fn.p3=anything"]) - assert cfg.fn == Namespace(p1=[1], p2=["a"], p3="anything") - # the resolvable part of the type hint is still validated + cfg = parser.parse_args(["--fn.p1=[1]", '--fn.p2=["a"]', f"--fn.p3={__name__}.function_unresolvable_subtype"]) + assert cfg.fn == Namespace(p1=[1], p2=["a"], p3=function_unresolvable_subtype) + # the resolvable parts of the type hints are still validated with pytest.raises(ArgumentError, match="Expected a "): - parser.parse_args(["--fn.p1=1", "--fn.p2=[]", "--fn.p3=x"]) + parser.parse_args(["--fn.p1=1", "--fn.p2=[]", f"--fn.p3={__name__}.function_unresolvable_subtype"]) + with pytest.raises(ArgumentError, match="Expected a dot import path string"): + parser.parse_args(["--fn.p1=[]", "--fn.p2=[]", "--fn.p3=not_a_callable"]) + + +class UnrebuildableTypehint: + """Stands in for an exotic type hint that can't be subscripted with the replaced args.""" + + __args__ = (ForwardRef("MisspelledType"),) + + def __repr__(self): + return "Unrebuildable[MisspelledType]" + + +def test_unresolvable_subtype_not_rebuildable(): + # failing to be rebuilt, the entire type hint becomes unresolved instead of an error + unresolved = replace_unresolved_forward_refs(UnrebuildableTypehint()) + assert type_to_str(unresolved) == "Unresolved" + + +def test_unresolvable_subtype_help(parser): + parser.add_function_arguments(function_unresolvable_subtype, "fn") + help_str = get_parser_help(parser, strip=True) + # only the part that failed to resolve is shown as unresolved + assert "type: List[Unresolved])" in help_str + assert "type: list[Unresolved])" in help_str + assert "type: Callable[[Unresolved], int])" in help_str # A TypedDict that inherits from a TypedDict in a different module must resolve the