Skip to content

Commit 610403b

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 610403b

File tree

4 files changed

+209
-24
lines changed

4 files changed

+209
-24
lines changed

.travis.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ matrix:
1414
- python: "nightly"
1515
- python: "3.8-dev"
1616
install:
17-
- pip install --upgrade coveralls pytest "pytest-cov>=2.5.1" dataclasses
17+
- pip install --upgrade coveralls pytest "pytest-cov>=2.5.1" dataclasses typing_extensions
1818
# mypy can't be installed on pypy
1919
- if [[ "${TRAVIS_PYTHON_VERSION}" != "pypy"* ]] ; then pip install mypy ; fi
2020
# Black is Python 3.6+-only

injector/__init__.py

Lines changed: 176 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -23,20 +23,15 @@
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+
HAVE_ANNOTATED = sys.version_info >= (3, 7, 0)
29+
30+
if HAVE_ANNOTATED:
31+
# Ignoring errors here as typing_extensions stub doesn't know about those things yet
32+
from typing_extensions import _AnnotatedAlias, Annotated, get_type_hints # type: ignore
33+
else:
34+
Annotated = None
4035

4136
TYPING353 = hasattr(Union[str, int], '__origin__')
4237

@@ -79,6 +74,83 @@ def wrapper(*args: Any, **kwargs: Any) -> Any:
7974
lock = threading.RLock()
8075

8176

77+
_inject_marker = object()
78+
_noinject_marker = object()
79+
80+
if HAVE_ANNOTATED:
81+
InjectT = TypeVar('InjectT')
82+
Inject = Annotated[InjectT, _inject_marker]
83+
"""An experimental way to declare injectable dependencies utilizing a `PEP 593`_ implementation
84+
in `typing_extensions`. This API is likely to change when `PEP 593`_ stabilizes.
85+
86+
Those two declarations are equivalent::
87+
88+
@inject
89+
def fun(t: SomeType) -> None:
90+
pass
91+
92+
def fun(t: Inject[SomeType]) -> None:
93+
pass
94+
95+
The advantage over using :func:`inject` is that if you have some noninjectable parameters
96+
it may be easier to spot what are they. Those two are equivalent::
97+
98+
@inject
99+
@noninjectable('s')
100+
def fun(t: SomeType, s: SomeOtherType) -> None:
101+
pass
102+
103+
def fun(t: Inject[SomeType], s: SomeOtherType) -> None:
104+
pass
105+
106+
.. seealso::
107+
108+
Function :func:`get_bindings`
109+
A way to inspect how various injection declarations interact with each other.
110+
111+
.. versionadded:: 0.18.0
112+
.. note:: Requires Python 3.7+.
113+
114+
.. _PEP 593: https://www.python.org/dev/peps/pep-0593/
115+
.. _typing_extensions: https://pypi.org/project/typing-extensions/
116+
"""
117+
118+
NoInject = Annotated[InjectT, _noinject_marker]
119+
"""An experimental way to declare noninjectable dependencies utilizing a `PEP 593`_ implementation
120+
in `typing_extensions`. This API is likely to change when `PEP 593`_ stabilizes.
121+
122+
Since :func:`inject` declares all function's parameters to be injectable there needs to be a way
123+
to opt out of it. This has been provided by :func:`noninjectable` but `noninjectable` suffers from
124+
two issues:
125+
126+
* You need to repeat the parameter name
127+
* The declaration may be relatively distance in space from the actual parameter declaration, thus
128+
hindering readability
129+
130+
`NoInject` solves both of those concerns, for example (those two declarations are equivalent)::
131+
132+
@inject
133+
@noninjectable('b')
134+
def fun(a: TypeA, b: TypeB) -> None:
135+
pass
136+
137+
@inject
138+
def fun(a: TypeA, b: NoInject[TypeB]) -> None:
139+
pass
140+
141+
.. seealso::
142+
143+
Function :func:`get_bindings`
144+
A way to inspect how various injection declarations interact with each other.
145+
146+
.. versionadded:: 0.18.0
147+
.. note:: Requires Python 3.7+.
148+
149+
.. _PEP 593: https://www.python.org/dev/peps/pep-0593/
150+
.. _typing_extensions: https://pypi.org/project/typing-extensions/
151+
"""
152+
153+
82154
def reraise(original: Exception, exception: Exception, maximum_frames: int = 1) -> None:
83155
prev_cls, prev, tb = sys.exc_info()
84156
frames = inspect.getinnerframes(cast(types.TracebackType, tb))
@@ -511,6 +583,13 @@ def _is_specialization(cls, generic_class):
511583
# issubclass(SomeGeneric[X], SomeGeneric) so we need some other way to
512584
# determine whether a particular object is a generic class with type parameters
513585
# provided. Fortunately there seems to be __origin__ attribute that's useful here.
586+
587+
# We need to special-case Annotated as its __origin__ behaves differently than
588+
# other typing generic classes. See https://github.com/python/typing/pull/635
589+
# for some details.
590+
if HAVE_ANNOTATED and generic_class is Annotated and isinstance(cls, _AnnotatedAlias):
591+
return True
592+
514593
if not hasattr(cls, '__origin__'):
515594
return False
516595
origin = cls.__origin__
@@ -866,22 +945,26 @@ def repr_key(k):
866945
def get_bindings(callable: Callable) -> Dict[str, type]:
867946
"""Get bindings of injectable parameters from a callable.
868947
869-
If the callable is not decorated with :func:`inject` an empty dictionary will
948+
If the callable is not decorated with :func:`inject` and does not have any of its
949+
parameters declared as injectable using :data:`Inject` an empty dictionary will
870950
be returned. Otherwise the returned dictionary will contain a mapping
871951
between parameter names and their types with the exception of parameters
872-
excluded from dependency injection with :func:`noninjectable`. For example::
952+
excluded from dependency injection (either with :func:`noninjectable`, :data:`NoInject`
953+
or only explicit injection with :data:`Inject` being used). For example::
873954
874955
>>> def function1(a: int) -> None:
875956
... pass
876957
...
877958
>>> get_bindings(function1)
878959
{}
960+
879961
>>> @inject
880962
... def function2(a: int) -> None:
881963
... pass
882964
...
883965
>>> get_bindings(function2)
884966
{'a': int}
967+
885968
>>> @inject
886969
... @noninjectable('b')
887970
... def function3(a: int, b: str) -> None:
@@ -890,14 +973,55 @@ def get_bindings(callable: Callable) -> Dict[str, type]:
890973
>>> get_bindings(function3)
891974
{'a': int}
892975
976+
>>> # The simple case of no @inject but injection requested with Inject[...]
977+
>>> def function4(a: Inject[int], b: str) -> None:
978+
... pass
979+
...
980+
>>> get_bindings(function4)
981+
{'a': int}
982+
983+
>>> # Using @inject with Inject is redundant but it should not break anything
984+
>>> @inject
985+
... def function5(a: Inject[int], b: str) -> None:
986+
... pass
987+
...
988+
>>> get_bindings(function5)
989+
{'a': int, 'b': str}
990+
991+
>>> # We need to be able to exclude a parameter from injection with NoInject
992+
>>> @inject
993+
... def function6(a: int, b: NoInject[str]) -> None:
994+
... pass
995+
...
996+
>>> get_bindings(function6)
997+
{'a': int}
998+
999+
>>> # The presence of NoInject should not trigger anything on its own
1000+
>>> def function7(a: int, b: NoInject[str]) -> None:
1001+
... pass
1002+
...
1003+
>>> get_bindings(function7)
1004+
{}
1005+
8931006
This function is used internally so by calling it you can learn what exactly
8941007
Injector is going to try to provide to a callable.
8951008
"""
1009+
look_for_explicit_bindings = False
8961010
if not hasattr(callable, '__bindings__'):
897-
return {}
1011+
type_hints = get_type_hints(callable, include_extras=True)
1012+
has_injectable_parameters = any(
1013+
_is_specialization(v, Annotated) and _inject_marker in v.__metadata__ for v in type_hints.values()
1014+
)
1015+
1016+
if not has_injectable_parameters:
1017+
return {}
1018+
else:
1019+
look_for_explicit_bindings = True
8981020

899-
if cast(Any, callable).__bindings__ == 'deferred':
900-
read_and_store_bindings(callable, _infer_injected_bindings(callable))
1021+
if look_for_explicit_bindings or cast(Any, callable).__bindings__ == 'deferred':
1022+
read_and_store_bindings(
1023+
callable, _infer_injected_bindings(callable, only_explicit_bindings=look_for_explicit_bindings)
1024+
)
9011025
noninjectables = getattr(callable, '__noninjectables__', set())
9021026
return {k: v for k, v in cast(Any, callable).__bindings__.items() if k not in noninjectables}
9031027

@@ -906,10 +1030,10 @@ class _BindingNotYetAvailable(Exception):
9061030
pass
9071031

9081032

909-
def _infer_injected_bindings(callable):
1033+
def _infer_injected_bindings(callable, only_explicit_bindings: bool):
9101034
spec = inspect.getfullargspec(callable)
9111035
try:
912-
bindings = get_type_hints(callable)
1036+
bindings = get_type_hints(callable, include_extras=True)
9131037
except NameError as e:
9141038
raise _BindingNotYetAvailable(e)
9151039

@@ -929,14 +1053,26 @@ def _infer_injected_bindings(callable):
9291053
bindings.pop(spec.varkw, None)
9301054

9311055
for k, v in list(bindings.items()):
1056+
if _is_specialization(v, Annotated):
1057+
v, metadata = v.__origin__, v.__metadata__
1058+
bindings[k] = v
1059+
else:
1060+
metadata = tuple()
1061+
1062+
if only_explicit_bindings and _inject_marker not in metadata or _noinject_marker in metadata:
1063+
del bindings[k]
1064+
break
1065+
9321066
if _is_specialization(v, Union):
9331067
# We don't treat Optional parameters in any special way at the moment.
9341068
if TYPING353:
9351069
union_members = v.__args__
9361070
else:
9371071
union_members = v.__union_params__
9381072
new_members = tuple(set(union_members) - {type(None)})
939-
new_union = Union[new_members]
1073+
# mypy stared complaining about this line for some reason:
1074+
# error: Variable "new_members" is not valid as a type
1075+
new_union = Union[new_members] # type: ignore
9401076
# mypy complains about this construct:
9411077
# error: The type alias is invalid in runtime context
9421078
# See: https://github.com/python/mypy/issues/5354
@@ -1051,6 +1187,14 @@ def inject(constructor_or_class):
10511187
Third party libraries may, however, provide support for injecting dependencies
10521188
into non-constructor methods or free functions in one form or another.
10531189
1190+
.. seealso::
1191+
1192+
Generic type :data:`Inject`
1193+
A more explicit way to declare parameters as injectable.
1194+
1195+
Function :func:`get_bindings`
1196+
A way to inspect how various injection declarations interact with each other.
1197+
10541198
.. versionchanged:: 0.16.2
10551199
10561200
(Re)added support for decorating classes with @inject.
@@ -1060,7 +1204,7 @@ def inject(constructor_or_class):
10601204
else:
10611205
function = constructor_or_class
10621206
try:
1063-
bindings = _infer_injected_bindings(function)
1207+
bindings = _infer_injected_bindings(function, only_explicit_bindings=False)
10641208
read_and_store_bindings(function, bindings)
10651209
except _BindingNotYetAvailable:
10661210
function.__bindings__ = 'deferred'
@@ -1089,6 +1233,15 @@ def noninjectable(*args):
10891233
each other and the order in which a function is decorated with
10901234
:func:`inject` and :func:`noninjectable`
10911235
doesn't matter.
1236+
1237+
.. seealso::
1238+
1239+
Generic type :data:`NoInject`
1240+
A nicer way to declare parameters as noninjectable.
1241+
1242+
Function :func:`get_bindings`
1243+
A way to inspect how various injection declarations interact with each other.
1244+
10921245
"""
10931246

10941247
def decorator(function):

injector_test.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,12 @@
4646
ClassAssistedBuilder,
4747
Error,
4848
UnknownArgument,
49+
HAVE_ANNOTATED,
4950
)
5051

52+
if HAVE_ANNOTATED:
53+
from injector import Inject, NoInject
54+
5155

5256
def prepare_basic_injection():
5357
class B:
@@ -1438,3 +1442,30 @@ def function3(a: int, b: str) -> None:
14381442
pass
14391443

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