From 00ebbfb70f05628f6caeadd33889ec9e76ad21b4 Mon Sep 17 00:00:00 2001 From: Rodrigo Martins de Oliveira Date: Sun, 4 Feb 2018 22:35:29 -0200 Subject: [PATCH] v0.1.0 --- .gitignore | 4 + README.rst | 83 +++++++++++++++++++ injectable/__init__.py | 5 ++ injectable.py => injectable/injectable.py | 41 ++++++--- setup.cfg | 2 + setup.py | 13 +++ tests/__init__.py | 0 tests/conftest.py | 8 ++ .../test_injectable.py | 9 +- 9 files changed, 151 insertions(+), 14 deletions(-) create mode 100644 README.rst create mode 100644 injectable/__init__.py rename injectable.py => injectable/injectable.py (65%) create mode 100644 setup.cfg create mode 100644 setup.py create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py rename test_injectable.py => tests/test_injectable.py (90%) diff --git a/.gitignore b/.gitignore index 2895fff..f8d4832 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ wheels/ *.egg-info/ .installed.cfg *.egg +MANIFEST # PyInstaller # Usually these files are written by a python script from a template @@ -100,5 +101,8 @@ ENV/ # mypy .mypy_cache/ +# pytest +.pytest_cache/ + # PyCharm .idea/ diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..f7de3f2 --- /dev/null +++ b/README.rst @@ -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 diff --git a/injectable/__init__.py b/injectable/__init__.py new file mode 100644 index 0000000..ee55f4e --- /dev/null +++ b/injectable/__init__.py @@ -0,0 +1,5 @@ +from injectable.injectable import injectable + +__all__ = [ + injectable, +] \ No newline at end of file diff --git a/injectable.py b/injectable/injectable.py similarity index 65% rename from injectable.py rename to injectable/injectable.py index b9d27d3..bdaf7b2 100644 --- a/injectable.py +++ b/injectable/injectable.py @@ -6,6 +6,27 @@ 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) @@ -13,37 +34,37 @@ def decorator(func: callable): 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( @@ -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 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..11e9ec4 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,2 @@ +[metadata] +description-file = README.rst \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..889bb05 --- /dev/null +++ b/setup.py @@ -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='allrod5@hotmail.com', + description='Cleanly expose injectable arguments in Python 3 functions', + test_requires=['testfixtures', 'pytest'] +) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..f3336e0 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,8 @@ +import pytest +from testfixtures import LogCapture + + +@pytest.fixture(autouse=True) +def log_capture(): + with LogCapture() as capture: + yield capture diff --git a/test_injectable.py b/tests/test_injectable.py similarity index 90% rename from test_injectable.py rename to tests/test_injectable.py index 4e25255..5022544 100644 --- a/test_injectable.py +++ b/tests/test_injectable.py @@ -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):