Experimental#
New alternate syntax that’s less DSL and more “regular Python classes”, inspired by writing the docs for “custom containers” in Non-Library Alternatives
Example#
from __future__ import annotations
import abc
import dataclasses
import enum
from typing import Any
import pytest
from typing_extensions import override
from dilib.experimental import (
Container,
FrozenContainerError,
NewContainerKeyError,
cache,
call,
container,
)
#####################################################################
# Model Layer
#####################################################################
class Engine(abc.ABC):
@abc.abstractmethod
def start_engine(self) -> None: ...
@dataclasses.dataclass(frozen=True)
class MockEngine(Engine):
@override
def start_engine(self) -> None:
print("Start mock engine")
@dataclasses.dataclass(frozen=True)
class DatabaseEngine(Engine):
host: str
port: int
timeout_secs: int = 10
@override
def start_engine(self) -> None:
print("Start db engine:", self.host, self.port, self.timeout_secs)
class TireType(enum.Enum):
REGULAR = enum.auto()
SPORT = enum.auto()
SNOW = enum.auto()
@dataclasses.dataclass(frozen=True)
class Wheel:
tire_type: TireType
class Car(abc.ABC):
@abc.abstractmethod
def start_car(self) -> None: ...
@dataclasses.dataclass(frozen=True)
class DefaultCar(Car):
engine: Engine
wheel0: Wheel
wheel1: Wheel
wheel2: Wheel
wheel3: Wheel
@override
def start_car(self) -> None:
self.engine.start_engine()
#####################################################################
# Container Layer
#####################################################################
@container
class CommonContainer(Container):
env: str = "dev"
@container
class EngineContainer(Container):
common_ctr: CommonContainer
host: str
port: int = 8000
timeout_secs: int = 10
@cache
def engine(self) -> Engine:
return DatabaseEngine(self.host, self.port, self.timeout_secs)
@container
class WheelContainer(Container):
common_ctr: CommonContainer
tire_type: TireType = TireType.REGULAR
@call
def wheel(self) -> Wheel:
return Wheel(self.tire_type)
@container
class CarContainer(Container):
common_ctr: CommonContainer
engine_ctr: EngineContainer
wheel_ctr: WheelContainer
@cache
def car(self) -> Car:
return DefaultCar(
engine=self.engine_ctr.engine,
wheel0=self.wheel_ctr.wheel,
wheel1=self.wheel_ctr.wheel,
wheel2=self.wheel_ctr.wheel,
wheel3=self.wheel_ctr.wheel,
)
#####################################################################
# Application Layer
#####################################################################
def test_basic() -> None:
# Create container with the minimal number of required params
# (in this case, `EngineContainer` requires `host`).
ctr = CarContainer.create({EngineContainer: {"host": "abc"}})
# Child containers are cached by type across container hierarchy.
assert ctr.common_ctr is ctr.engine_ctr.common_ctr
assert ctr.common_ctr is ctr.wheel_ctr.common_ctr
# Get objects and check types.
engine = ctr.engine_ctr.engine
assert isinstance(engine, DatabaseEngine)
car = ctr.car
assert isinstance(car, DefaultCar)
assert ctr.car is ctr.car
See dilib/experimental/tests/test_core.py
for more.
Anatomy of a Container#
A container is now a regular dataclasses.dataclass(frozen=False, ...)
with these types of values:
Type |
Examples from Above |
Created |
---|---|---|
Field value |
|
Upon container creation (each container is created once per container type) |
|
|
Upon every value retrieval |
|
|
Upon first value retrieval |
Primitives, simple, and cheap-to-construct objects can be field values
More complex objects that need to be recreated at every retrieval should be
@call
property valuesMore complex objects that need to be created only once at first retrieval (e.g., they’re expensive to construct, they contain important state) should be
@cache
property values
Pros/Cons over Classic Syntax#
Pros
Classic syntax is in some ways a DSL, and this syntax is basically just a plain Python class with cached properties
Fewer concepts and easier-to-understand names (e.g., no separation between configs and containers, no exposed singleton/prototype/forward/etc. specs, no special collection specs, no mix-in hacks, no lazy kwargs, no
container.config
confusion, words likecache
instead ofSingleton
, anonymous specs are expressed with regular Python construction)Although classic syntax should work entirely with static type checkers, because we don’t need to “lie” to the type checker (e.g.,
dilib.Singleton(T) -> T
), we should have much more robust static checking across editor/checker contexts and timeInstead of a bag of global inputs (which can even collide), local inputs are explicitly linked to their types, but still easily available at the top level when creating a container
Force user to consider when the container value type should be abstract instead of concrete
Cons
More boilerplate code. Specifically, everything needs to be a proper Python property-style method (e.g., need to type
def ...
, can’t infer value type)
Pros/Cons over Simpler “Custom Container” Alternative#
You could implement something similar with a simpler custom container type:
@dataclasses.dataclass(frozen=True)
class EngineContainer:
common_ctr: CommonContainer
host: str
@functools.cached_property
def engine(self) -> Engine:
return DatabaseEngine(self.host)
Pros
Child containers get created automatically and also once per type (i.e., there’s only ever exactly one instance of
CommonContainer
in every parent container in which it’s referenced). We assume this is what you probably want to do. It’s all the more difficult when the number of containers increase, with overlapping common container instances that need to be shared across parent containers.Containers understand the hierarchy of parent/child containers and support dotted keys (e.g.,
ctr["x.y.z"]
), which means every object now has a globally-addressable name (“global” with respect to the root config)E.g., in CLIs with flags, you can have flags like
--name bar_ctr.xyz
that you pass to the root container directly (ctr[args.name]
)
We maintain self-consistency guarantee under perturbing because we don’t allow users to perturb after any object in the container hierarchy has retrieved a value
Cons
Need to learn new library
New Potential Pattern: Load Config from File#
If some of your config values come from a config file (e.g., JSON, YAML, TOML), you can use those values easily in the container:
import json
from pathlib import Path
import cattrs
from dilib.experimental import Container, container
def load_config(value: T | str | Path, cls: type[T]) -> T:
if isinstance(value, (str, Path)):
converter = cattrs.Converter()
data = json.load(Path(value).open("rb"))
return converter.structure(data, cls)
return value
@dataclasses.dataclass(frozen=True)
class EngineConfig:
host: str
port: int
@container
class EngineContainer(Container):
input_config: EngineConfig | Path
@cache
def config(self) -> EngineConfig:
return load_config(self.input_config)
@call
def timeout_secs(self) -> int:
return 10
@cache
def engine(self) -> Engine:
return DatabaseEngine(
self.config.host, self.config.port, timeout_secs=self.timeout_secs
)
ctr0 = FooContainer.create(
{EngineContainer: {"input_config": Path("config.json")}}
)
ctr1 = FooContainer.create(
{EngineContainer: {"input_config": EngineConfig("abc", 8000)}}
)
Note that, for now, if you want to perturb ctr.config
, you’ll have to provide
the entire object. But because you can’t perturb a container after getting a
value from it (it’s frozen on first get to guarantee self-consistency),
you won’t be able to perturb just one field of the config object easily.
(Perhaps we should add a set_with(func: Callable[[T], T])
method?)
Notes#
We no longer validate container params (the equivalent to global/local
inputs in classic). If the user wants this, they can validate
with a custom __post_init__()
in their container class.
(Our type validation logic was never advanced enough to understand complex
types anyway.)