Skip to content

Commit 0f75a14

Browse files
authored
Merge pull request #2 from tylersuehr7/feature/providers
feat: Injection Factories
2 parents 7b20875 + c1e7f40 commit 0f75a14

49 files changed

Lines changed: 1437 additions & 115 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,25 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8-
## [0.1.0] — Unreleased
8+
## [0.3.0] — Unreleased
9+
10+
### Added
11+
12+
- `@provides` decorator for conditional/factory-based bindings. Use when the
13+
concrete implementation of an interface must be chosen at runtime (feature
14+
flags, environment, etc.). Factories support dependency injection via
15+
annotated parameters and respect `Scope.SINGLETON`, `TRANSIENT`, and
16+
`THREAD` for invocation frequency. Factories are evaluated eagerly at
17+
`container.initialize()`; failures raise the new `ProviderResolutionError`.
18+
- `ProviderResolutionError` exception type for `@provides` factory failures.
19+
- All three example apps (Django, FastAPI, Flask) now demo `@provides` via a
20+
`GREETER_FORMATTER_STYLE` env var that swaps the name formatter at boot.
21+
22+
### Changed
23+
24+
- Lowered minimum Python version to 3.12 (was 3.13).
25+
26+
## [0.2.0]
927

1028
### Fixed
1129

@@ -21,11 +39,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2139
injector approach did not affect transitive dependencies of parent-owned
2240
singletons, so overrides silently had no effect on deeper graphs.
2341

42+
## [0.1.0]
43+
2444
### Added
2545

26-
- `examples/django_demo/` — minimal Django project demonstrating ports/adapters,
27-
`@injectable`, `AutowiredAppConfig`, `container.get()` in views, and the
28-
three testing patterns (pure unit, full wiring, override).
46+
- `examples/django_demo/`, `examples/fastapi_demo/`, `examples/flask_demo/`
47+
minimal projects demonstrating ports/adapters, `@injectable`, framework
48+
integration, and the three testing patterns (pure unit, full wiring, override).
2949
- `django_autowired.inspect` module with `BindingReport`, `report()`, and
3050
renderers for `table`, `tree`, `json`, and `mermaid` output.
3151
- `python -m django_autowired inspect` CLI that scans packages and prints
@@ -53,4 +73,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
5373
`ContainerAlreadyInitializedError`, `DuplicateBindingError`,
5474
`BackendNotInstalledError`, `UnresolvableTypeError`.
5575

76+
[0.3.0]: https://github.com/tylersuehr7/django-autowired/releases/tag/v0.3.0
77+
[0.2.0]: https://github.com/tylersuehr7/django-autowired/releases/tag/v0.2.0
5678
[0.1.0]: https://github.com/tylersuehr7/django-autowired/releases/tag/v0.1.0

docs/guide/injectable.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
# `@injectable`
22

3-
The `@injectable` decorator is the only user-facing touchpoint for registering
3+
The `@injectable` decorator is the primary user-facing touchpoint for registering
44
a class with the container.
55

6+
!!! tip "Need conditional binding?"
7+
If the concrete implementation must be chosen at runtime — for example,
8+
based on a feature flag — see [`@provides`](provides.md) instead.
9+
610
## Signature
711

812
```python

docs/guide/provides.md

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
# `@provides`
2+
3+
`@injectable` covers 99% of dependency wiring. Use `@provides` when the concrete
4+
implementation of an interface must be chosen **at runtime** — typically based
5+
on a feature flag, environment, or config rule.
6+
7+
## When to reach for `@provides`
8+
9+
- A composite/strategy binding where the selected implementation depends on
10+
runtime state (feature flag, region, tenant, environment).
11+
- A third-party SDK client that needs non-trivial construction and can't be
12+
expressed as a single `@injectable` class.
13+
14+
If you just need to register a class, use `@injectable`. If you need a manual
15+
binding for a value that never changes (e.g. a constant config object), prefer
16+
`extra_modules`.
17+
18+
## Basic usage
19+
20+
```python
21+
from abc import ABC, abstractmethod
22+
from django_autowired import injectable, provides
23+
24+
25+
class IPaymentGateway(ABC):
26+
@abstractmethod
27+
def charge(self, amount: int) -> str: ...
28+
29+
30+
class StripeV1Gateway(IPaymentGateway):
31+
def charge(self, amount: int) -> str:
32+
return f"stripe-v1:{amount}"
33+
34+
35+
class StripeV2Gateway(IPaymentGateway):
36+
def charge(self, amount: int) -> str:
37+
return f"stripe-v2:{amount}"
38+
39+
40+
@provides(bind_to=IPaymentGateway)
41+
def payment_gateway(flags: FeatureFlags) -> IPaymentGateway:
42+
if flags.is_enabled("use_stripe_v2"):
43+
return StripeV2Gateway()
44+
return StripeV1Gateway()
45+
```
46+
47+
`FeatureFlags` is resolved from the container just like any `@injectable`
48+
dependency — provider functions support full dependency injection.
49+
50+
## Signature
51+
52+
```python
53+
@provides(bind_to: type, scope: Scope = Scope.SINGLETON)
54+
```
55+
56+
- `bind_to`**required**. The interface/type the factory's return value binds
57+
to. A provider without `bind_to` is rejected at decoration time.
58+
- `scope` — Lifecycle scope the container applies to factory invocation.
59+
60+
## Scope semantics
61+
62+
`@provides` is a factory — the framework owns the scope around it:
63+
64+
| Scope | Factory invoked |
65+
| --- | --- |
66+
| `SINGLETON` (default) | Once per container lifetime. Result cached. |
67+
| `TRANSIENT` | On every `container.get()` call. |
68+
| `THREAD` | Once per thread. Result cached per-thread. |
69+
70+
This matches Spring `@Bean` / Dagger `@Provides` semantics. Your factory does
71+
not manage caching — the framework does.
72+
73+
## Factory injection
74+
75+
Factory parameters are resolved from the container. All parameters **must be
76+
type-annotated**:
77+
78+
```python
79+
@provides(bind_to=IPaymentGateway)
80+
def payment_gateway(
81+
flags: FeatureFlags,
82+
cfg: StripeConfig,
83+
logger: ILogger,
84+
) -> IPaymentGateway:
85+
if flags.is_enabled("use_stripe_v2"):
86+
return StripeV2Gateway(cfg, logger)
87+
return StripeV1Gateway(cfg, logger)
88+
```
89+
90+
A factory parameter without an annotation raises `TypeError` at build time.
91+
92+
## One binding per target
93+
94+
Whether you use `@injectable` or `@provides`, each interface can be bound
95+
**exactly once**. Any combination of:
96+
97+
- two `@injectable` classes with the same `bind_to`
98+
- two `@provides` functions with the same `bind_to`
99+
- one `@injectable` + one `@provides` with the same `bind_to`
100+
101+
…raises `DuplicateBindingError`. This is a feature: predictable, deterministic
102+
resolution is a core guarantee of `django-autowired`.
103+
104+
If you need multiple candidates selected at runtime, express the conditional
105+
logic **inside a single** `@provides` function that returns the correct
106+
instance.
107+
108+
## Eager evaluation
109+
110+
Every `@provides` factory runs once during `container.initialize()` to catch
111+
errors at boot:
112+
113+
- Factory raises → `ProviderResolutionError` wraps the exception.
114+
- Factory has unresolvable dependencies → `ProviderResolutionError`.
115+
116+
For `SINGLETON` scope this eager invocation produces the cached instance. For
117+
`TRANSIENT` and `THREAD` scope the instance is discarded — the eager run is
118+
pure verification. This matches the library's "fail loudly at boot" principle.
119+
120+
## What `@provides` cannot do
121+
122+
- **Cannot decorate a class.** `@provides` only decorates functions.
123+
- **Cannot be called without `bind_to`.** Raises `TypeError` immediately.
124+
- **Cannot chain/fallback.** Multiple providers for the same target are an
125+
error, not an ordered list.
126+
127+
## FAQ
128+
129+
**Q: Should my factory return a class or an instance?**
130+
An instance. `@provides` is a factory. The framework handles scope/caching.
131+
132+
**Q: What if the factory needs to construct something complex with many deps?**
133+
Declare those deps as factory parameters — they are resolved from the container
134+
just like `@injectable` constructor parameters.
135+
136+
**Q: Can a `@provides` factory depend on another `@provides` factory?**
137+
Yes. Dependency resolution across factories is handled by the backend.

docs/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ That's the whole integration. No `binder.bind()`. No `Module` subclasses.
9393
## Next steps
9494

9595
- **[`@injectable`](guide/injectable.md)** — the decorator in depth
96+
- **[`@provides`](guide/provides.md)** — conditional/factory-based bindings
9697
- **[Scopes](guide/scopes.md)** — singleton, transient, thread
9798
- **[Scanning](guide/scanning.md)** — how packages get discovered
9899
- **[Django integration](integrations/django.md)** — the full tour

examples/django_demo/greetings/adapters/out_/config/__init__.py

Whitespace-only changes.
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
"""Runtime configuration for the name-formatter selection.
2+
3+
Reads ``GREETER_FORMATTER_STYLE`` from the environment so the demo can
4+
switch implementations without code changes:
5+
6+
GREETER_FORMATTER_STYLE=upper python main.py
7+
"""
8+
9+
import os
10+
11+
from django_autowired import injectable
12+
13+
14+
@injectable()
15+
class FormatterConfig:
16+
"""Holds the runtime-selected formatter style.
17+
18+
The value of ``style`` is consulted by the ``@provides`` factory in
19+
``services.name_formatter_provider`` to pick the concrete formatter.
20+
"""
21+
22+
def __init__(self) -> None:
23+
self.style: str = os.getenv("GREETER_FORMATTER_STYLE", "title").lower()

examples/django_demo/greetings/adapters/out_/services/friendly_greeter.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
"""Greeter service — the port implementation you actually call into."""
22

33
from django_autowired import injectable
4-
54
from greetings.domain.ports.services.greeter import IGreeter
65
from greetings.domain.ports.services.name_formatter import INameFormatter
76

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""``@provides`` factory that picks the name formatter at runtime.
2+
3+
Demonstrates two things:
4+
5+
1. **Conditional binding** — the concrete ``INameFormatter`` implementation
6+
is chosen from a runtime config value rather than hard-wired at boot.
7+
2. **Factory injection** — the factory itself receives ``FormatterConfig``
8+
from the container, just like an ``@injectable`` constructor would.
9+
10+
Try it::
11+
12+
GREETER_FORMATTER_STYLE=upper python main.py
13+
GREETER_FORMATTER_STYLE=title python main.py # default
14+
"""
15+
16+
from django_autowired import provides
17+
from greetings.adapters.out_.config.formatter_config import FormatterConfig
18+
from greetings.adapters.out_.services.title_case_name_formatter import (
19+
TitleCaseNameFormatter,
20+
)
21+
from greetings.adapters.out_.services.upper_case_name_formatter import (
22+
UpperCaseNameFormatter,
23+
)
24+
from greetings.domain.ports.services.name_formatter import INameFormatter
25+
26+
27+
@provides(bind_to=INameFormatter)
28+
def name_formatter(config: FormatterConfig) -> INameFormatter:
29+
"""Pick the formatter based on ``FormatterConfig.style``."""
30+
if config.style == "upper":
31+
return UpperCaseNameFormatter()
32+
return TitleCaseNameFormatter()

examples/django_demo/greetings/adapters/out_/services/title_case_name_formatter.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1-
"""Title-case name formatter — outbound adapter for ``INameFormatter``."""
1+
"""Title-case name formatter — default implementation of ``INameFormatter``.
22
3-
from django_autowired import injectable
3+
This class is intentionally **not** decorated with ``@injectable``. It is
4+
selected by the ``@provides`` factory in ``name_formatter_provider`` based
5+
on the runtime value of ``FormatterConfig.style``.
6+
"""
47

58
from greetings.domain.ports.services.name_formatter import INameFormatter
69

710

8-
@injectable(bind_to=INameFormatter)
911
class TitleCaseNameFormatter(INameFormatter):
1012
"""Capitalizes the first letter of each word."""
1113

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
"""Upper-case name formatter — alternative implementation of ``INameFormatter``.
2+
3+
This class is intentionally **not** decorated with ``@injectable``. It is
4+
selected by the ``@provides`` factory in ``name_formatter_provider`` based
5+
on the runtime value of ``FormatterConfig.style``.
6+
"""
7+
8+
from greetings.domain.ports.services.name_formatter import INameFormatter
9+
10+
11+
class UpperCaseNameFormatter(INameFormatter):
12+
"""Upper-cases the entire name."""
13+
14+
def format(self, name: str) -> str:
15+
return name.strip().upper()

0 commit comments

Comments
 (0)