Skip to content

Commit d50e581

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 d50e581

File tree

4 files changed

+214
-24
lines changed

4 files changed

+214
-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: 181 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -23,20 +23,20 @@
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
35+
from typing import get_type_hints as _get_type_hints
36+
37+
def get_type_hints(what, include_extras):
38+
return _get_type_hints(what)
39+
4040

4141
TYPING353 = hasattr(Union[str, int], '__origin__')
4242

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

8181

82+
_inject_marker = object()
83+
_noinject_marker = object()
84+
85+
if HAVE_ANNOTATED:
86+
InjectT = TypeVar('InjectT')
87+
Inject = Annotated[InjectT, _inject_marker]
88+
"""An experimental way to declare injectable dependencies utilizing a `PEP 593`_ implementation
89+
in `typing_extensions`. This API is likely to change when `PEP 593`_ stabilizes.
90+
91+
Those two declarations are equivalent::
92+
93+
@inject
94+
def fun(t: SomeType) -> None:
95+
pass
96+
97+
def fun(t: Inject[SomeType]) -> None:
98+
pass
99+
100+
The advantage over using :func:`inject` is that if you have some noninjectable parameters
101+
it may be easier to spot what are they. Those two are equivalent::
102+
103+
@inject
104+
@noninjectable('s')
105+
def fun(t: SomeType, s: SomeOtherType) -> None:
106+
pass
107+
108+
def fun(t: Inject[SomeType], s: SomeOtherType) -> None:
109+
pass
110+
111+
.. seealso::
112+
113+
Function :func:`get_bindings`
114+
A way to inspect how various injection declarations interact with each other.
115+
116+
.. versionadded:: 0.18.0
117+
.. note:: Requires Python 3.7+.
118+
119+
.. _PEP 593: https://www.python.org/dev/peps/pep-0593/
120+
.. _typing_extensions: https://pypi.org/project/typing-extensions/
121+
"""
122+
123+
NoInject = Annotated[InjectT, _noinject_marker]
124+
"""An experimental way to declare noninjectable dependencies utilizing a `PEP 593`_ implementation
125+
in `typing_extensions`. This API is likely to change when `PEP 593`_ stabilizes.
126+
127+
Since :func:`inject` declares all function's parameters to be injectable there needs to be a way
128+
to opt out of it. This has been provided by :func:`noninjectable` but `noninjectable` suffers from
129+
two issues:
130+
131+
* You need to repeat the parameter name
132+
* The declaration may be relatively distance in space from the actual parameter declaration, thus
133+
hindering readability
134+
135+
`NoInject` solves both of those concerns, for example (those two declarations are equivalent)::
136+
137+
@inject
138+
@noninjectable('b')
139+
def fun(a: TypeA, b: TypeB) -> None:
140+
pass
141+
142+
@inject
143+
def fun(a: TypeA, b: NoInject[TypeB]) -> None:
144+
pass
145+
146+
.. seealso::
147+
148+
Function :func:`get_bindings`
149+
A way to inspect how various injection declarations interact with each other.
150+
151+
.. versionadded:: 0.18.0
152+
.. note:: Requires Python 3.7+.
153+
154+
.. _PEP 593: https://www.python.org/dev/peps/pep-0593/
155+
.. _typing_extensions: https://pypi.org/project/typing-extensions/
156+
"""
157+
158+
82159
def reraise(original: Exception, exception: Exception, maximum_frames: int = 1) -> None:
83160
prev_cls, prev, tb = sys.exc_info()
84161
frames = inspect.getinnerframes(cast(types.TracebackType, tb))
@@ -511,6 +588,13 @@ def _is_specialization(cls, generic_class):
511588
# issubclass(SomeGeneric[X], SomeGeneric) so we need some other way to
512589
# determine whether a particular object is a generic class with type parameters
513590
# provided. Fortunately there seems to be __origin__ attribute that's useful here.
591+
592+
# We need to special-case Annotated as its __origin__ behaves differently than
593+
# other typing generic classes. See https://github.com/python/typing/pull/635
594+
# for some details.
595+
if HAVE_ANNOTATED and generic_class is Annotated and isinstance(cls, _AnnotatedAlias):
596+
return True
597+
514598
if not hasattr(cls, '__origin__'):
515599
return False
516600
origin = cls.__origin__
@@ -866,22 +950,26 @@ def repr_key(k):
866950
def get_bindings(callable: Callable) -> Dict[str, type]:
867951
"""Get bindings of injectable parameters from a callable.
868952
869-
If the callable is not decorated with :func:`inject` an empty dictionary will
953+
If the callable is not decorated with :func:`inject` and does not have any of its
954+
parameters declared as injectable using :data:`Inject` an empty dictionary will
870955
be returned. Otherwise the returned dictionary will contain a mapping
871956
between parameter names and their types with the exception of parameters
872-
excluded from dependency injection with :func:`noninjectable`. For example::
957+
excluded from dependency injection (either with :func:`noninjectable`, :data:`NoInject`
958+
or only explicit injection with :data:`Inject` being used). For example::
873959
874960
>>> def function1(a: int) -> None:
875961
... pass
876962
...
877963
>>> get_bindings(function1)
878964
{}
965+
879966
>>> @inject
880967
... def function2(a: int) -> None:
881968
... pass
882969
...
883970
>>> get_bindings(function2)
884971
{'a': int}
972+
885973
>>> @inject
886974
... @noninjectable('b')
887975
... def function3(a: int, b: str) -> None:
@@ -890,14 +978,55 @@ def get_bindings(callable: Callable) -> Dict[str, type]:
890978
>>> get_bindings(function3)
891979
{'a': int}
892980
981+
>>> # The simple case of no @inject but injection requested with Inject[...]
982+
>>> def function4(a: Inject[int], b: str) -> None:
983+
... pass
984+
...
985+
>>> get_bindings(function4)
986+
{'a': int}
987+
988+
>>> # Using @inject with Inject is redundant but it should not break anything
989+
>>> @inject
990+
... def function5(a: Inject[int], b: str) -> None:
991+
... pass
992+
...
993+
>>> get_bindings(function5)
994+
{'a': int, 'b': str}
995+
996+
>>> # We need to be able to exclude a parameter from injection with NoInject
997+
>>> @inject
998+
... def function6(a: int, b: NoInject[str]) -> None:
999+
... pass
1000+
...
1001+
>>> get_bindings(function6)
1002+
{'a': int}
1003+
1004+
>>> # The presence of NoInject should not trigger anything on its own
1005+
>>> def function7(a: int, b: NoInject[str]) -> None:
1006+
... pass
1007+
...
1008+
>>> get_bindings(function7)
1009+
{}
1010+
8931011
This function is used internally so by calling it you can learn what exactly
8941012
Injector is going to try to provide to a callable.
8951013
"""
1014+
look_for_explicit_bindings = False
8961015
if not hasattr(callable, '__bindings__'):
897-
return {}
1016+
type_hints = get_type_hints(callable, include_extras=True)
1017+
has_injectable_parameters = any(
1018+
_is_specialization(v, Annotated) and _inject_marker in v.__metadata__ for v in type_hints.values()
1019+
)
1020+
1021+
if not has_injectable_parameters:
1022+
return {}
1023+
else:
1024+
look_for_explicit_bindings = True
8981025

899-
if cast(Any, callable).__bindings__ == 'deferred':
900-
read_and_store_bindings(callable, _infer_injected_bindings(callable))
1026+
if look_for_explicit_bindings or cast(Any, callable).__bindings__ == 'deferred':
1027+
read_and_store_bindings(
1028+
callable, _infer_injected_bindings(callable, only_explicit_bindings=look_for_explicit_bindings)
1029+
)
9011030
noninjectables = getattr(callable, '__noninjectables__', set())
9021031
return {k: v for k, v in cast(Any, callable).__bindings__.items() if k not in noninjectables}
9031032

@@ -906,10 +1035,10 @@ class _BindingNotYetAvailable(Exception):
9061035
pass
9071036

9081037

909-
def _infer_injected_bindings(callable):
1038+
def _infer_injected_bindings(callable, only_explicit_bindings: bool):
9101039
spec = inspect.getfullargspec(callable)
9111040
try:
912-
bindings = get_type_hints(callable)
1041+
bindings = get_type_hints(callable, include_extras=True)
9131042
except NameError as e:
9141043
raise _BindingNotYetAvailable(e)
9151044

@@ -929,14 +1058,26 @@ def _infer_injected_bindings(callable):
9291058
bindings.pop(spec.varkw, None)
9301059

9311060
for k, v in list(bindings.items()):
1061+
if _is_specialization(v, Annotated):
1062+
v, metadata = v.__origin__, v.__metadata__
1063+
bindings[k] = v
1064+
else:
1065+
metadata = tuple()
1066+
1067+
if only_explicit_bindings and _inject_marker not in metadata or _noinject_marker in metadata:
1068+
del bindings[k]
1069+
break
1070+
9321071
if _is_specialization(v, Union):
9331072
# We don't treat Optional parameters in any special way at the moment.
9341073
if TYPING353:
9351074
union_members = v.__args__
9361075
else:
9371076
union_members = v.__union_params__
9381077
new_members = tuple(set(union_members) - {type(None)})
939-
new_union = Union[new_members]
1078+
# mypy stared complaining about this line for some reason:
1079+
# error: Variable "new_members" is not valid as a type
1080+
new_union = Union[new_members] # type: ignore
9401081
# mypy complains about this construct:
9411082
# error: The type alias is invalid in runtime context
9421083
# See: https://github.com/python/mypy/issues/5354
@@ -1051,6 +1192,14 @@ def inject(constructor_or_class):
10511192
Third party libraries may, however, provide support for injecting dependencies
10521193
into non-constructor methods or free functions in one form or another.
10531194
1195+
.. seealso::
1196+
1197+
Generic type :data:`Inject`
1198+
A more explicit way to declare parameters as injectable.
1199+
1200+
Function :func:`get_bindings`
1201+
A way to inspect how various injection declarations interact with each other.
1202+
10541203
.. versionchanged:: 0.16.2
10551204
10561205
(Re)added support for decorating classes with @inject.
@@ -1060,7 +1209,7 @@ def inject(constructor_or_class):
10601209
else:
10611210
function = constructor_or_class
10621211
try:
1063-
bindings = _infer_injected_bindings(function)
1212+
bindings = _infer_injected_bindings(function, only_explicit_bindings=False)
10641213
read_and_store_bindings(function, bindings)
10651214
except _BindingNotYetAvailable:
10661215
function.__bindings__ = 'deferred'
@@ -1089,6 +1238,15 @@ def noninjectable(*args):
10891238
each other and the order in which a function is decorated with
10901239
:func:`inject` and :func:`noninjectable`
10911240
doesn't matter.
1241+
1242+
.. seealso::
1243+
1244+
Generic type :data:`NoInject`
1245+
A nicer way to declare parameters as noninjectable.
1246+
1247+
Function :func:`get_bindings`
1248+
A way to inspect how various injection declarations interact with each other.
1249+
10921250
"""
10931251

10941252
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)