engin 0.0.8__py3-none-any.whl → 0.0.9__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/_block.py CHANGED
@@ -59,6 +59,8 @@ class Block(Iterable[Provide | Invoke]):
59
59
  raise RuntimeError("Block option is not an instance of Provide or Invoke")
60
60
  opt.set_block_name(self._name)
61
61
  self._options.append(opt)
62
+ for opt in self.options:
63
+ opt.set_block_name(self._name)
62
64
 
63
65
  @property
64
66
  def name(self) -> str:
engin/_dependency.py CHANGED
@@ -31,13 +31,23 @@ class Dependency(ABC, Generic[P, T]):
31
31
  self._block_name = block_name
32
32
 
33
33
  @property
34
- def module(self) -> str:
34
+ def origin(self) -> str:
35
+ """
36
+ The module that this Dependency originated from.
37
+
38
+ Returns:
39
+ A string, e.g. "examples.fastapi.app"
40
+ """
35
41
  return self._func.__module__
36
42
 
37
43
  @property
38
44
  def block_name(self) -> str | None:
39
45
  return self._block_name
40
46
 
47
+ @property
48
+ def func_name(self) -> str:
49
+ return self._func.__name__
50
+
41
51
  @property
42
52
  def name(self) -> str:
43
53
  if self._block_name:
@@ -105,6 +115,10 @@ class Entrypoint(Invoke):
105
115
  self._type = type_
106
116
  super().__init__(invocation=_noop, block_name=block_name)
107
117
 
118
+ @property
119
+ def origin(self) -> str:
120
+ return self._type.__module__
121
+
108
122
  @property
109
123
  def parameter_types(self) -> list[TypeId]:
110
124
  return [type_id_of(self._type)]
@@ -172,6 +186,10 @@ class Supply(Provide, Generic[T]):
172
186
  self._get_val.__annotations__["return"] = type_hint
173
187
  super().__init__(builder=self._get_val, block_name=block_name)
174
188
 
189
+ @property
190
+ def origin(self) -> str:
191
+ return self._value.__module__
192
+
175
193
  @property
176
194
  def return_type(self) -> type[T]:
177
195
  if self._type_hint is not None:
engin/_graph.py CHANGED
@@ -1,39 +1,50 @@
1
1
  from collections.abc import Iterable
2
- from typing import TypedDict
2
+ from dataclasses import dataclass
3
3
 
4
- from engin._dependency import Dependency, Provide
4
+ from engin import Provide
5
+ from engin._dependency import Dependency
5
6
  from engin._type_utils import TypeId
6
7
 
7
8
 
8
- class Node(TypedDict):
9
+ @dataclass(slots=True, frozen=True, kw_only=True)
10
+ class Node:
11
+ """
12
+ A Node in the Dependency Graph.
13
+ """
14
+
9
15
  node: Dependency
10
16
  parent: Dependency | None
11
17
 
18
+ def __repr__(self) -> str:
19
+ return f"Node(node={self.node!s},parent={self.parent!s})"
20
+
12
21
 
13
22
  class DependencyGrapher:
14
23
  def __init__(self, providers: dict[TypeId, Provide | list[Provide]]) -> None:
15
24
  self._providers: dict[TypeId, Provide | list[Provide]] = providers
16
25
 
17
26
  def resolve(self, roots: Iterable[Dependency]) -> list[Node]:
18
- seen: set[TypeId] = set()
19
- nodes: list[Node] = []
27
+ return self._resolve_recursive(roots, seen=set())
20
28
 
29
+ def _resolve_recursive(
30
+ self, roots: Iterable[Dependency], *, seen: set[TypeId]
31
+ ) -> list[Node]:
32
+ nodes: list[Node] = []
21
33
  for root in roots:
22
34
  for parameter in root.parameter_types:
23
- if parameter in seen:
24
- continue
25
-
26
- seen.add(parameter)
27
35
  provider = self._providers[parameter]
28
36
 
29
37
  # multiprovider
30
38
  if isinstance(provider, list):
31
- for p in provider:
32
- nodes.append({"node": p, "parent": root})
33
- nodes.extend(self.resolve([p]))
39
+ nodes.extend(Node(node=p, parent=root) for p in provider)
40
+ if parameter not in seen:
41
+ nodes.extend(self._resolve_recursive(provider, seen=seen))
34
42
  # single provider
35
43
  else:
36
- nodes.append({"node": provider, "parent": root})
37
- nodes.extend(self.resolve([provider]))
44
+ nodes.append(Node(node=provider, parent=root))
45
+ if parameter not in seen:
46
+ nodes.extend(self._resolve_recursive([provider], seen=seen))
47
+
48
+ seen.add(parameter)
38
49
 
39
50
  return nodes
engin/ext/asgi.py CHANGED
@@ -2,10 +2,11 @@ import traceback
2
2
  from collections.abc import Awaitable, Callable, MutableMapping
3
3
  from typing import Any, ClassVar, Protocol, TypeAlias
4
4
 
5
- from engin import Engin, Option
5
+ from engin import Engin, Entrypoint, Option
6
6
 
7
7
  __all__ = ["ASGIEngin", "ASGIType"]
8
8
 
9
+ from engin._graph import DependencyGrapher, Node
9
10
 
10
11
  _Scope: TypeAlias = MutableMapping[str, Any]
11
12
  _Message: TypeAlias = MutableMapping[str, Any]
@@ -49,6 +50,10 @@ class ASGIEngin(Engin, ASGIType):
49
50
  await self.start()
50
51
  self._asgi_app = await self._assembler.get(self._asgi_type)
51
52
 
53
+ def graph(self) -> list[Node]:
54
+ grapher = DependencyGrapher({**self._providers, **self._multiproviders})
55
+ return grapher.resolve([Entrypoint(self._asgi_type), *self._invocations])
56
+
52
57
 
53
58
  class _Rereceive:
54
59
  def __init__(self, message: _Message) -> None:
engin/ext/fastapi.py CHANGED
@@ -1,10 +1,19 @@
1
+ import inspect
2
+ import typing
3
+ from collections.abc import Iterable
4
+ from inspect import Parameter
1
5
  from typing import ClassVar, TypeVar
2
6
 
3
- from engin import Engin, Invoke, Option
7
+ from fastapi.routing import APIRoute
8
+
9
+ from engin import Engin, Entrypoint, Invoke, Option
10
+ from engin._dependency import Dependency, Supply
11
+ from engin._graph import DependencyGrapher, Node
12
+ from engin._type_utils import TypeId, type_id_of
4
13
  from engin.ext.asgi import ASGIEngin
5
14
 
6
15
  try:
7
- from fastapi import FastAPI
16
+ from fastapi import APIRouter, FastAPI
8
17
  from fastapi.params import Depends
9
18
  from starlette.requests import HTTPConnection
10
19
  except ImportError as err:
@@ -12,7 +21,7 @@ except ImportError as err:
12
21
  "fastapi package must be installed to use the fastapi extension"
13
22
  ) from err
14
23
 
15
- __all__ = ["FastAPIEngin", "Inject"]
24
+ __all__ = ["APIRouteDependency", "FastAPIEngin", "Inject"]
16
25
 
17
26
 
18
27
  def _attach_engin(
@@ -26,6 +35,15 @@ class FastAPIEngin(ASGIEngin):
26
35
  _LIB_OPTIONS: ClassVar[list[Option]] = [*ASGIEngin._LIB_OPTIONS, Invoke(_attach_engin)]
27
36
  _asgi_type = FastAPI
28
37
 
38
+ def graph(self) -> list[Node]:
39
+ grapher = _FastAPIDependencyGrapher({**self._providers, **self._multiproviders})
40
+ return grapher.resolve(
41
+ [
42
+ Entrypoint(self._asgi_type),
43
+ *[i for i in self._invocations if i.func_name != "_attach_engin"],
44
+ ]
45
+ )
46
+
29
47
 
30
48
  T = TypeVar("T")
31
49
 
@@ -35,4 +53,116 @@ def Inject(interface: type[T]) -> Depends:
35
53
  engin: Engin = conn.app.state.engin
36
54
  return await engin.assembler.get(interface)
37
55
 
38
- return Depends(inner)
56
+ dep = Depends(inner)
57
+ dep.__engin__ = True # type: ignore[attr-defined]
58
+ return dep
59
+
60
+
61
+ class _FastAPIDependencyGrapher(DependencyGrapher):
62
+ """
63
+ This exists in order to bridge the gap between
64
+ """
65
+
66
+ def _resolve_recursive(
67
+ self, roots: Iterable[Dependency], *, seen: set[TypeId]
68
+ ) -> list[Node]:
69
+ nodes: list[Node] = []
70
+ for root in roots:
71
+ for parameter in root.parameter_types:
72
+ provider = self._providers[parameter]
73
+
74
+ # multiprovider
75
+ if isinstance(provider, list):
76
+ for p in provider:
77
+ nodes.append(Node(node=p, parent=root))
78
+
79
+ if isinstance(p, Supply):
80
+ route_dependencies = _extract_routes_from_supply(p)
81
+ nodes.extend(
82
+ Node(node=route_dependency, parent=p)
83
+ for route_dependency in route_dependencies
84
+ )
85
+ nodes.extend(
86
+ self._resolve_recursive(route_dependencies, seen=seen)
87
+ )
88
+
89
+ if parameter not in seen:
90
+ nodes.extend(self._resolve_recursive(provider, seen=seen))
91
+ # single provider
92
+ else:
93
+ nodes.append(Node(node=provider, parent=root))
94
+ # not sure why anyone would ever supply a single APIRouter in an
95
+ # application, but just in case
96
+ if isinstance(provider, Supply):
97
+ route_dependencies = _extract_routes_from_supply(provider)
98
+ nodes.extend(
99
+ Node(node=route_dependency, parent=provider)
100
+ for route_dependency in route_dependencies
101
+ )
102
+ nodes.extend(self._resolve_recursive(route_dependencies, seen=seen))
103
+ if parameter not in seen:
104
+ nodes.extend(self._resolve_recursive([provider], seen=seen))
105
+
106
+ seen.add(parameter)
107
+
108
+ return nodes
109
+
110
+
111
+ def _extract_routes_from_supply(supply: Supply) -> list[Dependency]:
112
+ if supply.is_multiprovider:
113
+ inner = supply._value[0]
114
+ if isinstance(inner, APIRouter):
115
+ return [
116
+ APIRouteDependency(route, block_name=supply.block_name)
117
+ for route in inner.routes
118
+ if isinstance(route, APIRoute)
119
+ ]
120
+ return []
121
+
122
+
123
+ class APIRouteDependency(Dependency):
124
+ """
125
+ This is a pseudo-dependency that is only used when calling FastAPIEngin.graph() in
126
+ order to provide richer metadata to the Node.
127
+
128
+ This class should never be constructed in application code.
129
+ """
130
+
131
+ def __init__(self, route: APIRoute, block_name: str | None = None) -> None:
132
+ """
133
+ Warning: this should never be constructed in application code.
134
+ """
135
+ self._route = route
136
+ self._signature = inspect.signature(route.endpoint)
137
+ self._block_name = block_name
138
+
139
+ @property
140
+ def route(self) -> APIRoute:
141
+ return self._route
142
+
143
+ @property
144
+ def parameter_types(self) -> list[TypeId]:
145
+ parameters = list(self._signature.parameters.values())
146
+ if not parameters:
147
+ return []
148
+ if parameters[0].name == "self":
149
+ parameters.pop(0)
150
+ return [
151
+ type_id_of(typing.get_args(param.annotation)[0])
152
+ for param in parameters
153
+ if self._is_injected_param(param)
154
+ ]
155
+
156
+ @staticmethod
157
+ def _is_injected_param(param: Parameter) -> bool:
158
+ if typing.get_origin(param.annotation) != typing.Annotated:
159
+ return False
160
+ args = typing.get_args(param.annotation)
161
+ if len(args) != 2:
162
+ return False
163
+ return isinstance(args[1], Depends) and hasattr(args[1], "__engin__")
164
+
165
+ @property
166
+ def name(self) -> str:
167
+ methods = ",".join(self._route.methods)
168
+ return f"{methods} {self._route.path}"
engin/scripts/graph.py CHANGED
@@ -8,8 +8,10 @@ from http.server import BaseHTTPRequestHandler
8
8
  from time import sleep
9
9
  from typing import Any
10
10
 
11
- from engin import Engin
12
- from engin._dependency import Dependency, Provide
11
+ from engin import Engin, Entrypoint, Invoke
12
+ from engin._dependency import Dependency, Provide, Supply
13
+ from engin.ext.asgi import ASGIEngin
14
+ from engin.ext.fastapi import APIRouteDependency
13
15
 
14
16
  # mute logging from importing of files + engin's debug logging.
15
17
  logging.disable()
@@ -18,9 +20,6 @@ args = ArgumentParser(
18
20
  prog="engin-graph",
19
21
  description="Creates a visualisation of your application's dependencies",
20
22
  )
21
- args.add_argument(
22
- "-e", "--exclude", help="a list of packages or module to exclude", default=["engin"]
23
- )
24
23
  args.add_argument(
25
24
  "app",
26
25
  help=(
@@ -37,7 +36,6 @@ def serve_graph() -> None:
37
36
  parsed = args.parse_args()
38
37
 
39
38
  app = parsed.app
40
- excluded_modules = parsed.exclude
41
39
 
42
40
  try:
43
41
  module_name, engin_name = app.split(":", maxsplit=1)
@@ -60,13 +58,19 @@ def serve_graph() -> None:
60
58
 
61
59
  # transform dependencies into mermaid syntax
62
60
  dependencies = [
63
- f"{_render_node(node['parent'])} --> {_render_node(node['node'])}"
61
+ f"{_render_node(node.parent)} --> {_render_node(node.node)}"
64
62
  for node in nodes
65
- if node["parent"] is not None
66
- and not _should_exclude(node["node"].module, excluded_modules)
63
+ if node.parent is not None
67
64
  ]
68
65
 
69
- html = _GRAPH_HTML.replace("%%DATA%%", "\n".join(dependencies)).encode("utf8")
66
+ html = (
67
+ _GRAPH_HTML.replace("%%DATA%%", "\n".join(dependencies))
68
+ .replace(
69
+ "%%LEGEND%%",
70
+ ASGI_ENGIN_LEGEND if isinstance(instance, ASGIEngin) else DEFAULT_LEGEND,
71
+ )
72
+ .encode("utf8")
73
+ )
70
74
 
71
75
  class Handler(BaseHTTPRequestHandler):
72
76
  def do_GET(self) -> None:
@@ -93,24 +97,66 @@ def serve_graph() -> None:
93
97
  print("Exiting the server...")
94
98
 
95
99
 
100
+ _BLOCK_IDX: dict[str, int] = {}
101
+ _SEEN_BLOCKS: list[str] = []
102
+
103
+
96
104
  def _render_node(node: Dependency) -> str:
105
+ node_id = id(node)
106
+ md = ""
107
+ style = ""
108
+
109
+ # format block name
110
+ if n := node.block_name:
111
+ md += f"_{n}_\n"
112
+ if n not in _BLOCK_IDX:
113
+ _BLOCK_IDX[n] = len(_SEEN_BLOCKS) % 8
114
+ _SEEN_BLOCKS.append(n)
115
+ style = f":::b{_BLOCK_IDX[n]}"
116
+
117
+ if isinstance(node, Supply):
118
+ md += f"{node.return_type_id}"
119
+ return f'{node_id}("`{md}`"){style}'
97
120
  if isinstance(node, Provide):
98
- return str(node.return_type_id)
121
+ md += f"{node.return_type_id}"
122
+ return f'{node_id}["`{md}`"]{style}'
123
+ if isinstance(node, Entrypoint):
124
+ entrypoint_type = node.parameter_types[0]
125
+ md += f"{entrypoint_type}"
126
+ return f'{node_id}[/"`{md}`"\\]{style}'
127
+ if isinstance(node, Invoke):
128
+ md += f"{node.func_name}"
129
+ return f'{node_id}[/"`{md}`"/]{style}'
130
+ if isinstance(node, APIRouteDependency):
131
+ md += f"{node.name}"
132
+ return f'{node_id}[["`{md}`"]]{style}'
99
133
  else:
100
- return node.name
101
-
102
-
103
- def _should_exclude(module: str, excluded: list[str]) -> bool:
104
- return any(module.startswith(e) for e in excluded)
134
+ return f'{node_id}["`{node.name}`"]{style}'
105
135
 
106
136
 
107
137
  _GRAPH_HTML = """
108
138
  <!doctype html>
109
139
  <html lang="en">
110
140
  <body>
141
+ <div style="border-style:outset">
142
+ <p>LEGEND</p>
143
+ <pre class="mermaid">
144
+ graph LR
145
+ %%LEGEND%%
146
+ classDef b0 fill:#7fc97f;
147
+ </pre>
148
+ </div>
111
149
  <pre class="mermaid">
112
150
  graph TD
113
151
  %%DATA%%
152
+ classDef b0 fill:#7fc97f;
153
+ classDef b1 fill:#beaed4;
154
+ classDef b2 fill:#fdc086;
155
+ classDef b3 fill:#ffff99;
156
+ classDef b4 fill:#386cb0;
157
+ classDef b5 fill:#f0027f;
158
+ classDef b6 fill:#bf5b17;
159
+ classDef b7 fill:#666666;
114
160
  </pre>
115
161
  <script type="module">
116
162
  import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
@@ -120,3 +166,9 @@ _GRAPH_HTML = """
120
166
  </body>
121
167
  </html>
122
168
  """
169
+
170
+ DEFAULT_LEGEND = (
171
+ "0[/Invoke/] ~~~ 1[/Entrypoint\\] ~~~ 2[Provide] ~~~ 3(Supply)"
172
+ ' ~~~ 4["`Block Grouping`"]:::b0'
173
+ )
174
+ ASGI_ENGIN_LEGEND = DEFAULT_LEGEND + " ~~~ 5[[API Route]]"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: engin
3
- Version: 0.0.8
3
+ Version: 0.0.9
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,20 @@
1
+ engin/__init__.py,sha256=yTc8k0HDGMIrxDdEEA90qGD_dExQjVIbXCyaOFRrnMg,508
2
+ engin/_assembler.py,sha256=VCZA_Gq4hnH5LueB_vEVqsKbGXx-nI6KQ65YhzXw-VY,7575
3
+ engin/_block.py,sha256=0QJtqyP5uTFjXsdVGr4ZONLI2LhfzUKmQGnNQWouB3o,2121
4
+ engin/_dependency.py,sha256=Nmyk4cGcK6ZZA8YWNZWgckLpDdKsRwOeRQhqRlxZmI0,5883
5
+ engin/_engin.py,sha256=i2IxMIz-3yKjedyg5L6gQEpdFDUZnCbXi9Pwhw8Hsxk,9133
6
+ engin/_exceptions.py,sha256=fsc4pTOIGHUh0x7oZhEXPJUTE268sIhswLoiqXaudiw,635
7
+ engin/_graph.py,sha256=1pMB0cr--uS0XJycDb1rS_X45RBpoyA6NkKqbeSuz1Q,1628
8
+ engin/_lifecycle.py,sha256=_jQnGFj4RYXsxMpcXPJQagFOwnoTVh7oSN8oUYoYuW0,3246
9
+ engin/_type_utils.py,sha256=C71kX2Dr-gluGSL018K4uihX3zkTe7QNWaHhFU10ZmA,2127
10
+ engin/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ engin/ext/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ engin/ext/asgi.py,sha256=ViAWw-PNaP6y5fHK2rSGRfaxj7xxo217eqU2z2rhnq8,2200
13
+ engin/ext/fastapi.py,sha256=p9NekfRHbdNzK_1ttVNr500AO-IwLpBlG9Fhk6R0mmg,5649
14
+ engin/scripts/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
+ engin/scripts/graph.py,sha256=kmC_sSypGvIH89GddHz3KoRBEvooGNsGtrwLgw_uxNQ,4968
16
+ engin-0.0.9.dist-info/METADATA,sha256=fzCqyXhGBzC1hMcTtbRM9-oXVaHEP000abzPi9QZbFQ,2161
17
+ engin-0.0.9.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
18
+ engin-0.0.9.dist-info/entry_points.txt,sha256=Dehk4j5nK6zyuQtgOSRAoLE609V6eLzEp32bjqhO62Q,64
19
+ engin-0.0.9.dist-info/licenses/LICENSE,sha256=XHh5LPUPKZWTBqBv2xxN2RU7D59nHoiJGb5RIt8f45w,1070
20
+ engin-0.0.9.dist-info/RECORD,,
@@ -1,20 +0,0 @@
1
- engin/__init__.py,sha256=yTc8k0HDGMIrxDdEEA90qGD_dExQjVIbXCyaOFRrnMg,508
2
- engin/_assembler.py,sha256=VCZA_Gq4hnH5LueB_vEVqsKbGXx-nI6KQ65YhzXw-VY,7575
3
- engin/_block.py,sha256=-5qTp1Hdm3H54nScDGitFpcXRHLIyVHlDYATg_3dnPw,2045
4
- engin/_dependency.py,sha256=WjJKJY2KhseYpqM1Gg-VhYls4dcLg1CgdlpniJEUHjI,5489
5
- engin/_engin.py,sha256=i2IxMIz-3yKjedyg5L6gQEpdFDUZnCbXi9Pwhw8Hsxk,9133
6
- engin/_exceptions.py,sha256=fsc4pTOIGHUh0x7oZhEXPJUTE268sIhswLoiqXaudiw,635
7
- engin/_graph.py,sha256=piqcocrWt0mX_UAr0DVdkCtVYjkjVpkikLCdRVCoUnU,1230
8
- engin/_lifecycle.py,sha256=_jQnGFj4RYXsxMpcXPJQagFOwnoTVh7oSN8oUYoYuW0,3246
9
- engin/_type_utils.py,sha256=C71kX2Dr-gluGSL018K4uihX3zkTe7QNWaHhFU10ZmA,2127
10
- engin/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
- engin/ext/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
- engin/ext/asgi.py,sha256=6vuC4zIhsvAdmwRn2I6uuUWPYfqobox1dv7skg2OWwE,1940
13
- engin/ext/fastapi.py,sha256=CH2Zi7Oh_Va0TJGx05e7_LqAiCsoI1qcu0Z59_rgfRk,899
14
- engin/scripts/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
- engin/scripts/graph.py,sha256=f_Yk4TRrw9N-gltdriiOviNGcowQckeM2R-7CPVXkHY,3424
16
- engin-0.0.8.dist-info/METADATA,sha256=xX0-36ECkPFBcDsYjiY04-dhn-066fQhKKzEceUPkhQ,2161
17
- engin-0.0.8.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
18
- engin-0.0.8.dist-info/entry_points.txt,sha256=Dehk4j5nK6zyuQtgOSRAoLE609V6eLzEp32bjqhO62Q,64
19
- engin-0.0.8.dist-info/licenses/LICENSE,sha256=XHh5LPUPKZWTBqBv2xxN2RU7D59nHoiJGb5RIt8f45w,1070
20
- engin-0.0.8.dist-info/RECORD,,
File without changes