engin 0.0.20__py3-none-any.whl → 0.1.0a2__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 CHANGED
@@ -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",
engin/_engin.py CHANGED
@@ -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. 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.
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
- return AsyncClient()
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._stop_requested_event = Event()
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(Engin): Supply(self, as_type=Engin)
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
- self._assembler = Assembler(chain(self._providers.values(), multi_providers))
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) or the `stop` method is called on the engin.
148
+ SIGINT), the `stop` method is called on the engin, or a lifecycle task errors.
109
149
  """
110
150
  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()
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
- self._stop_requested_event.set()
162
- if self._shutdown_task is None:
163
- return
164
- await self._stop_complete_event.wait()
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
- async def _wait_for_stop_signal(stop_requested_event: Event) -> None:
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:
engin/_shutdown.py ADDED
@@ -0,0 +1,4 @@
1
+ from asyncio import Event
2
+
3
+
4
+ class ShutdownSwitch(Event): ...
engin/_supervisor.py ADDED
@@ -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):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: engin
3
- Version: 0.0.20
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 zero-dependency application framework for modern Python.
23
+ Engin is a lightweight application framework for modern Python.
22
24
 
23
25
  **Documentation**: https://engin.readthedocs.io/
24
26
 
@@ -1,12 +1,14 @@
1
- engin/__init__.py,sha256=A8TE_ci7idoR683535YoBrWZbYTgXXS-q7Y2y51nZ5M,486
1
+ engin/__init__.py,sha256=v5OWwQoxTtqh2sB2E5iSMg3gATJoXKOuJLq28aZX6C8,642
2
2
  engin/_assembler.py,sha256=-ENSrXPMWacionIYrTSQO7th9DDBOPyAT8ybPbBRtQw,11318
3
3
  engin/_block.py,sha256=IacP4PoJKRhSQCbQSdoyCtmu362a4vj6qoUQKyaJwzI,3062
4
4
  engin/_dependency.py,sha256=xINk3sudxzsTmkUkNAKQwzBc0G0DfhpnrZli4z3ALBY,9459
5
- engin/_engin.py,sha256=yIpZdeqvm8hv0RxOV0veFuvyu9xQ054JSaeuUWwHdOQ,7380
5
+ engin/_engin.py,sha256=XkGUG3D7z8X31rZhbu02_NFhFwSKB4JPSoma5jv6nq4,9135
6
6
  engin/_graph.py,sha256=y1g7Lm_Zy5GPEgRsggCKV5DDaDzcwUl8v3IZCK8jyGI,1631
7
7
  engin/_introspect.py,sha256=VdREX6Lhhga5SnEP9G7mjHkgJR4mpqk_SMnmL2zTcqY,966
8
8
  engin/_lifecycle.py,sha256=cSWe3euZkmpxmUPFvph2lsTtvuZbxttEfBL-RnOI7lo,5325
9
9
  engin/_option.py,sha256=nZcdrehp1QwgxMUoIpsM0PJuu1q1pbXzhcVsetbsHpc,223
10
+ engin/_shutdown.py,sha256=G85Cz-wG06ZxQz6QVPcpIcNaGY44aZp6SL8H9J4YvfU,61
11
+ engin/_supervisor.py,sha256=wKfGPjz8q1SH8ZP84USO-SxY3uTEXWy2NkTlhZQGRDE,4131
10
12
  engin/_type_utils.py,sha256=H3Tl-kJr2wY2RhaTXP9GrMqa2RsXMijHbjHKe1AxGmc,2276
11
13
  engin/exceptions.py,sha256=-VPwPReZb9YEIkrWMR9TW2K5HEwmHHgEO7QWH6wfV8c,1946
12
14
  engin/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -17,9 +19,9 @@ engin/_cli/_graph.py,sha256=HMC91nWvTOr6_czPBNx1RU55Ib3qesJRCmbnL2DsdDk,4659
17
19
  engin/_cli/_inspect.py,sha256=0jm25d4wcbXVNJkyaeECSKY-irsxd-EIYBH1GDW_Yjc,3163
18
20
  engin/extensions/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
19
21
  engin/extensions/asgi.py,sha256=d5Z6gtMVWDZdAlvrTaMt987sKyiq__A0X4gJQ7IETmA,3247
20
- engin/extensions/fastapi.py,sha256=e8F4L_nZ9dU9j8mb9lXKwJG6CTu5aIk4N5faRj4EyUA,6369
21
- engin-0.0.20.dist-info/METADATA,sha256=KiKW4DvikfKJJNzoXh7oC4RMdr02W0PkhtxXB8DN6bo,2354
22
- engin-0.0.20.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
23
- engin-0.0.20.dist-info/entry_points.txt,sha256=sW247zZUMxm0b5UKYvPuqQQljYDtU-j2zK3cu7gHwM0,41
24
- engin-0.0.20.dist-info/licenses/LICENSE,sha256=XHh5LPUPKZWTBqBv2xxN2RU7D59nHoiJGb5RIt8f45w,1070
25
- engin-0.0.20.dist-info/RECORD,,
22
+ engin/extensions/fastapi.py,sha256=7N6i-eZUEZRPo7kcvjS7kbRSY5QAPyKJXSeongSQ-OA,6371
23
+ engin-0.1.0a2.dist-info/METADATA,sha256=eg-rPpaGOV1MDNenGqSGCldPlTHh2yZ3CcZ0b5agakc,2408
24
+ engin-0.1.0a2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
25
+ engin-0.1.0a2.dist-info/entry_points.txt,sha256=sW247zZUMxm0b5UKYvPuqQQljYDtU-j2zK3cu7gHwM0,41
26
+ engin-0.1.0a2.dist-info/licenses/LICENSE,sha256=XHh5LPUPKZWTBqBv2xxN2RU7D59nHoiJGb5RIt8f45w,1070
27
+ engin-0.1.0a2.dist-info/RECORD,,