engin 0.0.20__tar.gz → 0.1.0a2__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.
- {engin-0.0.20 → engin-0.1.0a2}/CHANGELOG.md +6 -0
- {engin-0.0.20 → engin-0.1.0a2}/PKG-INFO +4 -2
- {engin-0.0.20 → engin-0.1.0a2}/README.md +1 -1
- {engin-0.0.20 → engin-0.1.0a2}/docs/index.md +1 -1
- {engin-0.0.20 → engin-0.1.0a2}/pyproject.toml +7 -3
- {engin-0.0.20 → engin-0.1.0a2}/src/engin/__init__.py +5 -0
- {engin-0.0.20 → engin-0.1.0a2}/src/engin/_engin.py +87 -25
- engin-0.1.0a2/src/engin/_shutdown.py +4 -0
- engin-0.1.0a2/src/engin/_supervisor.py +141 -0
- {engin-0.0.20 → engin-0.1.0a2}/src/engin/extensions/fastapi.py +2 -2
- engin-0.1.0a2/tests/acceptance/test_engin_signal_handling.py +17 -0
- {engin-0.0.20 → engin-0.1.0a2}/tests/acceptance/test_error_in_start_up.py +8 -0
- engin-0.1.0a2/tests/acceptance/test_error_in_supervised_task.py +21 -0
- engin-0.1.0a2/tests/test_supervisor.py +115 -0
- {engin-0.0.20 → engin-0.1.0a2}/uv.lock +211 -192
- {engin-0.0.20 → engin-0.1.0a2}/.github/workflows/benchmark.yaml +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/.github/workflows/check.yaml +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/.github/workflows/publish.yaml +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/.gitignore +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/.readthedocs.yaml +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/LICENSE +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/docs/concepts/blocks.md +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/docs/concepts/engin.md +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/docs/concepts/invocations.md +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/docs/concepts/lifecycle.md +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/docs/concepts/providers.md +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/docs/getting-started.md +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/docs/guides/fastapi-graph.png +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/docs/guides/fastapi.md +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/docs/js/readthedocs.js +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/docs/overrides/main.html +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/docs/reference.md +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/examples/__init__.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/examples/asgi/__init__.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/examples/asgi/app.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/examples/asgi/common/__init__.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/examples/asgi/common/db/__init__.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/examples/asgi/common/db/adapaters/__init__.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/examples/asgi/common/db/adapaters/memory.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/examples/asgi/common/db/block.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/examples/asgi/common/db/ports.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/examples/asgi/common/starlette/__init__.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/examples/asgi/common/starlette/endpoint.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/examples/asgi/features/__init__.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/examples/asgi/features/cats/__init__.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/examples/asgi/features/cats/api/__init__.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/examples/asgi/features/cats/api/get.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/examples/asgi/features/cats/api/post.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/examples/asgi/features/cats/block.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/examples/asgi/features/cats/domain.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/examples/asgi/main.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/examples/fastapi/__init__.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/examples/fastapi/app.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/examples/fastapi/main.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/examples/fastapi/routes/__init__.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/examples/fastapi/routes/cats/__init__.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/examples/fastapi/routes/cats/adapters/__init__.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/examples/fastapi/routes/cats/adapters/repository.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/examples/fastapi/routes/cats/api.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/examples/fastapi/routes/cats/block.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/examples/fastapi/routes/cats/domain.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/examples/fastapi/routes/cats/ports.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/examples/simple/__init__.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/examples/simple/main.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/mkdocs.yaml +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/src/engin/_assembler.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/src/engin/_block.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/src/engin/_cli/__init__.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/src/engin/_cli/_common.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/src/engin/_cli/_graph.html +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/src/engin/_cli/_graph.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/src/engin/_cli/_inspect.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/src/engin/_dependency.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/src/engin/_graph.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/src/engin/_introspect.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/src/engin/_lifecycle.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/src/engin/_option.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/src/engin/_type_utils.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/src/engin/exceptions.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/src/engin/extensions/__init__.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/src/engin/extensions/asgi.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/src/engin/py.typed +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/tests/__init__.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/tests/acceptance/__init__.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/tests/acceptance/test_error_in_shutdown.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/tests/acceptance/test_fastapi.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/tests/benchmarks/__init__.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/tests/benchmarks/conftest.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/tests/benchmarks/test_bench_assembler.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/tests/cli/__init__.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/tests/cli/test_graph.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/tests/cli/test_inspect.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/tests/conftest.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/tests/deps.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/tests/test_assembler.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/tests/test_block.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/tests/test_dependencies.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/tests/test_engin.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/tests/test_graph.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/tests/test_lifecycle.py +0 -0
- {engin-0.0.20 → engin-0.1.0a2}/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.
|
3
|
+
Version: 0.1.0a2
|
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
|
@@ -18,7 +20,7 @@ Description-Content-Type: text/markdown
|
|
18
20
|
|
19
21
|
# Engin 🏎️
|
20
22
|
|
21
|
-
Engin is a
|
23
|
+
Engin is a lightweight application framework for modern Python.
|
22
24
|
|
23
25
|
**Documentation**: https://engin.readthedocs.io/
|
24
26
|
|
@@ -1,6 +1,6 @@
|
|
1
1
|
# Introduction
|
2
2
|
|
3
|
-
Engin is a
|
3
|
+
Engin is a lightweight application framework for modern Python.
|
4
4
|
|
5
5
|
Engin is inspired by [Uber's Fx framework for Go](https://github.com/uber-go/fx) and the
|
6
6
|
[Injector framework for Python](https://github.com/python-injector/injector).
|
@@ -1,12 +1,16 @@
|
|
1
1
|
[project]
|
2
2
|
name = "engin"
|
3
|
-
version = "0.
|
3
|
+
version = "0.1.0a2"
|
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 =
|
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",
|
@@ -5,21 +5,54 @@ import signal
|
|
5
5
|
from asyncio import Event, Task
|
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 CancelScope, create_task_group, get_cancelled_exc_class
|
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._shutdown import ShutdownSwitch
|
21
|
+
from engin._supervisor import Supervisor
|
17
22
|
from engin._type_utils import TypeId
|
23
|
+
from engin.exceptions import EnginError
|
18
24
|
|
19
25
|
_OS_IS_WINDOWS = os.name == "nt"
|
20
26
|
LOG = logging.getLogger("engin")
|
21
27
|
|
22
28
|
|
29
|
+
class _EnginState(Enum):
|
30
|
+
IDLE = 0
|
31
|
+
"""
|
32
|
+
Not yet started.
|
33
|
+
"""
|
34
|
+
|
35
|
+
STARTED = 1
|
36
|
+
"""
|
37
|
+
Engin started via .start() call
|
38
|
+
"""
|
39
|
+
|
40
|
+
RUNNING = 2
|
41
|
+
"""
|
42
|
+
Engin running via .run() call
|
43
|
+
"""
|
44
|
+
|
45
|
+
STOPPING = 3
|
46
|
+
"""
|
47
|
+
Engin stopped via .stop() call
|
48
|
+
"""
|
49
|
+
|
50
|
+
SHUTDOWN = 4
|
51
|
+
"""
|
52
|
+
Engin has performed shutdown
|
53
|
+
"""
|
54
|
+
|
55
|
+
|
23
56
|
class Engin:
|
24
57
|
"""
|
25
58
|
The Engin is a modular application defined by a collection of options.
|
@@ -38,10 +71,10 @@ class Engin:
|
|
38
71
|
1. The Engin assembles all Invocations. Only Providers that are required to satisfy
|
39
72
|
the Invoke options parameters are assembled.
|
40
73
|
2. All Invocations are run sequentially in the order they were passed in to the Engin.
|
41
|
-
3.
|
42
|
-
|
43
|
-
|
44
|
-
5.
|
74
|
+
3. Lifecycle Startup tasks registered by assembled dependencies are run sequentially.
|
75
|
+
4. The Engin waits for a stop signal, i.e. SIGINT or SIGTERM, or for something to
|
76
|
+
set the ShutdownSwitch event.
|
77
|
+
5. Lifecyce Shutdown tasks are run in the reverse order to the Startup order.
|
45
78
|
|
46
79
|
Examples:
|
47
80
|
```python
|
@@ -49,11 +82,13 @@ class Engin:
|
|
49
82
|
|
50
83
|
from httpx import AsyncClient
|
51
84
|
|
52
|
-
from engin import Engin, Invoke, Provide
|
85
|
+
from engin import Engin, Invoke, Lifecycle, Provide
|
53
86
|
|
54
87
|
|
55
|
-
def httpx_client() -> AsyncClient:
|
56
|
-
|
88
|
+
def httpx_client(lifecycle: Lifecycle) -> AsyncClient:
|
89
|
+
client = AsyncClient()
|
90
|
+
lifecycle.append(client)
|
91
|
+
return client
|
57
92
|
|
58
93
|
|
59
94
|
async def main(http_client: AsyncClient) -> None:
|
@@ -65,7 +100,7 @@ class Engin:
|
|
65
100
|
```
|
66
101
|
"""
|
67
102
|
|
68
|
-
_LIB_OPTIONS: ClassVar[list[Option]] = [Provide(Lifecycle)]
|
103
|
+
_LIB_OPTIONS: ClassVar[list[Option]] = [Provide(Lifecycle), Provide(Supervisor)]
|
69
104
|
|
70
105
|
def __init__(self, *options: Option) -> None:
|
71
106
|
"""
|
@@ -77,14 +112,17 @@ class Engin:
|
|
77
112
|
Args:
|
78
113
|
*options: an instance of Provide, Supply, Invoke, Entrypoint or a Block.
|
79
114
|
"""
|
80
|
-
self.
|
115
|
+
self._state = _EnginState.IDLE
|
116
|
+
self._stop_requested_event = ShutdownSwitch()
|
81
117
|
self._stop_complete_event = Event()
|
82
118
|
self._exit_stack: AsyncExitStack = AsyncExitStack()
|
83
119
|
self._shutdown_task: Task | None = None
|
84
120
|
self._run_task: Task | None = None
|
121
|
+
self._assembler = Assembler([])
|
85
122
|
|
86
123
|
self._providers: dict[TypeId, Provide] = {
|
87
|
-
TypeId.from_type(
|
124
|
+
TypeId.from_type(Assembler): Supply(self._assembler),
|
125
|
+
TypeId.from_type(ShutdownSwitch): Supply(self._stop_requested_event),
|
88
126
|
}
|
89
127
|
self._multiproviders: dict[TypeId, list[Provide]] = defaultdict(list)
|
90
128
|
self._invocations: list[Invoke] = []
|
@@ -94,7 +132,9 @@ class Engin:
|
|
94
132
|
option.apply(self)
|
95
133
|
|
96
134
|
multi_providers = [p for multi in self._multiproviders.values() for p in multi]
|
97
|
-
|
135
|
+
|
136
|
+
for provider in chain(self._providers.values(), multi_providers):
|
137
|
+
self._assembler.add(provider)
|
98
138
|
|
99
139
|
@property
|
100
140
|
def assembler(self) -> Assembler:
|
@@ -105,11 +145,24 @@ class Engin:
|
|
105
145
|
Run the engin.
|
106
146
|
|
107
147
|
The engin will run until it is stopped via an external signal (i.e. SIGTERM or
|
108
|
-
SIGINT)
|
148
|
+
SIGINT), the `stop` method is called on the engin, or a lifecycle task errors.
|
109
149
|
"""
|
110
150
|
await self.start()
|
111
|
-
|
112
|
-
|
151
|
+
|
152
|
+
# engin failed to start, so exit early
|
153
|
+
if self._state != _EnginState.STARTED:
|
154
|
+
return
|
155
|
+
|
156
|
+
self._state = _EnginState.RUNNING
|
157
|
+
|
158
|
+
async with create_task_group() as tg:
|
159
|
+
tg.start_soon(_stop_engin_on_signal, self._stop_requested_event)
|
160
|
+
try:
|
161
|
+
await self._stop_requested_event.wait()
|
162
|
+
await self._shutdown()
|
163
|
+
except get_cancelled_exc_class():
|
164
|
+
with CancelScope(shield=True):
|
165
|
+
await self._shutdown()
|
113
166
|
|
114
167
|
async def start(self) -> None:
|
115
168
|
"""
|
@@ -119,6 +172,9 @@ class Engin:
|
|
119
172
|
lifecycle to complete and then returns. The caller is then responsible for
|
120
173
|
calling `stop`.
|
121
174
|
"""
|
175
|
+
if self._state != _EnginState.IDLE:
|
176
|
+
raise EnginError("Engin is not idle, unable to start")
|
177
|
+
|
122
178
|
LOG.info("starting engin")
|
123
179
|
assembled_invocations: list[AssembledDependency] = [
|
124
180
|
await self._assembler.assemble(invocation) for invocation in self._invocations
|
@@ -133,6 +189,10 @@ class Engin:
|
|
133
189
|
return
|
134
190
|
|
135
191
|
lifecycle = await self._assembler.build(Lifecycle)
|
192
|
+
supervisor = await self._assembler.build(Supervisor)
|
193
|
+
|
194
|
+
if not supervisor.empty:
|
195
|
+
lifecycle.append(supervisor)
|
136
196
|
|
137
197
|
try:
|
138
198
|
for hook in lifecycle.list():
|
@@ -147,8 +207,7 @@ class Engin:
|
|
147
207
|
return
|
148
208
|
|
149
209
|
LOG.info("startup complete")
|
150
|
-
|
151
|
-
self._shutdown_task = asyncio.create_task(self._shutdown_when_stopped())
|
210
|
+
self._state = _EnginState.STARTED
|
152
211
|
|
153
212
|
async def stop(self) -> None:
|
154
213
|
"""
|
@@ -158,10 +217,13 @@ class Engin:
|
|
158
217
|
Note this method can be safely called at any point, even before the engin is
|
159
218
|
started.
|
160
219
|
"""
|
161
|
-
|
162
|
-
if self.
|
163
|
-
|
164
|
-
|
220
|
+
# If the Engin was ran via `start()` perform shutdown directly
|
221
|
+
if self._state == _EnginState.STARTED:
|
222
|
+
await self._shutdown()
|
223
|
+
# If the Engin was ran via `run()` notify via event
|
224
|
+
elif self._state == _EnginState.RUNNING:
|
225
|
+
self._stop_requested_event.set()
|
226
|
+
await self._stop_complete_event.wait()
|
165
227
|
|
166
228
|
def graph(self) -> list[Node]:
|
167
229
|
grapher = DependencyGrapher({**self._providers, **self._multiproviders})
|
@@ -172,13 +234,13 @@ class Engin:
|
|
172
234
|
await self._exit_stack.aclose()
|
173
235
|
self._stop_complete_event.set()
|
174
236
|
LOG.info("shutdown complete")
|
237
|
+
self._state = _EnginState.SHUTDOWN
|
175
238
|
|
176
|
-
async def _shutdown_when_stopped(self) -> None:
|
177
|
-
await self._stop_requested_event.wait()
|
178
|
-
await self._shutdown()
|
179
239
|
|
180
|
-
|
181
|
-
|
240
|
+
async def _stop_engin_on_signal(stop_requested_event: Event) -> None:
|
241
|
+
"""
|
242
|
+
A task that waits for a stop signal (SIGINT/SIGTERM) and notifies the given event.
|
243
|
+
"""
|
182
244
|
try:
|
183
245
|
# try to gracefully handle sigint/sigterm
|
184
246
|
if not _OS_IS_WINDOWS:
|
@@ -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,
|
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 =
|
32
|
+
app.state.assembler = assembler
|
33
33
|
|
34
34
|
|
35
35
|
class FastAPIEngin(ASGIEngin):
|
@@ -0,0 +1,17 @@
|
|
1
|
+
import asyncio
|
2
|
+
from asyncio import TaskGroup
|
3
|
+
|
4
|
+
from engin import Engin
|
5
|
+
from engin._engin import _EnginState
|
6
|
+
|
7
|
+
|
8
|
+
async def test_engin_signal_handling():
|
9
|
+
engin = Engin()
|
10
|
+
|
11
|
+
async with TaskGroup() as tg:
|
12
|
+
tg.create_task(engin.run())
|
13
|
+
# give it time to startup
|
14
|
+
await asyncio.sleep(0.1)
|
15
|
+
assert engin._state == _EnginState.RUNNING
|
16
|
+
await engin.stop()
|
17
|
+
assert engin._state == _EnginState.SHUTDOWN
|
@@ -1,3 +1,4 @@
|
|
1
|
+
import asyncio
|
1
2
|
from contextlib import asynccontextmanager
|
2
3
|
|
3
4
|
from starlette.applications import Starlette
|
@@ -35,6 +36,13 @@ async def test_error_in_startup():
|
|
35
36
|
assert not B_LIFECYCLE_STATE
|
36
37
|
|
37
38
|
|
39
|
+
async def test_error_in_startup_handled_when_run():
|
40
|
+
engin = Engin(Invoke(a), Invoke(b))
|
41
|
+
|
42
|
+
await asyncio.wait_for(engin.run(), timeout=0.5)
|
43
|
+
assert not B_LIFECYCLE_STATE
|
44
|
+
|
45
|
+
|
38
46
|
async def test_error_in_startup_asgi():
|
39
47
|
def asgi_type() -> ASGIType:
|
40
48
|
return Starlette()
|
@@ -0,0 +1,21 @@
|
|
1
|
+
import asyncio
|
2
|
+
|
3
|
+
from engin import Engin, Invoke, Supervisor
|
4
|
+
|
5
|
+
|
6
|
+
async def delayed_error_task():
|
7
|
+
raise RuntimeError("Process errored")
|
8
|
+
|
9
|
+
|
10
|
+
def supervise(supervisor: Supervisor) -> None:
|
11
|
+
supervisor.supervise(delayed_error_task)
|
12
|
+
|
13
|
+
|
14
|
+
async def test_error_in_supervised_task_handled_when_run(caplog):
|
15
|
+
engin = Engin(Invoke(supervise))
|
16
|
+
await asyncio.wait_for(engin.run(), timeout=0.5)
|
17
|
+
|
18
|
+
|
19
|
+
async def test_error_in_supervised_task_handled_when_start(caplog):
|
20
|
+
engin = Engin(Invoke(supervise))
|
21
|
+
await asyncio.wait_for(engin.start(), timeout=0.5)
|
@@ -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()
|