mmerickel / wired

A service locator implementation for Python.
https://wired.readthedocs.io
MIT License
17 stars 9 forks source link

Understanding Context #46

Closed ianjosephwilson closed 9 months ago

ianjosephwilson commented 10 months ago

I've been using this library for a few years and I still don't understand context. This is a contrived example but in general I want to lookup a service that may or may not be context dependent but then have any dependencies resolved with the original context. It seems that the context is dropped. Is there a way to continue to use the original context?

Ie. this example fails like so

>       assert (registry.create_container(context=customer).get(Greeter, context=customer).greet(customer)) == 'Bonjour Henri'
E       AssertionError: assert 'Hi Henri' == 'Bonjour Henri'
E         - Bonjour Henri
E         + Hi Henri
def test_wired_nested_context():
    from dataclasses import dataclass
    from wired import ServiceRegistry
    registry = ServiceRegistry()
    @dataclass
    class Config:
        greeting: str = 'Hi'
    @dataclass
    class FrenchConfig(Config):
        greeting: str = 'Bonjour'
    @dataclass
    class Customer:
        name: str = 'Mary'
    @dataclass
    class FrenchCustomer(Customer):
        name: str = 'Henri'

    @dataclass
    class Greeter:
        config: Config
        def greet(self, customer):
            return f"{self.config.greeting} {customer.name}"

    def default_config(container):
        return Config()

    def french_config(container):
        return FrenchConfig()

    def greeter_factory(container):
        config = container.get(Config)
        return Greeter(config=config)

    registry.register_factory(greeter_factory, Greeter)
    registry.register_factory(default_config, Config)
    registry.register_factory(french_config, Config, context=FrenchCustomer)

    default_customer = Customer()
    assert (registry.create_container(context=default_customer).get(Greeter, context=default_customer).greet(default_customer)) == 'Hi Mary'
    french_customer = FrenchCustomer()
    assert (registry.create_container(context=french_customer).get(Greeter, context=french_customer).greet(french_customer)) == 'Bonjour Henri'
simonk52 commented 9 months ago

The context is being lost here:

https://github.com/mmerickel/wired/blob/6b6a3e83702b18ebb41ca1f94e957bdf7e44986d/src/wired/container.py#L220-L225

The Greeter factory is registered with no context, which means svc_info.wants_context is False. When you look up the Greeter service with an explicit context, and no existing greeter is found, wired creates a nested container where the context is None. This nested container is passed into the Greeter factory, and is the reason why the default Config is returned rather than the FrenchConfig.

The problem is that you have a generic (non-context-specific) service (Greeter) which depends on a context-specific service (Config). wired caches services according to their context. Because your Greeter isn't registered for any particular context, wired will only store a single instance of it in the container. But that would be incorrect because you actually need a different Greeter for each Customer class.

You can get the test to pass by changing the Greeter registration to:

registry.register_factory(greeter_factory, Greeter, context=Customer)

This ensures that the customer is preserved in the greeter_factory and used to select the correct Config factory. It's not an ideal fix because it requires the Greeter to know more about the Config dependencies. You'll also end up with separate Greeter instances for each Customer instance

A better way to solve it is to make the Greeter require a Config object as the context. For example:

def greeter_from_config(container):
    config = container.context
    return Greeter(config=config)

registry.register_factory(greeter_from_config, Greeter, context=Config)

Semantically this is better, as only a single Greeter instance will be created per Config instance. It makes it harder to construct Greeter instances, because you first need to get hold of the Config, but you can register a second factory function to hide that:

def greeter_from_anything(container):
    config = container.get(Config)
    return container.get(Greeter, context=config)

registry.register_factory(greeter_from_anything, Greeter, context=object)

Using object as the context for this factory means that it will pass any context through to the Config factory. If no appropriate factory has been registered for Config, it will throw a LookupError.

With those 2 factories registered, these two tests pass:

def get_greeting(customer):
    container = registry.create_container(context=customer)
    greeter = container.get(Greeter)
    return greeter.greet(customer)

default_customer = Customer()
assert get_greeting(default_customer) == "Hi Mary"
french_customer = FrenchCustomer()
assert get_greeting(french_customer) == "Bonjour Henri"
mmerickel commented 9 months ago

Excellent response @simonk52 - thanks for that.

Context is primarily used to define "when" to create a new service object. The container is using this info to cache the service objects it has created properly. If you declare a service as "not different per-context" then the context passed to that factory will always be None, to try and ensure the factory is not making assumptions that the context is always the same for this service instance.

It always goes back to how the service was registered, and not what was passed in to the .get().