engin 0.1.0b4__py3-none-any.whl → 0.1.0rc1__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/_cli/__init__.py CHANGED
@@ -9,6 +9,7 @@ except ImportError:
9
9
  " `cli` extra, e.g. pip install engin[cli]"
10
10
  ) from None
11
11
 
12
+ from engin._cli._check import cli as check_cli
12
13
  from engin._cli._graph import cli as graph_cli
13
14
  from engin._cli._inspect import cli as inspect_cli
14
15
 
@@ -20,5 +21,6 @@ sys.path.insert(0, "")
20
21
 
21
22
  app = typer.Typer()
22
23
 
24
+ app.add_typer(check_cli)
23
25
  app.add_typer(graph_cli)
24
26
  app.add_typer(inspect_cli)
engin/_cli/_check.py ADDED
@@ -0,0 +1,71 @@
1
+ from typing import Annotated
2
+
3
+ import typer
4
+ from rich.console import Console
5
+
6
+ from engin._cli._common import COMMON_HELP, get_engin_instance
7
+
8
+ cli = typer.Typer()
9
+
10
+
11
+ @cli.command(name="check")
12
+ def check_dependencies(
13
+ app: Annotated[
14
+ str | None,
15
+ typer.Argument(help=COMMON_HELP["app"]),
16
+ ] = None,
17
+ ) -> None:
18
+ """
19
+ Validates that all dependencies are satisfied for the given engin instance.
20
+
21
+ This command checks that all providers required by invocations and other providers
22
+ are available. It's intended for use in CI to catch missing dependencies.
23
+
24
+ Examples:
25
+
26
+ 1. `engin check`
27
+
28
+ Returns:
29
+ Exit code 0 if all dependencies are satisfied.
30
+ Exit code 1 if there are missing providers.
31
+ """
32
+ _, _, instance = get_engin_instance(app)
33
+
34
+ console = Console()
35
+ assembler = instance.assembler
36
+ missing_providers = set()
37
+
38
+ # Check dependencies for all invocations
39
+ for invocation in instance._invocations:
40
+ for param_type_id in invocation.parameter_type_ids:
41
+ try:
42
+ assembler._resolve_providers(param_type_id, set())
43
+ except LookupError:
44
+ missing_providers.add(param_type_id)
45
+
46
+ # Check dependencies for all providers
47
+ for provider in assembler.providers:
48
+ for param_type_id in provider.parameter_type_ids:
49
+ try:
50
+ assembler._resolve_providers(param_type_id, set())
51
+ except LookupError:
52
+ missing_providers.add(param_type_id)
53
+
54
+ if missing_providers:
55
+ sorted_missing = sorted(str(type_id) for type_id in missing_providers)
56
+
57
+ console.print("❌ Missing providers found:", style="red bold")
58
+ for missing_type in sorted_missing:
59
+ console.print(f" • {missing_type}", style="red")
60
+
61
+ available_providers = sorted(
62
+ str(provider.return_type_id) for provider in assembler.providers
63
+ )
64
+ console.print("\nAvailable providers:", style="yellow")
65
+ for available_type in available_providers:
66
+ console.print(f" • {available_type}", style="yellow")
67
+
68
+ raise typer.Exit(code=1)
69
+ else:
70
+ console.print("✅ All dependencies are satisfied!", style="green bold")
71
+ raise typer.Exit(code=0)
engin/_cli/_common.py CHANGED
@@ -1,4 +1,6 @@
1
1
  import importlib
2
+ import sys
3
+ from pathlib import Path
2
4
  from typing import Never
3
5
 
4
6
  import typer
@@ -7,6 +9,11 @@ from rich.panel import Panel
7
9
 
8
10
  from engin import Engin
9
11
 
12
+ if sys.version_info >= (3, 11):
13
+ import tomllib
14
+ else:
15
+ import tomli as tomllib
16
+
10
17
 
11
18
  def print_error(msg: str) -> Never:
12
19
  print(
@@ -24,12 +31,75 @@ def print_error(msg: str) -> Never:
24
31
  COMMON_HELP = {
25
32
  "app": (
26
33
  "The import path of your Engin instance, in the form 'package:application'"
27
- ", e.g. 'app.main:engin'"
34
+ ", e.g. 'app.main:engin'. If not provided, will try to use the `default-instance`"
35
+ " value specified in your pyproject.toml"
28
36
  )
29
37
  }
30
38
 
31
39
 
32
- def get_engin_instance(app: str) -> tuple[str, str, Engin]:
40
+ def _find_pyproject_toml() -> Path | None:
41
+ """Find pyproject.toml file starting from current directory and walking up."""
42
+ current_path = Path.cwd()
43
+
44
+ for path in [current_path, *current_path.parents]:
45
+ pyproject_path = path / "pyproject.toml"
46
+ if pyproject_path.exists():
47
+ return pyproject_path
48
+
49
+ return None
50
+
51
+
52
+ def _get_default_engin_from_pyproject() -> str | None:
53
+ """Get the default engin instance from pyproject.toml."""
54
+ pyproject_path = _find_pyproject_toml()
55
+ if not pyproject_path:
56
+ return None
57
+
58
+ try:
59
+ with Path(pyproject_path).open("rb") as f:
60
+ data = tomllib.load(f)
61
+
62
+ tool_section = data.get("tool", {})
63
+ engin_section = tool_section.get("engin", {})
64
+ instance = engin_section.get("default-instance")
65
+
66
+ if instance is None:
67
+ return None
68
+
69
+ if not isinstance(instance, str):
70
+ print_error("value of `default-instance` is not a string")
71
+
72
+ return instance
73
+
74
+ except (OSError, tomllib.TOMLDecodeError):
75
+ print_error("invalid toml detected")
76
+
77
+
78
+ NO_APP_FOUND_ERROR = (
79
+ "App path not specified and no default instance specified in pyproject.toml"
80
+ )
81
+
82
+
83
+ def get_engin_instance(app: str | None = None) -> tuple[str, str, Engin]:
84
+ """
85
+ Get an Engin instance either from the provided value or from pyproject.toml.
86
+
87
+ Args:
88
+ app: Optional string in format 'module:attribute'. If not provided will lookup in
89
+ pyproject.toml.
90
+
91
+ Returns:
92
+ Tuple of (module_name, engin_name, engin_instance)
93
+
94
+ Raises:
95
+ typer.Exit: If no app is provided and no default instance is specified in the user's
96
+ pyproject.toml.
97
+ """
98
+ if app is None:
99
+ app = _get_default_engin_from_pyproject()
100
+ if app is None:
101
+ print_error(NO_APP_FOUND_ERROR)
102
+
33
103
  try:
34
104
  module_name, engin_name = app.split(":", maxsplit=1)
35
105
  except ValueError:
engin/_cli/_graph.py CHANGED
@@ -28,12 +28,16 @@ _APP_ORIGIN = ""
28
28
  @cli.command(name="graph")
29
29
  def serve_graph(
30
30
  app: Annotated[
31
- str,
31
+ str | None,
32
32
  typer.Argument(help=COMMON_HELP["app"]),
33
- ],
33
+ ] = None,
34
34
  ) -> None:
35
35
  """
36
36
  Creates a visualisation of your application's dependencies.
37
+
38
+ Examples:
39
+
40
+ 1. `engin graph`
37
41
  """
38
42
  module_name, _, instance = get_engin_instance(app)
39
43
 
engin/_cli/_inspect.py CHANGED
@@ -19,9 +19,9 @@ _CLI_HELP = {
19
19
  @cli.command(name="inspect")
20
20
  def serve_graph(
21
21
  app: Annotated[
22
- str,
22
+ str | None,
23
23
  typer.Argument(help=COMMON_HELP["app"]),
24
- ],
24
+ ] = None,
25
25
  type_: Annotated[
26
26
  str | None,
27
27
  typer.Option("--type", help=_CLI_HELP["type"]),
@@ -39,9 +39,8 @@ def serve_graph(
39
39
 
40
40
  Examples:
41
41
 
42
- 1. `engin inspect examples.simple.main:engin --module httpx`
43
-
44
- 2. `engin inspect examples.simple.main:engin --type AsyncClient`
42
+ 1. `engin inspect --module httpx`
43
+ 2. `engin inspect --type AsyncClient`
45
44
  """
46
45
  module_name, _, instance = get_engin_instance(app)
47
46
 
engin/_engin.py CHANGED
@@ -10,7 +10,7 @@ from itertools import chain
10
10
  from types import FrameType
11
11
  from typing import ClassVar
12
12
 
13
- from anyio import create_task_group, get_cancelled_exc_class
13
+ from anyio import create_task_group, open_signal_receiver
14
14
 
15
15
  from engin._assembler import AssembledDependency, Assembler
16
16
  from engin._dependency import Invoke, Provide, Supply
@@ -90,6 +90,7 @@ class Engin:
90
90
  """
91
91
 
92
92
  _LIB_OPTIONS: ClassVar[list[Option]] = [Provide(Lifecycle), Provide(Supervisor)]
93
+ _STOP_ON_SINGAL: ClassVar[bool] = True
93
94
 
94
95
  def __init__(self, *options: Option) -> None:
95
96
  """
@@ -149,7 +150,7 @@ class Engin:
149
150
  except Exception as err:
150
151
  name = invocation.dependency.name
151
152
  LOG.error(f"invocation '{name}' errored, exiting", exc_info=err)
152
- return
153
+ raise
153
154
 
154
155
  lifecycle = await self._assembler.build(Lifecycle)
155
156
 
@@ -172,15 +173,17 @@ class Engin:
172
173
  self._start_complete_event.set()
173
174
 
174
175
  async with create_task_group() as tg:
175
- tg.start_soon(_stop_engin_on_signal, self._stop_requested_event)
176
+ if self._STOP_ON_SINGAL:
177
+ tg.start_soon(_stop_engin_on_signal, self._stop_requested_event)
176
178
 
177
179
  try:
178
180
  async with supervisor:
179
181
  await self._stop_requested_event.wait()
180
- except get_cancelled_exc_class():
181
- pass
182
+ await self._shutdown()
183
+ except BaseException:
184
+ await self._shutdown()
185
+
182
186
  tg.cancel_scope.cancel()
183
- await self._shutdown()
184
187
 
185
188
  async def start(self) -> None:
186
189
  """
@@ -217,7 +220,8 @@ class Engin:
217
220
  started.
218
221
  """
219
222
  self._stop_requested_event.set()
220
- await self._stop_complete_event.wait()
223
+ if self._state == _EnginState.RUNNING:
224
+ await self._stop_complete_event.wait()
221
225
 
222
226
  def graph(self) -> list[Node]:
223
227
  """
@@ -236,24 +240,23 @@ class Engin:
236
240
  return self._state == _EnginState.SHUTDOWN
237
241
 
238
242
  async def _shutdown(self) -> None:
239
- LOG.info("stopping engin")
240
- await self._exit_stack.aclose()
241
- self._stop_complete_event.set()
242
- LOG.info("shutdown complete")
243
- self._state = _EnginState.SHUTDOWN
243
+ if self._state == _EnginState.RUNNING:
244
+ LOG.info("stopping engin")
245
+ await self._exit_stack.aclose()
246
+ self._stop_complete_event.set()
247
+ LOG.info("shutdown complete")
248
+ self._state = _EnginState.SHUTDOWN
244
249
 
245
250
 
246
251
  async def _stop_engin_on_signal(stop_requested_event: Event) -> None:
247
252
  """
248
253
  A task that waits for a stop signal (SIGINT/SIGTERM) and notifies the given event.
249
254
  """
250
- # try to gracefully handle sigint/sigterm
251
255
  if not _OS_IS_WINDOWS:
252
- loop = asyncio.get_running_loop()
253
- for signame in (signal.SIGINT, signal.SIGTERM):
254
- loop.add_signal_handler(signame, stop_requested_event.set)
255
-
256
- await stop_requested_event.wait()
256
+ with open_signal_receiver(signal.SIGINT, signal.SIGTERM) as recieved_signals:
257
+ async for signum in recieved_signals:
258
+ LOG.debug(f"received {signum.name} signal")
259
+ stop_requested_event.set()
257
260
  else:
258
261
  should_stop = False
259
262
 
engin/extensions/asgi.py CHANGED
@@ -21,6 +21,8 @@ class ASGIType(Protocol):
21
21
 
22
22
 
23
23
  class ASGIEngin(Engin, ASGIType):
24
+ _STOP_ON_SINGAL = False # web server implementation is responsible for this
25
+
24
26
  _asgi_type: ClassVar[type[ASGIType]] = ASGIType # type: ignore[type-abstract]
25
27
  _asgi_app: ASGIType
26
28
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: engin
3
- Version: 0.1.0b4
3
+ Version: 0.1.0rc1
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/
@@ -13,6 +13,7 @@ Requires-Python: >=3.10
13
13
  Requires-Dist: anyio>=4
14
14
  Requires-Dist: exceptiongroup>=1
15
15
  Provides-Extra: cli
16
+ Requires-Dist: tomli>=2.0; (python_version < '3.11') and extra == 'cli'
16
17
  Requires-Dist: typer>=0.15; extra == 'cli'
17
18
  Description-Content-Type: text/markdown
18
19
 
@@ -2,7 +2,7 @@ engin/__init__.py,sha256=O0vS570kZFBq7Kwy4FgeJFIhfo4aIg5mv_Z_9vAQRio,577
2
2
  engin/_assembler.py,sha256=MC14BRsgabGlq9weyv2VXylH4RE282uNTyNH5rN8Lqc,11359
3
3
  engin/_block.py,sha256=IacP4PoJKRhSQCbQSdoyCtmu362a4vj6qoUQKyaJwzI,3062
4
4
  engin/_dependency.py,sha256=xINk3sudxzsTmkUkNAKQwzBc0G0DfhpnrZli4z3ALBY,9459
5
- engin/_engin.py,sha256=GASnv9x0Qrrok_4zGbaFm7wWZqYNAZ55w0vU3Z-gn8g,9343
5
+ engin/_engin.py,sha256=Eui-CtEjlF-PaXzZXZjUdB6ByTHJJan4hFMhPDLLJuE,9537
6
6
  engin/_graph.py,sha256=y1g7Lm_Zy5GPEgRsggCKV5DDaDzcwUl8v3IZCK8jyGI,1631
7
7
  engin/_introspect.py,sha256=VdREX6Lhhga5SnEP9G7mjHkgJR4mpqk_SMnmL2zTcqY,966
8
8
  engin/_lifecycle.py,sha256=cSWe3euZkmpxmUPFvph2lsTtvuZbxttEfBL-RnOI7lo,5325
@@ -11,16 +11,17 @@ engin/_supervisor.py,sha256=37h036bPe7ew88WjpIOmhZwCvOdLVcvalyJgbWZr1vU,3716
11
11
  engin/_type_utils.py,sha256=H3Tl-kJr2wY2RhaTXP9GrMqa2RsXMijHbjHKe1AxGmc,2276
12
12
  engin/exceptions.py,sha256=-VPwPReZb9YEIkrWMR9TW2K5HEwmHHgEO7QWH6wfV8c,1946
13
13
  engin/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
- engin/_cli/__init__.py,sha256=koD5WTkZXb8QQIiVU5bJiSR1wwPGb5rv2iwd-v-BA7A,564
15
- engin/_cli/_common.py,sha256=zMYb1Bs1yUuR3qf3r6WuVozYzDwHJvTVthVbTQfTF9w,1261
14
+ engin/_cli/__init__.py,sha256=Ixk3NoZeIN8Bj53I625uqJdLyyT9Gpbe_4GtNy-KQwM,636
15
+ engin/_cli/_check.py,sha256=FCGFKs5kD3ImicNDz2B4aOEOliwqfoa7uAjKoZRQpHo,2274
16
+ engin/_cli/_common.py,sha256=6tyjxAkROCViw0LOFdx-X1U-iSXKyeW5CoE9UxWRybI,3282
16
17
  engin/_cli/_graph.html,sha256=rR5dnDKoz7KtSff0ERCi2UKuoH_Z03MRYiXI_W03G5k,2430
17
- engin/_cli/_graph.py,sha256=HMC91nWvTOr6_czPBNx1RU55Ib3qesJRCmbnL2DsdDk,4659
18
- engin/_cli/_inspect.py,sha256=0jm25d4wcbXVNJkyaeECSKY-irsxd-EIYBH1GDW_Yjc,3163
18
+ engin/_cli/_graph.py,sha256=jvk_CPe47z2nF4yTo9T4BTAyFdy2nIsjjcZTrG2kRf0,4714
19
+ engin/_cli/_inspect.py,sha256=0ok7yglRHF29S31K9HChnWpsuxp13cB8g1Ret1hwJrM,3122
19
20
  engin/extensions/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
20
- engin/extensions/asgi.py,sha256=d5Z6gtMVWDZdAlvrTaMt987sKyiq__A0X4gJQ7IETmA,3247
21
+ engin/extensions/asgi.py,sha256=7vQFaVs1jxq1KbhHGN8k7x2UFab6SPUq2_hXfX6HiXU,3329
21
22
  engin/extensions/fastapi.py,sha256=7N6i-eZUEZRPo7kcvjS7kbRSY5QAPyKJXSeongSQ-OA,6371
22
- engin-0.1.0b4.dist-info/METADATA,sha256=GrfuoOkM35Dcjdjzf22LlZ_EgHhn7KtzMGEOybLhoxo,3201
23
- engin-0.1.0b4.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
24
- engin-0.1.0b4.dist-info/entry_points.txt,sha256=sW247zZUMxm0b5UKYvPuqQQljYDtU-j2zK3cu7gHwM0,41
25
- engin-0.1.0b4.dist-info/licenses/LICENSE,sha256=XHh5LPUPKZWTBqBv2xxN2RU7D59nHoiJGb5RIt8f45w,1070
26
- engin-0.1.0b4.dist-info/RECORD,,
23
+ engin-0.1.0rc1.dist-info/METADATA,sha256=oKmRR7rU91tH7IQIZqi9gMUrdYIBi1ghO_O4R8Gz8qQ,3274
24
+ engin-0.1.0rc1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
25
+ engin-0.1.0rc1.dist-info/entry_points.txt,sha256=sW247zZUMxm0b5UKYvPuqQQljYDtU-j2zK3cu7gHwM0,41
26
+ engin-0.1.0rc1.dist-info/licenses/LICENSE,sha256=XHh5LPUPKZWTBqBv2xxN2RU7D59nHoiJGb5RIt8f45w,1070
27
+ engin-0.1.0rc1.dist-info/RECORD,,