Dependency Injection
Build a dependency injection object.
Overview
The dependency injection (DI) container is a key part of clearskies, so understanding how to both configure them and get dependencies for your classes is important. Note however that you don’t often have to interact with the dependency injection container directly. All of the configuration options for the DI container are also available to all the contexts, which is typically how you will build clearskies applications. So, while you can create a DI container and use it directly, typically you’ll just follow the same basic techniques to configure your context and use that to run your application.
These are the main ways to configure the DI container:
- Import classes - each imported class is assigned an injection name based on the class name.
- Import modules - clearskies will iterate over the module and import all the classes and AdditionalConfigAutoImport classes it finds.
- Import AdditionalConfig classes - these allow you to programmatically define dependencies.
- Specify bindings - this allows you to provide any kind of value with whatever name you want.
- Specify class overrides - these allow you to swap out classes directly.
- Extending the Di class - this allows you to provide a default set of values.
When the DI system builds a class or calls a function, those classes and functions can themselves request any value configured inside the DI container. There are three ways to request the desired dependencies:
- By type hinting a class on any arguments (excluding python built-ins)
- By specifying the name of a registered dependency
- By extending the
clearskies.di.AutoFillProps
class and creating class properties from theclearskies.di.inject_from
module
Note that when a class is built/function is called by the DI container, keyword arguments are not allowed (because the DI container doesn’t know whether or not it should provide optional arguments). In addition, the DI container must be able to resolve all positional arguments. If the class requests an argument that the DI system does not recognize, an error will be thrown. Finally, it’s a common pattern in clearskies for some portion of the system to accept functions that will be called by the DI container. When this happens, it’s possible for clearskies to provide additional values that may be useful when executing the function. The areas that accept functions like this also document the additional dependency injection names that are available.
Given the variety of ways that dependencies can be specified, it’s important to understand the order the priority that clearskies uses to determine what value to provide in case there is more than one source. That order is:
- Positional arguments with type hints:
- The override class if the type-hinted class has a registered override
- A value provided by an AdditionalConfig that can provide the type-hinted class
- The class itself if the class has been added explicitly via add_classes or implicitly via add_modules
- A clearskies built-in for predefined types
- All other positional arguments will have values provided based on the argument name and will receive
- Things set via
add_binding(name, value)
- Class added via
add_classes
oradd_modules
which are made available according to their Di name - An AdditionalConfig class with a corresponding
provide_[name]
function - A clearskies built-in for predefined names
- Things set via
Here is the list of predefined values with their names and types:
Injection Name | Injection Type | Value |
---|---|---|
di | - | The active Di container |
now | - | The current time in a datetime object, without timezone |
utcnow | - | The current time in a datetime object, with timezone set to UTC |
requests | requests.Session | A requests object configured to allow a small number of retries |
input_output | clearskies.input_outputs.InputOutput | The clearskies builtin used for receiving and sending data to the client |
uuid | - | import uuid - the uuid module builtin to python |
environment | clearskies.Environment | A clearskies helper that access config info from the environment or a .env file |
sys | - | import sys - the sys module builtin to python |
oai3_schema_resolver | - | Used by the autodoc system |
connection_details | - | A dictionary containing credentials that pymysql should use when connecting to a database |
connection | - | A pymysql connection object |
cursor | - | A pymysql cursor object |
endpoint_groups | - | The list of endpoint groups handling the request |
Note: for dependencies with an injection name but no injection type, this means that to inject those values you must name your argument with the given injection name. In all of the above cases though you can still add type hints if desired. So, for instance, you can declare an argument of utcnow: datetime.datetime
. clearskies will ignore the type hint (since datetime.datetime
isn’t a type with a predefined value in clearskies) and identify the value based on the name of the argument.
Note: multiple AdditionalConfig
classes can be added to the Di container, and so a single injection name or class can potentially be provided by multiple AdditionalConfig classes. AdditionalConfig classes are checked in the reverse of the order they were addded in - classes added last are checked first when trying to find values.
Note: When importing modules, any classes that inherit from AdditionalConfigAutoImport
are automatically added to the list of additional config classes. These classes are added at the top of the list, so they are lower priority than any classes you add via add_additional_configs
or the additional_configs
argument of the Di constructor.
Note: Once a value is constructed, it is cached by the Di container and will automatically be provided for future references of that same Di name or class. Arguments injected in a constructor will always receive the cached value. If you want a “fresh” value of a given dependency, you have to attach instances from the clearskies.di.inject
module onto class proprties. The instances in the inject
module generally give options for cache control.
Here’s an example that brings most of these pieces together. Once again, note that we’re directly using the Di contianer to build class/call functions, while normally you configure the Di container via your context and then clearskies itself will build your class or call your functions as needed. Full explanation comes after the example.
from clearskies.di import Di, AdditionalConfig
class SomeClass:
def __init__(self, my_value: int):
self.my_value = my_value
class MyClass:
def __init__(self, some_specific_value: int, some_class: SomeClass):
# `some_specific_value` is defined in both `MyProvider` and `MyOtherProvider`
# `some_class` will be injected from the type hint, and the actual instance is made by our
# `MyProvider`
self.final_value = some_specific_value * some_class.my_value
class VeryNeedy:
def __init__(self, my_class, some_other_value: str):
# We're relying on the automatic conversion of class names to snake_case, so clearskies
# will connect `my_class` to `MyClass`, which we provided directly to the Di container.
# some_other_value is specified as a binding
self.my_class = my_class
self.some_other_value = some_other_value
class MyOtherProvider(AdditionalConfig):
def provide_some_specific_value(self):
# the order of additional configs will cause this function to be invoked
# (and hence some_specific_value will be `10`) despite the fact that MyProvider
# also has a `provide_` function with the same name.
return 10
class MyProvider(AdditionalConfig):
def provide_some_specific_value(self):
# note that the name of our function matches the name of the argument
# expected by MyClass.__init__. Again though, we won't get called because
# the order the AdditionalConfigs are loaded gives `MyOtherProvider` priority.
return 5
def can_build_class(self, class_to_check: type) -> bool:
# this lets the DI container know that if someone wants an instance
# of SomeClass, we can build it.
return class_to_check == SomeClass
def build_class(self, class_to_provide: type, argument_name: str, di, context: str = ""):
if class_to_provide == SomeClass:
return SomeClass(5)
raise ValueError(
f"I was asked to build a class I didn't expect '{class_to_provide.__name__}'"
)
di = Di(
classes=[MyClass, VeryNeedy, SomeClass],
additional_configs=[MyProvider(), MyOtherProvider()],
bindings={
"some_other_value": "dog",
},
)
def my_function(my_fancy_argument: VeryNeedy):
print(f"Jane owns {my_fancy_argument.my_class.final_value}:")
print(f"{my_fancy_argument.some_other_value}s")
print(di.call_function(my_function))
# prints 'Jane owns 50 dogs'
When call_function
is executed on my_function
, the di system checks the calling arguments of my_function
and runs through the priority list above to populate them. my_function
has one argument - my_fancy_argument: VeryNeedy
, which it resolves as so:
- The type hint (
VeryNeedy
) matches an imported class. Therefore, clearskies will build an instance of VeryNeedy and provide it formy_fancy_argument
. - clearskies inpsects the constructor for
VeryNeedy
and finds two arguments,my_class
andsome_other_value: str
, which it attempts to build.my_class
has no type hint, so clearskies falls back on name-based resolution. A class calledMyClass
was imported, and per standard naming convention, this automatically becomes available via the namemy_class
. Thus, clearskies prepares to build an instance ofMyClass
.MyClass
has two arguments:some_specific_value: int
andsome_class: SomeClass
- For
some_specific_value
, the Di service falls back on named-based resolution (because it will never try to provide values for type-hints of built-in types). BothMyOtherProvider
andMyProvider
have a method calledprovide_some_specific_value
, so both can be used to provide this value. SinceMyOtherProvider
was added to the Di container last, it takes priority. Therefore, clearskies callsMyOtherProvider.provide_some_specific_value
to create the value that it will populate into thesome_specific_value
parameter. - For
some_class: SomeClass
, clearskies evaluates the type-hint. It works through the additional configs and, sinceMyProvider
returns True whencan_build_class
is called withSomeClass
, the Di container will use this additional config to create the value for thesome_class
argument. Therefore, clearskies callsMyProvider.build_class(SomeClass, 'some_class', di)
and the return value is used for thesome_class
argument.
- For
some_other_value
uses a built-in for a type hint, so clearskies falls back on name-based resolution. It falls back on the registered binding of"dog"
to the name"some_other_value"
, so clearskies provides"dog"
.
classes
Optional
Record any class that should be made available for injection.
All classes that come in here become available via their injection name, which is calculated by converting the class name from TitleCase to snake_case. e.g. the following class:
class MyClass:
pass
gets an injection name of my_class
. Also, clearskies will only resolve and reject based on type hints if those classes are first added via add_classes
. See the following example:
from clearskies.di import Di
class MyClass:
name = "Simple Demo"
di = Di(classes=[MyClass])
# equivalent: di.add_classes(MyClass), di.add_classes([MyClass])
def my_function(my_class):
print(my_class.name)
def my_function_with_type_hinting(the_name_no_longer_matters: MyClass):
print(my-class.name)
# both print 'Simple Demo'
di.call_function(my_function)
di.call_function(my_function_with_type_hinting)
modules
Optional
Add a module to the dependency injection container.
clearskies will iterate through the module, adding all imported classes into the dependency injection container.
So, consider the following file structure inside a module:
my_module/
__init__.py
my_sub_module/
__init__.py
my_class.py
Assuming that the submodule and class are imported at each level (e.g. my_module/init.py imports my_sub_module, and my_sub_module/init.py imports my_class.py) then you can:
from clearksies.di import Di
import my_module
di = Di()
di.add_modules([
my_module
]) # also equivalent: di.add_modules(my_module), or Di(modules=[my_module])
def my_function(my_class):
pass
di.call_function(my_function)
my_function
will be called and my_class
will automatically be populated with an instance of my_module.sub_module.my_class.MyClass
.
Note that MyClass will be able to declare its own dependencies per normal dependency injection rules. See the main docblock in the clearskies.di.Di class for more details about how all the pieces work together.
bindings
Optional
Provide a specific value for name-based injection.
This method attaches a value to a specific dependency injection name.
import clearskies.di
di = clearskies.di.Di()
di.add_binding("my_name", 12345)
# equivalent:
# di = clearskies.di.Di(bindings={"my_name": 12345})
def my_function(my_name):
print(my_name) # prints 12345
di.call_function(my_function)
additional_configs
Optional
Add an additional config instance to the dependency injection container.
Additional config class provide an additional way to provide dependencies into the dependency injection system. For more details about how to use them, see both base classes:
- clearskies.di.additional_config.AdditionalConfig
- clearskies.di.additional_config_auto_import.AdditionalConfigAutoImport
To use this method:
import clearskies.di
class MyConfig(clearskies.di.AdditionalConfig):
def provide_some_value(self):
return 2
def provide_another_value(self, some_value):
return some_value * 2
di = clearskies.di.Di()
di.add_additional_configs([MyConfig()])
# equivalents:
# di.add_additional_configs(MyConfig())
# di = clearskies.di.Di(additional_configs=[MyConfig()])
def my_function(another_value):
print(another_value) # prints 4
di.call_function(my_function)
class_overrides
Optional
Override a class for type-based injection.
This function allows you to replace/mock class provided when relying on type hinting for injection. This is most often (but not exclusively) used for mocking out classes during texting. Note that this only overrides that specific class - not classes that extend it.
Example:
from clearskies.import Di
class TypeHintedClass:
my_value = 5
class ReplacementClass:
my_value = 10
di = Di()
di.add_classes(TypeHintedClass)
di.add_class_override(TypeHintedClass, ReplacementClass)
# also di = Di(class_overrides={TypeHintedClass: ReplacementClass})
def my_function(some_value: TypeHintedClass):
print(some_value.my_value) # prints 10
di.call_function(my_function)
overrides
Optional
now
Optional
Set the current time which will be passed along to any dependency arguments named now
.
utcnow
Optional
Set the current time which will be passed along to any dependency arguments named utcnow
.