Skip to content

Commit 1773960

Browse files
committed
Experiment with PEP 593-based way to declare what's (non)injectable
There's an implementation of PEP 593 draft in typing_extensions and mypy supports it already. See also: * typing module discussion (pre-PEP): python/typing#600 * python-ideas discussion (pre-PEP): https://mail.python.org/pipermail/python-ideas/2019-January/054908.html * The actual PEP: https://www.python.org/dev/peps/pep-0593/ * typing-sig PEP discussion (ongoing): https://mail.python.org/archives/list/[email protected]/thread/CZ7N3M3PGKHUY63RWWSPTICVOAVYI73D/
1 parent eb5344c commit 1773960

File tree

3 files changed

+197
-23
lines changed

3 files changed

+197
-23
lines changed

injector/__init__.py

Lines changed: 168 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -23,20 +23,10 @@
2323
import types
2424
from abc import ABCMeta, abstractmethod
2525
from collections import namedtuple
26-
from typing import (
27-
Any,
28-
Callable,
29-
cast,
30-
Dict,
31-
Generic,
32-
get_type_hints,
33-
List,
34-
overload,
35-
Tuple,
36-
Type,
37-
TypeVar,
38-
Union,
39-
)
26+
from typing import Any, Callable, cast, Dict, Generic, List, overload, Tuple, Type, TypeVar, Union
27+
28+
# Ignoring errors here as typing_extensions stub doesn't know about those things yet
29+
from typing_extensions import _AnnotatedAlias, Annotated, get_type_hints # type: ignore
4030

4131
TYPING353 = hasattr(Union[str, int], '__origin__')
4232

@@ -79,6 +69,80 @@ def wrapper(*args: Any, **kwargs: Any) -> Any:
7969
lock = threading.RLock()
8070

8171

72+
_inject_marker = object()
73+
_noinject_marker = object()
74+
75+
InjectT = TypeVar('InjectT')
76+
Inject = Annotated[InjectT, _inject_marker]
77+
"""An experimental way to declare injectable dependencies utilizing a `PEP 593`_ implementation
78+
in `typing_extensions`. This API is likely to change when `PEP 593`_ stabilizes.
79+
80+
Those two declarations are equivalent::
81+
82+
@inject
83+
def fun(t: SomeType) -> None:
84+
pass
85+
86+
def fun(t: Inject[SomeType]) -> None:
87+
pass
88+
89+
The advantage over using :func:`inject` is that if you have some noninjectable parameters
90+
it may be easier to spot what are they. Those two are equivalent::
91+
92+
@inject
93+
@noninjectable('s')
94+
def fun(t: SomeType, s: SomeOtherType) -> None:
95+
pass
96+
97+
def fun(t: Inject[SomeType], s: SomeOtherType) -> None:
98+
pass
99+
100+
.. seealso::
101+
102+
Function :func:`get_bindings`
103+
A way to inspect how various injection declarations interact with each other.
104+
105+
.. versionadded:: 0.18.0
106+
107+
.. _PEP 593: https://www.python.org/dev/peps/pep-0593/
108+
.. _typing_extensions: https://pypi.org/project/typing-extensions/
109+
"""
110+
111+
NoInject = Annotated[InjectT, _noinject_marker]
112+
"""An experimental way to declare noninjectable dependencies utilizing a `PEP 593`_ implementation
113+
in `typing_extensions`. This API is likely to change when `PEP 593`_ stabilizes.
114+
115+
Since :func:`inject` declares all function's parameters to be injectable there needs to be a way
116+
to opt out of it. This has been provided by :func:`noninjectable` but `noninjectable` suffers from
117+
two issues:
118+
119+
* You need to repeat the parameter name
120+
* The declaration may be relatively distance in space from the actual parameter declaration, thus
121+
hindering readability
122+
123+
`NoInject` solves both of those concerns, for example (those two declarations are equivalent)::
124+
125+
@inject
126+
@noninjectable('b')
127+
def fun(a: TypeA, b: TypeB) -> None:
128+
pass
129+
130+
@inject
131+
def fun(a: TypeA, b: NoInject[TypeB]) -> None:
132+
pass
133+
134+
.. seealso::
135+
136+
Function :func:`get_bindings`
137+
A way to inspect how various injection declarations interact with each other.
138+
139+
.. versionadded:: 0.18.0
140+
141+
.. _PEP 593: https://www.python.org/dev/peps/pep-0593/
142+
.. _typing_extensions: https://pypi.org/project/typing-extensions/
143+
"""
144+
145+
82146
def reraise(original: Exception, exception: Exception, maximum_frames: int = 1) -> None:
83147
prev_cls, prev, tb = sys.exc_info()
84148
frames = inspect.getinnerframes(cast(types.TracebackType, tb))
@@ -511,6 +575,13 @@ def _is_specialization(cls, generic_class):
511575
# issubclass(SomeGeneric[X], SomeGeneric) so we need some other way to
512576
# determine whether a particular object is a generic class with type parameters
513577
# provided. Fortunately there seems to be __origin__ attribute that's useful here.
578+
579+
# We need to special-case Annotated as its __origin__ behaves differently than
580+
# other typing generic classes. See https://github.com/python/typing/pull/635
581+
# for some details.
582+
if generic_class is Annotated and isinstance(cls, _AnnotatedAlias):
583+
return True
584+
514585
if not hasattr(cls, '__origin__'):
515586
return False
516587
origin = cls.__origin__
@@ -866,22 +937,26 @@ def repr_key(k):
866937
def get_bindings(callable: Callable) -> Dict[str, type]:
867938
"""Get bindings of injectable parameters from a callable.
868939
869-
If the callable is not decorated with :func:`inject` an empty dictionary will
940+
If the callable is not decorated with :func:`inject` and does not have any of its
941+
parameters declared as injectable using :data:`Inject` an empty dictionary will
870942
be returned. Otherwise the returned dictionary will contain a mapping
871943
between parameter names and their types with the exception of parameters
872-
excluded from dependency injection with :func:`noninjectable`. For example::
944+
excluded from dependency injection (either with :func:`noninjectable`, :data:`NoInject`
945+
or only explicit injection with :data:`Inject` being used). For example::
873946
874947
>>> def function1(a: int) -> None:
875948
... pass
876949
...
877950
>>> get_bindings(function1)
878951
{}
952+
879953
>>> @inject
880954
... def function2(a: int) -> None:
881955
... pass
882956
...
883957
>>> get_bindings(function2)
884958
{'a': int}
959+
885960
>>> @inject
886961
... @noninjectable('b')
887962
... def function3(a: int, b: str) -> None:
@@ -890,14 +965,55 @@ def get_bindings(callable: Callable) -> Dict[str, type]:
890965
>>> get_bindings(function3)
891966
{'a': int}
892967
968+
>>> # The simple case of no @inject but injection requested with Inject[...]
969+
>>> def function4(a: Inject[int], b: str) -> None:
970+
... pass
971+
...
972+
>>> get_bindings(function4)
973+
{'a': int}
974+
975+
>>> # Using @inject with Inject is redundant but it should not break anything
976+
>>> @inject
977+
... def function5(a: Inject[int], b: str) -> None:
978+
... pass
979+
...
980+
>>> get_bindings(function5)
981+
{'a': int, 'b': str}
982+
983+
>>> # We need to be able to exclude a parameter from injection with NoInject
984+
>>> @inject
985+
... def function6(a: int, b: NoInject[str]) -> None:
986+
... pass
987+
...
988+
>>> get_bindings(function6)
989+
{'a': int}
990+
991+
>>> # The presence of NoInject should not trigger anything on its own
992+
>>> def function7(a: int, b: NoInject[str]) -> None:
993+
... pass
994+
...
995+
>>> get_bindings(function7)
996+
{}
997+
893998
This function is used internally so by calling it you can learn what exactly
894999
Injector is going to try to provide to a callable.
8951000
"""
1001+
look_for_explicit_bindings = False
8961002
if not hasattr(callable, '__bindings__'):
897-
return {}
1003+
type_hints = get_type_hints(callable, include_extras=True)
1004+
has_injectable_parameters = any(
1005+
_is_specialization(v, Annotated) and _inject_marker in v.__metadata__ for v in type_hints.values()
1006+
)
1007+
1008+
if not has_injectable_parameters:
1009+
return {}
1010+
else:
1011+
look_for_explicit_bindings = True
8981012

899-
if cast(Any, callable).__bindings__ == 'deferred':
900-
read_and_store_bindings(callable, _infer_injected_bindings(callable))
1013+
if look_for_explicit_bindings or cast(Any, callable).__bindings__ == 'deferred':
1014+
read_and_store_bindings(
1015+
callable, _infer_injected_bindings(callable, only_explicit_bindings=look_for_explicit_bindings)
1016+
)
9011017
noninjectables = getattr(callable, '__noninjectables__', set())
9021018
return {k: v for k, v in cast(Any, callable).__bindings__.items() if k not in noninjectables}
9031019

@@ -906,10 +1022,10 @@ class _BindingNotYetAvailable(Exception):
9061022
pass
9071023

9081024

909-
def _infer_injected_bindings(callable):
1025+
def _infer_injected_bindings(callable, only_explicit_bindings: bool):
9101026
spec = inspect.getfullargspec(callable)
9111027
try:
912-
bindings = get_type_hints(callable)
1028+
bindings = get_type_hints(callable, include_extras=True)
9131029
except NameError as e:
9141030
raise _BindingNotYetAvailable(e)
9151031

@@ -929,14 +1045,26 @@ def _infer_injected_bindings(callable):
9291045
bindings.pop(spec.varkw, None)
9301046

9311047
for k, v in list(bindings.items()):
1048+
if _is_specialization(v, Annotated):
1049+
v, metadata = v.__origin__, v.__metadata__
1050+
bindings[k] = v
1051+
else:
1052+
metadata = tuple()
1053+
1054+
if only_explicit_bindings and _inject_marker not in metadata or _noinject_marker in metadata:
1055+
del bindings[k]
1056+
break
1057+
9321058
if _is_specialization(v, Union):
9331059
# We don't treat Optional parameters in any special way at the moment.
9341060
if TYPING353:
9351061
union_members = v.__args__
9361062
else:
9371063
union_members = v.__union_params__
9381064
new_members = tuple(set(union_members) - {type(None)})
939-
new_union = Union[new_members]
1065+
# mypy stared complaining about this line for some reason:
1066+
# error: Variable "new_members" is not valid as a type
1067+
new_union = Union[new_members] # type: ignore
9401068
# mypy complains about this construct:
9411069
# error: The type alias is invalid in runtime context
9421070
# See: https://github.com/python/mypy/issues/5354
@@ -1051,6 +1179,14 @@ def inject(constructor_or_class):
10511179
Third party libraries may, however, provide support for injecting dependencies
10521180
into non-constructor methods or free functions in one form or another.
10531181
1182+
.. seealso::
1183+
1184+
Generic type :data:`Inject`
1185+
A more explicit way to declare parameters as injectable.
1186+
1187+
Function :func:`get_bindings`
1188+
A way to inspect how various injection declarations interact with each other.
1189+
10541190
.. versionchanged:: 0.16.2
10551191
10561192
(Re)added support for decorating classes with @inject.
@@ -1060,7 +1196,7 @@ def inject(constructor_or_class):
10601196
else:
10611197
function = constructor_or_class
10621198
try:
1063-
bindings = _infer_injected_bindings(function)
1199+
bindings = _infer_injected_bindings(function, only_explicit_bindings=False)
10641200
read_and_store_bindings(function, bindings)
10651201
except _BindingNotYetAvailable:
10661202
function.__bindings__ = 'deferred'
@@ -1089,6 +1225,15 @@ def noninjectable(*args):
10891225
each other and the order in which a function is decorated with
10901226
:func:`inject` and :func:`noninjectable`
10911227
doesn't matter.
1228+
1229+
.. seealso::
1230+
1231+
Generic type :data:`NoInject`
1232+
A nicer way to declare parameters as noninjectable.
1233+
1234+
Function :func:`get_bindings`
1235+
A way to inspect how various injection declarations interact with each other.
1236+
10921237
"""
10931238

10941239
def decorator(function):

injector_test.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@
4646
ClassAssistedBuilder,
4747
Error,
4848
UnknownArgument,
49+
Inject,
50+
NoInject,
4951
)
5052

5153

@@ -1438,3 +1440,29 @@ def function3(a: int, b: str) -> None:
14381440
pass
14391441

14401442
assert get_bindings(function3) == {'a': int}
1443+
1444+
# The simple case of no @inject but injection requested with Inject[...]
1445+
def function4(a: Inject[int], b: str) -> None:
1446+
pass
1447+
1448+
assert get_bindings(function4) == {'a': int}
1449+
1450+
# Using @inject with Inject is redundant but it should not break anything
1451+
@inject
1452+
def function5(a: Inject[int], b: str) -> None:
1453+
pass
1454+
1455+
assert get_bindings(function5) == {'a': int, 'b': str}
1456+
1457+
# We need to be able to exclude a parameter from injection with NoInject
1458+
@inject
1459+
def function6(a: int, b: NoInject[str]) -> None:
1460+
pass
1461+
1462+
assert get_bindings(function6) == {'a': int}
1463+
1464+
# The presence of NoInject should not trigger anything on its own
1465+
def function7(a: int, b: NoInject[str]) -> None:
1466+
pass
1467+
1468+
assert get_bindings(function7) == {}

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,4 +69,5 @@ def read_injector_variable(name):
6969
'IoC',
7070
'Inversion of Control container',
7171
],
72+
install_requires=['typing_extensions>=3.7.4'],
7273
)

0 commit comments

Comments
 (0)