engin 0.0.3__py3-none-any.whl → 0.0.5__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
@@ -3,12 +3,11 @@ from engin._assembler import Assembler
3
3
  from engin._block import Block, invoke, provide
4
4
  from engin._dependency import Entrypoint, Invoke, Provide, Supply
5
5
  from engin._engin import Engin, Option
6
- from engin._exceptions import AssemblyError
6
+ from engin._exceptions import ProviderError
7
7
  from engin._lifecycle import Lifecycle
8
8
 
9
9
  __all__ = [
10
10
  "Assembler",
11
- "AssemblyError",
12
11
  "Block",
13
12
  "Engin",
14
13
  "Entrypoint",
@@ -16,6 +15,7 @@ __all__ = [
16
15
  "Lifecycle",
17
16
  "Option",
18
17
  "Provide",
18
+ "ProviderError",
19
19
  "Supply",
20
20
  "ext",
21
21
  "invoke",
engin/_assembler.py CHANGED
@@ -7,7 +7,7 @@ from inspect import BoundArguments, Signature
7
7
  from typing import Any, Generic, TypeVar, cast
8
8
 
9
9
  from engin._dependency import Dependency, Provide, Supply
10
- from engin._exceptions import AssemblyError
10
+ from engin._exceptions import ProviderError
11
11
  from engin._type_utils import TypeId, type_id_of
12
12
 
13
13
  LOG = logging.getLogger("engin")
@@ -17,14 +17,40 @@ T = TypeVar("T")
17
17
 
18
18
  @dataclass(slots=True, kw_only=True, frozen=True)
19
19
  class AssembledDependency(Generic[T]):
20
+ """
21
+ An AssembledDependency can be called to construct the result.
22
+ """
23
+
20
24
  dependency: Dependency[Any, T]
21
25
  bound_args: BoundArguments
22
26
 
23
27
  async def __call__(self) -> T:
28
+ """
29
+ Construct the dependency.
30
+
31
+ Returns:
32
+ The constructed value.
33
+ """
24
34
  return await self.dependency(*self.bound_args.args, **self.bound_args.kwargs)
25
35
 
26
36
 
27
37
  class Assembler:
38
+ """
39
+ A container for Providers that is responsible for building provided types.
40
+
41
+ The Assembler acts as a cache for previously built types, meaning repeat calls
42
+ to `get` will produce the same value.
43
+
44
+ Examples:
45
+ ```python
46
+ def build_str() -> str:
47
+ return "foo"
48
+
49
+ a = Assembler([Provide(build_str)])
50
+ await a.get(str)
51
+ ```
52
+ """
53
+
28
54
  def __init__(self, providers: Iterable[Provide]) -> None:
29
55
  self._providers: dict[TypeId, Provide[Any]] = {}
30
56
  self._multiproviders: dict[TypeId, list[Provide[list[Any]]]] = defaultdict(list)
@@ -41,6 +67,82 @@ class Assembler:
41
67
  else:
42
68
  self._multiproviders[type_id].append(provider)
43
69
 
70
+ async def assemble(self, dependency: Dependency[Any, T]) -> AssembledDependency[T]:
71
+ """
72
+ Assemble a dependency.
73
+
74
+ Given a Dependency type, such as Invoke, the Assembler constructs the types
75
+ required by the Dependency's signature from its providers.
76
+
77
+ Args:
78
+ dependency: the Dependency to assemble.
79
+
80
+ Returns:
81
+ An AssembledDependency, which can be awaited to construct the final value.
82
+ """
83
+ async with self._lock:
84
+ return AssembledDependency(
85
+ dependency=dependency,
86
+ bound_args=await self._bind_arguments(dependency.signature),
87
+ )
88
+
89
+ async def get(self, type_: type[T]) -> T:
90
+ """
91
+ Return the constructed value for the given type.
92
+
93
+ This method assembles the required Providers and constructs their corresponding
94
+ values.
95
+
96
+ If the
97
+
98
+ Args:
99
+ type_: the type of the desired value.
100
+
101
+ Raises:
102
+ LookupError: When no provider is found for the given type.
103
+ ProviderError: When a provider errors when trying to construct the type or
104
+ any of its dependent types.
105
+
106
+ Returns:
107
+ The constructed value.
108
+ """
109
+ type_id = type_id_of(type_)
110
+ if type_id in self._dependencies:
111
+ return cast(T, self._dependencies[type_id])
112
+ if type_id.multi:
113
+ out = []
114
+ if type_id not in self._multiproviders:
115
+ raise LookupError(f"no provider found for target type id '{type_id}'")
116
+ for provider in self._multiproviders[type_id]:
117
+ assembled_dependency = await self.assemble(provider)
118
+ try:
119
+ out.extend(await assembled_dependency())
120
+ except Exception as err:
121
+ raise ProviderError(
122
+ provider=provider,
123
+ error_type=type(err),
124
+ error_message=str(err),
125
+ ) from err
126
+ self._dependencies[type_id] = out
127
+ return out # type: ignore[return-value]
128
+ else:
129
+ if type_id not in self._providers:
130
+ raise LookupError(f"no provider found for target type id '{type_id}'")
131
+ assembled_dependency = await self.assemble(self._providers[type_id])
132
+ try:
133
+ value = await assembled_dependency()
134
+ except Exception as err:
135
+ raise ProviderError(
136
+ provider=self._providers[type_id],
137
+ error_type=type(err),
138
+ error_message=str(err),
139
+ ) from err
140
+ self._dependencies[type_id] = value
141
+ return value # type: ignore[return-value]
142
+
143
+ def has(self, type_: type[T]) -> bool:
144
+ return type_id_of(type_) in self._providers
145
+
44
146
  def _resolve_providers(self, type_id: TypeId) -> Collection[Provide]:
45
147
  if type_id.multi:
46
148
  providers = self._multiproviders.get(type_id)
@@ -73,7 +175,7 @@ class Assembler:
73
175
  try:
74
176
  value = await provider(*bound_args.args, **bound_args.kwargs)
75
177
  except Exception as err:
76
- raise AssemblyError(
178
+ raise ProviderError(
77
179
  provider=provider, error_type=type(err), error_message=str(err)
78
180
  ) from err
79
181
  if provider.is_multiprovider:
@@ -102,30 +204,3 @@ class Assembler:
102
204
  kwargs[param.name] = val
103
205
 
104
206
  return signature.bind(*args, **kwargs)
105
-
106
- async def assemble(self, dependency: Dependency[Any, T]) -> AssembledDependency[T]:
107
- async with self._lock:
108
- return AssembledDependency(
109
- dependency=dependency,
110
- bound_args=await self._bind_arguments(dependency.signature),
111
- )
112
-
113
- async def get(self, type_: type[T]) -> T:
114
- type_id = type_id_of(type_)
115
- if type_id in self._dependencies:
116
- return cast(T, self._dependencies[type_id])
117
- if type_id.multi:
118
- out = []
119
- for provider in self._multiproviders[type_id]:
120
- assembled_dependency = await self.assemble(provider)
121
- out.extend(await assembled_dependency())
122
- self._dependencies[type_id] = out
123
- return out # type: ignore[return-value]
124
- else:
125
- assembled_dependency = await self.assemble(self._providers[type_id])
126
- value = await assembled_dependency()
127
- self._dependencies[type_id] = value
128
- return value # type: ignore[return-value]
129
-
130
- def has(self, type_: type[T]) -> bool:
131
- return type_id_of(type_) in self._providers
engin/_block.py CHANGED
@@ -6,16 +6,48 @@ from engin._dependency import Func, Invoke, Provide
6
6
 
7
7
 
8
8
  def provide(func: Func) -> Func:
9
- func._opt = Provide(func) # type: ignore[union-attr]
9
+ """
10
+ A decorator for defining a Provider in a Block.
11
+ """
12
+ func._opt = Provide(func) # type: ignore[attr-defined]
10
13
  return func
11
14
 
12
15
 
13
16
  def invoke(func: Func) -> Func:
14
- func._opt = Invoke(func) # type: ignore[union-attr]
17
+ """
18
+ A decorator for defining an Invocation in a Block.
19
+ """
20
+ func._opt = Invoke(func) # type: ignore[attr-defined]
15
21
  return func
16
22
 
17
23
 
18
24
  class Block(Iterable[Provide | Invoke]):
25
+ """
26
+ A Block is a collection of providers and invocations.
27
+
28
+ Blocks are useful for grouping a collection of related providers and invocations, and
29
+ are themselves a valid Option type that can be passed to the Engin.
30
+
31
+ Providers are defined as methods decorated with the `provide` decorator, and similarly
32
+ for Invocations and the `invoke` decorator.
33
+
34
+ Examples:
35
+ Define a simple block.
36
+
37
+ ```python3
38
+ from engin import Block, provide, invoke
39
+
40
+ class MyBlock(Block):
41
+ @provide
42
+ def some_str(self) -> str:
43
+ return "foo"
44
+
45
+ @invoke
46
+ def print_str(self, string: str) -> None:
47
+ print(f"invoked on string '{string}')
48
+ ```
49
+ """
50
+
19
51
  options: ClassVar[list[Provide | Invoke]] = []
20
52
 
21
53
  def __init__(self, /, block_name: str | None = None) -> None:
@@ -23,7 +55,7 @@ class Block(Iterable[Provide | Invoke]):
23
55
  self._name = block_name or f"{type(self).__name__}"
24
56
  for _, method in inspect.getmembers(self):
25
57
  if opt := getattr(method, "_opt", None):
26
- if not isinstance(opt, (Provide, Invoke)):
58
+ if not isinstance(opt, Provide | Invoke):
27
59
  raise RuntimeError("Block option is not an instance of Provide or Invoke")
28
60
  opt.set_block_name(self._name)
29
61
  self._options.append(opt)
engin/_dependency.py CHANGED
@@ -1,14 +1,12 @@
1
1
  import inspect
2
2
  import typing
3
3
  from abc import ABC
4
+ from collections.abc import Awaitable, Callable
4
5
  from inspect import Parameter, Signature, isclass, iscoroutinefunction
5
6
  from typing import (
6
7
  Any,
7
- Awaitable,
8
- Callable,
9
8
  Generic,
10
9
  ParamSpec,
11
- Type,
12
10
  TypeAlias,
13
11
  TypeVar,
14
12
  cast,
@@ -19,9 +17,7 @@ from engin._type_utils import TypeId, type_id_of
19
17
 
20
18
  P = ParamSpec("P")
21
19
  T = TypeVar("T")
22
- Func: TypeAlias = (
23
- Callable[P, T] | Callable[P, Awaitable[T]] | Callable[[], T] | Callable[[], Awaitable[T]]
24
- )
20
+ Func: TypeAlias = Callable[P, T]
25
21
  _SELF = object()
26
22
 
27
23
 
@@ -66,11 +62,29 @@ class Dependency(ABC, Generic[P, T]):
66
62
  if self._is_async:
67
63
  return await cast(Awaitable[T], self._func(*args, **kwargs))
68
64
  else:
69
- return cast(T, self._func(*args, **kwargs))
65
+ return self._func(*args, **kwargs)
70
66
 
71
67
 
72
68
  class Invoke(Dependency):
73
- def __init__(self, invocation: Func[P, T], block_name: str | None = None):
69
+ """
70
+ Marks a function as an Invocation.
71
+
72
+ Invocations are functions that are called prior to lifecycle startup. Invocations
73
+ should not be long running as the application startup will be blocked until all
74
+ Invocation are completed.
75
+
76
+ Invocations can be provided as an Option to the Engin or a Block.
77
+
78
+ Examples:
79
+ ```python3
80
+ def print_string(a_string: str) -> None:
81
+ print(f"invoking with value: '{a_string}'")
82
+
83
+ invocation = Invoke(print_string)
84
+ ```
85
+ """
86
+
87
+ def __init__(self, invocation: Func[P, T], block_name: str | None = None) -> None:
74
88
  super().__init__(func=invocation, block_name=block_name)
75
89
 
76
90
  def __str__(self) -> str:
@@ -78,7 +92,13 @@ class Invoke(Dependency):
78
92
 
79
93
 
80
94
  class Entrypoint(Invoke):
81
- def __init__(self, type_: Type[Any], *, block_name: str | None = None) -> None:
95
+ """
96
+ Marks a type as an Entrypoint.
97
+
98
+ Entrypoints are a short hand for no-op Invocations that can be used to
99
+ """
100
+
101
+ def __init__(self, type_: type[Any], *, block_name: str | None = None) -> None:
82
102
  self._type = type_
83
103
  super().__init__(invocation=_noop, block_name=block_name)
84
104
 
@@ -99,7 +119,7 @@ class Entrypoint(Invoke):
99
119
 
100
120
 
101
121
  class Provide(Dependency[Any, T]):
102
- def __init__(self, builder: Func[P, T], block_name: str | None = None):
122
+ def __init__(self, builder: Func[P, T], block_name: str | None = None) -> None:
103
123
  super().__init__(func=builder, block_name=block_name)
104
124
  self._is_multi = typing.get_origin(self.return_type) is list
105
125
 
@@ -111,14 +131,16 @@ class Provide(Dependency[Any, T]):
111
131
  )
112
132
 
113
133
  @property
114
- def return_type(self) -> Type[T]:
134
+ def return_type(self) -> type[T]:
115
135
  if isclass(self._func):
116
136
  return_type = self._func # __init__ returns self
117
137
  else:
118
138
  try:
119
139
  return_type = get_type_hints(self._func)["return"]
120
- except KeyError:
121
- raise RuntimeError(f"Dependency '{self.name}' requires a return typehint")
140
+ except KeyError as err:
141
+ raise RuntimeError(
142
+ f"Dependency '{self.name}' requires a return typehint"
143
+ ) from err
122
144
 
123
145
  return return_type
124
146
 
@@ -140,7 +162,7 @@ class Provide(Dependency[Any, T]):
140
162
  class Supply(Provide, Generic[T]):
141
163
  def __init__(
142
164
  self, value: T, *, type_hint: type | None = None, block_name: str | None = None
143
- ):
165
+ ) -> None:
144
166
  self._value = value
145
167
  self._type_hint = type_hint
146
168
  if self._type_hint is not None:
@@ -148,7 +170,7 @@ class Supply(Provide, Generic[T]):
148
170
  super().__init__(builder=self._get_val, block_name=block_name)
149
171
 
150
172
  @property
151
- def return_type(self) -> Type[T]:
173
+ def return_type(self) -> type[T]:
152
174
  if self._type_hint is not None:
153
175
  return self._type_hint
154
176
  if isinstance(self._value, list):
engin/_engin.py CHANGED
@@ -1,7 +1,12 @@
1
+ import asyncio
1
2
  import logging
2
- from asyncio import Event
3
+ import os
4
+ import signal
5
+ from asyncio import Event, Task
3
6
  from collections.abc import Iterable
7
+ from contextlib import AsyncExitStack
4
8
  from itertools import chain
9
+ from types import FrameType
5
10
  from typing import ClassVar, TypeAlias
6
11
 
7
12
  from engin import Entrypoint
@@ -16,14 +21,74 @@ LOG = logging.getLogger("engin")
16
21
  Option: TypeAlias = Invoke | Provide | Supply | Block
17
22
  _Opt: TypeAlias = Invoke | Provide | Supply
18
23
 
24
+ _OS_IS_WINDOWS = os.name == "nt"
25
+
19
26
 
20
27
  class Engin:
28
+ """
29
+ The Engin is a modular application defined by a collection of options.
30
+
31
+ Users should instantiate the Engin with a number of options, where options can be an
32
+ instance of Provide, Invoke, or a collection of these combined in a Block.
33
+
34
+ To create a useful application, users should pass in one or more providers (Provide or
35
+ Supply) and at least one invocation (Invoke or Entrypoint).
36
+
37
+ When instantiated the Engin can be run. This is typically done via the `run` method,
38
+ but certain use cases, e.g. testing, it can be easier to use the `start` and `stop`
39
+ methods.
40
+
41
+ When ran the Engin takes care of the complete application lifecycle:
42
+ 1. The Engin assembles all Invocations. Only Providers that are required to satisfy
43
+ the Invoke options parameters are assembled.
44
+ 2. All Invocations are run sequentially in the order they were passed in to the Engin.
45
+ 3. Any Lifecycle Startup defined by a provider that was assembled in order to satisfy
46
+ the constructors is ran.
47
+ 4. The Engin waits for a stop signal, i.e. SIGINT or SIGTERM.
48
+ 5. Any Lifecyce Shutdown task is ran, in the reverse order to the Startup order.
49
+
50
+ Examples:
51
+ ```python
52
+ import asyncio
53
+
54
+ from httpx import AsyncClient
55
+
56
+ from engin import Engin, Invoke, Provide
57
+
58
+
59
+ def httpx_client() -> AsyncClient:
60
+ return AsyncClient()
61
+
62
+
63
+ async def main(http_client: AsyncClient) -> None:
64
+ print(await http_client.get("https://httpbin.org/get"))
65
+
66
+ engin = Engin(Provide(httpx_client), Invoke(main))
67
+
68
+ asyncio.run(engin.run())
69
+ ```
70
+ """
71
+
21
72
  _LIB_OPTIONS: ClassVar[list[Option]] = [Provide(Lifecycle)]
22
73
 
23
74
  def __init__(self, *options: Option) -> None:
75
+ """
76
+ Initialise the class with the provided options.
77
+
78
+ Examples:
79
+ >>> engin = Engin(Provide(construct_a), Invoke(do_b), Supply(C()), MyBlock())
80
+
81
+ Args:
82
+ *options: an instance of Provide, Supply, Invoke, Entrypoint or a Block.
83
+ """
24
84
  self._providers: dict[TypeId, Provide] = {TypeId.from_type(Engin): Provide(self._self)}
25
85
  self._invokables: list[Invoke] = []
26
- self._stop_event = Event()
86
+
87
+ self._stop_requested_event = Event()
88
+ self._stop_complete_event = Event()
89
+ self._exit_stack: AsyncExitStack = AsyncExitStack()
90
+ self._shutdown_task: Task | None = None
91
+ self._run_task: Task | None = None
27
92
 
28
93
  self._destruct_options(chain(self._LIB_OPTIONS, options))
29
94
  self._assembler = Assembler(self._providers.values())
@@ -33,36 +98,78 @@ class Engin:
33
98
  return self._assembler
34
99
 
35
100
  async def run(self) -> None:
36
- await self.start()
37
-
38
- # wait till stop signal recieved
39
- await self._stop_event.wait()
101
+ """
102
+ Run the engin.
40
103
 
41
- await self.stop()
104
+ The engin will run until it is stopped via an external signal (i.e. SIGTERM or
105
+ SIGINT) or the `stop` method is called on the engin.
106
+ """
107
+ await self.start()
108
+ self._run_task = asyncio.create_task(_wait_for_stop_signal(self._stop_requested_event))
109
+ await self._stop_requested_event.wait()
110
+ await self._shutdown()
42
111
 
43
112
  async def start(self) -> None:
113
+ """
114
+ Start the engin.
115
+
116
+ This is an alternative to calling `run`. This method waits for the startup
117
+ lifecycle to complete and then returns. The caller is then responsible for
118
+ calling `stop`.
119
+ """
44
120
  LOG.info("starting engin")
45
121
  assembled_invocations: list[AssembledDependency] = [
46
122
  await self._assembler.assemble(invocation) for invocation in self._invokables
47
123
  ]
124
+
48
125
  for invocation in assembled_invocations:
49
- await invocation()
126
+ try:
127
+ await invocation()
128
+ except Exception as err:
129
+ name = invocation.dependency.name
130
+ LOG.error(f"invocation '{name}' errored, exiting", exc_info=err)
131
+ return
50
132
 
51
133
  lifecycle = await self._assembler.get(Lifecycle)
52
- await lifecycle.startup()
53
- self._stop_event = Event()
134
+
135
+ try:
136
+ for hook in lifecycle.list():
137
+ await self._exit_stack.enter_async_context(hook)
138
+ except Exception as err:
139
+ LOG.error("lifecycle startup error, exiting", exc_info=err)
140
+ await self._exit_stack.aclose()
141
+ return
142
+
54
143
  LOG.info("startup complete")
55
144
 
145
+ self._shutdown_task = asyncio.create_task(self._shutdown_when_stopped())
146
+
56
147
  async def stop(self) -> None:
57
- self._stop_event.set()
58
- lifecycle = await self._assembler.get(Lifecycle)
59
- await lifecycle.shutdown()
148
+ """
149
+ Stop the engin.
150
+
151
+ This method will wait for the shutdown lifecycle to complete before returning.
152
+ Note this method can be safely called at any point, even before the engin is
153
+ started.
154
+ """
155
+ self._stop_requested_event.set()
156
+ await self._stop_complete_event.wait()
157
+
158
+ async def _shutdown(self) -> None:
159
+ LOG.info("stopping engin")
160
+ await self._exit_stack.aclose()
161
+ self._stop_complete_event.set()
162
+ LOG.info("shutdown complete")
163
+
164
+ async def _shutdown_when_stopped(self) -> None:
165
+ await self._stop_requested_event.wait()
166
+ await self._shutdown()
60
167
 
61
168
  def _destruct_options(self, options: Iterable[Option]) -> None:
62
169
  for opt in options:
63
170
  if isinstance(opt, Block):
64
171
  self._destruct_options(opt)
65
- if isinstance(opt, (Provide, Supply)):
172
+ if isinstance(opt, Provide | Supply):
66
173
  existing = self._providers.get(opt.return_type_id)
67
174
  self._log_option(opt, overwrites=existing)
68
175
  self._providers[opt.return_type_id] = opt
@@ -90,3 +197,35 @@ class Engin:
90
197
 
91
198
  def _self(self) -> "Engin":
92
199
  return self
200
+
201
+
202
+ async def _wait_for_stop_signal(stop_requested_event: Event) -> None:
203
+ try:
204
+ # try to gracefully handle sigint/sigterm
205
+ if not _OS_IS_WINDOWS:
206
+ loop = asyncio.get_running_loop()
207
+ for signame in (signal.SIGINT, signal.SIGTERM):
208
+ loop.add_signal_handler(signame, stop_requested_event.set)
209
+
210
+ await stop_requested_event.wait()
211
+ else:
212
+ should_stop = False
213
+
214
+ # windows does not support signal_handlers, so this is the workaround
215
+ def ctrlc_handler(sig: int, frame: FrameType | None) -> None:
216
+ nonlocal should_stop
217
+ if should_stop:
218
+ raise KeyboardInterrupt("Forced keyboard interrupt")
219
+ should_stop = True
220
+
221
+ signal.signal(signal.SIGINT, ctrlc_handler)
222
+
223
+ while not should_stop:
224
+ # In case engin is stopped via external `stop` call.
225
+ if stop_requested_event.is_set():
226
+ return
227
+ await asyncio.sleep(0.1)
228
+
229
+ stop_requested_event.set()
230
+ except asyncio.CancelledError:
231
+ pass
engin/_exceptions.py CHANGED
@@ -3,9 +3,16 @@ from typing import Any
3
3
  from engin._dependency import Provide
4
4
 
5
5
 
6
- class AssemblyError(Exception):
6
+ class ProviderError(Exception):
7
+ """
8
+ Raised when a Provider errors during Assembly.
9
+ """
10
+
7
11
  def __init__(
8
- self, provider: Provide[Any], error_type: type[Exception], error_message: str
12
+ self,
13
+ provider: Provide[Any],
14
+ error_type: type[Exception],
15
+ error_message: str,
9
16
  ) -> None:
10
17
  self.provider = provider
11
18
  self.error_type = error_type
engin/_lifecycle.py CHANGED
@@ -1,21 +1,104 @@
1
- from collections.abc import Callable
2
- from contextlib import AbstractAsyncContextManager, AsyncExitStack
1
+ import asyncio
2
+ import logging
3
+ from contextlib import AbstractAsyncContextManager, AbstractContextManager
4
+ from types import TracebackType
5
+ from typing import TypeAlias, TypeGuard, cast
6
+
7
+ LOG = logging.getLogger("engin")
8
+
9
+ _AnyContextManager: TypeAlias = AbstractAsyncContextManager | AbstractContextManager
3
10
 
4
11
 
5
12
  class Lifecycle:
13
+ """
14
+ Allows dependencies to define startup and shutdown tasks for the application.
15
+
16
+ Lifecycle tasks are defined using Context Managers, these can be async or sync.
17
+
18
+ Lifecycle tasks should generally be defined in Providers as they are tied to the
19
+ construction of a given dependency, but can be used in Invocations. The Lifecycle
20
+ type is provided as a built-in Dependency by the Engin framework.
21
+
22
+ Examples:
23
+ Using a type that implements context management.
24
+
25
+ ```python
26
+ from httpx import AsyncClient
27
+
28
+ def my_provider(lifecycle: Lifecycle) -> AsyncClient:
29
+ client = AsyncClient()
30
+
31
+ # AsyncClient is a context manager
32
+ lifecycle.append(client)
33
+ ```
34
+
35
+ Defining a custom lifecycle.
36
+
37
+ ```python
38
+ def my_provider(lifecycle: Lifecycle) -> str:
39
+ @contextmanager
40
+ def task():
41
+ print("starting up!")
42
+ yield
43
+ print("shutting down!)
44
+
45
+ lifecycle.append(task)
46
+ ```
47
+ """
48
+
6
49
  def __init__(self) -> None:
7
- self._on_startup: list[Callable[..., None]] = []
8
- self._on_shutdown: list[Callable[..., None]] = []
9
50
  self._context_managers: list[AbstractAsyncContextManager] = []
10
- self._stack: AsyncExitStack = AsyncExitStack()
11
51
 
12
- def register_context(self, cm: AbstractAsyncContextManager) -> None:
13
- self._context_managers.append(cm)
52
+ def append(self, cm: _AnyContextManager, /) -> None:
53
+ """
54
+ Append a Lifecycle task to the list.
55
+
56
+ Args:
57
+ cm: a task defined as a ContextManager or AsyncContextManager.
58
+ """
59
+ suppressed_cm = _AExitSuppressingAsyncContextManager(cm)
60
+ self._context_managers.append(suppressed_cm)
61
+
62
+ def list(self) -> list[AbstractAsyncContextManager]:
63
+ """
64
+ List all the defined tasks.
65
+
66
+ Returns:
67
+ A copy of the list of Lifecycle tasks.
68
+ """
69
+ return self._context_managers[:]
70
+
71
+
72
+ class _AExitSuppressingAsyncContextManager(AbstractAsyncContextManager):
73
+ def __init__(self, cm: _AnyContextManager) -> None:
74
+ self._cm = cm
75
+
76
+ async def __aenter__(self) -> None:
77
+ if self._is_async_cm(self._cm):
78
+ await self._cm.__aenter__()
79
+ else:
80
+ await asyncio.to_thread(cast(AbstractContextManager, self._cm).__enter__)
14
81
 
15
- async def startup(self) -> None:
16
- self._stack = AsyncExitStack()
17
- for cm in self._context_managers:
18
- await self._stack.enter_async_context(cm)
82
+ async def __aexit__(
83
+ self,
84
+ exc_type: type[BaseException] | None,
85
+ exc_value: BaseException | None,
86
+ traceback: TracebackType | None,
87
+ /,
88
+ ) -> None:
89
+ try:
90
+ if self._is_async_cm(self._cm):
91
+ await self._cm.__aexit__(exc_type, exc_value, traceback)
92
+ else:
93
+ await asyncio.to_thread(
94
+ cast(AbstractContextManager, self._cm).__exit__,
95
+ exc_type,
96
+ exc_value,
97
+ traceback,
98
+ )
99
+ except Exception as err:
100
+ LOG.error("error in lifecycle hook stop, ignoring...", exc_info=err)
19
101
 
20
- async def shutdown(self) -> None:
21
- await self._stack.aclose()
102
+ @staticmethod
103
+ def _is_async_cm(cm: _AnyContextManager) -> TypeGuard[AbstractAsyncContextManager]:
104
+ return hasattr(cm, "__aenter__")
engin/_type_utils.py CHANGED
@@ -5,13 +5,26 @@ from typing import Any
5
5
  _implict_modules = ["builtins", "typing", "collections.abc"]
6
6
 
7
7
 
8
- @dataclass(frozen=True, eq=True)
8
+ @dataclass(frozen=True, eq=True, slots=True)
9
9
  class TypeId:
10
+ """
11
+ Represents information about a Type in the Dependency Injection framework.
12
+ """
13
+
10
14
  type: type
11
15
  multi: bool
12
16
 
13
17
  @classmethod
14
18
  def from_type(cls, type_: Any) -> "TypeId":
19
+ """
20
+ Construct a TypeId from a given type.
21
+
22
+ Args:
23
+ type_: any type.
24
+
25
+ Returns:
26
+ The corresponding TypeId for that type.
27
+ """
15
28
  if is_multi_type(type_):
16
29
  inner_obj = typing.get_args(type_)[0]
17
30
  return TypeId(type=inner_obj, multi=True)
engin/ext/fastapi.py CHANGED
@@ -1,4 +1,3 @@
1
- import typing
2
1
  from typing import ClassVar, TypeVar
3
2
 
4
3
  from engin import Engin, Invoke, Option
@@ -8,13 +7,10 @@ try:
8
7
  from fastapi import FastAPI
9
8
  from fastapi.params import Depends
10
9
  from starlette.requests import HTTPConnection
11
- except ImportError:
12
- raise ImportError("fastapi must be installed to use the corresponding extension")
13
-
14
-
15
- if typing.TYPE_CHECKING:
16
- from fastapi import FastAPI
17
- from fastapi.params import Depends
10
+ except ImportError as err:
11
+ raise ImportError(
12
+ "fastapi package must be installed to use the fastapi extension"
13
+ ) from err
18
14
 
19
15
  __all__ = ["FastAPIEngin", "Inject"]
20
16
 
@@ -27,7 +23,7 @@ def _attach_engin(
27
23
 
28
24
 
29
25
  class FastAPIEngin(ASGIEngin):
30
- _LIB_OPTIONS: ClassVar[list[Option]] = [*ASGIEngin._LIB_OPTIONS, Invoke(_attach_engin)] # type: ignore[arg-type]
26
+ _LIB_OPTIONS: ClassVar[list[Option]] = [*ASGIEngin._LIB_OPTIONS, Invoke(_attach_engin)]
31
27
  _asgi_type = FastAPI
32
28
 
33
29
 
@@ -0,0 +1,61 @@
1
+ Metadata-Version: 2.4
2
+ Name: engin
3
+ Version: 0.0.5
4
+ Summary: An async-first modular application framework
5
+ License-File: LICENSE
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+
9
+ # Engin 🏎️
10
+
11
+ Engin is a zero-dependency application framework for modern Python.
12
+
13
+ **Documentation**: https://engin.readthedocs.io/
14
+
15
+ ## Features ✨
16
+
17
+ - **Dependency Injection** - Engin includes a fully-featured Dependency Injection system,
18
+ powered by type hints.
19
+ - **Lifecycle Management** - Engin provides a simple & portable approach for attaching
20
+ startup and shutdown tasks to the application's lifecycle.
21
+ - **Code Reuse** - Engin's modular components, called Blocks, work great as distributed
22
+ packages allowing zero boiler-plate code reuse across multiple applications. Perfect for
23
+ maintaining many services across your organisation.
24
+ - **Ecosystem Compatability** - Engin ships with integrations for popular frameworks that
25
+ provide their own Dependency Injection, for example FastAPI, allowing you to integrate
26
+ Engin into existing code bases incrementally.
27
+ - **Async Native**: Engin is an async framework, meaning first class support for async
28
+ dependencies. However Engin will happily run synchronous code as well.
29
+
30
+ ## Installation
31
+
32
+ Engin is available on PyPI, install using your favourite dependency manager:
33
+
34
+ - **pip**:`pip install engin`
35
+ - **poetry**: `poetry add engin`
36
+ - **uv**: `uv add engin`
37
+
38
+ ## Getting Started
39
+
40
+ A minimal example:
41
+
42
+ ```python
43
+ import asyncio
44
+
45
+ from httpx import AsyncClient
46
+
47
+ from engin import Engin, Invoke, Provide
48
+
49
+
50
+ def httpx_client() -> AsyncClient:
51
+ return AsyncClient()
52
+
53
+
54
+ async def main(http_client: AsyncClient) -> None:
55
+ print(await http_client.get("https://httpbin.org/get"))
56
+
57
+ engin = Engin(Provide(httpx_client), Invoke(main))
58
+
59
+ asyncio.run(engin.run())
60
+ ```
61
+
@@ -0,0 +1,16 @@
1
+ engin/__init__.py,sha256=yTc8k0HDGMIrxDdEEA90qGD_dExQjVIbXCyaOFRrnMg,508
2
+ engin/_assembler.py,sha256=VCZA_Gq4hnH5LueB_vEVqsKbGXx-nI6KQ65YhzXw-VY,7575
3
+ engin/_block.py,sha256=-5qTp1Hdm3H54nScDGitFpcXRHLIyVHlDYATg_3dnPw,2045
4
+ engin/_dependency.py,sha256=oh1T7oR-c9MGcZ6ZFUgPnvHRf-n6AIvpbm59R97To80,5404
5
+ engin/_engin.py,sha256=kk3U_SZLlGYDbFbPYmErvlRqKE855yPvHBlX6XH2Row,8212
6
+ engin/_exceptions.py,sha256=fsc4pTOIGHUh0x7oZhEXPJUTE268sIhswLoiqXaudiw,635
7
+ engin/_lifecycle.py,sha256=_jQnGFj4RYXsxMpcXPJQagFOwnoTVh7oSN8oUYoYuW0,3246
8
+ engin/_type_utils.py,sha256=C71kX2Dr-gluGSL018K4uihX3zkTe7QNWaHhFU10ZmA,2127
9
+ engin/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ engin/ext/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ engin/ext/asgi.py,sha256=6vuC4zIhsvAdmwRn2I6uuUWPYfqobox1dv7skg2OWwE,1940
12
+ engin/ext/fastapi.py,sha256=CH2Zi7Oh_Va0TJGx05e7_LqAiCsoI1qcu0Z59_rgfRk,899
13
+ engin-0.0.5.dist-info/METADATA,sha256=7qu0kb9zWAZWkp0osgHkCqFKBwKykvYUrH4JcbmBY_M,1806
14
+ engin-0.0.5.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
15
+ engin-0.0.5.dist-info/licenses/LICENSE,sha256=XHh5LPUPKZWTBqBv2xxN2RU7D59nHoiJGb5RIt8f45w,1070
16
+ engin-0.0.5.dist-info/RECORD,,
@@ -1,56 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: engin
3
- Version: 0.0.3
4
- Summary: An async-first modular application framework
5
- License-File: LICENSE
6
- Requires-Python: >=3.10
7
- Description-Content-Type: text/markdown
8
-
9
- # Engin 🏎️
10
-
11
- Engin is a zero-dependency application framework for modern Python.
12
-
13
- ## Features ✨
14
-
15
- - **Lightweight**: Engin has no dependencies.
16
- - **Async First**: Engin provides first-class support for applications.
17
- - **Dependency Injection**: Engin promotes a modular decoupled architecture in your application.
18
- - **Lifecycle Management**: Engin provides an simple, portable approach for implememting
19
- startup and shutdown tasks.
20
- - **Ecosystem Compatability**: seamlessly integrate with frameworks such as FastAPI without
21
- having to migrate your dependencies.
22
- - **Code Reuse**: Engin's modular components work great as packages and distributions. Allowing
23
- low boiler-plate code reuse within your Organisation.
24
-
25
- ## Installation
26
-
27
- Engin is available on PyPI, install using your favourite dependency manager:
28
-
29
- - **pip**:`pip install engin`
30
- - **poetry**: `poetry add engin`
31
- - **uv**: `uv add engin`
32
-
33
- ## Getting Started
34
-
35
- A minimal example:
36
-
37
- ```python
38
- import asyncio
39
-
40
- from httpx import AsyncClient
41
-
42
- from engin import Engin, Invoke, Provide
43
-
44
-
45
- def httpx_client() -> AsyncClient:
46
- return AsyncClient()
47
-
48
-
49
- async def main(http_client: AsyncClient) -> None:
50
- print(await http_client.get("https://httpbin.org/get"))
51
-
52
- engin = Engin(Provide(httpx_client), Invoke(main))
53
-
54
- asyncio.run(engin.run())
55
- ```
56
-
@@ -1,16 +0,0 @@
1
- engin/__init__.py,sha256=AIE9wnwvfXwS1mwp6Sa9xtatBYyYzhWa36GKfAkEx_M,508
2
- engin/_assembler.py,sha256=UY97tXdHgvRi_iph_kolXZ8YTrxRjHKoIc4tAhqYOcw,5258
3
- engin/_block.py,sha256=N_rSakTKM5mEW9qUfNio6WRaW6hByu8-HHBSy9UuN8Y,1149
4
- engin/_dependency.py,sha256=d1G3P6vYTFLJTFOh4DLu_EK5XW3rDX0ejBHGkE7JJbs,4759
5
- engin/_engin.py,sha256=feqRxk7fXx5VeTcRVJLPn49E5a7TqZ1ZnxUDFRxGznQ,3244
6
- engin/_exceptions.py,sha256=nkzTqxrW5nkcNgFDGoZ2TBtnHtO2RLk0qghM5LNAEmU,542
7
- engin/_lifecycle.py,sha256=zW9W7wU78JaGpOI1RqAyH6MiK0mwRZtFLBZDLB-NhX8,759
8
- engin/_type_utils.py,sha256=naEk-lknC3Fdsd4jiP4YZAxjX3KXZN0MhFde9EV-Fmo,1835
9
- engin/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
- engin/ext/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
- engin/ext/asgi.py,sha256=6vuC4zIhsvAdmwRn2I6uuUWPYfqobox1dv7skg2OWwE,1940
12
- engin/ext/fastapi.py,sha256=vf7eB5no6eVuZyYdJZdvDYZmjJAmoKbrKhskgiSWg5g,1005
13
- engin-0.0.3.dist-info/METADATA,sha256=ANtqVNefuIRfbjAJNXNWAMwMHzc4GbLRvOT_2zpYI3Y,1491
14
- engin-0.0.3.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
15
- engin-0.0.3.dist-info/licenses/LICENSE,sha256=XHh5LPUPKZWTBqBv2xxN2RU7D59nHoiJGb5RIt8f45w,1070
16
- engin-0.0.3.dist-info/RECORD,,
File without changes