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): https://github.com/python/typing/issues/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/typing-sig@python.org/thread/CZ7N3M3PGKHUY63RWWSPTICVOAVYI73D/
This commit is contained in:
Jakub Stasiak 2019-06-13 13:28:41 +02:00
parent eb5344cd8a
commit d50e581734
4 changed files with 214 additions and 24 deletions

View File

@ -14,7 +14,7 @@ matrix:
- python: "nightly"
- python: "3.8-dev"
install:
- pip install --upgrade coveralls pytest "pytest-cov>=2.5.1" dataclasses
- pip install --upgrade coveralls pytest "pytest-cov>=2.5.1" dataclasses typing_extensions
# mypy can't be installed on pypy
- if [[ "${TRAVIS_PYTHON_VERSION}" != "pypy"* ]] ; then pip install mypy ; fi
# Black is Python 3.6+-only

View File

@ -23,20 +23,20 @@ import threading
import types
from abc import ABCMeta, abstractmethod
from collections import namedtuple
from typing import (
Any,
Callable,
cast,
Dict,
Generic,
get_type_hints,
List,
overload,
Tuple,
Type,
TypeVar,
Union,
)
from typing import Any, Callable, cast, Dict, Generic, List, overload, Tuple, Type, TypeVar, Union
HAVE_ANNOTATED = sys.version_info >= (3, 7, 0)
if HAVE_ANNOTATED:
# Ignoring errors here as typing_extensions stub doesn't know about those things yet
from typing_extensions import _AnnotatedAlias, Annotated, get_type_hints # type: ignore
else:
Annotated = None
from typing import get_type_hints as _get_type_hints
def get_type_hints(what, include_extras):
return _get_type_hints(what)
TYPING353 = hasattr(Union[str, int], '__origin__')
@ -79,6 +79,83 @@ def synchronized(lock: threading.RLock) -> Callable[[CallableT], CallableT]:
lock = threading.RLock()
_inject_marker = object()
_noinject_marker = object()
if HAVE_ANNOTATED:
InjectT = TypeVar('InjectT')
Inject = Annotated[InjectT, _inject_marker]
"""An experimental way to declare injectable dependencies utilizing a `PEP 593`_ implementation
in `typing_extensions`. This API is likely to change when `PEP 593`_ stabilizes.
Those two declarations are equivalent::
@inject
def fun(t: SomeType) -> None:
pass
def fun(t: Inject[SomeType]) -> None:
pass
The advantage over using :func:`inject` is that if you have some noninjectable parameters
it may be easier to spot what are they. Those two are equivalent::
@inject
@noninjectable('s')
def fun(t: SomeType, s: SomeOtherType) -> None:
pass
def fun(t: Inject[SomeType], s: SomeOtherType) -> None:
pass
.. seealso::
Function :func:`get_bindings`
A way to inspect how various injection declarations interact with each other.
.. versionadded:: 0.18.0
.. note:: Requires Python 3.7+.
.. _PEP 593: https://www.python.org/dev/peps/pep-0593/
.. _typing_extensions: https://pypi.org/project/typing-extensions/
"""
NoInject = Annotated[InjectT, _noinject_marker]
"""An experimental way to declare noninjectable dependencies utilizing a `PEP 593`_ implementation
in `typing_extensions`. This API is likely to change when `PEP 593`_ stabilizes.
Since :func:`inject` declares all function's parameters to be injectable there needs to be a way
to opt out of it. This has been provided by :func:`noninjectable` but `noninjectable` suffers from
two issues:
* You need to repeat the parameter name
* The declaration may be relatively distance in space from the actual parameter declaration, thus
hindering readability
`NoInject` solves both of those concerns, for example (those two declarations are equivalent)::
@inject
@noninjectable('b')
def fun(a: TypeA, b: TypeB) -> None:
pass
@inject
def fun(a: TypeA, b: NoInject[TypeB]) -> None:
pass
.. seealso::
Function :func:`get_bindings`
A way to inspect how various injection declarations interact with each other.
.. versionadded:: 0.18.0
.. note:: Requires Python 3.7+.
.. _PEP 593: https://www.python.org/dev/peps/pep-0593/
.. _typing_extensions: https://pypi.org/project/typing-extensions/
"""
def reraise(original: Exception, exception: Exception, maximum_frames: int = 1) -> None:
prev_cls, prev, tb = sys.exc_info()
frames = inspect.getinnerframes(cast(types.TracebackType, tb))
@ -511,6 +588,13 @@ if TYPING353:
# issubclass(SomeGeneric[X], SomeGeneric) so we need some other way to
# determine whether a particular object is a generic class with type parameters
# provided. Fortunately there seems to be __origin__ attribute that's useful here.
# We need to special-case Annotated as its __origin__ behaves differently than
# other typing generic classes. See https://github.com/python/typing/pull/635
# for some details.
if HAVE_ANNOTATED and generic_class is Annotated and isinstance(cls, _AnnotatedAlias):
return True
if not hasattr(cls, '__origin__'):
return False
origin = cls.__origin__
@ -866,22 +950,26 @@ class Injector:
def get_bindings(callable: Callable) -> Dict[str, type]:
"""Get bindings of injectable parameters from a callable.
If the callable is not decorated with :func:`inject` an empty dictionary will
If the callable is not decorated with :func:`inject` and does not have any of its
parameters declared as injectable using :data:`Inject` an empty dictionary will
be returned. Otherwise the returned dictionary will contain a mapping
between parameter names and their types with the exception of parameters
excluded from dependency injection with :func:`noninjectable`. For example::
excluded from dependency injection (either with :func:`noninjectable`, :data:`NoInject`
or only explicit injection with :data:`Inject` being used). For example::
>>> def function1(a: int) -> None:
... pass
...
>>> get_bindings(function1)
{}
>>> @inject
... def function2(a: int) -> None:
... pass
...
>>> get_bindings(function2)
{'a': int}
>>> @inject
... @noninjectable('b')
... def function3(a: int, b: str) -> None:
@ -890,14 +978,55 @@ def get_bindings(callable: Callable) -> Dict[str, type]:
>>> get_bindings(function3)
{'a': int}
>>> # The simple case of no @inject but injection requested with Inject[...]
>>> def function4(a: Inject[int], b: str) -> None:
... pass
...
>>> get_bindings(function4)
{'a': int}
>>> # Using @inject with Inject is redundant but it should not break anything
>>> @inject
... def function5(a: Inject[int], b: str) -> None:
... pass
...
>>> get_bindings(function5)
{'a': int, 'b': str}
>>> # We need to be able to exclude a parameter from injection with NoInject
>>> @inject
... def function6(a: int, b: NoInject[str]) -> None:
... pass
...
>>> get_bindings(function6)
{'a': int}
>>> # The presence of NoInject should not trigger anything on its own
>>> def function7(a: int, b: NoInject[str]) -> None:
... pass
...
>>> get_bindings(function7)
{}
This function is used internally so by calling it you can learn what exactly
Injector is going to try to provide to a callable.
"""
look_for_explicit_bindings = False
if not hasattr(callable, '__bindings__'):
return {}
type_hints = get_type_hints(callable, include_extras=True)
has_injectable_parameters = any(
_is_specialization(v, Annotated) and _inject_marker in v.__metadata__ for v in type_hints.values()
)
if cast(Any, callable).__bindings__ == 'deferred':
read_and_store_bindings(callable, _infer_injected_bindings(callable))
if not has_injectable_parameters:
return {}
else:
look_for_explicit_bindings = True
if look_for_explicit_bindings or cast(Any, callable).__bindings__ == 'deferred':
read_and_store_bindings(
callable, _infer_injected_bindings(callable, only_explicit_bindings=look_for_explicit_bindings)
)
noninjectables = getattr(callable, '__noninjectables__', set())
return {k: v for k, v in cast(Any, callable).__bindings__.items() if k not in noninjectables}
@ -906,10 +1035,10 @@ class _BindingNotYetAvailable(Exception):
pass
def _infer_injected_bindings(callable):
def _infer_injected_bindings(callable, only_explicit_bindings: bool):
spec = inspect.getfullargspec(callable)
try:
bindings = get_type_hints(callable)
bindings = get_type_hints(callable, include_extras=True)
except NameError as e:
raise _BindingNotYetAvailable(e)
@ -929,6 +1058,16 @@ def _infer_injected_bindings(callable):
bindings.pop(spec.varkw, None)
for k, v in list(bindings.items()):
if _is_specialization(v, Annotated):
v, metadata = v.__origin__, v.__metadata__
bindings[k] = v
else:
metadata = tuple()
if only_explicit_bindings and _inject_marker not in metadata or _noinject_marker in metadata:
del bindings[k]
break
if _is_specialization(v, Union):
# We don't treat Optional parameters in any special way at the moment.
if TYPING353:
@ -936,7 +1075,9 @@ def _infer_injected_bindings(callable):
else:
union_members = v.__union_params__
new_members = tuple(set(union_members) - {type(None)})
new_union = Union[new_members]
# mypy stared complaining about this line for some reason:
# error: Variable "new_members" is not valid as a type
new_union = Union[new_members] # type: ignore
# mypy complains about this construct:
# error: The type alias is invalid in runtime context
# See: https://github.com/python/mypy/issues/5354
@ -1051,6 +1192,14 @@ def inject(constructor_or_class):
Third party libraries may, however, provide support for injecting dependencies
into non-constructor methods or free functions in one form or another.
.. seealso::
Generic type :data:`Inject`
A more explicit way to declare parameters as injectable.
Function :func:`get_bindings`
A way to inspect how various injection declarations interact with each other.
.. versionchanged:: 0.16.2
(Re)added support for decorating classes with @inject.
@ -1060,7 +1209,7 @@ def inject(constructor_or_class):
else:
function = constructor_or_class
try:
bindings = _infer_injected_bindings(function)
bindings = _infer_injected_bindings(function, only_explicit_bindings=False)
read_and_store_bindings(function, bindings)
except _BindingNotYetAvailable:
function.__bindings__ = 'deferred'
@ -1089,6 +1238,15 @@ def noninjectable(*args):
each other and the order in which a function is decorated with
:func:`inject` and :func:`noninjectable`
doesn't matter.
.. seealso::
Generic type :data:`NoInject`
A nicer way to declare parameters as noninjectable.
Function :func:`get_bindings`
A way to inspect how various injection declarations interact with each other.
"""
def decorator(function):

View File

@ -46,8 +46,12 @@ from injector import (
ClassAssistedBuilder,
Error,
UnknownArgument,
HAVE_ANNOTATED,
)
if HAVE_ANNOTATED:
from injector import Inject, NoInject
def prepare_basic_injection():
class B:
@ -1438,3 +1442,30 @@ def test_get_bindings():
pass
assert get_bindings(function3) == {'a': int}
if HAVE_ANNOTATED:
# The simple case of no @inject but injection requested with Inject[...]
def function4(a: Inject[int], b: str) -> None:
pass
assert get_bindings(function4) == {'a': int}
# Using @inject with Inject is redundant but it should not break anything
@inject
def function5(a: Inject[int], b: str) -> None:
pass
assert get_bindings(function5) == {'a': int, 'b': str}
# We need to be able to exclude a parameter from injection with NoInject
@inject
def function6(a: int, b: NoInject[str]) -> None:
pass
assert get_bindings(function6) == {'a': int}
# The presence of NoInject should not trigger anything on its own
def function7(a: int, b: NoInject[str]) -> None:
pass
assert get_bindings(function7) == {}

View File

@ -69,4 +69,5 @@ setup(
'IoC',
'Inversion of Control container',
],
install_requires=['typing_extensions>=3.7.4'],
)