engin 0.0.10__tar.gz → 0.0.12__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.0.10 → engin-0.0.12}/.github/workflows/check.yaml +6 -1
- {engin-0.0.10 → engin-0.0.12}/CHANGELOG.md +26 -0
- {engin-0.0.10 → engin-0.0.12}/PKG-INFO +3 -1
- {engin-0.0.10 → engin-0.0.12}/README.md +2 -0
- {engin-0.0.10 → engin-0.0.12}/pyproject.toml +10 -3
- {engin-0.0.10 → engin-0.0.12}/src/engin/_assembler.py +38 -1
- {engin-0.0.10 → engin-0.0.12}/src/engin/_dependency.py +33 -10
- {engin-0.0.10 → engin-0.0.12}/src/engin/ext/asgi.py +9 -2
- {engin-0.0.10 → engin-0.0.12}/src/engin/ext/fastapi.py +31 -14
- {engin-0.0.10 → engin-0.0.12}/src/engin/scripts/graph.py +28 -3
- engin-0.0.12/tests/acceptance/test_fastapi.py +72 -0
- {engin-0.0.10 → engin-0.0.12}/tests/test_assembler.py +37 -0
- {engin-0.0.10 → engin-0.0.12}/tests/test_dependencies.py +36 -2
- {engin-0.0.10 → engin-0.0.12}/uv.lock +144 -120
- {engin-0.0.10 → engin-0.0.12}/.github/workflows/publish.yaml +0 -0
- {engin-0.0.10 → engin-0.0.12}/.gitignore +0 -0
- {engin-0.0.10 → engin-0.0.12}/.readthedocs.yaml +0 -0
- {engin-0.0.10 → engin-0.0.12}/LICENSE +0 -0
- {engin-0.0.10 → engin-0.0.12}/docs/concepts/engin.md +0 -0
- {engin-0.0.10 → engin-0.0.12}/docs/concepts/invocations.md +0 -0
- {engin-0.0.10 → engin-0.0.12}/docs/concepts/lifecycle.md +0 -0
- {engin-0.0.10 → engin-0.0.12}/docs/concepts/providers.md +0 -0
- {engin-0.0.10 → engin-0.0.12}/docs/getting-started.md +0 -0
- {engin-0.0.10 → engin-0.0.12}/docs/guides/dependency_injection.md +0 -0
- {engin-0.0.10 → engin-0.0.12}/docs/guides/fastapi-graph.png +0 -0
- {engin-0.0.10 → engin-0.0.12}/docs/guides/fastapi.md +0 -0
- {engin-0.0.10 → engin-0.0.12}/docs/index.md +0 -0
- {engin-0.0.10 → engin-0.0.12}/docs/js/readthedocs.js +0 -0
- {engin-0.0.10 → engin-0.0.12}/docs/overrides/main.html +0 -0
- {engin-0.0.10 → engin-0.0.12}/docs/reference.md +0 -0
- {engin-0.0.10 → engin-0.0.12}/examples/__init__.py +0 -0
- {engin-0.0.10 → engin-0.0.12}/examples/asgi/__init__.py +0 -0
- {engin-0.0.10 → engin-0.0.12}/examples/asgi/app.py +0 -0
- {engin-0.0.10 → engin-0.0.12}/examples/asgi/common/__init__.py +0 -0
- {engin-0.0.10 → engin-0.0.12}/examples/asgi/common/db/__init__.py +0 -0
- {engin-0.0.10 → engin-0.0.12}/examples/asgi/common/db/adapaters/__init__.py +0 -0
- {engin-0.0.10 → engin-0.0.12}/examples/asgi/common/db/adapaters/memory.py +0 -0
- {engin-0.0.10 → engin-0.0.12}/examples/asgi/common/db/block.py +0 -0
- {engin-0.0.10 → engin-0.0.12}/examples/asgi/common/db/ports.py +0 -0
- {engin-0.0.10 → engin-0.0.12}/examples/asgi/common/starlette/__init__.py +0 -0
- {engin-0.0.10 → engin-0.0.12}/examples/asgi/common/starlette/endpoint.py +0 -0
- {engin-0.0.10 → engin-0.0.12}/examples/asgi/features/__init__.py +0 -0
- {engin-0.0.10 → engin-0.0.12}/examples/asgi/features/cats/__init__.py +0 -0
- {engin-0.0.10 → engin-0.0.12}/examples/asgi/features/cats/api/__init__.py +0 -0
- {engin-0.0.10 → engin-0.0.12}/examples/asgi/features/cats/api/get.py +0 -0
- {engin-0.0.10 → engin-0.0.12}/examples/asgi/features/cats/api/post.py +0 -0
- {engin-0.0.10 → engin-0.0.12}/examples/asgi/features/cats/block.py +0 -0
- {engin-0.0.10 → engin-0.0.12}/examples/asgi/features/cats/domain.py +0 -0
- {engin-0.0.10 → engin-0.0.12}/examples/asgi/main.py +0 -0
- {engin-0.0.10 → engin-0.0.12}/examples/fastapi/__init__.py +0 -0
- {engin-0.0.10 → engin-0.0.12}/examples/fastapi/app.py +0 -0
- {engin-0.0.10 → engin-0.0.12}/examples/fastapi/main.py +0 -0
- {engin-0.0.10 → engin-0.0.12}/examples/fastapi/routes/__init__.py +0 -0
- {engin-0.0.10 → engin-0.0.12}/examples/fastapi/routes/cats/__init__.py +0 -0
- {engin-0.0.10 → engin-0.0.12}/examples/fastapi/routes/cats/adapters/__init__.py +0 -0
- {engin-0.0.10 → engin-0.0.12}/examples/fastapi/routes/cats/adapters/repository.py +0 -0
- {engin-0.0.10 → engin-0.0.12}/examples/fastapi/routes/cats/api.py +0 -0
- {engin-0.0.10 → engin-0.0.12}/examples/fastapi/routes/cats/block.py +0 -0
- {engin-0.0.10 → engin-0.0.12}/examples/fastapi/routes/cats/domain.py +0 -0
- {engin-0.0.10 → engin-0.0.12}/examples/fastapi/routes/cats/ports.py +0 -0
- {engin-0.0.10 → engin-0.0.12}/examples/simple/__init__.py +0 -0
- {engin-0.0.10 → engin-0.0.12}/examples/simple/main.py +0 -0
- {engin-0.0.10 → engin-0.0.12}/mkdocs.yaml +0 -0
- {engin-0.0.10 → engin-0.0.12}/src/engin/__init__.py +0 -0
- {engin-0.0.10 → engin-0.0.12}/src/engin/_block.py +0 -0
- {engin-0.0.10 → engin-0.0.12}/src/engin/_engin.py +0 -0
- {engin-0.0.10 → engin-0.0.12}/src/engin/_exceptions.py +0 -0
- {engin-0.0.10 → engin-0.0.12}/src/engin/_graph.py +0 -0
- {engin-0.0.10 → engin-0.0.12}/src/engin/_lifecycle.py +0 -0
- {engin-0.0.10 → engin-0.0.12}/src/engin/_type_utils.py +0 -0
- {engin-0.0.10 → engin-0.0.12}/src/engin/ext/__init__.py +0 -0
- {engin-0.0.10 → engin-0.0.12}/src/engin/py.typed +0 -0
- {engin-0.0.10 → engin-0.0.12}/src/engin/scripts/__init__.py +0 -0
- {engin-0.0.10 → engin-0.0.12}/tests/__init__.py +0 -0
- {engin-0.0.10 → engin-0.0.12}/tests/acceptance/__init__.py +0 -0
- {engin-0.0.10 → engin-0.0.12}/tests/acceptance/test_error_in_shutdown.py +0 -0
- {engin-0.0.10 → engin-0.0.12}/tests/acceptance/test_error_in_start_up.py +0 -0
- {engin-0.0.10 → engin-0.0.12}/tests/conftest.py +0 -0
- {engin-0.0.10 → engin-0.0.12}/tests/deps.py +0 -0
- {engin-0.0.10 → engin-0.0.12}/tests/test_engin.py +0 -0
- {engin-0.0.10 → engin-0.0.12}/tests/test_modules.py +0 -0
- {engin-0.0.10 → engin-0.0.12}/tests/test_utils.py +0 -0
@@ -6,6 +6,32 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
7
7
|
|
8
8
|
|
9
|
+
## [0.0.12] - 2025-03-03
|
10
|
+
|
11
|
+
### Added
|
12
|
+
|
13
|
+
- `Assembler` has a new method `add(provider: Provide) -> None` which allows adding a
|
14
|
+
provider to the Assembler post initialisation.
|
15
|
+
|
16
|
+
### Changed
|
17
|
+
|
18
|
+
- `Provide` now raises a `ValueError` if the factory function is circular, i.e. one of its
|
19
|
+
parameters is the same as its return type as the behaviour of this is undefined.
|
20
|
+
- The ASGI utility method `engin_to_lifespan` has been improved so that it works "out of
|
21
|
+
the box" for more use cases now.
|
22
|
+
|
23
|
+
|
24
|
+
## [0.0.11] - 2025-03-02
|
25
|
+
|
26
|
+
### Added
|
27
|
+
|
28
|
+
- Dependency types now have two new attributes `source_module` & `source_package`.
|
29
|
+
|
30
|
+
### Changed
|
31
|
+
|
32
|
+
- `engin-graph` now highlights external dependencies.
|
33
|
+
|
34
|
+
|
9
35
|
## [0.0.10] - 2025-02-27
|
10
36
|
|
11
37
|
### Added
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: engin
|
3
|
-
Version: 0.0.
|
3
|
+
Version: 0.0.12
|
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/
|
@@ -12,6 +12,8 @@ Keywords: Application Framework,Dependency Injection
|
|
12
12
|
Requires-Python: >=3.10
|
13
13
|
Description-Content-Type: text/markdown
|
14
14
|
|
15
|
+
[](https://codecov.io/gh/invokermain/engin)
|
16
|
+
|
15
17
|
# Engin 🏎️
|
16
18
|
|
17
19
|
Engin is a zero-dependency application framework for modern Python.
|
@@ -1,6 +1,6 @@
|
|
1
1
|
[project]
|
2
2
|
name = "engin"
|
3
|
-
version = "0.0.
|
3
|
+
version = "0.0.12"
|
4
4
|
description = "An async-first modular application framework"
|
5
5
|
readme = "README.md"
|
6
6
|
requires-python = ">=3.10"
|
@@ -32,6 +32,7 @@ dev-dependencies = [
|
|
32
32
|
"ruff>=0",
|
33
33
|
"starlette>=0.39.2",
|
34
34
|
"uvicorn>=0.31.1",
|
35
|
+
"pytest-cov>=6.0.0",
|
35
36
|
]
|
36
37
|
|
37
38
|
|
@@ -72,12 +73,17 @@ ignore = [
|
|
72
73
|
|
73
74
|
|
74
75
|
[tool.pytest.ini_options]
|
75
|
-
log_cli =
|
76
|
+
log_cli = false
|
76
77
|
log_cli_level = "DEBUG"
|
77
78
|
asyncio_mode = "auto"
|
78
79
|
asyncio_default_fixture_loop_scope = "session"
|
79
80
|
|
80
81
|
|
82
|
+
[tool.coverage.run]
|
83
|
+
source = ["src"]
|
84
|
+
omit = ["src/engin/scripts/**"]
|
85
|
+
|
86
|
+
|
81
87
|
[tool.mypy]
|
82
88
|
strict = true
|
83
89
|
disable_error_code = [
|
@@ -102,5 +108,6 @@ check.sequence = [
|
|
102
108
|
fix.default_item_type = "cmd"
|
103
109
|
fix.sequence = ["ruff check src tests examples --fix"]
|
104
110
|
|
105
|
-
test = "pytest
|
111
|
+
test = "pytest tests"
|
112
|
+
ci-test = "pytest --cov=engin --cov-branch --cov-report=xml tests"
|
106
113
|
docs = "mkdocs serve"
|
@@ -141,7 +141,44 @@ class Assembler:
|
|
141
141
|
return value # type: ignore[return-value]
|
142
142
|
|
143
143
|
def has(self, type_: type[T]) -> bool:
|
144
|
-
|
144
|
+
"""
|
145
|
+
Returns True if this Assembler has a provider for the given type.
|
146
|
+
|
147
|
+
Args:
|
148
|
+
type_: the type to check.
|
149
|
+
|
150
|
+
Returns:
|
151
|
+
True if the Assembler has a provider for type else False.
|
152
|
+
"""
|
153
|
+
type_id = type_id_of(type_)
|
154
|
+
if type_id.multi:
|
155
|
+
return type_id in self._multiproviders
|
156
|
+
else:
|
157
|
+
return type_id in self._providers
|
158
|
+
|
159
|
+
def add(self, provider: Provide) -> None:
|
160
|
+
"""
|
161
|
+
Add a provider to the Assembler post-initialisation.
|
162
|
+
|
163
|
+
Args:
|
164
|
+
provider: the Provide instance to add.
|
165
|
+
|
166
|
+
Returns:
|
167
|
+
None
|
168
|
+
|
169
|
+
Raises:
|
170
|
+
ValueError: if a provider for this type already exists.
|
171
|
+
"""
|
172
|
+
type_id = provider.return_type_id
|
173
|
+
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]
|
178
|
+
else:
|
179
|
+
if type_id in self._providers:
|
180
|
+
raise ValueError(f"A provider for '{type_id}' already exists")
|
181
|
+
self._providers[type_id] = provider
|
145
182
|
|
146
183
|
def _resolve_providers(self, type_id: TypeId) -> Collection[Provide]:
|
147
184
|
if type_id.multi:
|
@@ -3,6 +3,7 @@ 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
|
6
7
|
from typing import (
|
7
8
|
Any,
|
8
9
|
Generic,
|
@@ -23,22 +24,43 @@ Func: TypeAlias = Callable[P, T]
|
|
23
24
|
def _noop(*args: Any, **kwargs: Any) -> None: ...
|
24
25
|
|
25
26
|
|
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
|
+
|
26
37
|
class Dependency(ABC, Generic[P, T]):
|
27
38
|
def __init__(self, func: Func[P, T], block_name: str | None = None) -> None:
|
28
39
|
self._func = func
|
29
40
|
self._is_async = iscoroutinefunction(func)
|
30
41
|
self._signature = inspect.signature(self._func)
|
31
42
|
self._block_name = block_name
|
43
|
+
self._source_frame = _walk_stack()
|
32
44
|
|
33
45
|
@property
|
34
|
-
def
|
46
|
+
def source_module(self) -> str:
|
35
47
|
"""
|
36
48
|
The module that this Dependency originated from.
|
37
49
|
|
38
50
|
Returns:
|
39
51
|
A string, e.g. "examples.fastapi.app"
|
40
52
|
"""
|
41
|
-
return self.
|
53
|
+
return self._source_frame.f_globals["__name__"] # type: ignore[no-any-return]
|
54
|
+
|
55
|
+
@property
|
56
|
+
def source_package(self) -> str:
|
57
|
+
"""
|
58
|
+
The package that this Dependency originated from.
|
59
|
+
|
60
|
+
Returns:
|
61
|
+
A string, e.g. "engin"
|
62
|
+
"""
|
63
|
+
return self._source_frame.f_globals["__package__"] # type: ignore[no-any-return]
|
42
64
|
|
43
65
|
@property
|
44
66
|
def block_name(self) -> str | None:
|
@@ -115,10 +137,6 @@ class Entrypoint(Invoke):
|
|
115
137
|
self._type = type_
|
116
138
|
super().__init__(invocation=_noop, block_name=block_name)
|
117
139
|
|
118
|
-
@property
|
119
|
-
def origin(self) -> str:
|
120
|
-
return self._type.__module__
|
121
|
-
|
122
140
|
@property
|
123
141
|
def parameter_types(self) -> list[TypeId]:
|
124
142
|
return [type_id_of(self._type)]
|
@@ -140,6 +158,15 @@ class Provide(Dependency[Any, T]):
|
|
140
158
|
super().__init__(func=builder, block_name=block_name)
|
141
159
|
self._is_multi = typing.get_origin(self.return_type) is list
|
142
160
|
|
161
|
+
# Validate that the provider does to depend on its own output value, as this will
|
162
|
+
# cause a recursion error and is undefined behaviour wise.
|
163
|
+
if any(
|
164
|
+
self.return_type == param.annotation
|
165
|
+
for param in self.signature.parameters.values()
|
166
|
+
):
|
167
|
+
raise ValueError("A provider cannot depend on its own return type")
|
168
|
+
|
169
|
+
# Validate that multiproviders only return a list of one type.
|
143
170
|
if self._is_multi:
|
144
171
|
args = typing.get_args(self.return_type)
|
145
172
|
if len(args) != 1:
|
@@ -186,10 +213,6 @@ class Supply(Provide, Generic[T]):
|
|
186
213
|
self._get_val.__annotations__["return"] = type_hint
|
187
214
|
super().__init__(builder=self._get_val, block_name=block_name)
|
188
215
|
|
189
|
-
@property
|
190
|
-
def origin(self) -> str:
|
191
|
-
return self._value.__module__
|
192
|
-
|
193
216
|
@property
|
194
217
|
def return_type(self) -> type[T]:
|
195
218
|
if self._type_hint is not None:
|
@@ -1,9 +1,10 @@
|
|
1
|
+
import contextlib
|
1
2
|
import traceback
|
2
3
|
from collections.abc import AsyncIterator, Awaitable, Callable, MutableMapping
|
3
4
|
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
4
5
|
from typing import Any, ClassVar, Protocol, TypeAlias
|
5
6
|
|
6
|
-
from engin import Engin, Entrypoint, Option
|
7
|
+
from engin import Engin, Entrypoint, Option, Supply
|
7
8
|
|
8
9
|
__all__ = ["ASGIEngin", "ASGIType", "engin_to_lifespan"]
|
9
10
|
|
@@ -79,7 +80,13 @@ def engin_to_lifespan(engin: Engin) -> Callable[[ASGIType], AbstractAsyncContext
|
|
79
80
|
"""
|
80
81
|
|
81
82
|
@asynccontextmanager
|
82
|
-
async def engin_lifespan(
|
83
|
+
async def engin_lifespan(app: ASGIType) -> AsyncIterator[None]:
|
84
|
+
# ensure the Engin
|
85
|
+
with contextlib.suppress(ValueError):
|
86
|
+
engin.assembler.add(Supply(app))
|
87
|
+
|
88
|
+
app.state.assembler = engin.assembler # type: ignore[attr-defined]
|
89
|
+
|
83
90
|
await engin.start()
|
84
91
|
yield
|
85
92
|
await engin.stop()
|
@@ -6,8 +6,8 @@ from typing import ClassVar, TypeVar
|
|
6
6
|
|
7
7
|
from fastapi.routing import APIRoute
|
8
8
|
|
9
|
-
from engin import Engin, Entrypoint, Invoke, Option
|
10
|
-
from engin._dependency import Dependency, Supply
|
9
|
+
from engin import Assembler, Engin, Entrypoint, Invoke, Option
|
10
|
+
from engin._dependency import Dependency, Supply, _noop
|
11
11
|
from engin._graph import DependencyGrapher, Node
|
12
12
|
from engin._type_utils import TypeId, type_id_of
|
13
13
|
from engin.ext.asgi import ASGIEngin
|
@@ -24,15 +24,16 @@ except ImportError as err:
|
|
24
24
|
__all__ = ["APIRouteDependency", "FastAPIEngin", "Inject"]
|
25
25
|
|
26
26
|
|
27
|
-
def
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
27
|
+
def _attach_assembler(app: FastAPI, engin: Engin) -> None:
|
28
|
+
"""
|
29
|
+
An invocation that attaches the Engin's Assembler to the FastAPI application, enabling
|
30
|
+
the Inject marker.
|
31
|
+
"""
|
32
|
+
app.state.assembler = engin.assembler
|
32
33
|
|
33
34
|
|
34
35
|
class FastAPIEngin(ASGIEngin):
|
35
|
-
_LIB_OPTIONS: ClassVar[list[Option]] = [*ASGIEngin._LIB_OPTIONS, Invoke(
|
36
|
+
_LIB_OPTIONS: ClassVar[list[Option]] = [*ASGIEngin._LIB_OPTIONS, Invoke(_attach_assembler)]
|
36
37
|
_asgi_type = FastAPI
|
37
38
|
|
38
39
|
def graph(self) -> list[Node]:
|
@@ -40,7 +41,7 @@ class FastAPIEngin(ASGIEngin):
|
|
40
41
|
return grapher.resolve(
|
41
42
|
[
|
42
43
|
Entrypoint(self._asgi_type),
|
43
|
-
*[i for i in self._invocations if i.func_name != "
|
44
|
+
*[i for i in self._invocations if i.func_name != "_attach_assembler"],
|
44
45
|
]
|
45
46
|
)
|
46
47
|
|
@@ -50,8 +51,11 @@ T = TypeVar("T")
|
|
50
51
|
|
51
52
|
def Inject(interface: type[T]) -> Depends:
|
52
53
|
async def inner(conn: HTTPConnection) -> T:
|
53
|
-
|
54
|
-
|
54
|
+
try:
|
55
|
+
assembler: Assembler = conn.app.state.assembler
|
56
|
+
except AttributeError:
|
57
|
+
raise RuntimeError("Assembler is not attached to Application state") from None
|
58
|
+
return await assembler.get(interface)
|
55
59
|
|
56
60
|
dep = Depends(inner)
|
57
61
|
dep.__engin__ = True # type: ignore[attr-defined]
|
@@ -113,7 +117,7 @@ def _extract_routes_from_supply(supply: Supply) -> list[Dependency]:
|
|
113
117
|
inner = supply._value[0]
|
114
118
|
if isinstance(inner, APIRouter):
|
115
119
|
return [
|
116
|
-
APIRouteDependency(
|
120
|
+
APIRouteDependency(supply, route)
|
117
121
|
for route in inner.routes
|
118
122
|
if isinstance(route, APIRoute)
|
119
123
|
]
|
@@ -128,13 +132,26 @@ class APIRouteDependency(Dependency):
|
|
128
132
|
This class should never be constructed in application code.
|
129
133
|
"""
|
130
134
|
|
131
|
-
def __init__(
|
135
|
+
def __init__(
|
136
|
+
self,
|
137
|
+
wraps: Dependency,
|
138
|
+
route: APIRoute,
|
139
|
+
) -> None:
|
132
140
|
"""
|
133
141
|
Warning: this should never be constructed in application code.
|
134
142
|
"""
|
143
|
+
super().__init__(_noop, wraps.block_name)
|
144
|
+
self._wrapped = wraps
|
135
145
|
self._route = route
|
136
146
|
self._signature = inspect.signature(route.endpoint)
|
137
|
-
|
147
|
+
|
148
|
+
@property
|
149
|
+
def source_module(self) -> str:
|
150
|
+
return self._wrapped.source_module
|
151
|
+
|
152
|
+
@property
|
153
|
+
def source_package(self) -> str:
|
154
|
+
return self._wrapped.source_package
|
138
155
|
|
139
156
|
@property
|
140
157
|
def route(self) -> APIRoute:
|
@@ -28,6 +28,8 @@ args.add_argument(
|
|
28
28
|
),
|
29
29
|
)
|
30
30
|
|
31
|
+
_APP_ORIGIN = ""
|
32
|
+
|
31
33
|
|
32
34
|
def serve_graph() -> None:
|
33
35
|
# add cwd to path to enable local package imports
|
@@ -44,6 +46,9 @@ def serve_graph() -> None:
|
|
44
46
|
"Expected an argument of the form 'module:attribute', e.g. 'myapp:engin'"
|
45
47
|
) from None
|
46
48
|
|
49
|
+
global _APP_ORIGIN
|
50
|
+
_APP_ORIGIN = module_name.split(".", maxsplit=1)[0]
|
51
|
+
|
47
52
|
module = importlib.import_module(module_name)
|
48
53
|
|
49
54
|
try:
|
@@ -112,7 +117,17 @@ def _render_node(node: Dependency) -> str:
|
|
112
117
|
if n not in _BLOCK_IDX:
|
113
118
|
_BLOCK_IDX[n] = len(_SEEN_BLOCKS) % 8
|
114
119
|
_SEEN_BLOCKS.append(n)
|
115
|
-
style = f"
|
120
|
+
style = f"b{_BLOCK_IDX[n]}"
|
121
|
+
|
122
|
+
node_root_package = node.source_package.split(".", maxsplit=1)[0]
|
123
|
+
if node_root_package != _APP_ORIGIN:
|
124
|
+
if style:
|
125
|
+
style += "E"
|
126
|
+
else:
|
127
|
+
style = "external"
|
128
|
+
|
129
|
+
if style:
|
130
|
+
style = f":::{style}"
|
116
131
|
|
117
132
|
if isinstance(node, Supply):
|
118
133
|
md += f"{node.return_type_id}"
|
@@ -144,6 +159,7 @@ _GRAPH_HTML = """
|
|
144
159
|
graph LR
|
145
160
|
%%LEGEND%%
|
146
161
|
classDef b0 fill:#7fc97f;
|
162
|
+
classDef external stroke-dasharray: 5 5;
|
147
163
|
</pre>
|
148
164
|
</div>
|
149
165
|
<pre class="mermaid">
|
@@ -157,6 +173,15 @@ _GRAPH_HTML = """
|
|
157
173
|
classDef b5 fill:#f0027f;
|
158
174
|
classDef b6 fill:#bf5b17;
|
159
175
|
classDef b7 fill:#666666;
|
176
|
+
classDef b0E fill:#7fc97f,stroke-dasharray: 5 5;
|
177
|
+
classDef b1E fill:#beaed4,stroke-dasharray: 5 5;
|
178
|
+
classDef b2E fill:#fdc086,stroke-dasharray: 5 5;
|
179
|
+
classDef b3E fill:#ffff99,stroke-dasharray: 5 5;
|
180
|
+
classDef b4E fill:#386cb0,stroke-dasharray: 5 5;
|
181
|
+
classDef b5E fill:#f0027f,stroke-dasharray: 5 5;
|
182
|
+
classDef b6E fill:#bf5b17,stroke-dasharray: 5 5;
|
183
|
+
classDef b7E fill:#666666,stroke-dasharray: 5 5;
|
184
|
+
classDef external stroke-dasharray: 5 5;
|
160
185
|
</pre>
|
161
186
|
<script type="module">
|
162
187
|
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
|
@@ -169,6 +194,6 @@ _GRAPH_HTML = """
|
|
169
194
|
|
170
195
|
DEFAULT_LEGEND = (
|
171
196
|
"0[/Invoke/] ~~~ 1[/Entrypoint\\] ~~~ 2[Provide] ~~~ 3(Supply)"
|
172
|
-
' ~~~ 4["`Block Grouping`"]:::b0'
|
197
|
+
' ~~~ 4["`Block Grouping`"]:::b0 ~~~ 5[External Dependency]:::external'
|
173
198
|
)
|
174
|
-
ASGI_ENGIN_LEGEND = DEFAULT_LEGEND + " ~~~
|
199
|
+
ASGI_ENGIN_LEGEND = DEFAULT_LEGEND + " ~~~ 6[[API Route]]"
|
@@ -0,0 +1,72 @@
|
|
1
|
+
from typing import Annotated
|
2
|
+
|
3
|
+
import pytest
|
4
|
+
import starlette.testclient
|
5
|
+
from fastapi import APIRouter, FastAPI
|
6
|
+
|
7
|
+
from engin import Engin, Provide, Supply
|
8
|
+
from engin.ext.asgi import engin_to_lifespan
|
9
|
+
from engin.ext.fastapi import APIRouteDependency, FastAPIEngin, Inject
|
10
|
+
|
11
|
+
ROUTER = APIRouter(prefix="")
|
12
|
+
|
13
|
+
|
14
|
+
@ROUTER.get("/")
|
15
|
+
async def hello_world() -> str:
|
16
|
+
return "hello world"
|
17
|
+
|
18
|
+
|
19
|
+
@ROUTER.get("/inject")
|
20
|
+
async def route_with_dep(some_int: Annotated[int, Inject(int)]) -> int:
|
21
|
+
return some_int
|
22
|
+
|
23
|
+
|
24
|
+
def app_factory(routers: list[APIRouter]) -> FastAPI:
|
25
|
+
app = FastAPI()
|
26
|
+
for router in routers:
|
27
|
+
app.include_router(router)
|
28
|
+
return app
|
29
|
+
|
30
|
+
|
31
|
+
async def test_fastapi():
|
32
|
+
engin = FastAPIEngin(Provide(app_factory), Supply([ROUTER]))
|
33
|
+
|
34
|
+
with starlette.testclient.TestClient(engin) as client:
|
35
|
+
result = client.get("http://127.0.0.1:8000/")
|
36
|
+
|
37
|
+
assert result.json() == "hello world"
|
38
|
+
|
39
|
+
|
40
|
+
async def test_inject():
|
41
|
+
engin = FastAPIEngin(Provide(app_factory), Supply([ROUTER]), Supply(10))
|
42
|
+
|
43
|
+
with starlette.testclient.TestClient(engin) as client:
|
44
|
+
result = client.get("http://127.0.0.1:8000/inject")
|
45
|
+
|
46
|
+
assert result.json() == 10
|
47
|
+
|
48
|
+
|
49
|
+
async def test_graph():
|
50
|
+
engin = FastAPIEngin(Provide(app_factory), Supply([ROUTER]), Supply(10))
|
51
|
+
|
52
|
+
nodes = engin.graph()
|
53
|
+
|
54
|
+
assert len(nodes) == 5
|
55
|
+
assert len([node for node in nodes if isinstance(node.node, APIRouteDependency)]) == 2
|
56
|
+
|
57
|
+
|
58
|
+
async def test_invalid_engin():
|
59
|
+
with pytest.raises(LookupError, match="FastAPI"):
|
60
|
+
FastAPIEngin()
|
61
|
+
|
62
|
+
|
63
|
+
async def test_engin_to_lifespan():
|
64
|
+
engin = Engin(Supply(10))
|
65
|
+
|
66
|
+
app = FastAPI(lifespan=engin_to_lifespan(engin))
|
67
|
+
app.include_router(ROUTER)
|
68
|
+
|
69
|
+
with starlette.testclient.TestClient(app) as client:
|
70
|
+
result = client.get("http://127.0.0.1:8000/inject")
|
71
|
+
|
72
|
+
assert result.json() == 10
|
@@ -83,3 +83,40 @@ async def test_annotations():
|
|
83
83
|
|
84
84
|
assert await assembler.get(Annotated[str, "1"]) == "bar"
|
85
85
|
assert await assembler.get(Annotated[str, "2"]) == "foo"
|
86
|
+
|
87
|
+
|
88
|
+
async def test_assembler_has():
|
89
|
+
def make_str() -> str:
|
90
|
+
raise RuntimeError("foo")
|
91
|
+
|
92
|
+
assembler = Assembler([Provide(make_str)])
|
93
|
+
|
94
|
+
assert assembler.has(str)
|
95
|
+
assert not assembler.has(int)
|
96
|
+
assert not assembler.has(list[str])
|
97
|
+
|
98
|
+
|
99
|
+
async def test_assembler_has_multi():
|
100
|
+
def make_str() -> list[str]:
|
101
|
+
raise RuntimeError("foo")
|
102
|
+
|
103
|
+
assembler = Assembler([Provide(make_str)])
|
104
|
+
|
105
|
+
assert assembler.has(list[str])
|
106
|
+
assert not assembler.has(int)
|
107
|
+
assert not assembler.has(str)
|
108
|
+
|
109
|
+
|
110
|
+
async def test_assembler_add():
|
111
|
+
assembler = Assembler([])
|
112
|
+
assembler.add(Provide(make_int))
|
113
|
+
assembler.add(Provide(make_many_int))
|
114
|
+
|
115
|
+
assert assembler.has(int)
|
116
|
+
assert assembler.has(list[int])
|
117
|
+
|
118
|
+
with pytest.raises(ValueError, match="exists"):
|
119
|
+
assembler.add(Provide(make_int))
|
120
|
+
|
121
|
+
# can always add more multiproviders
|
122
|
+
assembler.add(Provide(make_many_int))
|
@@ -1,8 +1,10 @@
|
|
1
1
|
from typing import Annotated
|
2
2
|
|
3
|
+
import pytest
|
4
|
+
|
3
5
|
from engin import Provide
|
4
|
-
from engin._dependency import Supply
|
5
|
-
from tests.deps import make_aliased_int
|
6
|
+
from engin._dependency import Entrypoint, Supply
|
7
|
+
from tests.deps import make_aliased_int, make_int
|
6
8
|
|
7
9
|
|
8
10
|
def test_provide_discriminates_singular():
|
@@ -57,3 +59,35 @@ def test_provide_with_annotation():
|
|
57
59
|
|
58
60
|
assert provider.return_type_id.type
|
59
61
|
assert str(provider.return_type_id) == "Annotated[str, 1]"
|
62
|
+
|
63
|
+
|
64
|
+
def test_dependency_sources():
|
65
|
+
provide = Provide(make_int)
|
66
|
+
assert provide.source_module == "tests.test_dependencies"
|
67
|
+
assert provide.source_package == "tests"
|
68
|
+
|
69
|
+
supply = Supply(3)
|
70
|
+
assert supply.source_module == "tests.test_dependencies"
|
71
|
+
assert supply.source_package == "tests"
|
72
|
+
|
73
|
+
invoke = Provide(make_int)
|
74
|
+
assert invoke.source_module == "tests.test_dependencies"
|
75
|
+
assert invoke.source_package == "tests"
|
76
|
+
|
77
|
+
entrypoint = Entrypoint(3)
|
78
|
+
assert entrypoint.source_module == "tests.test_dependencies"
|
79
|
+
assert entrypoint.source_package == "tests"
|
80
|
+
|
81
|
+
|
82
|
+
def test_provider_cannot_depend_on_self():
|
83
|
+
def invalid_provider_1(a: int) -> int:
|
84
|
+
return 1
|
85
|
+
|
86
|
+
def invalid_provider_2(a: list[int]) -> list[int]:
|
87
|
+
return [1]
|
88
|
+
|
89
|
+
with pytest.raises(ValueError, match="return type"):
|
90
|
+
Provide(invalid_provider_1)
|
91
|
+
|
92
|
+
with pytest.raises(ValueError, match="return type"):
|
93
|
+
Provide(invalid_provider_2)
|