A DI and AOP library for Python
Project description
aspyx
Table of Contents
- Introduction
- Installation
- Registration
- Environment
- Instantiation logic
- Custom scopes
- AOP
- Configuration
- Reflection
- Version History
Introduction
Aspyx is a small python libary, that adds support for both dependency injection and aop.
The following features are supported
- constructor and setter injection
- post processors
- factory classes and methods
- support for eager construction
- support for singleton and request scopes
- possibilty to add custom scopes
- lifecycle events methods
- bundling of injectable object sets by environment classes including recursive imports and inheritance
- container instances that relate to environment classes and manage the lifecylce of related objects
- hierarchical environments
The library is thread-safe!
Let's look at a simple example
from aspyx.di import injectable, on_init, on_destroy, environment, Environment
@injectable()
class Foo:
def __init__(self):
pass
def hello(msg: str):
print(f"hello {msg}")
@injectable() # eager and singleton by default
class Bar:
def __init__(self, foo: Foo): # will inject the Foo dependency
self.foo = foo
@on_init() # a lifecycle callback called after the constructor and all possible injections
def init(self):
...
# this class will register all - specifically decorated - classes and factories in the own module
# In this case Foo and Bar
@environment()
class SampleEnvironment:
def __init__(self):
pass
# go, forrest
environment = Environment(SampleEnvironment)
bar = env.get(Bar)
bar.foo.hello("world")
The concepts should be pretty familiar as well as the names which are a combination of Spring and Angular names :-)
Let's add some aspects...
@advice
class SampleAdvice:
def __init__(self): # could inject additional stuff
pass
@before(methods().named("hello").of_type(Foo))
def call_before(self, invocation: Invocation):
print("before Foo.hello(...)")
@error(methods().named("hello").of_type(Foo))
def call_error(self, invocation: Invocation):
print("error Foo.hello(...)")
print(invocation.exception)
@around(methods().named("hello"))
def call_around(self, invocation: Invocation):
print("around Foo.hello()")
return invocation.proceed()
The invocation parameter stores the complete context of the current execution, which are
- the method
- args
- kwargs
- the result
- the possible caught error
Let's look at the details
Installation
pip install aspyx
The library is tested with all Python version > 3.9
Ready to go...
Registration
Different mechanisms are available that make classes eligible for injection
Class
Any class annotated with @injectable is eligible for injection
Example:
@injectable()
class Foo:
def __init__(self):
pass
Please make sure, that the class defines a local constructor, as this is required to determine injected instances. All referenced types will be injected by the environemnt.
Only eligible types are allowed, of course!
The decorator accepts the keyword arguments
eager : boolean
ifTrue, the container will create the instances automatically while booting the environment. This is the default.scope: str
the name of a - registered - scope which will determine how often instances will be created.
The following scopes are implemented out of the box:
singleton
objects are created once inside an environment and cached. This is the default.request
obejcts are created on every injection requestthread
objects are cerated and cached with respect to the current thread.
Other scopes - e.g. session related scopes - can be defined dynamically. Please check the corresponding chapter.
Class Factory
Classes that implement the Factory base class and are annotated with @factory will register the appropriate classes returned by the create method.
Example:
@factory()
class TestFactory(Factory[Foo]):
def __init__(self):
pass
def create(self) -> Foo:
return Foo()
As in @injectable, the same arguments are possible.
Method
Any injectable can define methods decorated with @create(), that will create appropriate instances.
Example:
@injectable()
class Foo:
def __init__(self):
pass
@create(scope="request")
def create(self) -> Baz:
return Baz()
The same arguments as in @injectable are possible.
Environment
Definition
An Environment is the container that manages the lifecycle of objects. The set of classes and instances is determined by a constructor argument that controls the class registry.
Example:
@environment()
class SampleEnvironment:
def __init__(self):
pass
environment = Environment(SampleEnvironment)
The default is that all eligible classes, that are implemented in the containing module or in any submodule will be managed.
By adding an imports: list[Type] parameter, specifying other environment types, it will register the appropriate classes recursively.
Example:
@environment()
class SampleEnvironmen(imports=[OtherEnvironment])):
def __init__(self):
pass
Another possibility is to add a parent environment as an Environment constructor parameter
Example:
rootEnvironment = Environment(RootEnvironment)
environment = Environment(SampleEnvironment, parent=rootEnvironment)
The difference is, that in the first case, class instances of imported modules will be created in the scope of the own environment, while in the second case, it will return instances managed by the parent.
The method
shutdown()
is used when a container is not needed anymore. It will call any on_destroy() of all created instances.
Retrieval
def get(type: Type[T]) -> T
is used to retrieve object instances. Depending on the respective scope it will return either cached instances or newly instantiated objects.
The container knows about class hierarchies and is able to get base classes, as long as there is only one implementation.
In case of ambiguities, it will throw an exception.
Please be aware, that a base class are not required to be annotated with @injectable, as this would mean, that it could be created on its own as well. ( Which is possible as well, btw. )
Instantiation logic
Constructing a new instance involves a number of steps executed in this order
- Constructor call
the constructor is called with the resolved parameters - Advice injection
All methods involving aspects are updated - Lifecycle methods
different decorators can mark methods that should be called during the lifecycle ( here the construction ) of an instance. These are various injection possibilities as well as an optional finalon_initcall - PostProcessors
Any custom post processors, that can add isde effects or modify the instances
Injection methods
Different decorators are implemented, that call methods with computed values
@inject
the method is called with all resolved parameter types ( same as the constructor call)@inject_environment
the method is called with the creating environment as a single parameter@inject_value()
the method is called with a resolved configuration value. Check the corresponding chapter
Example:
@injectable()
class Foo:
def __init__(self):
pass
@inject_environment()
def initEnvironment(self, env: Environment):
...
@inject()
def set(self, baz: Baz) -> None:
...
Lifecycle methods
It is possible to mark specific lifecyle methods.
@on_init()called after the constructor and all other injections.@on_running()called an environment has initialized all eager objects.@on_destroy()called during shutdown of the environment
Post Processors
As part of the instantiation logic it is possible to define post processors that execute any side effect on newly created instances.
Example:
@injectable()
class SamplePostProcessor(PostProcessor):
def process(self, instance: object, environment: Environment):
print(f"created a {instance}")
Any implementing class of PostProcessor that is eligible for injection will be called by passing the new instance.
Please be aware, that a post processor will only handle instances after its own registration.
As injectables within a single file will be handled in the order as they are declared, a post processor will only take effect for all classes after its declaration!
Custom scopes
As explained, available scopes are "singleton" and "request".
It is easily possible to add custom scopes by inheriting the base-class Scope, decorating the class with @scope(<name>) and overriding the method get
def get(self, provider: AbstractInstanceProvider, environment: Environment, argProvider: Callable[[],list]):
Arguments are:
providerthe actual provider that will create an instanceenvironmentthe requesting environmentargPovidera function that can be called to compute the required arguments recursively
Example: The simplified code of the singleton provider ( disregarding locking logic )
@scope("singleton")
class SingletonScope(Scope):
# constructor
def __init__(self):
super().__init__()
self.value = None
# override
def get(self, provider: AbstractInstanceProvider, environment: Environment, argProvider: Callable[[],list]):
if self.value is None:
self.value = provider.create(environment, *argProvider())
return self.value
AOP
It is possible to define different Aspects, that will be part of method calling flow. This logic fits nicely in the library, since the DI framework controls the instantiation logic and can handle aspects within a regular post processor.
Advice classes need to be part of classes that add a @advice() decorator and can define methods that add aspects.
@advice()
class SampleAdvice:
def __init__(self): # could inject dependencies
pass
@before(methods().named("hello").of_type(Foo))
def call_before(self, invocation: Invocation):
# arguments: invocation.args
print("before Foo.hello(...)")
@error(methods().named("hello").of_type(Foo))
def call_error(self, invocation: Invocation):
print("error Foo.hello(...)")
print(invocation.exception)
@around(methods().named("hello"))
def call_around(self, invocation: Invocation):
print("around Foo.hello()")
return invocation.proceed() # will leave a result in invocation.result or invocation.exception in case of an exception
Different aspects - with the appropriate decorator - are possible:
before
methods that will be executed prior to the original methodaround
methods that will be executed around to the original method giving it the possibility add side effects or even change the parameters.after
methods that will be executed after to the original methoderror
methods that will be executed in case of a caught exception, which can be retrieved byinvocation.exception
All methods are expected to hava single Invocation parameter, that stores, the function, args and kwargs, the return value and possible exceptions.
It is essential for around methods to call proceed() on the invocation, which will call the next around method in the chain and finally the original method.
If the proceed is called with parameters, they will replace the original parameters!
The argument list to the corresponding decorators control, how aspects are associated with which methods.
A fluent interface is used describe the mapping.
The parameters restrict either methods or classes and are constructed by a call to either methods() or classes().
Both add the fluent methods:
of_type(type: Type)
defines the matching classesnamed(name: str)
defines method or class namesmatches(re: str)
defines regular expressions for methods or classesdecorated_with(type: Type)
defines decorators on methods or classes
The fluent methods named, matches and of_type can be called multiple times!
Example:
@injectable()
class TransactionAdvice:
def __init__(self):
pass
@around(methods().decorated_with(transactional), classes().decorated_with(transactional))
def establish_transaction(self, invocation: Invocation):
...
Configuration
It is possible to inject configuration values, by decorating methods with @inject-value(<name>) given a configuration key.
@injectable()
class Foo:
def __init__(self):
pass
@value("OS")
def inject_value(self, os: str):
...
This concept relies on a central object ConfigurationManager that stores the overall configuration values as provided by so called configuration sources that are defined as follows.
class ConfigurationSource(ABC):
def __init__(self):
pass
...
@abstractmethod
def load(self) -> dict:
pass
The load method is able to return a tree-like structure by returning a dict.
As a default environment variables are already supported.
Other sources can be added dynamically by just registering them.
Example:
@injectable()
class SampleConfigurationSource(ConfigurationSource):
def __init__(self):
super().__init__()
def load(self) -> dict:
return {
"a": 1,
"b": {
"d": "2",
"e": 3,
"f": 4
}
}
Reflection
As the library heavily relies on type introspection of classes and methods, a utility class TypeDescriptor is available that covers type information on classes.
After beeing instatiated with
TypeDescriptor.for_type(<type>)
it offers the methods
get_methods(local=False)
return a list of either local or overall methodsget_method(name: str, local=False)
return a single either local or overall methodhas_decorator(decorator: Callable) -> bool
returnTrue, if the class is decorated with the specified decratorget_decorator(decorator) -> Optional[DecoratorDescriptor]
return a descriptor covering the decorator. In addition to the callable, it also stores the supplied args in theargsproperty
The returned method descriptors offer:
param_types
list of arg typesreturn_type
the retur typehas_decorator(decorator: Callable) -> boolreturnTrue, if the method is decorated with the specified decratorget_decorator(decorator: Callable) -> Optional[DecoratorDescriptor]
return a descriptor covering the decorator. In addition to the callable, it also stores the supplied args in theargsproperty
The management of decorators in turn relies on another utility class Decorators that caches decorators.
Whenver you define a custom decorator, you will need to register it accordingly.
Example:
def transactional():
def decorator(func):
Decorators.add(func, transactional)
return func
return decorator
Version History
1.0.1
- some internal refactorings
1.1.0
- added
@on_running()callback - added
threadscope
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file aspyx-1.1.0.tar.gz.
File metadata
- Download URL: aspyx-1.1.0.tar.gz
- Upload date:
- Size: 29.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.12.10
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
3712843a6686b041b584bfdd2f5abb4dfd79f84843646b6941d44ed6badd384b
|
|
| MD5 |
0e5bad5eeb68cddf77d0a3fec292b10d
|
|
| BLAKE2b-256 |
77d8ed7fddd6a984bad90dabb24bb7eb3327af127a1eb98b45e3cae6944718ea
|
File details
Details for the file aspyx-1.1.0-py3-none-any.whl.
File metadata
- Download URL: aspyx-1.1.0-py3-none-any.whl
- Upload date:
- Size: 24.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.12.10
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
342fd937913698fc837ff2ac836b8a2eb1aa72f48986f9624e327b8929880897
|
|
| MD5 |
8ddade03b595c95918205318c26e4bd2
|
|
| BLAKE2b-256 |
61bf2550411013d4c13fe8fa790354f5d499f388bc8a760f819a82504a43fa57
|