Skip to content

@wired_factory refactoring proposal #35

@pauleveritt

Description

@pauleveritt

From a Jan 10 conversation

tl;dr Rewrite and refactor decorator and injector support to support function DI and clarify usage

Generic Decorator

We want a decorator that can register factory functions as well as dataclasses. Along the way, we want clean up naming and allow overriding how this “creator” happens.

Say you have the following:

def login_factory(container):
    dbsession = container.get(name='dbsession')
    return LoginService(dbsession)
registry.register_factory(login_factory, LoginService)

We’d like, as the explicit case for a decorated registration:

@wired_factory(for_=LoginService, creator=wired_function_creator)
def login_factory(container):
    dbsession = container.get(name='dbsession')
    return LoginService(dbsession)

The changes:

  • We no longer have to write a registry.register_function line
  • The name factory gets expanded to wired_factory to make it explicit
  • It turns the result of the creator callable into a wired factory
  • The creator callable can now handle function-based services instead of just dataclass-based services, through use of specific "creators"

Or, a shorthand notation:

@wired_factory()
def login_factory(container) -> LoginService:
    dbsession = container.get(name='dbsession')
    return LoginService(dbsession)

In this case:

  • for_ is deduced from the type annotation for the return type
  • creator is deduced from the wrapped target…if it is a function, we use wired_function_creator

Of course you can use an imperative form. In fact, we already have one: registry.register_factory. We could handle the explicit case above:

def login_factory(container):
    dbsession = container.get(name='dbsession')
    return LoginService(dbsession)
registry.register_factory(login_factory, LoginService, creator=wired_function_creator)

In existing cases, we don’t need a creator. There’s no prep work for the callable. However, if we change to support DI on functions, we’ll want a creator when registering functions.

Here is a shorthand version of this imperative form:

def login_factory(container) -> LoginService:
    dbsession = container.get(name='dbsession')
    return LoginService(dbsession)
registry.register_factory(login_factory)

Again, this version:

  • Looks at the argument login_factory and sees it is a function, thus uses creator=wired_function_creator
  • Looks at the type annotation return value of login_factory and uses that type as the second argument

The Calling Pattern

This change makes the two-step dance more explicit.

  • creator is a callable that does some work and returns a callable…yes, it is a factory factory
  • That returned callable is registered

It also makes the two-step dance extensible. I have wanted a “custom injector”. The injector is, in essence, the factory factory. In this case:

@wired_factory(creator=wired_dataclass_creator)
@dataclass
class Greeter:
    settings: Settings
    name: str = 'Mary'

    def __call__(self, customer):
        punctuation = self.settings.punctuation
        return f'Hello {customer} my name is {self.name}{punctuation}'

Thus wired_dataclass_creator is the “injector”. That is, it returns a callable that, when called, will construct the dataclass instance using DI.

If one wanted to customize the injection process, one could make a variation of wired_dataclass_creator. One could automate/hide usage of it by making a custom decorator.

Function DI

Above we discussed:

def login_factory(container):
    dbsession = container.get(name='dbsession')
    return LoginService(dbsession)
registry.register_factory(login_factory, LoginService, creator=wired_function_creator)

At first glance this seems unnecessary: the wired_function_creator callable would do nothing more than just return login_factory.

But what if we wanted to do DI on functions? For example:

def login_factory(dbsession: Session):
    dbsession = container.get(name='dbsession')
    return LoginService(dbsession)
registry.register_factory(login_factory, LoginService, creator=wired_function_creator)

In this case, we do need a factory-factory callable registered as the service. wired_function_creator would look at the login_factory signature and see if it wanted just the container. If so, it would immediately return login_factory and mimic current behavior.

Otherwise, it would presume this function wanted DI. It would return a lambda or function that, during service lookup/construction, would be handed the container and do the DI dance.

This could be simplified in the imperative form:

def login_factory(dbsession: Session) -> LoginService:
    dbsession = container.get(name='dbsession')
    return LoginService(dbsession)
registry.register_factory(login_factory)

…and the decorated form:

@wired_factory()
def login_factory(dbsession: Session) -> LoginService:
    dbsession = container.get(name='dbsession')
    return LoginService(dbsession)

Extensible Creators

These creator callables are important. They might also be custom. They are a perfect place to experiment with app-specific or site-specific policies (for example, caching.)

In the case of explicitly naming the creator to use, in the decorator or imperative form, this is already handled. Call the creator you want in that case.

Or is it? Perhaps you want a general case for the creator, but have some context-specific cases as exception. For this, one should treat “creator” as itself a service:

def login_factory(dbsession: Session):
    dbsession = container.get(name='dbsession')
    return LoginService(dbsession)
registry.register_factory(login_factory, LoginService, creator=WiredFunctionCreator)

With this, WiredFunctionCreator is a service in the registry which will return the correct creator for this situation. You then have access to wired’s extension/customization/overriding patterns:

  • Register a creator for a particular context
  • Register a creator for a particular name
  • Use ordering (whichever registration happens last) to allow overriding

This would mean wired would need to “scan” for creators before doing a scan for factories. We could make easier by having a basic registry usage, where this was done all in one step, or a more-advanced two-step process of setting up a registry.

Injector Service

With the above, injection is part of the creator step. Albeit a big, hairy part. People might want a custom creator, but not touch the injector. Or vice versa. People might want a different injector for a specific service.

Thus, behind the scenes, creator=WiredFunctionCreator will lookup a WiredFunctionInjector when it wants to do the injection part. The context will the for_ the creator is being used to construct.

Along the way, the current injector will be refactored to make each lookup more granular, to make it easier to write your own injector by re-using pieces of the existing injector. It will also be easier to test (all lookups are currently inlined in a big for loop.)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions