engin 0.0.15__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
@@ -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.
@@ -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)
engin/_block.py CHANGED
@@ -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:
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
@@ -152,16 +152,20 @@ 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, 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
- override: allow this provider to override existing providers from the same
162
- package.
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:
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,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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: engin
3
- Version: 0.0.15
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/
@@ -1,9 +1,9 @@
1
1
  engin/__init__.py,sha256=rBTteMLAVKg4TJSaMElJUwz72BA_X7nBTREg-I-bWhA,584
2
- engin/_assembler.py,sha256=GpTLW9AmGChnwWWK3SUq5AsxJJ8ukH7yWpemBiH87pw,9294
3
- engin/_block.py,sha256=Ypl6ffU52dgrHHgCcPokzfRD2-Lbu9b2wYMCgAZIx4g,2578
4
- engin/_dependency.py,sha256=w-MxF6Ju1Rc2umc7pk3bXTlc65NVIs1VEBj8825WEcg,8328
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
5
  engin/_engin.py,sha256=yIpZdeqvm8hv0RxOV0veFuvyu9xQ054JSaeuUWwHdOQ,7380
6
- engin/_exceptions.py,sha256=fsc4pTOIGHUh0x7oZhEXPJUTE268sIhswLoiqXaudiw,635
6
+ engin/_exceptions.py,sha256=UzMppJWDk_Hx3qWAypcPVLw9OYCibqiZjLYeTl22zaE,1355
7
7
  engin/_graph.py,sha256=1pMB0cr--uS0XJycDb1rS_X45RBpoyA6NkKqbeSuz1Q,1628
8
8
  engin/_introspect.py,sha256=VdREX6Lhhga5SnEP9G7mjHkgJR4mpqk_SMnmL2zTcqY,966
9
9
  engin/_lifecycle.py,sha256=cSWe3euZkmpxmUPFvph2lsTtvuZbxttEfBL-RnOI7lo,5325
@@ -11,13 +11,14 @@ engin/_option.py,sha256=nZcdrehp1QwgxMUoIpsM0PJuu1q1pbXzhcVsetbsHpc,223
11
11
  engin/_type_utils.py,sha256=Pmm4m1_WdevT5KTe8tzY_BseNxPyhu_nKsLGgyNcPpo,2247
12
12
  engin/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
13
  engin/_cli/__init__.py,sha256=lp1KiBpcgk_dZU5V9DjgLPwmp0ja444fwLH2CYCscNc,302
14
- engin/_cli/_graph.py,sha256=1Kj09BnKh5BTmuM4tqaGICS4KVDGNWT4oGFIrUa9xdU,6230
14
+ engin/_cli/_graph.html,sha256=rR5dnDKoz7KtSff0ERCi2UKuoH_Z03MRYiXI_W03G5k,2430
15
+ engin/_cli/_graph.py,sha256=2v-l5rEC4zm36SWgmzQ2UK-nIHofYpexTo3et55AtE0,5539
15
16
  engin/_cli/_utils.py,sha256=AQFtLO8qjYRCTQc9A8Z1HVf7eZr8iGWogxbYzsgIkS4,360
16
17
  engin/ext/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
17
- engin/ext/asgi.py,sha256=6V5Aad37MyGzkCtU5TlDrm0o5C04Un_LLvcomxnAmHY,3196
18
+ engin/ext/asgi.py,sha256=d5Z6gtMVWDZdAlvrTaMt987sKyiq__A0X4gJQ7IETmA,3247
18
19
  engin/ext/fastapi.py,sha256=e8UV521Mq9Iqr55CT7_jtd51iaIZjWlAacoqFBXsh-k,6356
19
- engin-0.0.15.dist-info/METADATA,sha256=qYhQHzJ_YrJEaZ_p4ddZL4OZDOtzWHkQFLimPH_XNDE,2354
20
- engin-0.0.15.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
21
- engin-0.0.15.dist-info/entry_points.txt,sha256=sW247zZUMxm0b5UKYvPuqQQljYDtU-j2zK3cu7gHwM0,41
22
- engin-0.0.15.dist-info/licenses/LICENSE,sha256=XHh5LPUPKZWTBqBv2xxN2RU7D59nHoiJGb5RIt8f45w,1070
23
- engin-0.0.15.dist-info/RECORD,,
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,,
File without changes