Injection#

Hopscotch has two faces: a registry for implementations and a dependency injector for construction.

What does that even mean?

In Hopscotch, here’s the flow:

  • Something asks for a “kind of thing”…maybe a component asks for a subcomponent

  • The registry gets the best implementation

  • The registry then calls that implementation, supplying it the arguments it is asking for

  • Some of those arguments might be “kinds of things” which also need instances

  • Later, when I’m brave, this will all be cached and persistent

If registries are an “OMG too much magic” thing in Python, then dependency injection is a “you’re trying to make this into Java” kind of thing. In Hopscotch, I want to show we can lower the bar to make the simple case really simple, especially for consumers.

Let’s roll.

The Simplest Case#

We have a Greeter who says hello with a greeting. Actually, a Greeting – an instance of a class that is in “the system”.

@injectable()
@dataclass()
class Greeter:
    """A dataclass to engage a customer."""

    greeting: Greeting

We can make a pluggable which has Greeting and Greeter in its registry:

>>> from hopscotch import Registry
>>> registry = Registry()
>>> registry.scan()

Now that we’re all setup, let’s ask for a Greeter:

>>> greeter = registry.get(Greeter)
>>> greeter.greeting.salutation
'Hello'

What happened?

  • We asked the registry for Greeter

  • It found the “best-fit” implementation…in this case, Greeter itself

  • The registry started constructing an instance by introspecting its fields

  • The Greeter.greeting field had a type hint of Greeting

  • The registry had an implementation for Greeting

  • Since Greeting had a default value for its one field, it could be constructed

  • The registry used that constructed instance to construct Greeter

  • Done

“Woah, dataclass magical mumbo jumbo!” you say. Well, here’s an example using a plain-old-class:

class Greeter:
    """A plain-old-class to engage a customer."""

    greeting: Greeting

    def __init__(self, greeting: Greeting):
        """Construct a greeter."""
        self.greeting = greeting

Here’s a NamedTuple:

class Greeter(NamedTuple):
    """A ``NamedTuple`` to engage a customer."""

    greeting: Greeting

…but with a caveat: NamedTuple and functions (next) have a little sharp edge regarding the “kind” discussion in Registry. Here’s a function for Greeter that can also be dependency injected:

class Greeter(NamedTuple):
    """A ``NamedTuple`` to engage a customer."""

    greeting: Greeting

Even for the “simple” case, this is pretty valuable. Really de-coupled systems, where you can add things without monkey-patching and the callees get to decide what they need.

Manual Factory#

“Too much magic!” It’s true that the injector has a good number of policy decisions in the service of “helping.” Perhaps you’d like to keep injection, but have manual control over construction? For that, provide a class method named __hopscotch_factory:

@dataclass()
class GreetingFactory:
    """Use the ``__hopscotch_factory__`` protocol to control creation."""

    salutation: str

    @classmethod
    def __hopscotch_factory__(cls, registry: Registry) -> GreetingFactory:
        """Manually construct this instance, instead of injection."""
        return cls(salutation="Hi From Factory")

Generics#

Hopscotch injection works by the type hint. Provide a type, Hopscotch tries to go get it and make an instance for you. But those type hints can be…rich. Here’s a Greeter who can have an optional Greeting:

@dataclass()
class GreeterOptional:
    """A dataclass to engage a customer with optional greeting."""

    greeting: Optional[Greeting]  # no default

Dataclasses especially have some extra generics to cover their fields.

Default Values#

When you’re constructing or calling something – dataclass, plain old class, NamedTuple, function – the parameters might have default values.

A dataclass might have a field with a default value:

class Greeting:
    """A dataclass to give a greeting."""

    salutation: str = "Hello"

But so might a function:

def GreeterOptional(greeting: Optional[str]) -> Optional[str]:
    """A function to engage a customer with optional greeting."""
    return greeting

The default value is the lowest-precedence option. The injector tries to go get a value from the registry based on the field/parameter’s type. In these two cases, the type hint says str which, obviously, won’t be in the registry.

Operators#

Now, on to the part where Hopscotch actually adds to the status quo.

In some cases, we want a little transform between what we’re asking for and what we’re getting. For example, perhaps we have a registry with a context:

>>> registry = Registry()
>>> registry.register(Greeting)
>>> registry.register(AnotherGreeting, kind=Greeting)
>>> registry.register(Greeter)

We’d like to get Greeting out, but we know it’s really going to be AnotherGreeting. For this we can use an “operator”: a simple callable class which is given some inputs and returns an output:

@injectable()
@dataclass()
class GreeterGetAnother:
    """Use an operator to change the type hint of what's retrieved."""

    customer_name: AnotherGreeting = get(Greeting)

What is get? It’s an “operator”:

@dataclass(frozen=True)
class Get:
    """Lookup a kind and optionally pluck an attr."""

    lookup_key: Any
    attr: Optional[str] = None

    def __call__(
        self,
        registry: Registry,
    ) -> object:
        """Use registry to find lookup key and optionally pluck attr."""
        # Can't lookup a string, ever, so bail on this with an error.
        if isinstance(self.lookup_key, str):
            lk = self.lookup_key
            msg = f"Cannot use a string '{lk}' as container lookup value"
            raise ValueError(msg)

        result_value = registry.get(self.lookup_key)

        # Are we plucking an attr?
        if self.attr is not None:
            result_value = getattr(result_value, self.attr)

        return result_value

In this case, we’re saying: “Sure, go get me a Greeting, but actually, I know it is a AnotherGreeting.”

Here’s a super-useful variation: get me a Greeting and then pluck the attribute I’m really looking for:

@dataclass()
class GreeterFirstName:
    """A dataclass that gets an attribute off a dependency."""

    customer_name: str = get(Customer, attr="first_name")

Let’s see it in action. I have a registry which registers a Customer singleton and GreeterFirstName, then gets a Greeter:

>>> registry = Registry()
>>> customer = Customer(first_name="Mary")
>>> registry.register(customer)  # A singleton
>>> registry.register(GreeterFirstName, kind=Greeter)
>>> greeter = registry.get(Greeter)
>>> greeter.customer_name
'Mary'

Operators act like a DSL, giving instructions to the injector. Since you can very easily write your own, it provides a nice way to concentrate your injectables on what they really need. Minimizing the surface area with the outside system has benefits.

Annotated#

We just discussed operators. I lied a little: get isn’t strictly an operator. It’s actually a dataclasses.field which stuffs some expected values – namely, operator – in the metadata part of a field.

This is actually syntactic sugar over a more verbose form that can be used outside dataclasses: plain old classes, NamedTuple, and even functions. For example, here’s a function that asks the registry to get the Customer and pluck the first_name:

def GreeterAnnotated(
    customer_name: Annotated[str, Get(Customer, attr="first_name")]
) -> str:
    """A function to engage a customer with an ``Annotated``."""
    return customer_name

Previously, the dataclasses.field metadata communicated with the injector. This uses PEP 593 – Flexible function and variable annotations to give injector instructions. The NamedTuple and plain old classes also use this. In fact, dataclasses can use Annotated also, though there’s no real reason to. As long as the operator has a “field” version – Get vs. get – it’s a lot more convenient to use the latter.

Context#

Sometimes you want to inject a field that has an attribute off the context. You can’t just say get(Context) as there isn’t a Context class registered on the registry. Instead, it’s an attribute.

Instead, there’s another operator for this: the Context operator with its context field:

@injectable(context=FrenchCustomer)
@dataclass()
class GreeterFrenchCustomer:
    """A dataclass that depends on a different registry context."""

    customer: FrenchCustomer = context()

This does the moral equivalent of grabbing registry.context. It also supports passing in attr= to pluck just one attribute.

Props#

We’ve seen how Hopscotch can gather the inputs needed to construct an instance: symbols in the registry, operators that return values, default values, etc.

Hopscotch was written to power ViewDOM and its software for component-driven development. In frontends, components are usually passed “props” in a particular usage. Hopscotch also allows “props”: values passed in during registry.get() which have the highest precedence.

Here’s an example of a registry with a Greeting:

>>> registry = Registry()
>>> registry.register(Greeting)

I’m now in a request and, for some reason, I want to supply a specific salutation, as a “prop”:

>>> greeting = registry.get(Greeting, salutation="Hello Prop")
>>> greeting.salutation
'Hello Prop'

What would that look like in ViewDOM? In a template somewhere, you’d say: <{Greeting} salutation="Hello Prop" //>.

Same Ol’ Dataclasses#

As can be inferred, dataclasses are the “first-class citizen.” As such, there’s several aspects that are accommodated. For example, fields that should be handled in a __post_init__ are deferred to let the dataclass handle that field, rather than injecting it.

inject_callable#

ViewDOM works well with Hopscotch, but doesn’t require it. You can have some utility components as functions that aren’t in the registry, because of the whole type/kind thing. Or, you can just skip completely the “replaceable components” thing and just rely in symbols as the only implementation.

For this, Hopscotch lets you use the injector independently of the registry via the hopscotch.inject_callable function. It’s a bit more work: you have to supply a Registration object that is the result of introspecting the target to be constructed. (There’s a function for that too.)