engin 0.0.14__py3-none-any.whl → 0.0.16__py3-none-any.whl

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/_assembler.py CHANGED
@@ -1,18 +1,27 @@
1
1
  import asyncio
2
2
  import logging
3
3
  from collections import defaultdict
4
- from collections.abc import Collection, Iterable
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)
@@ -39,7 +48,7 @@ class Assembler:
39
48
  A container for Providers that is responsible for building provided types.
40
49
 
41
50
  The Assembler acts as a cache for previously built types, meaning repeat calls
42
- to `get` will produce the same value.
51
+ to `build` will produce the same value.
43
52
 
44
53
  Examples:
45
54
  ```python
@@ -47,7 +56,7 @@ class Assembler:
47
56
  return "foo"
48
57
 
49
58
  a = Assembler([Provide(build_str)])
50
- await a.get(str)
59
+ await a.build(str)
51
60
  ```
52
61
  """
53
62
 
@@ -85,17 +94,15 @@ class Assembler:
85
94
  bound_args=await self._bind_arguments(dependency.signature),
86
95
  )
87
96
 
88
- async def get(self, type_: type[T]) -> T:
97
+ async def build(self, type_: type[T]) -> T:
89
98
  """
90
- Return the constructed value for the given type.
99
+ Build the type from Assembler's factories.
91
100
 
92
- This method assembles the required Providers and constructs their corresponding
93
- values.
94
-
95
- If the
101
+ If the type has been built previously the value will be cached and will return the
102
+ same instance.
96
103
 
97
104
  Args:
98
- type_: the type of the desired value.
105
+ type_: the type of the desired value to build.
99
106
 
100
107
  Raises:
101
108
  LookupError: When no provider is found for the given type.
@@ -114,6 +121,8 @@ class Assembler:
114
121
 
115
122
  out = []
116
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())
117
126
  assembled_dependency = await self.assemble(provider)
118
127
  try:
119
128
  out.extend(await assembled_dependency())
@@ -129,12 +138,16 @@ class Assembler:
129
138
  if type_id not in self._providers:
130
139
  raise LookupError(f"no provider found for target type id '{type_id}'")
131
140
 
132
- 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)
133
146
  try:
134
147
  value = await assembled_dependency()
135
148
  except Exception as err:
136
149
  raise ProviderError(
137
- provider=self._providers[type_id],
150
+ provider=provider,
138
151
  error_type=type(err),
139
152
  error_message=str(err),
140
153
  ) from err
@@ -180,31 +193,45 @@ class Assembler:
180
193
  del self._assembled_outputs[type_id]
181
194
  self._providers[type_id] = provider
182
195
 
183
- def _resolve_providers(self, type_id: TypeId) -> Collection[Provide]:
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
+
204
+ def _resolve_providers(self, type_id: TypeId) -> Iterable[Provide]:
205
+ """
206
+ Resolves the chain of providers required to satisfy the provider of a given type.
207
+ Ordering of the return value is very important!
208
+
209
+ # TODO: performance optimisation, do not recurse for already satisfied providers?
210
+ """
184
211
  if type_id.multi:
185
- providers = self._multiproviders.get(type_id)
212
+ root_providers = self._multiproviders.get(type_id)
186
213
  else:
187
- providers = [provider] if (provider := self._providers.get(type_id)) else None
188
- if not providers:
214
+ root_providers = [provider] if (provider := self._providers.get(type_id)) else None
215
+
216
+ if not root_providers:
189
217
  if type_id.multi:
190
218
  LOG.warning(f"no provider for '{type_id}' defaulting to empty list")
191
- providers = [(Supply([], type_hint=list[type_id.type]))] # type: ignore[name-defined]
219
+ root_providers = [(Supply([], as_type=list[type_id.type]))] # type: ignore[name-defined]
192
220
  # store default to prevent the warning appearing multiple times
193
- self._multiproviders[type_id] = providers
221
+ self._multiproviders[type_id] = root_providers
194
222
  else:
195
223
  available = sorted(str(k) for k in self._providers)
196
224
  msg = f"Missing Provider for type '{type_id}', available: {available}"
197
225
  raise LookupError(msg)
198
226
 
199
- required_providers: list[Provide[Any]] = []
200
- for provider in providers:
201
- required_providers.extend(
202
- provider
203
- for provider_param in provider.parameter_types
204
- for provider in self._resolve_providers(provider_param)
205
- )
206
-
207
- return {*required_providers, *providers}
227
+ # providers that must be satisfied to satisfy the root level providers
228
+ yield from (
229
+ child_provider
230
+ for root_provider in root_providers
231
+ for root_provider_param in root_provider.parameter_types
232
+ for child_provider in self._resolve_providers(root_provider_param)
233
+ )
234
+ yield from root_providers
208
235
 
209
236
  async def _satisfy(self, target: TypeId) -> None:
210
237
  for provider in self._resolve_providers(target):
@@ -247,3 +274,27 @@ class Assembler:
247
274
  kwargs[param.name] = val
248
275
 
249
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)
engin/_block.py CHANGED
@@ -1,5 +1,5 @@
1
1
  import inspect
2
- from collections.abc import Iterable, Sequence
2
+ from collections.abc import Callable, Iterable, Sequence
3
3
  from itertools import chain
4
4
  from typing import TYPE_CHECKING, ClassVar
5
5
 
@@ -10,20 +10,36 @@ if TYPE_CHECKING:
10
10
  from engin._engin import Engin
11
11
 
12
12
 
13
- def provide(func: Func) -> Func:
13
+ def provide(
14
+ func_: Func | None = None, *, scope: str | None = None, override: bool = False
15
+ ) -> Func | Callable[[Func], Func]:
14
16
  """
15
17
  A decorator for defining a Provider in a Block.
16
18
  """
17
- func._opt = Provide(func) # type: ignore[attr-defined]
18
- return func
19
19
 
20
+ def _inner(func: Func) -> Func:
21
+ func._opt = Provide(func, override=override, scope=scope) # type: ignore[attr-defined]
22
+ return func
20
23
 
21
- def invoke(func: Func) -> Func:
24
+ if func_ is None:
25
+ return _inner
26
+ else:
27
+ return _inner(func_)
28
+
29
+
30
+ def invoke(func_: Func | None = None) -> Func | Callable[[Func], Func]:
22
31
  """
23
32
  A decorator for defining an Invocation in a Block.
24
33
  """
25
- func._opt = Invoke(func) # type: ignore[attr-defined]
26
- return func
34
+
35
+ def _inner(func: Func) -> Func:
36
+ func._opt = Invoke(func) # type: ignore[attr-defined]
37
+ return func
38
+
39
+ if func_ is None:
40
+ return _inner
41
+ else:
42
+ return _inner(func_)
27
43
 
28
44
 
29
45
  class Block(Option):
engin/_cli/_graph.html ADDED
@@ -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>
engin/_cli/_graph.py CHANGED
@@ -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
- _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)"
engin/_dependency.py CHANGED
@@ -30,11 +30,11 @@ def _noop(*args: Any, **kwargs: Any) -> None: ...
30
30
 
31
31
 
32
32
  class Dependency(ABC, Option, Generic[P, T]):
33
- def __init__(self, func: Func[P, T], block_name: str | None = None) -> None:
33
+ def __init__(self, func: Func[P, T]) -> None:
34
34
  self._func = func
35
35
  self._is_async = iscoroutinefunction(func)
36
36
  self._signature = inspect.signature(self._func)
37
- self._block_name = block_name
37
+ self._block_name: str | None = None
38
38
 
39
39
  source_frame = get_first_external_frame()
40
40
  self._source_package = cast("str", source_frame.frame.f_globals["__package__"])
@@ -88,9 +88,6 @@ class Dependency(ABC, Option, Generic[P, T]):
88
88
  def signature(self) -> Signature:
89
89
  return self._signature
90
90
 
91
- def set_block_name(self, name: str) -> None:
92
- self._block_name = name
93
-
94
91
  async def __call__(self, *args: P.args, **kwargs: P.kwargs) -> T:
95
92
  if self._is_async:
96
93
  return await cast("Awaitable[T]", self._func(*args, **kwargs))
@@ -117,8 +114,8 @@ class Invoke(Dependency):
117
114
  ```
118
115
  """
119
116
 
120
- def __init__(self, invocation: Func[P, T], block_name: str | None = None) -> None:
121
- super().__init__(func=invocation, block_name=block_name)
117
+ def __init__(self, invocation: Func[P, T]) -> None:
118
+ super().__init__(func=invocation)
122
119
 
123
120
  def apply(self, engin: "Engin") -> None:
124
121
  engin._invocations.append(self)
@@ -134,9 +131,9 @@ class Entrypoint(Invoke):
134
131
  Entrypoints are a short hand for no-op Invocations that can be used to
135
132
  """
136
133
 
137
- def __init__(self, type_: type[Any], *, block_name: str | None = None) -> None:
134
+ def __init__(self, type_: type[Any]) -> None:
138
135
  self._type = type_
139
- super().__init__(invocation=_noop, block_name=block_name)
136
+ super().__init__(invocation=_noop)
140
137
 
141
138
  @property
142
139
  def parameter_types(self) -> list[TypeId]:
@@ -155,8 +152,21 @@ class Entrypoint(Invoke):
155
152
 
156
153
 
157
154
  class Provide(Dependency[Any, T]):
158
- def __init__(self, builder: Func[P, T], block_name: str | None = None) -> None:
159
- super().__init__(func=builder, block_name=block_name)
155
+ def __init__(
156
+ self, builder: Func[P, T], *, scope: str | None = None, override: bool = False
157
+ ) -> None:
158
+ """
159
+ Provide a type via a builder or factory function.
160
+
161
+ Args:
162
+ builder: the builder function that returns the type.
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.
166
+ """
167
+ super().__init__(func=builder)
168
+ self._scope = scope
169
+ self._override = override
160
170
  self._is_multi = typing.get_origin(self.return_type) is list
161
171
 
162
172
  # Validate that the provider does to depend on its own output value, as this will
@@ -197,11 +207,33 @@ class Provide(Dependency[Any, T]):
197
207
  def is_multiprovider(self) -> bool:
198
208
  return self._is_multi
199
209
 
210
+ @property
211
+ def scope(self) -> str | None:
212
+ return self._scope
213
+
200
214
  def apply(self, engin: "Engin") -> None:
215
+ type_id = self.return_type_id
201
216
  if self.is_multiprovider:
202
- engin._multiproviders[self.return_type_id].append(self)
203
- else:
204
- engin._providers[self.return_type_id] = self
217
+ engin._multiproviders[type_id].append(self)
218
+ return
219
+
220
+ if type_id not in engin._providers:
221
+ engin._providers[type_id] = self
222
+ return
223
+
224
+ existing_provider = engin._providers[type_id]
225
+ is_same_package = existing_provider.source_package == self.source_package
226
+
227
+ # overwriting a dependency from the same package must be explicit
228
+ if is_same_package and not self._override:
229
+ msg = (
230
+ f"Provider '{self.name}' is implicitly overriding "
231
+ f"'{existing_provider.name}', if this is intended specify "
232
+ "`override=True` for the overriding Provider"
233
+ )
234
+ raise RuntimeError(msg)
235
+
236
+ engin._providers[type_id] = self
205
237
 
206
238
  def __hash__(self) -> int:
207
239
  return hash(self.return_type_id)
@@ -212,13 +244,27 @@ class Provide(Dependency[Any, T]):
212
244
 
213
245
  class Supply(Provide, Generic[T]):
214
246
  def __init__(
215
- self, value: T, *, type_hint: type | None = None, block_name: str | None = None
247
+ self, value: T, *, as_type: type | None = None, override: bool = False
216
248
  ) -> None:
249
+ """
250
+ Supply a value.
251
+
252
+ This is a shorthand which under the hood creates a Provider with a noop factory
253
+ function.
254
+
255
+ Args:
256
+ value: the value to Supply
257
+ as_type: allows you to specify the provided type, useful for type erasing,
258
+ e.g. Supply a concrete value but specify it as an interface or other
259
+ abstraction.
260
+ override: allow this provider to override existing providers from the same
261
+ package.
262
+ """
217
263
  self._value = value
218
- self._type_hint = type_hint
264
+ self._type_hint = as_type
219
265
  if self._type_hint is not None:
220
- self._get_val.__annotations__["return"] = type_hint
221
- super().__init__(builder=self._get_val, block_name=block_name)
266
+ self._get_val.__annotations__["return"] = as_type
267
+ super().__init__(builder=self._get_val, override=override)
222
268
 
223
269
  @property
224
270
  def return_type(self) -> type[T]:
engin/_engin.py CHANGED
@@ -84,7 +84,7 @@ class Engin:
84
84
  self._run_task: Task | None = None
85
85
 
86
86
  self._providers: dict[TypeId, Provide] = {
87
- TypeId.from_type(Engin): Supply(self, type_hint=Engin)
87
+ TypeId.from_type(Engin): Supply(self, as_type=Engin)
88
88
  }
89
89
  self._multiproviders: dict[TypeId, list[Provide]] = defaultdict(list)
90
90
  self._invocations: list[Invoke] = []
@@ -132,14 +132,18 @@ class Engin:
132
132
  LOG.error(f"invocation '{name}' errored, exiting", exc_info=err)
133
133
  return
134
134
 
135
- lifecycle = await self._assembler.get(Lifecycle)
135
+ lifecycle = await self._assembler.build(Lifecycle)
136
136
 
137
137
  try:
138
138
  for hook in lifecycle.list():
139
- await self._exit_stack.enter_async_context(hook)
139
+ await asyncio.wait_for(self._exit_stack.enter_async_context(hook), timeout=15)
140
140
  except Exception as err:
141
- LOG.error("lifecycle startup error, exiting", exc_info=err)
142
- await self._exit_stack.aclose()
141
+ if isinstance(err, TimeoutError):
142
+ msg = "lifecycle startup task timed out after 15s, exiting"
143
+ else:
144
+ msg = "lifecycle startup task errored, exiting"
145
+ LOG.error(msg, exc_info=err)
146
+ await self._shutdown()
143
147
  return
144
148
 
145
149
  LOG.info("startup complete")
engin/_exceptions.py CHANGED
@@ -3,7 +3,19 @@ from typing import Any
3
3
  from engin._dependency import Provide
4
4
 
5
5
 
6
- class ProviderError(Exception):
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):
7
19
  """
8
20
  Raised when a Provider errors during Assembly.
9
21
  """
@@ -24,3 +36,19 @@ class ProviderError(Exception):
24
36
 
25
37
  def __str__(self) -> str:
26
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
engin/ext/asgi.py CHANGED
@@ -47,10 +47,11 @@ 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
- self._asgi_app = await self._assembler.get(self._asgi_type)
54
+ self._asgi_app = await self._assembler.build(self._asgi_type)
54
55
  await self.start()
55
56
 
56
57
  def graph(self) -> list[Node]:
engin/ext/fastapi.py CHANGED
@@ -58,7 +58,7 @@ def Inject(interface: type[T]) -> Depends:
58
58
  assembler: Assembler = conn.app.state.assembler
59
59
  except AttributeError:
60
60
  raise RuntimeError("Assembler is not attached to Application state") from None
61
- return await assembler.get(interface)
61
+ return await assembler.build(interface)
62
62
 
63
63
  dep = Depends(inner)
64
64
  dep.__engin__ = True # type: ignore[attr-defined]
@@ -143,7 +143,8 @@ class APIRouteDependency(Dependency):
143
143
  """
144
144
  Warning: this should never be constructed in application code.
145
145
  """
146
- super().__init__(_noop, wraps.block_name)
146
+ super().__init__(_noop)
147
+ self._block_name = wraps.block_name
147
148
  self._wrapped = wraps
148
149
  self._route = route
149
150
  self._signature = inspect.signature(route.endpoint)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: engin
3
- Version: 0.0.14
3
+ Version: 0.0.16
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/
@@ -0,0 +1,24 @@
1
+ engin/__init__.py,sha256=rBTteMLAVKg4TJSaMElJUwz72BA_X7nBTREg-I-bWhA,584
2
+ engin/_assembler.py,sha256=8rt16LGvPpXHtjSdEDQJ6XC6DVwSbr_4_Mcfcfnpf70,10949
3
+ engin/_block.py,sha256=8ysWrmHkWpTm6bmSc6jZVoO0Ax5Svu1HwxpZwAtIF_o,2617
4
+ engin/_dependency.py,sha256=KM_d4TEu7NaoOSuIC7lRO7UvPzBFb0sxR74ZbInLMng,8561
5
+ engin/_engin.py,sha256=yIpZdeqvm8hv0RxOV0veFuvyu9xQ054JSaeuUWwHdOQ,7380
6
+ engin/_exceptions.py,sha256=UzMppJWDk_Hx3qWAypcPVLw9OYCibqiZjLYeTl22zaE,1355
7
+ engin/_graph.py,sha256=1pMB0cr--uS0XJycDb1rS_X45RBpoyA6NkKqbeSuz1Q,1628
8
+ engin/_introspect.py,sha256=VdREX6Lhhga5SnEP9G7mjHkgJR4mpqk_SMnmL2zTcqY,966
9
+ engin/_lifecycle.py,sha256=cSWe3euZkmpxmUPFvph2lsTtvuZbxttEfBL-RnOI7lo,5325
10
+ engin/_option.py,sha256=nZcdrehp1QwgxMUoIpsM0PJuu1q1pbXzhcVsetbsHpc,223
11
+ engin/_type_utils.py,sha256=Pmm4m1_WdevT5KTe8tzY_BseNxPyhu_nKsLGgyNcPpo,2247
12
+ engin/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
+ engin/_cli/__init__.py,sha256=lp1KiBpcgk_dZU5V9DjgLPwmp0ja444fwLH2CYCscNc,302
14
+ engin/_cli/_graph.html,sha256=rR5dnDKoz7KtSff0ERCi2UKuoH_Z03MRYiXI_W03G5k,2430
15
+ engin/_cli/_graph.py,sha256=2v-l5rEC4zm36SWgmzQ2UK-nIHofYpexTo3et55AtE0,5539
16
+ engin/_cli/_utils.py,sha256=AQFtLO8qjYRCTQc9A8Z1HVf7eZr8iGWogxbYzsgIkS4,360
17
+ engin/ext/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
18
+ engin/ext/asgi.py,sha256=d5Z6gtMVWDZdAlvrTaMt987sKyiq__A0X4gJQ7IETmA,3247
19
+ engin/ext/fastapi.py,sha256=e8UV521Mq9Iqr55CT7_jtd51iaIZjWlAacoqFBXsh-k,6356
20
+ engin-0.0.16.dist-info/METADATA,sha256=1-9KPa3HdnKUM38_OD3yAdNHHTb4-cOBds8dlqedv9s,2354
21
+ engin-0.0.16.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
22
+ engin-0.0.16.dist-info/entry_points.txt,sha256=sW247zZUMxm0b5UKYvPuqQQljYDtU-j2zK3cu7gHwM0,41
23
+ engin-0.0.16.dist-info/licenses/LICENSE,sha256=XHh5LPUPKZWTBqBv2xxN2RU7D59nHoiJGb5RIt8f45w,1070
24
+ engin-0.0.16.dist-info/RECORD,,
@@ -1,23 +0,0 @@
1
- engin/__init__.py,sha256=rBTteMLAVKg4TJSaMElJUwz72BA_X7nBTREg-I-bWhA,584
2
- engin/_assembler.py,sha256=AJHrFKSlZiSNCDOpS-0-C16ns9XFYJUXrUX-phdtjKs,9003
3
- engin/_block.py,sha256=qOM3tSULwPEjNDkIERF0PSMe-1_Ea8Ihtv4Z8f94U0Y,2178
4
- engin/_dependency.py,sha256=aG-pW0hvW9ERuVQjPJLr0SJ4Ju7kXXS9qUozkHk4q48,7102
5
- engin/_engin.py,sha256=GwsR9iQGUIuIt0OeTpi2jr6XtWZfyh4PZUM4fz36axk,7186
6
- engin/_exceptions.py,sha256=fsc4pTOIGHUh0x7oZhEXPJUTE268sIhswLoiqXaudiw,635
7
- engin/_graph.py,sha256=1pMB0cr--uS0XJycDb1rS_X45RBpoyA6NkKqbeSuz1Q,1628
8
- engin/_introspect.py,sha256=VdREX6Lhhga5SnEP9G7mjHkgJR4mpqk_SMnmL2zTcqY,966
9
- engin/_lifecycle.py,sha256=cSWe3euZkmpxmUPFvph2lsTtvuZbxttEfBL-RnOI7lo,5325
10
- engin/_option.py,sha256=nZcdrehp1QwgxMUoIpsM0PJuu1q1pbXzhcVsetbsHpc,223
11
- engin/_type_utils.py,sha256=Pmm4m1_WdevT5KTe8tzY_BseNxPyhu_nKsLGgyNcPpo,2247
12
- engin/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
- engin/_cli/__init__.py,sha256=lp1KiBpcgk_dZU5V9DjgLPwmp0ja444fwLH2CYCscNc,302
14
- engin/_cli/_graph.py,sha256=1Kj09BnKh5BTmuM4tqaGICS4KVDGNWT4oGFIrUa9xdU,6230
15
- engin/_cli/_utils.py,sha256=AQFtLO8qjYRCTQc9A8Z1HVf7eZr8iGWogxbYzsgIkS4,360
16
- engin/ext/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
17
- engin/ext/asgi.py,sha256=cpplCnJSKB3yZb-AL6w70CW0SWsRmrR0S6mxfyJI-w8,3194
18
- engin/ext/fastapi.py,sha256=Z8pA8hrfcXbVKfDIuSfL94wHzW0E5WLJoYOjEVzuNMk,6328
19
- engin-0.0.14.dist-info/METADATA,sha256=VDeCZ_auc2cIoRHPuGf7_Cbarqf3r7_eEOjm72HVHpY,2354
20
- engin-0.0.14.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
21
- engin-0.0.14.dist-info/entry_points.txt,sha256=sW247zZUMxm0b5UKYvPuqQQljYDtU-j2zK3cu7gHwM0,41
22
- engin-0.0.14.dist-info/licenses/LICENSE,sha256=XHh5LPUPKZWTBqBv2xxN2RU7D59nHoiJGb5RIt8f45w,1070
23
- engin-0.0.14.dist-info/RECORD,,
File without changes