engin 0.1.0rc2__py3-none-any.whl → 0.2.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/_assembler.py CHANGED
@@ -8,6 +8,8 @@ from inspect import BoundArguments, Signature
8
8
  from types import TracebackType
9
9
  from typing import Any, Generic, TypeVar, cast
10
10
 
11
+ from typing_extensions import Self
12
+
11
13
  from engin._dependency import Dependency, Provide, Supply
12
14
  from engin._type_utils import TypeId
13
15
  from engin.exceptions import NotInScopeError, ProviderError, TypeNotProvidedError
@@ -76,6 +78,34 @@ class Assembler:
76
78
  else:
77
79
  self._multiproviders[type_id].append(provider)
78
80
 
81
+ @classmethod
82
+ def from_mapped_providers(
83
+ cls,
84
+ providers: dict[TypeId, Provide[Any]],
85
+ multiproviders: dict[TypeId, list[Provide[list[Any]]]],
86
+ ) -> Self:
87
+ """
88
+ Create an Assembler from pre-mapped providers.
89
+
90
+ This method is only exposed for performance reasons in the case that Providers
91
+ have already been mapped, it is recommended to use the `__init__` method if this
92
+ is no the case.
93
+
94
+ Args:
95
+ providers: a dictionary of Providers with the Provider's `return_type_id` as
96
+ the key.
97
+ multiproviders: a dictionary of list of Providers with the Provider's
98
+ `return_type_id` as key. All Providers in the given list must be for the
99
+ related `return_type_id`.
100
+
101
+ Returns:
102
+ An Assembler instance.
103
+ """
104
+ assembler = cls(tuple()) # noqa: C408
105
+ assembler._providers = providers
106
+ assembler._multiproviders = multiproviders
107
+ return assembler
108
+
79
109
  @property
80
110
  def providers(self) -> Sequence[Provide[Any]]:
81
111
  multi_providers = [p for multi in self._multiproviders.values() for p in multi]
engin/_cli/_check.py CHANGED
@@ -50,13 +50,6 @@ def check_dependencies(
50
50
  for missing_type in sorted_missing:
51
51
  console.print(f" • {missing_type}", style="red")
52
52
 
53
- available_providers = sorted(
54
- str(provider.return_type_id) for provider in assembler.providers
55
- )
56
- console.print("\nAvailable providers:", style="yellow")
57
- for available_type in available_providers:
58
- console.print(f" • {available_type}", style="yellow")
59
-
60
53
  raise typer.Exit(code=1)
61
54
  else:
62
55
  console.print("✅ All dependencies are satisfied!", style="green bold")
engin/_cli/_common.py CHANGED
@@ -1,11 +1,11 @@
1
1
  import importlib
2
2
  import sys
3
3
  from pathlib import Path
4
- from typing import Never
5
4
 
6
5
  import typer
7
6
  from rich import print
8
7
  from rich.panel import Panel
8
+ from typing_extensions import Never
9
9
 
10
10
  from engin import Engin
11
11
 
engin/_cli/_graph.html CHANGED
@@ -400,7 +400,7 @@
400
400
  }
401
401
 
402
402
  if (node.style_classes && node.style_classes.length > 0) {
403
- styleClasses = `:::${node.style_classes.join(' ')}`;
403
+ styleClasses = `:::${node.style_classes.join(',')}`;
404
404
  }
405
405
 
406
406
  return shape + styleClasses;
engin/_dependency.py CHANGED
@@ -171,6 +171,9 @@ class Provide(Dependency[Any, T]):
171
171
  override: (optional) allow this provider to override other providers for the
172
172
  same type from the same package.
173
173
  """
174
+ if not callable(factory):
175
+ msg = "Provided value is not callable, did you mean to use Supply instead?"
176
+ raise ValueError(msg)
174
177
  super().__init__(func=factory)
175
178
  self._scope = scope
176
179
  self._override = override
@@ -181,7 +184,9 @@ class Provide(Dependency[Any, T]):
181
184
  if self._explicit_type is not None:
182
185
  self._signature = self._signature.replace(return_annotation=self._explicit_type)
183
186
 
184
- self._is_multi = typing.get_origin(self._return_type) is list
187
+ self._is_multi = (
188
+ typing.get_origin(self._return_type) is list or self._return_type is list
189
+ )
185
190
 
186
191
  # Validate that the provider does to depend on its own output value, as this will
187
192
  # cause a recursion error and is undefined behaviour wise.
@@ -195,9 +200,11 @@ class Provide(Dependency[Any, T]):
195
200
  if self._is_multi:
196
201
  args = typing.get_args(self._return_type)
197
202
  if len(args) != 1:
198
- raise ValueError(
199
- f"A multiprovider must be of the form list[X], not '{self._return_type}'"
203
+ msg = (
204
+ "A multiprovider must be of the form list[X], not "
205
+ f"'{self._return_type_id}'"
200
206
  )
207
+ raise ValueError(msg)
201
208
 
202
209
  @property
203
210
  def return_type(self) -> type[T]:
engin/_engin.py CHANGED
@@ -1,14 +1,14 @@
1
1
  import asyncio
2
2
  import logging
3
- import os
4
3
  import signal
4
+ import sys
5
5
  from asyncio import Event
6
6
  from collections import defaultdict
7
7
  from contextlib import AsyncExitStack
8
8
  from enum import Enum
9
9
  from itertools import chain
10
10
  from types import FrameType
11
- from typing import ClassVar
11
+ from typing import TYPE_CHECKING, ClassVar
12
12
 
13
13
  from anyio import create_task_group, open_signal_receiver
14
14
 
@@ -18,10 +18,12 @@ from engin._graph import DependencyGrapher, Node
18
18
  from engin._lifecycle import Lifecycle
19
19
  from engin._option import Option
20
20
  from engin._supervisor import Supervisor
21
- from engin._type_utils import TypeId
22
21
  from engin.exceptions import EnginError
23
22
 
24
- _OS_IS_WINDOWS = os.name == "nt"
23
+ if TYPE_CHECKING:
24
+ from engin._type_utils import TypeId
25
+
26
+ _OS_IS_WINDOWS = sys.platform == "win32"
25
27
  LOG = logging.getLogger("engin")
26
28
 
27
29
 
@@ -107,12 +109,9 @@ class Engin:
107
109
  self._stop_requested_event = Event()
108
110
  self._stop_complete_event = Event()
109
111
  self._exit_stack = AsyncExitStack()
110
- self._assembler = Assembler([])
111
112
  self._async_context_run_task: asyncio.Task | None = None
112
113
 
113
- self._providers: dict[TypeId, Provide] = {
114
- TypeId.from_type(Assembler): Supply(self._assembler),
115
- }
114
+ self._providers: dict[TypeId, Provide] = {}
116
115
  self._multiproviders: dict[TypeId, list[Provide]] = defaultdict(list)
117
116
  self._invocations: list[Invoke] = []
118
117
 
@@ -120,10 +119,12 @@ class Engin:
120
119
  for option in chain(self._LIB_OPTIONS, options):
121
120
  option.apply(self)
122
121
 
123
- multi_providers = [p for multi in self._multiproviders.values() for p in multi]
124
-
125
- for provider in chain(self._providers.values(), multi_providers):
126
- self._assembler.add(provider)
122
+ # initialise Assembler
123
+ self._assembler = Assembler.from_mapped_providers(
124
+ providers=self._providers,
125
+ multiproviders=self._multiproviders,
126
+ )
127
+ self._assembler.add(Supply(self._assembler))
127
128
 
128
129
  @property
129
130
  def assembler(self) -> Assembler:
@@ -179,7 +180,9 @@ class Engin:
179
180
  try:
180
181
  async with supervisor:
181
182
  await self._stop_requested_event.wait()
182
- await self._shutdown()
183
+
184
+ # shutdown after stopping supervised tasks
185
+ await self._shutdown()
183
186
  except BaseException:
184
187
  await self._shutdown()
185
188
 
@@ -251,28 +254,37 @@ class Engin:
251
254
  async def _stop_engin_on_signal(stop_requested_event: Event) -> None:
252
255
  """
253
256
  A task that waits for a stop signal (SIGINT/SIGTERM) and notifies the given event.
257
+
258
+ On unix-like systems we can use asyncio's `loop.add_signal_handler` method but this
259
+ does not work on Windows as `signal.set_wakeup_fd` is not supported.
260
+
261
+ Therefore on Windows we fallback to using `signal.signal` directly.
254
262
  """
255
263
  if not _OS_IS_WINDOWS:
256
- with open_signal_receiver(signal.SIGINT, signal.SIGTERM) as recieved_signals:
257
- async for signum in recieved_signals:
258
- LOG.debug(f"received {signum.name} signal")
264
+ with open_signal_receiver(signal.SIGINT, signal.SIGTERM) as received_signals:
265
+ async for signum in received_signals:
266
+ LOG.debug(f"received signal: {signum.name}")
259
267
  stop_requested_event.set()
268
+ break
260
269
  else:
261
- should_stop = False
262
270
 
263
- # windows does not support signal_handlers, so this is the workaround
264
271
  def ctrlc_handler(sig: int, frame: FrameType | None) -> None:
265
- nonlocal should_stop
266
- if should_stop:
272
+ LOG.debug(f"received signal: {signal.Signals(sig).name}")
273
+ if stop_requested_event.is_set():
267
274
  raise KeyboardInterrupt("Forced keyboard interrupt")
268
- should_stop = True
275
+ else:
276
+ stop_requested_event.set()
269
277
 
270
- signal.signal(signal.SIGINT, ctrlc_handler)
278
+ previous_signal_handler_sigint = signal.signal(signal.SIGINT, ctrlc_handler)
271
279
 
272
- while not should_stop:
273
- # In case engin is stopped via external `stop` call.
274
- if stop_requested_event.is_set():
275
- return
276
- await asyncio.sleep(0.1)
280
+ # technically not needed due to the _OS_IS_WINDOWS checks but keeps mypy happy
281
+ if hasattr(signal, "SIGBREAK"):
282
+ previous_signal_handler_sigbreak = signal.signal(signal.SIGBREAK, ctrlc_handler)
277
283
 
278
- stop_requested_event.set()
284
+ try:
285
+ await stop_requested_event.wait()
286
+ finally:
287
+ # restore orginal signal handlers
288
+ signal.signal(signal.SIGINT, previous_signal_handler_sigint)
289
+ if hasattr(signal, "SIGBREAK"):
290
+ signal.signal(signal.SIGBREAK, previous_signal_handler_sigbreak)
engin/_supervisor.py CHANGED
@@ -5,17 +5,18 @@ from collections.abc import Awaitable, Callable
5
5
  from dataclasses import dataclass
6
6
  from enum import Enum
7
7
  from types import TracebackType
8
- from typing import TypeAlias, assert_never
8
+ from typing import TypeAlias
9
9
 
10
10
  import anyio
11
11
  from anyio import get_cancelled_exc_class
12
+ from typing_extensions import assert_never
12
13
 
13
14
  if typing.TYPE_CHECKING:
14
15
  from anyio.abc import TaskGroup
15
16
 
16
17
  LOG = logging.getLogger("engin")
17
18
 
18
- TaskFactory: TypeAlias = Callable[[], Awaitable[None]]
19
+ AsyncFunction: TypeAlias = Callable[[], Awaitable[None]]
19
20
 
20
21
 
21
22
  class OnException(Enum):
@@ -41,15 +42,16 @@ class _SupervisorTask:
41
42
  """
42
43
  Attributes:
43
44
  - factory: a coroutine function that can create the task.
44
- - on_exception: determines the behaviour when task raises an exception.
45
+ - on_exception: determines the behaviour when the task raises an exception.
45
46
  - complete: will be set to true if task stops for any reason except cancellation.
46
47
  - last_exception: the last exception raised by the task.
47
48
  """
48
49
 
49
- factory: TaskFactory
50
+ factory: AsyncFunction
50
51
  on_exception: OnException
51
52
  complete: bool = False
52
53
  last_exception: Exception | None = None
54
+ shutdown_hook: AsyncFunction | None = None
53
55
 
54
56
  async def __call__(self) -> None:
55
57
  # loop to allow for restarting erroring tasks
@@ -63,7 +65,6 @@ class _SupervisorTask:
63
65
  raise
64
66
  except Exception as err:
65
67
  self.last_exception = err
66
-
67
68
  if self.on_exception == OnException.IGNORE:
68
69
  LOG.warning(
69
70
  f"supervisor task '{self.name}' raised {type(err).__name__} "
@@ -107,9 +108,17 @@ class Supervisor:
107
108
  self._task_group: TaskGroup | None = None
108
109
 
109
110
  def supervise(
110
- self, func: TaskFactory, *, on_exception: OnException = OnException.SHUTDOWN
111
+ self,
112
+ func: AsyncFunction,
113
+ *,
114
+ on_exception: OnException = OnException.SHUTDOWN,
115
+ shutdown_hook: AsyncFunction | None = None,
111
116
  ) -> None:
112
- self._tasks.append(_SupervisorTask(factory=func, on_exception=on_exception))
117
+ self._tasks.append(
118
+ _SupervisorTask(
119
+ factory=func, on_exception=on_exception, shutdown_hook=shutdown_hook
120
+ )
121
+ )
113
122
 
114
123
  @property
115
124
  def empty(self) -> bool:
@@ -122,6 +131,7 @@ class Supervisor:
122
131
  self._task_group = await anyio.create_task_group().__aenter__()
123
132
 
124
133
  for task in self._tasks:
134
+ LOG.info(f"supervising task: {task.name}")
125
135
  self._task_group.start_soon(task, name=task.name)
126
136
 
127
137
  async def __aexit__(
@@ -132,6 +142,10 @@ class Supervisor:
132
142
  /,
133
143
  ) -> None:
134
144
  if self._task_group:
145
+ for task in self._tasks:
146
+ if task.shutdown_hook is not None:
147
+ LOG.debug(f"supervised task shutdown hook: {task.name}")
148
+ await task.shutdown_hook()
135
149
  if not self._task_group.cancel_scope.cancel_called:
136
150
  self._task_group.cancel_scope.cancel()
137
151
  await self._task_group.__aexit__(exc_type, exc_value, traceback)
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: engin
3
- Version: 0.1.0rc2
4
- Summary: An async-first modular application framework
3
+ Version: 0.2.0
4
+ Summary: A dependency-injection powered application framework
5
5
  Project-URL: Homepage, https://github.com/invokermain/engin
6
6
  Project-URL: Documentation, https://engin.readthedocs.io/en/latest/
7
7
  Project-URL: Repository, https://github.com/invokermain/engin.git
@@ -12,6 +12,7 @@ Keywords: Application Framework,Dependency Injection
12
12
  Requires-Python: >=3.10
13
13
  Requires-Dist: anyio>=4
14
14
  Requires-Dist: exceptiongroup>=1
15
+ Requires-Dist: typing-extensions>=4
15
16
  Provides-Extra: cli
16
17
  Requires-Dist: tomli>=2.0; (python_version < '3.11') and extra == 'cli'
17
18
  Requires-Dist: typer>=0.15; extra == 'cli'
@@ -33,13 +34,13 @@ Engin is a lightweight application framework powered by dependency injection, it
33
34
  you build and maintain large monoliths and many microservices.
34
35
 
35
36
 
36
- ## Feature
37
+ ## Features
37
38
 
38
39
  The Engin framework gives you:
39
40
 
40
41
  - A fully-featured dependency injection system.
41
42
  - A robust application runtime with lifecycle hooks and supervised background tasks.
42
- - Zero boiler-plate code reuse across applications.
43
+ - Zero boilerplate code reuse across applications.
43
44
  - Integrations for other frameworks such as FastAPI.
44
45
  - Full async support.
45
46
  - CLI commands to aid local development.
@@ -120,3 +121,8 @@ Engin is heavily inspired by [Uber's Fx framework for Go](https://github.com/ube
120
121
  and the [Injector framework for Python](https://github.com/python-injector/injector).
121
122
 
122
123
  They are both great projects, go check them out.
124
+
125
+ ## Benchmarks
126
+
127
+ Automated benchmarks for the Engin framework can be viewed
128
+ [here](https://invokermain.github.io/engin/dev/bench/).
@@ -1,27 +1,27 @@
1
1
  engin/__init__.py,sha256=O0vS570kZFBq7Kwy4FgeJFIhfo4aIg5mv_Z_9vAQRio,577
2
- engin/_assembler.py,sha256=0uXgtcO5M3EHg0I-TQK9y7LOzfxkLFmKia-zLyVHaxA,11178
2
+ engin/_assembler.py,sha256=Afi9i5wcbhbzqhJlCB_oa9LcR6RMYaQoR40JfSfnuGo,12235
3
3
  engin/_block.py,sha256=IacP4PoJKRhSQCbQSdoyCtmu362a4vj6qoUQKyaJwzI,3062
4
- engin/_dependency.py,sha256=xINk3sudxzsTmkUkNAKQwzBc0G0DfhpnrZli4z3ALBY,9459
5
- engin/_engin.py,sha256=oGaf_iedMNKxl3rbADpPzIvNtTx1Pfs-6o0e8yRrmHk,9532
4
+ engin/_dependency.py,sha256=Z2eS-w_5TM_JgnOtrzCbwtQT6yGmkg-o0-zG02Nk1XU,9722
5
+ engin/_engin.py,sha256=ip4kazNp7pz7tLLi2jFwFJ7oCLLC9bj0p3sWU0EE0dA,10129
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/_supervisor.py,sha256=HI0D4StqSJZE2l6x7RtouRLKWx1HOhUmLHqu8pUcWbQ,4343
10
+ engin/_supervisor.py,sha256=E7bGOGP11qIcbueMUkn-felJdP_bLMJmTBrenQtNdTU,4853
11
11
  engin/_type_utils.py,sha256=H3Tl-kJr2wY2RhaTXP9GrMqa2RsXMijHbjHKe1AxGmc,2276
12
12
  engin/exceptions.py,sha256=lSMOJI4Yl-VIM0yDzFWbPhC0mQm4f0WvGULr9SldIaY,2353
13
13
  engin/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
14
  engin/_cli/__init__.py,sha256=Ixk3NoZeIN8Bj53I625uqJdLyyT9Gpbe_4GtNy-KQwM,636
15
- engin/_cli/_check.py,sha256=YA37Gi4rimKIH-XMs7SEAFkSRNBIMG8OCKfF3W1V3-g,1976
16
- engin/_cli/_common.py,sha256=6tyjxAkROCViw0LOFdx-X1U-iSXKyeW5CoE9UxWRybI,3282
17
- engin/_cli/_graph.html,sha256=YIv34LR00aWsWgjNrqO4XBNu4frPo_y-i1CijaZyySo,29073
15
+ engin/_cli/_check.py,sha256=w9GA9RmCgSflvSU7EQqXKiOCqgrZB-pLS_UoJOqV56E,1666
16
+ engin/_cli/_common.py,sha256=dY9YEqoo1yNIZNPptM-GZjU1LaPcnkSqmIUcWkSii54,3293
17
+ engin/_cli/_graph.html,sha256=5Dw5eyhsrU8KrpdhGn1mxo5kTUTJLMSzT-MBKvSv13g,29073
18
18
  engin/_cli/_graph.py,sha256=MsxsNpL1v0v1AUT57ZS97l1diwacqRaPdVBObHHIGJE,6753
19
19
  engin/_cli/_inspect.py,sha256=_uzldpHA51IX4srpUGzL4lZNiepqucsO9M3Zo83XBBM,3159
20
20
  engin/extensions/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
21
21
  engin/extensions/asgi.py,sha256=7vQFaVs1jxq1KbhHGN8k7x2UFab6SPUq2_hXfX6HiXU,3329
22
22
  engin/extensions/fastapi.py,sha256=7N6i-eZUEZRPo7kcvjS7kbRSY5QAPyKJXSeongSQ-OA,6371
23
- engin-0.1.0rc2.dist-info/METADATA,sha256=I7BtKglKAs30NQ6n73p3gzLhZhxcvsIZXuuM356Ipa4,3951
24
- engin-0.1.0rc2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
25
- engin-0.1.0rc2.dist-info/entry_points.txt,sha256=sW247zZUMxm0b5UKYvPuqQQljYDtU-j2zK3cu7gHwM0,41
26
- engin-0.1.0rc2.dist-info/licenses/LICENSE,sha256=XHh5LPUPKZWTBqBv2xxN2RU7D59nHoiJGb5RIt8f45w,1070
27
- engin-0.1.0rc2.dist-info/RECORD,,
23
+ engin-0.2.0.dist-info/METADATA,sha256=RtzssuxOEpDcTkl_eMXR_-utl6UMq7Ey-ayQgpUrVtw,4123
24
+ engin-0.2.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
25
+ engin-0.2.0.dist-info/entry_points.txt,sha256=sW247zZUMxm0b5UKYvPuqQQljYDtU-j2zK3cu7gHwM0,41
26
+ engin-0.2.0.dist-info/licenses/LICENSE,sha256=XHh5LPUPKZWTBqBv2xxN2RU7D59nHoiJGb5RIt8f45w,1070
27
+ engin-0.2.0.dist-info/RECORD,,