diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml
index 35e6bfdc..98acf7ab 100644
--- a/docs/mkdocs.yml
+++ b/docs/mkdocs.yml
@@ -15,6 +15,7 @@ nav:
- Resource Management: resources.md
- Interfaces: interfaces.md
- Function Injection: function_injection.md
+ - Interactive Graph: interactive_graph.md
- Advanced Patterns:
- Reusable Bundles: reusable_bundles.md
- Conditional Registration: conditional_registration.md
@@ -31,6 +32,7 @@ nav:
- Web Frameworks:
- Django:
- Django Integration: integrations/django/index.md
+ - Interactive Graph: integrations/django/interactive_graph.md
- Setup and Installation: integrations/django/setup.md
- Inject in Views: integrations/django/view_injection.md
- Request-Time Injection: integrations/django/request_time_injection.md
diff --git a/docs/pages/img/pet_store_demo.gif b/docs/pages/img/pet_store_demo.gif
new file mode 100644
index 00000000..e6aff217
Binary files /dev/null and b/docs/pages/img/pet_store_demo.gif differ
diff --git a/docs/pages/index.md b/docs/pages/index.md
index 0d0cb8c1..23d94e49 100644
--- a/docs/pages/index.md
+++ b/docs/pages/index.md
@@ -3,6 +3,7 @@
Type-driven dependency injection for Python. Wireup is battle-tested in production, thread-safe, no-GIL (PEP 703) ready,
and designed to fail fast: **if the container starts, it works**.
+


@@ -12,6 +13,20 @@ and designed to fail fast: **if the container starts, it works**.
+---
+
+!!! interactive-graph "Interactive Graph"
+
+ Turn your container into an interactive dependency graph. Explore routes, functions, services, factories,
+ configuration, and scopes in a live page with search, grouping, and dependency tracing.
+
+ Learn how it works and explore it on an demo pet store app:
+
+ [Documentation](interactive_graph.md){ .md-button target="_blank" }
+ [:octicons-arrow-right-24: Live Demo](wireup_graph/pet_store.html){ .md-button .md-button--primary target="_blank" }
+
+---
+
- :material-shield-check:{ .lg .middle } __Correct by Default__
diff --git a/docs/pages/integrations/django/index.md b/docs/pages/integrations/django/index.md
index c5da9903..e1fd36d8 100644
--- a/docs/pages/integrations/django/index.md
+++ b/docs/pages/integrations/django/index.md
@@ -46,6 +46,16 @@ Wireup integrates with Django at both request scope and application scope. Use `
`@inject_app` for non-request entry points, while keeping services as ordinary Python classes that are easy to test and
reuse.
+!!! interactive-graph "Interactive Graph"
+
+ Turn your container into an interactive dependency graph. Explore routes, functions, services, factories,
+ configuration, and scopes in a live page with search, grouping, and dependency tracing.
+
+ Django can expose it with a small explicit view, and the same renderer is available for other apps too.
+
+ [Documentation](interactive_graph.md){ .md-button }
+ [:octicons-arrow-right-24: Live Demo](../../wireup_graph/pet_store.html){ .md-button .md-button--primary target="_blank" }
+
## Quick Start
This is the shortest path to a working endpoint with explicit injection.
@@ -119,6 +129,7 @@ See [What Wireup Validates](../../what_wireup_validates.md) for the full rules.
## Detailed Guides
- [Django Setup and Installation](setup.md): installation, middleware placement, and settings/config integration.
+- [Interactive Graph](interactive_graph.md): expose the graph page in Django and gate it by environment.
- [Inject in Views](view_injection.md): core Django, DRF, Ninja, forms, and request-scoped patterns.
- [Request-Time Injection](request_time_injection.md): reusable decorators, middleware entry points, and direct container access.
- [App-Level Injection](app_injection.md): management commands, Django 6 background tasks, signals, checks, and scripts with `@inject_app`.
diff --git a/docs/pages/integrations/django/interactive_graph.md b/docs/pages/integrations/django/interactive_graph.md
new file mode 100644
index 00000000..14f3907d
--- /dev/null
+++ b/docs/pages/integrations/django/interactive_graph.md
@@ -0,0 +1,70 @@
+---
+description: Wireup interactive dependency graph for Django: expose the graph page, understand what it shows, and gate it by environment.
+---
+
+## Enable The Interactive Graph
+
+Create a small view that renders the graph page from the application container:
+
+```python title="mysite/wireup_graph.py"
+from django.http import HttpRequest, HttpResponse
+from wireup.integration.django import get_app_container
+from wireup.renderer.full_page import GraphEndpointOptions, render_graph_page
+
+
+def wireup_graph(_request: HttpRequest) -> HttpResponse:
+ return HttpResponse(
+ render_graph_page(
+ get_app_container(),
+ title="My Django App - Wireup Graph",
+ options=GraphEndpointOptions(base_module="mysite"),
+ ),
+ content_type="text/html",
+ )
+```
+
+Then mount it in your URLconf:
+
+```python title="mysite/urls.py"
+from django.conf import settings
+from django.urls import path
+
+from mysite.wireup_graph import wireup_graph
+
+urlpatterns = [
+ # ...your existing routes...
+]
+
+if settings.DEBUG:
+ urlpatterns.append(path("_wireup", wireup_graph))
+```
+
+Then open `http://127.0.0.1:8000/_wireup`.
+
+## What It Shows
+
+The graph can include:
+
+- services and factories
+- configuration nodes
+- singleton, scoped, and transient lifetimes
+- discovered Django consumers that the graph can infer from the loaded app state
+
+## Environment Gating
+
+Because the graph exposes internal implementation details, it is best treated as a development tool.
+
+A typical pattern is to mount the route only in local or non-production environments:
+
+```python title="mysite/urls.py"
+if settings.DEBUG:
+ urlpatterns.append(path("_wireup", wireup_graph))
+```
+
+If you need stricter control, omit the route entirely in production or protect it like any other internal debug page.
+
+## Related
+
+- [Django Integration](index.md)
+- [General Interactive Graph Docs](../../interactive_graph.md)
+- [Django Request-Time Injection](request_time_injection.md)
diff --git a/docs/pages/integrations/fastapi/index.md b/docs/pages/integrations/fastapi/index.md
index cfb6adfd..fa5db9cf 100644
--- a/docs/pages/integrations/fastapi/index.md
+++ b/docs/pages/integrations/fastapi/index.md
@@ -38,12 +38,16 @@ description: FastAPI dependency injection with Wireup: type-safe DI for routes,
-!!! tip "Migrating from FastAPI Depends?"
+!!! interactive-graph "Interactive Graph"
- Evaluating Wireup for your FastAPI project? Check out the migration page which includes common pain points with FastAPI Depends and how to solve them with Wireup as well
- as a low-friction migration path:
+ Turn your container into an interactive dependency graph. Explore routes, functions, services, factories,
+ configuration, and scopes in a live page with search, grouping, and dependency tracing.
- [Migrate from FastAPI Depends to Wireup](../../migrate_to_wireup/fastapi_depends.md).
+
+ Learn how it works and explore it on an demo pet store app:
+
+ [Documentation](interactive_graph.md){ .md-button target="_blank" }
+ [:octicons-arrow-right-24: Live Demo](../../wireup_graph/pet_store.html){ .md-button .md-button--primary target="_blank" }
## Quick Start
@@ -103,6 +107,14 @@ See [What Wireup Validates](../../what_wireup_validates.md) for the full rules.
- [FastAPI Testing](testing.md): `TestClient` lifespan usage, overrides, and request-lifecycle tests.
- [Troubleshooting](troubleshooting.md): common setup/runtime errors and fast fixes.
+!!! tip "Migrating from FastAPI Depends?"
+
+ Evaluating Wireup for your FastAPI project? Check out the migration page which includes common pain points with FastAPI Depends and how to solve them with Wireup as well
+ as a low-friction migration path:
+
+ [Migrate from FastAPI Depends to Wireup](../../migrate_to_wireup/fastapi_depends.md).
+
+
## API Reference
- [fastapi_integration](../../class/fastapi_integration.md)
diff --git a/docs/pages/integrations/flask/index.md b/docs/pages/integrations/flask/index.md
index 10b36812..54b83f4b 100644
--- a/docs/pages/integrations/flask/index.md
+++ b/docs/pages/integrations/flask/index.md
@@ -18,6 +18,16 @@ Dependency injection for Flask is available in the `wireup.integration.flask` mo
+!!! interactive-graph "Interactive Graph"
+
+ Turn your container into an interactive dependency graph. Explore routes, functions, services, factories,
+ configuration, and scopes in a live page with search, grouping, and dependency tracing.
+
+ Learn how it works and explore it on an demo pet store app:
+
+ [Documentation](interactive_graph.md){ .md-button target="_blank" }
+ [:octicons-arrow-right-24: Live Demo](../../wireup_graph/pet_store.html){ .md-button .md-button--primary target="_blank" }
+
### Initialize the integration
First, [create a sync container](../../container.md) with your dependencies:
@@ -46,6 +56,18 @@ Then initialize the integration by calling `wireup.integration.flask.setup` afte
wireup.integration.flask.setup(container, app)
```
+To expose the interactive dependency graph page at `/_wireup`, enable the graph endpoint during setup:
+
+```python
+from wireup.integration.flask import GraphEndpointOptions
+
+wireup.integration.flask.setup(
+ container,
+ app,
+ add_graph_endpoint=True,
+)
+```
+
### Inject in Flask Views
To inject dependencies, add the type to the view's signature and annotate with `Injected[T]` or
diff --git a/docs/pages/interactive_graph.md b/docs/pages/interactive_graph.md
new file mode 100644
index 00000000..3bbb0aca
--- /dev/null
+++ b/docs/pages/interactive_graph.md
@@ -0,0 +1,93 @@
+# Interactive Graph
+
+!!! interactive-graph "Interactive Graph"
+
+ Turn your container into an interactive dependency graph. Explore routes, functions, services, factories,
+ configuration, and scopes in a live page with search, grouping, and dependency tracing.
+
+ Learn how it works and explore it on an demo pet store app:
+
+ [:octicons-arrow-right-24: Live Demo](wireup_graph/pet_store.html){ .md-button .md-button--primary target="_blank" }
+
+Wireup can render your dependency graph as an interactive page, including:
+
+- routes and injected functions for selected frameworks
+- services and factories
+- configuration nodes
+- singleton, scoped, and transient lifetimes
+
+This is one of the fastest ways to understand how a real application is wired together without relying on a static dump
+or a handwritten diagram.
+
+## Preview
+
+
+
+
+## Automatic with FastAPI and Flask
+
+FastAPI and Flask can expose the graph page automatically at `/_wireup`.
+
+### FastAPI
+
+```python
+from wireup.integration.fastapi import GraphEndpointOptions
+import wireup.integration.fastapi
+
+wireup.integration.fastapi.setup(
+ container,
+ app,
+ add_graph_endpoint=True,
+)
+```
+
+### Flask
+
+```python
+from wireup.integration.flask import GraphEndpointOptions
+import wireup.integration.flask
+
+wireup.integration.flask.setup(
+ container,
+ app,
+ add_graph_endpoint=True,
+)
+```
+
+## Use in other frameworks
+
+If your framework does not have automatic graph-page setup, you can still generate the graph yourself from Python.
+
+### 1. Build graph data
+
+```python
+from wireup.renderer.core import GraphOptions, to_graph_data
+
+graph_data = to_graph_data(
+ container, # or get_app_container() if the integration provides it
+ options=GraphOptions(base_module="myapp"),
+)
+```
+
+### 2. Render it as HTML
+
+```python
+from wireup.renderer.full_page import full_page_renderer
+
+html = full_page_renderer(graph_data, title="My App - Wireup Graph")
+```
+
+### 3. Return it from a route
+
+```python
+@app.get("/_wireup")
+def wireup_graph():
+ graph_data = to_graph_data(container, options=GraphOptions(base_module="myapp"))
+ html = full_page_renderer(graph_data, title="My App - Wireup Graph")
+
+ return HTMLResponse(html)
+```
+
+
+!!! tip
+ Make sure to permission this endpoint appropriately in production since it exposes internal implementation details. You can disable it by omitting `add_graph_endpoint=True` or by not registering the route at all.
diff --git a/docs/pages/packaging_injectables.md b/docs/pages/packaging_injectables.md
index f7582567..59b4d537 100644
--- a/docs/pages/packaging_injectables.md
+++ b/docs/pages/packaging_injectables.md
@@ -73,7 +73,6 @@ class Cache(Protocol): ...
@injectable(as_type=Cache)
class RedisCache: ...
-
@injectable(as_type=Cache)
class MemcachedCache: ...
diff --git a/docs/pages/stylesheets/extra.css b/docs/pages/stylesheets/extra.css
index 92b9efce..0e99ee0e 100644
--- a/docs/pages/stylesheets/extra.css
+++ b/docs/pages/stylesheets/extra.css
@@ -29,4 +29,25 @@ body[data-md-color-scheme="slate"] .grid.cards>ul>li {
.color-django {
color: #0C4B33 !important;
-}
\ No newline at end of file
+}
+
+:root {
+ --md-admonition-icon--interactive-graph: url('data:image/svg+xml;charset=utf-8, ');
+}
+
+.md-typeset .admonition.interactive-graph,
+.md-typeset details.interactive-graph {
+ border-color: #007acc;
+}
+
+.md-typeset .interactive-graph > .admonition-title,
+.md-typeset .interactive-graph > summary {
+ background-color: rgba(0, 122, 204, 0.12);
+}
+
+.md-typeset .interactive-graph > .admonition-title::before,
+.md-typeset .interactive-graph > summary::before {
+ background-color: #007acc;
+ -webkit-mask-image: var(--md-admonition-icon--interactive-graph);
+ mask-image: var(--md-admonition-icon--interactive-graph);
+}
diff --git a/docs/pages/wireup_graph/pet_store.html b/docs/pages/wireup_graph/pet_store.html
new file mode 100644
index 00000000..3b1c5317
--- /dev/null
+++ b/docs/pages/wireup_graph/pet_store.html
@@ -0,0 +1,1677 @@
+
+
+
+
+
+
+ Wireup Pet Store Demo - Wireup Graph
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ No node selected
+ Left click for the full neighborhood. Right click for dependency paths
+ only.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/readme.md b/readme.md
index abbea8c3..c25ba6d1 100644
--- a/readme.md
+++ b/readme.md
@@ -71,6 +71,22 @@ Override dependencies with context managers, keep tests isolated, and restore th
Full methodology and reproducibility: benchmarks .
+## Interactive Graph
+
+Turn your container into an interactive dependency graph. Explore routes, functions, services, factories,
+configuration, and scopes in a live page with search, grouping, and dependency tracing.
+
+Learn how it works and explore it on an example pet store app:
+
+
+
+
+
+
+ Documentation ·
+ Live Demo
+
+
## Installation
```bash
diff --git a/test/integration/flask/test_flask_integration.py b/test/integration/flask/test_flask_integration.py
index bb59434f..885415b6 100644
--- a/test/integration/flask/test_flask_integration.py
+++ b/test/integration/flask/test_flask_integration.py
@@ -6,7 +6,8 @@
from flask import Flask
from flask.testing import FlaskClient
from wireup._annotations import Injected, injectable
-from wireup.integration.flask import get_app_container
+from wireup.integration.flask import GraphEndpointOptions, get_app_container
+from wireup.renderer._consumers import get_consumers
from test.integration.flask import services as flask_integration_services
from test.integration.flask.bp import bp
@@ -107,3 +108,65 @@ def _err_endpoint(with_cleanup: Injected[Something]) -> str:
assert dep["created"] is True
assert dep["cleanup"] is True
+
+
+def test_graph_endpoint_renders_flask_dependency_page() -> None:
+ app = Flask(__name__)
+ app.config["TESTING"] = True
+ app.register_blueprint(bp)
+
+ container = wireup.create_sync_container(
+ injectables=[shared_services, flask_integration_services],
+ config={**app.config, "custom_params": True},
+ )
+ wireup.integration.flask.setup(
+ container,
+ app,
+ add_graph_endpoint=True,
+ graph_endpoint_options=GraphEndpointOptions(base_module="test.integration.flask"),
+ )
+
+ client = app.test_client()
+ res = client.get("/_wireup")
+
+ assert res.status_code == 200
+ assert "text/html" in res.content_type
+ assert "Wireup Graph" in res.text
+ assert "test.integration.flask.bp" in res.text
+ assert "random" in res.text
+ assert "Flask" in res.text
+
+
+def test_flask_views_record_route_metadata() -> None:
+ app = Flask(__name__)
+ app.config["TESTING"] = True
+ app.register_blueprint(bp)
+
+ container = wireup.create_sync_container(
+ injectables=[shared_services, flask_integration_services],
+ config={**app.config, "custom_params": True},
+ )
+ wireup.integration.flask.setup(container, app)
+
+ consumers = {consumer.id: consumer for consumer in get_consumers(container)}
+
+ consumer = consumers["GET /random"]
+ assert consumer.kind == "flask_route"
+ assert consumer.label == "🌐 GET /random"
+ assert consumer.group == "Flask"
+ assert consumer.module == "test.integration.flask.bp"
+
+
+def test_graph_endpoint_setup_called_again_raises() -> None:
+ app = Flask(__name__)
+ app.config["TESTING"] = True
+ app.register_blueprint(bp)
+
+ container = wireup.create_sync_container(
+ injectables=[shared_services, flask_integration_services],
+ config={**app.config, "custom_params": True},
+ )
+
+ wireup.integration.flask.setup(container, app, add_graph_endpoint=True)
+ with pytest.raises(AssertionError):
+ wireup.integration.flask.setup(container, app, add_graph_endpoint=True)
diff --git a/test/unit/large_mermaid_app/__init__.py b/test/unit/large_mermaid_app/__init__.py
new file mode 100644
index 00000000..0162004e
--- /dev/null
+++ b/test/unit/large_mermaid_app/__init__.py
@@ -0,0 +1,14 @@
+from test.unit.large_mermaid_app.factories import HttpClient, make_http_client
+from test.unit.large_mermaid_app.services.audit import AuditService
+from test.unit.large_mermaid_app.services.kv import KeyValueStore, MetricsClient, RedisConnection
+from test.unit.large_mermaid_app.services.weather import WeatherService
+
+__all__ = [
+ "AuditService",
+ "HttpClient",
+ "KeyValueStore",
+ "MetricsClient",
+ "RedisConnection",
+ "WeatherService",
+ "make_http_client",
+]
diff --git a/test/unit/large_mermaid_app/factories.py b/test/unit/large_mermaid_app/factories.py
new file mode 100644
index 00000000..6e7ec9e8
--- /dev/null
+++ b/test/unit/large_mermaid_app/factories.py
@@ -0,0 +1,18 @@
+from __future__ import annotations
+
+from typing import Iterator
+
+from typing_extensions import Annotated
+from wireup import Inject, injectable
+
+
+class HttpClient:
+ def __init__(self, base_url: str) -> None:
+ self.base_url = base_url
+
+
+@injectable
+def make_http_client(
+ weather_url: Annotated[str, Inject(config="services.weather.base_url")],
+) -> Iterator[HttpClient]:
+ yield HttpClient(weather_url)
diff --git a/test/unit/large_mermaid_app/services/__init__.py b/test/unit/large_mermaid_app/services/__init__.py
new file mode 100644
index 00000000..bb7f3567
--- /dev/null
+++ b/test/unit/large_mermaid_app/services/__init__.py
@@ -0,0 +1,11 @@
+from test.unit.large_mermaid_app.services.audit import AuditService
+from test.unit.large_mermaid_app.services.kv import KeyValueStore, MetricsClient, RedisConnection
+from test.unit.large_mermaid_app.services.weather import WeatherService
+
+__all__ = [
+ "AuditService",
+ "KeyValueStore",
+ "MetricsClient",
+ "RedisConnection",
+ "WeatherService",
+]
diff --git a/test/unit/large_mermaid_app/services/audit.py b/test/unit/large_mermaid_app/services/audit.py
new file mode 100644
index 00000000..dff291fd
--- /dev/null
+++ b/test/unit/large_mermaid_app/services/audit.py
@@ -0,0 +1,20 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from typing_extensions import Annotated
+from wireup import Inject, injectable
+
+if TYPE_CHECKING:
+ from test.unit.large_mermaid_app.services.kv import KeyValueStore
+
+
+@injectable
+class AuditService:
+ def __init__(
+ self,
+ kv_store: KeyValueStore,
+ topic: Annotated[str, Inject(expr="${messaging.kafka.topic_prefix}-${env.name}")],
+ ) -> None:
+ self.kv_store = kv_store
+ self.topic = topic
diff --git a/test/unit/large_mermaid_app/services/kv.py b/test/unit/large_mermaid_app/services/kv.py
new file mode 100644
index 00000000..9876c3da
--- /dev/null
+++ b/test/unit/large_mermaid_app/services/kv.py
@@ -0,0 +1,23 @@
+from __future__ import annotations
+
+from typing_extensions import Annotated
+from wireup import Inject, injectable
+
+
+@injectable
+class RedisConnection:
+ def __init__(self, dsn: Annotated[str, Inject(config="infra.redis.url")]) -> None:
+ self.dsn = dsn
+
+
+@injectable
+class MetricsClient:
+ def __init__(self, endpoint: Annotated[str, Inject(config="infra.metrics.endpoint")]) -> None:
+ self.endpoint = endpoint
+
+
+@injectable
+class KeyValueStore:
+ def __init__(self, redis: RedisConnection, metrics: MetricsClient) -> None:
+ self.redis = redis
+ self.metrics = metrics
diff --git a/test/unit/large_mermaid_app/services/weather.py b/test/unit/large_mermaid_app/services/weather.py
new file mode 100644
index 00000000..f07237a2
--- /dev/null
+++ b/test/unit/large_mermaid_app/services/weather.py
@@ -0,0 +1,28 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from typing_extensions import Annotated
+from wireup import Inject, injectable
+
+if TYPE_CHECKING:
+ from test.unit.large_mermaid_app.factories import HttpClient
+ from test.unit.large_mermaid_app.services.audit import AuditService
+ from test.unit.large_mermaid_app.services.kv import KeyValueStore
+
+
+@injectable
+class WeatherService:
+ def __init__(
+ self,
+ api_key: Annotated[str, Inject(config="services.weather.api_key")],
+ client: HttpClient,
+ kv_store: KeyValueStore,
+ audit: AuditService,
+ cache_key: Annotated[str, Inject(expr="${env.name}-${services.weather.cache_suffix}")],
+ ) -> None:
+ self.api_key = api_key
+ self.client = client
+ self.kv_store = kv_store
+ self.audit = audit
+ self.cache_key = cache_key
diff --git a/test/unit/large_mermaid_graph_poc.html b/test/unit/large_mermaid_graph_poc.html
new file mode 100644
index 00000000..cbfae43a
--- /dev/null
+++ b/test/unit/large_mermaid_graph_poc.html
@@ -0,0 +1,594 @@
+
+
+
+
+
+ Wireup Graph POC
+
+
+
+
+
+
+
+
+
+
+
+
+ No node selected
+
+ Type Click a node to inspect it
+ Module -
+ Depends on -
+ Used by -
+
+
+
+
+
+
+
+
+
+
diff --git a/test/unit/test_inject_from_container.py b/test/unit/test_inject_from_container.py
index 698c64a1..5ec8451f 100644
--- a/test/unit/test_inject_from_container.py
+++ b/test/unit/test_inject_from_container.py
@@ -264,9 +264,52 @@ def target(
svc: Annotated[EmptyQualifierService, Inject(qualifier="")],
) -> None:
assert isinstance(svc, EmptyQualifierService)
- assert svc.name == "empty"
- target()
+
+def test_inject_from_container_records_consumer_metadata() -> None:
+ container = create_sync_container(injectables=[services], config={"env_name": "test"})
+
+ @inject_from_container(container)
+ def target(foo: Injected[Foo]) -> Foo:
+ return foo
+
+ consumer = {consumer.id: consumer for consumer in get_consumers(container)}[
+ f"{target.__module__}.{target.__qualname__}"
+ ]
+ assert consumer.kind == "function"
+ assert consumer.label == f"ƒ {target.__qualname__}"
+ assert consumer.group == "test.unit.test_inject_from_container"
+ assert consumer.module == "test.unit.test_inject_from_container"
+ assert consumer.dependencies
+
+ resolved = target()
+ assert isinstance(resolved, FooImpl)
+
+
+def test_inject_from_container_allows_consumer_metadata_override() -> None:
+ container = create_sync_container(injectables=[services], config={"env_name": "test"})
+
+ @inject_from_container(
+ container,
+ consumer_metadata=ConsumerMetadata(
+ consumer_id="custom.consumer",
+ label="Custom Consumer",
+ kind="http_handler",
+ group="HTTP",
+ module="tests.http",
+ ),
+ )
+ def target(foo: Injected[Foo]) -> Foo:
+ return foo
+
+ consumer = {consumer.id: consumer for consumer in get_consumers(container)}["custom.consumer"]
+ assert consumer.kind == "http_handler"
+ assert consumer.label == "Custom Consumer"
+ assert consumer.group == "HTTP"
+ assert consumer.module == "tests.http"
+
+ resolved = target()
+ assert isinstance(resolved, FooImpl)
async def test_container_sync_raises_async_def() -> None:
diff --git a/test/unit/test_renderer_core.py b/test/unit/test_renderer_core.py
new file mode 100644
index 00000000..d898fbb8
--- /dev/null
+++ b/test/unit/test_renderer_core.py
@@ -0,0 +1,223 @@
+from __future__ import annotations
+
+import wireup
+import wireup.integration.fastapi as wireup_fastapi
+from fastapi import FastAPI
+from pet_store_demo_app import factories, fastapi_services, services
+from typing_extensions import Annotated
+from wireup import Inject, injectable
+from wireup._decorators import inject_from_container
+from wireup.integration.fastapi import setup
+from wireup.renderer._consumers import ConsumerMetadata
+from wireup.renderer._dependencies import ServiceDependencyReference
+from wireup.renderer.core import GraphOptions, to_graph_data
+
+
+@injectable(qualifier="primary")
+class QualifiedSearchBackend:
+ pass
+
+
+@injectable
+class QualifiedSearchService:
+ def __init__(self, backend: Annotated[QualifiedSearchBackend, Inject(qualifier="primary")]) -> None:
+ self.backend = backend
+
+
+@injectable
+class ConfigDrivenService:
+ def __init__(
+ self,
+ settings: Annotated[str, Inject(expr="${infra.database.url}-${infra.database.schema}-${env.name}")],
+ ) -> None:
+ self.settings = settings
+
+
+@injectable
+class AuditBackend:
+ pass
+
+
+@injectable
+class HandlerService:
+ pass
+
+
+def _create_graph_data():
+ app = FastAPI()
+
+ @app.get("/db-session")
+ async def db_session(service: wireup.Injected[services.SQLAlchemySession]) -> dict[str, str]:
+ return service.describe()
+
+ @app.get("/pets")
+ async def list_pets(service: wireup.Injected[services.PetCatalogService]) -> dict[str, object]:
+ return service.list_pets()
+
+ @app.get("/whoami")
+ async def whoami(service: wireup.Injected[fastapi_services.AuthService]) -> dict[str, str]:
+ return service.describe()
+
+ container = wireup.create_async_container(
+ injectables=[factories, services, fastapi_services, wireup_fastapi],
+ config={
+ "auth": {"demo_actor": "shelter-manager"},
+ "env": {"name": "demo"},
+ "infra": {
+ "redis": {"url": "redis://localhost:6379/0"},
+ "metrics": {"endpoint": "http://metrics.internal"},
+ "database": {
+ "url": "postgresql+psycopg://petstore:petstore@localhost:5432/petstore",
+ "schema": "adoption",
+ },
+ },
+ "services": {"search": {"base_url": "https://search.petstore.example"}},
+ "pets": {"store_name": "Happy Tails Shelter", "default_species": "cat"},
+ "messaging": {"events": {"topic_prefix": "petstore-events"}},
+ },
+ )
+
+ setup(container, app)
+ return to_graph_data(container, options=GraphOptions(base_module="pet_store_demo_app"))
+
+
+def test_to_graph_data_returns_expected_groups_nodes_and_edges() -> None:
+ graph = _create_graph_data()
+
+ group_ids = {group.id for group in graph.groups}
+ assert group_ids >= {
+ "group_Configuration",
+ "group_FastAPI",
+ "group_factories",
+ "group_services_adoption",
+ "group_services_audit",
+ "group_services_infra",
+ "group_services_owners",
+ "group_services_pets",
+ "group_services_session",
+ }
+
+ nodes_by_id = {node.id: node for node in graph.nodes}
+ assert nodes_by_id["consumer_GET_pets"].label == "🌐 GET /pets"
+ assert nodes_by_id["consumer_GET_db_session"].label == "🌐 GET /db-session"
+ assert nodes_by_id["consumer_GET_whoami"].label == "🌐 GET /whoami"
+ assert nodes_by_id["pet_store_demo_app_factories_SearchClient"].factory_name == "make_search_client"
+ assert (
+ nodes_by_id["pet_store_demo_app_services_pets_PetCatalogService"].module
+ == "pet_store_demo_app.services.pets"
+ )
+ assert nodes_by_id["pet_store_demo_app_fastapi_services_AuthService"].lifetime == "scoped"
+ assert nodes_by_id["pet_store_demo_app_services_adoption_AdoptionService"].group == "services.adoption"
+ assert nodes_by_id["pet_store_demo_app_services_owners_OwnerService"].group == "services.owners"
+ assert nodes_by_id["pet_store_demo_app_services_session_SQLAlchemySession"].lifetime == "scoped"
+
+ edges = {(edge.source, edge.target, edge.label) for edge in graph.edges}
+ assert ("config_services", "pet_store_demo_app_factories_SearchClient", "search_url") in edges
+ assert (
+ "pet_store_demo_app_factories_SearchClient",
+ "pet_store_demo_app_services_pets_PetCatalogService",
+ "search",
+ ) in edges
+ assert (
+ "pet_store_demo_app_services_pets_PetCatalogService",
+ "pet_store_demo_app_services_adoption_AdoptionService",
+ "catalog",
+ ) in edges
+ assert (
+ "pet_store_demo_app_services_infra_ShelterStore",
+ "pet_store_demo_app_services_audit_AuditService",
+ "shelter_store",
+ ) in edges
+ assert (
+ "pet_store_demo_app_services_pets_PetCatalogService",
+ "consumer_GET_pets",
+ "service",
+ ) in edges
+ assert (
+ "pet_store_demo_app_services_session_SQLAlchemySession",
+ "consumer_GET_db_session",
+ "service",
+ ) in edges
+ assert (
+ "pet_store_demo_app_fastapi_services_AuthService",
+ "consumer_GET_whoami",
+ "service",
+ ) in edges
+
+
+def test_to_graph_data_includes_qualified_service_nodes() -> None:
+ container = wireup.create_sync_container(injectables=[QualifiedSearchBackend, QualifiedSearchService])
+
+ graph = to_graph_data(container)
+
+ nodes_by_id = {node.id: node for node in graph.nodes}
+ assert "test_unit_test_renderer_core_QualifiedSearchBackend_primary" in nodes_by_id
+ assert (
+ nodes_by_id["test_unit_test_renderer_core_QualifiedSearchBackend_primary"].label
+ == "🐍 QualifiedSearchBackend [primary]"
+ )
+ assert (
+ "test_unit_test_renderer_core_QualifiedSearchBackend_primary",
+ "test_unit_test_renderer_core_QualifiedSearchService",
+ "backend",
+ ) in {(edge.source, edge.target, edge.label) for edge in graph.edges}
+
+
+def test_to_graph_data_collapses_config_dependencies_to_root_keys() -> None:
+ container = wireup.create_sync_container(
+ injectables=[ConfigDrivenService],
+ config={
+ "env": {"name": "demo"},
+ "infra": {"database": {"url": "postgresql://demo", "schema": "public"}},
+ },
+ )
+
+ graph = to_graph_data(container)
+
+ nodes_by_id = {node.id: node for node in graph.nodes}
+ assert "config_infra" in nodes_by_id
+ assert "config_env" in nodes_by_id
+ assert "config_infra_database" not in nodes_by_id
+
+ service_node_id = "test_unit_test_renderer_core_ConfigDrivenService"
+ edge_refs = {(edge.source, edge.target, edge.label) for edge in graph.edges}
+ assert ("config_infra", service_node_id, "settings") in edge_refs
+ assert ("config_env", service_node_id, "settings") in edge_refs
+ assert len([edge for edge in graph.edges if edge.target == service_node_id]) == 2
+
+
+def test_to_graph_data_uses_custom_consumer_metadata_and_extra_dependencies() -> None:
+ container = wireup.create_sync_container(injectables=[AuditBackend, HandlerService])
+
+ @inject_from_container(
+ container,
+ consumer_metadata=ConsumerMetadata(
+ consumer_id="custom.route",
+ label="🌐 GET /custom",
+ kind="fastapi_route",
+ group="FastAPI",
+ module="tests.handlers",
+ extra_dependencies=(
+ ServiceDependencyReference(
+ param_name="audit_backend",
+ service_id=f"{AuditBackend.__module__}.{AuditBackend.__qualname__}",
+ qualifier=None,
+ ),
+ ),
+ ),
+ )
+ def handler(service: wireup.Injected[HandlerService]) -> None:
+ assert service is not None
+
+ handler()
+
+ graph = to_graph_data(container)
+
+ nodes_by_id = {node.id: node for node in graph.nodes}
+ assert nodes_by_id["consumer_custom_route"].label == "🌐 GET /custom"
+ assert nodes_by_id["consumer_custom_route"].group == "FastAPI"
+ assert nodes_by_id["consumer_custom_route"].module == "tests.handlers"
+
+ edge_refs = {(edge.source, edge.target, edge.label) for edge in graph.edges}
+ assert ("test_unit_test_renderer_core_HandlerService", "consumer_custom_route", "service") in edge_refs
+ assert ("test_unit_test_renderer_core_AuditBackend", "consumer_custom_route", "audit_backend") in edge_refs
diff --git a/wireup/_decorators.py b/wireup/_decorators.py
index 51a6f6b4..9eabf634 100644
--- a/wireup/_decorators.py
+++ b/wireup/_decorators.py
@@ -15,6 +15,7 @@
get_inject_annotated_parameters,
get_valid_injection_annotated_parameters,
)
+from wireup.renderer._consumers import ConsumerMetadata, record_injected_consumer
if TYPE_CHECKING:
from wireup.ioc.container.async_container import AsyncContainer, ScopedAsyncContainer
@@ -60,6 +61,7 @@ def inject_from_container(
container: SyncContainer | AsyncContainer,
scoped_container_supplier: Callable[[], ScopedSyncContainer | ScopedAsyncContainer] | None = None,
_context_creator: dict[Any, str] | None = None,
+ consumer_metadata: ConsumerMetadata | None = None,
*,
hide_annotated_names: bool = False,
) -> Callable[[Callable[P, R]], Callable[..., R]]:
@@ -88,6 +90,7 @@ def _decorator(target: Callable[P, R]) -> Callable[..., R]:
container=container,
scoped_container_supplier=scoped_container_supplier,
context_creator=_context_creator,
+ consumer_metadata=consumer_metadata,
hide_annotated_names=hide_annotated_names,
)
@@ -100,6 +103,7 @@ def inject_from_container_util( # noqa: PLR0913
container: SyncContainer | AsyncContainer | None,
scoped_container_supplier: Callable[[], ScopedSyncContainer | ScopedAsyncContainer] | None = None,
context_creator: dict[Any, str] | None = None,
+ consumer_metadata: ConsumerMetadata | None = None,
*,
hide_annotated_names: bool,
) -> Callable[..., R]:
@@ -110,6 +114,14 @@ def inject_from_container_util( # noqa: PLR0913
if not names_to_inject:
return target
+ if container is not None:
+ record_injected_consumer(
+ container,
+ target=target,
+ names_to_inject=names_to_inject,
+ metadata=consumer_metadata,
+ )
+
res = compile_injection_wrapper(
target=target,
names_to_inject=names_to_inject,
diff --git a/wireup/integration/django/__init__.py b/wireup/integration/django/__init__.py
index 8d4ce4db..09715590 100644
--- a/wireup/integration/django/__init__.py
+++ b/wireup/integration/django/__init__.py
@@ -1,4 +1,9 @@
-from wireup.integration.django.apps import WireupSettings, get_app_container, get_request_container, wireup_middleware
+from wireup.integration.django.apps import (
+ WireupSettings,
+ get_app_container,
+ get_request_container,
+ wireup_middleware,
+)
from wireup.integration.django.decorators import inject, inject_app
__all__ = [
diff --git a/wireup/integration/django/apps.py b/wireup/integration/django/apps.py
index 71139092..a3611ffa 100644
--- a/wireup/integration/django/apps.py
+++ b/wireup/integration/django/apps.py
@@ -1,11 +1,12 @@
+from __future__ import annotations
+
import functools
import importlib
import inspect
import warnings
from contextvars import ContextVar
from dataclasses import dataclass
-from types import ModuleType
-from typing import TYPE_CHECKING, Any, Awaitable, Callable, List, Optional, Union
+from typing import TYPE_CHECKING, Any, Awaitable, Callable
import django
import django.urls
@@ -20,13 +21,15 @@
from wireup._decorators import inject_from_container
from wireup.errors import WireupError
from wireup.ioc.container.async_container import AsyncContainer, ScopedAsyncContainer, async_container_force_sync_scope
-from wireup.ioc.container.base_container import BaseContainer
-from wireup.ioc.container.sync_container import ScopedSyncContainer
from wireup.ioc.types import ConfigInjectionRequest
from wireup.ioc.util import get_valid_injection_annotated_parameters
if TYPE_CHECKING:
+ from types import ModuleType
+
from wireup.integration.django import WireupSettings
+ from wireup.ioc.container.base_container import BaseContainer
+ from wireup.ioc.container.sync_container import ScopedSyncContainer
_request_container: ContextVar[BaseContainer] = ContextVar("_wireup_request_container")
@@ -35,7 +38,7 @@
@sync_and_async_middleware
def wireup_middleware(
get_response: Callable[[HttpRequest], HttpResponse],
-) -> Callable[[HttpRequest], Union[HttpResponse, Awaitable[HttpResponse]]]:
+) -> Callable[[HttpRequest], HttpResponse | Awaitable[HttpResponse]]:
container = get_app_container()
if inspect.iscoroutinefunction(get_response):
@@ -70,7 +73,7 @@ def _django_request_factory() -> HttpRequest:
raise WireupError(msg)
-def get_request_container() -> Union[ScopedSyncContainer, ScopedAsyncContainer]:
+def get_request_container() -> ScopedSyncContainer | ScopedAsyncContainer:
"""When inside a request, returns the scoped container instance handling the current request."""
try:
return _request_container.get() # type:ignore[reportReturnType]
@@ -176,10 +179,10 @@ def view(request: HttpRequest, *args: Any, **kwargs: Any) -> Any:
class WireupSettings:
"""Class containing Wireup settings specific to Django."""
- service_modules: Optional[List[Union[str, ModuleType]]] = None
+ service_modules: list[str | ModuleType] | None = None
"""List of modules containing wireup injectable registrations."""
- injectables: Optional[List[Union[str, ModuleType]]] = None
+ injectables: list[str | ModuleType] | None = None
"""List of modules containing wireup injectable registrations."""
auto_inject_views: bool = True
diff --git a/wireup/integration/fastapi.py b/wireup/integration/fastapi.py
index 6855596d..b56f1e63 100644
--- a/wireup/integration/fastapi.py
+++ b/wireup/integration/fastapi.py
@@ -1,20 +1,12 @@
+from __future__ import annotations
+
import contextlib
-from typing import (
- Any,
- AsyncIterator,
- Callable,
- Iterable,
- List,
- Optional,
- Tuple,
- Type,
- Union,
-)
+from typing import TYPE_CHECKING, Any, AsyncIterator, Callable, Iterable
import fastapi
from fastapi import FastAPI, Request, WebSocket
+from fastapi.responses import HTMLResponse
from fastapi.routing import APIRoute, APIWebSocketRoute
-from starlette.routing import BaseRoute
from typing_extensions import Protocol
from wireup import inject_from_container
@@ -30,14 +22,21 @@
request_factory,
websocket_factory,
)
-from wireup.ioc.container.async_container import AsyncContainer
-from wireup.ioc.types import AnyCallable
from wireup.ioc.util import (
get_inject_annotated_parameters,
hide_annotated_names,
injection_requires_scope,
is_wireup_injected,
)
+from wireup.renderer._consumers import ConsumerMetadata
+from wireup.renderer._dependencies import DependencyReference, ServiceDependencyReference
+from wireup.renderer.full_page import GraphEndpointOptions, render_graph_page
+
+if TYPE_CHECKING:
+ from starlette.routing import BaseRoute
+
+ from wireup.ioc.container.async_container import AsyncContainer
+ from wireup.ioc.types import AnyCallable
__all__ = [
"WireupRoute",
@@ -61,11 +60,12 @@ def __init__(self, path: str, endpoint: Callable[..., Any], **kwargs: Any) -> No
super().__init__(path=path, endpoint=endpoint, **kwargs)
-def _inject_route_with_connection_context(
+def _inject_route_with_connection_context( # noqa: PLR0913
*,
container: AsyncContainer,
target: AnyCallable,
- http_connection_param_name: Optional[str],
+ consumer_metadata: ConsumerMetadata,
+ http_connection_param_name: str | None,
remove_http_connection_from_arguments: bool,
is_websocket_route: bool,
) -> AnyCallable:
@@ -78,10 +78,11 @@ def _inject_route_with_connection_context(
}
if http_connection_param_name
else None,
+ consumer_metadata=consumer_metadata,
)(target)
-def _ensure_http_connection_param(route: Union[APIRoute, APIWebSocketRoute]) -> Tuple[str, bool]:
+def _ensure_http_connection_param(route: APIRoute | APIWebSocketRoute) -> tuple[str, bool]:
"""Ensure FastAPI will pass the active connection and return param name + remove flag."""
has_connection_param_in_signature = route.dependant.http_connection_param_name is not None
if not route.dependant.http_connection_param_name:
@@ -90,9 +91,35 @@ def _ensure_http_connection_param(route: Union[APIRoute, APIWebSocketRoute]) ->
return route.dependant.http_connection_param_name, not has_connection_param_in_signature
+def _get_consumer_metadata(route: APIRoute | APIWebSocketRoute) -> ConsumerMetadata:
+ methods = () if isinstance(route, APIWebSocketRoute) else tuple(sorted(route.methods or []))
+ method_label = "WS" if isinstance(route, APIWebSocketRoute) else "|".join(methods) if methods else "ROUTE"
+ consumer_id = f"{method_label} {route.path}"
+ extra_dependencies: tuple[DependencyReference, ...] = ()
+
+ bound_instance = getattr(route.dependant.call, "__self__", None)
+ if bound_instance is not None:
+ extra_dependencies = (
+ ServiceDependencyReference(
+ param_name="handler",
+ service_id=f"{bound_instance.__class__.__module__}.{bound_instance.__class__.__qualname__}",
+ qualifier=None,
+ ),
+ )
+
+ return ConsumerMetadata(
+ consumer_id=consumer_id,
+ kind="fastapi_websocket" if isinstance(route, APIWebSocketRoute) else "fastapi_route",
+ label=f"🌐 {consumer_id}",
+ group="FastAPI",
+ module=route.dependant.call.__module__,
+ extra_dependencies=extra_dependencies,
+ )
+
+
def _inject_routes(
container: AsyncContainer,
- routes: List[BaseRoute],
+ routes: list[BaseRoute],
*,
is_using_asgi_middleware: bool,
) -> None:
@@ -109,10 +136,16 @@ def _inject_routes(
if not names_to_inject:
continue
+ consumer_metadata = _get_consumer_metadata(route)
+
# When using the asgi middleware, the request context variable is set there.
# and we can get the scoped container from the request.
if isinstance(route, APIRoute) and is_using_asgi_middleware:
- route.dependant.call = inject_from_container(container, get_request_container)(route.dependant.call)
+ route.dependant.call = inject_from_container(
+ container,
+ get_request_container,
+ consumer_metadata=consumer_metadata,
+ )(route.dependant.call)
continue
# We now are either in a websocket endpoint or HTTP without middleware_mode.
@@ -126,16 +159,30 @@ def _inject_routes(
route.dependant.call = _inject_route_with_connection_context(
container=container,
target=route.dependant.call,
+ consumer_metadata=consumer_metadata,
http_connection_param_name=http_connection_param_name,
remove_http_connection_from_arguments=remove_http_connection_from_arguments,
is_websocket_route=isinstance(route, APIWebSocketRoute),
)
+def _setup_graph_routes(app: FastAPI, *, options: GraphEndpointOptions) -> None:
+ async def _wireup_graph_page(request: Request) -> HTMLResponse:
+ return HTMLResponse(
+ render_graph_page(
+ get_app_container(request.app),
+ title=f"{app.title} - Wireup Graph",
+ options=options,
+ )
+ )
+
+ app.add_api_route("/_wireup", _wireup_graph_page, methods=["GET"], response_class=HTMLResponse)
+
+
async def _instantiate_class_based_route(
app: FastAPI,
container: AsyncContainer,
- cls: Type[_ClassBasedHandlersProtocol],
+ cls: type[_ClassBasedHandlersProtocol],
) -> None:
instance = await container.get(cls)
@@ -164,7 +211,7 @@ async def _instantiate_class_based_route(
def _update_lifespan(
app: FastAPI,
- class_based_routes: Optional[Iterable[Type[_ClassBasedHandlersProtocol]]] = None,
+ class_based_routes: Iterable[type[_ClassBasedHandlersProtocol]] | None = None,
*,
is_using_asgi_middleware: bool,
) -> None:
@@ -194,12 +241,14 @@ async def lifespan(app: FastAPI) -> AsyncIterator[Any]:
app.router.lifespan_context = lifespan
-def setup(
+def setup( # noqa: PLR0913
container: AsyncContainer,
app: FastAPI,
*,
- class_based_handlers: Optional[Iterable[Type[_ClassBasedHandlersProtocol]]] = None,
+ class_based_handlers: Iterable[type[_ClassBasedHandlersProtocol]] | None = None,
middleware_mode: bool = False,
+ add_graph_endpoint: bool = False,
+ graph_endpoint_options: GraphEndpointOptions | None = None,
) -> None:
"""Integrate Wireup with FastAPI.
@@ -216,11 +265,16 @@ def setup(
Warning: Do not include these with fastapi directly.
:param middleware_mode: If True, the container is exposed in fastapi middleware.
Note, for this to work correctly, there should be no more middleware added after the call to this function.
+ :param add_graph_endpoint: If True, mount the `/_wireup` endpoint exposing
+ the Wireup graph viewer and raw graph JSON.
+ :param graph_endpoint_options: Optional graph endpoint configuration.
For more details, visit: https://maldoinc.github.io/wireup/latest/integrations/fastapi/
"""
app.state.wireup_container = container
_expose_wireup_task(container)
+ if add_graph_endpoint:
+ _setup_graph_routes(app, options=graph_endpoint_options or GraphEndpointOptions())
if middleware_mode:
app.add_middleware(WireupAsgiMiddleware, include_websocket=False)
_update_lifespan(
diff --git a/wireup/integration/flask.py b/wireup/integration/flask.py
index deb4b12d..66679a2e 100644
--- a/wireup/integration/flask.py
+++ b/wireup/integration/flask.py
@@ -1,18 +1,72 @@
-from typing import Optional
+from __future__ import annotations
-from flask import Flask, g
+from typing import TYPE_CHECKING
-from wireup._decorators import inject_from_container
-from wireup.ioc.container.sync_container import ScopedSyncContainer, SyncContainer
+from flask import Flask, Response, g
+from wireup._decorators import inject_from_container
+from wireup.renderer._consumers import ConsumerMetadata
+from wireup.renderer.full_page import GraphEndpointOptions, render_graph_page
-def _inject_views(container: SyncContainer, app: Flask) -> None:
- inject_scoped = inject_from_container(container, get_request_container)
+if TYPE_CHECKING:
+ from wireup.ioc.container.sync_container import ScopedSyncContainer, SyncContainer
- app.view_functions = {name: inject_scoped(view) for name, view in app.view_functions.items()}
+__all__ = [
+ "GraphEndpointOptions",
+ "get_app_container",
+ "get_request_container",
+ "setup",
+]
-def setup(container: SyncContainer, app: Flask) -> None:
+def _inject_views(container: SyncContainer, app: Flask) -> None:
+ app.view_functions = {
+ endpoint: inject_from_container(
+ container,
+ get_request_container,
+ consumer_metadata=_flask_consumer_metadata(app, endpoint, view),
+ )(view)
+ for endpoint, view in app.view_functions.items()
+ }
+
+
+def _flask_consumer_metadata(app: Flask, endpoint: str, view: object) -> ConsumerMetadata:
+ rules = sorted((rule for rule in app.url_map.iter_rules() if rule.endpoint == endpoint), key=lambda item: item.rule)
+ paths = tuple(rule.rule for rule in rules)
+ methods = tuple(dict.fromkeys(method for rule in rules for method in sorted(rule.methods - {"HEAD", "OPTIONS"})))
+ method_label = "|".join(methods) if methods else "ROUTE"
+ path_label = ", ".join(paths) if paths else endpoint
+ consumer_id = f"{method_label} {path_label}"
+
+ return ConsumerMetadata(
+ consumer_id=consumer_id,
+ kind="flask_route",
+ label=f"🌐 {consumer_id}",
+ group="Flask",
+ module=getattr(view, "__module__", "unknown"),
+ )
+
+
+def _setup_graph_route(app: Flask, *, options: GraphEndpointOptions) -> None:
+ @app.get("/_wireup")
+ def _wireup_graph_page() -> Response:
+ return Response(
+ render_graph_page(
+ get_app_container(app),
+ title=f"{app.name} - Wireup Graph",
+ options=options,
+ ),
+ mimetype="text/html",
+ )
+
+
+def setup(
+ container: SyncContainer,
+ app: Flask,
+ *,
+ add_graph_endpoint: bool = False,
+ graph_endpoint_options: GraphEndpointOptions | None = None,
+) -> None:
"""Integrate Wireup with Flask.
Setup performs the following:
@@ -25,13 +79,15 @@ def _before_request() -> None:
g.wireup_container_ctx = ctx
g.wireup_container = ctx.__enter__()
- def _teardown_request(exc: Optional[BaseException] = None) -> None:
+ def _teardown_request(exc: BaseException | None = None) -> None:
if ctx := getattr(g, "wireup_container_ctx", None):
ctx.__exit__(type(exc) if exc else None, exc, exc.__traceback__ if exc else None)
app.before_request(_before_request)
app.teardown_request(_teardown_request)
+ if add_graph_endpoint:
+ _setup_graph_route(app, options=graph_endpoint_options or GraphEndpointOptions())
_inject_views(container, app)
app.wireup_container = container # type: ignore[reportAttributeAccessIssue]
diff --git a/wireup/ioc/container/base_container.py b/wireup/ioc/container/base_container.py
index 2f65be71..f67588b7 100644
--- a/wireup/ioc/container/base_container.py
+++ b/wireup/ioc/container/base_container.py
@@ -31,6 +31,7 @@
class BaseContainer:
__slots__ = (
+ "__weakref__",
"_compiler",
"_concurrent_scoped_access",
"_current_scope_exit_stack",
diff --git a/wireup/renderer/__init__.py b/wireup/renderer/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/wireup/renderer/_consumers.py b/wireup/renderer/_consumers.py
new file mode 100644
index 00000000..e15009f7
--- /dev/null
+++ b/wireup/renderer/_consumers.py
@@ -0,0 +1,94 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Any
+from weakref import WeakKeyDictionary
+
+from wireup.renderer._dependencies import DependencyReference, resolve_dependencies
+
+if TYPE_CHECKING:
+ from wireup.ioc.container.base_container import BaseContainer
+ from wireup.ioc.types import AnnotatedParameter
+
+
+@dataclass(frozen=True)
+class ConsumerRecord:
+ kind: str
+ id: str
+ label: str
+ group: str
+ module: str
+ dependencies: tuple[DependencyReference, ...]
+
+
+@dataclass(frozen=True)
+class ConsumerMetadata:
+ consumer_id: str | None = None
+ label: str | None = None
+ kind: str = "function"
+ group: str | None = None
+ module: str | None = None
+ extra_dependencies: tuple[DependencyReference, ...] = ()
+
+
+_CONSUMERS_BY_CONTAINER: WeakKeyDictionary[BaseContainer, dict[str, ConsumerRecord]] = WeakKeyDictionary()
+
+
+def record_consumer( # noqa: PLR0913
+ container: BaseContainer,
+ *,
+ kind: str,
+ consumer_id: str,
+ label: str,
+ group: str,
+ module: str,
+ names_to_inject: dict[str, AnnotatedParameter],
+ extra_dependencies: tuple[DependencyReference, ...] = (),
+) -> None:
+ _CONSUMERS_BY_CONTAINER.setdefault(container, {})[consumer_id] = ConsumerRecord(
+ kind=kind,
+ id=consumer_id,
+ label=label,
+ group=group,
+ module=module,
+ dependencies=resolve_dependencies(container, names_to_inject) + extra_dependencies,
+ )
+
+
+def get_consumers(container: BaseContainer) -> tuple[ConsumerRecord, ...]:
+ return tuple(_CONSUMERS_BY_CONTAINER.get(container, {}).values())
+
+
+def infer_consumer_metadata(target: Any) -> ConsumerMetadata:
+ module = getattr(target, "__module__", "unknown")
+ qualname = getattr(target, "__qualname__", getattr(target, "__name__", repr(target)))
+ consumer_id = f"{module}.{qualname}"
+
+ return ConsumerMetadata(
+ consumer_id=consumer_id,
+ label=f"ƒ {qualname}",
+ group=module,
+ module=module,
+ )
+
+
+def record_injected_consumer(
+ container: BaseContainer,
+ *,
+ target: Any,
+ names_to_inject: dict[str, AnnotatedParameter],
+ metadata: ConsumerMetadata | None = None,
+) -> None:
+ inferred = infer_consumer_metadata(target)
+ provided = metadata or ConsumerMetadata()
+
+ record_consumer(
+ container,
+ kind=provided.kind,
+ consumer_id=provided.consumer_id or inferred.consumer_id or repr(target),
+ label=provided.label or inferred.label or repr(target),
+ group=provided.group or inferred.group or "Functions",
+ module=provided.module or inferred.module or "unknown",
+ names_to_inject=names_to_inject,
+ extra_dependencies=provided.extra_dependencies,
+ )
diff --git a/wireup/renderer/_dependencies.py b/wireup/renderer/_dependencies.py
new file mode 100644
index 00000000..64d8b942
--- /dev/null
+++ b/wireup/renderer/_dependencies.py
@@ -0,0 +1,77 @@
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Any
+
+from typing_extensions import TypeAlias
+
+from wireup.ioc.types import ConfigInjectionRequest, TemplatedString
+
+if TYPE_CHECKING:
+ from wireup.ioc.container.base_container import BaseContainer
+ from wireup.ioc.types import AnnotatedParameter
+
+_CONFIG_REF_PATTERN = re.compile(r"\${(.*?)}", flags=re.DOTALL)
+
+
+@dataclass(frozen=True)
+class ConfigDependencyReference:
+ param_name: str
+ config_keys: tuple[str, ...]
+
+
+@dataclass(frozen=True)
+class ServiceDependencyReference:
+ param_name: str
+ service_id: str
+ qualifier: Any = None
+
+
+DependencyReference: TypeAlias = ConfigDependencyReference | ServiceDependencyReference
+
+
+def resolve_dependencies(
+ container: BaseContainer,
+ names_to_inject: dict[str, AnnotatedParameter],
+) -> tuple[DependencyReference, ...]:
+ dependencies: list[DependencyReference] = []
+
+ for param_name, parameter in names_to_inject.items():
+ config_keys = extract_config_sources(parameter.annotation)
+ if config_keys:
+ dependencies.append(
+ ConfigDependencyReference(
+ param_name=param_name,
+ config_keys=config_keys,
+ )
+ )
+ continue
+
+ impl = container._registry.get_implementation(parameter.klass, parameter.qualifier_value)
+ dependencies.append(
+ ServiceDependencyReference(
+ param_name=param_name,
+ service_id=f"{impl.__module__}.{impl.__qualname__}",
+ qualifier=parameter.qualifier_value,
+ )
+ )
+
+ return tuple(dependencies)
+
+
+def extract_config_sources(annotation: object) -> tuple[str, ...]:
+ if not isinstance(annotation, ConfigInjectionRequest):
+ return ()
+
+ config_key = annotation.config_key
+ if isinstance(config_key, TemplatedString):
+ keys = set(_CONFIG_REF_PATTERN.findall(config_key.value))
+ else:
+ keys = {config_key}
+
+ return tuple({_collapse_config_key(key) for key in keys})
+
+
+def _collapse_config_key(key: str) -> str:
+ return key if "." not in key else key.split(".", maxsplit=1)[0]
diff --git a/wireup/renderer/core.py b/wireup/renderer/core.py
new file mode 100644
index 00000000..158b50a2
--- /dev/null
+++ b/wireup/renderer/core.py
@@ -0,0 +1,260 @@
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Any
+
+from wireup.renderer._consumers import get_consumers
+from wireup.renderer._dependencies import (
+ ConfigDependencyReference,
+ DependencyReference,
+ resolve_dependencies,
+)
+
+if TYPE_CHECKING:
+ from wireup.ioc.container.base_container import BaseContainer
+ from wireup.ioc.registry import InjectableFactory
+ from wireup.ioc.types import ContainerObjectIdentifier, Qualifier
+ from wireup.renderer._consumers import ConsumerRecord
+
+
+@dataclass(frozen=True)
+class GraphOptions:
+ base_module: str | None = None
+
+
+@dataclass(frozen=True)
+class GraphGroup:
+ id: str
+ label: str
+ kind: str
+ module: str
+
+
+@dataclass(frozen=True)
+class GraphNode:
+ id: str
+ label: str
+ kind: str
+ lifetime: str | None
+ module: str
+ parent: str
+ original_parent: str
+ group: str
+ factory_name: str | None
+
+
+@dataclass(frozen=True)
+class GraphEdge:
+ id: str
+ source: str
+ target: str
+ label: str
+ kind: str
+
+
+@dataclass(frozen=True)
+class GraphData:
+ groups: tuple[GraphGroup, ...]
+ nodes: tuple[GraphNode, ...]
+ edges: tuple[GraphEdge, ...]
+
+
+def to_graph_data(container: BaseContainer, *, options: GraphOptions | None = None) -> GraphData:
+ opts = options or GraphOptions()
+ service_nodes = _service_nodes(container, base_module=opts.base_module)
+ consumer_nodes = _consumer_nodes(container)
+ config_nodes: dict[str, GraphNode] = {}
+ edge_refs: set[tuple[str, str, str]] = set()
+
+ for node, dependencies in [*service_nodes.values(), *consumer_nodes.values()]:
+ _collect_edges(
+ dependencies,
+ target_node_id=node.id,
+ service_nodes=service_nodes,
+ config_nodes=config_nodes,
+ edge_refs=edge_refs,
+ )
+
+ nodes = tuple(
+ sorted(
+ [
+ *config_nodes.values(),
+ *(node for node, _ in service_nodes.values()),
+ *(node for node, _ in consumer_nodes.values()),
+ ],
+ key=lambda item: item.id,
+ )
+ )
+ groups = tuple(sorted(_groups_for_nodes(nodes), key=lambda item: item.id))
+ edges = tuple(
+ GraphEdge(
+ id=f"edge_{index}",
+ source=source,
+ target=target,
+ label=label,
+ kind="dependency",
+ )
+ for index, (source, target, label) in enumerate(sorted(edge_refs))
+ )
+ return GraphData(groups=groups, nodes=nodes, edges=edges)
+
+
+def _service_nodes(
+ container: BaseContainer,
+ *,
+ base_module: str | None,
+) -> dict[str, tuple[GraphNode, tuple[DependencyReference, ...]]]:
+ registry = container._registry
+ nodes: dict[str, tuple[GraphNode, tuple[DependencyReference, ...]]] = {}
+
+ for obj_id in registry.factories:
+ impl, qualifier = _split_obj_id(obj_id)
+ factory = registry.factories[obj_id]
+ node = _service_node(impl, qualifier, factory, registry.lifetime[obj_id], base_module)
+ dependencies = resolve_dependencies(container, registry.dependencies[factory.factory])
+ nodes[node.id] = (node, dependencies)
+
+ return nodes
+
+
+def _consumer_nodes(
+ container: BaseContainer,
+) -> dict[str, tuple[GraphNode, tuple[DependencyReference, ...]]]:
+ nodes: dict[str, tuple[GraphNode, tuple[DependencyReference, ...]]] = {}
+ for consumer in get_consumers(container):
+ node = _consumer_node(consumer)
+ nodes[node.id] = (node, consumer.dependencies)
+
+ return nodes
+
+
+def _collect_edges(
+ dependencies: tuple[DependencyReference, ...],
+ *,
+ target_node_id: str,
+ service_nodes: dict[str, tuple[GraphNode, tuple[DependencyReference, ...]]],
+ config_nodes: dict[str, GraphNode],
+ edge_refs: set[tuple[str, str, str]],
+) -> None:
+ for dependency in dependencies:
+ if isinstance(dependency, ConfigDependencyReference):
+ for config_key in dependency.config_keys:
+ config_node = _config_node(config_key)
+ config_nodes[config_node.id] = config_node
+ edge_refs.add((config_node.id, target_node_id, dependency.param_name))
+ continue
+ service_node_id = _node_id(dependency.service_id, dependency.qualifier)
+ if service_node_id in service_nodes:
+ edge_refs.add((service_node_id, target_node_id, dependency.param_name))
+
+
+def _groups_for_nodes(nodes: tuple[GraphNode, ...]) -> tuple[GraphGroup, ...]:
+ groups: dict[str, GraphGroup] = {}
+ for node in nodes:
+ group_id = _group_id(node.group)
+ groups[group_id] = GraphGroup(
+ id=group_id,
+ label=node.group,
+ kind="group",
+ module=node.group,
+ )
+ return tuple(groups.values())
+
+
+def _service_node(
+ impl: type[Any],
+ qualifier: Qualifier | None,
+ factory: InjectableFactory,
+ lifetime: str,
+ base_module: str | None,
+) -> GraphNode:
+ is_factory = not isinstance(factory.factory, type)
+ label_prefix = "🏭" if is_factory else "🐍"
+ label = f"{label_prefix} {impl.__name__}"
+ if qualifier is not None:
+ label = f"{label} [{qualifier}]"
+
+ module_name = factory.factory.__module__
+ group = _display_module_name(module_name, base_module)
+ parent = _group_id(group)
+ return GraphNode(
+ id=_node_id(f"{impl.__module__}.{impl.__qualname__}", qualifier),
+ label=label,
+ kind="factory" if is_factory else "service",
+ lifetime=lifetime,
+ module=module_name,
+ parent=parent,
+ original_parent=parent,
+ group=group,
+ factory_name=None if not is_factory else factory.factory.__name__,
+ )
+
+
+def _config_node(config_key: str) -> GraphNode:
+ group = "Configuration"
+ parent = _group_id(group)
+ return GraphNode(
+ id=_node_id(f"config.{config_key}", None),
+ label=f"⚙️ {config_key}",
+ kind="config",
+ lifetime=None,
+ module="config",
+ parent=parent,
+ original_parent=parent,
+ group=group,
+ factory_name=None,
+ )
+
+
+def _consumer_node(consumer: ConsumerRecord) -> GraphNode:
+ parent = _group_id(consumer.group)
+ return GraphNode(
+ id=_node_id(f"consumer.{consumer.id}", None),
+ label=consumer.label,
+ kind="consumer",
+ lifetime=None,
+ module=consumer.module,
+ parent=parent,
+ original_parent=parent,
+ group=consumer.group,
+ factory_name=None,
+ )
+
+
+def _sort_obj_id(obj_id: ContainerObjectIdentifier) -> tuple[str, str]:
+ impl, qualifier = _split_obj_id(obj_id)
+ return (f"{impl.__module__}.{impl.__qualname__}", "" if qualifier is None else str(qualifier))
+
+
+def _split_obj_id(obj_id: ContainerObjectIdentifier) -> tuple[type[Any], Qualifier | None]:
+ return obj_id if isinstance(obj_id, tuple) else (obj_id, None)
+
+
+def _node_id(name: str, qualifier: Qualifier | None) -> str:
+ raw = name if qualifier is None else f"{name}.{qualifier}"
+ sanitized = re.sub(r"\W+", "_", raw).strip("_")
+ if not sanitized:
+ return "node"
+ if sanitized[0].isdigit():
+ return f"n_{sanitized}"
+ return sanitized
+
+
+def _group_id(group_name: str) -> str:
+ return f"group_{_node_id(group_name, None)}"
+
+
+def _display_module_name(module_name: str, base_module: str | None) -> str:
+ if not base_module:
+ return module_name
+
+ normalized_base = base_module.rstrip(".")
+ if module_name == normalized_base:
+ return normalized_base.split(".")[-1]
+
+ prefix = f"{normalized_base}."
+ if module_name.startswith(prefix):
+ return module_name[len(prefix) :]
+
+ return module_name
diff --git a/wireup/renderer/full_page.py b/wireup/renderer/full_page.py
new file mode 100644
index 00000000..9a1c0ce8
--- /dev/null
+++ b/wireup/renderer/full_page.py
@@ -0,0 +1,1719 @@
+from __future__ import annotations
+
+import json
+from dataclasses import asdict, dataclass
+from typing import TYPE_CHECKING
+
+from wireup.renderer.core import GraphData, GraphOptions, to_graph_data
+
+__all__ = [
+ "GraphEndpointOptions",
+ "GraphOptions",
+ "full_page_renderer",
+ "render_graph_page",
+ "to_graph_data",
+]
+
+if TYPE_CHECKING:
+ from wireup.ioc.container.base_container import BaseContainer
+
+
+@dataclass(frozen=True)
+class GraphEndpointOptions:
+ base_module: str | None = None
+
+_TITLE_PLACEHOLDER = "__WIREUP_TITLE__"
+_GRAPH_DATA_PLACEHOLDER = "__WIREUP_GRAPH_DATA__"
+_FULL_PAGE_TEMPLATE = """
+
+
+
+
+ __WIREUP_TITLE__
+
+
+
+
+
+
+
+ Wireup Graph
+
+
+
+ __WIREUP_TITLE__
+
+
+
+
+
+
+
+
+
+
+
+
+
+ No node selected
+ Left click for the full neighborhood. Right click for dependency paths only.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+"""
+
+
+def _escape_html(value: str) -> str:
+ return value.replace("&", "&").replace("<", "<").replace(">", ">")
+
+
+def render_graph_page(container: BaseContainer, *, title: str, options: GraphEndpointOptions) -> str:
+ graph_data = to_graph_data(
+ container,
+ options=GraphOptions(base_module=options.base_module),
+ )
+ return full_page_renderer(graph_data, title=title)
+
+
+def full_page_renderer(graph_data: GraphData, *, title: str = "Wireup Graph") -> str:
+ payload = _escape_html(json.dumps(asdict(graph_data), separators=(",", ":"), ensure_ascii=False))
+ return _FULL_PAGE_TEMPLATE.replace(_TITLE_PLACEHOLDER, _escape_html(title)).replace(
+ _GRAPH_DATA_PLACEHOLDER, payload
+ )