Skip to content

Commit bde53d9

Browse files
authored
Add support callable protocols for instance factory dependency injection (#758)
1 parent 0177681 commit bde53d9

File tree

7 files changed

+381
-168
lines changed

7 files changed

+381
-168
lines changed

CHANGELOG.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ Added
1919
^^^^^
2020
- Support for Python 3.14 (`#753
2121
<https://github.com/omni-us/jsonargparse/pull/753>`__).
22+
- Support callable protocols for instance factory dependency injection (`#758
23+
<https://github.com/omni-us/jsonargparse/pull/758>`__).
2224

2325
Fixed
2426
^^^^^

DOCUMENTATION.rst

Lines changed: 211 additions & 119 deletions
Original file line numberDiff line numberDiff line change
@@ -1035,96 +1035,9 @@ A second option is a class that once instantiated becomes callable:
10351035
>>> init.callable(5)
10361036
8
10371037

1038-
The third option is only applicable when the type is a callable that has a class
1039-
as return type or a ``Union`` including a class. This is useful to support
1040-
dependency injection for classes that require a parameter that is only available
1041-
after injection. The parser supports this automatically by providing a function
1042-
that receives this parameter and returns the instance of the class. Take for
1043-
example the classes:
1044-
1045-
.. testcode:: callable
1046-
1047-
class Optimizer:
1048-
def __init__(self, params: Iterable):
1049-
self.params = params
1050-
1051-
1052-
class SGD(Optimizer):
1053-
def __init__(self, params: Iterable, lr: float):
1054-
super().__init__(params)
1055-
self.lr = lr
1056-
1057-
.. testcode:: callable
1058-
:hide:
1059-
1060-
doctest_mock_class_in_main(SGD)
1061-
1062-
A possible parser and callable behavior would be:
1063-
1064-
.. doctest:: callable
1065-
1066-
>>> value = {
1067-
... "class_path": "SGD",
1068-
... "init_args": {
1069-
... "lr": 0.01,
1070-
... },
1071-
... }
1072-
1073-
>>> parser.add_argument("--optimizer", type=Callable[[Iterable], Optimizer]) # doctest: +IGNORE_RESULT
1074-
>>> cfg = parser.parse_args(["--optimizer", str(value)])
1075-
>>> cfg.optimizer
1076-
Namespace(class_path='__main__.SGD', init_args=Namespace(lr=0.01))
1077-
>>> init = parser.instantiate_classes(cfg)
1078-
>>> optimizer = init.optimizer([1, 2, 3])
1079-
>>> isinstance(optimizer, SGD)
1080-
True
1081-
>>> optimizer.params, optimizer.lr
1082-
([1, 2, 3], 0.01)
1083-
1084-
Multiple arguments available after injection are also supported and can be
1085-
specified the same way with a ``Callable`` type hint. For example, for two
1086-
``Iterable`` arguments, you can use the following syntax: ``Callable[[Iterable,
1087-
Iterable], Type]``. Please be aware that the arguments are passed as positional
1088-
arguments, this means that the injected function would be called like
1089-
``function(value1, value2)``. Similarly, for a callable that accepts zero
1090-
arguments, the syntax would be ``Callable[[], Type]``.
1091-
1092-
.. note::
1093-
1094-
When the ``Callable`` has a class return type, it is possible to specify the
1095-
``class_path`` giving only its name if imported before parsing, as explained
1096-
in :ref:`sub-classes-command-line`.
1097-
1098-
If the same type above is used as type hint of a parameter of another class, a
1099-
default can be set using a lambda, for example:
1100-
1101-
.. testcode:: callable
1102-
1103-
class Model:
1104-
def __init__(
1105-
self,
1106-
optimizer: Callable[[Iterable], Optimizer] = lambda p: SGD(p, lr=0.05),
1107-
):
1108-
self.optimizer = optimizer
1109-
1110-
Then a parser and behavior could be:
1111-
1112-
.. code-block::
1113-
1114-
>>> parser.add_class_arguments(Model, 'model')
1115-
>>> cfg = parser.get_defaults()
1116-
>>> cfg.model.optimizer
1117-
Namespace(class_path='__main__.SGD', init_args=Namespace(lr=0.05))
1118-
>>> init = parser.instantiate_classes(cfg)
1119-
>>> optimizer = init.model.optimizer([1, 2, 3])
1120-
>>> optimizer.params, optimizer.lr
1121-
([1, 2, 3], 0.05)
1122-
1123-
See :ref:`ast-resolver` for limitations of lambda defaults in signatures.
1124-
Providing a lambda default to :py:meth:`.ActionsContainer.add_argument` does not
1125-
work since there is no AST resolving. In this case, a dict with ``class_path``
1126-
and ``init_args`` can be used as default.
1127-
1038+
The third option is only applicable when the type is a callable that returns
1039+
class instances. This is a form of :ref:`dependency-injection`, so this third
1040+
case is explained in section :ref:`instance-factories`.
11281041

11291042
.. _registering-types:
11301043

@@ -1967,28 +1880,55 @@ the stubs. In these cases in the parser help the default is shown as
19671880
``Unknown<stubs-resolver>`` and not included in
19681881
:py:meth:`.ArgumentParser.get_defaults` or the output of ``--print_config``.
19691882

1883+
1884+
.. _dependency-injection:
1885+
1886+
Dependency injection
1887+
====================
1888+
1889+
Dependency injection is a software design pattern that separates the
1890+
instantiation details of objects from their usage, resulting in more loosely
1891+
coupled programs, see the `wikipedia article
1892+
<https://en.wikipedia.org/wiki/Dependency_injection>`__. Because of its
1893+
benefits, support for dependency injection has been a design goal of
1894+
jsonargparse.
1895+
1896+
In python, dependency injection is achieved by:
1897+
1898+
- Using as type hint a class, such that the parameter accepts an instance of
1899+
this class or any subclass, e.g. ``module: ModuleBaseClass``.
1900+
- Using as type hint a callable that returns an instance of a class, such that
1901+
the parameter accepts a function for instantiation. This could be either
1902+
using ``Callable``, e.g. ``module: Callable[[int], ModuleBaseClass]``, or a
1903+
protocol, e.g. ``module: ModuleFactoryProtocol``.
1904+
19701905
.. _sub-classes:
19711906

19721907
Class type and sub-classes
1973-
==========================
1974-
1975-
It is possible to use an arbitrary class as a type such that the argument
1976-
accepts an instance of this class or any derived subclass. This practice is
1977-
known as `dependency injection
1978-
<https://en.wikipedia.org/wiki/Dependency_injection>`__. In the config file a
1979-
class is represented by a dictionary with a ``class_path`` entry indicating the
1980-
dot notation expression to import the class, and optionally some ``init_args``
1981-
that would be used to instantiate it. When parsing, it will be checked that the
1982-
class can be imported, that it is a subclass of the given type and that
1983-
``init_args`` values correspond to valid arguments to instantiate it. After
1984-
parsing, the config object will include the ``class_path`` and ``init_args``
1985-
entries. To get a config object with all sub-classes instantiated, the
1986-
:py:meth:`.ArgumentParser.instantiate_classes` method is used. The ``skip``
1987-
parameter of the signature methods can also be used to exclude arguments within
1988-
subclasses. This is done by giving its relative destination key, i.e. as
1989-
``param.init_args.subparam``.
1990-
1991-
A simple example would be having some config file ``config.yaml`` as:
1908+
--------------------------
1909+
1910+
When a class is used as a type hint, jsonargparse expects in config files a
1911+
dictionary with a ``class_path`` entry indicating the dot notation expression to
1912+
import the class, and optionally some ``init_args`` that would be used to
1913+
instantiate it. When parsing, it will be checked that the class can be imported,
1914+
that it is a subclass of the given type and that ``init_args`` values correspond
1915+
to valid arguments to instantiate it. After parsing, the config object will
1916+
include the ``class_path`` and ``init_args`` entries. To get a config object
1917+
with all nested sub-classes instantiated, the
1918+
:py:meth:`.ArgumentParser.instantiate_classes` method is used.
1919+
1920+
Additional to using a class as type hint in signatures, for low level
1921+
construction of parsers, there are also the methods
1922+
:py:meth:`.SignatureArguments.add_class_arguments` and
1923+
:py:meth:`.SignatureArguments.add_subclass_arguments`. These methods accept a
1924+
``skip`` argument that can be used to exclude parameters within subclasses. This
1925+
is done by giving its relative destination key, i.e. as
1926+
``param.init_args.subparam``. An individual argument can also be added having as
1927+
type a class, i.e. ``parser.add_argument("--module", type=ModuleBase)``.
1928+
1929+
A simple example with a top-level class to instantiate, with a parameter that
1930+
expects an injected class instance, would be having some config file
1931+
``config.yaml`` as:
19921932

19931933
.. code-block:: yaml
19941934
@@ -2030,20 +1970,31 @@ Then in python:
20301970
{'class_path': 'calendar.Calendar', 'init_args': {'firstweekday': 1}}
20311971

20321972
>>> cfg = parser.instantiate_classes(cfg)
1973+
>>> isinstance(cfg.myclass, MyClass)
1974+
True
1975+
>>> isinstance(cfg.myclass.calendar, Calendar)
1976+
True
20331977
>>> cfg.myclass.calendar.getfirstweekday()
20341978
1
20351979

20361980
In this example the ``class_path`` points to the same class used for the type.
20371981
But a subclass of ``Calendar`` with an extended set of init parameters would
20381982
also work.
20391983

2040-
An individual argument can also be added having as type a class, i.e.
2041-
``parser.add_argument('--calendar', type=Calendar)``. There is also another
2042-
method :py:meth:`.SignatureArguments.add_subclass_arguments` which does the same
2043-
as ``add_argument``, but has some added benefits: 1) the argument is added in a
2044-
new group automatically; 2) the argument values can be given in an independent
2045-
config file by specifying a path to it; and 3) by default sets a useful
2046-
``metavar`` and ``help`` strings.
1984+
If the previous example were changed to use
1985+
:py:meth:`.SignatureArguments.add_subclass_arguments` instead of
1986+
:py:meth:`.SignatureArguments.add_class_arguments`, then subclasses ``MyClass``
1987+
would also be accepted. In this case the config would be like:
1988+
1989+
.. code-block:: yaml
1990+
1991+
myclass:
1992+
class_path: my_module.MyClass
1993+
init_args:
1994+
calendar:
1995+
class_path: calendar.TextCalendar
1996+
init_args:
1997+
firstweekday: 1
20471998
20481999
.. note::
20492000

@@ -2057,14 +2008,149 @@ config file by specifying a path to it; and 3) by default sets a useful
20572008
type a class. The accepted ``init_args`` would be the parameters of that
20582009
function.
20592010

2011+
.. _instance-factories:
2012+
2013+
Instance factories
2014+
------------------
2015+
2016+
As explained at the beginning of section :ref:`dependency-injection`, callables
2017+
that return instances of classes, referred to as instance factories, represent
2018+
an alternative approach to dependency injection. This is useful to support
2019+
dependency injection of classes that require parameters that are only available
2020+
after injection. For this case, when
2021+
:py:meth:`.ArgumentParser.instantiate_classes` is run, a partial function is
2022+
provided, which might accept parameters and returns the instance of the class.
2023+
Two options are possible, either using ``Callable`` or ``Protocol``. First to
2024+
illustrate the ``Callable`` option, take for example the classes:
2025+
2026+
.. testcode:: callable
2027+
2028+
class Optimizer:
2029+
def __init__(self, params: Iterable):
2030+
self.params = params
2031+
2032+
2033+
class SGD(Optimizer):
2034+
def __init__(self, params: Iterable, lr: float):
2035+
super().__init__(params)
2036+
self.lr = lr
2037+
2038+
.. testcode:: callable
2039+
:hide:
2040+
2041+
doctest_mock_class_in_main(SGD)
2042+
2043+
A possible parser and callable behavior would be:
2044+
2045+
.. doctest:: callable
2046+
2047+
>>> value = {
2048+
... "class_path": "SGD",
2049+
... "init_args": {
2050+
... "lr": 0.01,
2051+
... },
2052+
... }
2053+
2054+
>>> parser.add_argument("--optimizer", type=Callable[[Iterable], Optimizer]) # doctest: +IGNORE_RESULT
2055+
>>> cfg = parser.parse_args(["--optimizer", str(value)])
2056+
>>> cfg.optimizer
2057+
Namespace(class_path='__main__.SGD', init_args=Namespace(lr=0.01))
2058+
>>> init = parser.instantiate_classes(cfg)
2059+
>>> optimizer = init.optimizer([1, 2, 3])
2060+
>>> isinstance(optimizer, SGD)
2061+
True
2062+
>>> optimizer.params, optimizer.lr
2063+
([1, 2, 3], 0.01)
2064+
2065+
.. note::
2066+
2067+
When the ``Callable`` has a class return type, it is possible to specify the
2068+
``class_path`` giving only its name if imported before parsing, as explained
2069+
in :ref:`sub-classes-command-line`.
2070+
2071+
If the same type above is used as type hint of a parameter of another class, a
2072+
default can be set using a lambda, for example:
2073+
2074+
.. testcode:: callable
2075+
2076+
class Model:
2077+
def __init__(
2078+
self,
2079+
optimizer: Callable[[Iterable], Optimizer] = lambda p: SGD(p, lr=0.05),
2080+
):
2081+
self.optimizer = optimizer
2082+
2083+
Then a parser and behavior could be:
2084+
2085+
.. code-block::
2086+
2087+
>>> parser.add_class_arguments(Model, 'model')
2088+
>>> cfg = parser.get_defaults()
2089+
>>> cfg.model.optimizer
2090+
Namespace(class_path='__main__.SGD', init_args=Namespace(lr=0.05))
2091+
>>> init = parser.instantiate_classes(cfg)
2092+
>>> optimizer = init.model.optimizer([1, 2, 3])
2093+
>>> optimizer.params, optimizer.lr
2094+
([1, 2, 3], 0.05)
2095+
2096+
See :ref:`ast-resolver` for limitations of lambda defaults in signatures.
2097+
Providing a lambda default to :py:meth:`.ActionsContainer.add_argument` does not
2098+
work since there is no AST resolving. In this case, a dict with ``class_path``
2099+
and ``init_args`` can be used as default.
2100+
2101+
Multiple arguments required after injection is also supported and can be
2102+
specified the same way with a ``Callable``. For example, for two
2103+
``Iterable`` arguments, you can use the syntax: ``Callable[[Iterable,
2104+
Iterable], Type]``. Similarly, for a callable that accepts zero
2105+
arguments, the syntax would be ``Callable[[], Type]``.
2106+
2107+
Note the big limitation that ``Callable`` has. It is only possible to specify
2108+
positional and unnamed parameters. To overcome this limitation, the second
2109+
option, a callable ``Protocol`` can be used instead. Building up from the same
2110+
example, an ``OptimizerFactory`` protocol can be defined as:
2111+
2112+
.. testcode:: callable
2113+
2114+
class OptimizerFactory(Protocol):
2115+
def __call__(self, params: Iterable) -> Optimizer: ...
2116+
2117+
Then a parser and protocol behavior would be:
2118+
2119+
.. testcode:: callable
2120+
:hide:
2121+
2122+
parser = ArgumentParser()
2123+
2124+
.. doctest:: callable
2125+
2126+
>>> value = {
2127+
... "class_path": "SGD",
2128+
... "init_args": {
2129+
... "lr": 0.02,
2130+
... },
2131+
... }
2132+
2133+
>>> parser.add_argument("--optimizer", type=OptimizerFactory) # doctest: +IGNORE_RESULT
2134+
>>> cfg = parser.parse_args(["--optimizer", str(value)])
2135+
>>> cfg.optimizer
2136+
Namespace(class_path='__main__.SGD', init_args=Namespace(lr=0.02))
2137+
>>> init = parser.instantiate_classes(cfg)
2138+
>>> optimizer = init.optimizer(params=[6, 5])
2139+
>>> optimizer.params, optimizer.lr
2140+
([6, 5], 0.02)
2141+
2142+
The key difference with respect to the ``Callable`` is being able to call
2143+
``init.optimizer()`` with keyword arguments ``params=[6, 5]``.
2144+
20602145
.. _sub-classes-command-line:
20612146

20622147
Command line
20632148
------------
20642149

2065-
The help of the parser does not show details for a type class since this depends
2066-
on the subclass. To get details for a particular subclass there is a specific
2067-
help option that receives the import path. Take for example a parser defined as:
2150+
The help of the parser does not show accepted parameters of a class since this
2151+
depends on the chosen subclass. To get details for a particular subclass there
2152+
is a help option that receives the import path. Take for example a parser
2153+
defined as:
20682154

20692155
.. testcode::
20702156

@@ -2163,6 +2249,12 @@ example above, this would be:
21632249
Like this, the parsed default will be a dict with ``class_path`` and
21642250
``init_args``, again avoiding the risk of mutability.
21652251

2252+
The use of :func:`.lazy_instance` is somewhat discouraged. A function that
2253+
delays the initialization of instances, and works for all possible cases out
2254+
there, is challenging. The current implementation is known to have some
2255+
problems. Instead of using :func:`.lazy_instance`, you could consider switching
2256+
to :ref:`instance-factories`.
2257+
21662258
.. note::
21672259

21682260
In python there can be some classes or functions for which it is not

0 commit comments

Comments
 (0)