engin 0.1.0b4__tar.gz → 0.1.0rc1__tar.gz
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-0.1.0b4 → engin-0.1.0rc1}/.github/workflows/benchmark.yaml +3 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/.github/workflows/check.yaml +4 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/CHANGELOG.md +3 -4
- {engin-0.1.0b4 → engin-0.1.0rc1}/PKG-INFO +2 -1
- {engin-0.1.0b4 → engin-0.1.0rc1}/pyproject.toml +2 -2
- {engin-0.1.0b4 → engin-0.1.0rc1}/src/engin/_cli/__init__.py +2 -0
- engin-0.1.0rc1/src/engin/_cli/_check.py +71 -0
- engin-0.1.0rc1/src/engin/_cli/_common.py +121 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/src/engin/_cli/_graph.py +6 -2
- {engin-0.1.0b4 → engin-0.1.0rc1}/src/engin/_cli/_inspect.py +4 -5
- {engin-0.1.0b4 → engin-0.1.0rc1}/src/engin/_engin.py +21 -18
- {engin-0.1.0b4 → engin-0.1.0rc1}/src/engin/extensions/asgi.py +2 -0
- engin-0.1.0rc1/tests/acceptance/test_engin_signal_handling.py +28 -0
- engin-0.1.0rc1/tests/acceptance/test_error_in_invocation.py +28 -0
- engin-0.1.0b4/tests/acceptance/test_error_in_shutdown.py → engin-0.1.0rc1/tests/acceptance/test_error_in_lifecycle_shutdown.py +11 -3
- engin-0.1.0b4/tests/acceptance/test_error_in_start_up.py → engin-0.1.0rc1/tests/acceptance/test_error_in_lifecycle_startup.py +17 -1
- engin-0.1.0rc1/tests/acceptance/test_error_in_provider.py +35 -0
- engin-0.1.0b4/tests/acceptance/test_error_in_supervised_task.py → engin-0.1.0rc1/tests/acceptance/test_error_in_supervisor_task.py +2 -2
- {engin-0.1.0b4 → engin-0.1.0rc1}/tests/acceptance/test_fastapi.py +1 -0
- engin-0.1.0rc1/tests/cli/test_check.py +95 -0
- engin-0.1.0rc1/tests/cli/test_get_engin_instance.py +111 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/tests/test_engin.py +0 -16
- {engin-0.1.0b4 → engin-0.1.0rc1}/uv.lock +75 -62
- engin-0.1.0b4/src/engin/_cli/_common.py +0 -51
- engin-0.1.0b4/tests/acceptance/test_engin_signal_handling.py +0 -24
- {engin-0.1.0b4 → engin-0.1.0rc1}/.github/workflows/publish.yaml +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/.gitignore +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/.readthedocs.yaml +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/LICENSE +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/README.md +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/docs/concepts/blocks.md +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/docs/concepts/engin.md +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/docs/concepts/invocations.md +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/docs/concepts/lifecycle.md +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/docs/concepts/providers.md +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/docs/concepts/supervisor.md +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/docs/index.md +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/docs/integrations/fastapi-graph.png +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/docs/integrations/fastapi.md +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/docs/js/readthedocs.js +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/docs/overrides/main.html +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/docs/reference.md +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/docs/tutorial.md +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/examples/__init__.py +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/examples/asgi/__init__.py +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/examples/asgi/app.py +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/examples/asgi/common/__init__.py +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/examples/asgi/common/db/__init__.py +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/examples/asgi/common/db/adapaters/__init__.py +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/examples/asgi/common/db/adapaters/memory.py +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/examples/asgi/common/db/block.py +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/examples/asgi/common/db/ports.py +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/examples/asgi/common/starlette/__init__.py +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/examples/asgi/common/starlette/endpoint.py +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/examples/asgi/features/__init__.py +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/examples/asgi/features/cats/__init__.py +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/examples/asgi/features/cats/api/__init__.py +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/examples/asgi/features/cats/api/get.py +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/examples/asgi/features/cats/api/post.py +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/examples/asgi/features/cats/block.py +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/examples/asgi/features/cats/domain.py +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/examples/asgi/main.py +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/examples/fastapi/__init__.py +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/examples/fastapi/app.py +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/examples/fastapi/main.py +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/examples/fastapi/routes/__init__.py +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/examples/fastapi/routes/cats/__init__.py +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/examples/fastapi/routes/cats/adapters/__init__.py +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/examples/fastapi/routes/cats/adapters/repository.py +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/examples/fastapi/routes/cats/api.py +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/examples/fastapi/routes/cats/block.py +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/examples/fastapi/routes/cats/domain.py +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/examples/fastapi/routes/cats/ports.py +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/examples/simple/__init__.py +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/examples/simple/main.py +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/mkdocs.yaml +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/src/engin/__init__.py +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/src/engin/_assembler.py +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/src/engin/_block.py +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/src/engin/_cli/_graph.html +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/src/engin/_dependency.py +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/src/engin/_graph.py +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/src/engin/_introspect.py +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/src/engin/_lifecycle.py +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/src/engin/_option.py +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/src/engin/_supervisor.py +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/src/engin/_type_utils.py +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/src/engin/exceptions.py +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/src/engin/extensions/__init__.py +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/src/engin/extensions/fastapi.py +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/src/engin/py.typed +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/tests/__init__.py +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/tests/acceptance/__init__.py +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/tests/benchmarks/__init__.py +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/tests/benchmarks/conftest.py +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/tests/benchmarks/test_bench_assembler.py +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/tests/cli/__init__.py +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/tests/cli/test_graph.py +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/tests/cli/test_inspect.py +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/tests/conftest.py +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/tests/deps.py +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/tests/test_assembler.py +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/tests/test_block.py +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/tests/test_dependencies.py +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/tests/test_graph.py +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/tests/test_lifecycle.py +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/tests/test_supervisor.py +0 -0
- {engin-0.1.0b4 → engin-0.1.0rc1}/tests/test_type_id.py +0 -0
@@ -3,6 +3,9 @@ name: Check
|
|
3
3
|
on:
|
4
4
|
push:
|
5
5
|
|
6
|
+
env:
|
7
|
+
UV_FROZEN: "1"
|
8
|
+
|
6
9
|
jobs:
|
7
10
|
check:
|
8
11
|
name: python
|
@@ -40,6 +43,7 @@ jobs:
|
|
40
43
|
run: uv run poe ci-test
|
41
44
|
|
42
45
|
- name: Upload coverage reports to Codecov
|
46
|
+
if: matrix.os == 'ubuntu-latest'
|
43
47
|
uses: codecov/codecov-action@v5
|
44
48
|
with:
|
45
49
|
token: ${{ secrets.CODECOV_TOKEN }}
|
@@ -10,10 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
10
10
|
### Added
|
11
11
|
|
12
12
|
- Supervisor class which can safely supervise long running tasks.
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
- `ASGIEngin.run()` now raises an error to prevent incorrect usage.
|
13
|
+
- A new cli option `engin check` that validates whether you have any missing providers.
|
14
|
+
- Support for specifying `default-instance` in your `pyproject.toml` under `[tool.engin]`
|
15
|
+
which is used as a default value for the `app` parameter when using the cli.
|
17
16
|
|
18
17
|
|
19
18
|
## [0.0.20] - 2025-06-18
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: engin
|
3
|
-
Version: 0.1.
|
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
|
|
@@ -1,6 +1,6 @@
|
|
1
1
|
[project]
|
2
2
|
name = "engin"
|
3
|
-
version = "0.1.
|
3
|
+
version = "0.1.0rc1"
|
4
4
|
description = "An async-first modular application framework"
|
5
5
|
readme = "README.md"
|
6
6
|
requires-python = ">=3.10"
|
@@ -13,7 +13,7 @@ dependencies = [
|
|
13
13
|
]
|
14
14
|
|
15
15
|
[project.optional-dependencies]
|
16
|
-
cli = ["typer>=0.15"]
|
16
|
+
cli = ["typer>=0.15", "tomli>=2.0; python_version < '3.11'"]
|
17
17
|
|
18
18
|
[project.scripts]
|
19
19
|
engin = "engin._cli:app"
|
@@ -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)
|
@@ -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)
|
@@ -0,0 +1,121 @@
|
|
1
|
+
import importlib
|
2
|
+
import sys
|
3
|
+
from pathlib import Path
|
4
|
+
from typing import Never
|
5
|
+
|
6
|
+
import typer
|
7
|
+
from rich import print
|
8
|
+
from rich.panel import Panel
|
9
|
+
|
10
|
+
from engin import Engin
|
11
|
+
|
12
|
+
if sys.version_info >= (3, 11):
|
13
|
+
import tomllib
|
14
|
+
else:
|
15
|
+
import tomli as tomllib
|
16
|
+
|
17
|
+
|
18
|
+
def print_error(msg: str) -> Never:
|
19
|
+
print(
|
20
|
+
Panel(
|
21
|
+
title="Error",
|
22
|
+
renderable=msg,
|
23
|
+
title_align="left",
|
24
|
+
border_style="red",
|
25
|
+
highlight=True,
|
26
|
+
)
|
27
|
+
)
|
28
|
+
raise typer.Exit(code=1)
|
29
|
+
|
30
|
+
|
31
|
+
COMMON_HELP = {
|
32
|
+
"app": (
|
33
|
+
"The import path of your Engin instance, in the form 'package:application'"
|
34
|
+
", e.g. 'app.main:engin'. If not provided, will try to use the `default-instance`"
|
35
|
+
" value specified in your pyproject.toml"
|
36
|
+
)
|
37
|
+
}
|
38
|
+
|
39
|
+
|
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
|
+
|
103
|
+
try:
|
104
|
+
module_name, engin_name = app.split(":", maxsplit=1)
|
105
|
+
except ValueError:
|
106
|
+
print_error("Expected an argument of the form 'module:attribute', e.g. 'myapp:engin'")
|
107
|
+
|
108
|
+
try:
|
109
|
+
module = importlib.import_module(module_name)
|
110
|
+
except ModuleNotFoundError:
|
111
|
+
print_error(f"Unable to find module '{module_name}'")
|
112
|
+
|
113
|
+
try:
|
114
|
+
instance = getattr(module, engin_name)
|
115
|
+
except AttributeError:
|
116
|
+
print_error(f"Module '{module_name}' has no attribute '{engin_name}'")
|
117
|
+
|
118
|
+
if not isinstance(instance, Engin):
|
119
|
+
print_error(f"'{app}' is not an Engin instance")
|
120
|
+
|
121
|
+
return module_name, engin_name, instance
|
@@ -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
|
|
@@ -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
|
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
|
|
@@ -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,
|
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
|
-
|
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
|
-
|
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
|
-
|
181
|
-
|
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
|
-
|
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
|
-
|
240
|
-
|
241
|
-
|
242
|
-
|
243
|
-
|
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
|
-
|
253
|
-
|
254
|
-
|
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
|
|
@@ -0,0 +1,28 @@
|
|
1
|
+
import asyncio
|
2
|
+
import signal
|
3
|
+
import sys
|
4
|
+
|
5
|
+
import pytest
|
6
|
+
|
7
|
+
from engin import Engin
|
8
|
+
|
9
|
+
|
10
|
+
@pytest.mark.skipif(sys.platform == "win32", reason="`signal.raise_signal` not supported")
|
11
|
+
async def test_engin_signal_handling_when_run():
|
12
|
+
engin = Engin()
|
13
|
+
task = asyncio.create_task(engin.run())
|
14
|
+
await asyncio.sleep(0.1)
|
15
|
+
signal.raise_signal(signal.SIGTERM)
|
16
|
+
await asyncio.sleep(0.1)
|
17
|
+
assert engin.is_stopped()
|
18
|
+
del task
|
19
|
+
|
20
|
+
|
21
|
+
@pytest.mark.skipif(sys.platform == "win32", reason="`signal.raise_signal` not supported")
|
22
|
+
async def test_engin_signal_handling_when_start():
|
23
|
+
engin = Engin()
|
24
|
+
await engin.start()
|
25
|
+
await asyncio.sleep(0.1)
|
26
|
+
signal.raise_signal(signal.SIGTERM)
|
27
|
+
await asyncio.sleep(0.1)
|
28
|
+
assert engin.is_stopped()
|
@@ -0,0 +1,28 @@
|
|
1
|
+
import asyncio
|
2
|
+
|
3
|
+
import pytest
|
4
|
+
|
5
|
+
from engin import Engin, Invoke
|
6
|
+
|
7
|
+
|
8
|
+
async def test_error_in_invocation_when_run():
|
9
|
+
async def main() -> None:
|
10
|
+
raise ValueError("foo")
|
11
|
+
|
12
|
+
engin = Engin(Invoke(main))
|
13
|
+
|
14
|
+
with pytest.raises(ValueError, match="foo"):
|
15
|
+
await asyncio.wait_for(engin.run(), timeout=0.5)
|
16
|
+
|
17
|
+
|
18
|
+
async def test_error_in_invocation_when_start():
|
19
|
+
async def main() -> None:
|
20
|
+
raise ValueError("foo")
|
21
|
+
|
22
|
+
engin = Engin(Invoke(main))
|
23
|
+
|
24
|
+
with pytest.raises(ValueError, match="foo"):
|
25
|
+
await asyncio.wait_for(engin.start(), timeout=0.5)
|
26
|
+
|
27
|
+
# check we can shutdown the app
|
28
|
+
await asyncio.wait_for(engin.stop(), timeout=0.5)
|
@@ -1,3 +1,4 @@
|
|
1
|
+
import asyncio
|
1
2
|
from contextlib import asynccontextmanager
|
2
3
|
|
3
4
|
from starlette.applications import Starlette
|
@@ -29,13 +30,20 @@ def b(lifecycle: Lifecycle) -> None:
|
|
29
30
|
lifecycle.append(_b_startup())
|
30
31
|
|
31
32
|
|
32
|
-
async def
|
33
|
+
async def test_error_in_shutdown_when_start():
|
33
34
|
engin = Engin(Invoke(a), Invoke(b))
|
34
35
|
|
35
|
-
await engin.start()
|
36
|
+
await asyncio.wait_for(engin.start(), timeout=0.5)
|
36
37
|
assert B_LIFECYCLE_STATE == 1
|
37
38
|
|
38
|
-
await engin.stop()
|
39
|
+
await asyncio.wait_for(engin.stop(), timeout=0.5)
|
40
|
+
assert B_LIFECYCLE_STATE == 2
|
41
|
+
|
42
|
+
|
43
|
+
async def test_error_in_shutdown_when_run():
|
44
|
+
engin = Engin(Invoke(a), Invoke(b))
|
45
|
+
|
46
|
+
await asyncio.wait_for(engin.run(), timeout=0.5)
|
39
47
|
assert B_LIFECYCLE_STATE == 2
|
40
48
|
|
41
49
|
|
@@ -42,8 +42,11 @@ async def test_error_in_startup_handled_when_start():
|
|
42
42
|
await asyncio.wait_for(engin.start(), timeout=0.5)
|
43
43
|
assert not B_LIFECYCLE_STATE
|
44
44
|
|
45
|
+
# check we can shutdown the app
|
46
|
+
await asyncio.wait_for(engin.stop(), timeout=0.5)
|
45
47
|
|
46
|
-
|
48
|
+
|
49
|
+
async def test_error_in_startup_asgi_handled_when_run():
|
47
50
|
def asgi_type() -> ASGIType:
|
48
51
|
return Starlette()
|
49
52
|
|
@@ -51,3 +54,16 @@ async def test_error_in_startup_asgi():
|
|
51
54
|
|
52
55
|
await engin.run()
|
53
56
|
assert not B_LIFECYCLE_STATE
|
57
|
+
|
58
|
+
|
59
|
+
async def test_error_in_startup_asgi_handled_when_start():
|
60
|
+
def asgi_type() -> ASGIType:
|
61
|
+
return Starlette()
|
62
|
+
|
63
|
+
engin = ASGIEngin(Invoke(a), Invoke(b), Provide(asgi_type))
|
64
|
+
|
65
|
+
await asyncio.wait_for(engin.start(), timeout=0.5)
|
66
|
+
assert not B_LIFECYCLE_STATE
|
67
|
+
|
68
|
+
# check we can shutdown the app
|
69
|
+
await asyncio.wait_for(engin.stop(), timeout=0.5)
|
@@ -0,0 +1,35 @@
|
|
1
|
+
import asyncio
|
2
|
+
|
3
|
+
import pytest
|
4
|
+
|
5
|
+
from engin import Engin, Invoke, Provide
|
6
|
+
from engin.exceptions import ProviderError
|
7
|
+
|
8
|
+
|
9
|
+
async def test_error_in_provider_when_run():
|
10
|
+
async def raise_value_error() -> int:
|
11
|
+
raise ValueError("foo")
|
12
|
+
|
13
|
+
async def main(foo: int) -> None:
|
14
|
+
return
|
15
|
+
|
16
|
+
engin = Engin(Provide(raise_value_error), Invoke(main))
|
17
|
+
|
18
|
+
with pytest.raises(ProviderError, match="foo"):
|
19
|
+
await asyncio.wait_for(engin.run(), timeout=0.5)
|
20
|
+
|
21
|
+
|
22
|
+
async def test_error_in_provider_when_start():
|
23
|
+
async def raise_value_error() -> int:
|
24
|
+
raise ValueError("foo")
|
25
|
+
|
26
|
+
async def main(foo: int) -> None:
|
27
|
+
return
|
28
|
+
|
29
|
+
engin = Engin(Provide(raise_value_error), Invoke(main))
|
30
|
+
|
31
|
+
with pytest.raises(ProviderError, match="foo"):
|
32
|
+
await asyncio.wait_for(engin.start(), timeout=0.5)
|
33
|
+
|
34
|
+
# check we can shutdown the app
|
35
|
+
await asyncio.wait_for(engin.stop(), timeout=0.5)
|
@@ -15,7 +15,7 @@ def supervise(supervisor: Supervisor) -> None:
|
|
15
15
|
async def test_error_in_supervised_task_handled_when_run(caplog):
|
16
16
|
caplog.set_level(logging.INFO)
|
17
17
|
engin = Engin(Invoke(supervise))
|
18
|
-
await engin.run()
|
18
|
+
await asyncio.wait_for(engin.run(), timeout=0.5)
|
19
19
|
assert "Process errored" in caplog.text
|
20
20
|
assert engin.is_stopped()
|
21
21
|
|
@@ -23,7 +23,7 @@ async def test_error_in_supervised_task_handled_when_run(caplog):
|
|
23
23
|
async def test_error_in_supervised_task_handled_when_start(caplog):
|
24
24
|
caplog.set_level(logging.INFO)
|
25
25
|
engin = Engin(Invoke(supervise))
|
26
|
-
await engin.start()
|
26
|
+
await asyncio.wait_for(engin.start(), timeout=0.5)
|
27
27
|
await asyncio.sleep(0.1)
|
28
28
|
assert "Process errored" in caplog.text
|
29
29
|
assert engin.is_stopped()
|