engin 0.0.15__tar.gz → 0.0.17__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.
Files changed (90) hide show
  1. {engin-0.0.15 → engin-0.0.17}/CHANGELOG.md +26 -0
  2. {engin-0.0.15 → engin-0.0.17}/PKG-INFO +1 -1
  3. {engin-0.0.15 → engin-0.0.17}/examples/fastapi/main.py +1 -1
  4. {engin-0.0.15 → engin-0.0.17}/pyproject.toml +1 -1
  5. {engin-0.0.15 → engin-0.0.17}/src/engin/_assembler.py +51 -4
  6. {engin-0.0.15 → engin-0.0.17}/src/engin/_block.py +2 -2
  7. engin-0.0.17/src/engin/_cli/_graph.html +78 -0
  8. {engin-0.0.15 → engin-0.0.17}/src/engin/_cli/_graph.py +29 -54
  9. {engin-0.0.15 → engin-0.0.17}/src/engin/_dependency.py +36 -18
  10. engin-0.0.17/src/engin/_exceptions.py +54 -0
  11. {engin-0.0.15 → engin-0.0.17}/src/engin/_graph.py +1 -1
  12. {engin-0.0.15 → engin-0.0.17}/src/engin/ext/asgi.py +2 -1
  13. {engin-0.0.15 → engin-0.0.17}/src/engin/ext/fastapi.py +2 -2
  14. {engin-0.0.15 → engin-0.0.17}/tests/acceptance/test_fastapi.py +36 -3
  15. {engin-0.0.15 → engin-0.0.17}/tests/test_assembler.py +44 -1
  16. {engin-0.0.15 → engin-0.0.17}/tests/test_dependencies.py +12 -0
  17. {engin-0.0.15 → engin-0.0.17}/uv.lock +216 -190
  18. engin-0.0.15/src/engin/_exceptions.py +0 -26
  19. {engin-0.0.15 → engin-0.0.17}/.github/workflows/check.yaml +0 -0
  20. {engin-0.0.15 → engin-0.0.17}/.github/workflows/publish.yaml +0 -0
  21. {engin-0.0.15 → engin-0.0.17}/.gitignore +0 -0
  22. {engin-0.0.15 → engin-0.0.17}/.readthedocs.yaml +0 -0
  23. {engin-0.0.15 → engin-0.0.17}/LICENSE +0 -0
  24. {engin-0.0.15 → engin-0.0.17}/README.md +0 -0
  25. {engin-0.0.15 → engin-0.0.17}/docs/concepts/engin.md +0 -0
  26. {engin-0.0.15 → engin-0.0.17}/docs/concepts/invocations.md +0 -0
  27. {engin-0.0.15 → engin-0.0.17}/docs/concepts/lifecycle.md +0 -0
  28. {engin-0.0.15 → engin-0.0.17}/docs/concepts/providers.md +0 -0
  29. {engin-0.0.15 → engin-0.0.17}/docs/getting-started.md +0 -0
  30. {engin-0.0.15 → engin-0.0.17}/docs/guides/fastapi-graph.png +0 -0
  31. {engin-0.0.15 → engin-0.0.17}/docs/guides/fastapi.md +0 -0
  32. {engin-0.0.15 → engin-0.0.17}/docs/index.md +0 -0
  33. {engin-0.0.15 → engin-0.0.17}/docs/js/readthedocs.js +0 -0
  34. {engin-0.0.15 → engin-0.0.17}/docs/overrides/main.html +0 -0
  35. {engin-0.0.15 → engin-0.0.17}/docs/reference.md +0 -0
  36. {engin-0.0.15 → engin-0.0.17}/examples/__init__.py +0 -0
  37. {engin-0.0.15 → engin-0.0.17}/examples/asgi/__init__.py +0 -0
  38. {engin-0.0.15 → engin-0.0.17}/examples/asgi/app.py +0 -0
  39. {engin-0.0.15 → engin-0.0.17}/examples/asgi/common/__init__.py +0 -0
  40. {engin-0.0.15 → engin-0.0.17}/examples/asgi/common/db/__init__.py +0 -0
  41. {engin-0.0.15 → engin-0.0.17}/examples/asgi/common/db/adapaters/__init__.py +0 -0
  42. {engin-0.0.15 → engin-0.0.17}/examples/asgi/common/db/adapaters/memory.py +0 -0
  43. {engin-0.0.15 → engin-0.0.17}/examples/asgi/common/db/block.py +0 -0
  44. {engin-0.0.15 → engin-0.0.17}/examples/asgi/common/db/ports.py +0 -0
  45. {engin-0.0.15 → engin-0.0.17}/examples/asgi/common/starlette/__init__.py +0 -0
  46. {engin-0.0.15 → engin-0.0.17}/examples/asgi/common/starlette/endpoint.py +0 -0
  47. {engin-0.0.15 → engin-0.0.17}/examples/asgi/features/__init__.py +0 -0
  48. {engin-0.0.15 → engin-0.0.17}/examples/asgi/features/cats/__init__.py +0 -0
  49. {engin-0.0.15 → engin-0.0.17}/examples/asgi/features/cats/api/__init__.py +0 -0
  50. {engin-0.0.15 → engin-0.0.17}/examples/asgi/features/cats/api/get.py +0 -0
  51. {engin-0.0.15 → engin-0.0.17}/examples/asgi/features/cats/api/post.py +0 -0
  52. {engin-0.0.15 → engin-0.0.17}/examples/asgi/features/cats/block.py +0 -0
  53. {engin-0.0.15 → engin-0.0.17}/examples/asgi/features/cats/domain.py +0 -0
  54. {engin-0.0.15 → engin-0.0.17}/examples/asgi/main.py +0 -0
  55. {engin-0.0.15 → engin-0.0.17}/examples/fastapi/__init__.py +0 -0
  56. {engin-0.0.15 → engin-0.0.17}/examples/fastapi/app.py +0 -0
  57. {engin-0.0.15 → engin-0.0.17}/examples/fastapi/routes/__init__.py +0 -0
  58. {engin-0.0.15 → engin-0.0.17}/examples/fastapi/routes/cats/__init__.py +0 -0
  59. {engin-0.0.15 → engin-0.0.17}/examples/fastapi/routes/cats/adapters/__init__.py +0 -0
  60. {engin-0.0.15 → engin-0.0.17}/examples/fastapi/routes/cats/adapters/repository.py +0 -0
  61. {engin-0.0.15 → engin-0.0.17}/examples/fastapi/routes/cats/api.py +0 -0
  62. {engin-0.0.15 → engin-0.0.17}/examples/fastapi/routes/cats/block.py +0 -0
  63. {engin-0.0.15 → engin-0.0.17}/examples/fastapi/routes/cats/domain.py +0 -0
  64. {engin-0.0.15 → engin-0.0.17}/examples/fastapi/routes/cats/ports.py +0 -0
  65. {engin-0.0.15 → engin-0.0.17}/examples/simple/__init__.py +0 -0
  66. {engin-0.0.15 → engin-0.0.17}/examples/simple/main.py +0 -0
  67. {engin-0.0.15 → engin-0.0.17}/mkdocs.yaml +0 -0
  68. {engin-0.0.15 → engin-0.0.17}/src/engin/__init__.py +0 -0
  69. {engin-0.0.15 → engin-0.0.17}/src/engin/_cli/__init__.py +0 -0
  70. {engin-0.0.15 → engin-0.0.17}/src/engin/_cli/_utils.py +0 -0
  71. {engin-0.0.15 → engin-0.0.17}/src/engin/_engin.py +0 -0
  72. {engin-0.0.15 → engin-0.0.17}/src/engin/_introspect.py +0 -0
  73. {engin-0.0.15 → engin-0.0.17}/src/engin/_lifecycle.py +0 -0
  74. {engin-0.0.15 → engin-0.0.17}/src/engin/_option.py +0 -0
  75. {engin-0.0.15 → engin-0.0.17}/src/engin/_type_utils.py +0 -0
  76. {engin-0.0.15 → engin-0.0.17}/src/engin/ext/__init__.py +0 -0
  77. {engin-0.0.15 → engin-0.0.17}/src/engin/py.typed +0 -0
  78. {engin-0.0.15 → engin-0.0.17}/tests/__init__.py +0 -0
  79. {engin-0.0.15 → engin-0.0.17}/tests/acceptance/__init__.py +0 -0
  80. {engin-0.0.15 → engin-0.0.17}/tests/acceptance/test_error_in_shutdown.py +0 -0
  81. {engin-0.0.15 → engin-0.0.17}/tests/acceptance/test_error_in_start_up.py +0 -0
  82. {engin-0.0.15 → engin-0.0.17}/tests/cli/__init__.py +0 -0
  83. {engin-0.0.15 → engin-0.0.17}/tests/cli/test_graph.py +0 -0
  84. {engin-0.0.15 → engin-0.0.17}/tests/conftest.py +0 -0
  85. {engin-0.0.15 → engin-0.0.17}/tests/deps.py +0 -0
  86. {engin-0.0.15 → engin-0.0.17}/tests/test_block.py +0 -0
  87. {engin-0.0.15 → engin-0.0.17}/tests/test_engin.py +0 -0
  88. {engin-0.0.15 → engin-0.0.17}/tests/test_graph.py +0 -0
  89. {engin-0.0.15 → engin-0.0.17}/tests/test_lifecycle.py +0 -0
  90. {engin-0.0.15 → engin-0.0.17}/tests/test_utils.py +0 -0
@@ -6,6 +6,32 @@ 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.17] - 2025-04-20
10
+
11
+ ### Added
12
+
13
+ - `Provide` now has the `as_type` parameter that `Supply` had previously.
14
+
15
+ ### Changed
16
+
17
+ - Renamed `parameter_types` property on dependencies to `parameter_type_ids` to be more
18
+ explicit.
19
+
20
+
21
+ ## [0.0.16] - 2025-04-16
22
+
23
+ ### Added
24
+
25
+ - Preliminary support for scoped providers. Scoped providers are only accessible when
26
+ the assembler is in the matching scope, and the built output is only cached until the
27
+ assembler leaves the matching scope. This can be used for example to have request scoped
28
+ providers in a Web Server.
29
+
30
+ ### Changed
31
+
32
+ - Minor improvements to the work-in-progress dependency grapher.
33
+
34
+
9
35
  ## [0.0.15] - 2025-03-25
10
36
 
11
37
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: engin
3
- Version: 0.0.15
3
+ Version: 0.0.17
4
4
  Summary: An async-first modular application framework
5
5
  Project-URL: Homepage, https://github.com/invokermain/engin
6
6
  Project-URL: Documentation, https://engin.readthedocs.io/en/latest/
@@ -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__":
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "engin"
3
- version = "0.0.15"
3
+ version = "0.0.17"
4
4
  description = "An async-first modular application framework"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -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
- assembled_dependency = await self.assemble(self._providers[type_id])
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=self._providers[type_id],
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.
@@ -205,7 +228,7 @@ class Assembler:
205
228
  yield from (
206
229
  child_provider
207
230
  for root_provider in root_providers
208
- for root_provider_param in root_provider.parameter_types
231
+ for root_provider_param in root_provider.parameter_type_ids
209
232
  for child_provider in self._resolve_providers(root_provider_param)
210
233
  )
211
234
  yield from root_providers
@@ -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,13 +159,13 @@ 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
- entrypoint_type = node.parameter_types[0]
168
+ entrypoint_type = node.parameter_type_ids[0]
157
169
  md += f"{entrypoint_type}"
158
170
  return f'{node_id}[/"`{md}`"\\]{style}'
159
171
  if isinstance(node, Invoke):
@@ -166,48 +178,11 @@ def _render_node(node: Dependency) -> str:
166
178
  return f'{node_id}["`{node.name}`"]{style}'
167
179
 
168
180
 
169
- _GRAPH_HTML = """
170
- <!doctype html>
171
- <html lang="en">
172
- <body>
173
- <div style="border-style:outset">
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)"
@@ -76,7 +76,7 @@ class Dependency(ABC, Option, Generic[P, T]):
76
76
  return f"{self._func.__module__}.{self._func.__name__}"
77
77
 
78
78
  @property
79
- def parameter_types(self) -> list[TypeId]:
79
+ def parameter_type_ids(self) -> list[TypeId]:
80
80
  parameters = list(self._signature.parameters.values())
81
81
  if not parameters:
82
82
  return []
@@ -136,7 +136,7 @@ class Entrypoint(Invoke):
136
136
  super().__init__(invocation=_noop)
137
137
 
138
138
  @property
139
- def parameter_types(self) -> list[TypeId]:
139
+ def parameter_type_ids(self) -> list[TypeId]:
140
140
  return [TypeId.from_type(self._type)]
141
141
 
142
142
  @property
@@ -152,24 +152,40 @@ class Entrypoint(Invoke):
152
152
 
153
153
 
154
154
  class Provide(Dependency[Any, T]):
155
- def __init__(self, builder: Func[P, T], *, override: bool = False) -> None:
155
+ def __init__(
156
+ self,
157
+ builder: Func[P, T],
158
+ *,
159
+ scope: str | None = None,
160
+ as_type: type | None = None,
161
+ override: bool = False,
162
+ ) -> None:
156
163
  """
157
164
  Provide a type via a builder or factory function.
158
165
 
159
166
  Args:
160
167
  builder: the builder function that returns the type.
161
- override: allow this provider to override existing providers from the same
162
- package.
168
+ scope: (optional) associate this provider with a specific scope.
169
+ as_type: (optional) allows you to explicitly specify the provided type, e.g.
170
+ to type erase a concrete type, or to provide a mock implementation.
171
+ override: (optional) allow this provider to override other providers for the
172
+ same type from the same package.
163
173
  """
164
174
  super().__init__(func=builder)
175
+ self._scope = scope
165
176
  self._override = override
177
+ self._explicit_type = as_type
178
+
179
+ if self._explicit_type is not None:
180
+ self._signature = self._signature.replace(return_annotation=self._explicit_type)
181
+
166
182
  self._is_multi = typing.get_origin(self.return_type) is list
167
183
 
168
184
  # Validate that the provider does to depend on its own output value, as this will
169
185
  # cause a recursion error and is undefined behaviour wise.
170
186
  if any(
171
187
  self.return_type == param.annotation
172
- for param in self.signature.parameters.values()
188
+ for param in self._signature.parameters.values()
173
189
  ):
174
190
  raise ValueError("A provider cannot depend on its own return type")
175
191
 
@@ -183,6 +199,8 @@ class Provide(Dependency[Any, T]):
183
199
 
184
200
  @property
185
201
  def return_type(self) -> type[T]:
202
+ if self._explicit_type is not None:
203
+ return self._explicit_type
186
204
  if isclass(self._func):
187
205
  return_type = self._func # __init__ returns self
188
206
  else:
@@ -203,6 +221,10 @@ class Provide(Dependency[Any, T]):
203
221
  def is_multiprovider(self) -> bool:
204
222
  return self._is_multi
205
223
 
224
+ @property
225
+ def scope(self) -> str | None:
226
+ return self._scope
227
+
206
228
  def apply(self, engin: "Engin") -> None:
207
229
  type_id = self.return_type_id
208
230
  if self.is_multiprovider:
@@ -245,23 +267,19 @@ class Supply(Provide, Generic[T]):
245
267
  function.
246
268
 
247
269
  Args:
248
- value: the value to Supply
249
- as_type: allows you to specify the provided type, useful for type erasing,
250
- e.g. Supply a concrete value but specify it as an interface or other
251
- abstraction.
252
- override: allow this provider to override existing providers from the same
253
- package.
270
+ value: the value to Supply.
271
+ as_type: (optional) allows you to explicitly specify the provided type, e.g.
272
+ to type erase a concrete type, or to provide a mock implementation.
273
+ override: (optional) allow this provider to override other providers for the
274
+ same type from the same package.
254
275
  """
255
276
  self._value = value
256
- self._type_hint = as_type
257
- if self._type_hint is not None:
258
- self._get_val.__annotations__["return"] = as_type
259
- super().__init__(builder=self._get_val, override=override)
277
+ super().__init__(builder=self._get_val, as_type=as_type, override=override)
260
278
 
261
279
  @property
262
280
  def return_type(self) -> type[T]:
263
- if self._type_hint is not None:
264
- return self._type_hint
281
+ if self._explicit_type is not None:
282
+ return self._explicit_type
265
283
  if isinstance(self._value, list):
266
284
  return list[type(self._value[0])] # type: ignore[misc,return-value]
267
285
  return type(self._value)
@@ -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
@@ -31,7 +31,7 @@ class DependencyGrapher:
31
31
  ) -> list[Node]:
32
32
  nodes: list[Node] = []
33
33
  for root in roots:
34
- for parameter in root.parameter_types:
34
+ for parameter in root.parameter_type_ids:
35
35
  provider = self._providers[parameter]
36
36
 
37
37
  # multiprovider
@@ -47,7 +47,8 @@ class ASGIEngin(Engin, ASGIType):
47
47
  elif message["type"] == "lifespan.shutdown":
48
48
  await self.stop()
49
49
 
50
- await self._asgi_app(scope, receive, send)
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)
@@ -75,7 +75,7 @@ class _FastAPIDependencyGrapher(DependencyGrapher):
75
75
  ) -> list[Node]:
76
76
  nodes: list[Node] = []
77
77
  for root in roots:
78
- for parameter in root.parameter_types:
78
+ for parameter in root.parameter_type_ids:
79
79
  provider = self._providers[parameter]
80
80
 
81
81
  # multiprovider
@@ -162,7 +162,7 @@ class APIRouteDependency(Dependency):
162
162
  return self._route
163
163
 
164
164
  @property
165
- def parameter_types(self) -> list[TypeId]:
165
+ def parameter_type_ids(self) -> list[TypeId]:
166
166
  parameters = list(self._signature.parameters.values())
167
167
  if not parameters:
168
168
  return []
@@ -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) == 5
55
- assert len([node for node in nodes if isinstance(node.node, APIRouteDependency)]) == 2
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")