-
-
Notifications
You must be signed in to change notification settings - Fork 9
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
3 changed files
with
174 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -99,3 +99,6 @@ ENV/ | |
|
||
# mypy | ||
.mypy_cache/ | ||
|
||
# PyCharm | ||
.idea/ |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,64 @@ | ||
import inspect | ||
from functools import wraps | ||
from typing import Iterable | ||
|
||
import logging | ||
|
||
|
||
def injectable(injectable_kwargs: Iterable[str] = None): | ||
def decorator(func: callable): | ||
specs = inspect.getfullargspec(func) | ||
|
||
if injectable_kwargs is None: | ||
injectables = { | ||
kwarg: specs.annotations.get(kwarg) | ||
for kwarg in specs.kwonlyargs | ||
if (kwarg not in specs.kwonlydefaults | ||
and inspect.isclass(specs.annotations.get(kwarg))) | ||
} | ||
if len(injectables) is 0: | ||
logging.warning("Function '{function}' is annotated with" | ||
" '@injectable' but no arguments that" | ||
" qualify as injectable were found") | ||
else: | ||
injectables = { | ||
kwarg: specs.annotations.get(kwarg) | ||
for kwarg in injectable_kwargs | ||
} | ||
|
||
for kwarg, klass in injectables.items(): | ||
issue = None | ||
if kwarg not in specs.kwonlyargs: | ||
issue = ("Injectable arguments must be keyword arguments" | ||
" only") | ||
elif not inspect.isclass(klass): | ||
issue = ("Injectable arguments must be annotated with a" | ||
" class type") | ||
else: | ||
try: | ||
klass() | ||
except Exception as e: | ||
issue = ("Injectable arguments must be able to be" | ||
" instantiated through a default constructor" | ||
" but if attempted to be instantiated the" | ||
" {klass}'s constructor will raise:" | ||
" {exception}" | ||
.format(klass=klass.__name__, exception=e)) | ||
|
||
if issue is not None: | ||
raise TypeError( | ||
"Argument '{argument}' in function '{function}' cannot" | ||
" be injected: {reason}" | ||
.format(argument=kwarg, function=func.__name__, | ||
reason=issue)) | ||
|
||
@wraps(func) | ||
def wrapper(*args, **kwargs): | ||
for kwarg, klass in injectables.items(): | ||
if kwarg in kwargs: | ||
continue | ||
kwargs[kwarg] = klass() | ||
return func(*args, **kwargs) | ||
return wrapper | ||
|
||
return decorator |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,107 @@ | ||
import pytest | ||
|
||
from injectable import injectable | ||
|
||
|
||
class DummyClass(object): | ||
pass | ||
|
||
|
||
class NoDefaultConstructorClass(object): | ||
def __init__(self, something): | ||
pass | ||
|
||
|
||
def bar(*args, x: DummyClass = None, y: 'DummyClass', f: callable): | ||
return args, {'x': x, 'y': y, 'f': f} | ||
|
||
|
||
def foo(a, | ||
b: int, | ||
c, | ||
*, | ||
w=42, | ||
x: str = None, | ||
y: 'bool', | ||
z: DummyClass, | ||
f: callable, | ||
**kwargs): | ||
kwargs.update({ | ||
'w': w, | ||
'x': x, | ||
'y': y, | ||
'z': z, | ||
'f': f, | ||
}) | ||
return (a, b, c), kwargs | ||
|
||
|
||
def qux(*, nope: NoDefaultConstructorClass): | ||
return nope | ||
|
||
|
||
class TestInjectableAnnotation(object): | ||
|
||
def test_ineffective_use_of_annotation_logs_warning(self, mocker): | ||
logging = mocker.patch('injectable.logging') | ||
|
||
injectable()(bar) | ||
|
||
assert logging.warning.called | ||
|
||
def test_positional_arg_as_injectable_raises_type_error(self): | ||
with pytest.raises(TypeError): | ||
injectable(['b'])(foo) | ||
|
||
def test_injectable_kwarg_with_no_class_annotation_raises_type_error(self): | ||
with pytest.raises(TypeError): | ||
injectable(['f'])(foo) | ||
|
||
def test_missing_positional_arguments_raises_type_error(self): | ||
with pytest.raises(TypeError): | ||
injectable()(foo)() | ||
|
||
def test_missing_kwonly_args_raises_type_error(self): | ||
with pytest.raises(TypeError): | ||
injectable()(foo)(a=None, b=10, c='') | ||
|
||
def test_caller_defined_arguments_are_not_overridden(self): | ||
caller_args = (True, 80, []) | ||
caller_kwargs = { | ||
'w': {}, | ||
'x': "string", | ||
'y': True, | ||
'z': None, | ||
'f': lambda x: print(x), | ||
'extra': True, | ||
} | ||
|
||
args, kwargs = injectable()(foo)(*caller_args, **caller_kwargs) | ||
|
||
assert args == caller_args | ||
assert kwargs == caller_kwargs | ||
|
||
def test_injectables_initialization_when_not_injected(self): | ||
caller_args = (False, None, True) | ||
caller_kwargs = { | ||
'w': {}, | ||
'x': "string", | ||
'y': True, | ||
'f': lambda x: print(x), | ||
'kwargs': {'extra': True}, | ||
} | ||
|
||
args, kwargs = injectable()(foo)(*caller_args, **caller_kwargs) | ||
|
||
assert isinstance(kwargs['z'], DummyClass) | ||
|
||
def test_injectable_with_non_instantiable_class_raises_type_error(self): | ||
# eligible injectable argument is annotated with non | ||
# instantiable class | ||
with pytest.raises(TypeError): | ||
injectable()(qux) | ||
|
||
# specificed argument for injection is annotated with non | ||
# instantiable class | ||
with pytest.raises(TypeError): | ||
injectable(['nope'])(qux) |