Registry and Injection

The ViewDOM component system can be used as “better templating”: smaller, testable, Pythonic, re-usable units. But it really shines when combined with Hopscotch for replaceable, injectable components.

Simple Registered Heading

We’ll start with the Heading component from the first components example. In this case, our component is a dataclass, rather than a function:

@injectable()
@dataclass
class Heading:
    """The default heading."""

    def __call__(self) -> VDOM:
        """Render the component."""
        return html("<h1>My Title</h1>")

This time our main function simulates making a registry and processing a request:

from .app import Heading  # noqa: F401
from viewdom import html
from viewdom import render


def main() -> str:
    """Main entry point."""
    # At startup
    registry = Registry()
    registry.scan()

    # Per request
    vdom = html("<{Heading} />")
    result = render(vdom, registry=registry)
    return result

Replacement

In the previous example, the component shipped with a pluggable app’s package. But a local site might want to register a replacement for that component. Here is a site.py which does just this – replace app.Heading with a different implementation:

@injectable(kind=Heading)
@dataclass
class SiteHeading:
    """A heading customized to the site."""

    def __call__(self) -> VDOM:
        """Render the component."""
        return html("<h1>Local Site Title</h1>")

This time the decorator said kind=Heading.

Nothing changed in __init__.py, and yet, a different class was selected and Local Site Title is the result. How did that work, without monkey-patching?

It’s because ViewDOM can tranparently look up components using a registry. In that model, app.Heading isn’t a dataclass. It’s a “kind”…sort of like an interface, or a base type, or a protocol. The registry has two implementations, and the second one was added last, so it was used.

That’s replacement. But what if you want both implementations, but used in different contexts?

Variants

First, let’s imagine our app had a concept of a Customer:

@dataclass
class Customer:
    """The person to greet, stored as the registry context."""

    first_name: str

We could then tell our “replacement” in our local site to only replace in certain cases. Namely, when the “context” is a Customer:

@injectable(kind=Heading, context=Customer)
@dataclass
class SiteHeading:
    """A heading customized to the site."""

    def __call__(self) -> VDOM:
        """Render the component."""
        return html("<h1>Local Site Title</h1>")

What is the “context”? It’s a special aspect of a registry. In this case, a per-request child registry:

from .app import Customer
from .app import Heading  # noqa: F401
from viewdom import html
from viewdom import render


def main() -> tuple[str, str]:
    """Main entry point."""
    # At startup
    registry = Registry()
    registry.scan()

    # First request, no customer, context=None
    request_registry0 = Registry(parent=registry, context=None)
    vdom = html("<{Heading} />")
    result0 = render(vdom, registry=request_registry0)

    # Second request, context=customer
    customer = Customer(first_name="Marie")
    request_registry1 = Registry(parent=registry, context=customer)
    vdom = html("<{Heading} />")
    result1 = render(vdom, registry=request_registry1)

    return result0, result1

As you can see in the test, each “request” renders a different output:

    assert main() == ("<h1>My Title</h1>", "<h1>Local Site Title</h1>")

From a component developer’s perspective, this is all transparent. But what if I want my component to get access to that Customer?

Simple Injection

We could arrange for the caller to pass in the context into the component. But if the component is way down the component tree, it will have to be passed by all the parents. What if the component could just ask the registry – that is, the registry’s injector – to provide the context

@injectable(kind=Heading, context=Customer)
@dataclass
class SiteHeading:
    """A heading customized to the site."""

    customer: Customer = context()

    def __call__(self) -> VDOM:
        """Render the component."""
        first_name = self.customer.first_name
        return html("<h1>Hello {first_name}</h1>")

Our component can then get the first_name off the context, because we said it was a Customer in the type hint.

What is context()? It is a Hopscotch “operator”: something which acts like a dataclasses.field but during injection, does extra work. You can write your own operators, but the built-in ones have some extra features.

Only Inject What You Need

Our component says it needs the entire Customer. That’s a broad surface area. Let’s change it, to ask the injector to get just the piece of information we need – the first_name – as part of injection:

@injectable(kind=Heading, context=Customer)
@dataclass
class SiteHeading:
    """A heading customized to the site."""

    customer_name: str = context(attr="first_name")

    def __call__(self) -> VDOM:
        """Render the component."""
        return html("<h1>Hello {self.customer_name}</h1>")

Note

Broke The Contract You’ll note that app.Heading and site.SiteHeading have deviated in “props”.

This has some powerful consequences. First, you can customize a component by passing first_name in manually as a prop, which will be used instead of injection. For example, html('<{Heading} first_name="Bob" />').

In a larger sense, your components could become observable. During registration, Hopscotch could record that you need that value. Then during injection, it could persist the Customer and this SiteHeading instance, which a relationship. If the particular customer’s first_name changed, you could rebuild just that component instance.