engin 0.0.19__py3-none-any.whl → 0.1.0__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/__init__.py +3 -0
- engin/_assembler.py +12 -12
- engin/_cli/__init__.py +2 -0
- engin/_cli/_check.py +56 -0
- engin/_cli/_common.py +72 -2
- engin/_cli/_graph.html +878 -73
- engin/_cli/_graph.py +128 -65
- engin/_cli/_inspect.py +10 -8
- engin/_dependency.py +18 -11
- engin/_engin.py +142 -71
- engin/_supervisor.py +137 -0
- engin/_type_utils.py +2 -2
- engin/exceptions.py +21 -6
- engin/extensions/asgi.py +2 -0
- engin/extensions/fastapi.py +2 -2
- engin-0.1.0.dist-info/METADATA +122 -0
- engin-0.1.0.dist-info/RECORD +27 -0
- engin-0.0.19.dist-info/METADATA +0 -71
- engin-0.0.19.dist-info/RECORD +0 -25
- {engin-0.0.19.dist-info → engin-0.1.0.dist-info}/WHEEL +0 -0
- {engin-0.0.19.dist-info → engin-0.1.0.dist-info}/entry_points.txt +0 -0
- {engin-0.0.19.dist-info → engin-0.1.0.dist-info}/licenses/LICENSE +0 -0
engin/_cli/_graph.py
CHANGED
@@ -1,4 +1,5 @@
|
|
1
1
|
import contextlib
|
2
|
+
import json
|
2
3
|
import socketserver
|
3
4
|
import threading
|
4
5
|
from http.server import BaseHTTPRequestHandler
|
@@ -9,9 +10,10 @@ from typing import Annotated, Any
|
|
9
10
|
import typer
|
10
11
|
from rich import print
|
11
12
|
|
12
|
-
from engin import Entrypoint, Invoke, TypeId
|
13
|
+
from engin import Engin, Entrypoint, Invoke, TypeId
|
13
14
|
from engin._cli._common import COMMON_HELP, get_engin_instance
|
14
15
|
from engin._dependency import Dependency, Provide, Supply
|
16
|
+
from engin._graph import Node
|
15
17
|
from engin.extensions.asgi import ASGIEngin
|
16
18
|
|
17
19
|
try:
|
@@ -28,12 +30,16 @@ _APP_ORIGIN = ""
|
|
28
30
|
@cli.command(name="graph")
|
29
31
|
def serve_graph(
|
30
32
|
app: Annotated[
|
31
|
-
str,
|
33
|
+
str | None,
|
32
34
|
typer.Argument(help=COMMON_HELP["app"]),
|
33
|
-
],
|
35
|
+
] = None,
|
34
36
|
) -> None:
|
35
37
|
"""
|
36
38
|
Creates a visualisation of your application's dependencies.
|
39
|
+
|
40
|
+
Examples:
|
41
|
+
|
42
|
+
1. `engin graph`
|
37
43
|
"""
|
38
44
|
module_name, _, instance = get_engin_instance(app)
|
39
45
|
|
@@ -42,37 +48,11 @@ def serve_graph(
|
|
42
48
|
|
43
49
|
nodes = instance.graph()
|
44
50
|
|
45
|
-
#
|
46
|
-
|
47
|
-
f"{_render_node(node.parent)} --> {_render_node(node.node)}"
|
48
|
-
for node in nodes
|
49
|
-
if node.parent is not None
|
50
|
-
and not (node.node.block_name and node.node.block_name == node.parent.block_name)
|
51
|
-
]
|
51
|
+
# Generate JSON data for interactive graph
|
52
|
+
graph_data = _generate_graph_data(nodes, instance)
|
52
53
|
|
53
|
-
|
54
|
-
|
55
|
-
# group blocks into subgraphs
|
56
|
-
for block in blocks:
|
57
|
-
dependencies.append(f"subgraph {block}")
|
58
|
-
dependencies.extend(
|
59
|
-
[
|
60
|
-
f"{_render_node(node.parent, False)} --> {_render_node(node.node, False)}"
|
61
|
-
for node in nodes
|
62
|
-
if node.parent is not None
|
63
|
-
and node.node.block_name == block
|
64
|
-
and node.parent.block_name == block
|
65
|
-
]
|
66
|
-
)
|
67
|
-
dependencies.append("end")
|
68
|
-
|
69
|
-
html = (
|
70
|
-
_GRAPH_HTML.replace("%%DATA%%", "\n".join(dependencies))
|
71
|
-
.replace(
|
72
|
-
"%%LEGEND%%",
|
73
|
-
ASGI_ENGIN_LEGEND if isinstance(instance, ASGIEngin) else DEFAULT_LEGEND,
|
74
|
-
)
|
75
|
-
.encode("utf8")
|
54
|
+
html = _GRAPH_HTML.replace("%%GRAPH_DATA%%", json.dumps(graph_data, indent=2)).encode(
|
55
|
+
"utf8"
|
76
56
|
)
|
77
57
|
|
78
58
|
class Handler(BaseHTTPRequestHandler):
|
@@ -104,47 +84,130 @@ def wait_for_interrupt() -> None:
|
|
104
84
|
sleep(10000)
|
105
85
|
|
106
86
|
|
107
|
-
|
108
|
-
|
87
|
+
def _generate_graph_data(nodes: list[Node], instance: Engin) -> dict[str, Any]:
|
88
|
+
"""Generate JSON data structure for interactive graph rendering."""
|
89
|
+
all_deps = set()
|
90
|
+
for node in nodes:
|
91
|
+
all_deps.add(node.node)
|
92
|
+
if node.parent:
|
93
|
+
all_deps.add(node.parent)
|
94
|
+
|
95
|
+
# Generate node data
|
96
|
+
node_data = []
|
97
|
+
for dep in all_deps:
|
98
|
+
node_info = _get_node_info(dep)
|
99
|
+
node_data.append(node_info)
|
100
|
+
|
101
|
+
# Generate edge data
|
102
|
+
edge_data = [
|
103
|
+
{
|
104
|
+
"from": f"n{id(node.parent)}",
|
105
|
+
"to": f"n{id(node.node)}",
|
106
|
+
"from_block": node.parent.block_name,
|
107
|
+
"to_block": node.node.block_name,
|
108
|
+
}
|
109
|
+
for node in nodes
|
110
|
+
if node.parent is not None
|
111
|
+
]
|
109
112
|
|
113
|
+
# Get block information
|
114
|
+
blocks = list({node.node.block_name for node in nodes if node.node.block_name is not None})
|
110
115
|
|
111
|
-
|
112
|
-
|
113
|
-
md = ""
|
114
|
-
style = ""
|
116
|
+
# Generate legend
|
117
|
+
legend = ASGI_ENGIN_LEGEND if isinstance(instance, ASGIEngin) else DEFAULT_LEGEND
|
115
118
|
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
+
return {
|
120
|
+
"nodes": node_data,
|
121
|
+
"edges": edge_data,
|
122
|
+
"blocks": blocks,
|
123
|
+
"legend": legend,
|
124
|
+
"app_origin": _APP_ORIGIN,
|
125
|
+
}
|
119
126
|
|
120
|
-
node_root_package = node.source_package.split(".", maxsplit=1)[0]
|
121
|
-
if node_root_package != _APP_ORIGIN:
|
122
|
-
if style:
|
123
|
-
style += "E"
|
124
|
-
else:
|
125
|
-
style = "external"
|
126
127
|
|
127
|
-
|
128
|
-
|
128
|
+
def _get_node_info(node: Dependency) -> dict[str, Any]:
|
129
|
+
"""Extract node information for JSON representation."""
|
130
|
+
node_id = f"n{id(node)}" # Add 'n' prefix to match mermaid node IDs
|
131
|
+
label = ""
|
132
|
+
style_classes = []
|
129
133
|
|
134
|
+
# Determine if external
|
135
|
+
node_root_package = node.source_package.split(".", maxsplit=1)[0]
|
136
|
+
is_external = node_root_package != _APP_ORIGIN
|
137
|
+
if is_external:
|
138
|
+
style_classes.append("external")
|
139
|
+
|
140
|
+
# Collect detailed information for tooltips
|
141
|
+
details: dict[str, Any] = {
|
142
|
+
"full_name": node.name,
|
143
|
+
"source_module": node.source_module,
|
144
|
+
"source_package": node.source_package,
|
145
|
+
"parameters": [],
|
146
|
+
"return_type": None,
|
147
|
+
"scope": None,
|
148
|
+
}
|
149
|
+
|
150
|
+
# Get parameter information
|
151
|
+
if hasattr(node, "parameter_type_ids"):
|
152
|
+
details["parameters"] = [str(param_id) for param_id in node.parameter_type_ids]
|
153
|
+
|
154
|
+
# Determine node type and extract specific details
|
130
155
|
if isinstance(node, Supply):
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
156
|
+
node_type = "Supply"
|
157
|
+
label += f"{_short_name(node.return_type_id)}"
|
158
|
+
shape = "round"
|
159
|
+
details["return_type"] = str(node.return_type_id)
|
160
|
+
if hasattr(node, "_value"):
|
161
|
+
details["value_type"] = type(node._value).__name__
|
162
|
+
elif isinstance(node, Provide):
|
163
|
+
node_type = "Provide"
|
164
|
+
label += f"{_short_name(node.return_type_id)}"
|
165
|
+
shape = "rect"
|
166
|
+
details["return_type"] = str(node.return_type_id)
|
167
|
+
details["factory_function"] = node.func_name
|
168
|
+
if node.scope:
|
169
|
+
details["scope"] = node.scope
|
170
|
+
style_classes.append(f"scope-{node.scope}")
|
171
|
+
if node.is_multiprovider:
|
172
|
+
details["multiprovider"] = True
|
173
|
+
style_classes.append("multi")
|
174
|
+
elif isinstance(node, Entrypoint):
|
175
|
+
node_type = "Entrypoint"
|
137
176
|
entrypoint_type = node.parameter_type_ids[0]
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
177
|
+
label += f"{entrypoint_type}"
|
178
|
+
shape = "trapezoid"
|
179
|
+
details["entrypoint_type"] = str(entrypoint_type)
|
180
|
+
elif isinstance(node, Invoke):
|
181
|
+
node_type = "Invoke"
|
182
|
+
label += f"{node.func_name}"
|
183
|
+
shape = "trapezoid"
|
184
|
+
details["function"] = node.func_name
|
185
|
+
elif APIRouteDependency is not None and isinstance(node, APIRouteDependency):
|
186
|
+
node_type = "APIRoute"
|
187
|
+
label += f"{node.name}"
|
188
|
+
shape = "subroutine"
|
189
|
+
if hasattr(node, "route"):
|
190
|
+
details["methods"] = (
|
191
|
+
list(node.route.methods) if hasattr(node.route, "methods") else []
|
192
|
+
)
|
193
|
+
details["path"] = getattr(node.route, "path", "")
|
146
194
|
else:
|
147
|
-
|
195
|
+
node_type = "Other"
|
196
|
+
label += f"{node.name}"
|
197
|
+
shape = "rect"
|
198
|
+
|
199
|
+
return {
|
200
|
+
"id": node_id,
|
201
|
+
"label": label,
|
202
|
+
"type": node_type,
|
203
|
+
"external": is_external,
|
204
|
+
"block": node.block_name,
|
205
|
+
"shape": shape,
|
206
|
+
"style_classes": style_classes,
|
207
|
+
"source_module": node.source_module,
|
208
|
+
"source_package": node.source_package,
|
209
|
+
"details": details,
|
210
|
+
}
|
148
211
|
|
149
212
|
|
150
213
|
def _short_name(name: TypeId) -> str:
|
engin/_cli/_inspect.py
CHANGED
@@ -11,7 +11,7 @@ from engin._cli._common import COMMON_HELP, get_engin_instance, print_error
|
|
11
11
|
cli = typer.Typer()
|
12
12
|
_CLI_HELP = {
|
13
13
|
"type": "Filter providers by the provided type, e.g. `AsyncClient` or `float[]`",
|
14
|
-
"module": "Filter providers by the provided
|
14
|
+
"module": "Filter providers by the provided type's module, e.g. `engin` or `httpx`",
|
15
15
|
"verbose": "Enables verbose output",
|
16
16
|
}
|
17
17
|
|
@@ -19,9 +19,9 @@ _CLI_HELP = {
|
|
19
19
|
@cli.command(name="inspect")
|
20
20
|
def serve_graph(
|
21
21
|
app: Annotated[
|
22
|
-
str,
|
22
|
+
str | None,
|
23
23
|
typer.Argument(help=COMMON_HELP["app"]),
|
24
|
-
],
|
24
|
+
] = None,
|
25
25
|
type_: Annotated[
|
26
26
|
str | None,
|
27
27
|
typer.Option("--type", help=_CLI_HELP["type"]),
|
@@ -39,9 +39,8 @@ def serve_graph(
|
|
39
39
|
|
40
40
|
Examples:
|
41
41
|
|
42
|
-
1. `engin inspect
|
43
|
-
|
44
|
-
2. `engin inspect examples.simple.main:engin --type AsyncClient`
|
42
|
+
1. `engin inspect --module httpx`
|
43
|
+
2. `engin inspect --type AsyncClient`
|
45
44
|
"""
|
46
45
|
module_name, _, instance = get_engin_instance(app)
|
47
46
|
|
@@ -75,8 +74,11 @@ def serve_graph(
|
|
75
74
|
available = sorted(map(str, instance.assembler.providers))
|
76
75
|
print_error(f"No matching providers, available: {available}")
|
77
76
|
|
78
|
-
|
79
|
-
|
77
|
+
console.print(
|
78
|
+
f"Found {matching_provider_count} matching provider"
|
79
|
+
+ ("s" if matching_provider_count > 1 else ""),
|
80
|
+
style="dim",
|
81
|
+
)
|
80
82
|
|
81
83
|
table = Table(show_header=False, show_lines=False, box=box.ASCII)
|
82
84
|
|
engin/_dependency.py
CHANGED
@@ -33,7 +33,7 @@ class Dependency(ABC, Option, Generic[P, T]):
|
|
33
33
|
def __init__(self, func: Func[P, T]) -> None:
|
34
34
|
self._func = func
|
35
35
|
self._is_async = iscoroutinefunction(func)
|
36
|
-
self._signature = inspect.signature(self._func)
|
36
|
+
self._signature = inspect.signature(self._func, eval_str=True)
|
37
37
|
self._block_name: str | None = None
|
38
38
|
|
39
39
|
source_frame = get_first_external_frame()
|
@@ -154,24 +154,24 @@ class Entrypoint(Invoke):
|
|
154
154
|
class Provide(Dependency[Any, T]):
|
155
155
|
def __init__(
|
156
156
|
self,
|
157
|
-
|
157
|
+
factory: Func[P, T],
|
158
158
|
*,
|
159
159
|
scope: str | None = None,
|
160
160
|
as_type: type | None = None,
|
161
161
|
override: bool = False,
|
162
162
|
) -> None:
|
163
163
|
"""
|
164
|
-
Provide a type via a
|
164
|
+
Provide a type via a factory function.
|
165
165
|
|
166
166
|
Args:
|
167
|
-
|
167
|
+
factory: the factory function that returns the type.
|
168
168
|
scope: (optional) associate this provider with a specific scope.
|
169
169
|
as_type: (optional) allows you to explicitly specify the provided type, e.g.
|
170
170
|
to type erase a concrete type, or to provide a mock implementation.
|
171
171
|
override: (optional) allow this provider to override other providers for the
|
172
172
|
same type from the same package.
|
173
173
|
"""
|
174
|
-
super().__init__(func=
|
174
|
+
super().__init__(func=factory)
|
175
175
|
self._scope = scope
|
176
176
|
self._override = override
|
177
177
|
self._explicit_type = as_type
|
@@ -231,9 +231,9 @@ class Provide(Dependency[Any, T]):
|
|
231
231
|
# overwriting a dependency from the same package must be explicit
|
232
232
|
if is_same_package and not self._override:
|
233
233
|
msg = (
|
234
|
-
f"
|
235
|
-
f"'{existing_provider.
|
236
|
-
"`override=True` for the overriding Provider"
|
234
|
+
f"{self} from '{self._source_frame}' is implicitly overriding "
|
235
|
+
f"{existing_provider} from '{existing_provider.source_module}', if this "
|
236
|
+
"is intentional specify `override=True` for the overriding Provider"
|
237
237
|
)
|
238
238
|
raise RuntimeError(msg)
|
239
239
|
|
@@ -243,7 +243,7 @@ class Provide(Dependency[Any, T]):
|
|
243
243
|
return hash(self.return_type_id)
|
244
244
|
|
245
245
|
def __str__(self) -> str:
|
246
|
-
return f"Provide({self.
|
246
|
+
return f"Provide(factory={self.func_name}, type={self._return_type_id})"
|
247
247
|
|
248
248
|
def _resolve_return_type(self) -> type[T]:
|
249
249
|
if self._explicit_type is not None:
|
@@ -279,7 +279,14 @@ class Supply(Provide, Generic[T]):
|
|
279
279
|
same type from the same package.
|
280
280
|
"""
|
281
281
|
self._value = value
|
282
|
-
super().__init__(
|
282
|
+
super().__init__(factory=self._get_val, as_type=as_type, override=override)
|
283
|
+
|
284
|
+
@property
|
285
|
+
def name(self) -> str:
|
286
|
+
if self._block_name:
|
287
|
+
return f"{self._block_name}.supply"
|
288
|
+
else:
|
289
|
+
return f"{self._source_frame}.supply"
|
283
290
|
|
284
291
|
def _resolve_return_type(self) -> type[T]:
|
285
292
|
if self._explicit_type is not None:
|
@@ -292,4 +299,4 @@ class Supply(Provide, Generic[T]):
|
|
292
299
|
return self._value
|
293
300
|
|
294
301
|
def __str__(self) -> str:
|
295
|
-
return f"Supply({self.return_type_id})"
|
302
|
+
return f"Supply(value={self._value}, type={self.return_type_id})"
|
engin/_engin.py
CHANGED
@@ -2,24 +2,46 @@ import asyncio
|
|
2
2
|
import logging
|
3
3
|
import os
|
4
4
|
import signal
|
5
|
-
from asyncio import Event
|
5
|
+
from asyncio import Event
|
6
6
|
from collections import defaultdict
|
7
7
|
from contextlib import AsyncExitStack
|
8
|
+
from enum import Enum
|
8
9
|
from itertools import chain
|
9
10
|
from types import FrameType
|
10
11
|
from typing import ClassVar
|
11
12
|
|
13
|
+
from anyio import create_task_group, open_signal_receiver
|
14
|
+
|
12
15
|
from engin._assembler import AssembledDependency, Assembler
|
13
16
|
from engin._dependency import Invoke, Provide, Supply
|
14
17
|
from engin._graph import DependencyGrapher, Node
|
15
18
|
from engin._lifecycle import Lifecycle
|
16
19
|
from engin._option import Option
|
20
|
+
from engin._supervisor import Supervisor
|
17
21
|
from engin._type_utils import TypeId
|
22
|
+
from engin.exceptions import EnginError
|
18
23
|
|
19
24
|
_OS_IS_WINDOWS = os.name == "nt"
|
20
25
|
LOG = logging.getLogger("engin")
|
21
26
|
|
22
27
|
|
28
|
+
class _EnginState(Enum):
|
29
|
+
IDLE = 0
|
30
|
+
"""
|
31
|
+
Engin is not yet started.
|
32
|
+
"""
|
33
|
+
|
34
|
+
RUNNING = 1
|
35
|
+
"""
|
36
|
+
Engin is currently running.
|
37
|
+
"""
|
38
|
+
|
39
|
+
SHUTDOWN = 2
|
40
|
+
"""
|
41
|
+
Engin has performed shutdown
|
42
|
+
"""
|
43
|
+
|
44
|
+
|
23
45
|
class Engin:
|
24
46
|
"""
|
25
47
|
The Engin is a modular application defined by a collection of options.
|
@@ -38,10 +60,10 @@ class Engin:
|
|
38
60
|
1. The Engin assembles all Invocations. Only Providers that are required to satisfy
|
39
61
|
the Invoke options parameters are assembled.
|
40
62
|
2. All Invocations are run sequentially in the order they were passed in to the Engin.
|
41
|
-
3.
|
42
|
-
|
43
|
-
|
44
|
-
5.
|
63
|
+
3. Lifecycle Startup tasks registered by assembled dependencies are run sequentially.
|
64
|
+
4. The Engin waits for a stop signal, i.e. SIGINT or SIGTERM, or a supervised task that
|
65
|
+
causes a shutdown.
|
66
|
+
5. Lifecyce Shutdown tasks are run in the reverse order to the Startup order.
|
45
67
|
|
46
68
|
Examples:
|
47
69
|
```python
|
@@ -49,11 +71,13 @@ class Engin:
|
|
49
71
|
|
50
72
|
from httpx import AsyncClient
|
51
73
|
|
52
|
-
from engin import Engin, Invoke, Provide
|
74
|
+
from engin import Engin, Invoke, Lifecycle, Provide
|
53
75
|
|
54
76
|
|
55
|
-
def httpx_client() -> AsyncClient:
|
56
|
-
|
77
|
+
def httpx_client(lifecycle: Lifecycle) -> AsyncClient:
|
78
|
+
client = AsyncClient()
|
79
|
+
lifecycle.append(client)
|
80
|
+
return client
|
57
81
|
|
58
82
|
|
59
83
|
async def main(http_client: AsyncClient) -> None:
|
@@ -65,7 +89,8 @@ class Engin:
|
|
65
89
|
```
|
66
90
|
"""
|
67
91
|
|
68
|
-
_LIB_OPTIONS: ClassVar[list[Option]] = [Provide(Lifecycle)]
|
92
|
+
_LIB_OPTIONS: ClassVar[list[Option]] = [Provide(Lifecycle), Provide(Supervisor)]
|
93
|
+
_STOP_ON_SINGAL: ClassVar[bool] = True
|
69
94
|
|
70
95
|
def __init__(self, *options: Option) -> None:
|
71
96
|
"""
|
@@ -77,14 +102,16 @@ class Engin:
|
|
77
102
|
Args:
|
78
103
|
*options: an instance of Provide, Supply, Invoke, Entrypoint or a Block.
|
79
104
|
"""
|
105
|
+
self._state = _EnginState.IDLE
|
106
|
+
self._start_complete_event = Event()
|
80
107
|
self._stop_requested_event = Event()
|
81
108
|
self._stop_complete_event = Event()
|
82
|
-
self._exit_stack
|
83
|
-
self.
|
84
|
-
self.
|
109
|
+
self._exit_stack = AsyncExitStack()
|
110
|
+
self._assembler = Assembler([])
|
111
|
+
self._async_context_run_task: asyncio.Task | None = None
|
85
112
|
|
86
113
|
self._providers: dict[TypeId, Provide] = {
|
87
|
-
TypeId.from_type(
|
114
|
+
TypeId.from_type(Assembler): Supply(self._assembler),
|
88
115
|
}
|
89
116
|
self._multiproviders: dict[TypeId, list[Provide]] = defaultdict(list)
|
90
117
|
self._invocations: list[Invoke] = []
|
@@ -94,7 +121,9 @@ class Engin:
|
|
94
121
|
option.apply(self)
|
95
122
|
|
96
123
|
multi_providers = [p for multi in self._multiproviders.values() for p in multi]
|
97
|
-
|
124
|
+
|
125
|
+
for provider in chain(self._providers.values(), multi_providers):
|
126
|
+
self._assembler.add(provider)
|
98
127
|
|
99
128
|
@property
|
100
129
|
def assembler(self) -> Assembler:
|
@@ -105,20 +134,11 @@ class Engin:
|
|
105
134
|
Run the engin.
|
106
135
|
|
107
136
|
The engin will run until it is stopped via an external signal (i.e. SIGTERM or
|
108
|
-
SIGINT)
|
109
|
-
"""
|
110
|
-
await self.start()
|
111
|
-
self._run_task = asyncio.create_task(_wait_for_stop_signal(self._stop_requested_event))
|
112
|
-
await self._stop_requested_event.wait()
|
113
|
-
|
114
|
-
async def start(self) -> None:
|
137
|
+
SIGINT), the `stop` method is called on the engin, or a lifecycle task errors.
|
115
138
|
"""
|
116
|
-
|
139
|
+
if self._state != _EnginState.IDLE:
|
140
|
+
raise EnginError("Engin is not idle, unable to start")
|
117
141
|
|
118
|
-
This is an alternative to calling `run`. This method waits for the startup
|
119
|
-
lifecycle to complete and then returns. The caller is then responsible for
|
120
|
-
calling `stop`.
|
121
|
-
"""
|
122
142
|
LOG.info("starting engin")
|
123
143
|
assembled_invocations: list[AssembledDependency] = [
|
124
144
|
await self._assembler.assemble(invocation) for invocation in self._invocations
|
@@ -130,7 +150,7 @@ class Engin:
|
|
130
150
|
except Exception as err:
|
131
151
|
name = invocation.dependency.name
|
132
152
|
LOG.error(f"invocation '{name}' errored, exiting", exc_info=err)
|
133
|
-
|
153
|
+
raise
|
134
154
|
|
135
155
|
lifecycle = await self._assembler.build(Lifecycle)
|
136
156
|
|
@@ -146,9 +166,52 @@ class Engin:
|
|
146
166
|
await self._shutdown()
|
147
167
|
return
|
148
168
|
|
169
|
+
supervisor = await self._assembler.build(Supervisor)
|
170
|
+
|
149
171
|
LOG.info("startup complete")
|
172
|
+
self._state = _EnginState.RUNNING
|
173
|
+
self._start_complete_event.set()
|
150
174
|
|
151
|
-
|
175
|
+
async with create_task_group() as tg:
|
176
|
+
if self._STOP_ON_SINGAL:
|
177
|
+
tg.start_soon(_stop_engin_on_signal, self._stop_requested_event)
|
178
|
+
|
179
|
+
try:
|
180
|
+
async with supervisor:
|
181
|
+
await self._stop_requested_event.wait()
|
182
|
+
|
183
|
+
# shutdown after stopping supervised tasks
|
184
|
+
await self._shutdown()
|
185
|
+
except BaseException:
|
186
|
+
await self._shutdown()
|
187
|
+
|
188
|
+
tg.cancel_scope.cancel()
|
189
|
+
|
190
|
+
async def start(self) -> None:
|
191
|
+
"""
|
192
|
+
Starts the engin in the background. This method will wait until the engin is fully
|
193
|
+
started to return so it is safe to use immediately after.
|
194
|
+
"""
|
195
|
+
self._async_context_run_task = asyncio.create_task(self.run())
|
196
|
+
wait_tasks = [
|
197
|
+
asyncio.create_task(self._start_complete_event.wait()),
|
198
|
+
asyncio.create_task(self._stop_complete_event.wait()),
|
199
|
+
]
|
200
|
+
await asyncio.wait(
|
201
|
+
[
|
202
|
+
self._async_context_run_task, # if a provider errors this will return first
|
203
|
+
*wait_tasks,
|
204
|
+
],
|
205
|
+
return_when=asyncio.FIRST_COMPLETED,
|
206
|
+
)
|
207
|
+
for task in wait_tasks:
|
208
|
+
task.cancel()
|
209
|
+
|
210
|
+
# raise the exception from the startup during run
|
211
|
+
if self._async_context_run_task.done():
|
212
|
+
startup_exception = self._async_context_run_task.exception()
|
213
|
+
if startup_exception is not None:
|
214
|
+
raise startup_exception
|
152
215
|
|
153
216
|
async def stop(self) -> None:
|
154
217
|
"""
|
@@ -159,52 +222,60 @@ class Engin:
|
|
159
222
|
started.
|
160
223
|
"""
|
161
224
|
self._stop_requested_event.set()
|
162
|
-
if self.
|
163
|
-
|
164
|
-
await self._stop_complete_event.wait()
|
225
|
+
if self._state == _EnginState.RUNNING:
|
226
|
+
await self._stop_complete_event.wait()
|
165
227
|
|
166
228
|
def graph(self) -> list[Node]:
|
229
|
+
"""
|
230
|
+
Creates a graph representation of the engin's dependencies which can be used for
|
231
|
+
introspection or visualisations.
|
232
|
+
|
233
|
+
Returns: a list of Node objects.
|
234
|
+
"""
|
167
235
|
grapher = DependencyGrapher({**self._providers, **self._multiproviders})
|
168
236
|
return grapher.resolve(self._invocations)
|
169
237
|
|
238
|
+
def is_running(self) -> bool:
|
239
|
+
return self._state == _EnginState.RUNNING
|
240
|
+
|
241
|
+
def is_stopped(self) -> bool:
|
242
|
+
return self._state == _EnginState.SHUTDOWN
|
243
|
+
|
170
244
|
async def _shutdown(self) -> None:
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
|
178
|
-
|
179
|
-
|
180
|
-
|
181
|
-
|
182
|
-
|
183
|
-
|
184
|
-
|
185
|
-
|
186
|
-
|
187
|
-
|
188
|
-
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
|
195
|
-
|
196
|
-
|
197
|
-
|
198
|
-
|
199
|
-
|
200
|
-
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
stop_requested_event.set()
|
209
|
-
except asyncio.CancelledError:
|
210
|
-
pass
|
245
|
+
if self._state == _EnginState.RUNNING:
|
246
|
+
LOG.info("stopping engin")
|
247
|
+
await self._exit_stack.aclose()
|
248
|
+
self._stop_complete_event.set()
|
249
|
+
LOG.info("shutdown complete")
|
250
|
+
self._state = _EnginState.SHUTDOWN
|
251
|
+
|
252
|
+
|
253
|
+
async def _stop_engin_on_signal(stop_requested_event: Event) -> None:
|
254
|
+
"""
|
255
|
+
A task that waits for a stop signal (SIGINT/SIGTERM) and notifies the given event.
|
256
|
+
"""
|
257
|
+
if not _OS_IS_WINDOWS:
|
258
|
+
with open_signal_receiver(signal.SIGINT, signal.SIGTERM) as recieved_signals:
|
259
|
+
async for signum in recieved_signals:
|
260
|
+
LOG.debug(f"received {signum.name} signal")
|
261
|
+
stop_requested_event.set()
|
262
|
+
else:
|
263
|
+
should_stop = False
|
264
|
+
|
265
|
+
# windows does not support signal_handlers, so this is the workaround
|
266
|
+
def ctrlc_handler(sig: int, frame: FrameType | None) -> None:
|
267
|
+
LOG.debug(f"received {signal.SIGINT.name} signal")
|
268
|
+
nonlocal should_stop
|
269
|
+
if should_stop:
|
270
|
+
raise KeyboardInterrupt("Forced keyboard interrupt")
|
271
|
+
should_stop = True
|
272
|
+
|
273
|
+
signal.signal(signal.SIGINT, ctrlc_handler)
|
274
|
+
|
275
|
+
while not should_stop:
|
276
|
+
# In case engin is stopped via external `stop` call.
|
277
|
+
if stop_requested_event.is_set():
|
278
|
+
return
|
279
|
+
await asyncio.sleep(0.1)
|
280
|
+
|
281
|
+
stop_requested_event.set()
|