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**. + ![Scoped Performance](img/benchmarks_scoped_light.svg#only-light) ![Scoped Performance](img/benchmarks_scoped_dark.svg#only-dark) @@ -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 + +![Wireup interactive dependency graph demo](img/pet_store_demo.gif) + + +## 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 + + + + + +
+
+
+ Wireup Graph +
+ +
+ 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: + +

+ Wireup interactive dependency graph demo +

+ +

+ 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 + + + +
+ + +
+
+
+ Interactive container sketch + Click a node to isolate its neighborhood. +
+ Showing all mocked nodes +
+ +
+
+
+

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 + )