engin 0.0.20__tar.gz → 0.1a1__tar.gz

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.
Files changed (100) hide show
  1. {engin-0.0.20 → engin-0.1a1}/CHANGELOG.md +6 -0
  2. {engin-0.0.20 → engin-0.1a1}/PKG-INFO +3 -1
  3. {engin-0.0.20 → engin-0.1a1}/pyproject.toml +7 -3
  4. {engin-0.0.20 → engin-0.1a1}/src/engin/__init__.py +5 -0
  5. {engin-0.0.20 → engin-0.1a1}/src/engin/_engin.py +40 -15
  6. engin-0.1a1/src/engin/_shutdown.py +4 -0
  7. engin-0.1a1/src/engin/_supervisor.py +141 -0
  8. {engin-0.0.20 → engin-0.1a1}/src/engin/extensions/fastapi.py +2 -2
  9. engin-0.1a1/tests/acceptance/test_error_in_supervised_task.py +17 -0
  10. engin-0.1a1/tests/test_supervisor.py +115 -0
  11. {engin-0.0.20 → engin-0.1a1}/uv.lock +199 -181
  12. {engin-0.0.20 → engin-0.1a1}/.github/workflows/benchmark.yaml +0 -0
  13. {engin-0.0.20 → engin-0.1a1}/.github/workflows/check.yaml +0 -0
  14. {engin-0.0.20 → engin-0.1a1}/.github/workflows/publish.yaml +0 -0
  15. {engin-0.0.20 → engin-0.1a1}/.gitignore +0 -0
  16. {engin-0.0.20 → engin-0.1a1}/.readthedocs.yaml +0 -0
  17. {engin-0.0.20 → engin-0.1a1}/LICENSE +0 -0
  18. {engin-0.0.20 → engin-0.1a1}/README.md +0 -0
  19. {engin-0.0.20 → engin-0.1a1}/docs/concepts/blocks.md +0 -0
  20. {engin-0.0.20 → engin-0.1a1}/docs/concepts/engin.md +0 -0
  21. {engin-0.0.20 → engin-0.1a1}/docs/concepts/invocations.md +0 -0
  22. {engin-0.0.20 → engin-0.1a1}/docs/concepts/lifecycle.md +0 -0
  23. {engin-0.0.20 → engin-0.1a1}/docs/concepts/providers.md +0 -0
  24. {engin-0.0.20 → engin-0.1a1}/docs/getting-started.md +0 -0
  25. {engin-0.0.20 → engin-0.1a1}/docs/guides/fastapi-graph.png +0 -0
  26. {engin-0.0.20 → engin-0.1a1}/docs/guides/fastapi.md +0 -0
  27. {engin-0.0.20 → engin-0.1a1}/docs/index.md +0 -0
  28. {engin-0.0.20 → engin-0.1a1}/docs/js/readthedocs.js +0 -0
  29. {engin-0.0.20 → engin-0.1a1}/docs/overrides/main.html +0 -0
  30. {engin-0.0.20 → engin-0.1a1}/docs/reference.md +0 -0
  31. {engin-0.0.20 → engin-0.1a1}/examples/__init__.py +0 -0
  32. {engin-0.0.20 → engin-0.1a1}/examples/asgi/__init__.py +0 -0
  33. {engin-0.0.20 → engin-0.1a1}/examples/asgi/app.py +0 -0
  34. {engin-0.0.20 → engin-0.1a1}/examples/asgi/common/__init__.py +0 -0
  35. {engin-0.0.20 → engin-0.1a1}/examples/asgi/common/db/__init__.py +0 -0
  36. {engin-0.0.20 → engin-0.1a1}/examples/asgi/common/db/adapaters/__init__.py +0 -0
  37. {engin-0.0.20 → engin-0.1a1}/examples/asgi/common/db/adapaters/memory.py +0 -0
  38. {engin-0.0.20 → engin-0.1a1}/examples/asgi/common/db/block.py +0 -0
  39. {engin-0.0.20 → engin-0.1a1}/examples/asgi/common/db/ports.py +0 -0
  40. {engin-0.0.20 → engin-0.1a1}/examples/asgi/common/starlette/__init__.py +0 -0
  41. {engin-0.0.20 → engin-0.1a1}/examples/asgi/common/starlette/endpoint.py +0 -0
  42. {engin-0.0.20 → engin-0.1a1}/examples/asgi/features/__init__.py +0 -0
  43. {engin-0.0.20 → engin-0.1a1}/examples/asgi/features/cats/__init__.py +0 -0
  44. {engin-0.0.20 → engin-0.1a1}/examples/asgi/features/cats/api/__init__.py +0 -0
  45. {engin-0.0.20 → engin-0.1a1}/examples/asgi/features/cats/api/get.py +0 -0
  46. {engin-0.0.20 → engin-0.1a1}/examples/asgi/features/cats/api/post.py +0 -0
  47. {engin-0.0.20 → engin-0.1a1}/examples/asgi/features/cats/block.py +0 -0
  48. {engin-0.0.20 → engin-0.1a1}/examples/asgi/features/cats/domain.py +0 -0
  49. {engin-0.0.20 → engin-0.1a1}/examples/asgi/main.py +0 -0
  50. {engin-0.0.20 → engin-0.1a1}/examples/fastapi/__init__.py +0 -0
  51. {engin-0.0.20 → engin-0.1a1}/examples/fastapi/app.py +0 -0
  52. {engin-0.0.20 → engin-0.1a1}/examples/fastapi/main.py +0 -0
  53. {engin-0.0.20 → engin-0.1a1}/examples/fastapi/routes/__init__.py +0 -0
  54. {engin-0.0.20 → engin-0.1a1}/examples/fastapi/routes/cats/__init__.py +0 -0
  55. {engin-0.0.20 → engin-0.1a1}/examples/fastapi/routes/cats/adapters/__init__.py +0 -0
  56. {engin-0.0.20 → engin-0.1a1}/examples/fastapi/routes/cats/adapters/repository.py +0 -0
  57. {engin-0.0.20 → engin-0.1a1}/examples/fastapi/routes/cats/api.py +0 -0
  58. {engin-0.0.20 → engin-0.1a1}/examples/fastapi/routes/cats/block.py +0 -0
  59. {engin-0.0.20 → engin-0.1a1}/examples/fastapi/routes/cats/domain.py +0 -0
  60. {engin-0.0.20 → engin-0.1a1}/examples/fastapi/routes/cats/ports.py +0 -0
  61. {engin-0.0.20 → engin-0.1a1}/examples/simple/__init__.py +0 -0
  62. {engin-0.0.20 → engin-0.1a1}/examples/simple/main.py +0 -0
  63. {engin-0.0.20 → engin-0.1a1}/mkdocs.yaml +0 -0
  64. {engin-0.0.20 → engin-0.1a1}/src/engin/_assembler.py +0 -0
  65. {engin-0.0.20 → engin-0.1a1}/src/engin/_block.py +0 -0
  66. {engin-0.0.20 → engin-0.1a1}/src/engin/_cli/__init__.py +0 -0
  67. {engin-0.0.20 → engin-0.1a1}/src/engin/_cli/_common.py +0 -0
  68. {engin-0.0.20 → engin-0.1a1}/src/engin/_cli/_graph.html +0 -0
  69. {engin-0.0.20 → engin-0.1a1}/src/engin/_cli/_graph.py +0 -0
  70. {engin-0.0.20 → engin-0.1a1}/src/engin/_cli/_inspect.py +0 -0
  71. {engin-0.0.20 → engin-0.1a1}/src/engin/_dependency.py +0 -0
  72. {engin-0.0.20 → engin-0.1a1}/src/engin/_graph.py +0 -0
  73. {engin-0.0.20 → engin-0.1a1}/src/engin/_introspect.py +0 -0
  74. {engin-0.0.20 → engin-0.1a1}/src/engin/_lifecycle.py +0 -0
  75. {engin-0.0.20 → engin-0.1a1}/src/engin/_option.py +0 -0
  76. {engin-0.0.20 → engin-0.1a1}/src/engin/_type_utils.py +0 -0
  77. {engin-0.0.20 → engin-0.1a1}/src/engin/exceptions.py +0 -0
  78. {engin-0.0.20 → engin-0.1a1}/src/engin/extensions/__init__.py +0 -0
  79. {engin-0.0.20 → engin-0.1a1}/src/engin/extensions/asgi.py +0 -0
  80. {engin-0.0.20 → engin-0.1a1}/src/engin/py.typed +0 -0
  81. {engin-0.0.20 → engin-0.1a1}/tests/__init__.py +0 -0
  82. {engin-0.0.20 → engin-0.1a1}/tests/acceptance/__init__.py +0 -0
  83. {engin-0.0.20 → engin-0.1a1}/tests/acceptance/test_error_in_shutdown.py +0 -0
  84. {engin-0.0.20 → engin-0.1a1}/tests/acceptance/test_error_in_start_up.py +0 -0
  85. {engin-0.0.20 → engin-0.1a1}/tests/acceptance/test_fastapi.py +0 -0
  86. {engin-0.0.20 → engin-0.1a1}/tests/benchmarks/__init__.py +0 -0
  87. {engin-0.0.20 → engin-0.1a1}/tests/benchmarks/conftest.py +0 -0
  88. {engin-0.0.20 → engin-0.1a1}/tests/benchmarks/test_bench_assembler.py +0 -0
  89. {engin-0.0.20 → engin-0.1a1}/tests/cli/__init__.py +0 -0
  90. {engin-0.0.20 → engin-0.1a1}/tests/cli/test_graph.py +0 -0
  91. {engin-0.0.20 → engin-0.1a1}/tests/cli/test_inspect.py +0 -0
  92. {engin-0.0.20 → engin-0.1a1}/tests/conftest.py +0 -0
  93. {engin-0.0.20 → engin-0.1a1}/tests/deps.py +0 -0
  94. {engin-0.0.20 → engin-0.1a1}/tests/test_assembler.py +0 -0
  95. {engin-0.0.20 → engin-0.1a1}/tests/test_block.py +0 -0
  96. {engin-0.0.20 → engin-0.1a1}/tests/test_dependencies.py +0 -0
  97. {engin-0.0.20 → engin-0.1a1}/tests/test_engin.py +0 -0
  98. {engin-0.0.20 → engin-0.1a1}/tests/test_graph.py +0 -0
  99. {engin-0.0.20 → engin-0.1a1}/tests/test_lifecycle.py +0 -0
  100. {engin-0.0.20 → engin-0.1a1}/tests/test_type_id.py +0 -0
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.1.0] - UNRELEASED
9
+
10
+ ### Added
11
+
12
+ - Supervisor class which can safely supervise long running tasks.
13
+
8
14
 
9
15
  ## [0.0.20] - 2025-06-18
10
16
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: engin
3
- Version: 0.0.20
3
+ Version: 0.1a1
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/
@@ -10,6 +10,8 @@ License-Expression: MIT
10
10
  License-File: LICENSE
11
11
  Keywords: Application Framework,Dependency Injection
12
12
  Requires-Python: >=3.10
13
+ Requires-Dist: anyio>=4
14
+ Requires-Dist: exceptiongroup>=1
13
15
  Provides-Extra: cli
14
16
  Requires-Dist: typer>=0.15; extra == 'cli'
15
17
  Description-Content-Type: text/markdown
@@ -1,12 +1,16 @@
1
1
  [project]
2
2
  name = "engin"
3
- version = "0.0.20"
3
+ version = "0.1.a1"
4
4
  description = "An async-first modular application framework"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
7
7
  license = "MIT"
8
8
  keywords = ["Dependency Injection", "Application Framework"]
9
- dependencies = []
9
+ dependencies = [
10
+ "anyio>=4",
11
+ # backwards compatability for exception group in 3.10
12
+ "exceptiongroup>=1",
13
+ ]
10
14
 
11
15
  [project.optional-dependencies]
12
16
  cli = ["typer>=0.15"]
@@ -79,7 +83,7 @@ ignore = [
79
83
 
80
84
 
81
85
  [tool.pytest.ini_options]
82
- log_cli = false
86
+ log_cli = true
83
87
  log_cli_level = "DEBUG"
84
88
  asyncio_mode = "auto"
85
89
  asyncio_default_fixture_loop_scope = "session"
@@ -4,6 +4,8 @@ from engin._dependency import Entrypoint, Invoke, Provide, Supply
4
4
  from engin._engin import Engin
5
5
  from engin._lifecycle import Lifecycle
6
6
  from engin._option import Option
7
+ from engin._shutdown import ShutdownSwitch
8
+ from engin._supervisor import OnException, Supervisor
7
9
  from engin._type_utils import TypeId
8
10
 
9
11
  __all__ = [
@@ -13,8 +15,11 @@ __all__ = [
13
15
  "Entrypoint",
14
16
  "Invoke",
15
17
  "Lifecycle",
18
+ "OnException",
16
19
  "Option",
17
20
  "Provide",
21
+ "ShutdownSwitch",
22
+ "Supervisor",
18
23
  "Supply",
19
24
  "TypeId",
20
25
  "invoke",
@@ -9,11 +9,15 @@ from itertools import chain
9
9
  from types import FrameType
10
10
  from typing import ClassVar
11
11
 
12
+ from anyio import CancelScope, create_task_group, get_cancelled_exc_class
13
+
12
14
  from engin._assembler import AssembledDependency, Assembler
13
15
  from engin._dependency import Invoke, Provide, Supply
14
16
  from engin._graph import DependencyGrapher, Node
15
17
  from engin._lifecycle import Lifecycle
16
18
  from engin._option import Option
19
+ from engin._shutdown import ShutdownSwitch
20
+ from engin._supervisor import Supervisor
17
21
  from engin._type_utils import TypeId
18
22
 
19
23
  _OS_IS_WINDOWS = os.name == "nt"
@@ -38,10 +42,10 @@ class Engin:
38
42
  1. The Engin assembles all Invocations. Only Providers that are required to satisfy
39
43
  the Invoke options parameters are assembled.
40
44
  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.
45
+ 3. Lifecycle Startup tasks registered by assembled dependencies are run sequentially.
46
+ 4. The Engin waits for a stop signal, i.e. SIGINT or SIGTERM, or for something to
47
+ set the ShutdownSwitch event.
48
+ 5. Lifecyce Shutdown tasks are run in the reverse order to the Startup order.
45
49
 
46
50
  Examples:
47
51
  ```python
@@ -49,11 +53,13 @@ class Engin:
49
53
 
50
54
  from httpx import AsyncClient
51
55
 
52
- from engin import Engin, Invoke, Provide
56
+ from engin import Engin, Invoke, Lifecycle, Provide
53
57
 
54
58
 
55
- def httpx_client() -> AsyncClient:
56
- return AsyncClient()
59
+ def httpx_client(lifecycle: Lifecycle) -> AsyncClient:
60
+ client = AsyncClient()
61
+ lifecycle.append(client)
62
+ return client
57
63
 
58
64
 
59
65
  async def main(http_client: AsyncClient) -> None:
@@ -65,7 +71,7 @@ class Engin:
65
71
  ```
66
72
  """
67
73
 
68
- _LIB_OPTIONS: ClassVar[list[Option]] = [Provide(Lifecycle)]
74
+ _LIB_OPTIONS: ClassVar[list[Option]] = [Provide(Lifecycle), Provide(Supervisor)]
69
75
 
70
76
  def __init__(self, *options: Option) -> None:
71
77
  """
@@ -77,14 +83,16 @@ class Engin:
77
83
  Args:
78
84
  *options: an instance of Provide, Supply, Invoke, Entrypoint or a Block.
79
85
  """
80
- self._stop_requested_event = Event()
86
+ self._stop_requested_event = ShutdownSwitch()
81
87
  self._stop_complete_event = Event()
82
88
  self._exit_stack: AsyncExitStack = AsyncExitStack()
83
89
  self._shutdown_task: Task | None = None
84
90
  self._run_task: Task | None = None
91
+ self._assembler = Assembler([])
85
92
 
86
93
  self._providers: dict[TypeId, Provide] = {
87
- TypeId.from_type(Engin): Supply(self, as_type=Engin)
94
+ TypeId.from_type(Assembler): Supply(self._assembler),
95
+ TypeId.from_type(ShutdownSwitch): Supply(self._stop_requested_event),
88
96
  }
89
97
  self._multiproviders: dict[TypeId, list[Provide]] = defaultdict(list)
90
98
  self._invocations: list[Invoke] = []
@@ -94,7 +102,9 @@ class Engin:
94
102
  option.apply(self)
95
103
 
96
104
  multi_providers = [p for multi in self._multiproviders.values() for p in multi]
97
- self._assembler = Assembler(chain(self._providers.values(), multi_providers))
105
+
106
+ for provider in chain(self._providers.values(), multi_providers):
107
+ self._assembler.add(provider)
98
108
 
99
109
  @property
100
110
  def assembler(self) -> Assembler:
@@ -105,11 +115,19 @@ class Engin:
105
115
  Run the engin.
106
116
 
107
117
  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.
118
+ SIGINT), the `stop` method is called on the engin, or a lifecycle task errors.
109
119
  """
110
120
  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()
121
+ if self._shutdown_task:
122
+ self._shutdown_task.cancel("redundant")
123
+ async with create_task_group() as tg:
124
+ tg.start_soon(_stop_engin_on_signal, self._stop_requested_event)
125
+ try:
126
+ await self._stop_requested_event.wait()
127
+ await self._shutdown()
128
+ except get_cancelled_exc_class():
129
+ with CancelScope(shield=True):
130
+ await self._shutdown()
113
131
 
114
132
  async def start(self) -> None:
115
133
  """
@@ -133,6 +151,10 @@ class Engin:
133
151
  return
134
152
 
135
153
  lifecycle = await self._assembler.build(Lifecycle)
154
+ supervisor = await self._assembler.build(Supervisor)
155
+
156
+ if not supervisor.empty:
157
+ lifecycle.append(supervisor)
136
158
 
137
159
  try:
138
160
  for hook in lifecycle.list():
@@ -178,7 +200,10 @@ class Engin:
178
200
  await self._shutdown()
179
201
 
180
202
 
181
- async def _wait_for_stop_signal(stop_requested_event: Event) -> None:
203
+ async def _stop_engin_on_signal(stop_requested_event: Event) -> None:
204
+ """
205
+ A task that waits for a stop signal (SIGINT/SIGTERM) and notifies the given event.
206
+ """
182
207
  try:
183
208
  # try to gracefully handle sigint/sigterm
184
209
  if not _OS_IS_WINDOWS:
@@ -0,0 +1,4 @@
1
+ from asyncio import Event
2
+
3
+
4
+ class ShutdownSwitch(Event): ...
@@ -0,0 +1,141 @@
1
+ import inspect
2
+ import logging
3
+ import typing
4
+ from collections.abc import Awaitable, Callable
5
+ from contextlib import AsyncExitStack
6
+ from dataclasses import dataclass
7
+ from enum import Enum
8
+ from types import TracebackType
9
+ from typing import TypeAlias
10
+
11
+ import anyio
12
+ from anyio import get_cancelled_exc_class
13
+ from exceptiongroup import BaseExceptionGroup, catch
14
+
15
+ from engin._shutdown import ShutdownSwitch
16
+
17
+ if typing.TYPE_CHECKING:
18
+ from anyio.abc import TaskGroup
19
+
20
+ LOG = logging.getLogger("engin")
21
+
22
+ TaskFactory: TypeAlias = Callable[[], Awaitable[None]]
23
+
24
+
25
+ class OnException(Enum):
26
+ SHUTDOWN = 0
27
+ """
28
+ Cancel all other supervised tasks and shutdown the Engin.
29
+ """
30
+
31
+ RETRY = 1
32
+ """
33
+ Retry the task.
34
+ """
35
+
36
+ IGNORE = 2
37
+ """
38
+ The task will be not be retried and the engin will not be stopped, other tasks will
39
+ continue to run.
40
+ """
41
+
42
+
43
+ @dataclass(kw_only=True, slots=True, eq=False)
44
+ class _SupervisorTask:
45
+ """
46
+ Attributes:
47
+ - factory: a coroutine function that can create the task.
48
+ - on_exception: determines the behaviour when task raises an exception.
49
+ - complete: will be set to true if task stops for any reason except cancellation.
50
+ - last_exception: the last exception raised by the task.
51
+ """
52
+
53
+ factory: TaskFactory
54
+ on_exception: OnException
55
+ complete: bool = False
56
+ last_exception: Exception | None = None
57
+
58
+ async def __call__(self) -> None:
59
+ # loop to allow for restarting erroring tasks
60
+ while True:
61
+ try:
62
+ await self.factory()
63
+ self.complete = True
64
+ return
65
+ except get_cancelled_exc_class():
66
+ LOG.info(f"{self.name} cancelled")
67
+ raise
68
+ except Exception as err:
69
+ LOG.error(f"Supervisor: {self.name} raised {type(err).__name__}", exc_info=err)
70
+ self.last_exception = err
71
+
72
+ if self.on_exception == OnException.IGNORE:
73
+ self.complete = True
74
+ return
75
+
76
+ if self.on_exception == OnException.RETRY:
77
+ continue
78
+
79
+ if self.on_exception == OnException.SHUTDOWN:
80
+ self.complete = True
81
+ raise
82
+
83
+ @property
84
+ def name(self) -> str:
85
+ factory = self.factory
86
+ if inspect.ismethod(factory):
87
+ return f"{factory.__self__.__class__.__name__}.{factory.__func__.__name__}"
88
+ if inspect.isclass(factory):
89
+ return type(factory).__name__
90
+ if inspect.isfunction(factory):
91
+ return factory.__name__
92
+ return str(factory)
93
+
94
+
95
+ class Supervisor:
96
+ def __init__(self, shutdown: ShutdownSwitch) -> None:
97
+ self._tasks: list[_SupervisorTask] = []
98
+ self._shutdown = shutdown
99
+ self._is_complete: bool = False
100
+
101
+ self._exit_stack: AsyncExitStack | None = None
102
+ self._task_group: TaskGroup | None = None
103
+
104
+ def supervise(
105
+ self, func: TaskFactory, *, on_exception: OnException = OnException.SHUTDOWN
106
+ ) -> None:
107
+ self._tasks.append(_SupervisorTask(factory=func, on_exception=on_exception))
108
+
109
+ @property
110
+ def empty(self) -> bool:
111
+ return not self._tasks
112
+
113
+ async def __aenter__(self) -> None:
114
+ if not self._tasks:
115
+ return
116
+
117
+ def _handler(_: BaseExceptionGroup) -> None:
118
+ self._shutdown.set()
119
+
120
+ self._exit_stack = AsyncExitStack()
121
+ await self._exit_stack.__aenter__()
122
+ self._exit_stack.enter_context(catch({Exception: _handler}))
123
+ self._task_group = await self._exit_stack.enter_async_context(
124
+ anyio.create_task_group()
125
+ )
126
+
127
+ for task in self._tasks:
128
+ self._task_group.start_soon(task, name=task.name)
129
+
130
+ async def __aexit__(
131
+ self,
132
+ exc_type: type[BaseException] | None,
133
+ exc_value: BaseException | None,
134
+ traceback: TracebackType | None,
135
+ /,
136
+ ) -> None:
137
+ if not self._tasks:
138
+ return
139
+
140
+ if self._exit_stack:
141
+ await self._exit_stack.__aexit__(exc_type, exc_value, traceback)
@@ -24,12 +24,12 @@ except ImportError as err:
24
24
  __all__ = ["APIRouteDependency", "FastAPIEngin", "Inject"]
25
25
 
26
26
 
27
- def _attach_assembler(app: FastAPI, engin: Engin) -> None:
27
+ def _attach_assembler(app: FastAPI, assembler: Assembler) -> None:
28
28
  """
29
29
  An invocation that attaches the Engin's Assembler to the FastAPI application, enabling
30
30
  the Inject marker.
31
31
  """
32
- app.state.assembler = engin.assembler
32
+ app.state.assembler = assembler
33
33
 
34
34
 
35
35
  class FastAPIEngin(ASGIEngin):
@@ -0,0 +1,17 @@
1
+ import asyncio
2
+
3
+ from engin import Engin, Invoke, Supervisor
4
+
5
+
6
+ async def delayed_error_task():
7
+ await asyncio.sleep(0.5)
8
+ raise RuntimeError("Process errored")
9
+
10
+
11
+ def supervise(supervisor: Supervisor) -> None:
12
+ supervisor.supervise(delayed_error_task)
13
+
14
+
15
+ async def test_error_in_task(caplog):
16
+ engin = Engin(Invoke(supervise))
17
+ await engin.run()
@@ -0,0 +1,115 @@
1
+ import asyncio
2
+ import contextlib
3
+ from asyncio import CancelledError
4
+ from dataclasses import dataclass
5
+
6
+ from engin import OnException, ShutdownSwitch, Supervisor
7
+
8
+
9
+ async def delayed_error_task():
10
+ await asyncio.sleep(0.05)
11
+ raise RuntimeError("Process errored")
12
+
13
+
14
+ async def happy_task():
15
+ await asyncio.sleep(1)
16
+
17
+
18
+ @dataclass
19
+ class ClassHappyTask:
20
+ async def run(self) -> None:
21
+ await asyncio.sleep(1)
22
+
23
+
24
+ async def test_empty_supervisor():
25
+ shutdown_event = ShutdownSwitch()
26
+ supervisor = Supervisor(shutdown_event)
27
+
28
+ with contextlib.suppress(CancelledError):
29
+ async with supervisor:
30
+ await asyncio.sleep(0.1)
31
+
32
+
33
+ async def test_supervisor_on_exception_shutdown():
34
+ shutdown_event = ShutdownSwitch()
35
+ supervisor = Supervisor(shutdown_event)
36
+
37
+ supervisor.supervise(delayed_error_task, on_exception=OnException.SHUTDOWN)
38
+
39
+ with contextlib.suppress(CancelledError):
40
+ async with supervisor:
41
+ await asyncio.sleep(0.1)
42
+
43
+ assert shutdown_event.is_set()
44
+
45
+
46
+ async def test_supervisor_on_exception_retry():
47
+ shutdown_event = ShutdownSwitch()
48
+ supervisor = Supervisor(shutdown_event)
49
+ attempt = 0
50
+
51
+ async def retry_task():
52
+ nonlocal attempt
53
+ if attempt == 0:
54
+ attempt += 1
55
+ raise RuntimeError("Process errored")
56
+
57
+ supervisor.supervise(retry_task, on_exception=OnException.RETRY)
58
+
59
+ with contextlib.suppress(CancelledError):
60
+ async with supervisor:
61
+ await asyncio.sleep(0.1)
62
+
63
+ assert supervisor._tasks[0].complete
64
+ assert isinstance(supervisor._tasks[0].last_exception, RuntimeError)
65
+ assert attempt == 1
66
+
67
+
68
+ async def test_supervisor_on_exception_ignore():
69
+ shutdown_event = ShutdownSwitch()
70
+ supervisor = Supervisor(shutdown_event)
71
+
72
+ async def error_task():
73
+ raise RuntimeError("Process errored")
74
+
75
+ async def complete_task():
76
+ await asyncio.sleep(0.09)
77
+
78
+ supervisor.supervise(error_task, on_exception=OnException.IGNORE)
79
+ supervisor.supervise(complete_task, on_exception=OnException.SHUTDOWN)
80
+
81
+ with contextlib.suppress(CancelledError):
82
+ async with supervisor:
83
+ await asyncio.sleep(0.1)
84
+
85
+ assert supervisor._tasks[0].complete
86
+ assert isinstance(supervisor._tasks[0].last_exception, RuntimeError)
87
+ assert supervisor._tasks[1].complete
88
+ assert supervisor._tasks[1].last_exception is None
89
+
90
+
91
+ async def test_supervisor():
92
+ shutdown_event = ShutdownSwitch()
93
+ supervisor = Supervisor(shutdown_event)
94
+
95
+ supervisor.supervise(delayed_error_task)
96
+ supervisor.supervise(happy_task)
97
+ supervisor.supervise(ClassHappyTask().run)
98
+
99
+ with contextlib.suppress(CancelledError):
100
+ async with supervisor:
101
+ await asyncio.sleep(1)
102
+
103
+ # task one completed and has error (as raised exception)
104
+ assert supervisor._tasks[0].complete
105
+ assert supervisor._tasks[0].last_exception is not None
106
+
107
+ # task two did not complete and has no error (as was cancelled)
108
+ assert not supervisor._tasks[1].complete
109
+ assert supervisor._tasks[1].last_exception is None
110
+
111
+ # task three did not complete and has no error (as was cancelled)
112
+ assert not supervisor._tasks[2].complete
113
+ assert supervisor._tasks[2].last_exception is None
114
+
115
+ assert shutdown_event.is_set()