From f0109cdec1e07c32cd04495b2f1f2e85417443e5 Mon Sep 17 00:00:00 2001 From: CoprDistGit Date: Wed, 17 May 2023 03:11:41 +0000 Subject: automatic import of python-serum --- .gitignore | 1 + python-serum.spec | 1662 +++++++++++++++++++++++++++++++++++++++++++++++++++++ sources | 1 + 3 files changed, 1664 insertions(+) create mode 100644 python-serum.spec create mode 100644 sources diff --git a/.gitignore b/.gitignore index e69de29..b407b9d 100644 --- a/.gitignore +++ b/.gitignore @@ -0,0 +1 @@ +/serum-5.1.0.tar.gz diff --git a/python-serum.spec b/python-serum.spec new file mode 100644 index 0000000..25d2573 --- /dev/null +++ b/python-serum.spec @@ -0,0 +1,1662 @@ +%global _empty_manifest_terminate_build 0 +Name: python-serum +Version: 5.1.0 +Release: 1 +Summary: Dependency Injection library for Python 3 +License: MIT +URL: https://github.com/suned/serum +Source0: https://mirrors.nju.edu.cn/pypi/web/packages/5f/c8/8f43f98bf795ec7de87c98b22c68860319ea2357573de12a2f550d794f11/serum-5.1.0.tar.gz +BuildArch: noarch + + +%description +# Build Status + +Build status: + +[![CircleCI](https://circleci.com/gh/suned/serum.svg?style=svg)](https://circleci.com/gh/suned/serum) + +Code quality: + +[![Test Coverage](https://api.codeclimate.com/v1/badges/523bc990f4ef696aa22d/test_coverage)](https://codeclimate.com/github/suned/serum/test_coverage) + +[![Maintainability](https://api.codeclimate.com/v1/badges/523bc990f4ef696aa22d/maintainability)](https://codeclimate.com/github/suned/serum/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 +```python +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) +- [`dependency`](#dependency) +- [`Context`](#context) +- [`singleton`](#singleton) +- [`mock`](#mock) +- [`match`](#match) +- [IPython Integration](#ipython-integration) + +## `inject` +`inject` is used to decorate functions and classes in which you want to inject +dependencies. +```python +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. +```python +@inject +def f(dependency: MyDependency): + print(dependency) + +f('Overridden dependency') # outputs: Overridden dependency +``` +`inject` will instantiate classes decorated with [`dependency`](#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`](#context). +```python +@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. +```python +@inject +class SomeClass: + dependency: MyDependency +``` +This is roughly equivalent to: +```python +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__` +```python +assert SomeClass(dependency='Overridden!').dependency == 'Overridden!' +``` +## `dependency` +Classes decorated with `dependency` can be instantiated and injected +by `serum`. +```python +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`. +```python +@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 +# when injecting argument "dependency" in . +``` + +Note that circular dependencies preventing instantiation of `dependency` decorated +classes leads to an error. +```python +@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 in +``` +## `Context` +`Context`s provide implementations of dependencies. A `Context` will always provide the most +specific subtype of the requested type (in Method Resolution Order). +```python +@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: +```python +class AlsoSub(Super): + pass + + +with Context(Sub, AlsoSub): + NeedsSuper() # raises: AmbiguousDependencies: Attempt to inject type with equally specific provided subtypes: , +``` +`Context`s can also be used as decorators: +```python +context = Context(Sub) + +@context +def f(): + assert isinstance(NeedsSuper().instance, Sub) + +``` +You can provide named dependencies of any type using keyword arguments. +```python +@inject +class Database: + connection_string: str + + +connection_string = 'mysql+pymysql://root:my_pass@127.0.0.1:3333/my_db' +context = Context( + connection_string=connection_string +) +with context: + assert Database().connection_string == connection_string +``` +`Context`s are local to each thread. This means that when using multi-threading +each thread runs in its own context +```python +import threading + + +@singleton +class SomeSingleton: + pass + +def worker_without_environment(): + NeedsSuper().instance + +@inject +def worker(instance: SomeSingleton): + print(instance) + +with Context(): + worker() # outputs: + threading.Thread(target=worker).start() # outputs: +``` + +## `singleton` +To always inject the same instance of a dependency in the same `Context`, annotate your type with `singleton`. +```python +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. +```python + +with Context(): + instance1 = NeedsExpensiveObject() + +with Context(): + assert instance1.expensive_instance is not NeedsExpensiveObject().expensive_instance +``` +## `mock` +`serum` has support for injecting `MagicMock`s from the builtin +`unittest.mock` library in unittests using the `mock` utility +function. Mocks are reset +when the environment context is closed. +```python +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 +```python +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 +```python +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. +```python +# 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`: +```python +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`: +```python +# 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. + + + + +%package -n python3-serum +Summary: Dependency Injection library for Python 3 +Provides: python-serum +BuildRequires: python3-devel +BuildRequires: python3-setuptools +BuildRequires: python3-pip +%description -n python3-serum +# Build Status + +Build status: + +[![CircleCI](https://circleci.com/gh/suned/serum.svg?style=svg)](https://circleci.com/gh/suned/serum) + +Code quality: + +[![Test Coverage](https://api.codeclimate.com/v1/badges/523bc990f4ef696aa22d/test_coverage)](https://codeclimate.com/github/suned/serum/test_coverage) + +[![Maintainability](https://api.codeclimate.com/v1/badges/523bc990f4ef696aa22d/maintainability)](https://codeclimate.com/github/suned/serum/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 +```python +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) +- [`dependency`](#dependency) +- [`Context`](#context) +- [`singleton`](#singleton) +- [`mock`](#mock) +- [`match`](#match) +- [IPython Integration](#ipython-integration) + +## `inject` +`inject` is used to decorate functions and classes in which you want to inject +dependencies. +```python +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. +```python +@inject +def f(dependency: MyDependency): + print(dependency) + +f('Overridden dependency') # outputs: Overridden dependency +``` +`inject` will instantiate classes decorated with [`dependency`](#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`](#context). +```python +@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. +```python +@inject +class SomeClass: + dependency: MyDependency +``` +This is roughly equivalent to: +```python +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__` +```python +assert SomeClass(dependency='Overridden!').dependency == 'Overridden!' +``` +## `dependency` +Classes decorated with `dependency` can be instantiated and injected +by `serum`. +```python +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`. +```python +@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 +# when injecting argument "dependency" in . +``` + +Note that circular dependencies preventing instantiation of `dependency` decorated +classes leads to an error. +```python +@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 in +``` +## `Context` +`Context`s provide implementations of dependencies. A `Context` will always provide the most +specific subtype of the requested type (in Method Resolution Order). +```python +@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: +```python +class AlsoSub(Super): + pass + + +with Context(Sub, AlsoSub): + NeedsSuper() # raises: AmbiguousDependencies: Attempt to inject type with equally specific provided subtypes: , +``` +`Context`s can also be used as decorators: +```python +context = Context(Sub) + +@context +def f(): + assert isinstance(NeedsSuper().instance, Sub) + +``` +You can provide named dependencies of any type using keyword arguments. +```python +@inject +class Database: + connection_string: str + + +connection_string = 'mysql+pymysql://root:my_pass@127.0.0.1:3333/my_db' +context = Context( + connection_string=connection_string +) +with context: + assert Database().connection_string == connection_string +``` +`Context`s are local to each thread. This means that when using multi-threading +each thread runs in its own context +```python +import threading + + +@singleton +class SomeSingleton: + pass + +def worker_without_environment(): + NeedsSuper().instance + +@inject +def worker(instance: SomeSingleton): + print(instance) + +with Context(): + worker() # outputs: + threading.Thread(target=worker).start() # outputs: +``` + +## `singleton` +To always inject the same instance of a dependency in the same `Context`, annotate your type with `singleton`. +```python +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. +```python + +with Context(): + instance1 = NeedsExpensiveObject() + +with Context(): + assert instance1.expensive_instance is not NeedsExpensiveObject().expensive_instance +``` +## `mock` +`serum` has support for injecting `MagicMock`s from the builtin +`unittest.mock` library in unittests using the `mock` utility +function. Mocks are reset +when the environment context is closed. +```python +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 +```python +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 +```python +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. +```python +# 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`: +```python +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`: +```python +# 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. + + + + +%package help +Summary: Development documents and examples for serum +Provides: python3-serum-doc +%description help +# Build Status + +Build status: + +[![CircleCI](https://circleci.com/gh/suned/serum.svg?style=svg)](https://circleci.com/gh/suned/serum) + +Code quality: + +[![Test Coverage](https://api.codeclimate.com/v1/badges/523bc990f4ef696aa22d/test_coverage)](https://codeclimate.com/github/suned/serum/test_coverage) + +[![Maintainability](https://api.codeclimate.com/v1/badges/523bc990f4ef696aa22d/maintainability)](https://codeclimate.com/github/suned/serum/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 +```python +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) +- [`dependency`](#dependency) +- [`Context`](#context) +- [`singleton`](#singleton) +- [`mock`](#mock) +- [`match`](#match) +- [IPython Integration](#ipython-integration) + +## `inject` +`inject` is used to decorate functions and classes in which you want to inject +dependencies. +```python +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. +```python +@inject +def f(dependency: MyDependency): + print(dependency) + +f('Overridden dependency') # outputs: Overridden dependency +``` +`inject` will instantiate classes decorated with [`dependency`](#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`](#context). +```python +@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. +```python +@inject +class SomeClass: + dependency: MyDependency +``` +This is roughly equivalent to: +```python +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__` +```python +assert SomeClass(dependency='Overridden!').dependency == 'Overridden!' +``` +## `dependency` +Classes decorated with `dependency` can be instantiated and injected +by `serum`. +```python +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`. +```python +@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 +# when injecting argument "dependency" in . +``` + +Note that circular dependencies preventing instantiation of `dependency` decorated +classes leads to an error. +```python +@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 in +``` +## `Context` +`Context`s provide implementations of dependencies. A `Context` will always provide the most +specific subtype of the requested type (in Method Resolution Order). +```python +@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: +```python +class AlsoSub(Super): + pass + + +with Context(Sub, AlsoSub): + NeedsSuper() # raises: AmbiguousDependencies: Attempt to inject type with equally specific provided subtypes: , +``` +`Context`s can also be used as decorators: +```python +context = Context(Sub) + +@context +def f(): + assert isinstance(NeedsSuper().instance, Sub) + +``` +You can provide named dependencies of any type using keyword arguments. +```python +@inject +class Database: + connection_string: str + + +connection_string = 'mysql+pymysql://root:my_pass@127.0.0.1:3333/my_db' +context = Context( + connection_string=connection_string +) +with context: + assert Database().connection_string == connection_string +``` +`Context`s are local to each thread. This means that when using multi-threading +each thread runs in its own context +```python +import threading + + +@singleton +class SomeSingleton: + pass + +def worker_without_environment(): + NeedsSuper().instance + +@inject +def worker(instance: SomeSingleton): + print(instance) + +with Context(): + worker() # outputs: + threading.Thread(target=worker).start() # outputs: +``` + +## `singleton` +To always inject the same instance of a dependency in the same `Context`, annotate your type with `singleton`. +```python +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. +```python + +with Context(): + instance1 = NeedsExpensiveObject() + +with Context(): + assert instance1.expensive_instance is not NeedsExpensiveObject().expensive_instance +``` +## `mock` +`serum` has support for injecting `MagicMock`s from the builtin +`unittest.mock` library in unittests using the `mock` utility +function. Mocks are reset +when the environment context is closed. +```python +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 +```python +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 +```python +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. +```python +# 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`: +```python +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`: +```python +# 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. + + + + +%prep +%autosetup -n serum-5.1.0 + +%build +%py3_build + +%install +%py3_install +install -d -m755 %{buildroot}/%{_pkgdocdir} +if [ -d doc ]; then cp -arf doc %{buildroot}/%{_pkgdocdir}; fi +if [ -d docs ]; then cp -arf docs %{buildroot}/%{_pkgdocdir}; fi +if [ -d example ]; then cp -arf example %{buildroot}/%{_pkgdocdir}; fi +if [ -d examples ]; then cp -arf examples %{buildroot}/%{_pkgdocdir}; fi +pushd %{buildroot} +if [ -d usr/lib ]; then + find usr/lib -type f -printf "/%h/%f\n" >> filelist.lst +fi +if [ -d usr/lib64 ]; then + find usr/lib64 -type f -printf "/%h/%f\n" >> filelist.lst +fi +if [ -d usr/bin ]; then + find usr/bin -type f -printf "/%h/%f\n" >> filelist.lst +fi +if [ -d usr/sbin ]; then + find usr/sbin -type f -printf "/%h/%f\n" >> filelist.lst +fi +touch doclist.lst +if [ -d usr/share/man ]; then + find usr/share/man -type f -printf "/%h/%f.gz\n" >> doclist.lst +fi +popd +mv %{buildroot}/filelist.lst . +mv %{buildroot}/doclist.lst . + +%files -n python3-serum -f filelist.lst +%dir %{python3_sitelib}/* + +%files help -f doclist.lst +%{_docdir}/* + +%changelog +* Wed May 17 2023 Python_Bot - 5.1.0-1 +- Package Spec generated diff --git a/sources b/sources new file mode 100644 index 0000000..34146bc --- /dev/null +++ b/sources @@ -0,0 +1 @@ +abdb1938dcfdde09188b745c6f66a18f serum-5.1.0.tar.gz -- cgit v1.2.3