engin 0.0.18__tar.gz → 0.0.19__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.19/.github/workflows/benchmark.yaml +38 -0
- {engin-0.0.18 → engin-0.0.19}/.github/workflows/check.yaml +6 -1
- {engin-0.0.18 → engin-0.0.19}/CHANGELOG.md +19 -0
- {engin-0.0.18 → engin-0.0.19}/PKG-INFO +1 -1
- engin-0.0.19/docs/concepts/block.md +82 -0
- {engin-0.0.18 → engin-0.0.19}/docs/guides/fastapi.md +21 -15
- {engin-0.0.18 → engin-0.0.19}/examples/asgi/app.py +1 -1
- {engin-0.0.18 → engin-0.0.19}/examples/asgi/main.py +1 -1
- {engin-0.0.18 → engin-0.0.19}/examples/fastapi/main.py +1 -1
- {engin-0.0.18 → engin-0.0.19}/examples/fastapi/routes/cats/api.py +1 -1
- {engin-0.0.18 → engin-0.0.19}/pyproject.toml +28 -3
- {engin-0.0.18 → engin-0.0.19}/src/engin/__init__.py +0 -4
- {engin-0.0.18 → engin-0.0.19}/src/engin/_assembler.py +24 -17
- {engin-0.0.18 → engin-0.0.19}/src/engin/_block.py +16 -4
- {engin-0.0.18 → engin-0.0.19}/src/engin/_cli/_graph.py +2 -2
- {engin-0.0.18 → engin-0.0.19}/src/engin/_dependency.py +24 -20
- engin-0.0.18/src/engin/_exceptions.py → engin-0.0.19/src/engin/exceptions.py +27 -1
- {engin-0.0.18/src/engin/ext → engin-0.0.19/src/engin/extensions}/fastapi.py +1 -1
- {engin-0.0.18 → engin-0.0.19}/tests/acceptance/test_error_in_shutdown.py +1 -1
- {engin-0.0.18 → engin-0.0.19}/tests/acceptance/test_error_in_start_up.py +1 -1
- {engin-0.0.18 → engin-0.0.19}/tests/acceptance/test_fastapi.py +2 -2
- engin-0.0.19/tests/benchmarks/conftest.py +21 -0
- engin-0.0.19/tests/benchmarks/test_bench_assembler.py +114 -0
- {engin-0.0.18 → engin-0.0.19}/tests/cli/test_graph.py +23 -2
- {engin-0.0.18 → engin-0.0.19}/tests/cli/test_inspect.py +4 -1
- engin-0.0.19/tests/conftest.py +0 -0
- {engin-0.0.18 → engin-0.0.19}/tests/deps.py +8 -1
- {engin-0.0.18 → engin-0.0.19}/tests/test_assembler.py +28 -13
- engin-0.0.19/tests/test_block.py +54 -0
- {engin-0.0.18 → engin-0.0.19}/tests/test_dependencies.py +11 -15
- {engin-0.0.18 → engin-0.0.19}/tests/test_engin.py +2 -1
- {engin-0.0.18 → engin-0.0.19}/uv.lock +28 -4
- engin-0.0.18/tests/test_block.py +0 -25
- {engin-0.0.18 → engin-0.0.19}/.github/workflows/publish.yaml +0 -0
- {engin-0.0.18 → engin-0.0.19}/.gitignore +0 -0
- {engin-0.0.18 → engin-0.0.19}/.readthedocs.yaml +0 -0
- {engin-0.0.18 → engin-0.0.19}/LICENSE +0 -0
- {engin-0.0.18 → engin-0.0.19}/README.md +0 -0
- {engin-0.0.18 → engin-0.0.19}/docs/concepts/engin.md +0 -0
- {engin-0.0.18 → engin-0.0.19}/docs/concepts/invocations.md +0 -0
- {engin-0.0.18 → engin-0.0.19}/docs/concepts/lifecycle.md +0 -0
- {engin-0.0.18 → engin-0.0.19}/docs/concepts/providers.md +0 -0
- {engin-0.0.18 → engin-0.0.19}/docs/getting-started.md +0 -0
- {engin-0.0.18 → engin-0.0.19}/docs/guides/fastapi-graph.png +0 -0
- {engin-0.0.18 → engin-0.0.19}/docs/index.md +0 -0
- {engin-0.0.18 → engin-0.0.19}/docs/js/readthedocs.js +0 -0
- {engin-0.0.18 → engin-0.0.19}/docs/overrides/main.html +0 -0
- {engin-0.0.18 → engin-0.0.19}/docs/reference.md +0 -0
- {engin-0.0.18 → engin-0.0.19}/examples/__init__.py +0 -0
- {engin-0.0.18 → engin-0.0.19}/examples/asgi/__init__.py +0 -0
- {engin-0.0.18 → engin-0.0.19}/examples/asgi/common/__init__.py +0 -0
- {engin-0.0.18 → engin-0.0.19}/examples/asgi/common/db/__init__.py +0 -0
- {engin-0.0.18 → engin-0.0.19}/examples/asgi/common/db/adapaters/__init__.py +0 -0
- {engin-0.0.18 → engin-0.0.19}/examples/asgi/common/db/adapaters/memory.py +0 -0
- {engin-0.0.18 → engin-0.0.19}/examples/asgi/common/db/block.py +0 -0
- {engin-0.0.18 → engin-0.0.19}/examples/asgi/common/db/ports.py +0 -0
- {engin-0.0.18 → engin-0.0.19}/examples/asgi/common/starlette/__init__.py +0 -0
- {engin-0.0.18 → engin-0.0.19}/examples/asgi/common/starlette/endpoint.py +0 -0
- {engin-0.0.18 → engin-0.0.19}/examples/asgi/features/__init__.py +0 -0
- {engin-0.0.18 → engin-0.0.19}/examples/asgi/features/cats/__init__.py +0 -0
- {engin-0.0.18 → engin-0.0.19}/examples/asgi/features/cats/api/__init__.py +0 -0
- {engin-0.0.18 → engin-0.0.19}/examples/asgi/features/cats/api/get.py +0 -0
- {engin-0.0.18 → engin-0.0.19}/examples/asgi/features/cats/api/post.py +0 -0
- {engin-0.0.18 → engin-0.0.19}/examples/asgi/features/cats/block.py +0 -0
- {engin-0.0.18 → engin-0.0.19}/examples/asgi/features/cats/domain.py +0 -0
- {engin-0.0.18 → engin-0.0.19}/examples/fastapi/__init__.py +0 -0
- {engin-0.0.18 → engin-0.0.19}/examples/fastapi/app.py +0 -0
- {engin-0.0.18 → engin-0.0.19}/examples/fastapi/routes/__init__.py +0 -0
- {engin-0.0.18 → engin-0.0.19}/examples/fastapi/routes/cats/__init__.py +0 -0
- {engin-0.0.18 → engin-0.0.19}/examples/fastapi/routes/cats/adapters/__init__.py +0 -0
- {engin-0.0.18 → engin-0.0.19}/examples/fastapi/routes/cats/adapters/repository.py +0 -0
- {engin-0.0.18 → engin-0.0.19}/examples/fastapi/routes/cats/block.py +0 -0
- {engin-0.0.18 → engin-0.0.19}/examples/fastapi/routes/cats/domain.py +0 -0
- {engin-0.0.18 → engin-0.0.19}/examples/fastapi/routes/cats/ports.py +0 -0
- {engin-0.0.18 → engin-0.0.19}/examples/simple/__init__.py +0 -0
- {engin-0.0.18 → engin-0.0.19}/examples/simple/main.py +0 -0
- {engin-0.0.18 → engin-0.0.19}/mkdocs.yaml +0 -0
- {engin-0.0.18 → engin-0.0.19}/src/engin/_cli/__init__.py +0 -0
- {engin-0.0.18 → engin-0.0.19}/src/engin/_cli/_common.py +0 -0
- {engin-0.0.18 → engin-0.0.19}/src/engin/_cli/_graph.html +0 -0
- {engin-0.0.18 → engin-0.0.19}/src/engin/_cli/_inspect.py +0 -0
- {engin-0.0.18 → engin-0.0.19}/src/engin/_engin.py +0 -0
- {engin-0.0.18 → engin-0.0.19}/src/engin/_graph.py +0 -0
- {engin-0.0.18 → engin-0.0.19}/src/engin/_introspect.py +0 -0
- {engin-0.0.18 → engin-0.0.19}/src/engin/_lifecycle.py +0 -0
- {engin-0.0.18 → engin-0.0.19}/src/engin/_option.py +0 -0
- {engin-0.0.18 → engin-0.0.19}/src/engin/_type_utils.py +0 -0
- {engin-0.0.18/src/engin/ext → engin-0.0.19/src/engin/extensions}/__init__.py +0 -0
- {engin-0.0.18/src/engin/ext → engin-0.0.19/src/engin/extensions}/asgi.py +0 -0
- {engin-0.0.18 → engin-0.0.19}/src/engin/py.typed +0 -0
- {engin-0.0.18 → engin-0.0.19}/tests/__init__.py +0 -0
- {engin-0.0.18 → engin-0.0.19}/tests/acceptance/__init__.py +0 -0
- {engin-0.0.18/tests/cli → engin-0.0.19/tests/benchmarks}/__init__.py +0 -0
- /engin-0.0.18/tests/conftest.py → /engin-0.0.19/tests/cli/__init__.py +0 -0
- {engin-0.0.18 → engin-0.0.19}/tests/test_graph.py +0 -0
- {engin-0.0.18 → engin-0.0.19}/tests/test_lifecycle.py +0 -0
- {engin-0.0.18 → engin-0.0.19}/tests/test_utils.py +0 -0
@@ -0,0 +1,38 @@
|
|
1
|
+
name: Benchmark Main
|
2
|
+
|
3
|
+
on:
|
4
|
+
push:
|
5
|
+
branches: [main]
|
6
|
+
|
7
|
+
jobs:
|
8
|
+
benchmark-main:
|
9
|
+
runs-on: ubuntu-latest
|
10
|
+
|
11
|
+
steps:
|
12
|
+
- uses: actions/checkout@v4
|
13
|
+
|
14
|
+
- name: Install uv
|
15
|
+
uses: astral-sh/setup-uv@v5
|
16
|
+
with:
|
17
|
+
enable-cache: true
|
18
|
+
cache-dependency-glob: "uv.lock"
|
19
|
+
|
20
|
+
- name: Set up Python
|
21
|
+
uses: actions/setup-python@v5
|
22
|
+
with:
|
23
|
+
python-version-file: "pyproject.toml"
|
24
|
+
|
25
|
+
- name: Install the project
|
26
|
+
run: uv sync --dev
|
27
|
+
|
28
|
+
- name: Run benchmark
|
29
|
+
run: |
|
30
|
+
uv run pytest tests --benchmark-only --benchmark-json bench.json
|
31
|
+
|
32
|
+
- name: Store benchmark result
|
33
|
+
uses: benchmark-action/github-action-benchmark@v1
|
34
|
+
with:
|
35
|
+
tool: 'pytest'
|
36
|
+
output-file-path: bench.json
|
37
|
+
github-token: ${{ secrets.PAT }}
|
38
|
+
auto-push: true
|
@@ -6,6 +6,25 @@ 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.19] - 2025-04-27
|
10
|
+
|
11
|
+
### Added
|
12
|
+
|
13
|
+
- A new exception: `InvalidBlockError`.
|
14
|
+
|
15
|
+
### Changed
|
16
|
+
|
17
|
+
- Improved performance of Provide & Assembler by a factor of >2x (in certain scenarios).
|
18
|
+
- Renamed the `ext` subpackage to `extensions`.
|
19
|
+
- Errors are now imported from `engin.exceptions.*` instead of `engin.*`
|
20
|
+
- Blocks will now raise an `InvalidBlockError` if the block has methods which are not
|
21
|
+
decorated with `@provide` & `@invoke`.
|
22
|
+
|
23
|
+
### Fixed
|
24
|
+
|
25
|
+
- `Assembler.add` incorrect cache invalidation logic.
|
26
|
+
|
27
|
+
|
9
28
|
## [0.0.18] - 2025-04-25
|
10
29
|
|
11
30
|
### Added
|
@@ -0,0 +1,82 @@
|
|
1
|
+
# Blocks
|
2
|
+
|
3
|
+
A Block is a collection of options defined as a class.
|
4
|
+
|
5
|
+
Blocks are useful for grouping related options, as they can then be passed to the Engin in
|
6
|
+
one go helping manage complex applications.
|
7
|
+
|
8
|
+
Blocks are preferred over other data structures such as a list of options as they
|
9
|
+
integrate with other functionality in the rest of the framework.
|
10
|
+
|
11
|
+
|
12
|
+
## Defining an empty Block
|
13
|
+
|
14
|
+
A Block is just a class that inherits from `Block`:
|
15
|
+
|
16
|
+
```python
|
17
|
+
from engin import Engin, Block, provide, invoke
|
18
|
+
|
19
|
+
|
20
|
+
class ExampleBlock(Block):
|
21
|
+
...
|
22
|
+
```
|
23
|
+
|
24
|
+
## Adding options to the Block
|
25
|
+
|
26
|
+
Blocks have a class attribute named `options` which can be used to include existing
|
27
|
+
options.
|
28
|
+
|
29
|
+
```python
|
30
|
+
from engin import Engin, Block, Invoke, Provide, Supply
|
31
|
+
|
32
|
+
|
33
|
+
def print_string(self, string: str) -> None:
|
34
|
+
print(string)
|
35
|
+
|
36
|
+
|
37
|
+
class ExampleBlock(Block):
|
38
|
+
options = [
|
39
|
+
Supply("hello"),
|
40
|
+
Invoke(print_string)
|
41
|
+
]
|
42
|
+
|
43
|
+
|
44
|
+
# register it as a provider with the Engin
|
45
|
+
engin = Engin(ExampleBlock())
|
46
|
+
|
47
|
+
await engin.run() # prints 'hello'
|
48
|
+
```
|
49
|
+
|
50
|
+
!!!tip
|
51
|
+
|
52
|
+
Blocks are themselves valid options, so Blocks can include other Blocks as options. This
|
53
|
+
compisitional approach can help you build and manage larger applications.
|
54
|
+
|
55
|
+
|
56
|
+
## Defining Providers & Invocations in the Block
|
57
|
+
|
58
|
+
Engin ships two decorators: `@provide` & `@invoke` that can be used to define providers
|
59
|
+
and invocations within a Block as methods. These decorators mirror the signature of their
|
60
|
+
respective classes `Provide` & `Invoke`.
|
61
|
+
|
62
|
+
|
63
|
+
```python
|
64
|
+
from engin import Engin, Block, provide, invoke
|
65
|
+
|
66
|
+
|
67
|
+
# this block is equivalent to the one in the example above
|
68
|
+
class ExampleBlock(Block):
|
69
|
+
@provide
|
70
|
+
def string_factory() -> str:
|
71
|
+
return "hello"
|
72
|
+
|
73
|
+
@invoke
|
74
|
+
def print_string(self, string: str) -> None:
|
75
|
+
print(string)
|
76
|
+
```
|
77
|
+
|
78
|
+
!!!note
|
79
|
+
|
80
|
+
The `self` parameter in these methods is replaced with an empty object at runtime so
|
81
|
+
should not be used. Blocks do not need to be instantiated to be passed to Engin as an
|
82
|
+
option.
|
@@ -18,7 +18,7 @@ provide an instance of a `FastAPI` application:
|
|
18
18
|
|
19
19
|
```python
|
20
20
|
from engin import Supply
|
21
|
-
from engin.
|
21
|
+
from engin.extensions.fastapi import FastAPIEngin
|
22
22
|
from fastapi import FastAPI
|
23
23
|
import uvicorn
|
24
24
|
|
@@ -74,19 +74,23 @@ reusable SQL session per request, we could use a nested dependency:
|
|
74
74
|
```python
|
75
75
|
from typing import Annotated, AsyncIterable
|
76
76
|
|
77
|
-
from engin.
|
77
|
+
from engin.extensions.fastapi import Inject
|
78
78
|
from fastapi import Depends
|
79
79
|
|
80
|
+
|
80
81
|
async def database_session(
|
81
|
-
|
82
|
-
|
82
|
+
database: Annotated[Database, Inject(Database)])
|
83
|
+
|
84
|
+
) -> AsyncIterable[Session]:
|
83
85
|
with database.new_session() as session:
|
84
86
|
yield session
|
85
|
-
|
87
|
+
session.commit()
|
88
|
+
|
89
|
+
@ app.post("/{id}")
|
90
|
+
async
|
86
91
|
|
87
|
-
|
88
|
-
|
89
|
-
session.add(MyORMModel(...))
|
92
|
+
def add_item(session: Annotated[Session, Depends(database_session)]):
|
93
|
+
session.add(MyORMModel(...))
|
90
94
|
```
|
91
95
|
|
92
96
|
|
@@ -109,17 +113,18 @@ this is to use `Supply` as the router is already instantiated. We also need to a
|
|
109
113
|
`APIRouter` to our `FastAPI` application, we can do this in the provider for `FastAPI`.
|
110
114
|
|
111
115
|
```python title="app.py"
|
112
|
-
from engin.
|
116
|
+
from engin.extensions.fastapi import FastAPIEngin
|
113
117
|
from fastapi import FastAPI
|
114
118
|
|
115
119
|
from api import users_router
|
116
120
|
|
121
|
+
|
117
122
|
def create_fastapi_app(api_routers: list[APIRouter]) -> FastAPI:
|
118
123
|
app = FastAPI()
|
119
|
-
|
124
|
+
|
120
125
|
for api_router in api_routers:
|
121
126
|
app.include_router(api_router)
|
122
|
-
|
127
|
+
|
123
128
|
return app
|
124
129
|
|
125
130
|
|
@@ -135,21 +140,22 @@ Or similarly, we could use a block instead:
|
|
135
140
|
|
136
141
|
```python title="app.py"
|
137
142
|
from engin import Block, provide
|
138
|
-
from engin.
|
143
|
+
from engin.extensions.fastapi import FastAPIEngin
|
139
144
|
from fastapi import FastAPI
|
140
145
|
|
141
146
|
from api import users_router
|
142
147
|
|
148
|
+
|
143
149
|
class AppBlock(Block):
|
144
150
|
options = [Supply([users_router])]
|
145
|
-
|
151
|
+
|
146
152
|
@provide
|
147
153
|
def create_fastapi_app(self, api_routers: list[APIRouter]) -> FastAPI:
|
148
154
|
app = FastAPI()
|
149
|
-
|
155
|
+
|
150
156
|
for api_router in api_routers:
|
151
157
|
app.include_router(api_router)
|
152
|
-
|
158
|
+
|
153
159
|
return app
|
154
160
|
|
155
161
|
|
@@ -3,7 +3,7 @@ import logging
|
|
3
3
|
import uvicorn
|
4
4
|
|
5
5
|
from engin import Supply
|
6
|
-
from engin.
|
6
|
+
from engin.extensions.asgi import ASGIEngin
|
7
7
|
from examples.asgi.app import AppBlock, AppConfig
|
8
8
|
from examples.asgi.common.db.block import DatabaseBlock
|
9
9
|
from examples.asgi.features.cats.block import CatBlock
|
@@ -3,7 +3,7 @@ import logging
|
|
3
3
|
import uvicorn
|
4
4
|
|
5
5
|
from engin import Supply
|
6
|
-
from engin.
|
6
|
+
from engin.extensions.fastapi import FastAPIEngin
|
7
7
|
from examples.fastapi.app import AppBlock, AppConfig
|
8
8
|
from examples.fastapi.routes.cats.block import CatBlock
|
9
9
|
|
@@ -3,7 +3,7 @@ from typing import Annotated
|
|
3
3
|
from fastapi import APIRouter
|
4
4
|
from pydantic import BaseModel
|
5
5
|
|
6
|
-
from engin.
|
6
|
+
from engin.extensions.fastapi import Inject
|
7
7
|
from examples.fastapi.routes.cats.domain import Cat, CatPersonality
|
8
8
|
from examples.fastapi.routes.cats.ports import CatRepository
|
9
9
|
|
@@ -1,6 +1,6 @@
|
|
1
1
|
[project]
|
2
2
|
name = "engin"
|
3
|
-
version = "0.0.
|
3
|
+
version = "0.0.19"
|
4
4
|
description = "An async-first modular application framework"
|
5
5
|
readme = "README.md"
|
6
6
|
requires-python = ">=3.10"
|
@@ -37,6 +37,7 @@ dev = [
|
|
37
37
|
"pytest-cov>=6.0.0",
|
38
38
|
"typer>=0.15.2",
|
39
39
|
"pytest-mock>=3.14.0",
|
40
|
+
"pytest-benchmark>=5.1.0",
|
40
41
|
]
|
41
42
|
docs = [
|
42
43
|
"mkdocs-material>=9.5.50",
|
@@ -87,6 +88,29 @@ asyncio_default_fixture_loop_scope = "session"
|
|
87
88
|
source = ["src"]
|
88
89
|
omit = ["src/engin/scripts/**"]
|
89
90
|
|
91
|
+
[tool.coverage.report]
|
92
|
+
precision = 1
|
93
|
+
skip_covered = true
|
94
|
+
exclude_lines = [
|
95
|
+
"pragma: no cover",
|
96
|
+
"abc.abstractmethod",
|
97
|
+
"if TYPE_CHECKING.*:",
|
98
|
+
"if _t.TYPE_CHECKING:",
|
99
|
+
"if t.TYPE_CHECKING:",
|
100
|
+
"@overload",
|
101
|
+
'class .*\bProtocol\b.*\):',
|
102
|
+
"raise NotImplementedError",
|
103
|
+
]
|
104
|
+
partial_branches = [
|
105
|
+
"pragma: no branch",
|
106
|
+
"if not TYPE_CHECKING:",
|
107
|
+
"if not _t.TYPE_CHECKING:",
|
108
|
+
"if not t.TYPE_CHECKING:",
|
109
|
+
"if .* or not TYPE_CHECKING:",
|
110
|
+
"if .* or not _t.TYPE_CHECKING:",
|
111
|
+
"if .* or not t.TYPE_CHECKING:",
|
112
|
+
]
|
113
|
+
|
90
114
|
|
91
115
|
[tool.mypy]
|
92
116
|
strict = true
|
@@ -112,6 +136,7 @@ check.sequence = [
|
|
112
136
|
fix.default_item_type = "cmd"
|
113
137
|
fix.sequence = ["ruff check src tests examples --fix"]
|
114
138
|
|
115
|
-
test = "pytest tests"
|
116
|
-
ci-test = "pytest --cov=engin --cov-branch --cov-report=xml tests"
|
139
|
+
test = "pytest tests --benchmark-skip"
|
140
|
+
ci-test = "pytest --cov=engin --cov-branch --cov-report=xml tests --benchmark-skip"
|
141
|
+
bench = "pytest tests --benchmark-only"
|
117
142
|
docs = "mkdocs serve"
|
@@ -1,9 +1,7 @@
|
|
1
|
-
from engin import ext
|
2
1
|
from engin._assembler import Assembler
|
3
2
|
from engin._block import Block, invoke, provide
|
4
3
|
from engin._dependency import Entrypoint, Invoke, Provide, Supply
|
5
4
|
from engin._engin import Engin
|
6
|
-
from engin._exceptions import ProviderError
|
7
5
|
from engin._lifecycle import Lifecycle
|
8
6
|
from engin._option import Option
|
9
7
|
from engin._type_utils import TypeId
|
@@ -17,10 +15,8 @@ __all__ = [
|
|
17
15
|
"Lifecycle",
|
18
16
|
"Option",
|
19
17
|
"Provide",
|
20
|
-
"ProviderError",
|
21
18
|
"Supply",
|
22
19
|
"TypeId",
|
23
|
-
"ext",
|
24
20
|
"invoke",
|
25
21
|
"provide",
|
26
22
|
]
|
@@ -9,8 +9,8 @@ from types import TracebackType
|
|
9
9
|
from typing import Any, Generic, TypeVar, cast
|
10
10
|
|
11
11
|
from engin._dependency import Dependency, Provide, Supply
|
12
|
-
from engin._exceptions import NotInScopeError, ProviderError
|
13
12
|
from engin._type_utils import TypeId
|
13
|
+
from engin.exceptions import NotInScopeError, ProviderError
|
14
14
|
|
15
15
|
LOG = logging.getLogger("engin")
|
16
16
|
|
@@ -65,6 +65,7 @@ class Assembler:
|
|
65
65
|
self._multiproviders: dict[TypeId, list[Provide[list[Any]]]] = defaultdict(list)
|
66
66
|
self._assembled_outputs: dict[TypeId, Any] = {}
|
67
67
|
self._lock = asyncio.Lock()
|
68
|
+
self._graph_cache: dict[TypeId, set[Provide]] = defaultdict(set)
|
68
69
|
|
69
70
|
for provider in providers:
|
70
71
|
type_id = provider.return_type_id
|
@@ -179,8 +180,8 @@ class Assembler:
|
|
179
180
|
"""
|
180
181
|
Add a provider to the Assembler post-initialisation.
|
181
182
|
|
182
|
-
If this replaces an existing provider, this will clear
|
183
|
-
output
|
183
|
+
If this replaces an existing provider, this will clear all previously assembled
|
184
|
+
output. Note: multiproviders cannot be replaced, they are always appended.
|
184
185
|
|
185
186
|
Args:
|
186
187
|
provider: the Provide instance to add.
|
@@ -190,14 +191,13 @@ class Assembler:
|
|
190
191
|
"""
|
191
192
|
type_id = provider.return_type_id
|
192
193
|
if provider.is_multiprovider:
|
193
|
-
if type_id in self._assembled_outputs:
|
194
|
-
del self._assembled_outputs[type_id]
|
195
194
|
self._multiproviders[type_id].append(provider)
|
196
195
|
else:
|
197
|
-
if type_id in self._assembled_outputs:
|
198
|
-
del self._assembled_outputs[type_id]
|
199
196
|
self._providers[type_id] = provider
|
200
197
|
|
198
|
+
self._assembled_outputs.clear()
|
199
|
+
self._graph_cache.clear()
|
200
|
+
|
201
201
|
def scope(self, scope: str) -> "_ScopeContextManager":
|
202
202
|
return _ScopeContextManager(scope=scope, assembler=self)
|
203
203
|
|
@@ -206,13 +206,14 @@ class Assembler:
|
|
206
206
|
if provider.scope == scope:
|
207
207
|
self._assembled_outputs.pop(type_id, None)
|
208
208
|
|
209
|
-
def _resolve_providers(self, type_id: TypeId) ->
|
209
|
+
def _resolve_providers(self, type_id: TypeId, resolved: set[TypeId]) -> set[Provide]:
|
210
210
|
"""
|
211
211
|
Resolves the chain of providers required to satisfy the provider of a given type.
|
212
212
|
Ordering of the return value is very important!
|
213
|
-
|
214
|
-
# TODO: performance optimisation, do not recurse for already satisfied providers?
|
215
213
|
"""
|
214
|
+
if type_id in self._graph_cache:
|
215
|
+
return self._graph_cache[type_id]
|
216
|
+
|
216
217
|
if type_id.multi:
|
217
218
|
root_providers = self._multiproviders.get(type_id)
|
218
219
|
else:
|
@@ -230,22 +231,28 @@ class Assembler:
|
|
230
231
|
raise LookupError(msg)
|
231
232
|
|
232
233
|
# providers that must be satisfied to satisfy the root level providers
|
233
|
-
|
234
|
+
resolved_providers = {
|
234
235
|
child_provider
|
235
236
|
for root_provider in root_providers
|
236
237
|
for root_provider_param in root_provider.parameter_type_ids
|
237
|
-
for child_provider in self._resolve_providers(root_provider_param)
|
238
|
-
|
239
|
-
|
238
|
+
for child_provider in self._resolve_providers(root_provider_param, resolved)
|
239
|
+
if root_provider_param not in resolved
|
240
|
+
} | set(root_providers)
|
241
|
+
|
242
|
+
resolved.add(type_id)
|
243
|
+
self._graph_cache[type_id] = resolved_providers
|
244
|
+
|
245
|
+
return resolved_providers
|
240
246
|
|
241
247
|
async def _satisfy(self, target: TypeId) -> None:
|
242
|
-
for provider in self._resolve_providers(target):
|
248
|
+
for provider in self._resolve_providers(target, set()):
|
243
249
|
if (
|
244
250
|
not provider.is_multiprovider
|
245
251
|
and provider.return_type_id in self._assembled_outputs
|
246
252
|
):
|
247
253
|
continue
|
248
254
|
type_id = provider.return_type_id
|
255
|
+
|
249
256
|
bound_args = await self._bind_arguments(provider.signature)
|
250
257
|
try:
|
251
258
|
value = await provider(*bound_args.args, **bound_args.kwargs)
|
@@ -253,6 +260,7 @@ class Assembler:
|
|
253
260
|
raise ProviderError(
|
254
261
|
provider=provider, error_type=type(err), error_message=str(err)
|
255
262
|
) from err
|
263
|
+
|
256
264
|
if provider.is_multiprovider:
|
257
265
|
if type_id in self._assembled_outputs:
|
258
266
|
self._assembled_outputs[type_id].extend(value)
|
@@ -269,8 +277,7 @@ class Assembler:
|
|
269
277
|
args.append(object())
|
270
278
|
continue
|
271
279
|
param_key = TypeId.from_type(param.annotation)
|
272
|
-
|
273
|
-
if not has_dependency:
|
280
|
+
if param_key not in self._assembled_outputs:
|
274
281
|
await self._satisfy(param_key)
|
275
282
|
val = self._assembled_outputs[param_key]
|
276
283
|
if param.kind == param.POSITIONAL_ONLY:
|
@@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, ClassVar
|
|
5
5
|
|
6
6
|
from engin._dependency import Dependency, Func, Invoke, Provide
|
7
7
|
from engin._option import Option
|
8
|
+
from engin.exceptions import InvalidBlockError
|
8
9
|
|
9
10
|
if TYPE_CHECKING:
|
10
11
|
from engin._engin import Engin
|
@@ -42,7 +43,7 @@ def invoke(func_: Func | None = None) -> Func | Callable[[Func], Func]:
|
|
42
43
|
return _inner(func_)
|
43
44
|
|
44
45
|
|
45
|
-
class Block
|
46
|
+
class Block:
|
46
47
|
"""
|
47
48
|
A Block is a collection of providers and invocations.
|
48
49
|
|
@@ -74,7 +75,7 @@ class Block(Option):
|
|
74
75
|
|
75
76
|
@classmethod
|
76
77
|
def apply(cls, engin: "Engin") -> None:
|
77
|
-
block_name = cls.name or
|
78
|
+
block_name = cls.name or cls.__name__
|
78
79
|
for option in chain(cls.options, cls._method_options()):
|
79
80
|
if isinstance(option, Dependency):
|
80
81
|
option._block_name = block_name
|
@@ -82,8 +83,19 @@ class Block(Option):
|
|
82
83
|
|
83
84
|
@classmethod
|
84
85
|
def _method_options(cls) -> Iterable[Provide | Invoke]:
|
85
|
-
for
|
86
|
+
for name, method in inspect.getmembers(cls, inspect.isfunction):
|
86
87
|
if option := getattr(method, "_opt", None):
|
87
88
|
if not isinstance(option, Provide | Invoke):
|
88
|
-
raise
|
89
|
+
raise InvalidBlockError(
|
90
|
+
block=cls,
|
91
|
+
reason="Block option is not an instance of Provide or Invoke",
|
92
|
+
)
|
89
93
|
yield option
|
94
|
+
else:
|
95
|
+
raise InvalidBlockError(
|
96
|
+
block=cls,
|
97
|
+
reason=(
|
98
|
+
f"Method '{name}' is not a Provider or Invocation, did you "
|
99
|
+
"forget to decorate it?"
|
100
|
+
),
|
101
|
+
)
|
@@ -12,10 +12,10 @@ from rich import print
|
|
12
12
|
from engin import Entrypoint, Invoke, TypeId
|
13
13
|
from engin._cli._common import COMMON_HELP, get_engin_instance
|
14
14
|
from engin._dependency import Dependency, Provide, Supply
|
15
|
-
from engin.
|
15
|
+
from engin.extensions.asgi import ASGIEngin
|
16
16
|
|
17
17
|
try:
|
18
|
-
from engin.
|
18
|
+
from engin.extensions.fastapi import APIRouteDependency
|
19
19
|
except ImportError:
|
20
20
|
APIRouteDependency = None # type: ignore[assignment,misc]
|
21
21
|
|
@@ -175,47 +175,37 @@ class Provide(Dependency[Any, T]):
|
|
175
175
|
self._scope = scope
|
176
176
|
self._override = override
|
177
177
|
self._explicit_type = as_type
|
178
|
+
self._return_type = self._resolve_return_type()
|
179
|
+
self._return_type_id = TypeId.from_type(self._return_type)
|
178
180
|
|
179
181
|
if self._explicit_type is not None:
|
180
182
|
self._signature = self._signature.replace(return_annotation=self._explicit_type)
|
181
183
|
|
182
|
-
self._is_multi = typing.get_origin(self.
|
184
|
+
self._is_multi = typing.get_origin(self._return_type) is list
|
183
185
|
|
184
186
|
# Validate that the provider does to depend on its own output value, as this will
|
185
187
|
# cause a recursion error and is undefined behaviour wise.
|
186
188
|
if any(
|
187
|
-
self.
|
189
|
+
self._return_type == param.annotation
|
188
190
|
for param in self._signature.parameters.values()
|
189
191
|
):
|
190
192
|
raise ValueError("A provider cannot depend on its own return type")
|
191
193
|
|
192
194
|
# Validate that multiproviders only return a list of one type.
|
193
195
|
if self._is_multi:
|
194
|
-
args = typing.get_args(self.
|
196
|
+
args = typing.get_args(self._return_type)
|
195
197
|
if len(args) != 1:
|
196
198
|
raise ValueError(
|
197
|
-
f"A multiprovider must be of the form list[X], not '{self.
|
199
|
+
f"A multiprovider must be of the form list[X], not '{self._return_type}'"
|
198
200
|
)
|
199
201
|
|
200
202
|
@property
|
201
203
|
def return_type(self) -> type[T]:
|
202
|
-
|
203
|
-
return self._explicit_type
|
204
|
-
if isclass(self._func):
|
205
|
-
return_type = self._func # __init__ returns self
|
206
|
-
else:
|
207
|
-
try:
|
208
|
-
return_type = get_type_hints(self._func, include_extras=True)["return"]
|
209
|
-
except KeyError as err:
|
210
|
-
raise RuntimeError(
|
211
|
-
f"Dependency '{self.name}' requires a return typehint"
|
212
|
-
) from err
|
213
|
-
|
214
|
-
return return_type
|
204
|
+
return self._return_type
|
215
205
|
|
216
206
|
@property
|
217
207
|
def return_type_id(self) -> TypeId:
|
218
|
-
return
|
208
|
+
return self._return_type_id
|
219
209
|
|
220
210
|
@property
|
221
211
|
def is_multiprovider(self) -> bool:
|
@@ -255,6 +245,21 @@ class Provide(Dependency[Any, T]):
|
|
255
245
|
def __str__(self) -> str:
|
256
246
|
return f"Provide({self.return_type_id})"
|
257
247
|
|
248
|
+
def _resolve_return_type(self) -> type[T]:
|
249
|
+
if self._explicit_type is not None:
|
250
|
+
return self._explicit_type
|
251
|
+
if isclass(self._func):
|
252
|
+
return_type = self._func # __init__ returns self
|
253
|
+
else:
|
254
|
+
try:
|
255
|
+
return_type = get_type_hints(self._func, include_extras=True)["return"]
|
256
|
+
except KeyError as err:
|
257
|
+
raise RuntimeError(
|
258
|
+
f"Dependency '{self.name}' requires a return typehint"
|
259
|
+
) from err
|
260
|
+
|
261
|
+
return return_type
|
262
|
+
|
258
263
|
|
259
264
|
class Supply(Provide, Generic[T]):
|
260
265
|
def __init__(
|
@@ -276,8 +281,7 @@ class Supply(Provide, Generic[T]):
|
|
276
281
|
self._value = value
|
277
282
|
super().__init__(builder=self._get_val, as_type=as_type, override=override)
|
278
283
|
|
279
|
-
|
280
|
-
def return_type(self) -> type[T]:
|
284
|
+
def _resolve_return_type(self) -> type[T]:
|
281
285
|
if self._explicit_type is not None:
|
282
286
|
return self._explicit_type
|
283
287
|
if isinstance(self._value, list):
|
@@ -1,7 +1,10 @@
|
|
1
|
-
from typing import Any
|
1
|
+
from typing import TYPE_CHECKING, Any
|
2
2
|
|
3
3
|
from engin._dependency import Provide
|
4
4
|
|
5
|
+
if TYPE_CHECKING:
|
6
|
+
from engin._block import Block
|
7
|
+
|
5
8
|
|
6
9
|
class EnginError(Exception):
|
7
10
|
"""
|
@@ -15,6 +18,20 @@ class AssemblerError(EnginError):
|
|
15
18
|
"""
|
16
19
|
|
17
20
|
|
21
|
+
class InvalidBlockError(EnginError):
|
22
|
+
"""
|
23
|
+
Raised when an invalid block is instantiated.
|
24
|
+
"""
|
25
|
+
|
26
|
+
def __init__(self, block: "type[Block]", reason: str) -> None:
|
27
|
+
self.block = block
|
28
|
+
self.block_name = block.name or block.__name__
|
29
|
+
self.message = f"block '{self.block_name}' is invalid, reason: '{reason}'"
|
30
|
+
|
31
|
+
def __str__(self) -> str:
|
32
|
+
return self.message
|
33
|
+
|
34
|
+
|
18
35
|
class ProviderError(AssemblerError):
|
19
36
|
"""
|
20
37
|
Raised when a Provider errors during Assembly.
|
@@ -52,3 +69,12 @@ class NotInScopeError(AssemblerError):
|
|
52
69
|
|
53
70
|
def __str__(self) -> str:
|
54
71
|
return self.message
|
72
|
+
|
73
|
+
|
74
|
+
__all__ = [
|
75
|
+
"AssemblerError",
|
76
|
+
"EnginError",
|
77
|
+
"InvalidBlockError",
|
78
|
+
"NotInScopeError",
|
79
|
+
"ProviderError",
|
80
|
+
]
|