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 +2 -2
- engin/_assembler.py +104 -29
- engin/_block.py +35 -3
- engin/_dependency.py +37 -15
- engin/_engin.py +153 -14
- engin/_exceptions.py +9 -2
- engin/_lifecycle.py +96 -13
- engin/_type_utils.py +14 -1
- engin/ext/fastapi.py +5 -9
- engin-0.0.5.dist-info/METADATA +61 -0
- engin-0.0.5.dist-info/RECORD +16 -0
- engin-0.0.3.dist-info/METADATA +0 -56
- engin-0.0.3.dist-info/RECORD +0 -16
- {engin-0.0.3.dist-info → engin-0.0.5.dist-info}/WHEEL +0 -0
- {engin-0.0.3.dist-info → engin-0.0.5.dist-info}/licenses/LICENSE +0 -0
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
|
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
|
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
|
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
|
-
|
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
|
-
|
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,
|
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
|
65
|
+
return self._func(*args, **kwargs)
|
70
66
|
|
71
67
|
|
72
68
|
class Invoke(Dependency):
|
73
|
-
|
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
|
-
|
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) ->
|
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(
|
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) ->
|
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
|
-
|
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
|
-
|
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
|
-
|
37
|
-
|
38
|
-
# wait till stop signal recieved
|
39
|
-
await self._stop_event.wait()
|
101
|
+
"""
|
102
|
+
Run the engin.
|
40
103
|
|
41
|
-
|
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
|
-
|
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
|
-
|
53
|
-
|
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
|
-
|
58
|
-
|
59
|
-
|
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,
|
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
|
6
|
+
class ProviderError(Exception):
|
7
|
+
"""
|
8
|
+
Raised when a Provider errors during Assembly.
|
9
|
+
"""
|
10
|
+
|
7
11
|
def __init__(
|
8
|
-
self,
|
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
|
-
|
2
|
-
|
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
|
13
|
-
|
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
|
16
|
-
self
|
17
|
-
|
18
|
-
|
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
|
-
|
21
|
-
|
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(
|
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)]
|
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,,
|
engin-0.0.3.dist-info/METADATA
DELETED
@@ -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
|
-
|
engin-0.0.3.dist-info/RECORD
DELETED
@@ -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
|
File without changes
|