scrapinghub/andi

Update `andi.plan` to support a function/class signature in `dict`

BurnzZ opened this issue · 3 comments

Currently, andi.plan() works by passing a function or class. This requires them to have their signatures already established:

class Valves:
    pass

class Engine:
    def __init__(self, valves: Valves):
        self.valves = valves

class Wheels:
    pass

class Car:
    def __init__(self, engine: Engine, wheels: Wheels):
        self.engine = engine
        self.wheels = wheels

However, there are use cases where we might want to create different types of cars which means that the car signatures could be dynamic which is only determined during runtime.

For example, during runtime the car's wheels could be electric, or the wheels could become tank treads. We could solve this by defining an ElectricCar or TankCar. Yet, this solution doesn't cover all the other permutation of car types, especially when its signature changes if it adds an arbitrary amount of attachments:

  • engine: Engine, wheels: Wheels, top_carrier: RoofCarrier
  • engine: Engine, wheels: Wheels, top_carrier: BikeRack
  • engine: Engine, wheels: Wheels, back_carrier: BikeRack, top_carrier: RoofCarrier

We could list down all of the possible dependencies in the signature but that wouldn't be efficient since it takes some effort to fulfill all of them but at the end, only a handful of them will be used.


I'm proposing to update the API to allow such arbitrary signatures to used. This allows something like this to be possible:

def get_blueprint(customer_request):
    results: Dict[str, Any] = {}
    for i, dependency in enumerate(read_request(customer_request)):
        results[f"arg_{i}"] = results
    
    return results  # something like {"arg_1": Engine, "arg_2": Wheels, "arg_3": BikeRack}

signature = get_blueprint(customer_request)

plan = andi.plan(
    signature,
    is_injectable=is_injectable,
    externally_provided=externally_provided,
)

andi.plan() largely remains the same except that it now supports a mapping representing any arbitrary function/class signature.

I think another way to solve this problem without modifying andi would be something like:

import attrs
import andi

class BaseDep:
    pass


@attrs.define
class Foo:
    dep: BaseDep


@attrs.define
class Bar:
    dep: BaseDep

and then calling andi.plan() for each dependency:

>>> is_injectable = lambda x: True

>>> andi.plan(Foo, is_injectable=is_injectable)
[(__main__.BaseDep, {}), (__main__.Foo, {'dep': __main__.BaseDep})]

>>> andi.plan(Bar, is_injectable=is_injectable)
[(__main__.BaseDep, {}), (__main__.Bar, {'dep': __main__.BaseDep})]

But we have to be careful to deduplicate any base dependencies that are shared.

Having a modified andi.plan() would still be great since it could easily render:

[(__main__.BaseDep, {}),
 (__main__.Foo, {'dep': __main__.BaseDep}),
 (__main__.Bar, {'dep': __main__.BaseDep}),
 (<function __main__.combine(foo: __main__.Foo, bar: __main__.Bar)>,
  {'foo': __main__.Foo, 'bar': __main__.Bar})]

I found a way to prevent modifying andi but still create dynamic class signature using dataclasses.make_dataclass. 🎉

import andi
import attrs
from dataclasses import make_dataclass


class BaseDep:
    pass

@attrs.define
class Foo:
    dep: BaseDep

@attrs.define
class Bar:
    dep: BaseDep

Tmp = make_dataclass("Tmp", [('f', Foo), ('b', Bar)])
is_injectable = lambda x: True

andi.plan(Tmp, is_injectable=is_injectable)

#[(__main__.BaseDep, {}),
# (__main__.Foo, {'dep': __main__.BaseDep}),
# (__main__.Bar, {'dep': __main__.BaseDep}),
# (types.Tmp, {'f': __main__.Foo, 'b': __main__.Bar})]
kmike commented

Allowing andi to accept {"arg_1": Engine, "arg_2": Wheels, "arg_3": BikeRack} dicts instead of callables or classes makes sense to me; +1 to implement this.

I'd probably avoid calling it "signature" or "blueprint", because it enables other possibilities, not tied to signatures. Just an API to create a plan on how to create dependencies, which you can use anywhere.