Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Dependency Injection: Scopes #9

Open
lhaze opened this issue Nov 18, 2018 · 3 comments
Open

Dependency Injection: Scopes #9

lhaze opened this issue Nov 18, 2018 · 3 comments

Comments

@lhaze
Copy link
Collaborator

lhaze commented Nov 18, 2018

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
Copy link
Collaborator Author

lhaze commented Nov 18, 2018

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
Copy link
Collaborator Author

lhaze commented Nov 28, 2018

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
Copy link
Collaborator Author

lhaze commented Nov 28, 2018

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 added a commit that referenced this issue Dec 26, 2018
* added Scopes.SINGLETON
* done some DRYing of the DI Container code
* added some docstrings
lhaze added a commit that referenced this issue Dec 26, 2018
* added Scopes.SINGLETON
* done some DRYing of the DI Container code
* added some docstrings
jakos pushed a commit that referenced this issue Dec 31, 2018
* added Scopes.SINGLETON
* done some DRYing of the DI Container code
* added some docstrings
@lhaze lhaze modified the milestones: 0.1, 0.2 Dec 1, 2020
@lhaze lhaze modified the milestones: 0.2, 0.1 Aug 4, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant