diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 9675b898..e7d4aadd 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -59,22 +59,43 @@ Fixed `__). - ``shtab`` bash completion scripts not escaping choices and type messages, such that a value containing a single quote, e.g. a ``Literal`` type, produced a - script with invalid syntax (`#497 - `__). + script with invalid syntax (`#947 + `__). - Tests for ``shtab`` completions failing with ``shtab>=1.9.1`` due to a change in how it quotes the elements of the generated bash arrays. The completion - scripts themselves were not affected (`#497 - `__). + scripts themselves were not affected (`#947 + `__). +- ``fail_untyped=True`` failing for mandatory parameters that do have a type, + with an error that says the parameter "does not specify a type". This happened + for any type that jsonargparse can't validate, since the parameter was skipped, + making it indistinguishable from an untyped one. Now ``fail_untyped`` only fails + for parameters that have no type at all (`#948 + `__). +- Signature parameters with a pydantic type nested in a container, e.g. + ``list[HttpUrl]``, being skipped. Only pydantic types given as the entire type + of a parameter were registered for validation (`#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, e.g. + a default that is an arbitrary object. Now these values 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 (`#948 `__). 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 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 `__). +- Signature parameters with a type that jsonargparse can't validate are now + accepted instead of skipped. A type can't be validated when it fails to + resolve, e.g. a missing import or a typo in a postponed annotation, or when it + is not a supported type. Only the parts of the type that can't be validated + accept any value, e.g. a ``list[SomeType]`` still requires a list, and the + subtypes of a ``Union`` that can't be validated are no longer silently + discarded. These parts are shown in the help as ``Unvalidated<...>``, making + evident which type is not validated, and a debug log states the reason. See + the new documentation section :ref:`unvalidated-types` (`#936 + `__, `#944 + `__, `#948 + `__). - ``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 @@ -90,6 +111,10 @@ Changed `__). - The default print config argument name will remain as ``--print_config`` in v5.0.0, no longer changing as described in the deprecated section of v4.35.0. +- A signature parameter typed as ``jsonargparse.Namespace`` now raises a + ``ValueError`` when adding the arguments, instead of the parameter being + silently skipped. ``Namespace`` is only intended for parsing results (`#948 + `__). v4.50.0 (2026-07-22) diff --git a/DOCUMENTATION.rst b/DOCUMENTATION.rst index f7461ec4..468f6e7a 100644 --- a/DOCUMENTATION.rst +++ b/DOCUMENTATION.rst @@ -612,6 +612,52 @@ Some notes about this support are: (python 3.12+) and aliases created with ``typing_extensions.TypeAliasType``. +.. _unvalidated-types: + +Unvalidated types +----------------- + +When arguments are added from a signature, i.e. :meth:`add_function_arguments +<.ArgumentParser.add_function_arguments>`, :meth:`add_method_arguments +<.ArgumentParser.add_method_arguments>`, :meth:`add_class_arguments +<.ArgumentParser.add_class_arguments>` or a parameter of a :ref:`subclass type +`, there can be parameters with a type that jsonargparse can't +validate. Instead of skipping these parameters, which would make it impossible +to give them in the command line or a config file, the parameter is added with +only the parts of the type that can't be validated replaced by a type that +accepts any value. In the help these parts are shown as ``Unvalidated<...>``, +keeping the name that the source code has. For example, a class with an ``items: +list[SomeType] = []`` parameter for which ``SomeType`` can't be validated, is +shown in the help as: + +.. code-block:: text + + --myclass.items ITEMS (type: list[Unvalidated], default: []) + +A type or a part of it can't be validated when: + +- It failed to resolve, e.g. a missing import or a typo in a postponed + annotation. +- It is not a type that jsonargparse supports. + +To know which of the two it is for a given parameter, enable debut level +logging, see :ref:`logging`. The debug log states the reason for each of the +parts of the type that can't be validated. + +Note that only these parts accept any value. In the example above, the value +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``. + + .. _restricted-numbers: Restricted numbers @@ -1723,6 +1769,10 @@ used for class instantiation. It is called ``dict_kwargs`` because there are use cases in which ``**kwargs`` is used just as a dict, thus it also serves that purpose. +This section is about parameters whose *name* the resolvers can't determine. For +parameters that are resolved but have a type that can't be validated, see +:ref:`unvalidated-types`. + Take for example the following parsing and instantiation: .. testsetup:: unresolved diff --git a/jsonargparse/_signatures.py b/jsonargparse/_signatures.py index f7bd4417..df1f008b 100644 --- a/jsonargparse/_signatures.py +++ b/jsonargparse/_signatures.py @@ -32,12 +32,12 @@ is_optional, is_subclass_container_typehint, not_required_types, - replace_unresolved_forward_refs, + replace_unvalidatable_typehints, sequence_origin_types, strip_required_typehint, ) from ._util import NoneType, get_import_path, get_private_kwargs, get_typehint_origin, iter_to_set_str -from .typing import _LazyInitBaseClass, register_pydantic_type +from .typing import _LazyInitBaseClass, register_pydantic_types kinds = inspect._ParameterKind inspect_empty = inspect._empty @@ -341,15 +341,17 @@ def _add_signature_parameter( name = param.name kind = param.kind annotation = param.annotation - unresolved_replaced = replace_unresolved_forward_refs(annotation) - if unresolved_replaced is not annotation: + register_pydantic_types(annotation) # before the check of what can be validated + unvalidated: list = [] + unvalidatable_replaced = replace_unvalidatable_typehints(annotation, unvalidated) + if unvalidated: + reasons = " ".join(f"{u.name}: {u.reason}." for u in unvalidated) 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 shown in the help as Unresolved<...> and accept " - "any value, so the parameter is accepted but its value is not validated." + f'Parameter "{name}" from "{get_parameter_origins(param.component, param.parent)}" has ' + f"a type that can't be fully validated: {annotation}. {reasons} These parts are shown " + "in the help as Unvalidated<...> and accept any value without validation." ) - annotation = unresolved_replaced + annotation = unvalidatable_replaced if default == inspect_empty: default = param.default if default == inspect_empty: @@ -437,35 +439,31 @@ def _add_signature_parameter( ) if annotation in {str, int, float, bool} or is_subclass(annotation, (str, int, float)) or subclasses_disabled: kwargs["type"] = annotation - register_pydantic_type(annotation) elif annotation != inspect_empty: - try: - is_subclass_typehint = ActionTypeHint.is_subclass_typehint(annotation, all_subtypes=False) - is_return_subclass_typehint = ActionTypeHint.is_return_subclass_typehint(annotation) - kwargs["type"] = annotation - sub_add_kwargs: dict = {"fail_untyped": fail_untyped, "sub_configs": sub_configs} - if is_subclass_typehint or is_return_subclass_typehint: - prefix = f"{name}.init_args." - nested_skip = {s[len(prefix) :] for s in skip or [] if s.startswith(prefix)} - sub_add_kwargs["skip"] = nested_skip - else: - register_pydantic_type(annotation) - enable_path = sub_configs and ( - is_subclass_typehint - or is_return_subclass_typehint - or is_list_pathlike(annotation) - or is_subclass_container_typehint(annotation) - ) - args = ActionTypeHint.prepare_add_argument( - args=args, - kwargs=kwargs, - enable_path=enable_path, - container=container, - logger=self.logger, - sub_add_kwargs=sub_add_kwargs, - ) - except ValueError as ex: - self.logger.debug(skip_message + str(ex)) + # No need to handle unsupported types here, since replace_unvalidatable_typehints + # already replaced them by a type that accepts any value without validation. + is_subclass_typehint = ActionTypeHint.is_subclass_typehint(annotation, all_subtypes=False) + is_return_subclass_typehint = ActionTypeHint.is_return_subclass_typehint(annotation) + kwargs["type"] = annotation + sub_add_kwargs: dict = {"fail_untyped": fail_untyped, "sub_configs": sub_configs} + if is_subclass_typehint or is_return_subclass_typehint: + prefix = f"{name}.init_args." + nested_skip = {s[len(prefix) :] for s in skip or [] if s.startswith(prefix)} + sub_add_kwargs["skip"] = nested_skip + enable_path = sub_configs and ( + is_subclass_typehint + or is_return_subclass_typehint + or is_list_pathlike(annotation) + or is_subclass_container_typehint(annotation) + ) + args = ActionTypeHint.prepare_add_argument( + args=args, + kwargs=kwargs, + enable_path=enable_path, + container=container, + logger=self.logger, + sub_add_kwargs=sub_add_kwargs, + ) if "type" in kwargs or "action" in kwargs: sub_add_kwargs = { "fail_untyped": fail_untyped, diff --git a/jsonargparse/_typehints.py b/jsonargparse/_typehints.py index 2e1d1293..c4dcb8b1 100644 --- a/jsonargparse/_typehints.py +++ b/jsonargparse/_typehints.py @@ -12,6 +12,7 @@ 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 from importlib import import_module @@ -407,7 +408,7 @@ def is_supported_typehint(typehint, full=False): supported = ( typehint in root_types - or isinstance(typehint, UnresolvedType) + or isinstance(typehint, UnvalidatedType) or get_typehint_origin(typehint) in root_types or get_registered_type(typehint) is not None or is_subclass(typehint, Enum) @@ -886,18 +887,30 @@ 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. +unresolved_reason = "failed to resolve, e.g. a missing import or a typo" +unsupported_reason = "not a supported type" +unrebuildable_reason = "could not be rebuilt with its unvalidatable subtypes replaced" - 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. + +class UnvalidatedType: + """Type hint that stands in for one that can't be validated, accepting any value. + + A type hint can't be validated when it fails to resolve, e.g. a missing + import or a typo in a postponed annotation, or an unsupported type. + Instances are used as the type hint, keeping what the source code has, such + that the help shows it as Unvalidated<...>. """ - def __init__(self, typehint): + def __init__(self, typehint, reason: str = unresolved_reason): + self.reason = reason if isinstance(typehint, ForwardRef): self.name = typehint.__forward_arg__ elif isinstance(typehint, str): self.name = typehint + elif isinstance(typehint, TypeVar): + self.name = typehint.__name__ # the str of a TypeVar has a ~ prefix + elif inspect.isclass(typehint): + self.name = f"{typehint.__module__}.{typehint.__qualname__}" # the str of a class has a wrap 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)) @@ -908,47 +921,77 @@ def __call__(self): 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)}>" + return f"Unvalidated<{strip_module_names(self.name)}>" def __eq__(self, other): - return isinstance(other, UnresolvedType) and other.name == self.name + return isinstance(other, UnvalidatedType) and other.name == self.name def __hash__(self): - return hash((UnresolvedType, self.name)) + return hash((UnvalidatedType, self.name)) -def replace_unresolved_forward_refs(typehint): - """Replaces the unresolved forward references of a type hint with UnresolvedType. +def keep_subtype_as_is(subtype, typehint_origin) -> bool: + """Whether a subtype is never replaced, mirroring the exceptions that is_supported_typehint makes.""" + return ( + subtype is NoneType + or subtype is Ellipsis + or (typehint_origin is type and isinstance(subtype, TypeVar)) + or (isinstance(subtype, type) and subtype in leaf_types) + ) - 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 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. + +def replace_unvalidatable_typehints(typehint, unvalidated: list | None = None, replace_unsupported: bool = True): + """Replaces the parts of a type hint that can't be validated with UnvalidatedType. + + A type hint can't be validated when it fails to resolve, i.e. a postponed + annotation that remains a string or a ForwardRef, or when it is not + supported. Replacing only these parts with a type hint that accepts any + value keeps the parameter usable, though without validation. What can't be + validated is kept so that the help shows it, making it evident that the + value is not validated as the type in the code. The instances that replace + it are appended to the given unvalidated list. """ + + def replaced(typehint, reason): + unvalidatable = UnvalidatedType(typehint, reason) + if unvalidated is not None: + unvalidated.append(unvalidatable) + return unvalidatable + if isinstance(typehint, (str, ForwardRef)): - return UnresolvedType(typehint) - if get_typehint_origin(typehint) in literal_types: + return replaced(typehint, unresolved_reason) + typehint_origin = get_typehint_origin(typehint) + if typehint_origin in literal_types: return typehint # the args of a Literal are values, not types args = getattr(typehint, "__args__", None) # only a tuple, since e.g. types.UnionType and types.GenericAlias have __args__ as a # class level slot descriptor, which is truthy but not the subtypes of an instance - if not isinstance(args, tuple) or not args: - return typehint - new_args = tuple(replace_unresolved_forward_refs(a) for a in args) - if new_args == args: - return 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 UnresolvedType(typehint) + if isinstance(args, tuple) and args: + # Subtypes are only validated when the origin is a supported container type. For the + # others, e.g. a user defined generic, the subtypes are not used for validation, so + # only the unresolved ones are replaced, keeping the type hint as in the source code. + sub_replace_unsupported = replace_unsupported and typehint_origin in root_types + new_args = tuple( + a + if keep_subtype_as_is(a, typehint_origin) + else replace_unvalidatable_typehints(a, unvalidated, sub_replace_unsupported) + for a in args + ) + if new_args != args: + 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 replaced(typehint, unrebuildable_reason) + if replace_unsupported and not ActionTypeHint.is_supported_typehint(typehint, full=True): + return replaced(typehint, unsupported_reason) + return typehint def resolve_module_annotations(module: str, annotations: dict, global_vars: dict, logger=None) -> dict: @@ -1117,8 +1160,8 @@ def adapt_typehints( typehint_origin = get_typehint_origin(typehint) or typehint unset_sentinel = get_parsing_setting("unset_sentinel") - # Any and unresolved, i.e. no validation - if typehint == Any or isinstance(typehint, UnresolvedType): + # Any and unvalidated, i.e. no validation + 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) @@ -1126,6 +1169,8 @@ def adapt_typehints( with suppress(*get_loader_exceptions()): val, _ = parse_value_or_config(val, enable_path=False, simple_types=True) val = adapt_classes_any(val, serialize, instantiate_classes, sub_add_kwargs, logger) + if serialize: + val = serialize_unvalidated(val) # Literal elif typehint_origin in literal_types: @@ -2220,6 +2265,36 @@ 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 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 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. + """ + 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) + return 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) + + def callable_instances(cls: type): # https://stackoverflow.com/a/71568161/2732151 return isinstance(getattr(cls, "__call__", None), FunctionType) diff --git a/jsonargparse/typing.py b/jsonargparse/typing.py index 5c3daa01..fca2ffeb 100644 --- a/jsonargparse/typing.py +++ b/jsonargparse/typing.py @@ -731,4 +731,15 @@ def register_pydantic_type(class_type): ) +def register_pydantic_types(typehint): + """Registers the pydantic types found anywhere in a type hint, e.g. also in list[HttpUrl].""" + register_pydantic_type(typehint) + args = getattr(typehint, "__args__", None) + # only a tuple, since e.g. types.UnionType and types.GenericAlias have __args__ as a + # class level slot descriptor, which is truthy but not the subtypes of an instance + if isinstance(args, tuple): + for arg in args: + register_pydantic_types(arg) + + del _fail_already_registered diff --git a/jsonargparse_tests/test_postponed_annotations.py b/jsonargparse_tests/test_postponed_annotations.py index 0ac36e8c..7e53dc6e 100644 --- a/jsonargparse_tests/test_postponed_annotations.py +++ b/jsonargparse_tests/test_postponed_annotations.py @@ -32,10 +32,10 @@ from jsonargparse._typehints import ( Required, Unpack, - UnresolvedType, + UnvalidatedType, get_typed_dict_annotations, get_typed_dict_required_keys, - replace_unresolved_forward_refs, + replace_unvalidatable_typehints, type_to_str, ) from jsonargparse.typing import Path_drw @@ -348,7 +348,7 @@ def test_typed_dict_unresolvable_key_unpack(parser): # 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 + assert "--cls.typo TYPO (type: Unvalidated" in help_str def function_unresolvable_annotation(num: int = 1, typo: "MisspelledType" = None): # type: ignore[name-defined] # noqa: F821 @@ -366,8 +366,19 @@ def test_unresolvable_annotation_debug_log(parser, logger): parser.logger = logger with capture_logs(logger) as logs: parser.add_function_arguments(function_unresolvable_annotation, "fn") - assert "Unable to resolve the type of parameter" in logs.getvalue() - assert "typo" in logs.getvalue() + assert 'Parameter "typo"' in logs.getvalue() + assert "MisspelledType: failed to resolve" in logs.getvalue() + + +def function_unresolvable_required(typo: "MisspelledType"): # type: ignore[name-defined] # noqa: F821 + return typo # pragma: no cover + + +def test_unresolvable_annotation_mandatory_fail_untyped_true(parser): + # fail_untyped is about parameters that don't have a type, not about types that fail to resolve + added = parser.add_function_arguments(function_unresolvable_required, "fn", fail_untyped=True) + assert added == ["fn.typo"] + assert parser.parse_args(["--fn.typo=abc"]).fn.typo == "abc" def test_unresolvable_annotation_help(parser): @@ -375,9 +386,9 @@ def test_unresolvable_annotation_help(parser): 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]" + optional = "Optional[Unvalidated]" else: - optional = "Unresolved | None" + optional = "Unvalidated | None" assert f"--fn.typo TYPO (type: {optional}, default: null)" in help_str @@ -396,15 +407,15 @@ def function_unresolvable_subtype( return p1, p2, p3 # pragma: no cover -def test_unresolvable_subtype_replaced_with_unresolved(): - unresolved = UnresolvedType("MisspelledType") +def test_unresolvable_subtype_replaced_with_unvalidated(): + unvalidated = UnvalidatedType("MisspelledType") annotations = {p.name: p.annotation for p in get_params(function_unresolvable_subtype)} - 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] + assert replace_unvalidatable_typehints(annotations["p1"]) == List[unvalidated] + assert replace_unvalidatable_typehints(annotations["p2"]) == list[unvalidated] + assert replace_unvalidatable_typehints(annotations["p3"]) == Callable[[unvalidated], 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] + assert replace_unvalidatable_typehints(typing_callable) == typing.Callable[[unvalidated], int] def test_unresolvable_subtype_parse(parser): @@ -431,24 +442,24 @@ def __repr__(self): def test_types_with_args_slot_descriptor_unchanged(): # types.UnionType and types.GenericAlias have __args__ as a class level slot # descriptor, which is truthy but not the tuple of subtypes of an instance - assert replace_unresolved_forward_refs(UnionType) is UnionType - assert replace_unresolved_forward_refs(GenericAlias) is GenericAlias - assert replace_unresolved_forward_refs(Union[type, UnionType]) == Union[type, UnionType] + assert replace_unvalidatable_typehints(UnionType) is UnionType + assert replace_unvalidatable_typehints(GenericAlias) is GenericAlias + assert replace_unvalidatable_typehints(Union[type, UnionType]) == Union[type, UnionType] 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" + unvalidated = replace_unvalidatable_typehints(UnrebuildableTypehint()) + assert type_to_str(unvalidated) == "Unvalidated" 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 + assert "type: List[Unvalidated])" in help_str + assert "type: list[Unvalidated])" in help_str + assert "type: Callable[[Unvalidated], int])" in help_str # A TypedDict that inherits from a TypedDict in a different module must resolve the diff --git a/jsonargparse_tests/test_pydantic.py b/jsonargparse_tests/test_pydantic.py index 66c3bb55..a856bd89 100644 --- a/jsonargparse_tests/test_pydantic.py +++ b/jsonargparse_tests/test_pydantic.py @@ -273,6 +273,17 @@ def test_pydantic_types(self, valid_value, invalid_value, cast, type_str, monkey with pytest.raises(ArgumentError, match='Parser key "model.param"'): parser.parse_args([f"--model.param={invalid_value}"]) + @skip_if_pydantic_v1_on_v2 + def test_pydantic_type_as_subtype(self, parser): + self.num_models += 1 + Model = pydantic.create_model(f"Model{self.num_models}", param=(List[pydantic.HttpUrl], ...)) + + parser.add_argument("--model", type=Model) + cfg = parser.parse_args(['--model.param=["http://abc.es/"]']) + assert [str(v) for v in cfg.model.param] == ["http://abc.es/"] + with pytest.raises(ArgumentError, match='Parser key "model.param"'): + parser.parse_args(["--model.param=[-]"]) + @pytest.mark.skipif(not pydantic_supports_field_init, reason="Field.init is required") def test_dataclass_field_init_false(self, parser): parser.add_argument("--data", type=PydanticDataFieldInitFalse) diff --git a/jsonargparse_tests/test_signatures.py b/jsonargparse_tests/test_signatures.py index 5270e626..edc81146 100644 --- a/jsonargparse_tests/test_signatures.py +++ b/jsonargparse_tests/test_signatures.py @@ -631,14 +631,16 @@ def test_add_function_skip_positionals_invalid(parser): ctx.match("Unexpected number of positionals to skip") -def func_invalid_type(a1: None): +def func_unsupported_type(a1: None): return a1 # pragma: no cover -def test_add_function_invalid_type(parser): - with pytest.raises(ValueError) as ctx: - parser.add_function_arguments(func_invalid_type) - ctx.match("all mandatory parameters must have a supported type") +def test_add_function_unsupported_type(parser): + # not a supported type, so added without validation + assert ["a1"] == parser.add_function_arguments(func_unsupported_type) + help_str = get_parser_help(parser, strip=True) + # shown as null or None, depending on whether the annotation is postponed + assert "--a1 A1 (required, type: Unvalidated<" in help_str def func_implicit_optional(a1: int = None): # type: ignore[assignment] diff --git a/jsonargparse_tests/test_typehints.py b/jsonargparse_tests/test_typehints.py index d2a88f21..2b684091 100644 --- a/jsonargparse_tests/test_typehints.py +++ b/jsonargparse_tests/test_typehints.py @@ -10,6 +10,7 @@ import uuid from collections import OrderedDict, abc, deque from dataclasses import dataclass, field +from datetime import date from enum import Enum from pathlib import Path from textwrap import dedent @@ -20,10 +21,12 @@ Deque, Dict, FrozenSet, + Generic, Iterable, List, Literal, Mapping, + NoReturn, Optional, Protocol, Sequence, @@ -31,6 +34,7 @@ Tuple, Type, TypedDict, + TypeVar, Union, ) from unittest import mock @@ -45,10 +49,13 @@ NotRequired, Required, Unpack, + UnvalidatedType, get_all_subclass_paths, get_subclass_types, is_optional, is_typed_dict_subtype, + replace_unvalidatable_typehints, + serialize_unvalidated, type_to_str, ) from jsonargparse._util import get_import_path @@ -223,6 +230,54 @@ def test_type_any_dump(parser): assert {"any": "B"} == json_or_yaml_load(parser.dump(cfg)) +class NotSerializable: + def __repr__(self): + return "" + + +not_serializable = NotSerializable() +unable_to_serialize = "Unable to serialize instance " + + +def test_type_any_dump_not_serializable(parser): + # without a type there is no serializer, so instances that a config format can't + # represent are serialized the same as instances given for a subclass type + parser.add_argument("--any", type=Any, default=NotSerializable()) + 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: + 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): + # values that can be imported back are serialized as their import path + parser.add_argument("--cls", type=Any, default=NotSerializable) + parser.add_argument("--obj", type=Any, default=not_serializable) + cfg = parser.parse_args([]) + assert json_or_yaml_load(parser.dump(cfg)) == { + "cls": f"{__name__}.NotSerializable", + "obj": f"{__name__}.not_serializable", + } + + +def test_serialize_unvalidated_containers(): + 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 + + def test_type_typehint_without_arg(parser): parser.add_argument("--type", type=type) cfg = parser.parse_args(["--type=uuid.UUID"]) @@ -2179,6 +2234,159 @@ def test_lazy_instance_callable(): assert optimizer.params == [3, 4] +# unvalidated types tests + + +UnsupportedVar = TypeVar("UnsupportedVar") +unvalidated_var = UnvalidatedType(UnsupportedVar) + + +class UserGeneric(Generic[UnsupportedVar]): + def __init__(self, p1: int = 1): + self.p1 = p1 # pragma: no cover + + +def test_unvalidated_type_repr(): + assert repr(unvalidated_var) == "Unvalidated" + assert repr(UnvalidatedType(NoReturn)) == "Unvalidated" + + +@pytest.mark.parametrize( + ["typehint", "expected"], + [ + (UnsupportedVar, unvalidated_var), + (NoReturn, UnvalidatedType(NoReturn)), + (List[UnsupportedVar], List[unvalidated_var]), # type: ignore[valid-type] + (list[UnsupportedVar], list[unvalidated_var]), # type: ignore[valid-type] + (Dict[UnsupportedVar, int], Dict[unvalidated_var, int]), # type: ignore[valid-type] + (Optional[UnsupportedVar], Optional[unvalidated_var]), + (Union[UnsupportedVar, int], Union[unvalidated_var, int]), + (Tuple[UnsupportedVar, ...], Tuple[unvalidated_var, ...]), + (Callable[..., UnsupportedVar], Callable[..., unvalidated_var]), + (List[Union[UnsupportedVar, int]], List[Union[unvalidated_var, int]]), # type: ignore[valid-type] + (List[List[UnsupportedVar]], List[List[unvalidated_var]]), # type: ignore[valid-type] + ], + ids=str, +) +def test_replace_unsupported_typehints(typehint, expected): + assert replace_unvalidatable_typehints(typehint) == expected + + +@pytest.mark.parametrize( + "typehint", + [ + int, + List[int], + Optional[str], + Dict[str, int], + Tuple[int, ...], + Literal["a", "b"], + Type[UnsupportedVar], # a TypeVar is accepted as the subtype of type + UserGeneric[UnsupportedVar], # type: ignore[valid-type] # subtypes of a subclass type not validated + ], + ids=str, +) +def test_replace_unvalidatable_supported_unchanged(typehint): + assert replace_unvalidatable_typehints(typehint) == typehint + + +def function_unsupported_subtypes(p1: List[UnsupportedVar] = [], p2: Union[UnsupportedVar, int] = 0): + return p1, p2 # pragma: no cover + + +def test_unsupported_subtypes_parameters_added(parser): + added = parser.add_function_arguments(function_unsupported_subtypes, "fn") + assert added == ["fn.p1", "fn.p2"] + # the unsupported parts accept any value without validation + cfg = parser.parse_args(['--fn.p1=[{"a": 1}]', "--fn.p2=x"]) + assert cfg.fn.p1 == [{"a": 1}] + assert cfg.fn.p2 == "x" + # the supported parts are still validated + assert parser.parse_args(["--fn.p2=3"]).fn.p2 == 3 + with pytest.raises(ArgumentError, match="Expected a "): + parser.parse_args(["--fn.p1=1"]) + + +def test_unsupported_subtypes_help(parser): + parser.add_function_arguments(function_unsupported_subtypes, "fn") + help_str = get_parser_help(parser, strip=True) + # only the parts that are not supported are shown as unvalidated + if sys.version_info < (3, 14): + union = "Union[Unvalidated, int]" + else: + union = "Unvalidated | int" + assert "type: List[Unvalidated]" in help_str + assert f"type: {union}" in help_str + + +def test_unsupported_subtypes_debug_log(parser, logger): + parser.logger = logger + with capture_logs(logger) as logs: + parser.add_function_arguments(function_unsupported_subtypes, "fn") + assert "UnsupportedVar: not a supported type" in logs.getvalue() + + +def function_unsupported_optional(p1: UnsupportedVar = None): # type: ignore[assignment] + return p1 # pragma: no cover + + +def test_unsupported_type_not_required_added(parser): + added = parser.add_function_arguments(function_unsupported_optional, "fn") + assert added == ["fn.p1"] + assert parser.parse_args(["--fn.p1=x"]).fn.p1 == "x" + help_str = get_parser_help(parser, strip=True) + if sys.version_info < (3, 14): + optional = "Optional[Unvalidated]" + else: + optional = "Unvalidated | None" + assert f"--fn.p1 P1 (type: {optional}, default: null)" in help_str + + +def function_unsupported_required(p1: UnsupportedVar, p2: List[UnsupportedVar]): + return p1, p2 # pragma: no cover + + +def test_unvalidated_mandatory_fail_untyped_true(parser): + # fail_untyped is about parameters that don't have a type, not about types that + # jsonargparse can't validate, so these are added and required as any other + added = parser.add_function_arguments(function_unsupported_required, "fn", fail_untyped=True) + assert added == ["fn.p1", "fn.p2"] + cfg = parser.parse_args(["--fn.p1=x", '--fn.p2=["y"]']) + assert cfg.fn == Namespace(p1="x", p2=["y"]) + with pytest.raises(ArgumentError, match="arguments are required: fn.p1"): + parser.parse_args(['--fn.p2=["y"]']) + + +def function_unvalidated_not_serializable( + p1: UnsupportedVar = NotSerializable(), # type: ignore[assignment] + p2: List[UnsupportedVar] = [NotSerializable()], # type: ignore[list-item] + p3: UnsupportedVar = not_serializable, # type: ignore[assignment] +): + return p1, p2, p3 # pragma: no cover + + +def test_unvalidated_not_serializable_default_dump(parser): + # a default that a config format can't represent must not make dump fail + parser.add_function_arguments(function_unvalidated_not_serializable, "fn") + cfg = parser.parse_args([]) + with catch_warnings(record=True) as w: + dump = json_or_yaml_load(parser.dump(cfg)) + assert dump["fn"]["p1"] == unable_to_serialize + assert dump["fn"]["p2"] == [unable_to_serialize] + assert dump["fn"]["p3"] == f"{__name__}.not_serializable" # importable back, so its import path + assert unable_to_serialize in str(w[0].message) + + +def function_namespace_parameter(p1: Namespace = None): # type: ignore[assignment] + return p1 # pragma: no cover + + +def test_namespace_signature_parameter_fails(parser): + # deliberate user facing error, not turned into an unvalidated type + with pytest.raises(ValueError, match="Namespace .* not supported as a type"): + parser.add_function_arguments(function_namespace_parameter, "fn") + + # other tests