engin 0.0.15__tar.gz → 0.0.16__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {engin-0.0.15 → engin-0.0.16}/CHANGELOG.md +14 -0
- {engin-0.0.15 → engin-0.0.16}/PKG-INFO +1 -1
- {engin-0.0.15 → engin-0.0.16}/examples/fastapi/main.py +1 -1
- {engin-0.0.15 → engin-0.0.16}/pyproject.toml +1 -1
- {engin-0.0.15 → engin-0.0.16}/src/engin/_assembler.py +50 -3
- {engin-0.0.15 → engin-0.0.16}/src/engin/_block.py +2 -2
- engin-0.0.16/src/engin/_cli/_graph.html +78 -0
- {engin-0.0.15 → engin-0.0.16}/src/engin/_cli/_graph.py +28 -53
- {engin-0.0.15 → engin-0.0.16}/src/engin/_dependency.py +11 -3
- engin-0.0.16/src/engin/_exceptions.py +54 -0
- {engin-0.0.15 → engin-0.0.16}/src/engin/ext/asgi.py +2 -1
- {engin-0.0.15 → engin-0.0.16}/tests/acceptance/test_fastapi.py +36 -3
- {engin-0.0.15 → engin-0.0.16}/tests/test_assembler.py +44 -1
- {engin-0.0.15 → engin-0.0.16}/uv.lock +209 -184
- engin-0.0.15/src/engin/_exceptions.py +0 -26
- {engin-0.0.15 → engin-0.0.16}/.github/workflows/check.yaml +0 -0
- {engin-0.0.15 → engin-0.0.16}/.github/workflows/publish.yaml +0 -0
- {engin-0.0.15 → engin-0.0.16}/.gitignore +0 -0
- {engin-0.0.15 → engin-0.0.16}/.readthedocs.yaml +0 -0
- {engin-0.0.15 → engin-0.0.16}/LICENSE +0 -0
- {engin-0.0.15 → engin-0.0.16}/README.md +0 -0
- {engin-0.0.15 → engin-0.0.16}/docs/concepts/engin.md +0 -0
- {engin-0.0.15 → engin-0.0.16}/docs/concepts/invocations.md +0 -0
- {engin-0.0.15 → engin-0.0.16}/docs/concepts/lifecycle.md +0 -0
- {engin-0.0.15 → engin-0.0.16}/docs/concepts/providers.md +0 -0
- {engin-0.0.15 → engin-0.0.16}/docs/getting-started.md +0 -0
- {engin-0.0.15 → engin-0.0.16}/docs/guides/fastapi-graph.png +0 -0
- {engin-0.0.15 → engin-0.0.16}/docs/guides/fastapi.md +0 -0
- {engin-0.0.15 → engin-0.0.16}/docs/index.md +0 -0
- {engin-0.0.15 → engin-0.0.16}/docs/js/readthedocs.js +0 -0
- {engin-0.0.15 → engin-0.0.16}/docs/overrides/main.html +0 -0
- {engin-0.0.15 → engin-0.0.16}/docs/reference.md +0 -0
- {engin-0.0.15 → engin-0.0.16}/examples/__init__.py +0 -0
- {engin-0.0.15 → engin-0.0.16}/examples/asgi/__init__.py +0 -0
- {engin-0.0.15 → engin-0.0.16}/examples/asgi/app.py +0 -0
- {engin-0.0.15 → engin-0.0.16}/examples/asgi/common/__init__.py +0 -0
- {engin-0.0.15 → engin-0.0.16}/examples/asgi/common/db/__init__.py +0 -0
- {engin-0.0.15 → engin-0.0.16}/examples/asgi/common/db/adapaters/__init__.py +0 -0
- {engin-0.0.15 → engin-0.0.16}/examples/asgi/common/db/adapaters/memory.py +0 -0
- {engin-0.0.15 → engin-0.0.16}/examples/asgi/common/db/block.py +0 -0
- {engin-0.0.15 → engin-0.0.16}/examples/asgi/common/db/ports.py +0 -0
- {engin-0.0.15 → engin-0.0.16}/examples/asgi/common/starlette/__init__.py +0 -0
- {engin-0.0.15 → engin-0.0.16}/examples/asgi/common/starlette/endpoint.py +0 -0
- {engin-0.0.15 → engin-0.0.16}/examples/asgi/features/__init__.py +0 -0
- {engin-0.0.15 → engin-0.0.16}/examples/asgi/features/cats/__init__.py +0 -0
- {engin-0.0.15 → engin-0.0.16}/examples/asgi/features/cats/api/__init__.py +0 -0
- {engin-0.0.15 → engin-0.0.16}/examples/asgi/features/cats/api/get.py +0 -0
- {engin-0.0.15 → engin-0.0.16}/examples/asgi/features/cats/api/post.py +0 -0
- {engin-0.0.15 → engin-0.0.16}/examples/asgi/features/cats/block.py +0 -0
- {engin-0.0.15 → engin-0.0.16}/examples/asgi/features/cats/domain.py +0 -0
- {engin-0.0.15 → engin-0.0.16}/examples/asgi/main.py +0 -0
- {engin-0.0.15 → engin-0.0.16}/examples/fastapi/__init__.py +0 -0
- {engin-0.0.15 → engin-0.0.16}/examples/fastapi/app.py +0 -0
- {engin-0.0.15 → engin-0.0.16}/examples/fastapi/routes/__init__.py +0 -0
- {engin-0.0.15 → engin-0.0.16}/examples/fastapi/routes/cats/__init__.py +0 -0
- {engin-0.0.15 → engin-0.0.16}/examples/fastapi/routes/cats/adapters/__init__.py +0 -0
- {engin-0.0.15 → engin-0.0.16}/examples/fastapi/routes/cats/adapters/repository.py +0 -0
- {engin-0.0.15 → engin-0.0.16}/examples/fastapi/routes/cats/api.py +0 -0
- {engin-0.0.15 → engin-0.0.16}/examples/fastapi/routes/cats/block.py +0 -0
- {engin-0.0.15 → engin-0.0.16}/examples/fastapi/routes/cats/domain.py +0 -0
- {engin-0.0.15 → engin-0.0.16}/examples/fastapi/routes/cats/ports.py +0 -0
- {engin-0.0.15 → engin-0.0.16}/examples/simple/__init__.py +0 -0
- {engin-0.0.15 → engin-0.0.16}/examples/simple/main.py +0 -0
- {engin-0.0.15 → engin-0.0.16}/mkdocs.yaml +0 -0
- {engin-0.0.15 → engin-0.0.16}/src/engin/__init__.py +0 -0
- {engin-0.0.15 → engin-0.0.16}/src/engin/_cli/__init__.py +0 -0
- {engin-0.0.15 → engin-0.0.16}/src/engin/_cli/_utils.py +0 -0
- {engin-0.0.15 → engin-0.0.16}/src/engin/_engin.py +0 -0
- {engin-0.0.15 → engin-0.0.16}/src/engin/_graph.py +0 -0
- {engin-0.0.15 → engin-0.0.16}/src/engin/_introspect.py +0 -0
- {engin-0.0.15 → engin-0.0.16}/src/engin/_lifecycle.py +0 -0
- {engin-0.0.15 → engin-0.0.16}/src/engin/_option.py +0 -0
- {engin-0.0.15 → engin-0.0.16}/src/engin/_type_utils.py +0 -0
- {engin-0.0.15 → engin-0.0.16}/src/engin/ext/__init__.py +0 -0
- {engin-0.0.15 → engin-0.0.16}/src/engin/ext/fastapi.py +0 -0
- {engin-0.0.15 → engin-0.0.16}/src/engin/py.typed +0 -0
- {engin-0.0.15 → engin-0.0.16}/tests/__init__.py +0 -0
- {engin-0.0.15 → engin-0.0.16}/tests/acceptance/__init__.py +0 -0
- {engin-0.0.15 → engin-0.0.16}/tests/acceptance/test_error_in_shutdown.py +0 -0
- {engin-0.0.15 → engin-0.0.16}/tests/acceptance/test_error_in_start_up.py +0 -0
- {engin-0.0.15 → engin-0.0.16}/tests/cli/__init__.py +0 -0
- {engin-0.0.15 → engin-0.0.16}/tests/cli/test_graph.py +0 -0
- {engin-0.0.15 → engin-0.0.16}/tests/conftest.py +0 -0
- {engin-0.0.15 → engin-0.0.16}/tests/deps.py +0 -0
- {engin-0.0.15 → engin-0.0.16}/tests/test_block.py +0 -0
- {engin-0.0.15 → engin-0.0.16}/tests/test_dependencies.py +0 -0
- {engin-0.0.15 → engin-0.0.16}/tests/test_engin.py +0 -0
- {engin-0.0.15 → engin-0.0.16}/tests/test_graph.py +0 -0
- {engin-0.0.15 → engin-0.0.16}/tests/test_lifecycle.py +0 -0
- {engin-0.0.15 → engin-0.0.16}/tests/test_utils.py +0 -0
@@ -6,6 +6,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
7
7
|
|
8
8
|
|
9
|
+
## [0.0.16] - 2025-04-16
|
10
|
+
|
11
|
+
### Added
|
12
|
+
|
13
|
+
- Preliminary support for scoped providers. Scoped providers are only accessible when
|
14
|
+
the assembler is in the matching scope, and the built output is only cached until the
|
15
|
+
assembler leaves the matching scope. This can be used for example to have request scoped
|
16
|
+
providers in a Web Server.
|
17
|
+
|
18
|
+
### Changed
|
19
|
+
|
20
|
+
- Minor improvements to the work-in-progress dependency grapher.
|
21
|
+
|
22
|
+
|
9
23
|
## [0.0.15] - 2025-03-25
|
10
24
|
|
11
25
|
### Changed
|
@@ -9,7 +9,7 @@ from examples.fastapi.routes.cats.block import CatBlock
|
|
9
9
|
|
10
10
|
logging.basicConfig(level=logging.DEBUG)
|
11
11
|
|
12
|
-
app = FastAPIEngin(AppBlock(), CatBlock(), Supply(AppConfig(debug=True)))
|
12
|
+
app = FastAPIEngin(AppBlock(), CatBlock(), Supply(AppConfig(debug=True), override=True))
|
13
13
|
|
14
14
|
|
15
15
|
if __name__ == "__main__":
|
@@ -2,17 +2,26 @@ import asyncio
|
|
2
2
|
import logging
|
3
3
|
from collections import defaultdict
|
4
4
|
from collections.abc import Iterable
|
5
|
+
from contextvars import ContextVar
|
5
6
|
from dataclasses import dataclass
|
6
7
|
from inspect import BoundArguments, Signature
|
8
|
+
from types import TracebackType
|
7
9
|
from typing import Any, Generic, TypeVar, cast
|
8
10
|
|
9
11
|
from engin._dependency import Dependency, Provide, Supply
|
10
|
-
from engin._exceptions import ProviderError
|
12
|
+
from engin._exceptions import NotInScopeError, ProviderError
|
11
13
|
from engin._type_utils import TypeId
|
12
14
|
|
13
15
|
LOG = logging.getLogger("engin")
|
14
16
|
|
15
17
|
T = TypeVar("T")
|
18
|
+
_SCOPE: ContextVar[list[str] | None] = ContextVar("_SCOPE", default=None)
|
19
|
+
|
20
|
+
|
21
|
+
def _get_scope() -> list[str]:
|
22
|
+
if _SCOPE.get() is None:
|
23
|
+
_SCOPE.set([])
|
24
|
+
return cast("list[str]", _SCOPE.get())
|
16
25
|
|
17
26
|
|
18
27
|
@dataclass(slots=True, kw_only=True, frozen=True)
|
@@ -112,6 +121,8 @@ class Assembler:
|
|
112
121
|
|
113
122
|
out = []
|
114
123
|
for provider in self._multiproviders[type_id]:
|
124
|
+
if provider.scope and provider.scope not in _get_scope():
|
125
|
+
raise NotInScopeError(provider=provider, scope_stack=_get_scope())
|
115
126
|
assembled_dependency = await self.assemble(provider)
|
116
127
|
try:
|
117
128
|
out.extend(await assembled_dependency())
|
@@ -127,12 +138,16 @@ class Assembler:
|
|
127
138
|
if type_id not in self._providers:
|
128
139
|
raise LookupError(f"no provider found for target type id '{type_id}'")
|
129
140
|
|
130
|
-
|
141
|
+
provider = self._providers[type_id]
|
142
|
+
if provider.scope and provider.scope not in _get_scope():
|
143
|
+
raise NotInScopeError(provider=provider, scope_stack=_get_scope())
|
144
|
+
|
145
|
+
assembled_dependency = await self.assemble(provider)
|
131
146
|
try:
|
132
147
|
value = await assembled_dependency()
|
133
148
|
except Exception as err:
|
134
149
|
raise ProviderError(
|
135
|
-
provider=
|
150
|
+
provider=provider,
|
136
151
|
error_type=type(err),
|
137
152
|
error_message=str(err),
|
138
153
|
) from err
|
@@ -178,6 +193,14 @@ class Assembler:
|
|
178
193
|
del self._assembled_outputs[type_id]
|
179
194
|
self._providers[type_id] = provider
|
180
195
|
|
196
|
+
def scope(self, scope: str) -> "_ScopeContextManager":
|
197
|
+
return _ScopeContextManager(scope=scope, assembler=self)
|
198
|
+
|
199
|
+
def _exit_scope(self, scope: str) -> None:
|
200
|
+
for type_id, provider in self._providers.items():
|
201
|
+
if provider.scope == scope:
|
202
|
+
self._assembled_outputs.pop(type_id, None)
|
203
|
+
|
181
204
|
def _resolve_providers(self, type_id: TypeId) -> Iterable[Provide]:
|
182
205
|
"""
|
183
206
|
Resolves the chain of providers required to satisfy the provider of a given type.
|
@@ -251,3 +274,27 @@ class Assembler:
|
|
251
274
|
kwargs[param.name] = val
|
252
275
|
|
253
276
|
return signature.bind(*args, **kwargs)
|
277
|
+
|
278
|
+
|
279
|
+
class _ScopeContextManager:
|
280
|
+
def __init__(self, scope: str, assembler: Assembler) -> None:
|
281
|
+
self._scope = scope
|
282
|
+
self._assembler = assembler
|
283
|
+
|
284
|
+
def __enter__(self) -> Assembler:
|
285
|
+
_get_scope().append(self._scope)
|
286
|
+
return self._assembler
|
287
|
+
|
288
|
+
def __exit__(
|
289
|
+
self,
|
290
|
+
exc_type: type[BaseException] | None,
|
291
|
+
exc_value: BaseException | None,
|
292
|
+
traceback: TracebackType | None,
|
293
|
+
/,
|
294
|
+
) -> None:
|
295
|
+
popped = _get_scope().pop()
|
296
|
+
if popped != self._scope:
|
297
|
+
raise RuntimeError(
|
298
|
+
f"Exited scope '{popped}' is not the expected scope '{self._scope}'"
|
299
|
+
)
|
300
|
+
self._assembler._exit_scope(self._scope)
|
@@ -11,14 +11,14 @@ if TYPE_CHECKING:
|
|
11
11
|
|
12
12
|
|
13
13
|
def provide(
|
14
|
-
func_: Func | None = None, *, override: bool = False
|
14
|
+
func_: Func | None = None, *, scope: str | None = None, override: bool = False
|
15
15
|
) -> Func | Callable[[Func], Func]:
|
16
16
|
"""
|
17
17
|
A decorator for defining a Provider in a Block.
|
18
18
|
"""
|
19
19
|
|
20
20
|
def _inner(func: Func) -> Func:
|
21
|
-
func._opt = Provide(func, override=override) # type: ignore[attr-defined]
|
21
|
+
func._opt = Provide(func, override=override, scope=scope) # type: ignore[attr-defined]
|
22
22
|
return func
|
23
23
|
|
24
24
|
if func_ is None:
|
@@ -0,0 +1,78 @@
|
|
1
|
+
<!doctype html>
|
2
|
+
<html lang="en">
|
3
|
+
<style>
|
4
|
+
#mermaid-container {
|
5
|
+
width: 100%;
|
6
|
+
height: 100%;
|
7
|
+
overflow: auto; /* Enables scrolling */
|
8
|
+
border: 1px solid #ddd;
|
9
|
+
cursor: grab;
|
10
|
+
position: relative;
|
11
|
+
white-space: nowrap; /* Prevents wrapping */
|
12
|
+
}
|
13
|
+
|
14
|
+
#mermaid-content {
|
15
|
+
width: max-content; /* Ensures content can expand */
|
16
|
+
height: max-content;
|
17
|
+
}
|
18
|
+
</style>
|
19
|
+
<script type="module">
|
20
|
+
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
|
21
|
+
let config = { flowchart: { useMaxWidth: false, htmlLabels: true, defaultRenderer: "elk" } };
|
22
|
+
mermaid.initialize(config);
|
23
|
+
|
24
|
+
// Drag-to-Move Functionality
|
25
|
+
const container = document.getElementById("mermaid-container");
|
26
|
+
|
27
|
+
let isDragging = false;
|
28
|
+
let startX, startY, scrollLeft, scrollTop;
|
29
|
+
|
30
|
+
container.addEventListener("pointerdown", (e) => {
|
31
|
+
isDragging = true;
|
32
|
+
startX = e.clientX;
|
33
|
+
startY = e.clientY;
|
34
|
+
scrollLeft = container.scrollLeft;
|
35
|
+
scrollTop = container.scrollTop;
|
36
|
+
container.style.cursor = "grabbing";
|
37
|
+
});
|
38
|
+
|
39
|
+
container.addEventListener("pointermove", (e) => {
|
40
|
+
if (!isDragging) return;
|
41
|
+
const x = e.clientX - startX;
|
42
|
+
const y = e.clientY - startY;
|
43
|
+
container.scrollLeft = scrollLeft - x;
|
44
|
+
container.scrollTop = scrollTop - y;
|
45
|
+
});
|
46
|
+
|
47
|
+
container.addEventListener("pointerup", () => {
|
48
|
+
isDragging = false;
|
49
|
+
container.style.cursor = "grab";
|
50
|
+
});
|
51
|
+
|
52
|
+
container.addEventListener("pointerleave", () => {
|
53
|
+
isDragging = false;
|
54
|
+
container.style.cursor = "grab";
|
55
|
+
});
|
56
|
+
</script>
|
57
|
+
<body>
|
58
|
+
<div style="border-style:outset">
|
59
|
+
<p>LEGEND</p>
|
60
|
+
<pre class="mermaid" id="legend">
|
61
|
+
graph LR
|
62
|
+
%%LEGEND%%
|
63
|
+
classDef b0 fill:#7fc97f;
|
64
|
+
classDef external stroke-dasharray: 5 5;
|
65
|
+
</pre>
|
66
|
+
</div>
|
67
|
+
<div id="mermaid-container" style="width: 100%; overflow-x: auto; border: 1px solid #ddd; cursor: grab; position: relative;">
|
68
|
+
<div id="mermaid-content" style="width: max-content; height: max-content;">
|
69
|
+
<pre class="mermaid" id="graph">
|
70
|
+
%%{init: {"flowchart": {"defaultRenderer": "elk"}} }%%
|
71
|
+
graph LR
|
72
|
+
%%DATA%%
|
73
|
+
classDef external stroke-dasharray: 5 5;
|
74
|
+
</pre>
|
75
|
+
</div>
|
76
|
+
</div>
|
77
|
+
</body>
|
78
|
+
</html>
|
@@ -5,13 +5,14 @@ import socketserver
|
|
5
5
|
import sys
|
6
6
|
import threading
|
7
7
|
from http.server import BaseHTTPRequestHandler
|
8
|
+
from pathlib import Path
|
8
9
|
from time import sleep
|
9
10
|
from typing import Annotated, Any
|
10
11
|
|
11
12
|
import typer
|
12
13
|
from rich import print
|
13
14
|
|
14
|
-
from engin import Engin, Entrypoint, Invoke
|
15
|
+
from engin import Engin, Entrypoint, Invoke, TypeId
|
15
16
|
from engin._cli._utils import print_error
|
16
17
|
from engin._dependency import Dependency, Provide, Supply
|
17
18
|
from engin.ext.asgi import ASGIEngin
|
@@ -23,11 +24,9 @@ except ImportError:
|
|
23
24
|
|
24
25
|
cli = typer.Typer()
|
25
26
|
|
26
|
-
|
27
27
|
# mute logging from importing of files + engin's debug logging.
|
28
28
|
logging.disable()
|
29
29
|
|
30
|
-
|
31
30
|
_APP_ORIGIN = ""
|
32
31
|
|
33
32
|
_CLI_HELP = {
|
@@ -79,8 +78,25 @@ def serve_graph(
|
|
79
78
|
f"{_render_node(node.parent)} --> {_render_node(node.node)}"
|
80
79
|
for node in nodes
|
81
80
|
if node.parent is not None
|
81
|
+
and not (node.node.block_name and node.node.block_name == node.parent.block_name)
|
82
82
|
]
|
83
83
|
|
84
|
+
blocks = {node.node.block_name for node in nodes if node.node.block_name is not None}
|
85
|
+
|
86
|
+
# group blocks into subgraphs
|
87
|
+
for block in blocks:
|
88
|
+
dependencies.append(f"subgraph {block}")
|
89
|
+
dependencies.extend(
|
90
|
+
[
|
91
|
+
f"{_render_node(node.parent, False)} --> {_render_node(node.node, False)}"
|
92
|
+
for node in nodes
|
93
|
+
if node.parent is not None
|
94
|
+
and node.node.block_name == block
|
95
|
+
and node.parent.block_name == block
|
96
|
+
]
|
97
|
+
)
|
98
|
+
dependencies.append("end")
|
99
|
+
|
84
100
|
html = (
|
85
101
|
_GRAPH_HTML.replace("%%DATA%%", "\n".join(dependencies))
|
86
102
|
.replace(
|
@@ -123,18 +139,14 @@ _BLOCK_IDX: dict[str, int] = {}
|
|
123
139
|
_SEEN_BLOCKS: list[str] = []
|
124
140
|
|
125
141
|
|
126
|
-
def _render_node(node: Dependency) -> str:
|
142
|
+
def _render_node(node: Dependency, render_block: bool = True) -> str:
|
127
143
|
node_id = id(node)
|
128
144
|
md = ""
|
129
145
|
style = ""
|
130
146
|
|
131
147
|
# format block name
|
132
|
-
if n := node.block_name:
|
148
|
+
if render_block and (n := node.block_name):
|
133
149
|
md += f"_{n}_\n"
|
134
|
-
if n not in _BLOCK_IDX:
|
135
|
-
_BLOCK_IDX[n] = len(_SEEN_BLOCKS) % 8
|
136
|
-
_SEEN_BLOCKS.append(n)
|
137
|
-
style = f"b{_BLOCK_IDX[n]}"
|
138
150
|
|
139
151
|
node_root_package = node.source_package.split(".", maxsplit=1)[0]
|
140
152
|
if node_root_package != _APP_ORIGIN:
|
@@ -147,10 +159,10 @@ def _render_node(node: Dependency) -> str:
|
|
147
159
|
style = f":::{style}"
|
148
160
|
|
149
161
|
if isinstance(node, Supply):
|
150
|
-
md += f"{node.return_type_id}"
|
162
|
+
md += f"{_short_name(node.return_type_id)}"
|
151
163
|
return f'{node_id}("`{md}`"){style}'
|
152
164
|
if isinstance(node, Provide):
|
153
|
-
md += f"{node.return_type_id}"
|
165
|
+
md += f"{_short_name(node.return_type_id)}"
|
154
166
|
return f'{node_id}["`{md}`"]{style}'
|
155
167
|
if isinstance(node, Entrypoint):
|
156
168
|
entrypoint_type = node.parameter_types[0]
|
@@ -166,48 +178,11 @@ def _render_node(node: Dependency) -> str:
|
|
166
178
|
return f'{node_id}["`{node.name}`"]{style}'
|
167
179
|
|
168
180
|
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
<p>LEGEND</p>
|
175
|
-
<pre class="mermaid">
|
176
|
-
graph LR
|
177
|
-
%%LEGEND%%
|
178
|
-
classDef b0 fill:#7fc97f;
|
179
|
-
classDef external stroke-dasharray: 5 5;
|
180
|
-
</pre>
|
181
|
-
</div>
|
182
|
-
<pre class="mermaid">
|
183
|
-
graph TD
|
184
|
-
%%DATA%%
|
185
|
-
classDef b0 fill:#7fc97f;
|
186
|
-
classDef b1 fill:#beaed4;
|
187
|
-
classDef b2 fill:#fdc086;
|
188
|
-
classDef b3 fill:#ffff99;
|
189
|
-
classDef b4 fill:#386cb0;
|
190
|
-
classDef b5 fill:#f0027f;
|
191
|
-
classDef b6 fill:#bf5b17;
|
192
|
-
classDef b7 fill:#666666;
|
193
|
-
classDef b0E fill:#7fc97f,stroke-dasharray: 5 5;
|
194
|
-
classDef b1E fill:#beaed4,stroke-dasharray: 5 5;
|
195
|
-
classDef b2E fill:#fdc086,stroke-dasharray: 5 5;
|
196
|
-
classDef b3E fill:#ffff99,stroke-dasharray: 5 5;
|
197
|
-
classDef b4E fill:#386cb0,stroke-dasharray: 5 5;
|
198
|
-
classDef b5E fill:#f0027f,stroke-dasharray: 5 5;
|
199
|
-
classDef b6E fill:#bf5b17,stroke-dasharray: 5 5;
|
200
|
-
classDef b7E fill:#666666,stroke-dasharray: 5 5;
|
201
|
-
classDef external stroke-dasharray: 5 5;
|
202
|
-
</pre>
|
203
|
-
<script type="module">
|
204
|
-
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
|
205
|
-
let config = { flowchart: { useMaxWidth: false, htmlLabels: true } };
|
206
|
-
mermaid.initialize(config);
|
207
|
-
</script>
|
208
|
-
</body>
|
209
|
-
</html>
|
210
|
-
"""
|
181
|
+
def _short_name(name: TypeId) -> str:
|
182
|
+
return str(name).rsplit(".", maxsplit=1)[-1]
|
183
|
+
|
184
|
+
|
185
|
+
_GRAPH_HTML = (Path(__file__).parent / "_graph.html").read_text()
|
211
186
|
|
212
187
|
DEFAULT_LEGEND = (
|
213
188
|
"0[/Invoke/] ~~~ 1[/Entrypoint\\] ~~~ 2[Provide] ~~~ 3(Supply)"
|
@@ -152,16 +152,20 @@ class Entrypoint(Invoke):
|
|
152
152
|
|
153
153
|
|
154
154
|
class Provide(Dependency[Any, T]):
|
155
|
-
def __init__(
|
155
|
+
def __init__(
|
156
|
+
self, builder: Func[P, T], *, scope: str | None = None, override: bool = False
|
157
|
+
) -> None:
|
156
158
|
"""
|
157
159
|
Provide a type via a builder or factory function.
|
158
160
|
|
159
161
|
Args:
|
160
162
|
builder: the builder function that returns the type.
|
161
|
-
|
162
|
-
|
163
|
+
scope: (optional) associate this provider with a specific scope.
|
164
|
+
override: (optional) allow this provider to override existing providers from
|
165
|
+
the same package.
|
163
166
|
"""
|
164
167
|
super().__init__(func=builder)
|
168
|
+
self._scope = scope
|
165
169
|
self._override = override
|
166
170
|
self._is_multi = typing.get_origin(self.return_type) is list
|
167
171
|
|
@@ -203,6 +207,10 @@ class Provide(Dependency[Any, T]):
|
|
203
207
|
def is_multiprovider(self) -> bool:
|
204
208
|
return self._is_multi
|
205
209
|
|
210
|
+
@property
|
211
|
+
def scope(self) -> str | None:
|
212
|
+
return self._scope
|
213
|
+
|
206
214
|
def apply(self, engin: "Engin") -> None:
|
207
215
|
type_id = self.return_type_id
|
208
216
|
if self.is_multiprovider:
|
@@ -0,0 +1,54 @@
|
|
1
|
+
from typing import Any
|
2
|
+
|
3
|
+
from engin._dependency import Provide
|
4
|
+
|
5
|
+
|
6
|
+
class EnginError(Exception):
|
7
|
+
"""
|
8
|
+
Base class for all custom exceptions in the Engin library.
|
9
|
+
"""
|
10
|
+
|
11
|
+
|
12
|
+
class AssemblerError(EnginError):
|
13
|
+
"""
|
14
|
+
Base class for all custom exceptions raised by the Assembler.
|
15
|
+
"""
|
16
|
+
|
17
|
+
|
18
|
+
class ProviderError(AssemblerError):
|
19
|
+
"""
|
20
|
+
Raised when a Provider errors during Assembly.
|
21
|
+
"""
|
22
|
+
|
23
|
+
def __init__(
|
24
|
+
self,
|
25
|
+
provider: Provide[Any],
|
26
|
+
error_type: type[Exception],
|
27
|
+
error_message: str,
|
28
|
+
) -> None:
|
29
|
+
self.provider = provider
|
30
|
+
self.error_type = error_type
|
31
|
+
self.error_message = error_message
|
32
|
+
self.message = (
|
33
|
+
f"provider '{provider.name}' errored with error "
|
34
|
+
f"({error_type.__name__}): '{error_message}'"
|
35
|
+
)
|
36
|
+
|
37
|
+
def __str__(self) -> str:
|
38
|
+
return self.message
|
39
|
+
|
40
|
+
|
41
|
+
class NotInScopeError(AssemblerError):
|
42
|
+
"""
|
43
|
+
Raised when a Provider is requested outside of its scope.
|
44
|
+
"""
|
45
|
+
|
46
|
+
def __init__(self, provider: Provide[Any], scope_stack: list[str]) -> None:
|
47
|
+
self.provider = provider
|
48
|
+
self.message = (
|
49
|
+
f"provider '{provider.name}' was requested outside of its specified scope "
|
50
|
+
f"'{provider.scope}', current scope stack is {scope_stack}"
|
51
|
+
)
|
52
|
+
|
53
|
+
def __str__(self) -> str:
|
54
|
+
return self.message
|
@@ -47,7 +47,8 @@ class ASGIEngin(Engin, ASGIType):
|
|
47
47
|
elif message["type"] == "lifespan.shutdown":
|
48
48
|
await self.stop()
|
49
49
|
|
50
|
-
|
50
|
+
with self._assembler.scope("request"):
|
51
|
+
await self._asgi_app(scope, receive, send)
|
51
52
|
|
52
53
|
async def _startup(self) -> None:
|
53
54
|
self._asgi_app = await self._assembler.build(self._asgi_type)
|
@@ -1,3 +1,4 @@
|
|
1
|
+
import time
|
1
2
|
from typing import Annotated
|
2
3
|
|
3
4
|
import pytest
|
@@ -21,6 +22,14 @@ async def route_with_dep(some_int: Annotated[int, Inject(int)]) -> int:
|
|
21
22
|
return some_int
|
22
23
|
|
23
24
|
|
25
|
+
@ROUTER.get("/inject2")
|
26
|
+
async def route_with_dep_2(
|
27
|
+
some_int: Annotated[int, Inject(int)], some_str: Annotated[str, Inject(str)]
|
28
|
+
) -> int:
|
29
|
+
assert some_int == int(some_str)
|
30
|
+
return some_int
|
31
|
+
|
32
|
+
|
24
33
|
def app_factory(routers: list[APIRouter]) -> FastAPI:
|
25
34
|
app = FastAPI()
|
26
35
|
for router in routers:
|
@@ -47,12 +56,12 @@ async def test_inject():
|
|
47
56
|
|
48
57
|
|
49
58
|
async def test_graph():
|
50
|
-
engin = FastAPIEngin(Provide(app_factory), Supply([ROUTER]), Supply(10))
|
59
|
+
engin = FastAPIEngin(Provide(app_factory), Supply([ROUTER]), Supply(10), Supply("a"))
|
51
60
|
|
52
61
|
nodes = engin.graph()
|
53
62
|
|
54
|
-
assert len(nodes) ==
|
55
|
-
assert len([node for node in nodes if isinstance(node.node, APIRouteDependency)]) ==
|
63
|
+
assert len(nodes) == 8
|
64
|
+
assert len([node for node in nodes if isinstance(node.node, APIRouteDependency)]) == 3
|
56
65
|
|
57
66
|
|
58
67
|
async def test_invalid_engin():
|
@@ -70,3 +79,27 @@ async def test_engin_to_lifespan():
|
|
70
79
|
result = client.get("http://127.0.0.1:8000/inject")
|
71
80
|
|
72
81
|
assert result.json() == 10
|
82
|
+
|
83
|
+
|
84
|
+
async def test_asgi_request_scope():
|
85
|
+
def scoped_factory() -> int:
|
86
|
+
return time.time_ns()
|
87
|
+
|
88
|
+
def child_factory(some: int) -> str:
|
89
|
+
return str(some)
|
90
|
+
|
91
|
+
app = FastAPIEngin(
|
92
|
+
Provide(app_factory),
|
93
|
+
Provide(scoped_factory, scope="request"),
|
94
|
+
Provide(child_factory),
|
95
|
+
Supply([ROUTER]),
|
96
|
+
)
|
97
|
+
|
98
|
+
with starlette.testclient.TestClient(app) as client:
|
99
|
+
first_call = client.get("http://127.0.0.1:8000/inject").json()
|
100
|
+
second_call = client.get("http://127.0.0.1:8000/inject").json()
|
101
|
+
|
102
|
+
# these should differ as the factory is request scoped to each request
|
103
|
+
assert first_call != second_call
|
104
|
+
|
105
|
+
client.get("http://127.0.0.1:8000/inject2")
|
@@ -1,9 +1,10 @@
|
|
1
|
+
import time
|
1
2
|
from typing import Annotated
|
2
3
|
|
3
4
|
import pytest
|
4
5
|
|
5
6
|
from engin import Assembler, Entrypoint, Invoke, Provide
|
6
|
-
from engin._exceptions import ProviderError
|
7
|
+
from engin._exceptions import NotInScopeError, ProviderError
|
7
8
|
from tests.deps import make_int, make_many_int, make_many_int_alt, make_str
|
8
9
|
|
9
10
|
|
@@ -158,3 +159,45 @@ async def test_assembler_add_overrides():
|
|
158
159
|
assembler.add(Provide(return_two))
|
159
160
|
|
160
161
|
assert await assembler.build(int) == 2
|
162
|
+
|
163
|
+
|
164
|
+
async def test_assembler_provider_not_in_scope():
|
165
|
+
def scoped_provider() -> int:
|
166
|
+
return time.time_ns()
|
167
|
+
|
168
|
+
assembler = Assembler([Provide(scoped_provider, scope="foo")])
|
169
|
+
|
170
|
+
with pytest.raises(NotInScopeError):
|
171
|
+
await assembler.build(int)
|
172
|
+
|
173
|
+
|
174
|
+
async def test_assembler_provider_scope():
|
175
|
+
def scoped_provider() -> int:
|
176
|
+
return time.time_ns()
|
177
|
+
|
178
|
+
assembler = Assembler([Provide(scoped_provider, scope="foo")])
|
179
|
+
|
180
|
+
with assembler.scope("foo"):
|
181
|
+
await assembler.build(int)
|
182
|
+
|
183
|
+
with pytest.raises(NotInScopeError):
|
184
|
+
await assembler.build(int)
|
185
|
+
|
186
|
+
|
187
|
+
async def test_assembler_provider_multi_scope():
|
188
|
+
def scoped_provider() -> int:
|
189
|
+
return time.time_ns()
|
190
|
+
|
191
|
+
def scoped_provider_2() -> str:
|
192
|
+
return "bar"
|
193
|
+
|
194
|
+
assembler = Assembler(
|
195
|
+
[Provide(scoped_provider, scope="foo"), Provide(scoped_provider_2, scope="bar")]
|
196
|
+
)
|
197
|
+
|
198
|
+
with assembler.scope("foo"):
|
199
|
+
await assembler.build(int)
|
200
|
+
with assembler.scope("bar"):
|
201
|
+
await assembler.build(int)
|
202
|
+
await assembler.build(str)
|
203
|
+
await assembler.build(int)
|