Skip to content

Adityadt68/serum

 
 

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

72 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Build Status

CircleCI codecov

Description

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

serum is pure python, has no dependencies, and is less than 300 lines of code.

Installation

> pip install serum

Quickstart

from serum import *

class Log(Component):
    @abstractmethod
    def info(self, message: str):
        pass

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

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

class NeedsLog:
    log = inject(Log)

NeedsLog().log.info('Hello serum!')  # raises: NoEnvironment

with Environment():
    NeedsLog().log.info('Hello serum!')  # raises: UnregisteredDependency

with Environment(SimpleLog):
    NeedsLog().log.info('Hello serum!')  # outputs: Hello serum!

with Environment(StubLog):
    NeedsLog().log.info('Hello serum!')  # doesn't output anything

class NeedsSimpleLog:
    log = inject(SimpleLog)

with Environment():
    NeedsSimpleLog().log.info('Hello serum!')  # outputs: Hello serum!

Documentation

serum uses 3 main abstractions: Component, Environment and inject.

Components are dependencies that can be injected.

from serum import Component, Environment, inject

class Log(Component):
    def info(self, message):
        print(message)

class NeedsLog:
    log = inject(Log)

instance = NeedsLog()
with Environment():
    assert isinstance(instance.log, Log)

Environments provide implementations of Components. An Environment will always provide the most specific subtype of the requested type (in Method Resolution Order).

class MockLog(Log):
    def info(self, message):
        pass

with Environment(MockLog):
    assert isinstance(instance.log, MockLog)

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

class FileLog(Log):
    _file = 'log.txt'
    def info(self, message):
        with open(self._file, 'w') as f:
            f.write(message)

with Environment(MockLog, FileLog):
    instance.log  # raises: AmbiguousDependencies: Attempt to inject type <class 'Log'> with equally specific provided subtypes: <class 'MockLog'>, <class 'FileLog'>

Environments can also be used as decorators:

test_environment = Environment(MockLog)

@test_environment
def f():
    assert isinstance(instance.log, MockLog)

You can only provide subtypes of Component with Environment.

class C:
    pass

Environment(C)  # raises: InvalidDependency: Attempt to register type that is not a Component: <class 'C'> 

Similarly, you can't inject types that are not Component subtypes.

class InvalidDependent:
    dependency = inject(C)  # raises: InvalidDependency: Attempt to inject type that is not a Component: <class 'C'>

Injected Components can't be accessed outside an Environment context:

instance.log  # raises NoEnvironment: Can't inject components outside an environment 

Injected Components are immutable

with Environment():
    instance.log = 'mutate this'  # raises AttributeError: Can't set property

You can define mutable static fields in a Component. If you want to define immutable static fields (constants), serum provides the immutable utility that also supports type inference with PEP 484 tools.

from serum import immutable

class Immutable(Component):
    value = immutable(1)

i = Immutable()
i.value = 2  # raises AttributeError: Can't set property

This is just convenience for:

class Immutable(Component):
    value = property(fget=lambda _: 1)

Components can only define an __init__ method that takes 1 parameter.

class ValidComponent(Component):  # OK!
    some_dependency = inject(SomeDependency)
    def __init__(self):
        self.value = self.some_dependency.method()

class InvalidComponent(Component):  # raises: InvalidComponent: __init__ method in Components can only take 1 parameter
    def __init__(self, a):
        self.a = a

To construct Components with dependencies, you should instead use inject

class ComponentWithDependencies(Component):
    log = inject(Log)

Note that if you access injected members in the constructor of any type, that type can only be instantiated inside an environment.

Components can be abstract. Abstract Components can't be injected in an Environment that doesn't provide a concrete implementation. For convenience you can import abstractmethod, abstractclassmethod or abstractclassmethod from serum, but they are simply references to the equivalent decorators from the abc module in the standard library.

from serum import abstractmethod

class AbstractLog(Component):
    @abstractmethod
    def info(self, message):
        pass
        
class NeedsLog:
    log = inject(AbstractLog)

instance = NeedsLog()
with Environment():
    instance.log  # raises UnregisteredDependency: No concrete implementation of <class 'AbstractLog'> found

class ConcreteLog(AbstractLog):
    def info(self, message):
        print(message)

with Environment(ConcreteLog):
    instance.log  # Ok!
 

Environments are local to each thread. This means that when using multi-threading each thread must define its own environment.

import threading

def worker_without_environment():
    NeedsLog().log  # raises NoEnvironment: Can't inject components outside an environment

def worker_with_environment():
    with Environment(ConcreteLog):
        NeedsLog().log  # OK!

with Environment(ConcreteLog):
    threading.Thread(target=worker_without_environment()).start()
    threading.Thread(target=worker_with_environment()).start()

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

from serum import mock

environment = Environment(ConcreteLog)
with environment:
    log_mock = mock(AbstractLog)
    log_mock.method.return_value = 'some value'
    instance = NeedsLog()
    assert instance.log is log_mock
    assert instance.log.method() == 'some value'

with environment:
    instance = NeedsLog()
    assert instance.log is not log_mock
    assert isinstance(instance.log, ConcreteLog)

mock(AbstractLog)  # raises: NoEnvironment: Can't register mock outside environment

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
class Super(Component):
    pass

class Sub(Super):
    pass

class SubSub(Sub):
    pass

class NeedsSuper:
    injected = inject(Super)

class NeedsSub:
    injected = inject(Sub)

class NeedsSubSub:
    injected = inject(SubSub)

with Environment():
    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)

serum is designed for type inference with PEP 484 tools (work in progress). This feature is currently only supported for the PyCharm type checker.

type inference in PyCharm

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. See the example.py for a detailed tutorial (work in progress).

About

Dependency injection framework for Python 3

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Languages

  • Python 99.5%
  • Shell 0.5%