From 29f3748d1c159d53135bc3852c92769c5bdaee5e Mon Sep 17 00:00:00 2001 From: Mauricio Villegas <5780272+mauvilsa@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:14:20 +0200 Subject: [PATCH 1/5] Minor fix --- jsonargparse/_typehints.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/jsonargparse/_typehints.py b/jsonargparse/_typehints.py index c62d3d7e..5e2aad12 100644 --- a/jsonargparse/_typehints.py +++ b/jsonargparse/_typehints.py @@ -974,8 +974,7 @@ class UntypedType(UnvalidatedType): """ def __init__(self): - self.reason = untyped_reason - self.name = "" + super().__init__("", reason=untyped_reason) # no type in the source code, thus an empty name def __repr__(self): return "Untyped" From 305f48634cf859f7ef4e152a630d843d954a6bf0 Mon Sep 17 00:00:00 2001 From: Mauricio Villegas <5780272+mauvilsa@users.noreply.github.com> Date: Fri, 28 Aug 2026 11:48:23 +0200 Subject: [PATCH 2/5] Warn when dumping a callable default that can't be imported back --- CHANGELOG.rst | 4 ++++ jsonargparse/_typehints.py | 28 ++++++++++++++++------------ jsonargparse_tests/test_typehints.py | 22 ++++++++++++++++++++++ 3 files changed, 42 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 11f84b65..ff64141d 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -82,6 +82,10 @@ Fixed ``collections.abc`` spelling of the same types was. A bare one didn't validate and a composed one, e.g. ``Optional[Hashable]``, raised ``Unsupported type hint`` (`#963 `__). +- A ``Callable`` default that can't be imported back, was silently dumped as a + non-importable ```` import path. Now the default is kept as the object + and dumping it gives the not serializable message and a warning (`#??? + `__). Changed ^^^^^^^ diff --git a/jsonargparse/_typehints.py b/jsonargparse/_typehints.py index 5e2aad12..13e74e1c 100644 --- a/jsonargparse/_typehints.py +++ b/jsonargparse/_typehints.py @@ -392,7 +392,11 @@ def normalize_default(self, default): elif is_module_type(self._typehint) and isinstance(default, ModuleType): default = default.__name__ elif is_callable_type(self._typehint) and callable(default) and not inspect.isclass(default): - default = get_import_path(default) + try: + default = object_path_serializer(default) + except ValueError: + # kept as is when it can't be imported back, e.g. a closure, so that dump warns + pass elif ActionTypeHint.is_return_subclass_typehint(self._typehint) and inspect.isclass(default): default = {"class_path": get_import_path(default)} elif is_subclass_type and not allow_default_instance.get(): @@ -1557,7 +1561,7 @@ def adapt_typehints( val, partial_skip_args = adapt_partial_callable_class(typehint, val) val = adapt_class_type(val, True, False, sub_add_kwargs, partial_skip_args=partial_skip_args) else: - val = object_path_serializer(val) + val = serialize_as_import_path(val) else: adapted = adapt_subconfig_path(val, typehint, adapt_kwargs) if adapted is not not_a_subconfig_path: @@ -1626,7 +1630,7 @@ def adapt_typehints( elif inspect.isclass(typehint_origin): if is_instance_or_supports_protocol(val, typehint): if serialize: - val = serialize_class_instance(val) + val = serialize_as_import_path(val) return val if serialize and isinstance(val, str): return val @@ -2833,14 +2837,14 @@ def typehint_metavar(typehint): return metavar -def serialize_class_instance(val): - with suppress(Exception): - import_path = get_import_path(val) - if import_path and import_object(import_path, check_path=False) is val: - return import_path - val = f"Unable to serialize instance {val}" - warning(val) - return val +def serialize_as_import_path(val): + """Serializes an object as its import path, warning when it can't be imported back.""" + try: + return object_path_serializer(val) + except ValueError: + val = f"Unable to serialize instance {val}" + warning(val) + return val def typehint_from_value(val): @@ -2884,7 +2888,7 @@ def serialize_unvalidated(val, adapt_kwargs): return val typehint = typehint_from_value(val) if typehint is None: - return serialize_class_instance(val) + return serialize_as_import_path(val) if isinstance(val, dict): adapt_val = dict(val) # adapt_typehints serializes the items in place, so give it a copy elif isinstance(val, list): diff --git a/jsonargparse_tests/test_typehints.py b/jsonargparse_tests/test_typehints.py index 2d2e92f7..89951394 100644 --- a/jsonargparse_tests/test_typehints.py +++ b/jsonargparse_tests/test_typehints.py @@ -2429,6 +2429,28 @@ def test_callable_function_path(parser): ctx.match("Callable expects a function or a callable class") +def make_closure_callable(): + def unbound_closure(): + return "closure" + + return unbound_closure + + +closure_callable = make_closure_callable() + + +def test_callable_default_not_importable(parser): + # the import path of a closure includes a part, so it can't be imported back + parser.add_argument("--callable", type=Callable, default=closure_callable) + + cfg = parser.parse_args([]) + assert cfg.callable is closure_callable + + with assert_dump_warnings("Unable to serialize instance Date: Fri, 28 Aug 2026 12:35:47 +0200 Subject: [PATCH 3/5] register_type by default replaces a previous registration instead of failing --- CHANGELOG.rst | 6 +++ DOCUMENTATION.rst | 5 +++ jsonargparse/typing.py | 42 ++++++++++++------- jsonargparse_tests/test_typing.py | 70 ++++++++++++++++++++++++++++++- 4 files changed, 107 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index ff64141d..4ea9c8df 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -105,6 +105,12 @@ Changed makes the type optional, a ``NotRequired`` parameter without a default and a parameter that is the target of a link (`#965 `__). +- ``register_type`` no longer fails when the type is already registered. The new + registration now replaces the previous one and a debug log informs about it, + naming the module of each registration. This way a new type registered by + jsonargparse doesn't break code that already registers it. The previous + behavior is available with ``fail_already_registered=True`` (`#??? + `__). Deprecated ^^^^^^^^^^ diff --git a/DOCUMENTATION.rst b/DOCUMENTATION.rst index ffa0fa8f..56311077 100644 --- a/DOCUMENTATION.rst +++ b/DOCUMENTATION.rst @@ -1186,6 +1186,11 @@ for example ``datetime``: parser.add_argument("--datetime", type=datetime) parser.parse_args(["--datetime=2008-09-03T20:56:35"]) +Registering an already registered type replaces the previous one, jsonargparse's +own registrations included. A debug log names the module of each, useful when +two packages register the same type. Give ``fail_already_registered=True`` to +fail instead. + .. note:: Registering is only intended for simple types. By default, any class used as diff --git a/jsonargparse/typing.py b/jsonargparse/typing.py index 3547a082..cea04749 100644 --- a/jsonargparse/typing.py +++ b/jsonargparse/typing.py @@ -9,7 +9,7 @@ from collections.abc import Callable from typing import Any, TypeAlias, get_type_hints -from ._common import ClassType, is_final_class, is_subclass, path_dump_preserve_relative +from ._common import ClassType, get_settings_logger, is_final_class, is_subclass, path_dump_preserve_relative from ._deprecated import renamed_parameter_warning from ._namespace import Namespace from ._optionals import final, is_alias_type, pydantic_support @@ -436,6 +436,8 @@ def __init__(self, v, **k): class RegisteredType: + _eq_attrs = ["class_type", "serializer", "base_deserializer", "deserializer_exceptions", "type_check"] + def __init__( self, class_type: _TypeClass, @@ -443,15 +445,17 @@ def __init__( deserializer: Callable | None, deserializer_exceptions: type[Exception] | tuple[type[Exception], ...], type_check: Callable, + module: str, ): self.class_type = class_type self.serializer = serializer self.base_deserializer = class_type if deserializer is None else deserializer self.deserializer_exceptions = deserializer_exceptions self.type_check = type_check + self.module = module def __eq__(self, other): - return all(getattr(self, k) == getattr(other, k) for k in ["class_type", "serializer", "base_deserializer"]) + return all(getattr(self, k) == getattr(other, k) for k in self._eq_attrs) def is_value_of_type(self, value): return self.type_check(value, self.class_type) @@ -466,6 +470,14 @@ def deserializer(self, value): raise ex2 from ex +def get_registrant_module() -> str: + """Returns the name of the module that called the caller of this function.""" + frame: Any = sys._getframe(2) + while frame.f_globals.get("__name__") == "jsonargparse._deprecated": # skip the deprecation decorator + frame = frame.f_back + return frame.f_globals.get("__name__", "unknown") + + @renamed_parameter_warning({"type_class": "class_type"}) def register_type( class_type: _TypeClass, @@ -477,7 +489,7 @@ def register_type( AttributeError, ), type_check: Callable = lambda v, t: v.__class__ == t, - fail_already_registered: bool = True, + fail_already_registered: bool = False, uniqueness_key: tuple | None = None, ) -> None: """Registers a new type for use in jsonargparse parsers. @@ -490,19 +502,26 @@ def register_type( class. Default instantiates ``class_type``. deserializer_exceptions: Exceptions that deserializer raises when it fails. type_check: Function to check if a value is of ``class_type``. Gets as arguments the value and ``class_type``. - fail_already_registered: Whether to fail if type has already been registered. + fail_already_registered: Whether to fail instead of replacing a previous registration of the type. uniqueness_key: Key to determine uniqueness of type. """ if sys.version_info[:2] < (3, 12) and not inspect.isclass(class_type): raise ValueError(f"Expected class_type to be a class, got {type(class_type)}") elif sys.version_info[:2] >= (3, 12) and not (inspect.isclass(class_type) or is_alias_type(class_type)): raise ValueError(f"Expected class_type to be a class or a type alias, got {type(class_type)}") - type_handler = RegisteredType(class_type, serializer, deserializer, deserializer_exceptions, type_check) - fail_already_registered = globals().get("_fail_already_registered", fail_already_registered) - if not uniqueness_key and fail_already_registered and get_registered_type(class_type): - if type_handler == registered_type_handlers[class_type]: + module = get_registrant_module() + type_handler = RegisteredType(class_type, serializer, deserializer, deserializer_exceptions, type_check, module) + previous = None if uniqueness_key else get_registered_type(class_type) + if previous: + if previous == type_handler: return - raise ValueError(f'Type "{class_type}" already registered with different serializer and/or deserializer.') + if fail_already_registered: + raise ValueError(f'Type "{class_type}" already registered with different serializer and/or deserializer.') + class_type_name = getattr(class_type, "__name__", str(class_type)) + get_settings_logger().debug( + f"Type {class_type_name!r} registered by module {module!r} replaced the previous " + f"registration by module {previous.module!r}" + ) registered_type_handlers[class_type] = type_handler if uniqueness_key is not None: registered_types[uniqueness_key] = class_type @@ -538,8 +557,6 @@ def add_type(class_type: type, uniqueness_key: tuple | None, type_check: Callabl register_type(class_type, class_type._type, **kwargs) # type: ignore[attr-defined] -_fail_already_registered = False - PositiveInt = restricted_number_type("PositiveInt", int, (">", 0), docstring="int restricted to be >0") NonNegativeInt = restricted_number_type("NonNegativeInt", int, (">=", 0), docstring="int restricted to be ≥0") PositiveFloat = restricted_number_type("PositiveFloat", float, (">", 0), docstring="float restricted to be >0") @@ -766,6 +783,3 @@ def register_pydantic_types(typehint): if isinstance(args, tuple): for arg in args: register_pydantic_types(arg) - - -del _fail_already_registered diff --git a/jsonargparse_tests/test_typing.py b/jsonargparse_tests/test_typing.py index 486f2ed3..bf0363c1 100644 --- a/jsonargparse_tests/test_typing.py +++ b/jsonargparse_tests/test_typing.py @@ -1,6 +1,7 @@ from __future__ import annotations import inspect +import os import pickle import random import sys @@ -9,10 +10,12 @@ from decimal import Decimal from random import Random from typing import List, Optional, Union +from unittest.mock import patch import pytest from jsonargparse import ArgumentError, Namespace +from jsonargparse._common import get_settings_logger from jsonargparse._optionals import docstring_parser_support from jsonargparse._util import get_import_path from jsonargparse.typing import ( @@ -32,12 +35,13 @@ lazy_instance, register_type, register_type_on_first_use, + registered_type_handlers, registered_types, registration_pending, restricted_number_type, restricted_string_type, ) -from jsonargparse_tests.conftest import get_parser_help, json_or_yaml_load +from jsonargparse_tests.conftest import capture_logs, get_parser_help, json_or_yaml_load if sys.version_info >= (3, 12): from typing import TypeAliasType @@ -383,7 +387,69 @@ def deserializer(v): assert json_or_yaml_load(parser.dump(cfg)) == {"datetime": "2008-09-03T20:56:35"} register_type(datetime, serializer, deserializer) # identical re-registering is okay - pytest.raises(ValueError, lambda: register_type(datetime)) # different registration not okay + + +@pytest.fixture +def restore_registrations(): + handlers = registered_type_handlers.copy() + pending = registration_pending.copy() + yield + registered_type_handlers.clear() + registered_type_handlers.update(handlers) + registration_pending.clear() + registration_pending.update(pending) + + +class ReRegistered: + def __init__(self, value): + self.value = value + + +def test_register_type_replaces_previous(parser, restore_registrations): + register_type(ReRegistered, lambda v: f"first:{v.value}", lambda v: ReRegistered(v.split(":")[-1])) + register_type(ReRegistered, lambda v: f"second:{v.value}", lambda v: ReRegistered(v.upper())) + + parser.add_argument("--item", type=ReRegistered) + cfg = parser.parse_args(["--item=abc"]) + assert cfg.item.value == "ABC" + assert json_or_yaml_load(parser.dump(cfg)) == {"item": "second:ABC"} + + +def test_register_type_replace_fail_already_registered(restore_registrations): + register_type(ReRegistered, lambda v: v.value) + with pytest.raises(ValueError, match="already registered with different serializer"): + register_type(ReRegistered, lambda v: str(v.value), fail_already_registered=True) + assert get_registered_type(ReRegistered).module == __name__ + + +def re_registered_serializer(value): + return value.value # pragma: no cover + + +@patch.dict(os.environ, {"JSONARGPARSE_DEBUG": "true"}) +def test_register_type_replace_debug_log(restore_registrations): + with capture_logs(get_settings_logger()) as logs: + register_type(ReRegistered, re_registered_serializer) + register_type(ReRegistered, re_registered_serializer) # identical, not a replacement + register_type(ReRegistered, lambda v: str(v.value)) + register_type(complex, deserializer=lambda v: complex(v)) + logs = logs.getvalue() + assert logs.count("replaced the previous registration") == 2 + assert f"Type 'ReRegistered' registered by module '{__name__}' replaced the previous registration" in logs + assert f"Type 'complex' registered by module '{__name__}' replaced the previous registration" in logs + assert "registration by module 'jsonargparse.typing'" in logs + + +@patch.dict(os.environ, {"JSONARGPARSE_DEBUG": "true"}) +def test_register_type_replaces_registered_on_first_use(parser, restore_registrations): + with capture_logs(get_settings_logger()) as logs: + register_type(uuid.UUID, serializer=lambda v: f"uuid:{v}", deserializer=lambda v: uuid.UUID(v[5:])) + assert "replaced the previous registration by module 'jsonargparse.typing'" in logs.getvalue() + + parser.add_argument("--id", type=uuid.UUID) + cfg = parser.parse_args(["--id=uuid:12345678-1234-5678-1234-567812345678"]) + assert cfg.id == uuid.UUID("12345678-1234-5678-1234-567812345678") + assert json_or_yaml_load(parser.dump(cfg)) == {"id": "uuid:12345678-1234-5678-1234-567812345678"} def test_register_not_a_class_type_failure(): From b397ba6fac812bf6d9d6355af53c55590cdb475f Mon Sep 17 00:00:00 2001 From: Mauricio Villegas <5780272+mauvilsa@users.noreply.github.com> Date: Fri, 28 Aug 2026 13:27:16 +0200 Subject: [PATCH 4/5] Use the registration of the origin for subscripted registered types --- CHANGELOG.rst | 4 ++++ DOCUMENTATION.rst | 4 +++- jsonargparse/typing.py | 13 ++++++++--- jsonargparse_tests/test_paths.py | 5 ++++ jsonargparse_tests/test_typing.py | 39 ++++++++++++++++++++++++++++++- 5 files changed, 60 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 4ea9c8df..5853685c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -86,6 +86,10 @@ Fixed non-importable ```` import path. Now the default is kept as the object and dumping it gives the not serializable message and a warning (`#??? `__). +- Types registered with ``register_type`` were ignored when the type is + subscripted, e.g. ``os.PathLike[str]`` for a registered ``PathLike``. Now the + registration of the unsubscripted type is used (`#??? + `__). Changed ^^^^^^^ diff --git a/DOCUMENTATION.rst b/DOCUMENTATION.rst index 56311077..09de782b 100644 --- a/DOCUMENTATION.rst +++ b/DOCUMENTATION.rst @@ -1189,7 +1189,9 @@ for example ``datetime``: Registering an already registered type replaces the previous one, jsonargparse's own registrations included. A debug log names the module of each, useful when two packages register the same type. Give ``fail_already_registered=True`` to -fail instead. +fail instead. A generic class is registered unsubscripted, and the registration +also applies to its subscripted forms, e.g. ``os.PathLike[str]``. The type +arguments are not validated, since the deserializer gets the complete value. .. note:: diff --git a/jsonargparse/typing.py b/jsonargparse/typing.py index cea04749..184d13b1 100644 --- a/jsonargparse/typing.py +++ b/jsonargparse/typing.py @@ -495,8 +495,9 @@ def register_type( """Registers a new type for use in jsonargparse parsers. Args: - class_type: The class to be registered. Python 3.12+ also supports - ``TypeAliasType`` aliases. + class_type: The class to be registered. A generic class is registered + unsubscripted and its registration also applies to its subscripted + forms. Python 3.12+ also supports ``TypeAliasType`` aliases. serializer: Function that converts an instance of the class to a basic type. deserializer: Function that converts a basic type to an instance of the class. Default instantiates ``class_type``. @@ -543,7 +544,13 @@ def get_registered_type(class_type) -> RegisteredType | None: import_path = get_import_path(class_type) if import_path in registration_pending: registration_pending.pop(import_path)() - return registered_type_handlers.get(class_type) + type_handler = registered_type_handlers.get(class_type) + if type_handler is None: + # a subscripted generic is handled by the registration of its origin, e.g. MyMapping[str, int] + origin = getattr(class_type, "__origin__", None) + if inspect.isclass(origin): + type_handler = get_registered_type(origin) + return type_handler def add_type(class_type: type, uniqueness_key: tuple | None, type_check: Callable | None = None): diff --git a/jsonargparse_tests/test_paths.py b/jsonargparse_tests/test_paths.py index b3b88c0c..5b3eb849 100644 --- a/jsonargparse_tests/test_paths.py +++ b/jsonargparse_tests/test_paths.py @@ -52,6 +52,11 @@ def test_os_pathlike(parser, file_r): assert file_r == parser.parse_args([f"--path={file_r}"]).path +def test_os_pathlike_subscripted(parser, file_r): + parser.add_argument("--path", type=os.PathLike[str]) + assert file_r == parser.parse_args([f"--path={file_r}"]).path + + # base path tests diff --git a/jsonargparse_tests/test_typing.py b/jsonargparse_tests/test_typing.py index bf0363c1..62a19125 100644 --- a/jsonargparse_tests/test_typing.py +++ b/jsonargparse_tests/test_typing.py @@ -9,7 +9,7 @@ from datetime import datetime, timedelta from decimal import Decimal from random import Random -from typing import List, Optional, Union +from typing import List, Mapping, Optional, TypeVar, Union from unittest.mock import patch import pytest @@ -46,6 +46,9 @@ if sys.version_info >= (3, 12): from typing import TypeAliasType +KeyType = TypeVar("KeyType") +ValType = TypeVar("ValType") + def test_public_api(): import jsonargparse.typing @@ -460,6 +463,40 @@ class SomeClass: register_type(Union[SomeClass, int]) +class FrozenMapping(Mapping[KeyType, ValType]): + def __init__(self, data): + self._data = dict(data) + + def __getitem__(self, key): + return self._data[key] + + def __iter__(self): + return iter(self._data) + + def __len__(self): + return len(self._data) # pragma: no cover + + +def test_register_type_subscripted_generic(parser): + register_type( + FrozenMapping, + serializer=dict, + deserializer=FrozenMapping, + type_check=lambda value, _: isinstance(value, FrozenMapping), + ) + + parser.add_argument("--map", type=FrozenMapping[str, int]) + parser.add_argument("--opt", type=Optional[FrozenMapping[str, int]]) + parser.add_argument("--list", type=List[FrozenMapping[str, int]]) + cfg = parser.parse_args(['--map={"a": 1}', '--opt={"b": 2}', '--list=[{"c": 3}]']) + assert dict(cfg.map) == {"a": 1} + assert dict(cfg.opt) == {"b": 2} + assert [dict(v) for v in cfg.list] == [{"c": 3}] + assert all(isinstance(v, FrozenMapping) for v in [cfg.map, cfg.opt, *cfg.list]) + dump = {"map": {"a": 1}, "opt": {"b": 2}, "list": [{"c": 3}]} + assert dump == json_or_yaml_load(parser.dump(cfg)) + + class RegisterOnFirstUse: pass From d94719298a5d054a1cfdb9fd1cd05c2c2862ca55 Mon Sep 17 00:00:00 2001 From: Mauricio Villegas <5780272+mauvilsa@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:28:19 +0200 Subject: [PATCH 5/5] Set pull request number --- CHANGELOG.rst | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 5853685c..0f9e2738 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -84,12 +84,12 @@ Fixed hint`` (`#963 `__). - A ``Callable`` default that can't be imported back, was silently dumped as a non-importable ```` import path. Now the default is kept as the object - and dumping it gives the not serializable message and a warning (`#??? - `__). + and dumping it gives the not serializable message and a warning (`#966 + `__). - Types registered with ``register_type`` were ignored when the type is subscripted, e.g. ``os.PathLike[str]`` for a registered ``PathLike``. Now the - registration of the unsubscripted type is used (`#??? - `__). + registration of the unsubscripted type is used (`#966 + `__). Changed ^^^^^^^ @@ -113,8 +113,8 @@ Changed registration now replaces the previous one and a debug log informs about it, naming the module of each registration. This way a new type registered by jsonargparse doesn't break code that already registers it. The previous - behavior is available with ``fail_already_registered=True`` (`#??? - `__). + behavior is available with ``fail_already_registered=True`` (`#966 + `__). Deprecated ^^^^^^^^^^