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/_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
- # transform dependencies into mermaid syntax
46
- dependencies = [
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
- blocks = {node.node.block_name for node in nodes if node.node.block_name is not None}
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
- _BLOCK_IDX: dict[str, int] = {}
108
- _SEEN_BLOCKS: list[str] = []
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
- def _render_node(node: Dependency, render_block: bool = True) -> str:
112
- node_id = id(node)
113
- md = ""
114
- style = ""
116
+ # Generate legend
117
+ legend = ASGI_ENGIN_LEGEND if isinstance(instance, ASGIEngin) else DEFAULT_LEGEND
115
118
 
116
- # format block name
117
- if render_block and (n := node.block_name):
118
- md += f"_{n}_\n"
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
- if style:
128
- style = f":::{style}"
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
- md += f"{_short_name(node.return_type_id)}"
132
- return f'{node_id}("`{md}`"){style}'
133
- if isinstance(node, Provide):
134
- md += f"{_short_name(node.return_type_id)}"
135
- return f'{node_id}["`{md}`"]{style}'
136
- if isinstance(node, Entrypoint):
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
- md += f"{entrypoint_type}"
139
- return f'{node_id}[/"`{md}`"\\]{style}'
140
- if isinstance(node, Invoke):
141
- md += f"{node.func_name}"
142
- return f'{node_id}[/"`{md}`"/]{style}'
143
- if isinstance(node, APIRouteDependency):
144
- md += f"{node.name}"
145
- return f'{node_id}[["`{md}`"]]{style}'
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
- return f'{node_id}["`{node.name}`"]{style}'
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 types' module, e.g. `engin` or `httpx`",
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 examples.simple.main:engin --module httpx`
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
- if matching_provider_count > 1:
79
- console.print(f"Found {matching_provider_count} matching providers", style="dim")
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
- builder: Func[P, T],
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 builder or factory function.
164
+ Provide a type via a factory function.
165
165
 
166
166
  Args:
167
- builder: the builder function that returns the type.
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=builder)
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"Provider '{self.name}' is implicitly overriding "
235
- f"'{existing_provider.name}', if this is intended specify "
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.return_type_id})"
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__(builder=self._get_val, as_type=as_type, override=override)
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, Task
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. Any Lifecycle Startup defined by a provider that was assembled in order to satisfy
42
- the constructors is ran.
43
- 4. The Engin waits for a stop signal, i.e. SIGINT or SIGTERM.
44
- 5. Any Lifecyce Shutdown task is ran, in the reverse order to the Startup order.
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
- return AsyncClient()
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: AsyncExitStack = AsyncExitStack()
83
- self._shutdown_task: Task | None = None
84
- self._run_task: Task | None = None
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(Engin): Supply(self, as_type=Engin)
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
- self._assembler = Assembler(chain(self._providers.values(), multi_providers))
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) or the `stop` method is called on the engin.
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
- Start the engin.
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
- return
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
- self._shutdown_task = asyncio.create_task(self._shutdown_when_stopped())
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._shutdown_task is None:
163
- return
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
- LOG.info("stopping engin")
172
- await self._exit_stack.aclose()
173
- self._stop_complete_event.set()
174
- LOG.info("shutdown complete")
175
-
176
- async def _shutdown_when_stopped(self) -> None:
177
- await self._stop_requested_event.wait()
178
- await self._shutdown()
179
-
180
-
181
- async def _wait_for_stop_signal(stop_requested_event: Event) -> None:
182
- try:
183
- # try to gracefully handle sigint/sigterm
184
- if not _OS_IS_WINDOWS:
185
- loop = asyncio.get_running_loop()
186
- for signame in (signal.SIGINT, signal.SIGTERM):
187
- loop.add_signal_handler(signame, stop_requested_event.set)
188
-
189
- await stop_requested_event.wait()
190
- else:
191
- should_stop = False
192
-
193
- # windows does not support signal_handlers, so this is the workaround
194
- def ctrlc_handler(sig: int, frame: FrameType | None) -> None:
195
- nonlocal should_stop
196
- if should_stop:
197
- raise KeyboardInterrupt("Forced keyboard interrupt")
198
- should_stop = True
199
-
200
- signal.signal(signal.SIGINT, ctrlc_handler)
201
-
202
- while not should_stop:
203
- # In case engin is stopped via external `stop` call.
204
- if stop_requested_event.is_set():
205
- return
206
- await asyncio.sleep(0.1)
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()