python-clean-architecture icon indicating copy to clipboard operation
python-clean-architecture copied to clipboard

Dependency Injection: Scopes

Open lhaze opened this issue 7 years ago • 3 comments

Follows #1

When a constructor is registered in the DI Container, registrar can define a scope of the instance created by the constructor. I think of them as an Enum of objects (functions? objects with state?), that know when they want to create a new instance and when to return the old one.

I think of them as something like the code below:

from enum import Enum
import typing as t

from pca.utils.functools import reify


class Container:

    _singleton_objects = {}

    @reify
    def _thread_local(self):
        from threading import local
        return local()

    def find_by_interface(self, interface):
        raise NotImplementedError

    def register_by_interface(self, interface):
        raise NotImplementedError


def instance_scope(container: Container, constructor: t.Callable) -> t.Any:
    """Every injection makes a new instance."""
    return constructor()


def thread_scope(container: Container, constructor: t.Callable) -> t.Any:
    _thread_local = container._thread_local
    try:
        objects = _thread_local.objects
    except AttributeError:
        objects = {}
        _thread_local.objects = objects

    if constructor in objects:
        return objects[constructor]
    else:
        obj = constructor()
        objects[constructor] = obj
    return obj


def request_scope(container: Container, constructor: t.Callable) -> t.Any:
    """
    Created once per request in the container. Uses Container to find the request
    (whatever it is in your application), so don't get caught into infinite recursion
    using the scope onto request.
    """
    # TODO should request has the same API find/register_by_interface?
    # TODO or should request generate some UUID which is used as a key for the container
    # TODO but when & how to clean objects for requests that are already dead

    request = container.find_by_interface(IRequest)
    try:
        return request.find_by_interface[constructor]
    except KeyError:
        obj = constructor()
        request.register_by_interface[constructor] = obj
        return obj


def session_scope(container: Container, constructor: t.Callable) -> t.Any:
    # TODO same problem as in the request_scope
    raise NotImplementedError


def singleton_scope(container: Container, constructor: t.Callable) -> t.Any:
    """Created only once in the container."""
    try:
        return container._singleton_objects[constructor]
    except KeyError:
        obj = constructor()
        container._singleton_objects[constructor] = obj
        return obj


class Scopes(Enum):
    INSTANCE = instance_scope  # every injection makes a new instance
    REQUEST = request_scope  # per a "request" (whatever a request is in your application)
    SESSION = session_scope  # per session (whatever is a user session in your application)
    THREAD = thread_scope  # per thread (via `threading.local`)
    SINGLETON = singleton_scope  # always the same instance in this container

    def get_object(self, container: Container, constructor: t.Callable):
        return self.value(container, constructor)

lhaze avatar Nov 18 '18 01:11 lhaze

The snippet above is not all that is needed. Consider following use-case:

@scope(Scopes.SINGLETON)
class ConsoleMailer(IMailer):

    def __init__(self, container, session):
        self.container = container

    def send_to_user(self, body: str, ...):
        print("Sending email to current user")
        print(body)


@scope(Scopes.SESSION)
class SmtpMailer(IMailer):

    def __init__(self, container):
        self.container = container

    def send_to_user(self, body: str, ...):
        session = self.container.find_by_interface(ISession)
        email.send(to=session.user.email, body=body, ...)

# app.py
Container.register_by_interface(IMailer, SmptMailer)

# ctl.py
Container.register_by_interface(IMailer, ConsoleMailer)

So:

  1. DI scope might be defined at the implementation level, not by the registrar
  2. scopes may pass something to the constructor (the Container!!) so it would be nice if the scope decorator check for the possibility for passing the container

lhaze avatar Nov 18 '18 01:11 lhaze

I have flagged this issue as Important: goals of incoming version (see roadmap) will need session scope and request scope, at least in the context of CLI.

lhaze avatar Nov 28 '18 10:11 lhaze

Note: the request scope in the context of CLI is just a singleton scope, as the process running CLI command finishes at the end of the request. I See two solutions:

  1. scope choosing has some kind of fallback mode (I have no request scope then I choose the singleton)
  2. or it has to be by passing strategies to Container construction?

lhaze avatar Nov 28 '18 11:11 lhaze