diff --git a/CHANGELOG.rst b/CHANGELOG.rst index d982ebdf..3419a765 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -24,6 +24,9 @@ Deprecated - ``instantiate_classes`` is deprecated and will be removed in v5.0.0. Instead use ``instantiate`` (`#896 `__). +- ``ArgumentParser.add_instantiator`` is deprecated and will be removed in + v5.0.0. Use the global function ``jsonargparse.add_instantiator`` instead + (`#899 `__). v4.48.0 (2026-04-10) diff --git a/jsonargparse/__init__.py b/jsonargparse/__init__.py index 320aac6d..e6da7c42 100644 --- a/jsonargparse/__init__.py +++ b/jsonargparse/__init__.py @@ -17,6 +17,7 @@ from ._deprecated import * # noqa: F403 from ._formatters import * # noqa: F403 from ._from_config import * # noqa: F403 +from ._instantiation import * # noqa: F403 from ._jsonnet import * # noqa: F403 from ._jsonschema import * # noqa: F403 from ._link_arguments import * # noqa: F403 @@ -47,6 +48,7 @@ _deprecated, _formatters, _from_config, + _instantiation, _jsonnet, _jsonschema, _link_arguments, @@ -69,6 +71,7 @@ __all__ += _namespace.__all__ __all__ += _formatters.__all__ __all__ += _common.__all__ +__all__ += _instantiation.__all__ __all__ += _loaders_dumpers.__all__ __all__ += _util.__all__ __all__ += _deprecated.__all__ diff --git a/jsonargparse/_common.py b/jsonargparse/_common.py index 90abf950..aa0cc67c 100644 --- a/jsonargparse/_common.py +++ b/jsonargparse/_common.py @@ -378,34 +378,6 @@ def subclass_type_behavior( ) -def default_class_instantiator(class_type: type[ClassType], *args, **kwargs) -> ClassType: - return class_type(*args, **kwargs) - - -class ClassInstantiator: - def __init__(self, instantiators: InstantiatorsDictType) -> None: - self.instantiators = instantiators - - def __call__(self, class_type: type[ClassType], *args, **kwargs) -> ClassType: - for (cls, subclasses), instantiator in self.instantiators.items(): - if class_type is cls or (subclasses and is_subclass(class_type, cls)): - param_names = set(inspect.signature(instantiator).parameters) - if "applied_instantiation_links" in param_names: - applied_links = applied_instantiation_links.get() or set() - kwargs["applied_instantiation_links"] = { - action.target[0]: action.applied_value for action in applied_links - } - return instantiator(class_type, *args, **kwargs) - return default_class_instantiator(class_type, *args, **kwargs) - - -def get_class_instantiator() -> InstantiatorCallable: - instantiators = class_instantiators.get() - if not instantiators: - return default_class_instantiator - return ClassInstantiator(instantiators) - - # logging logging_levels = {"CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"} diff --git a/jsonargparse/_core.py b/jsonargparse/_core.py index cd89861a..d214d4a7 100644 --- a/jsonargparse/_core.py +++ b/jsonargparse/_core.py @@ -26,11 +26,7 @@ previous_config, ) from ._common import ( - ClassType, - InstantiatorCallable, - InstantiatorsDictType, LoggerProperty, - class_instantiators, debug_mode_active, get_optionals_as_positionals_actions, is_subclasses_disabled, @@ -45,6 +41,7 @@ ) from ._deprecated import ParserDeprecations, deprecated_skip_check, deprecated_yaml_comments from ._formatters import DefaultHelpFormatter, get_env_var +from ._instantiation import get_class_instantiators from ._jsonnet import ActionJsonnet from ._jsonschema import ActionJsonSchema from ._link_arguments import ActionLink, ArgumentLinking @@ -237,7 +234,6 @@ class ArgumentParser(ParserDeprecations, ActionsContainer, ArgumentLinking, Logg groups: Optional[dict[str, ArgumentGroup]] = None _group_class: type[ArgumentGroup] _subcommands_action: Optional[ActionSubCommands] = None - _instantiators: Optional[InstantiatorsDictType] = None def __init__( self, @@ -1204,56 +1200,6 @@ def check_values(cfg): if not skip_required and not lenient_check.get(): check_required(cfg, self, prefix) - def add_instantiator( - self, - instantiator: InstantiatorCallable, - class_type: type[ClassType], - subclasses: bool = True, - prepend: bool = False, - ) -> None: - """Adds a custom instantiator for a class type. Used by ``instantiate``. - - Instantiator functions are expected to have as signature ``(class_type: - Type[ClassType], *args, **kwargs) -> ClassType``. - - For reference, the default instantiator is ``return class_type(*args, - **kwargs)``. - - In some use cases, the instantiator function might need access to values - applied by instantiation links. For this, the instantiator function can - have an additional keyword parameter ``applied_instantiation_links: - dict``. This parameter will be populated with a dictionary having as - keys the targets of the instantiation links and corresponding values - that were applied. - - Args: - instantiator: Function that instantiates a class. - class_type: The class type to instantiate. - subclasses: Whether to instantiate subclasses of ``class_type``. - prepend: Whether to prepend the instantiator to the existing instantiators. - """ - if self._instantiators is None: - self._instantiators = {} - key = (class_type, subclasses) - instantiators = {k: v for k, v in self._instantiators.items() if k != key} - if prepend: - self._instantiators = {key: instantiator, **instantiators} - else: - instantiators[key] = instantiator - self._instantiators = instantiators - - def _get_instantiators(self): - instantiators = self._instantiators or {} - if hasattr(self, "parent_parser"): - parent_instantiators = self.parent_parser._get_instantiators() - instantiators = instantiators.copy() - instantiators.update({k: v for k, v in parent_instantiators.items() if k not in instantiators}) - context_instantiators = class_instantiators.get() - if context_instantiators: - instantiators = instantiators.copy() - instantiators.update({k: v for k, v in context_instantiators.items() if k not in instantiators}) - return instantiators - def instantiate( self, cfg: Namespace, @@ -1323,14 +1269,14 @@ def instantiate( with parser_context( parent_parser=self, nested_links=ActionLink.get_nested_links(self, component), - class_instantiators=self._get_instantiators(), + class_instantiators=get_class_instantiators(self), applied_instantiation_links=cfg.get("__applied_instantiation_links__"), ): parent[key] = component.instantiate_classes(value) else: with parser_context( load_value_mode=self.parser_mode, - class_instantiators=self._get_instantiators(), + class_instantiators=get_class_instantiators(self), applied_instantiation_links=cfg.get("__applied_instantiation_links__"), ): component.instantiate_class(component, cfg) diff --git a/jsonargparse/_deprecated.py b/jsonargparse/_deprecated.py index e81fd036..4249c38d 100644 --- a/jsonargparse/_deprecated.py +++ b/jsonargparse/_deprecated.py @@ -11,8 +11,9 @@ from types import ModuleType from typing import Any, Callable, Dict, Optional, Set, Union, overload -from ._common import Action, null_logger +from ._common import Action, InstantiatorsDictType, null_logger from ._common import LoggerProperty as InternalLoggerProperty +from ._instantiation import _register_instantiator from ._namespace import Namespace from ._type_checking import ArgumentParser, ruamelCommentedMap @@ -566,6 +567,8 @@ def deprecation_warning_error_handler(stacklevel): class ParserDeprecations: """Helper class for ArgumentParser deprecations. Will be removed in v5.0.0.""" + _instantiators: Optional[InstantiatorsDictType] = None + def __init__(self, *args, error_handler=False, default_meta=None, **kwargs): super().__init__(*args, **kwargs) self.error_handler = error_handler @@ -654,6 +657,30 @@ def add_dataclass_arguments(self, *args, **kwargs): def check_config(self, *args, **kwargs): return self.validate(*args, **kwargs) + @deprecated(""" + ``ArgumentParser.add_instantiator`` was deprecated in v4.49.0 and will be + removed in v5.0.0. Use the global function ``jsonargparse.add_instantiator`` + instead. + """) + def add_instantiator( + self, + instantiator, + class_type, + subclasses: bool = True, + prepend: bool = False, + ) -> None: + if self._instantiators is None: + self._instantiators = {} + _register_instantiator(self._instantiators, instantiator, class_type, subclasses=subclasses, prepend=prepend) + + def _get_parser_instantiators(self) -> InstantiatorsDictType: + instantiators = self._instantiators or {} + if hasattr(self, "parent_parser"): + parent_instantiators = self.parent_parser._get_parser_instantiators() + instantiators = instantiators.copy() + instantiators.update({k: v for k, v in parent_instantiators.items() if k not in instantiators}) + return instantiators + def deprecated_skip_check(component, kwargs: dict, skip_validation: bool) -> bool: skip_check = kwargs.pop("skip_check", None) diff --git a/jsonargparse/_instantiation.py b/jsonargparse/_instantiation.py new file mode 100644 index 00000000..c5869cbd --- /dev/null +++ b/jsonargparse/_instantiation.py @@ -0,0 +1,112 @@ +import inspect + +from ._common import ( + ClassType, + InstantiatorCallable, + InstantiatorsDictType, + applied_instantiation_links, + class_instantiators, + is_subclass, +) + +__all__ = ["add_instantiator"] + +_global_class_instantiators: InstantiatorsDictType = {} + + +def add_instantiator( + instantiator: InstantiatorCallable, + class_type: type[ClassType], + subclasses: bool = True, + prepend: bool = False, +) -> None: + """Adds a custom instantiator for a class type. Used by ``ArgumentParser.instantiate``. + + Instantiator functions are expected to have as signature ``(class_type: + Type[ClassType], *args, **kwargs) -> ClassType``. + + For reference, the default instantiator is ``return class_type(*args, + **kwargs)``. + + In some use cases, the instantiator function might need access to values + applied by instantiation links. For this, the instantiator function can + have an additional keyword parameter ``applied_instantiation_links: + dict``. This parameter will be populated with a dictionary having as + keys the targets of the instantiation links and corresponding values + that were applied. + + Args: + instantiator: Function that instantiates a class. + class_type: The class type to instantiate. + subclasses: Whether to instantiate subclasses of ``class_type``. + prepend: Whether to prepend the instantiator to the existing instantiators. + """ + _register_instantiator( + _global_class_instantiators, instantiator, class_type, subclasses=subclasses, prepend=prepend + ) + + +def _register_instantiator( + registry: InstantiatorsDictType, + instantiator: InstantiatorCallable, + class_type: type[ClassType], + subclasses: bool = True, + prepend: bool = False, +) -> None: + """Registers an instantiator in the given registry dict (in-place).""" + key = (class_type, subclasses) + items = {k: v for k, v in registry.items() if k != key} + if prepend: + registry.clear() + registry.update({key: instantiator, **items}) + else: + items[key] = instantiator + registry.clear() + registry.update(items) + + +def _get_global_class_instantiators() -> InstantiatorsDictType: + """Returns the global instantiators registry.""" + return _global_class_instantiators + + +def default_class_instantiator(class_type: type[ClassType], *args, **kwargs) -> ClassType: + return class_type(*args, **kwargs) + + +class ClassInstantiator: + def __init__(self, instantiators: InstantiatorsDictType) -> None: + self.instantiators = instantiators + + def __call__(self, class_type: type[ClassType], *args, **kwargs) -> ClassType: + for (cls, subclasses), instantiator in self.instantiators.items(): + if class_type is cls or (subclasses and is_subclass(class_type, cls)): + param_names = set(inspect.signature(instantiator).parameters) + if "applied_instantiation_links" in param_names: + applied_links = applied_instantiation_links.get() or set() + kwargs["applied_instantiation_links"] = { + action.target[0]: action.applied_value for action in applied_links + } + return instantiator(class_type, *args, **kwargs) + return default_class_instantiator(class_type, *args, **kwargs) + + +def get_class_instantiator() -> InstantiatorCallable: + instantiators = class_instantiators.get() + if not instantiators: + return default_class_instantiator + return ClassInstantiator(instantiators) + + +def get_class_instantiators(parser) -> InstantiatorsDictType: + """Gathers all instantiators applicable to the given parser.""" + instantiators = parser._get_parser_instantiators() + context_instantiators = class_instantiators.get() + if context_instantiators: + instantiators = instantiators.copy() + instantiators.update({k: v for k, v in context_instantiators.items() if k not in instantiators}) + global_instantiators = _get_global_class_instantiators() + if global_instantiators: + instantiators = instantiators.copy() + instantiators.update({k: v for k, v in global_instantiators.items() if k not in instantiators}) + return instantiators diff --git a/jsonargparse/_signatures.py b/jsonargparse/_signatures.py index 3b7cfbb0..42dd1437 100644 --- a/jsonargparse/_signatures.py +++ b/jsonargparse/_signatures.py @@ -9,13 +9,13 @@ from ._actions import _ActionConfigLoad from ._common import ( LoggerProperty, - get_class_instantiator, get_generic_origin, get_unaliased_type, is_final_class, is_subclass, is_subclasses_disabled, ) +from ._instantiation import get_class_instantiator from ._namespace import Namespace from ._optionals import attrs_support, get_doc_short_description, is_attrs_class, is_pydantic_model from ._parameter_resolvers import ParamData, get_parameter_origins, get_signature_parameters diff --git a/jsonargparse/_typehints.py b/jsonargparse/_typehints.py index ea0e5fd8..63695b48 100644 --- a/jsonargparse/_typehints.py +++ b/jsonargparse/_typehints.py @@ -46,7 +46,6 @@ remove_actions, ) from ._common import ( - get_class_instantiator, get_unaliased_type, is_generic_class, is_instance, @@ -58,6 +57,7 @@ parser_context, validating_defaults, ) +from ._instantiation import get_class_instantiator from ._loaders_dumpers import ( basic_json_or_yaml_load, get_loader_exceptions, diff --git a/jsonargparse_tests/conftest.py b/jsonargparse_tests/conftest.py index 84141673..6057bf1a 100644 --- a/jsonargparse_tests/conftest.py +++ b/jsonargparse_tests/conftest.py @@ -140,6 +140,15 @@ def subsubparser() -> ArgumentParser: return ArgumentParser(exit_on_error=False) +@pytest.fixture +def clear_instantiators(): + from jsonargparse._instantiation import _global_class_instantiators + + _global_class_instantiators.clear() + yield + _global_class_instantiators.clear() + + @pytest.fixture def example_parser() -> ArgumentParser: parser = ArgumentParser(prog="app", exit_on_error=False) diff --git a/jsonargparse_tests/test_deprecated.py b/jsonargparse_tests/test_deprecated.py index f64f899f..04ba8ab9 100644 --- a/jsonargparse_tests/test_deprecated.py +++ b/jsonargparse_tests/test_deprecated.py @@ -66,6 +66,7 @@ from jsonargparse_tests.test_dataclasses import DataClassA from jsonargparse_tests.test_jsonnet import example_2_jsonnet from jsonargparse_tests.test_paths import paths # noqa: F401 +from jsonargparse_tests.test_subclasses import CustomInstantiationBase, instantiator @pytest.fixture(autouse=True, scope="module") @@ -289,6 +290,21 @@ def test_instantiate_classes(): assert isinstance(cfg_init["cal"], Calendar) +def test_add_instantiator_method_deprecated(parser): + parser.add_argument("--cls", type=CustomInstantiationBase) + with catch_warnings(record=True) as w: + parser.add_instantiator(instantiator("custom"), CustomInstantiationBase) + assert_deprecation_warn( + w, + message="``ArgumentParser.add_instantiator`` was deprecated", + code='parser.add_instantiator(instantiator("custom"), CustomInstantiationBase)', + ) + cfg = parser.parse_args(["--cls=CustomInstantiationBase"]) + init = parser.instantiate(cfg) + assert isinstance(init.cls, CustomInstantiationBase) + assert init.cls.call == "custom" + + def function(a1: float): return a1 diff --git a/jsonargparse_tests/test_link_arguments.py b/jsonargparse_tests/test_link_arguments.py index 10880cfd..139f05cb 100644 --- a/jsonargparse_tests/test_link_arguments.py +++ b/jsonargparse_tests/test_link_arguments.py @@ -12,6 +12,7 @@ ArgumentError, ArgumentParser, Namespace, + add_instantiator, lazy_instance, ) from jsonargparse._optionals import docstring_parser_support @@ -973,7 +974,7 @@ def custom_instantiator(class_type, *args, applied_instantiation_links: dict, ** return init -def test_on_instantiate_targets_passed_to_instantiator(parser): +def test_on_instantiate_targets_passed_to_instantiator(parser, clear_instantiators): parser.add_argument("--data", type=Dataloader) parser.add_argument("--model", type=Model) parser.link_arguments( @@ -981,8 +982,8 @@ def test_on_instantiate_targets_passed_to_instantiator(parser): "model.init_args.optimizer.init_args.num_classes", apply_on="instantiate", ) - parser.add_instantiator(custom_instantiator, Dataloader, subclasses=True) - parser.add_instantiator(custom_instantiator, Model, subclasses=True) + add_instantiator(custom_instantiator, Dataloader, subclasses=True) + add_instantiator(custom_instantiator, Model, subclasses=True) cfg = parser.parse_args(["--data=Dataloader", "--model=Model", "--model.label=ok"]) init = parser.instantiate(cfg) diff --git a/jsonargparse_tests/test_signatures.py b/jsonargparse_tests/test_signatures.py index 7842d4f5..6940665e 100644 --- a/jsonargparse_tests/test_signatures.py +++ b/jsonargparse_tests/test_signatures.py @@ -12,6 +12,7 @@ ActionParser, ArgumentError, Namespace, + add_instantiator, lazy_instance, ) from jsonargparse._optionals import docstring_parser_support @@ -396,14 +397,14 @@ def test_add_class_docstring_parse_fail(parser, logger): assert "a1 description" not in help_str -def test_add_class_custom_instantiator(parser): +def test_add_class_custom_instantiator(parser, clear_instantiators): def instantiate(cls, **kwargs): instance = cls(**kwargs) instance.call = "custom" return instance parser.add_class_arguments(Class0, "a") - parser.add_instantiator(instantiate, Class0) + add_instantiator(instantiate, Class0) cfg = parser.parse_args([]) init = parser.instantiate(cfg) assert isinstance(init.a, Class0) diff --git a/jsonargparse_tests/test_subclasses.py b/jsonargparse_tests/test_subclasses.py index 4a7015c0..3d11860a 100644 --- a/jsonargparse_tests/test_subclasses.py +++ b/jsonargparse_tests/test_subclasses.py @@ -19,8 +19,10 @@ ArgumentError, ArgumentParser, Namespace, + add_instantiator, lazy_instance, ) +from jsonargparse._instantiation import _global_class_instantiators from jsonargparse._typehints import implements_protocol, is_instance_or_supports_protocol from jsonargparse.typing import final from jsonargparse_tests.conftest import ( @@ -448,52 +450,52 @@ def instantiate(cls, **kwargs): return instantiate -def test_custom_instantiation_argument_type(parser): +def test_custom_instantiation_argument_type(parser, clear_instantiators): parser.add_argument("--cls", type=CustomInstantiationBase) - parser.add_instantiator(instantiator("argument type"), CustomInstantiationBase) + add_instantiator(instantiator("argument type"), CustomInstantiationBase) cfg = parser.parse_args(["--cls=CustomInstantiationBase"]) init = parser.instantiate(cfg) assert isinstance(init.cls, CustomInstantiationBase) assert init.cls.call == "argument type" -def test_custom_instantiation_unused_for_subclass(parser): +def test_custom_instantiation_unused_for_subclass(parser, clear_instantiators): parser.add_argument("--cls", type=CustomInstantiationBase) - parser.add_instantiator(instantiator("base"), CustomInstantiationBase, subclasses=False) + add_instantiator(instantiator("base"), CustomInstantiationBase, subclasses=False) cfg = parser.parse_args(["--cls=CustomInstantiationSub"]) init = parser.instantiate(cfg) assert isinstance(init.cls, CustomInstantiationSub) assert not hasattr(init.cls, "call") -def test_custom_instantiation_used_for_subclass(parser): +def test_custom_instantiation_used_for_subclass(parser, clear_instantiators): parser.add_argument("--cls", type=CustomInstantiationBase) - parser.add_instantiator(instantiator("subclass"), CustomInstantiationBase, subclasses=True) + add_instantiator(instantiator("subclass"), CustomInstantiationBase, subclasses=True) cfg = parser.parse_args(["--cls=CustomInstantiationSub"]) init = parser.instantiate(cfg) assert isinstance(init.cls, CustomInstantiationSub) assert init.cls.call == "subclass" -def test_custom_instantiation_prepend(parser): +def test_custom_instantiation_prepend(parser, clear_instantiators): parser.add_argument("--cls", type=CustomInstantiationBase) - parser.add_instantiator(instantiator("first"), CustomInstantiationSub) - parser.add_instantiator(instantiator("prepended"), CustomInstantiationBase, subclasses=True, prepend=True) - assert len(parser._instantiators) == 2 + add_instantiator(instantiator("first"), CustomInstantiationSub) + add_instantiator(instantiator("prepended"), CustomInstantiationBase, subclasses=True, prepend=True) + assert len(_global_class_instantiators) == 2 cfg = parser.parse_args(["--cls=CustomInstantiationSub"]) init = parser.instantiate(cfg) assert isinstance(init.cls, CustomInstantiationSub) assert init.cls.call == "prepended" -def test_custom_instantiation_replace(parser): +def test_custom_instantiation_replace(parser, clear_instantiators): first_instantiator = instantiator("first") second_instantiator = instantiator("second") parser.add_argument("--cls", type=CustomInstantiationBase) - parser.add_instantiator(first_instantiator, CustomInstantiationBase) - parser.add_instantiator(second_instantiator, CustomInstantiationBase) - assert len(parser._instantiators) == 1 - assert list(parser._instantiators.values())[0] is second_instantiator + add_instantiator(first_instantiator, CustomInstantiationBase) + add_instantiator(second_instantiator, CustomInstantiationBase) + assert len(_global_class_instantiators) == 1 + assert list(_global_class_instantiators.values())[0] is second_instantiator class CustomInstantiationNested: @@ -501,9 +503,9 @@ def __init__(self, sub: CustomInstantiationBase): self.sub = sub -def test_custom_instantiation_nested(parser): +def test_custom_instantiation_nested(parser, clear_instantiators): parser.add_argument("--cls", type=CustomInstantiationNested) - parser.add_instantiator(instantiator("nested"), CustomInstantiationBase, subclasses=True) + add_instantiator(instantiator("nested"), CustomInstantiationBase, subclasses=True) cfg = parser.parse_args(["--cls=CustomInstantiationNested", "--cls.sub=CustomInstantiationSub"]) init = parser.instantiate(cfg) assert isinstance(init.cls, CustomInstantiationNested) diff --git a/jsonargparse_tests/test_subcommands.py b/jsonargparse_tests/test_subcommands.py index dfe5ff3b..f065fb13 100644 --- a/jsonargparse_tests/test_subcommands.py +++ b/jsonargparse_tests/test_subcommands.py @@ -14,6 +14,7 @@ ArgumentError, ArgumentParser, Namespace, + add_instantiator, ) from jsonargparse_tests.conftest import ( get_parse_args_stderr, @@ -422,20 +423,20 @@ def test_subsubcommands_wrong_add_order(parser): ctx.match("Multiple levels of subcommands must be added in level order") -def test_subcommands_custom_instantiator(parser, subparser, subtests): +def test_subcommands_custom_instantiator(parser, subparser, subtests, clear_instantiators): subparser.add_argument("--cls", type=CustomInstantiationBase) subcommands = parser.add_subcommands() subcommands.add_subcommand("cmd", subparser) with subtests.test("main parser"): - parser.add_instantiator(instantiator("main parser"), CustomInstantiationBase) + add_instantiator(instantiator("main parser"), CustomInstantiationBase) cfg = parser.parse_args(["cmd", "--cls", "CustomInstantiationBase"]) init = parser.instantiate(cfg) assert isinstance(init.cmd.cls, CustomInstantiationBase) assert init.cmd.cls.call == "main parser" with subtests.test("subparser"): - subparser.add_instantiator(instantiator("subparser"), CustomInstantiationBase) + add_instantiator(instantiator("subparser"), CustomInstantiationBase) cfg = parser.parse_args(["cmd", "--cls", "CustomInstantiationBase"]) init = parser.instantiate(cfg) assert isinstance(init.cmd.cls, CustomInstantiationBase)