Coder Social home page Coder Social logo

serum's Introduction

Build Status

Build status:

CircleCI

Code quality:

Test Coverage

Maintainability

Description

serum is a fresh take on Dependency Injection in Python 3.

serum is pure python and has no dependencies.

Installation

> pip install serum

Quickstart

from serum import inject, dependency, Context


# Classes decorated with 'dependency' are injectable types.
@dependency 
class Log:
    def info(self, message: str):
        raise NotImplementedError()


class SimpleLog(Log):
    def info(self, message: str):
        print(message)


class StubLog(SimpleLog):
    def info(self, message: str):
        pass


@inject  # Dependencies are injected using a class decorator...
class NeedsLog:
    log: Log  # ...and class level annotations...


class NeedsSimpleLog:
    @inject  # ...or using a function decorator
    def __init__(self, log: SimpleLog):
        self.log = log 


@inject
class NeedsNamedDependency:
    named_dependency: str  # class level annotations annotated with a type that is not
                           # decorated with 'dependency' will be treated as a named
                           # dependency
                           

# Contexts provide dependencies
with Context(SimpleLog, named_dependency='this name is injected!'):
    assert isinstance(NeedsLog().log, SimpleLog)
    assert NeedsNamedDependency().named_dependency == 'this name is injected!'
    

# Contexts will always provide the most specific 
# subtype of the requested type. This allows you to change which
# dependencies are injected.
with Context(StubLog):
    NeedsLog().log.info('Hello serum!')  # doesn't output anything
    NeedsSimpleLog().log.info('Hello serum!')  # doesn't output anything

Documentation

inject

inject is used to decorate functions and classes in which you want to inject dependencies.

from serum import inject, dependency

@dependency
class MyDependency:
    pass

@inject
def f(dependency: MyDependency):
    assert isinstance(dependency, MyDependency)

f()

Functions decorated with inject can be called as normal functions. serum will not attempt to inject arguments given at call time.

@inject
def f(dependency: MyDependency):
    print(dependency)

f('Overridden dependency')  #  outputs: Overridden dependency 

inject will instantiate classes decorated with dependency. In this way, your entire dependency graph can be specified using just inject and dependency.

Instances of simple types and objects you want to instantiate yourself can be injected using keyword arguments to Context.

@inject
def f(dependency: str):
    assert dependency == 'a named dependency'

with Context(dependency='a named dependency'):
    f()

inject can also be used to decorate classes.

@inject
class SomeClass:
    dependency: MyDependency 

This is roughly equivalent to:

class SomeClass:
    @inject
    def __init__(self, dependency: MyDependency):
        self.__dependency = dependency
    
    @property
    def dependency(self) -> MyDependency:
        return self.__dependency

Dependencies that are specified as class level annotations can be overridden using key-word arguments to __init__

assert SomeClass(dependency='Overridden!').dependency == 'Overridden!'

dependency

Classes decorated with dependency can be instantiated and injected by serum.

from serum import dependency, inject

@dependency
class Log:
    def info(self, message):
        print(message)


@inject
class NeedsLog:
    log: Log


assert isinstance(NeedsLog().log, Log)

serum relies on being able to inject all dependencies for dependency decorated classes recursively. To achieve this, serum assumes that the __init__ method of dependency decorated classes can be called without any arguments. This means that all arguments to __init__ of dependency decorated classes must be injected using inject.

@dependency
class SomeDependency:
    def method(self):
        pass


@inject
@dependency
class ValidDependency:  # OK!
    some_dependency: SomeDependency

    def __init__(self):
        ...


@dependency
class AlsoValidDependency:  # Also OK!
    @inject
    def __init__(self, some_dependency: SomeDependency):
        ...


@dependency
class InvalidDependency:
    def __init__(self, a):
        ...

@inject
def f(dependency: InvalidDependency):
    ...

f()  
# raises:
# TypeError: __init__() missing 1 required positional argument: 'a'

# The above exception was the direct cause of the following exception:

# InjectionError                            Traceback (most recent call last)
# ...
# InjectionError: Could not instantiate dependency <class 'InvalidDependency'> 
# when injecting argument "dependency" in <function f at 0x10a074ea0>.

Note that circular dependencies preventing instantiation of dependency decorated classes leads to an error.

@dependency
class AbstractA:
    pass

@dependency
class AbstractB:
    pass


class A(AbstractA):

    @inject
    def __init__(self, b: AbstractB):
        self.b = b

class B(AbstractB):
    @inject
    def __init__(self, a: AbstractA):
        self.a = a

@inject
class Dependent:
    a: AbstractA


with Context(A, B):
    Dependent().a  # raises: CircularDependency: Circular dependency encountered while injecting <class 'AbstractA'> in <B object at 0x1061e3898>

Context

Contexts provide implementations of dependencies. A Context will always provide the most specific subtype of the requested type (in Method Resolution Order).

@dependency
class Super:
    pass


class Sub(Super):
    pass

@inject
class NeedsSuper:
    instance: Super


with Context(Sub):
    assert isinstance(NeedsSuper().instance, Sub)

It is an error to inject a type in an Context that provides two or more equally specific subtypes of that type:

class AlsoSub(Super):
    pass


with Context(Sub, AlsoSub):
    NeedsSuper() # raises: AmbiguousDependencies: Attempt to inject type <class 'Log'> with equally specific provided subtypes: <class 'MockLog'>, <class 'FileLog'>

Contexts can also be used as decorators:

context = Context(Sub)

@context
def f():
    assert isinstance(NeedsSuper().instance, Sub)

You can provide named dependencies of any type using keyword arguments.

@inject
class Database:
    connection_string: str
    

connection_string = 'mysql+pymysql://root:[email protected]:3333/my_db'
context = Context(
    connection_string=connection_string
)
with context:
    assert Database().connection_string == connection_string

Contexts are local to each thread. This means that when using multi-threading each thread runs in its own context

import threading


@singleton
class SomeSingleton:
    pass


@inject
def worker(instance: SomeSingleton):
    print(instance)

with Context():
    worker() # outputs: <SomeSingleton object at 0x101f75470>
    threading.Thread(target=worker).start() # outputs: <SomeSingleton object at 0x1035fb320>

singleton

To always inject the same instance of a dependency in the same Context, annotate your type with singleton.

from serum import singleton


@singleton
class ExpensiveObject:
    pass


@inject
class NeedsExpensiveObject:
    expensive_instance: ExpensiveObject


instance1 = NeedsExpensiveObject()
instance2 = NeedsExpensiveObject()
assert instance1.expensive_instance is instance2.expensive_instance

Note that Singleton dependencies injected in different environments will not refer to the same instance.

with Context():
    instance1 = NeedsExpensiveObject()

with Context():
    assert instance1.expensive_instance is not NeedsExpensiveObject().expensive_instance

mock

serum has support for injecting MagicMocks from the builtin unittest.mock library in unittests using the mock utility function. Mocks are reset when the environment context is closed.

from serum import mock

@dependency
class SomeDependency:
    def method(self):
        return 'some value' 

@inject
class Dependent:
    dependency: SomeDependency


context = Context()
with context:
    mock_dependency = mock(SomeDependency)
    mock_dependency.method.return_value = 'some mocked value'
    instance = Dependent()
    assert instance.dependency is mock_dependency
    assert instance.dependency.method() == 'some mocked value'

with context:
    instance = Dependent()
    assert instance.dependency is not mock_dependency
    assert isinstance(instance.dependency, SomeDependency)

mock uses its argument to spec the injected instance of MagicMock. This means that attempting to call methods that are not defined by the mocked Component leads to an error

with context:
    mock_dependency = mock(SomeDependency)
    mock_dependency.no_method()  # raises: AttributeError: Mock object has no attribute 'no method'

Note that mock will only mock requests of the exact type supplied as its argument, but not requests of more or less specific types

from unittest.mock import MagicMock

@dependency
class Super:
    pass


class Sub(Super):
    pass


class SubSub(Sub):
    pass


@inject
class NeedsSuper:
    injected: Super


@inject
class NeedsSub:
    injected: Sub


@inject
class NeedsSubSub:
    injected: SubSub


with Context():
    mock(Sub)
    needs_super = NeedsSuper()
    needs_sub = NeedsSub()
    needs_subsub = NeedsSubSub()
    assert isinstance(needs_super.injected, Super)
    assert isinstance(needs_sub.injected, MagicMock)
    assert isinstance(needs_subsub.injected, SubSub)

match

match is small utility function for matching Context instances with values of an environment variable.

# my_script.py
from serum import match, dependency, Context, inject

@dependency
class BaseDependency:
    def method(self):
        raise NotImplementedError()


class ProductionDependency(BaseDependency):
    def method(self):
        print('Production!')


class TestDependency(BaseDependency):
    def method(self):
        print('Test!')


@inject
def f(dependency: BaseDependency):
    dependency.method()


context = match(
    environment_variable='MY_SCRIPT_ENV', 
    default=Context(ProductionDependency),
    PROD=Context(ProductionDependency),
    TEST=Context(TestDependency)
)

with context:
    f()
> python my_script.py
Production!
> MY_SCRIPT_ENV=PROD python my_script.py
Production!
> MY_SCRIPT_ENV=TEST python my_script.py
Test!

IPython Integration

It can be slightly annoying to import some Context and start it as a context manager in the beginning of every IPython session. Moreover, you quite often want to run an IPython REPL in a special context, e.g to provide configuration that is normally supplied through command line arguments in some other way.

To this end serum can act as an IPython extension. To activate it, add the following lines to your ipython_config.py:

c.InteractiveShellApp.extensions = ['serum']

Finally, create a file named ipython_context.py in the root of your project. In it, assign the Context instance you would like automatically started to a global variable named context:

# ipython_context.py
from serum import Context


context = Context()

IPython will now enter this context automatically in the beginning of every REPL session started in the root of your project.

Why?

If you've been researching Dependency Injection frameworks for python, you've no doubt come across this opinion:

You dont need Dependency Injection in python. You can just use duck typing and monkey patching!

The position behind this statement is often that you only need Dependency Injection in statically typed languages.

In truth, you don't really need Dependency Injection in any language, statically typed or otherwise. When building large applications that need to run in multiple environments however, Dependency Injection can make your life a lot easier. In my experience, excessive use of monkey patching for managing environments leads to a jumbled mess of implicit initialisation steps and if value is None type code.

In addition to being a framework, I've attempted to design serum to encourage designing classes that follow the Dependency Inversion Principle:

one should “depend upon abstractions, not concretions."

This is achieved by letting inheritance being the principle way of providing dependencies and allowing dependencies to be abstract.

serum's People

Contributors

adaliszk avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar

serum's Issues

Change Environment API to be immutable

from serum import immutable

class Environment:
    __registry = immutable(tuple())
   ...

import os

def app(name: str, default: Environment, **kwargs):
    kwargs['default'] = default
    def inject(component, environment=None):
        if environment is None:
            environment = os.environ.get(name + '_ENVIRONMENT', 'default')
        env = environments[environment]
        return property(lambda _: env.get(component))
    return inject 

dev_env = Environment(ConsoleLog)
prod_env = dev_env.use(FileLog)
test_env = production_env.mock(AbstractLog)
inject = app(name='MyApp', default=dev_env, dev=dev_env, production=prod_env, test=test_env})

class Dependent:
    log = inject(AbstractLog)

if __name__ == '__main__':
    Dependent().log.info('Hello serum!')
> MYAPP_ENVIRONMENT=dev python my_app.py  # ouputs 'Hello serum!'

TypeError: injected method got an unexpected keyword argument 'return'

It looks like the automatic dependency injection captures the return type as an argument well:

from serum import dependency, inject

@dependency
class Json(object):
    def __str__(self):
        return '{"data": [], "message": "This is an example output!"}'

@inject
@dependency
class ExternalService(object):
    json: Json

    def run(self):
        return self.json

class Service:

    @inject
    def run(self, external_service: ExternalService) -> Json:
        return external_service.run()


print(Service().run())

Output:

Traceback (most recent call last):
  File "serum/v4.0.1/return_type_error.py", line 22, in <module>
    print(Service().run())
  File "serum/venv/lib/python3.6/site-packages/serum/_inject.py", line 136, in decorator
    return f(*args, **dependency_args)
TypeError: run() got an unexpected keyword argument 'return'

Relatively easy to fix. Just remove the return key from the __annotations__ before you iterate trough that in the _inject.py

Use GitLab for CI/CD

I know that the project uses Circle-CI right now, but it would make sense to move the code and the main development into GitLab because you can use the built-in CI/CD with docker and you can even serve your docker image for those who want to mess with this library/micro-framework ;-)

Initialize classes with __init__ arguments

serum assumes that the init method of dependency decorated classes can be called without any arguments. This means that all arguments to init of dependency decorated classes must be injected using inject.

How would you go about instantiating a complex object that requires a number of initialization arguments (primitive strings, ints, etc.), while still having it be injectable? An example use case would be a random API-consuming Client from a third-party library that expects you to pass api tokens, api hashes, session paths etc.
I guess you could instantiate such an object yourself, but then how would you make Serum aware of the instance? And I suppose this would also defeat the purpose of DI.
Maybe I have failed to understand the use of Contexts, as they seem to be related. Any hints?

Suggestion: Rename Environment to Context

The different environment usually referred as Containers in IoC frameworks, it makes more sense because it's more generic. You could have different reasons to separate something than just Environments.

Support Singleton

from serum import *

class S(Singleton):
    pass

class Depenedent:
    s = inject(S)

with Environment():
    s1 = Dependent().s
    s2 = Dependent().s
    assert s1 is is2

Handle Circular dependencies

from serum import *

class AbstractA(Component):
    pass

class AbstractB(Component):
    pass

class A(AbstractA):
    b = inject(AbstractB)

    def __init__(self):
        self.b

class B(AbstractB):
    a = inject(AbstractA)
    def __init__(self):
        self.a

class Dependent:
    a = inject(AbstractA)

with Environment(A, B):
    Dependent().a  # whoops!

Contexts should be stackable

With the current implemenation, a dependency can only be found in the current context. This means that if you want to temporarily override a dependency, you must supply all the dependencies from the old context and replace your dependency of interest.

If contexts were nestable, such that dependencies are first looked up in the current context, then the context that preceeded it, replacing only a subset of dependencies would be very easy.

Reference Documentation

It would be nice to have a readme.io or readthedocs.org or any similar generated documentation. For that we need to document the code better.

Use Docker

I really like to use Docker for development, it would help a lot to try out different versions and it would be a proof of concept which shows that it is working with the official environment.

Python 3.5 Support

It would make sense to support 3.5 since most of the reports show that around 40-45% of software is using it, the 3.6 show 30-40% usually.

My idea is to create the bare minimum Context class, dependency and inject decorator and use that to provide the full-featured sub-classes. The dependency and the inject could be served without any problem if you call internal Classes to do the IoC.

With this architecture, you can move the IPython into a separate context to make the base more lightweight and performant.

Don't load IPython automatically

It would be nice if the library doesn't do anything until you don't explicitly say so. Having the IPython right in the __init__.py feels wrong, I think it would make sense to extract that and use it in a separate file which can be loaded when it's actually necessary.

mock should be a context manager

With the current implementation, the empty context is the default.

This can lead to problems when using mock without a Context because any mocked types cannot be unmocked in the default context.

By changing mock to a context manager this is no longer a problem because mocks can be reset when the mock context manager closes, rather than when the context closes.

Moarh Examples

What most probably would be nice to have in separate files is:

  • constructor/method by Classes
  • constructor/method by Subclass
  • constructor/method by Subclass and Argument name
  • constructor/method by Argument name
  • constructor/method by Package name
  • constructor/method by Dependent class/method
  • dev/stage/prod Context

Every case should have a version of:

  • python 3.5
  • python 3.6
  • python 3.7 (allowed to fail)
  • dependency_injector
  • django 1.11, 2.0
  • flask 0.12, 1.0

Links:

Singleton not working without any context

Singleton injection does not work, when no Context() created previously.

The following example shows the error:

  • calling broken_if_no_context_used() shows the error
  • if calling works() beforehand, calling broken_if_no_context_used() works too

We should add a unit test for this.

from serum import singleton, inject, Context


@singleton
class ExpensiveObject:
    pass


@inject
class NeedsExpensiveObject:
    expensive_instance: ExpensiveObject


def works():
    with Context():
        instance1 = NeedsExpensiveObject()
        instance2 = NeedsExpensiveObject()
        assert instance1.expensive_instance is instance2.expensive_instance
        print('context works')


def broken_if_no_context_used():
    # works if works() called previously
    instance1 = NeedsExpensiveObject()
    instance2 = NeedsExpensiveObject()
    assert instance1.expensive_instance is instance2.expensive_instance
    print('without context works')


# works()
broken_if_no_context_used()

Error:

Traceback (most recent call last):
  File "dij2.py", line 33, in <module>
    broken_if_no_context_used()
  File "dij2.py", line 28, in broken_if_no_context_used
    assert instance1.expensive_instance is instance2.expensive_instance
AssertionError

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.