engin 0.0.19__py3-none-any.whl → 0.1a1__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/_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
@@ -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:
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)
engin/_type_utils.py CHANGED
@@ -33,8 +33,8 @@ class TypeId:
33
33
  return TypeId(type=type_, multi=False)
34
34
 
35
35
  def __str__(self) -> str:
36
- module = self.type.__module__
37
- out = f"{module}." if module not in _implict_modules else ""
36
+ module = getattr(self.type, "__module__", None)
37
+ out = f"{module}." if module and module not in _implict_modules else ""
38
38
  out += _args_to_str(self.type)
39
39
  if self.multi:
40
40
  out += "[]"
@@ -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.19
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,13 +1,15 @@
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
- engin/_dependency.py,sha256=Nfq6L92LN4X53QpiMCIF3MjmWfuntYVOnZmmoPYYJEw,9165
5
- engin/_engin.py,sha256=yIpZdeqvm8hv0RxOV0veFuvyu9xQ054JSaeuUWwHdOQ,7380
4
+ engin/_dependency.py,sha256=xINk3sudxzsTmkUkNAKQwzBc0G0DfhpnrZli4z3ALBY,9459
5
+ engin/_engin.py,sha256=UGS-dIMGcdhRWvsQBuwIH6I7XFxYMlgSZeuvl3ur3Eo,8417
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/_type_utils.py,sha256=Pmm4m1_WdevT5KTe8tzY_BseNxPyhu_nKsLGgyNcPpo,2247
10
+ engin/_shutdown.py,sha256=G85Cz-wG06ZxQz6QVPcpIcNaGY44aZp6SL8H9J4YvfU,61
11
+ engin/_supervisor.py,sha256=wKfGPjz8q1SH8ZP84USO-SxY3uTEXWy2NkTlhZQGRDE,4131
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
13
15
  engin/_cli/__init__.py,sha256=koD5WTkZXb8QQIiVU5bJiSR1wwPGb5rv2iwd-v-BA7A,564
@@ -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.19.dist-info/METADATA,sha256=Rb1VPxLjnzVxhnq8Llie5XRxpI0GANR-dK31BEcPAqg,2354
22
- engin-0.0.19.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
23
- engin-0.0.19.dist-info/entry_points.txt,sha256=sW247zZUMxm0b5UKYvPuqQQljYDtU-j2zK3cu7gHwM0,41
24
- engin-0.0.19.dist-info/licenses/LICENSE,sha256=XHh5LPUPKZWTBqBv2xxN2RU7D59nHoiJGb5RIt8f45w,1070
25
- engin-0.0.19.dist-info/RECORD,,
22
+ engin/extensions/fastapi.py,sha256=7N6i-eZUEZRPo7kcvjS7kbRSY5QAPyKJXSeongSQ-OA,6371
23
+ engin-0.1a1.dist-info/METADATA,sha256=f5atVNZHxnofJLnnf2upFC_MYjAJv3lVkQIxe62z1TA,2410
24
+ engin-0.1a1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
25
+ engin-0.1a1.dist-info/entry_points.txt,sha256=sW247zZUMxm0b5UKYvPuqQQljYDtU-j2zK3cu7gHwM0,41
26
+ engin-0.1a1.dist-info/licenses/LICENSE,sha256=XHh5LPUPKZWTBqBv2xxN2RU7D59nHoiJGb5RIt8f45w,1070
27
+ engin-0.1a1.dist-info/RECORD,,
File without changes