Skip to content

Commit

Permalink
Injectable
Browse files Browse the repository at this point in the history
  • Loading branch information
roo-oliv committed Feb 4, 2018
1 parent 52cfce3 commit a356f18
Show file tree
Hide file tree
Showing 3 changed files with 174 additions and 0 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,6 @@ ENV/

# mypy
.mypy_cache/

# PyCharm
.idea/
64 changes: 64 additions & 0 deletions injectable.py
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
107 changes: 107 additions & 0 deletions test_injectable.py
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)

0 comments on commit a356f18

Please sign in to comment.