From acde53c86f02d2f50f46f143214e552dbda6c363 Mon Sep 17 00:00:00 2001 From: Mauricio Villegas <5780272+mauvilsa@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:44:09 +0200 Subject: [PATCH] New Untyped type, fail_untyped='all' and debug logs for adjusted signature parameters --- CHANGELOG.rst | 15 ++++ DOCUMENTATION.rst | 81 +++++++++------------ jsonargparse/_cli.py | 10 +-- jsonargparse/_signatures.py | 88 +++++++++++++++++------ jsonargparse/_typehints.py | 24 ++++++- jsonargparse_tests/test_deprecated.py | 2 +- jsonargparse_tests/test_link_arguments.py | 18 ++++- jsonargparse_tests/test_signatures.py | 71 +++++++++++++++--- jsonargparse_tests/test_subclasses.py | 23 ++++++ jsonargparse_tests/test_typehints.py | 12 +++- 10 files changed, 257 insertions(+), 87 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 180c4504..11f84b65 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -42,6 +42,10 @@ Added function with a compatible signature, so the import path of a function is accepted as value, see :ref:`type-hints` (`#963 `__). +- New ``fail_untyped="all"`` for the add signature methods and ``auto_cli``, + which raises an exception for all parameters that don't have a type + annotation, not only the required ones (`#965 + `__). Fixed ^^^^^ @@ -86,6 +90,17 @@ Changed (`#964 `__). - New :ref:`migrate-v5` guide that describes what needs to be changed to migrate from v4 to v5 (`#964 `__). +- Signature parameters without a type annotation now get type ``Untyped`` + instead of ``Any``, so that the help makes evident that the value is not + validated. A parameter that has a default gets ``Union[, + Untyped]`` as before, now also with ``fail_untyped=False``, which previously + gave ``Any`` (`#965 `__). +- New debug logs for the cases in which the type or the requiredness of a + signature parameter is not what the signature says: no type annotation, a + parameter skipped because its name starts with ``_``, a ``None`` default that + makes the type optional, a ``NotRequired`` parameter without a default and a + parameter that is the target of a link (`#965 + `__). Deprecated ^^^^^^^^^^ diff --git a/DOCUMENTATION.rst b/DOCUMENTATION.rst index f1db3742..ffa0fa8f 100644 --- a/DOCUMENTATION.rst +++ b/DOCUMENTATION.rst @@ -635,53 +635,43 @@ an argument of type ``Union[int, list[int]]``, ``--val=1`` gives ``1``, while 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 -`, some parameters can have a type that jsonargparse can't -validate. The same holds for the keys of a ``TypedDict``, however the argument -is added. Skipping these parameters would make it impossible to give them at -all, so instead only the parts of the type that can't be validated are replaced -by a type that accepts any value. The help shows these parts as -``Unvalidated<...>``, keeping the name used in the source code. For example, a -class with an ``items: list[SomeType] = []`` parameter for which ``SomeType`` -can't be validated is shown in the help as: +A :ref:`signature parameter ` or a ``TypedDict`` key +can have a type that jsonargparse can't validate. The argument is still added, +with only the parts of the type that can't be validated replaced by a type that +accepts any value. The help shows these parts as ``Unvalidated<...>``, keeping +the name used in the source code. For example, a class with a parameter +``items: list[SomeType] = []`` 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: +Only these parts accept any value: in the example the value must still be a +list, and in a ``Union`` the other subtypes are still validated. 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, e.g. a ``TypeVar`` that stands for nothing, see :ref:`generic-types`. -To know which of the two it is for a given parameter, enable debug level -logging, see :ref:`logging`. The debug log gives the reason for each part of the -type that can't be validated. - -Only these parts accept any value. In the example above the value must still be -a list, only its items are not validated. Likewise, in a ``Union`` only the -subtypes that can't be validated accept any value, the others are validated as -usual. +The debug log gives the reason for each part, see :ref:`logging`. A parameter +without a type annotation is shown as ``Untyped`` and behaves the same, see +:ref:`classes-methods-functions`. Since there is no type to serialize with, :meth:`dump <.ArgumentParser.dump>` -and ``--print_config`` derive a type from the value itself, so that it is -serialized 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 like the instances given for a :ref:`subclass type `: as -an import path when the value can be imported back, and otherwise as a message -saying that it was not serializable, together with a warning. +and ``--print_config`` derive a type from the value itself. A value of a type +that jsonargparse doesn't support, e.g. an arbitrary object, is serialized like +the instances given for a :ref:`subclass type `: as an import path +when it can be imported back, and otherwise as a message saying that it was not +serializable, together with a warning. 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 dumped as a -list and parses back as a list, and an ``Enum`` member is dumped 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 applies equally to arguments typed as -``Any``/``object``. +the config formats represent round-trip, e.g. a ``set`` is dumped and parsed +back as a list, and an ``Enum`` member as its name. A warning is raised for each +dumped value that loses its type this way. All of the above applies equally to +``Any`` and ``object``. .. _restricted-numbers: @@ -1556,17 +1546,17 @@ instantiation and for the method call. A wide range of type hints is supported for signature parameters, see :ref:`type-hints`. Notes about the add signature methods: -- With the default ``fail_untyped=True``, all required parameters must have a - type, otherwise an exception is raised. Positional-only parameters are always - required. +- A parameter without a type annotation, or with a type that can only be + validated in part, is added with a type that accepts any value, see + :ref:`unvalidated-types`. Without an annotation but with a default, the type + is ``Union[, Untyped]``, i.e. a value is converted to the + default's type when it accepts it. -- A parameter that has a default but no type annotation is added with type - ``Union[, Any]``, so any value is accepted. With - ``fail_untyped=False``, a required parameter without a type gets type ``Any``. - -- A parameter whose type can only be validated in part is added with the - remaining parts replaced by a type that accepts any value, see - :ref:`unvalidated-types`. +- ``fail_untyped`` decides which parameters without a type annotation raise an + exception instead: the required ones with the default ``True``, all of them + with ``"all"``, and none with ``False``. Positional-only parameters are always + required. Use ``"all"`` only for code you own, since one untyped parameter of + a dependency would make its signature impossible to add. - Parameters whose name starts with ``_`` are considered internal and skipped, unless they are required. @@ -1992,7 +1982,7 @@ Most of the Python standard library has its types in stubs, for example: Without the stubs resolver, that :meth:`add_function_arguments <.ArgumentParser.add_function_arguments>` call needs ``fail_untyped=False``, and -then ``a`` and ``b`` get type ``Any`` instead of ``float``, so an invalid value +then ``a`` and ``b`` get ``Untyped`` instead of ``float``, so an invalid value such as a string would not fail. The defaults of parameters found only through stubs are not known. The help then @@ -2154,9 +2144,8 @@ would also accept subclasses of ``MyClass``, and the config would be: .. note:: - A parameter of type ``Any`` or ``object``, which is also what - ``fail_untyped=False`` gives, accepts a dict with ``class_path`` and - ``init_args``, and the class is parsed and instantiated. + A parameter of type ``Any``, ``object``, or ``Untyped``, accepts a dict with + ``class_path`` and ``init_args``, and the class is parsed and instantiated. This instantiation is deprecated. From v5.0.0 the subclass spec is kept as is, so that the code receiving it decides whether to instantiate it. Set diff --git a/jsonargparse/_cli.py b/jsonargparse/_cli.py index 9f7955f7..7976ae60 100644 --- a/jsonargparse/_cli.py +++ b/jsonargparse/_cli.py @@ -9,6 +9,7 @@ from ._deprecated import deprecation_warning_cli_return_parser, get_implicit_auto_cli_components from ._namespace import Namespace, dict_to_namespace from ._optionals import get_doc_short_description +from ._signatures import FailUntyped from ._util import capture_parser, default_config_option_help __all__ = ["auto_cli", "auto_parser"] @@ -31,7 +32,7 @@ def auto_cli( set_defaults: dict[str, Any] | None = None, as_positional: bool = True, return_instance: bool = False, - fail_untyped: bool = True, + fail_untyped: FailUntyped = True, parser_class: type[ArgumentParser] = ArgumentParser, **kwargs, ): @@ -56,7 +57,8 @@ def auto_cli( as_positional: Whether to add required parameters as positional arguments. return_instance: Whether class components should be instantiated directly and returned, i.e. without exposing class methods as subcommands. - fail_untyped: Whether to raise exception if a required parameter does not have a type. + fail_untyped: Whether to raise an exception for parameters that don't have a type: + True for the required ones, "all" for all of them, False for none. parser_class: The :class:`ArgumentParser` subclass to use. **kwargs: Used to instantiate :class:`.ArgumentParser`. @@ -147,7 +149,7 @@ def _add_subcommands( config_help: str, as_positional: bool, return_instance: bool, - fail_untyped: bool, + fail_untyped: FailUntyped, ) -> None: subcommands = parser.add_subcommands(required=True) for name, component in components.items(): @@ -177,7 +179,7 @@ def _add_component_to_parser( parser: ArgumentParser, as_positional: bool, return_instance: bool, - fail_untyped: bool, + fail_untyped: FailUntyped, config_help: str, ): kwargs: dict = {"as_positional": as_positional, "fail_untyped": fail_untyped, "sub_configs": True} diff --git a/jsonargparse/_signatures.py b/jsonargparse/_signatures.py index 119bc725..8e106510 100644 --- a/jsonargparse/_signatures.py +++ b/jsonargparse/_signatures.py @@ -6,7 +6,7 @@ import re from argparse import SUPPRESS, ArgumentParser from collections.abc import Callable -from typing import Any, Optional, Union +from typing import Any, Literal, Optional, Union from ._actions import _ActionConfigLoad from ._common import ( @@ -26,6 +26,7 @@ from ._required import set_required from ._typehints import ( ActionTypeHint, + Untyped, callable_instances, get_subclass_names, is_list_pathlike, @@ -35,7 +36,9 @@ replace_type_vars, replace_unvalidatable_typehints, sequence_origin_types, + sort_unions_in_typehint, strip_required_typehint, + type_to_str, ) from ._util import NoneType, get_import_path, get_private_kwargs, get_typehint_origin, iter_to_set_str from .typing import _LazyInitBaseClass, register_pydantic_types @@ -43,6 +46,14 @@ kinds = inspect._ParameterKind inspect_empty = inspect._empty +FailUntyped = Union[bool, Literal["all"]] +fail_untyped_values: tuple = (True, False, "all") + + +def validate_fail_untyped(fail_untyped) -> None: + if not any(fail_untyped is value for value in fail_untyped_values): + raise ValueError(f"Expected 'fail_untyped' to be True, False or 'all', got: {fail_untyped!r}") + class SignatureArguments(LoggerProperty): """Methods to add arguments based on signatures to an :class:`ArgumentParser` instance.""" @@ -57,7 +68,7 @@ def add_class_arguments( default: dict | Namespace | type | None = None, skip: set[str | int] | None = None, instantiate: bool = True, - fail_untyped: bool = True, + fail_untyped: FailUntyped = True, sub_configs: bool = False, **kwargs, ) -> list[str]: @@ -74,7 +85,8 @@ def add_class_arguments( skip: Names of parameters or number of positionals that should be skipped. instantiate: Whether the class group should be instantiated by :meth:`instantiate <.ArgumentParser.instantiate>`. - fail_untyped: Whether to raise exception if a required parameter does not have a type. + fail_untyped: Whether to raise an exception for parameters that don't have a type: + True for the required ones, "all" for all of them, False for none. sub_configs: Whether subclass type hints should be loadable from inner config file. Returns: @@ -147,7 +159,7 @@ def add_method_arguments( as_group: bool = True, as_positional: bool = False, skip: set[str | int] | None = None, - fail_untyped: bool = True, + fail_untyped: FailUntyped = True, sub_configs: bool = False, ) -> list[str]: """Adds arguments from a class based on its type hints and docstrings. @@ -161,7 +173,8 @@ def add_method_arguments( as_group: Whether arguments should be added to a new argument group. as_positional: Whether to add required parameters as positional arguments. skip: Names of parameters or number of positionals that should be skipped. - fail_untyped: Whether to raise exception if a required parameter does not have a type. + fail_untyped: Whether to raise an exception for parameters that don't have a type: + True for the required ones, "all" for all of them, False for none. sub_configs: Whether subclass type hints should be loadable from inner config file. Returns: @@ -195,7 +208,7 @@ def add_function_arguments( as_group: bool = True, as_positional: bool = False, skip: set[str | int] | None = None, - fail_untyped: bool = True, + fail_untyped: FailUntyped = True, sub_configs: bool = False, ) -> list[str]: """Adds arguments from a function based on its type hints and docstrings. @@ -208,7 +221,8 @@ def add_function_arguments( as_group: Whether arguments should be added to a new argument group. as_positional: Whether to add required parameters as positional arguments. skip: Names of parameters or number of positionals that should be skipped. - fail_untyped: Whether to raise exception if a required parameter does not have a type. + fail_untyped: Whether to raise an exception for parameters that don't have a type: + True for the required ones, "all" for all of them, False for none. sub_configs: Whether subclass type hints should be loadable from inner config file. Returns: @@ -245,7 +259,7 @@ def _add_signature_arguments( as_group: bool = True, as_positional: bool = False, skip: set[str | int] | None = None, - fail_untyped: bool = True, + fail_untyped: FailUntyped = True, sub_configs: bool = False, instantiate: bool = True, linked_targets: set[str] | None = None, @@ -260,7 +274,8 @@ def _add_signature_arguments( as_group: Whether arguments should be added to a new argument group. as_positional: Whether to add required parameters as positional arguments. skip: Names of parameters or number of positionals that should be skipped. - fail_untyped: Whether to raise exception if a required parameter does not have a type. + fail_untyped: Whether to raise an exception for parameters that don't have a type: + True for the required ones, "all" for all of them, False for none. sub_configs: Whether subclass type hints should be loadable from inner config file. instantiate: Whether the class group should be instantiated. @@ -268,8 +283,9 @@ def _add_signature_arguments( The list of arguments added. Raises: - ValueError: When there are required parameters without at least one valid type. + ValueError: When there are parameters without a type that fail_untyped requires to have one. """ + validate_fail_untyped(fail_untyped) params = get_signature_parameters(function_or_class, method_name, logger=self.logger) skip_positionals = [s for s in (skip or []) if isinstance(s, int) and s != 0] @@ -331,7 +347,7 @@ def _add_signature_parameter( param, added_args: list[str], skip: set[str] | None = None, - fail_untyped: bool = True, + fail_untyped: FailUntyped = True, as_positional: bool = False, sub_configs: bool = False, instantiate: bool = True, @@ -342,6 +358,7 @@ def _add_signature_parameter( name = param.name kind = param.kind annotation = param.annotation + untyped = annotation == inspect_empty src = get_parameter_origins(param.component, param.parent) skip_message = f'Skipping parameter "{name}" from "{src}" because of: ' # Before anything is done with the annotation, so that a type that jsonargparse @@ -379,6 +396,10 @@ def _add_signature_parameter( default = unset_sentinel if unset_sentinel is not None else None elif get_typehint_origin(annotation) in not_required_types: default = SUPPRESS + self.logger.debug( + f'Parameter "{name}" from "{src}" is NotRequired and does not have a default, ' + "so it is not included in the parsed namespace unless given." + ) # Determine argument characteristics based on parameter kind and default value if kind == kinds.POSITIONAL_ONLY: is_required = True # Always required @@ -396,37 +417,64 @@ def _add_signature_parameter( # Checked before linked_targets and fail_untyped adjust is_required, since the wrappers # are meant to agree with the requiredness that the signature itself defines. annotation = strip_required_typehint(annotation, is_required, f'parameter "{name}" from "{src}"') - if not fail_untyped and annotation == inspect_empty: - if is_required and os.environ.get("JSONARGPARSE_DEPRECATION_WARNINGS", "").lower() == "all": + if is_required and annotation == inspect_empty and fail_untyped is False: + if os.environ.get("JSONARGPARSE_DEPRECATION_WARNINGS", "").lower() == "all": deprecation_warning( "fail_untyped_false_required_parameter", "With fail_untyped=False, required parameters without a type annotation are currently " - "set to optional with default None. In v5 the type will be set to Any but the parameter " - "will remain required.", + "set to optional with default None. In v5 the type will be set to Untyped but the " + "parameter will remain required.", stacklevel=4, ) - annotation = Any - default = None if is_required else default + annotation = Untyped + default = None is_required = False is_required_link_target = False if is_required and linked_targets is not None and name in linked_targets: default = None is_required = False is_required_link_target = True + self.logger.debug( + f'Parameter "{name}" from "{src}" is the target of a link, so it is not required ' + "and its value is not taken from the command line." + ) if not is_required and name[0] == "_": + self.logger.debug(skip_message + "Name starts with '_' and the parameter is not required.") return if is_factory_class(default): default = param.parent.__dataclass_fields__[name].default_factory() - if annotation == inspect_empty and not is_required: - annotation = Union[type(default), Any] + if annotation == inspect_empty: + if fail_untyped == "all": + raise ValueError( + "With fail_untyped='all', all parameters must have a supported type." + f" Parameter '{name}' from '{src}' does not specify a type." + ) + if not is_required: + # The type of the default is attempted first, so that a value that it accepts is + # converted as it would be for a parameter that has the type in its signature. + annotation = Union[type(default), Untyped] if "help" not in kwargs: kwargs["help"] = param.doc if not is_required: kwargs["default"] = default if default is None and not is_optional(annotation, object) and not is_required_link_target: annotation = Optional[annotation] + if not untyped and param.default is None: + # only when the default in the signature is what makes the type optional, i.e. + # not when the caller gives the default, as add_subclass_arguments does + self.logger.debug( + f'Parameter "{name}" from "{src}" has None as default, so its type is ' + f"changed to {type_to_str(sort_unions_in_typehint(annotation))}." + ) elif not as_positional or is_non_positional: kwargs["required"] = True + if untyped and annotation != inspect_empty: + # only when the parameter got a type, otherwise fail_untyped raises further below + self.logger.debug( + f'Parameter "{name}" from "{src}" does not have a type annotation. Added as ' + f"{type_to_str(sort_unions_in_typehint(annotation))}, thus any value is accepted, " + "without validation." + ) is_subclass_typehint = False nested_skip: set[str] = set() subclasses_disabled = is_subclasses_disabled(annotation) @@ -489,7 +537,7 @@ def _add_signature_parameter( if nested_skip: action.sub_add_kwargs["skip"] = nested_skip added_args.append(dest) - elif is_required and fail_untyped: + elif is_required and fail_untyped is True: raise ValueError( "With fail_untyped=True, all mandatory parameters must have a supported" f" type. Parameter '{name}' from '{src}' does not specify a type." diff --git a/jsonargparse/_typehints.py b/jsonargparse/_typehints.py index 5dfb00be..c62d3d7e 100644 --- a/jsonargparse/_typehints.py +++ b/jsonargparse/_typehints.py @@ -924,6 +924,7 @@ def resolve_forward_ref(ref, global_vars=None): 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" +untyped_reason = "no type annotation" class UnvalidatedType: @@ -958,10 +959,29 @@ def __repr__(self): return f"Unvalidated<{strip_module_names(self.name)}>" def __eq__(self, other): - return isinstance(other, UnvalidatedType) and other.name == self.name + # the class is part of the comparison, since subclasses stand for a different reason + return type(other) is type(self) and other.name == self.name def __hash__(self): - return hash((UnvalidatedType, self.name)) + return hash((type(self), self.name)) + + +class UntypedType(UnvalidatedType): + """Type hint that stands in for a parameter that has no type annotation, accepting any value. + + Only instantiated once, see the Untyped singleton. There is no type in the + source code to keep, thus the help shows it as Untyped. + """ + + def __init__(self): + self.reason = untyped_reason + self.name = "" + + def __repr__(self): + return "Untyped" + + +Untyped = UntypedType() def accepts_any_value(typehint) -> bool: diff --git a/jsonargparse_tests/test_deprecated.py b/jsonargparse_tests/test_deprecated.py index cceeb283..f4a0b8c0 100644 --- a/jsonargparse_tests/test_deprecated.py +++ b/jsonargparse_tests/test_deprecated.py @@ -1428,7 +1428,7 @@ def with_untyped_required_warning(b1, b2=None): parser.add_function_arguments(with_untyped_required_warning, fail_untyped=False) assert len(warnings) == 1 assert "fail_untyped=False" in str(warnings[0].message) - assert "In v5 the type will be set to Any but the parameter will remain required" in str(warnings[0].message) + assert "In v5 the type will be set to Untyped but the parameter will remain required" in str(warnings[0].message) assert parser.get_defaults() == Namespace(a1=None, a2=None, b1=None, b2=None) diff --git a/jsonargparse_tests/test_link_arguments.py b/jsonargparse_tests/test_link_arguments.py index 68635371..3156125c 100644 --- a/jsonargparse_tests/test_link_arguments.py +++ b/jsonargparse_tests/test_link_arguments.py @@ -16,7 +16,13 @@ set_parsing_settings, ) from jsonargparse._optionals import docstring_parser_support -from jsonargparse_tests.conftest import get_parse_args_stdout, get_parser_help, json_or_yaml_dump, json_or_yaml_load +from jsonargparse_tests.conftest import ( + capture_logs, + get_parse_args_stdout, + get_parser_help, + json_or_yaml_dump, + json_or_yaml_load, +) # tests for links applied on parse @@ -189,7 +195,7 @@ def __init__(self, v3: int): self.v3 = v3 # pragma: no cover -def test_on_parse_add_subclass_arguments(parser, subtests): +def test_on_parse_add_subclass_arguments(parser, subtests, logger): def add(v1, v2): return v1 + v2 @@ -203,9 +209,15 @@ def add(v1, v2): } with subtests.test("compute_fn result"): - cfg = parser.parse_args([f"--s1={json.dumps(s1_value)}", f"--s2={__name__}.ClassS2"]) + parser.logger = logger # the target is added to the parser when the class path is first given + with capture_logs(logger) as logs: + cfg = parser.parse_args([f"--s1={json.dumps(s1_value)}", f"--s2={__name__}.ClassS2"]) + parser.logger = False assert cfg.s2.init_args.v3 == 4 assert cfg.s2.init_args.v3 == cfg.s1.init_args.v1 + cfg.s1.init_args.v2 + assert f'"v3" from "{__name__}.ClassS2.__init__" is the target of a link, so it is not required' in ( + logs.getvalue() + ) with subtests.test("dump removal of target"): cfg = parser.parse_args([f"--s1={json.dumps(s1_value)}", f"--s2={__name__}.ClassS2"]) diff --git a/jsonargparse_tests/test_signatures.py b/jsonargparse_tests/test_signatures.py index b3ad8695..a1938bfd 100644 --- a/jsonargparse_tests/test_signatures.py +++ b/jsonargparse_tests/test_signatures.py @@ -18,6 +18,8 @@ ) from jsonargparse._optionals import docstring_parser_support from jsonargparse._subcommands import find_action +from jsonargparse._typehints import Untyped, type_to_str +from jsonargparse._util import NoneType from jsonargparse_tests.conftest import ( capture_logs, get_parse_args_stdout, @@ -226,8 +228,12 @@ def __init__(self, _a0=None): pass # pragma: no cover -def test_add_class_skipped_underscore_parameter(parser): - assert [] == parser.add_class_arguments(SkippedUnderscoreParam) +def test_add_class_skipped_underscore_parameter(parser, logger): + parser.logger = logger + with capture_logs(logger) as logs: + assert [] == parser.add_class_arguments(SkippedUnderscoreParam) + assert 'Skipping parameter "_a0"' in logs.getvalue() + assert "because of: Name starts with '_' and the parameter is not required." in logs.getvalue() class WithNew: @@ -804,9 +810,14 @@ def func_implicit_optional(a1: int = None): # type: ignore[assignment] return a1 # pragma: no cover -def test_add_function_implicit_optional(parser): - parser.add_function_arguments(func_implicit_optional) - assert None is parser.parse_args(["--a1=null"]).a1 +def test_add_function_implicit_optional(parser, logger): + parser.logger = logger + with capture_logs(logger) as logs: + parser.add_function_arguments(func_implicit_optional) + assert None is parser.parse_args(["--a1=null"]).a1 + if sys.version_info >= (3, 11): # in python<3.11 get_type_hints already makes the type optional + assert f'"a1" from "{__name__}.func_implicit_optional" has None as default, so its type is ' in logs.getvalue() + assert f"changed to {type_to_str(Optional[int])}." in logs.getvalue() def func_type_as_string(a2: "int"): @@ -825,13 +836,21 @@ def func_untyped_params(a1, a2=None): def test_add_function_fail_untyped_true_untyped_params(parser): with pytest.raises(ValueError) as ctx: parser.add_function_arguments(func_untyped_params, fail_untyped=True) + ctx.match("With fail_untyped=True, all mandatory parameters must have a supported type") ctx.match("Parameter 'a1' from .* does not specify a type") -def test_add_function_fail_untyped_false(parser): - added_args = parser.add_function_arguments(func_untyped_params, fail_untyped=False) +def test_add_function_fail_untyped_false(parser, logger): + parser.logger = logger + with capture_logs(logger) as logs: + added_args = parser.add_function_arguments(func_untyped_params, fail_untyped=False) + assert Namespace(a1=None, a2=None) == parser.parse_args([]) + help_str = get_parser_help(parser) assert ["a1", "a2"] == added_args - assert Namespace(a1=None, a2=None) == parser.parse_args([]) + assert f"--a1 A1 (type: {type_to_str(Union[NoneType, Untyped])}, default: null)" in help_str + assert f"--a2 A2 (type: {type_to_str(Union[NoneType, Untyped])}, default: null)" in help_str + assert f'"a1" from "{__name__}.func_untyped_params" does not have a type annotation. Added as ' in logs.getvalue() + assert f"{type_to_str(Union[NoneType, Untyped])}, thus any value is accepted" in logs.getvalue() def func_untyped_optional(a1: str, a2=None): @@ -844,6 +863,42 @@ def test_add_function_fail_untyped_true_untyped_optional(parser): assert Namespace(a1="x", a2=None) == parser.parse_args(["--a1=x"]) +def func_untyped_default(a1: str, a2=3): + return a1 # pragma: no cover + + +@pytest.mark.parametrize("fail_untyped", [True, False]) +def test_add_function_untyped_default_type_from_default(parser, logger, fail_untyped): + parser.logger = logger + with capture_logs(logger) as logs: + added_args = parser.add_function_arguments(func_untyped_default, fail_untyped=fail_untyped) + help_str = get_parser_help(parser) + assert 4 == parser.parse_args(["--a1=x", "--a2=4"]).a2 + assert "y" == parser.parse_args(["--a1=x", "--a2=y"]).a2 + assert ["a1", "a2"] == added_args + assert f"--a2 A2 (type: {type_to_str(Union[int, Untyped])}, default: 3)" in help_str + assert f'"a2" from "{__name__}.func_untyped_default" does not have a type annotation. Added as ' in logs.getvalue() + assert f"{type_to_str(Union[int, Untyped])}, thus any value is accepted" in logs.getvalue() + + +def test_add_function_fail_untyped_all(parser): + with pytest.raises(ValueError) as ctx: + parser.add_function_arguments(func_untyped_default, fail_untyped="all") + ctx.match("With fail_untyped='all', all parameters must have a supported type") + ctx.match("Parameter 'a2' from .* does not specify a type") + + +def test_add_function_fail_untyped_all_typed(parser): + added_args = parser.add_function_arguments(func_type_as_string, fail_untyped="all") + assert ["a2"] == added_args + + +def test_add_function_fail_untyped_unexpected_value(parser): + with pytest.raises(ValueError) as ctx: + parser.add_function_arguments(func_untyped_default, fail_untyped="none") + ctx.match("Expected 'fail_untyped' to be True, False or 'all', got: 'none'") + + def test_add_function_group_config(parser, tmp_cwd): parser.add_function_arguments(func, "func") diff --git a/jsonargparse_tests/test_subclasses.py b/jsonargparse_tests/test_subclasses.py index 9b5cdb29..26d40ef2 100644 --- a/jsonargparse_tests/test_subclasses.py +++ b/jsonargparse_tests/test_subclasses.py @@ -396,6 +396,29 @@ def test_subclass_allow_untyped_parameters_help(parser): assert "--c1.a2 A2" in help_str +class UntypedDefaultParam: + def __init__(self, a1: int = 1, a2="x"): + self.a1 = a1 # pragma: no cover + + +def func_subclass_untyped_default(c1: UntypedDefaultParam): + return c1 # pragma: no cover + + +def test_subclass_fail_untyped_all_propagated_to_subclass(parser): + parser.add_function_arguments(func_subclass_untyped_default, fail_untyped="all") + with pytest.raises(ArgumentError) as ctx: + parser.parse_args([f"--c1={__name__}.UntypedDefaultParam"]) + ctx.match("With fail_untyped='all', all parameters must have a supported type") + ctx.match("Parameter 'a2' from .* does not specify a type") + + +def test_subclass_fail_untyped_true_default_propagated_to_subclass(parser): + parser.add_function_arguments(func_subclass_untyped_default, fail_untyped=True) + cfg = parser.parse_args([f"--c1={__name__}.UntypedDefaultParam", "--c1.a2=2"]) + assert cfg.c1.init_args == Namespace(a1=1, a2="2") + + class MergeInitArgs(BaseC): def __init__(self, param_a: int = 1, param_b: str = "x", **kwargs): super().__init__(**kwargs) # pragma: no cover diff --git a/jsonargparse_tests/test_typehints.py b/jsonargparse_tests/test_typehints.py index 85e523ec..2d2e92f7 100644 --- a/jsonargparse_tests/test_typehints.py +++ b/jsonargparse_tests/test_typehints.py @@ -1960,15 +1960,21 @@ def __init__(self, p1: Required[int] = 1): @skip_if_no_required -def test_signature_params_wrappers_removed_from_help(parser, wrappers_module): - added = parser.add_class_arguments(wrappers_module.WrapperParams, "cls") +def test_signature_params_wrappers_removed_from_help(parser, wrappers_module, logger): + parser.logger = logger + with capture_logs(logger) as logs: + added = parser.add_class_arguments(wrappers_module.WrapperParams, "cls") + cfg = parser.parse_args(["--cls.p1=1"]) assert added == ["cls.p1", "cls.p2", "cls.p3"] + src = "required_wrappers_module.WrapperParams.__init__" + assert f'"p2" from "{src}" is NotRequired and does not have a default' in logs.getvalue() + assert f'"p3" from "{src}" has None as default, so its type is ' in logs.getvalue() + assert f"changed to {type_to_str(Optional[int])}." in logs.getvalue() help_str = get_parser_help(parser) assert "NotRequired" not in help_str assert "--cls.p1 P1 (required, type: int)" in help_str assert "--cls.p2 P2 (type: str)" in help_str assert f"--cls.p3 P3 (type: {type_to_str(Optional[int])}, default: null)" in help_str - cfg = parser.parse_args(["--cls.p1=1"]) assert cfg.cls == Namespace(p1=1, p3=None)