Skip to content

Commit

Permalink
v0.1.0
Browse files Browse the repository at this point in the history
  • Loading branch information
roo-oliv committed Feb 5, 2018
1 parent a356f18 commit 00ebbfb
Show file tree
Hide file tree
Showing 9 changed files with 151 additions and 14 deletions.
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST

# PyInstaller
# Usually these files are written by a python script from a template
Expand Down Expand Up @@ -100,5 +101,8 @@ ENV/
# mypy
.mypy_cache/

# pytest
.pytest_cache/

# PyCharm
.idea/
83 changes: 83 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
..highlight:: rst
.. _injectable:
injectable
==========

:decorator:`injectable` enables exposing injectable arguments in
function parameters without worrying to initialize these dependencies
latter if the caller didn't inject them.

.. _usage:
Usage
-----

Just annotate a function with :decorator:`injectable` and worry no more
about initializing it's injectable dependencies when the caller do not
pass them explicitly:

.. code:: python
class Printer:
def print_something(self):
print("Something")
@injectable()
def foo(*, printer: Printer):
printer.print_something()
foo()
# Something
.. _how-works:
How does this work?
~~~~~~~~~~~~~~~~~~~
:decorator:`injectable` uses type annotations to decide whether or not
to inject the dependency. Some conditions may be observed:

* Only Keyword-Only arguments can be injected:
.. code:: python
@injectable()
def foo(not_injectable: MyClass, not_injectable_either: MyClass = None,
*, injectable_kwarg: MyClass):
...
* If a default value is provided, the argument will **not** be injected:
.. code:: python
@injectable()
def foo(*, injectable_kwarg: MyClass, not_injectable_kwarg: MyClass = None):
...
* The class must have a default constructor without arguments:
.. code:: python
class OkForInjection:
def __init__(self, optional_arg=42):
...
class NotSuitableForInjection:
def __init__(self, mandatory_arg):
...
Attempting to use a not suitable class for injection will result in a
TypeError raised during initialization of the annotated function.

.. _specify-injectables:
Cherry picking arguments for injection
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
If no parameters are passed into :decorator:`injectable`, then it will consider every
keyword-only argument that does not have a default value to be an injectable
argument. This can be undesired, because situations like this can happen:

.. code:: python
@injectable()
def foo(*, injectable_dependency: MyClass, not_injectable: ClassWithoutNoArgsContructor):
...
# This will raise a TypeError as parameter `not_injectable` cannot be injected
This is solved by naming which arguments shall be injected:

.. code:: python
@injectable(['injectable_dependency'])
def foo(*, injectable_dependency: MyClass, not_injectable: ClassWithoutNoArgsContructor):
...
# This will run just fine and only `injectable_dependecy` will be injected
5 changes: 5 additions & 0 deletions injectable/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from injectable.injectable import injectable

__all__ = [
injectable,
]
41 changes: 31 additions & 10 deletions injectable.py → injectable/injectable.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,44 +6,65 @@


def injectable(injectable_kwargs: Iterable[str] = None):
"""
Returns a functions decorated for injection. The caller can
explicitly pass into the function wanted dependencies. Any
dependency not injected by the caller will be automatically
injected.
>>> try:
... class Dependency:
... def __init__(self):
... self.msg = "dependency initialized"
...
... def foo(*, dep: Dependency):
... return dep.msg
...
... finally:
... injectable()(foo)()
'dependency initialized'
:param injectable_kwargs:
:return:
"""
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
if (kwarg not in (specs.kwonlydefaults or [])
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")
" qualify as injectable were found"
.format(function=func.__name__))
else:
injectables = {
kwarg: specs.annotations.get(kwarg)
for kwarg in injectable_kwargs
}

for kwarg, klass in injectables.items():
for kwarg, cls in injectables.items():
issue = None
if kwarg not in specs.kwonlyargs:
issue = ("Injectable arguments must be keyword arguments"
" only")
elif not inspect.isclass(klass):
elif not inspect.isclass(cls):
issue = ("Injectable arguments must be annotated with a"
" class type")
else:
try:
klass()
cls()
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))
" {cls}'s constructor will raise: {exception}"
.format(cls=cls.__name__, exception=e))

if issue is not None:
raise TypeError(
Expand All @@ -54,10 +75,10 @@ def decorator(func: callable):

@wraps(func)
def wrapper(*args, **kwargs):
for kwarg, klass in injectables.items():
for kwarg, cls in injectables.items():
if kwarg in kwargs:
continue
kwargs[kwarg] = klass()
kwargs[kwarg] = cls()
return func(*args, **kwargs)
return wrapper

Expand Down
2 changes: 2 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[metadata]
description-file = README.rst
13 changes: 13 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from setuptools import setup

setup(
name='injectable',
version='0.1.0',
packages=['tests', 'injectable'],
url='https://github.com/allrod5/injectable',
license='MIT',
author='rodrigo',
author_email='[email protected]',
description='Cleanly expose injectable arguments in Python 3 functions',
test_requires=['testfixtures', 'pytest']
)
Empty file added tests/__init__.py
Empty file.
8 changes: 8 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import pytest
from testfixtures import LogCapture


@pytest.fixture(autouse=True)
def log_capture():
with LogCapture() as capture:
yield capture
9 changes: 5 additions & 4 deletions test_injectable.py → tests/test_injectable.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,13 @@ def qux(*, nope: NoDefaultConstructorClass):

class TestInjectableAnnotation(object):

def test_ineffective_use_of_annotation_logs_warning(self, mocker):
logging = mocker.patch('injectable.logging')

def test_ineffective_use_of_annotation_logs_warning(self, log_capture):
injectable()(bar)

assert logging.warning.called
log_capture.check(
('root', 'WARNING',
"Function 'bar' is annotated with '@injectable' but no arguments"
" that qualify as injectable were found"))

def test_positional_arg_as_injectable_raises_type_error(self):
with pytest.raises(TypeError):
Expand Down

0 comments on commit 00ebbfb

Please sign in to comment.