Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
<https://github.com/mauvilsa/jsonargparse/pull/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 <https://github.com/mauvilsa/jsonargparse/pull/936>`__,
`#944 <https://github.com/mauvilsa/jsonargparse/pull/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
Expand Down
4 changes: 2 additions & 2 deletions jsonargparse/_signatures.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
60 changes: 52 additions & 8 deletions jsonargparse/_typehints.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
TypedDict,
TypeVar,
Union,
get_args,
)

from ._actions import (
Expand Down Expand Up @@ -397,6 +398,7 @@

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)
Expand Down Expand Up @@ -870,15 +872,48 @@
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)
Expand All @@ -890,9 +925,14 @@
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:
Expand Down Expand Up @@ -981,8 +1021,8 @@
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)
Expand Down Expand Up @@ -1995,10 +2035,14 @@
return action_or_typehint


def strip_module_names(string: str) -> str:
return re.sub(r"[A-Za-z0-9_<>.]+\.", "", string)

Check warning on line 2039 in jsonargparse/_typehints.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Simplify this regular expression to reduce its runtime, as it has super-linear performance due to backtracking.

See more on https://sonarcloud.io/project/issues?id=mauvilsa_jsonargparse&issues=AZ_LIVevnTNtJqMtejuM&open=AZ_LIVevnTNtJqMtejuM&pullRequest=944


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):
Expand Down
79 changes: 63 additions & 16 deletions jsonargparse_tests/test_postponed_annotations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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<MisspelledType>" 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")

Expand All @@ -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<MisspelledType>]"
else:
optional = "Unresolved<MisspelledType> | 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(
Expand All @@ -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 <class 'list'>"):
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<Unrebuildable[MisspelledType]>"


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<MisspelledType>])" in help_str
assert "type: list[Unresolved<MisspelledType>])" in help_str
assert "type: Callable[[Unresolved<MisspelledType>], int])" in help_str


# A TypedDict that inherits from a TypedDict in a different module must resolve the
Expand Down