Specifications#
Specifications are one half of diy
.
They determine how objects should be constructed.
While diy
tries to infer as much as possible, there are some edge cases where a sensible default cannot be inferred.
Lets start with an example showing why we even need them:
from enum import StrEnum
class HashingAlgorithm(StrEnum):
SHA256 = "sha256"
CRC32 = "crc32"
MD5 = "md5"
class PasswordHasher:
def __init__(self, algorithm: HashingAlgorithm):
self.algorithm = algorithm
# ...
In this case, it can not be automatically inferred what HashingAlgorithm
to use, when constructing a PasswordHasher
.
If you would try to resolve an instance of PasswordHasher
without any specifications, you would receive
import diy
container = diy.RuntimeContainer()
hasher = container.get(PasswordHasher)
# 🧨💥 raises a diy.errors.UnresolvableDependencyError
To solve this, we explicitly need to tell the container how it should construct one. And this is where specs come into play.
Defining Specifications#
@spec.add
def build_hasher() -> PasswordHasher:
return PasswordHasher(HashingAlgorithm.CRC32)
Always annotate the return type when using decorators!
Usually these kinds of annotations are optional in Python.
However when using the decorator approach for registering builder functions, they are mandatory!
Otherwise we'd have to run extensive analysis on the function body to check what type the function constructs.
If you forget to add them a diy.erros.MissingReturnTypeAnnotationError
is thrown.
Builder Function Dependencies#
This is not implemented yet!
TODO
Partial Specifications#
Sometimes the need arises to specify one or only a few parameters of a constructor.
Consider a UserService
that does lots of things related to users
class UserService:
def __init__(
self,
mailer: EmailSender,
api: UsersApiClient,
debug: bool,
):
# forward the arguments to properties
Lets assume the first two parameters can be resolved from the container.
The third one is a simple boolean, which the container can not choose a sensible default for.
Lets pretend we only want to set this to true, if the environment variable DEBUG
is defined.
We could register a builder: retrieve the values for mailer
and api
by specifying them as builder function dependencies, set debug
based on the presence of the environment variable and build our instance based on that
import os
import diy
spec = diy.Specification()
def build_user_service(
mailer: EmailSender,
api: UsersApiClient,
) -> UserService:
return UserService(mailer, api, "DEBUG" in os.environ)
This works, but leads to more code, the more arguments our UserService
has.
We also
Leveraging Annotated Types#
This is not implemented yet!
TODO
Someone could create a "token type" / a specific type for one kind of special dependency. Imagine you have a class representing a cloud storage bucket
from typing import Protocol
class CloudStorageBucket(Protocol):
def path_exists(path: str) -> bool:
# ...
def read(path: str) -> str:
# ...
# ...
You might have several instances of this class that you want to inject into services. Imagine you got a bucket for user profile pictures, and one for.
from typing import Annotated
from app.cloud.interfaces import CloudStorageBucket
# Just put something as the second argument here, the specific contents
# of the string do not matter.
ProfilePicturesBucket = Annotated[CloudStorageBucket, "users"]
OpenGraphImagesBucket = Annotated[CloudStorageBucket, "og-images"]
then we annotate our classes accordingly
class UserService:
def __init__(self, profile_pictures_bucket: ProfilePicturesBucket):
# ...
class ImageGenerator:
def __init__(self, bucket: OpenGraphImagesBucket):
# ...
Note that to an IDE or type checker, those parameters still look and behave like regular CloudStorageBucket
s.
But specifying annotated types for specific instances has the advantage that you can easily find all the places where your code interacts with this specific bucket.
We also can utilize this for our dependency injection container:
import diy
from app.cloud.buckets import ProfilePicturesBucket, OpenGraphImagesBucket
from app.cloud.aws import S3CloudStorageBucket # This is a specialized CloudStorageBucket child class utilizing the AWS sdk
from app.cloud.digitalocean import DoSpacesBucket # Another one from a different cloud provider
spec = diy.Specification()
@spec.add
def build_profile_pictures_bucket() -> ProfilePicturesBucket:
return S3CloudStorageBucket("arn:aws:s3:::profile-pictures")
@spec.add
def build_og_images_bucket() -> OpenGraphImagesBucket:
return DoSpacesBucket("/images/og")
Now our container knows to assign the correct buckets to the correct services.
Conditional Specifications#
This is not implemented yet!
TODO: Implement this, but the Annotated Specifications might be better? This is more of an escape hatch for types out of your control.
TODO: When UserService
needs EmailNotifier
-> use global_bcc = "[email protected]"
When ImportService
needs EmailNotifier
-> use global_bcc = "[email protected]"
Eager And Lazy Specifications#
DB Session -> short lived Current Request -> short lived PasswordHasher -> long lived, static