engin 0.0.12__py3-none-any.whl → 0.0.14__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
@@ -2,9 +2,11 @@ from engin import ext
2
2
  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
- from engin._engin import Engin, Option
5
+ from engin._engin import Engin
6
6
  from engin._exceptions import ProviderError
7
7
  from engin._lifecycle import Lifecycle
8
+ from engin._option import Option
9
+ from engin._type_utils import TypeId
8
10
 
9
11
  __all__ = [
10
12
  "Assembler",
@@ -17,6 +19,7 @@ __all__ = [
17
19
  "Provide",
18
20
  "ProviderError",
19
21
  "Supply",
22
+ "TypeId",
20
23
  "ext",
21
24
  "invoke",
22
25
  "provide",
engin/_assembler.py CHANGED
@@ -8,7 +8,7 @@ from typing import Any, Generic, TypeVar, cast
8
8
 
9
9
  from engin._dependency import Dependency, Provide, Supply
10
10
  from engin._exceptions import ProviderError
11
- from engin._type_utils import TypeId, type_id_of
11
+ from engin._type_utils import TypeId
12
12
 
13
13
  LOG = logging.getLogger("engin")
14
14
 
@@ -54,8 +54,7 @@ class Assembler:
54
54
  def __init__(self, providers: Iterable[Provide]) -> None:
55
55
  self._providers: dict[TypeId, Provide[Any]] = {}
56
56
  self._multiproviders: dict[TypeId, list[Provide[list[Any]]]] = defaultdict(list)
57
- self._dependencies: dict[TypeId, Any] = {}
58
- self._consumed_providers: set[Provide[Any]] = set()
57
+ self._assembled_outputs: dict[TypeId, Any] = {}
59
58
  self._lock = asyncio.Lock()
60
59
 
61
60
  for provider in providers:
@@ -106,13 +105,14 @@ class Assembler:
106
105
  Returns:
107
106
  The constructed value.
108
107
  """
109
- type_id = type_id_of(type_)
110
- if type_id in self._dependencies:
111
- return cast(T, self._dependencies[type_id])
108
+ type_id = TypeId.from_type(type_)
109
+ if type_id in self._assembled_outputs:
110
+ return cast("T", self._assembled_outputs[type_id])
112
111
  if type_id.multi:
113
- out = []
114
112
  if type_id not in self._multiproviders:
115
113
  raise LookupError(f"no provider found for target type id '{type_id}'")
114
+
115
+ out = []
116
116
  for provider in self._multiproviders[type_id]:
117
117
  assembled_dependency = await self.assemble(provider)
118
118
  try:
@@ -123,11 +123,12 @@ class Assembler:
123
123
  error_type=type(err),
124
124
  error_message=str(err),
125
125
  ) from err
126
- self._dependencies[type_id] = out
126
+ self._assembled_outputs[type_id] = out
127
127
  return out # type: ignore[return-value]
128
128
  else:
129
129
  if type_id not in self._providers:
130
130
  raise LookupError(f"no provider found for target type id '{type_id}'")
131
+
131
132
  assembled_dependency = await self.assemble(self._providers[type_id])
132
133
  try:
133
134
  value = await assembled_dependency()
@@ -137,7 +138,7 @@ class Assembler:
137
138
  error_type=type(err),
138
139
  error_message=str(err),
139
140
  ) from err
140
- self._dependencies[type_id] = value
141
+ self._assembled_outputs[type_id] = value
141
142
  return value # type: ignore[return-value]
142
143
 
143
144
  def has(self, type_: type[T]) -> bool:
@@ -150,7 +151,7 @@ class Assembler:
150
151
  Returns:
151
152
  True if the Assembler has a provider for type else False.
152
153
  """
153
- type_id = type_id_of(type_)
154
+ type_id = TypeId.from_type(type_)
154
155
  if type_id.multi:
155
156
  return type_id in self._multiproviders
156
157
  else:
@@ -160,24 +161,23 @@ class Assembler:
160
161
  """
161
162
  Add a provider to the Assembler post-initialisation.
162
163
 
164
+ If this replaces an existing provider, this will clear any previously assembled
165
+ output for the existing Provider.
166
+
163
167
  Args:
164
168
  provider: the Provide instance to add.
165
169
 
166
170
  Returns:
167
171
  None
168
-
169
- Raises:
170
- ValueError: if a provider for this type already exists.
171
172
  """
172
173
  type_id = provider.return_type_id
173
174
  if provider.is_multiprovider:
174
- if type_id in self._multiproviders:
175
- self._multiproviders[type_id].append(provider)
176
- else:
177
- self._multiproviders[type_id] = [provider]
175
+ if type_id in self._assembled_outputs:
176
+ del self._assembled_outputs[type_id]
177
+ self._multiproviders[type_id].append(provider)
178
178
  else:
179
- if type_id in self._providers:
180
- raise ValueError(f"A provider for '{type_id}' already exists")
179
+ if type_id in self._assembled_outputs:
180
+ del self._assembled_outputs[type_id]
181
181
  self._providers[type_id] = provider
182
182
 
183
183
  def _resolve_providers(self, type_id: TypeId) -> Collection[Provide]:
@@ -192,7 +192,9 @@ class Assembler:
192
192
  # store default to prevent the warning appearing multiple times
193
193
  self._multiproviders[type_id] = providers
194
194
  else:
195
- raise LookupError(f"No Provider registered for dependency '{type_id}'")
195
+ available = sorted(str(k) for k in self._providers)
196
+ msg = f"Missing Provider for type '{type_id}', available: {available}"
197
+ raise LookupError(msg)
196
198
 
197
199
  required_providers: list[Provide[Any]] = []
198
200
  for provider in providers:
@@ -206,9 +208,11 @@ class Assembler:
206
208
 
207
209
  async def _satisfy(self, target: TypeId) -> None:
208
210
  for provider in self._resolve_providers(target):
209
- if provider in self._consumed_providers:
211
+ if (
212
+ not provider.is_multiprovider
213
+ and provider.return_type_id in self._assembled_outputs
214
+ ):
210
215
  continue
211
- self._consumed_providers.add(provider)
212
216
  type_id = provider.return_type_id
213
217
  bound_args = await self._bind_arguments(provider.signature)
214
218
  try:
@@ -218,12 +222,12 @@ class Assembler:
218
222
  provider=provider, error_type=type(err), error_message=str(err)
219
223
  ) from err
220
224
  if provider.is_multiprovider:
221
- if type_id in self._dependencies:
222
- self._dependencies[type_id].extend(value)
225
+ if type_id in self._assembled_outputs:
226
+ self._assembled_outputs[type_id].extend(value)
223
227
  else:
224
- self._dependencies[type_id] = value
228
+ self._assembled_outputs[type_id] = value
225
229
  else:
226
- self._dependencies[type_id] = value
230
+ self._assembled_outputs[type_id] = value
227
231
 
228
232
  async def _bind_arguments(self, signature: Signature) -> BoundArguments:
229
233
  args = []
@@ -232,11 +236,11 @@ class Assembler:
232
236
  if param_name == "self":
233
237
  args.append(object())
234
238
  continue
235
- param_key = type_id_of(param.annotation)
236
- has_dependency = param_key in self._dependencies
239
+ param_key = TypeId.from_type(param.annotation)
240
+ has_dependency = param_key in self._assembled_outputs
237
241
  if not has_dependency:
238
242
  await self._satisfy(param_key)
239
- val = self._dependencies[param_key]
243
+ val = self._assembled_outputs[param_key]
240
244
  if param.kind == param.POSITIONAL_ONLY:
241
245
  args.append(val)
242
246
  else:
engin/_block.py CHANGED
@@ -1,8 +1,13 @@
1
1
  import inspect
2
- from collections.abc import Iterable, Iterator
3
- from typing import ClassVar
2
+ from collections.abc import Iterable, Sequence
3
+ from itertools import chain
4
+ from typing import TYPE_CHECKING, ClassVar
4
5
 
5
- from engin._dependency import Func, Invoke, Provide
6
+ from engin._dependency import Dependency, Func, Invoke, Provide
7
+ from engin._option import Option
8
+
9
+ if TYPE_CHECKING:
10
+ from engin._engin import Engin
6
11
 
7
12
 
8
13
  def provide(func: Func) -> Func:
@@ -21,7 +26,7 @@ def invoke(func: Func) -> Func:
21
26
  return func
22
27
 
23
28
 
24
- class Block(Iterable[Provide | Invoke]):
29
+ class Block(Option):
25
30
  """
26
31
  A Block is a collection of providers and invocations.
27
32
 
@@ -48,23 +53,21 @@ class Block(Iterable[Provide | Invoke]):
48
53
  ```
49
54
  """
50
55
 
51
- options: ClassVar[list[Provide | Invoke]] = []
52
-
53
- def __init__(self, /, block_name: str | None = None) -> None:
54
- self._options: list[Provide | Invoke] = self.options[:]
55
- self._name = block_name or f"{type(self).__name__}"
56
- for _, method in inspect.getmembers(self):
57
- if opt := getattr(method, "_opt", None):
58
- if not isinstance(opt, Provide | Invoke):
56
+ name: ClassVar[str | None] = None
57
+ options: ClassVar[Sequence[Option]] = []
58
+
59
+ @classmethod
60
+ def apply(cls, engin: "Engin") -> None:
61
+ block_name = cls.name or f"{cls.__name__}"
62
+ for option in chain(cls.options, cls._method_options()):
63
+ if isinstance(option, Dependency):
64
+ option._block_name = block_name
65
+ option.apply(engin)
66
+
67
+ @classmethod
68
+ def _method_options(cls) -> Iterable[Provide | Invoke]:
69
+ for _, method in inspect.getmembers(cls):
70
+ if option := getattr(method, "_opt", None):
71
+ if not isinstance(option, Provide | Invoke):
59
72
  raise RuntimeError("Block option is not an instance of Provide or Invoke")
60
- opt.set_block_name(self._name)
61
- self._options.append(opt)
62
- for opt in self.options:
63
- opt.set_block_name(self._name)
64
-
65
- @property
66
- def name(self) -> str:
67
- return self._name
68
-
69
- def __iter__(self) -> Iterator[Provide | Invoke]:
70
- return iter(self._options)
73
+ yield option
engin/_cli/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ try:
2
+ import typer
3
+ except ImportError:
4
+ raise ImportError(
5
+ "Unable to import typer, to use the engin cli please install the"
6
+ " `cli` extra, e.g. pip install engin[cli]"
7
+ ) from None
8
+
9
+ from engin._cli._graph import cli as graph_cli
10
+
11
+ app = typer.Typer()
12
+
13
+ app.add_typer(graph_cli)
@@ -1,63 +1,76 @@
1
+ import contextlib
1
2
  import importlib
2
3
  import logging
3
4
  import socketserver
4
5
  import sys
5
6
  import threading
6
- from argparse import ArgumentParser
7
7
  from http.server import BaseHTTPRequestHandler
8
8
  from time import sleep
9
- from typing import Any
9
+ from typing import Annotated, Any
10
+
11
+ import typer
12
+ from rich import print
10
13
 
11
14
  from engin import Engin, Entrypoint, Invoke
15
+ from engin._cli._utils import print_error
12
16
  from engin._dependency import Dependency, Provide, Supply
13
17
  from engin.ext.asgi import ASGIEngin
14
- from engin.ext.fastapi import APIRouteDependency
18
+
19
+ try:
20
+ from engin.ext.fastapi import APIRouteDependency
21
+ except ImportError:
22
+ APIRouteDependency = None # type: ignore[assignment,misc]
23
+
24
+ cli = typer.Typer()
25
+
15
26
 
16
27
  # mute logging from importing of files + engin's debug logging.
17
28
  logging.disable()
18
29
 
19
- args = ArgumentParser(
20
- prog="engin-graph",
21
- description="Creates a visualisation of your application's dependencies",
22
- )
23
- args.add_argument(
24
- "app",
25
- help=(
26
- "the import path of your Engin instance, in the form "
27
- "'package:application', e.g. 'app.main:engin'"
28
- ),
29
- )
30
30
 
31
31
  _APP_ORIGIN = ""
32
32
 
33
-
34
- def serve_graph() -> None:
33
+ _CLI_HELP = {
34
+ "app": (
35
+ "The import path of your Engin instance, in the form 'package:application'"
36
+ ", e.g. 'app.main:engin'"
37
+ )
38
+ }
39
+
40
+
41
+ @cli.command(name="graph")
42
+ def serve_graph(
43
+ app: Annotated[
44
+ str,
45
+ typer.Argument(help=_CLI_HELP["app"]),
46
+ ],
47
+ ) -> None:
48
+ """
49
+ Creates a visualisation of your application's dependencies.
50
+ """
35
51
  # add cwd to path to enable local package imports
36
52
  sys.path.insert(0, "")
37
53
 
38
- parsed = args.parse_args()
39
-
40
- app = parsed.app
41
-
42
54
  try:
43
55
  module_name, engin_name = app.split(":", maxsplit=1)
44
56
  except ValueError:
45
- raise ValueError(
46
- "Expected an argument of the form 'module:attribute', e.g. 'myapp:engin'"
47
- ) from None
57
+ print_error("Expected an argument of the form 'module:attribute', e.g. 'myapp:engin'")
48
58
 
49
59
  global _APP_ORIGIN
50
60
  _APP_ORIGIN = module_name.split(".", maxsplit=1)[0]
51
61
 
52
- module = importlib.import_module(module_name)
62
+ try:
63
+ module = importlib.import_module(module_name)
64
+ except ModuleNotFoundError:
65
+ print_error(f"unable to find module '{module_name}'")
53
66
 
54
67
  try:
55
68
  instance = getattr(module, engin_name)
56
- except LookupError:
57
- raise LookupError(f"Module '{module_name}' has no attribute '{engin_name}'") from None
69
+ except AttributeError:
70
+ print_error(f"module '{module_name}' has no attribute '{engin_name}'")
58
71
 
59
72
  if not isinstance(instance, Engin):
60
- raise TypeError(f"'{app}' is not an Engin instance")
73
+ print_error(f"'{app}' is not an Engin instance")
61
74
 
62
75
  nodes = instance.graph()
63
76
 
@@ -96,10 +109,14 @@ def serve_graph() -> None:
96
109
  server_thread.daemon = True # Daemonize the thread so it exits when the main script exits
97
110
  server_thread.start()
98
111
 
99
- try:
100
- sleep(10000)
101
- except KeyboardInterrupt:
102
- print("Exiting the server...")
112
+ with contextlib.suppress(KeyboardInterrupt):
113
+ wait_for_interrupt()
114
+
115
+ print("Exiting the server...")
116
+
117
+
118
+ def wait_for_interrupt() -> None:
119
+ sleep(10000)
103
120
 
104
121
 
105
122
  _BLOCK_IDX: dict[str, int] = {}
engin/_cli/_utils.py ADDED
@@ -0,0 +1,18 @@
1
+ from typing import Never
2
+
3
+ import typer
4
+ from rich import print
5
+ from rich.panel import Panel
6
+
7
+
8
+ def print_error(msg: str) -> Never:
9
+ print(
10
+ Panel(
11
+ title="Error",
12
+ renderable=msg.capitalize(),
13
+ title_align="left",
14
+ border_style="red",
15
+ highlight=True,
16
+ )
17
+ )
18
+ raise typer.Exit(code=1)
engin/_dependency.py CHANGED
@@ -3,8 +3,8 @@ import typing
3
3
  from abc import ABC
4
4
  from collections.abc import Awaitable, Callable
5
5
  from inspect import Parameter, Signature, isclass, iscoroutinefunction
6
- from types import FrameType
7
6
  from typing import (
7
+ TYPE_CHECKING,
8
8
  Any,
9
9
  Generic,
10
10
  ParamSpec,
@@ -14,7 +14,12 @@ from typing import (
14
14
  get_type_hints,
15
15
  )
16
16
 
17
- from engin._type_utils import TypeId, type_id_of
17
+ from engin._introspect import get_first_external_frame
18
+ from engin._option import Option
19
+ from engin._type_utils import TypeId
20
+
21
+ if TYPE_CHECKING:
22
+ from engin._engin import Engin
18
23
 
19
24
  P = ParamSpec("P")
20
25
  T = TypeVar("T")
@@ -24,23 +29,16 @@ Func: TypeAlias = Callable[P, T]
24
29
  def _noop(*args: Any, **kwargs: Any) -> None: ...
25
30
 
26
31
 
27
- def _walk_stack() -> FrameType:
28
- stack = inspect.stack()[1]
29
- frame = stack.frame
30
- while True:
31
- if frame.f_globals["__package__"] != "engin" or frame.f_back is None:
32
- return frame
33
- else:
34
- frame = frame.f_back
35
-
36
-
37
- class Dependency(ABC, Generic[P, T]):
32
+ class Dependency(ABC, Option, Generic[P, T]):
38
33
  def __init__(self, func: Func[P, T], block_name: str | None = None) -> None:
39
34
  self._func = func
40
35
  self._is_async = iscoroutinefunction(func)
41
36
  self._signature = inspect.signature(self._func)
42
37
  self._block_name = block_name
43
- self._source_frame = _walk_stack()
38
+
39
+ source_frame = get_first_external_frame()
40
+ self._source_package = cast("str", source_frame.frame.f_globals["__package__"])
41
+ self._source_frame = cast("str", source_frame.frame.f_globals["__name__"])
44
42
 
45
43
  @property
46
44
  def source_module(self) -> str:
@@ -50,7 +48,7 @@ class Dependency(ABC, Generic[P, T]):
50
48
  Returns:
51
49
  A string, e.g. "examples.fastapi.app"
52
50
  """
53
- return self._source_frame.f_globals["__name__"] # type: ignore[no-any-return]
51
+ return self._source_frame
54
52
 
55
53
  @property
56
54
  def source_package(self) -> str:
@@ -60,7 +58,7 @@ class Dependency(ABC, Generic[P, T]):
60
58
  Returns:
61
59
  A string, e.g. "engin"
62
60
  """
63
- return self._source_frame.f_globals["__package__"] # type: ignore[no-any-return]
61
+ return self._source_package
64
62
 
65
63
  @property
66
64
  def block_name(self) -> str | None:
@@ -84,7 +82,7 @@ class Dependency(ABC, Generic[P, T]):
84
82
  return []
85
83
  if parameters[0].name == "self":
86
84
  parameters.pop(0)
87
- return [type_id_of(param.annotation) for param in parameters]
85
+ return [TypeId.from_type(param.annotation) for param in parameters]
88
86
 
89
87
  @property
90
88
  def signature(self) -> Signature:
@@ -95,7 +93,7 @@ class Dependency(ABC, Generic[P, T]):
95
93
 
96
94
  async def __call__(self, *args: P.args, **kwargs: P.kwargs) -> T:
97
95
  if self._is_async:
98
- return await cast(Awaitable[T], self._func(*args, **kwargs))
96
+ return await cast("Awaitable[T]", self._func(*args, **kwargs))
99
97
  else:
100
98
  return self._func(*args, **kwargs)
101
99
 
@@ -122,6 +120,9 @@ class Invoke(Dependency):
122
120
  def __init__(self, invocation: Func[P, T], block_name: str | None = None) -> None:
123
121
  super().__init__(func=invocation, block_name=block_name)
124
122
 
123
+ def apply(self, engin: "Engin") -> None:
124
+ engin._invocations.append(self)
125
+
125
126
  def __str__(self) -> str:
126
127
  return f"Invoke({self.name})"
127
128
 
@@ -139,7 +140,7 @@ class Entrypoint(Invoke):
139
140
 
140
141
  @property
141
142
  def parameter_types(self) -> list[TypeId]:
142
- return [type_id_of(self._type)]
143
+ return [TypeId.from_type(self._type)]
143
144
 
144
145
  @property
145
146
  def signature(self) -> Signature:
@@ -150,7 +151,7 @@ class Entrypoint(Invoke):
150
151
  )
151
152
 
152
153
  def __str__(self) -> str:
153
- return f"Entrypoint({type_id_of(self._type)})"
154
+ return f"Entrypoint({TypeId.from_type(self._type)})"
154
155
 
155
156
 
156
157
  class Provide(Dependency[Any, T]):
@@ -190,12 +191,18 @@ class Provide(Dependency[Any, T]):
190
191
 
191
192
  @property
192
193
  def return_type_id(self) -> TypeId:
193
- return type_id_of(self.return_type)
194
+ return TypeId.from_type(self.return_type)
194
195
 
195
196
  @property
196
197
  def is_multiprovider(self) -> bool:
197
198
  return self._is_multi
198
199
 
200
+ def apply(self, engin: "Engin") -> None:
201
+ if self.is_multiprovider:
202
+ engin._multiproviders[self.return_type_id].append(self)
203
+ else:
204
+ engin._providers[self.return_type_id] = self
205
+
199
206
  def __hash__(self) -> int:
200
207
  return hash(self.return_type_id)
201
208
 
engin/_engin.py CHANGED
@@ -3,26 +3,21 @@ import logging
3
3
  import os
4
4
  import signal
5
5
  from asyncio import Event, Task
6
- from collections.abc import Iterable
6
+ from collections import defaultdict
7
7
  from contextlib import AsyncExitStack
8
8
  from itertools import chain
9
9
  from types import FrameType
10
- from typing import ClassVar, TypeAlias
10
+ from typing import ClassVar
11
11
 
12
- from engin import Entrypoint
13
12
  from engin._assembler import AssembledDependency, Assembler
14
- from engin._block import Block
15
- from engin._dependency import Dependency, Invoke, Provide, Supply
13
+ from engin._dependency import Invoke, Provide, Supply
16
14
  from engin._graph import DependencyGrapher, Node
17
15
  from engin._lifecycle import Lifecycle
16
+ from engin._option import Option
18
17
  from engin._type_utils import TypeId
19
18
 
20
- LOG = logging.getLogger("engin")
21
-
22
- Option: TypeAlias = Invoke | Provide | Supply | Block
23
- _Opt: TypeAlias = Invoke | Provide | Supply
24
-
25
19
  _OS_IS_WINDOWS = os.name == "nt"
20
+ LOG = logging.getLogger("engin")
26
21
 
27
22
 
28
23
  class Engin:
@@ -88,12 +83,16 @@ class Engin:
88
83
  self._shutdown_task: Task | None = None
89
84
  self._run_task: Task | None = None
90
85
 
91
- # TODO: refactor _destruct_options and related attributes into a dedicated class?
92
- self._providers: dict[TypeId, Provide] = {TypeId.from_type(Engin): Provide(self._self)}
93
- self._multiproviders: dict[TypeId, list[Provide]] = {}
86
+ self._providers: dict[TypeId, Provide] = {
87
+ TypeId.from_type(Engin): Supply(self, type_hint=Engin)
88
+ }
89
+ self._multiproviders: dict[TypeId, list[Provide]] = defaultdict(list)
94
90
  self._invocations: list[Invoke] = []
91
+
95
92
  # populates the above
96
- self._destruct_options(chain(self._LIB_OPTIONS, options))
93
+ for option in chain(self._LIB_OPTIONS, options):
94
+ option.apply(self)
95
+
97
96
  multi_providers = [p for multi in self._multiproviders.values() for p in multi]
98
97
  self._assembler = Assembler(chain(self._providers.values(), multi_providers))
99
98
 
@@ -111,7 +110,6 @@ class Engin:
111
110
  await self.start()
112
111
  self._run_task = asyncio.create_task(_wait_for_stop_signal(self._stop_requested_event))
113
112
  await self._stop_requested_event.wait()
114
- await self._shutdown()
115
113
 
116
114
  async def start(self) -> None:
117
115
  """
@@ -175,46 +173,6 @@ class Engin:
175
173
  await self._stop_requested_event.wait()
176
174
  await self._shutdown()
177
175
 
178
- def _destruct_options(self, options: Iterable[Option]) -> None:
179
- for opt in options:
180
- if isinstance(opt, Block):
181
- self._destruct_options(opt)
182
- if isinstance(opt, Provide | Supply):
183
- if not opt.is_multiprovider:
184
- existing = self._providers.get(opt.return_type_id)
185
- self._log_option(opt, overwrites=existing)
186
- self._providers[opt.return_type_id] = opt
187
- else:
188
- self._log_option(opt)
189
- if opt.return_type_id in self._multiproviders:
190
- self._multiproviders[opt.return_type_id].append(opt)
191
- else:
192
- self._multiproviders[opt.return_type_id] = [opt]
193
- elif isinstance(opt, Invoke):
194
- self._log_option(opt)
195
- self._invocations.append(opt)
196
-
197
- @staticmethod
198
- def _log_option(opt: Dependency, overwrites: Dependency | None = None) -> None:
199
- if overwrites is not None:
200
- extra = f"\tOVERWRITES {overwrites.name}"
201
- if overwrites.block_name:
202
- extra += f" [{overwrites.block_name}]"
203
- else:
204
- extra = ""
205
- if isinstance(opt, Supply):
206
- LOG.debug(f"SUPPLY {opt.return_type_id!s:<35}{extra}")
207
- elif isinstance(opt, Provide):
208
- LOG.debug(f"PROVIDE {opt.return_type_id!s:<35} <- {opt.name}() {extra}")
209
- elif isinstance(opt, Entrypoint):
210
- type_id = opt.parameter_types[0]
211
- LOG.debug(f"ENTRYPOINT {type_id!s:<35}")
212
- elif isinstance(opt, Invoke):
213
- LOG.debug(f"INVOKE {opt.name:<35}")
214
-
215
- def _self(self) -> "Engin":
216
- return self
217
-
218
176
 
219
177
  async def _wait_for_stop_signal(stop_requested_event: Event) -> None:
220
178
  try:
engin/_introspect.py ADDED
@@ -0,0 +1,34 @@
1
+ import inspect
2
+ from collections.abc import Iterable
3
+ from inspect import FrameInfo
4
+
5
+
6
+ def walk_stack() -> Iterable[FrameInfo]:
7
+ """
8
+ Fast alternative to `inspect.stack()`
9
+
10
+ Compared to `inspect.stack()`:
11
+ - Does not read source files to load neighboring context
12
+ - Less accurate filename determination, still correct for most cases
13
+ - Does not compute 3.11+ code positions (PEP 657)
14
+ """
15
+
16
+ frame = inspect.currentframe()
17
+
18
+ while frame := frame and frame.f_back:
19
+ yield inspect.FrameInfo(
20
+ frame,
21
+ inspect.getfile(frame),
22
+ frame.f_lineno,
23
+ frame.f_code.co_name,
24
+ None,
25
+ None,
26
+ )
27
+
28
+
29
+ def get_first_external_frame() -> FrameInfo:
30
+ for frame_info in walk_stack():
31
+ frame = frame_info.frame
32
+ if frame.f_globals["__package__"] != "engin" or frame.f_back is None:
33
+ return frame_info
34
+ raise RuntimeError("Unable to find external frame")
engin/_lifecycle.py CHANGED
@@ -1,12 +1,15 @@
1
1
  import asyncio
2
2
  import logging
3
+ from collections.abc import Awaitable, Callable
3
4
  from contextlib import AbstractAsyncContextManager, AbstractContextManager
5
+ from inspect import iscoroutinefunction
4
6
  from types import TracebackType
5
7
  from typing import TypeAlias, TypeGuard, cast
6
8
 
7
9
  LOG = logging.getLogger("engin")
8
10
 
9
11
  _AnyContextManager: TypeAlias = AbstractAsyncContextManager | AbstractContextManager
12
+ _ParameterlessCallable: TypeAlias = Callable[[], None | Awaitable[None]]
10
13
 
11
14
 
12
15
  class Lifecycle:
@@ -44,6 +47,18 @@ class Lifecycle:
44
47
 
45
48
  lifecycle.append(task)
46
49
  ```
50
+
51
+ Defining a custom lifecycle using a LifecycleHook.
52
+
53
+ ```python
54
+ def my_provider(lifecycle: Lifecycle) -> str:
55
+ connection_pool = ConnectionPool()
56
+
57
+ lifecycle.hook(
58
+ on_start=connection_pool.connect,
59
+ on_stop=connection_pool.close,
60
+ )
61
+ ```
47
62
  """
48
63
 
49
64
  def __init__(self) -> None:
@@ -59,6 +74,25 @@ class Lifecycle:
59
74
  suppressed_cm = _AExitSuppressingAsyncContextManager(cm)
60
75
  self._context_managers.append(suppressed_cm)
61
76
 
77
+ def hook(
78
+ self,
79
+ *,
80
+ on_start: _ParameterlessCallable | None = None,
81
+ on_stop: _ParameterlessCallable | None = None,
82
+ ) -> None:
83
+ """
84
+ Append a hook to the Lifecycle.
85
+
86
+ At least one of `on_start` or `on_stop` must be provided.
87
+
88
+ Args:
89
+ on_start: a callable to be executed on Lifecycle startup.
90
+ on_stop: a callable to be executed on Lifecycle shutdown.
91
+ """
92
+ if on_start is None and on_stop is None:
93
+ raise ValueError("At least one of on_start or on_stop must be provided")
94
+ self.append(LifecycleHook(on_start=on_start, on_stop=on_stop))
95
+
62
96
  def list(self) -> list[AbstractAsyncContextManager]:
63
97
  """
64
98
  List all the defined tasks.
@@ -69,6 +103,38 @@ class Lifecycle:
69
103
  return self._context_managers[:]
70
104
 
71
105
 
106
+ class LifecycleHook(AbstractAsyncContextManager):
107
+ def __init__(
108
+ self,
109
+ on_start: _ParameterlessCallable | None = None,
110
+ on_stop: _ParameterlessCallable | None = None,
111
+ ) -> None:
112
+ self._on_start = on_start
113
+ self._on_stop = on_stop
114
+
115
+ async def __aenter__(self) -> None:
116
+ if self._on_start is not None:
117
+ func = self._on_start
118
+ if iscoroutinefunction(func):
119
+ await func()
120
+ else:
121
+ await asyncio.to_thread(func)
122
+
123
+ async def __aexit__(
124
+ self,
125
+ exc_type: type[BaseException] | None,
126
+ exc_value: BaseException | None,
127
+ traceback: TracebackType | None,
128
+ /,
129
+ ) -> None:
130
+ if self._on_stop is not None:
131
+ func = self._on_stop
132
+ if iscoroutinefunction(func):
133
+ await func()
134
+ else:
135
+ await asyncio.to_thread(func)
136
+
137
+
72
138
  class _AExitSuppressingAsyncContextManager(AbstractAsyncContextManager):
73
139
  def __init__(self, cm: _AnyContextManager) -> None:
74
140
  self._cm = cm
@@ -77,7 +143,7 @@ class _AExitSuppressingAsyncContextManager(AbstractAsyncContextManager):
77
143
  if self._is_async_cm(self._cm):
78
144
  await self._cm.__aenter__()
79
145
  else:
80
- await asyncio.to_thread(cast(AbstractContextManager, self._cm).__enter__)
146
+ await asyncio.to_thread(cast("AbstractContextManager", self._cm).__enter__)
81
147
 
82
148
  async def __aexit__(
83
149
  self,
@@ -91,7 +157,7 @@ class _AExitSuppressingAsyncContextManager(AbstractAsyncContextManager):
91
157
  await self._cm.__aexit__(exc_type, exc_value, traceback)
92
158
  else:
93
159
  await asyncio.to_thread(
94
- cast(AbstractContextManager, self._cm).__exit__,
160
+ cast("AbstractContextManager", self._cm).__exit__,
95
161
  exc_type,
96
162
  exc_value,
97
163
  traceback,
engin/_option.py ADDED
@@ -0,0 +1,10 @@
1
+ from abc import abstractmethod
2
+ from typing import TYPE_CHECKING, Protocol
3
+
4
+ if TYPE_CHECKING:
5
+ from engin._engin import Engin
6
+
7
+
8
+ class Option(Protocol):
9
+ @abstractmethod
10
+ def apply(self, engin: "Engin") -> None: ...
engin/_type_utils.py CHANGED
@@ -1,11 +1,12 @@
1
1
  import typing
2
2
  from dataclasses import dataclass
3
+ from types import UnionType
3
4
  from typing import Any
4
5
 
5
- _implict_modules = ["builtins", "typing", "collections.abc"]
6
+ _implict_modules = ["builtins", "typing", "collections.abc", "types"]
6
7
 
7
8
 
8
- @dataclass(frozen=True, eq=True, slots=True)
9
+ @dataclass(frozen=True, slots=True)
9
10
  class TypeId:
10
11
  """
11
12
  Represents information about a Type in the Dependency Injection framework.
@@ -25,7 +26,7 @@ class TypeId:
25
26
  Returns:
26
27
  The corresponding TypeId for that type.
27
28
  """
28
- if is_multi_type(type_):
29
+ if _is_multi_type(type_):
29
30
  inner_obj = typing.get_args(type_)[0]
30
31
  return TypeId(type=inner_obj, multi=True)
31
32
  else:
@@ -39,11 +40,16 @@ class TypeId:
39
40
  out += "[]"
40
41
  return out
41
42
 
43
+ def __eq__(self, other: Any) -> bool:
44
+ if not isinstance(other, TypeId):
45
+ return False
46
+ return self.type == other.type and self.multi == other.multi
47
+
42
48
 
43
49
  def _args_to_str(type_: Any) -> str:
44
50
  args = typing.get_args(type_)
45
51
  if args:
46
- arg_str = f"{type_.__name__}["
52
+ arg_str = "Union[" if isinstance(type_, UnionType) else f"{type_.__name__}["
47
53
  for idx, arg in enumerate(args):
48
54
  if isinstance(arg, list):
49
55
  arg_str += "["
@@ -64,14 +70,7 @@ def _args_to_str(type_: Any) -> str:
64
70
  return arg_str
65
71
 
66
72
 
67
- def type_id_of(type_: Any) -> TypeId:
68
- """
69
- Generates a string TypeId for any type.
70
- """
71
- return TypeId.from_type(type_)
72
-
73
-
74
- def is_multi_type(type_: Any) -> bool:
73
+ def _is_multi_type(type_: Any) -> bool:
75
74
  """
76
75
  Discriminates a type to determine whether it is the return type of a multiprovider.
77
76
  """
engin/ext/asgi.py CHANGED
@@ -42,6 +42,7 @@ class ASGIEngin(Engin, ASGIType):
42
42
  except Exception as err:
43
43
  exc = "".join(traceback.format_exception(err))
44
44
  await send({"type": "lifespan.startup.failed", "message": exc})
45
+ raise
45
46
 
46
47
  elif message["type"] == "lifespan.shutdown":
47
48
  await self.stop()
@@ -49,8 +50,8 @@ class ASGIEngin(Engin, ASGIType):
49
50
  await self._asgi_app(scope, receive, send)
50
51
 
51
52
  async def _startup(self) -> None:
52
- await self.start()
53
53
  self._asgi_app = await self._assembler.get(self._asgi_type)
54
+ await self.start()
54
55
 
55
56
  def graph(self) -> list[Node]:
56
57
  grapher = DependencyGrapher({**self._providers, **self._multiproviders})
engin/ext/fastapi.py CHANGED
@@ -9,7 +9,7 @@ from fastapi.routing import APIRoute
9
9
  from engin import Assembler, Engin, Entrypoint, Invoke, Option
10
10
  from engin._dependency import Dependency, Supply, _noop
11
11
  from engin._graph import DependencyGrapher, Node
12
- from engin._type_utils import TypeId, type_id_of
12
+ from engin._type_utils import TypeId
13
13
  from engin.ext.asgi import ASGIEngin
14
14
 
15
15
  try:
@@ -33,7 +33,10 @@ def _attach_assembler(app: FastAPI, engin: Engin) -> None:
33
33
 
34
34
 
35
35
  class FastAPIEngin(ASGIEngin):
36
- _LIB_OPTIONS: ClassVar[list[Option]] = [*ASGIEngin._LIB_OPTIONS, Invoke(_attach_assembler)]
36
+ _LIB_OPTIONS: ClassVar[list[Option]] = [
37
+ *ASGIEngin._LIB_OPTIONS,
38
+ Invoke(_attach_assembler),
39
+ ]
37
40
  _asgi_type = FastAPI
38
41
 
39
42
  def graph(self) -> list[Node]:
@@ -165,7 +168,7 @@ class APIRouteDependency(Dependency):
165
168
  if parameters[0].name == "self":
166
169
  parameters.pop(0)
167
170
  return [
168
- type_id_of(typing.get_args(param.annotation)[0])
171
+ TypeId.from_type(typing.get_args(param.annotation)[0])
169
172
  for param in parameters
170
173
  if self._is_injected_param(param)
171
174
  ]
@@ -183,3 +186,6 @@ class APIRouteDependency(Dependency):
183
186
  def name(self) -> str:
184
187
  methods = ",".join(self._route.methods)
185
188
  return f"{methods} {self._route.path}"
189
+
190
+ def apply(self, engin: Engin) -> None:
191
+ raise NotImplementedError("APIRouteDependency is not a real dependency")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: engin
3
- Version: 0.0.12
3
+ Version: 0.0.14
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
+ Provides-Extra: cli
14
+ Requires-Dist: typer>=0.15; extra == 'cli'
13
15
  Description-Content-Type: text/markdown
14
16
 
15
17
  [![codecov](https://codecov.io/gh/invokermain/engin/graph/badge.svg?token=4PJOIMV6IB)](https://codecov.io/gh/invokermain/engin)
@@ -0,0 +1,23 @@
1
+ engin/__init__.py,sha256=rBTteMLAVKg4TJSaMElJUwz72BA_X7nBTREg-I-bWhA,584
2
+ engin/_assembler.py,sha256=AJHrFKSlZiSNCDOpS-0-C16ns9XFYJUXrUX-phdtjKs,9003
3
+ engin/_block.py,sha256=qOM3tSULwPEjNDkIERF0PSMe-1_Ea8Ihtv4Z8f94U0Y,2178
4
+ engin/_dependency.py,sha256=aG-pW0hvW9ERuVQjPJLr0SJ4Ju7kXXS9qUozkHk4q48,7102
5
+ engin/_engin.py,sha256=GwsR9iQGUIuIt0OeTpi2jr6XtWZfyh4PZUM4fz36axk,7186
6
+ engin/_exceptions.py,sha256=fsc4pTOIGHUh0x7oZhEXPJUTE268sIhswLoiqXaudiw,635
7
+ engin/_graph.py,sha256=1pMB0cr--uS0XJycDb1rS_X45RBpoyA6NkKqbeSuz1Q,1628
8
+ engin/_introspect.py,sha256=VdREX6Lhhga5SnEP9G7mjHkgJR4mpqk_SMnmL2zTcqY,966
9
+ engin/_lifecycle.py,sha256=cSWe3euZkmpxmUPFvph2lsTtvuZbxttEfBL-RnOI7lo,5325
10
+ engin/_option.py,sha256=nZcdrehp1QwgxMUoIpsM0PJuu1q1pbXzhcVsetbsHpc,223
11
+ engin/_type_utils.py,sha256=Pmm4m1_WdevT5KTe8tzY_BseNxPyhu_nKsLGgyNcPpo,2247
12
+ engin/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
+ engin/_cli/__init__.py,sha256=lp1KiBpcgk_dZU5V9DjgLPwmp0ja444fwLH2CYCscNc,302
14
+ engin/_cli/_graph.py,sha256=1Kj09BnKh5BTmuM4tqaGICS4KVDGNWT4oGFIrUa9xdU,6230
15
+ engin/_cli/_utils.py,sha256=AQFtLO8qjYRCTQc9A8Z1HVf7eZr8iGWogxbYzsgIkS4,360
16
+ engin/ext/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
17
+ engin/ext/asgi.py,sha256=cpplCnJSKB3yZb-AL6w70CW0SWsRmrR0S6mxfyJI-w8,3194
18
+ engin/ext/fastapi.py,sha256=Z8pA8hrfcXbVKfDIuSfL94wHzW0E5WLJoYOjEVzuNMk,6328
19
+ engin-0.0.14.dist-info/METADATA,sha256=VDeCZ_auc2cIoRHPuGf7_Cbarqf3r7_eEOjm72HVHpY,2354
20
+ engin-0.0.14.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
21
+ engin-0.0.14.dist-info/entry_points.txt,sha256=sW247zZUMxm0b5UKYvPuqQQljYDtU-j2zK3cu7gHwM0,41
22
+ engin-0.0.14.dist-info/licenses/LICENSE,sha256=XHh5LPUPKZWTBqBv2xxN2RU7D59nHoiJGb5RIt8f45w,1070
23
+ engin-0.0.14.dist-info/RECORD,,
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ engin = engin._cli:app
engin/scripts/__init__.py DELETED
File without changes
@@ -1,20 +0,0 @@
1
- engin/__init__.py,sha256=yTc8k0HDGMIrxDdEEA90qGD_dExQjVIbXCyaOFRrnMg,508
2
- engin/_assembler.py,sha256=1ODW3HenDlIQLetg0LNEPPbsI6HkFnPU_AHzkR9Zxmc,8844
3
- engin/_block.py,sha256=0QJtqyP5uTFjXsdVGr4ZONLI2LhfzUKmQGnNQWouB3o,2121
4
- engin/_dependency.py,sha256=RWOyGpMFp_5aLq5TP2wLKz0HT5f6CRoLvFyWvVif9jY,6825
5
- engin/_engin.py,sha256=MTE4MkLrK45h0Nv7p5H92Kv5URa1nX246B9Pp1JkM3A,9134
6
- engin/_exceptions.py,sha256=fsc4pTOIGHUh0x7oZhEXPJUTE268sIhswLoiqXaudiw,635
7
- engin/_graph.py,sha256=1pMB0cr--uS0XJycDb1rS_X45RBpoyA6NkKqbeSuz1Q,1628
8
- engin/_lifecycle.py,sha256=_jQnGFj4RYXsxMpcXPJQagFOwnoTVh7oSN8oUYoYuW0,3246
9
- engin/_type_utils.py,sha256=C71kX2Dr-gluGSL018K4uihX3zkTe7QNWaHhFU10ZmA,2127
10
- engin/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
- engin/ext/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
- engin/ext/asgi.py,sha256=RUxkG03VTlvI6EG19c1nEJY8FnQw6MQwolfJSFnhUFE,3168
13
- engin/ext/fastapi.py,sha256=GO3AIZNQ69MtzbWuACffx_6Pp34wC5a5Fi_fIAaQvTg,6186
14
- engin/scripts/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
- engin/scripts/graph.py,sha256=por62FkzcWx72V2Ha9sIoki-o99fe2Ifm1w-mdoHZIQ,5922
16
- engin-0.0.12.dist-info/METADATA,sha256=lNUoVBIDpm9KlzvkPQWdY29gZsgfMI-3HHoQT7slA6k,2291
17
- engin-0.0.12.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
18
- engin-0.0.12.dist-info/entry_points.txt,sha256=Dehk4j5nK6zyuQtgOSRAoLE609V6eLzEp32bjqhO62Q,64
19
- engin-0.0.12.dist-info/licenses/LICENSE,sha256=XHh5LPUPKZWTBqBv2xxN2RU7D59nHoiJGb5RIt8f45w,1070
20
- engin-0.0.12.dist-info/RECORD,,
@@ -1,2 +0,0 @@
1
- [console_scripts]
2
- engin-graph = engin.scripts.graph:serve_graph
File without changes