Skip to content

Commit

Permalink
Update README and package config (#6)
Browse files Browse the repository at this point in the history
* Fix install_requires

* Update README.md
  • Loading branch information
yanovs authored Jan 5, 2023
1 parent 1faf592 commit 56967db
Show file tree
Hide file tree
Showing 2 changed files with 136 additions and 73 deletions.
207 changes: 135 additions & 72 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,28 +8,47 @@ Dependency injection (DI) library for python

## About DI

[Dependency injection](https://en.wikipedia.org/wiki/Dependency_injection) can be thought of as a
**software engineering pattern** as well as a **framework**. The goal is to develop objects in a more
[Dependency injection](https://en.wikipedia.org/wiki/Dependency_injection)
can be thought of as a **software engineering pattern**
as well as a **framework**. The goal is to develop objects in a more
composable and modular way.

The **pattern** is: when creating objects, always express what you depend on,
and let someone else give you those dependencies.
and let someone else give you those dependencies. (This is sometimes
referred to as the "Hollywood principle": "Don't call us; we'll call you.")

The **framework** is meant to ease the inevitable boilerplate that occurs when following this pattern, and dilib
is one such framework.
The **framework** is meant to ease the inevitable boilerplate
that occurs when following this pattern, and `dilib` is one such framework.

See the [Google Clean Code Talk about Dependency Injection](https://testing.googleblog.com/2008/11/clean-code-talks-dependency-injection.html).

## Installation

`dilib` is available on [PyPI](https://pypi.org/project/dilib/):

```bash
pip install dilib
```

## Quick Start

There are 3 major parts of this framework:
- `dilib.{Prototype,Singleton}`: Recipe on how to instantiate the object when needed. `dilib.Prototype`
creates a new instance per call, while `dilib.Singleton` ensures only 1 instance of the object exists per config field.
- `dilib.Config`: Nestable bag of params (types and values) that can be loaded, perturbed, and saved.
- `dilib.Container`: The object retriever--it's in charge of _materializing_ delayed specs that
are wired together by config into actual instances (plus caching, as necessary per the spec chosen).

- `dilib.{Prototype,Singleton}`: A recipe that describes how to instantiate
the object when needed later. `dilib.Prototype` indicates to the retriever
that a new instance should be created per retrieval,
while `dilib.Singleton` indicates only 1 instance of the object
should exist. (Both spec types inherit from `dilib.Spec`.)
- `dilib.Config`: Nestable bag of types and values, bound by specs,
that can be loaded, perturbed, and saved.
- `dilib.Container`: The object retriever--it's in charge of
_materializing_ the aforementioned delayed specs that
are wired together by config into actual instances
(plus caching, if indicated by the spec).

```python
from typing import Optional

import dilib


Expand All @@ -40,9 +59,9 @@ class Engine:

# An implementation of the engine API that makes network calls
class DBEngine(Engine):
def __init__(self, addr: str, user: str):
def __init__(self, addr: str, token: Optional[str] = None):
self.addr = addr
self.user = user
self.token = token


# An implementation of the engine API designed for testing
Expand All @@ -57,64 +76,84 @@ class Car:


class EngineConfig(dilib.Config):
db_addr = dilib.GlobalInput(str, "some-db-addr")
db_user = dilib.LocalInput(str)
adj_db_user = dilib.Prototype(lambda x: x + ".foo", x=db_user)
db_addr = dilib.GlobalInput(str, default="some-db-addr")

token_prefix = dilib.LocalInput(str)
token = dilib.Prototype(lambda x: x + ".bar", x=token_prefix)

# Objects depend on other objects via named aliases
engine: Engine = dilib.Singleton(DBEngine, db_addr, user=adj_db_user)
engine0: Engine = dilib.Singleton(DBEngine, db_addr, token=token)
# Or equivalently, if DBEngine used dilib.SingletonMixin:
# engine = dilib.DBEngine(db_addr, user=adj_db_user)
# engine0 = dilib.DBEngine(db_addr, token=token)

# Alternate engine spec
engine1: Engine = dilib.Singleton(DBEngine, db_addr)

# Forward spec resolution to the target spec
engine: Engine = dilib.Forward(engine0)


class CarConfig(dilib.Config):
# Configs depend on other configs via types. Here, CarConfig depends on EngineConfig
engine_config = EngineConfig(db_user="user")
# Configs depend on other configs via types.
# Here, CarConfig depends on EngineConfig.
engine_config = EngineConfig(foo_prefix="baz")

car = dilib.Singleton(Car, engine_config.engine)


# Get instance of config (with global input value set)
car_config: CarConfig = dilib.get_config(CarConfig, db_addr="some-other-db-addr")
car_config: CarConfig = dilib.get_config(
CarConfig, db_addr="some-other-db-addr"
)

# Perturb here as you'd like. E.g.:
car_config.engine_config.Engine = MockEngine()
car_config.engine_config.Engine = dilib.Singleton(MockEngine)

# Pass config to a container that can get and cache objs for you
# Pass config to a container
container: dilib.Container[CarConfig] = dilib.get_container(car_config)

# Retrieve objects from container (some of which are cached inside)
assert container.config.engine_config.db_addr == "some-other-db-addr"
assert isinstance(container.config.engine_config.engine, MockEngine)
assert isinstance(container.config.car, Car)
assert container.config.car is container.car # Because it's a Singleton
```

Notes:
- `Car` *takes in* an `Engine` instead of making or getting one within itself.
- For this to work, `Car` cannot make any assumptions about *what kind* of `Engine` it received.
Different engines have different constructor params
but have the [same API and semantics](https://en.wikipedia.org/wiki/Liskov_substitution_principle).
- In order to take advantage of typing (e.g., mypy, PyCharm auto-complete), use `dilib.get_config(...)`
and `container.config`, which are type-safe alternatives. Note also how we set the `engine` config type
to the base class `Engine`--this way, clients of the config are abstracted away from which implementation
is currently configured.
- `Car` *takes in* an `Engine` via its constructor
(known as "constructor injection"),
instead of making or getting one within itself.
- For this to work, `Car` cannot make any assumptions about
*what kind* of `Engine` it received. Different engines have different
constructor params but have the [same API and semantics](https://en.wikipedia.org/wiki/Liskov_substitution_principle).
- In order to take advantage of typing (e.g., `mypy`, PyCharm auto-complete),
use `dilib.get_config(...)` and `container.config`,
which are type-safe alternatives to `CarConfig().get(...)` and
direct `container` access. Note also how we set the `engine` config field type
to the base class `Engine`--this way, clients of the config are
abstracted away from which implementation is currently configured.

### API Overview

- `dilib.Config`: Inherit from this to specify your objects and params
- `config = dilib.get_config(ConfigClass, **global_inputs)`: Instantiate config object
- `config = dilib.get_config(ConfigClass, **global_inputs)`: Instantiate
config object
- Alternatively: `config = ConfigClass().get(**global_inputs)`
- `container = dilib.get_container(config)`: Pass the config when instantiating the container
- `container = dilib.get_container(config)`: Instantiate container object
by passing in the config object
- Alternatively: `container = dilib.Container(config)`
- `container.config.x_config.y_config.z`: Get the instantianted object
- Alternatively: `container.x_config.y_config.z`, or even `container["x_config.y_config.z"]`
- Alternatively: `container.x_config.y_config.z`,
or even `container["x_config.y_config.z"]`

Specs:

- `dilib.Object`: Pass-through already-instantiated object
- `dilib.Forward`: Forward to a different config field
- `dilib.Prototype`: Instantiate a new object at each container get, per the spec
- `dilib.Singleton`: Instantiate and cache object, per the spec
- `dilib.Singleton{Tuple,List,Dict}`: Special helpers to ease collections of specs. E.g.:
- `dilib.Prototype`: Instantiate a new object at each container retrieval
- `dilib.Singleton`: Instantiate and cache object at each container retrieval
- `dilib.Singleton{Tuple,List,Dict}`: Special helpers to ease
collections of specs. E.g.:

```python
import dataclasses
Expand All @@ -139,12 +178,13 @@ class CollectionsConfig(dilib.Config):
xy_dict0 = dilib.SingletonDict(x=x, y=y)
xy_dict1 = dilib.SingletonDict({"x": x, "y": y})
xy_dict2 = dilib.SingletonDict({"x": x, "y": y}, z=z)

# You can also build a partial kwargs dict that can be re-used and combined downstream

# You can also build a partial kwargs dict that can be
# re-used and combined downstream
partial_kwargs = dilib.SingletonDict(x=x, y=y)
values0 = dilib.Singleton(ValuesWrapper, __lazy_kwargs=partial_kwargs)
values1 = dilib.Singleton(ValuesWrapper, z=4, __lazy_kwargs=partial_kwargs)


config = dilib.get_config(CollectionsConfig)
container = dilib.get_container(config)
Expand All @@ -156,71 +196,94 @@ assert container.config.xy_dict1 == {"x": 1, "y": 2}
assert container.config.xy_dict2 == {"x": 1, "y": 2, "z": 3}
```

## Compare with Other DI Frameworks
## Comparisons with Other DI Frameworks

### pinject

A prominent DI library in python is [pinject](https://github.com/google/pinject).
A prominent DI library in
python is [`pinject`](https://github.com/google/pinject).

#### Advantages of dilib

- Focus on simplicity. E.g.:
- `foo = "a"` rather than `bind("foo", to_instance="a")`.
- `foo = dilib.Object("a")` rather than `bind("foo", to_instance="a")`.
- Child configs look like just another field on the config.
- Getting is via *names* rather than *classes*.
- In pinject, the equivalent of `ctr.__getattr__()` takes a class (like `Car`) rather than a config address.
- No implicit wiring: No assumptions are made about aligning arg names with config params.
- Granted, pinject does have an explicit mode, but the framework's default state is implicit.
- The explicit wiring in dilib configs obviates the need for complications like
[inject decorators](https://github.com/google/pinject#safety)
- In `pinject`, the equivalent of container attr access
takes a class (like `Car`) rather than a config address.
- No implicit wiring: No assumptions are made about aligning
arg names with config params.
- Granted, `pinject` does have an explicit mode,
but the framework's default state is implicit.
- The explicit wiring in dilib configs obviates the need
for complications like [inject decorators](https://github.com/google/pinject#safety)
and [annotations](https://github.com/google/pinject#annotations).
- Minimal or no pollution of objects: Objects are not aware of the DI framework. The only exception is
if you want the IDE autocompletion to work in configs (e.g., `car = Car(engine=...)`), you have
to inherit from, e.g., `dilib.SingletonMixin`, but this is completely optional.
In pinject, on the other hand, one is required to decorate with `@pinject.inject()` in some circumstances.
- Minimal or no pollution of objects: Objects are not aware of
the DI framework. The only exception is:
if you want the IDE autocompletion to work when wiring up configs in an
environment that does not support `ParamSpec`
(e.g., `car = Car(engine=...)`), you have
to inherit from, e.g., `dilib.SingletonMixin`. But this is completely
optional; in `pinject`, on the other hand, one is required to
decorate with `@pinject.inject()` in some circumstances.

### dependency-injector

Another prominent DI library in python is [dependency-injector](https://github.com/ets-labs/python-dependency-injector).
Another prominent DI library in python is [`dependency-injector`](https://github.com/ets-labs/python-dependency-injector).

#### Advantages of dilib
- dilib discourages use of class-level state by not supporting it
(that is, `dilib.Container` is equivalent to `dependency_injector.containers.DynamicContainer`).
- Cleaner separation between "config" and "container" (dependency-injector conflates the two).
- Easy-to-use perturbing with simple `config.x = new_value` syntax.
- Easier to nest configs via config locator pattern.
- Child configs are typed instead of relying on `DependenciesContainer` stub (which aids in IDE auto-complete).
- Easier-to-use global input configuration.
- Written in native python for more transparency.

- `dilib` discourages use of class-level state by not supporting it
(that is, `dilib.Container` is equivalent to
`dependency_injector.containers.DynamicContainer`)
- Cleaner separation between "config" and "container"
(dependency-injector conflates the two)
- Easy-to-use perturbing with simple `config.x = new_value` syntax
- Easier to nest configs via config locator pattern
- Child configs are typed instead of relying on
`DependenciesContainer` stub (which aids in IDE auto-complete)
- Easier-to-use global input configuration
- Written in native python for more transparency

## Design

### Prevent Pollution of Objects

The dependency between the DI config and the actual objects in the object graph should be one way:
the DI config depends on the object graph types and values. This keeps the objects clean of
The dependency between the DI config and the actual objects in the
object graph should be one way:
the DI config depends on the object graph types and values.
This keeps the objects clean of
particular decisions made by the DI framework.

(dilib offers optional mixins that violate this decision for users that want to favor the typing and
(`dilib` offers optional mixins that violate this decision
for users that want to favor the typing and
auto-completion benefits of using the object types directly.)

### Child Configs are Singletons by Type

In dilib, when you set a child config on a config object, you're not actually instantiating the child config.
Rather, you're creating a spec that will be instantiated when the root config's `.get()` is called.
In `dilib`, when you set a child config on a config object,
you're not actually instantiating the child config.
Rather, you're creating a spec that will be instantiated
when the root config's `.get()` is called.
This means that the config instances are singletons by type
(unlike the actual objects specified in the config, which are by alias).
It would be cleaner to create instances of common configs and pass them through to other configs
(that's what DI is all about!). However, the decision was made to not allow this because this would make
building up configs almost as complicated as building up the actual object graph users are interested in
It would be cleaner to create instances of common configs and
pass them through to other configs
(that's what DI is all about, after all!). However, the decision was made
to not allow this because this would make
building up configs almost as complicated as building up the
actual object graph users are interested in
(essentially, the user would be engaged in an abstract meta-DI problem).
As such, all references to the same config type are automatically resolved to the same instance,
As such, all references to the same config type are
automatically resolved to the same instance,
at the expense of some flexibility and directness.
The upside, however, is that it's much easier to create nested configs,
which means users can get to designing the actual object graph quicker.

### Factories for Dynamic Objects

If you need to configure objects dynamically (e.g., check db value to resolve what type to use,
If you need to configure objects dynamically
(e.g., check db value to resolve what type to use,
set config keys based on another value), consider a factory pattern like:

```python
Expand All @@ -230,12 +293,12 @@ import dilib
class Foo:
@property
def value(self) -> int:
...
raise NotImplementedError


class FooFactory:
def get_foo(self) -> Foo:
...
raise NotImplementedError


class FooClient:
Expand Down
2 changes: 1 addition & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ classifiers =
packages = find:
python_requires = >=3.7
install_requires =
typing_extensions >= 4.4.0
setup_requires =
setuptools_scm[toml] >= 3.4

Expand All @@ -50,7 +51,6 @@ testing =
pytest-cov >=2, <3
pyright >= 1.1.284
tox >= 3.27.1
typing_extensions >= 4.4.0

[options.package_data]
dilib = py.typed
Expand Down

0 comments on commit 56967db

Please sign in to comment.