engin 0.0.17__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.17 → engin-0.0.19}/.github/workflows/check.yaml +6 -1
- {engin-0.0.17 → engin-0.0.19}/CHANGELOG.md +27 -0
- {engin-0.0.17 → engin-0.0.19}/PKG-INFO +1 -1
- engin-0.0.19/docs/concepts/block.md +82 -0
- {engin-0.0.17 → engin-0.0.19}/docs/guides/fastapi.md +21 -15
- {engin-0.0.17 → engin-0.0.19}/examples/asgi/app.py +1 -1
- {engin-0.0.17 → engin-0.0.19}/examples/asgi/main.py +1 -1
- {engin-0.0.17 → engin-0.0.19}/examples/fastapi/main.py +1 -1
- {engin-0.0.17 → engin-0.0.19}/examples/fastapi/routes/cats/api.py +1 -1
- {engin-0.0.17 → engin-0.0.19}/pyproject.toml +28 -3
- {engin-0.0.17 → engin-0.0.19}/src/engin/__init__.py +0 -4
- {engin-0.0.17 → engin-0.0.19}/src/engin/_assembler.py +30 -18
- {engin-0.0.17 → engin-0.0.19}/src/engin/_block.py +16 -4
- {engin-0.0.17 → engin-0.0.19}/src/engin/_cli/__init__.py +11 -0
- engin-0.0.19/src/engin/_cli/_common.py +51 -0
- {engin-0.0.17 → engin-0.0.19}/src/engin/_cli/_graph.py +6 -37
- engin-0.0.19/src/engin/_cli/_inspect.py +94 -0
- {engin-0.0.17 → engin-0.0.19}/src/engin/_dependency.py +24 -20
- engin-0.0.17/src/engin/_exceptions.py → engin-0.0.19/src/engin/exceptions.py +27 -1
- {engin-0.0.17/src/engin/ext → engin-0.0.19/src/engin/extensions}/fastapi.py +1 -1
- {engin-0.0.17 → engin-0.0.19}/tests/acceptance/test_error_in_shutdown.py +1 -1
- {engin-0.0.17 → engin-0.0.19}/tests/acceptance/test_error_in_start_up.py +1 -1
- {engin-0.0.17 → 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.17 → engin-0.0.19}/tests/cli/test_graph.py +24 -3
- engin-0.0.19/tests/cli/test_inspect.py +16 -0
- engin-0.0.19/tests/conftest.py +0 -0
- {engin-0.0.17 → engin-0.0.19}/tests/deps.py +8 -1
- {engin-0.0.17 → engin-0.0.19}/tests/test_assembler.py +28 -13
- engin-0.0.19/tests/test_block.py +54 -0
- {engin-0.0.17 → engin-0.0.19}/tests/test_dependencies.py +11 -15
- {engin-0.0.17 → engin-0.0.19}/tests/test_engin.py +2 -1
- {engin-0.0.17 → engin-0.0.19}/uv.lock +64 -40
- engin-0.0.17/src/engin/_cli/_utils.py +0 -18
- engin-0.0.17/tests/test_block.py +0 -25
- {engin-0.0.17 → engin-0.0.19}/.github/workflows/publish.yaml +0 -0
- {engin-0.0.17 → engin-0.0.19}/.gitignore +0 -0
- {engin-0.0.17 → engin-0.0.19}/.readthedocs.yaml +0 -0
- {engin-0.0.17 → engin-0.0.19}/LICENSE +0 -0
- {engin-0.0.17 → engin-0.0.19}/README.md +0 -0
- {engin-0.0.17 → engin-0.0.19}/docs/concepts/engin.md +0 -0
- {engin-0.0.17 → engin-0.0.19}/docs/concepts/invocations.md +0 -0
- {engin-0.0.17 → engin-0.0.19}/docs/concepts/lifecycle.md +0 -0
- {engin-0.0.17 → engin-0.0.19}/docs/concepts/providers.md +0 -0
- {engin-0.0.17 → engin-0.0.19}/docs/getting-started.md +0 -0
- {engin-0.0.17 → engin-0.0.19}/docs/guides/fastapi-graph.png +0 -0
- {engin-0.0.17 → engin-0.0.19}/docs/index.md +0 -0
- {engin-0.0.17 → engin-0.0.19}/docs/js/readthedocs.js +0 -0
- {engin-0.0.17 → engin-0.0.19}/docs/overrides/main.html +0 -0
- {engin-0.0.17 → engin-0.0.19}/docs/reference.md +0 -0
- {engin-0.0.17 → engin-0.0.19}/examples/__init__.py +0 -0
- {engin-0.0.17 → engin-0.0.19}/examples/asgi/__init__.py +0 -0
- {engin-0.0.17 → engin-0.0.19}/examples/asgi/common/__init__.py +0 -0
- {engin-0.0.17 → engin-0.0.19}/examples/asgi/common/db/__init__.py +0 -0
- {engin-0.0.17 → engin-0.0.19}/examples/asgi/common/db/adapaters/__init__.py +0 -0
- {engin-0.0.17 → engin-0.0.19}/examples/asgi/common/db/adapaters/memory.py +0 -0
- {engin-0.0.17 → engin-0.0.19}/examples/asgi/common/db/block.py +0 -0
- {engin-0.0.17 → engin-0.0.19}/examples/asgi/common/db/ports.py +0 -0
- {engin-0.0.17 → engin-0.0.19}/examples/asgi/common/starlette/__init__.py +0 -0
- {engin-0.0.17 → engin-0.0.19}/examples/asgi/common/starlette/endpoint.py +0 -0
- {engin-0.0.17 → engin-0.0.19}/examples/asgi/features/__init__.py +0 -0
- {engin-0.0.17 → engin-0.0.19}/examples/asgi/features/cats/__init__.py +0 -0
- {engin-0.0.17 → engin-0.0.19}/examples/asgi/features/cats/api/__init__.py +0 -0
- {engin-0.0.17 → engin-0.0.19}/examples/asgi/features/cats/api/get.py +0 -0
- {engin-0.0.17 → engin-0.0.19}/examples/asgi/features/cats/api/post.py +0 -0
- {engin-0.0.17 → engin-0.0.19}/examples/asgi/features/cats/block.py +0 -0
- {engin-0.0.17 → engin-0.0.19}/examples/asgi/features/cats/domain.py +0 -0
- {engin-0.0.17 → engin-0.0.19}/examples/fastapi/__init__.py +0 -0
- {engin-0.0.17 → engin-0.0.19}/examples/fastapi/app.py +0 -0
- {engin-0.0.17 → engin-0.0.19}/examples/fastapi/routes/__init__.py +0 -0
- {engin-0.0.17 → engin-0.0.19}/examples/fastapi/routes/cats/__init__.py +0 -0
- {engin-0.0.17 → engin-0.0.19}/examples/fastapi/routes/cats/adapters/__init__.py +0 -0
- {engin-0.0.17 → engin-0.0.19}/examples/fastapi/routes/cats/adapters/repository.py +0 -0
- {engin-0.0.17 → engin-0.0.19}/examples/fastapi/routes/cats/block.py +0 -0
- {engin-0.0.17 → engin-0.0.19}/examples/fastapi/routes/cats/domain.py +0 -0
- {engin-0.0.17 → engin-0.0.19}/examples/fastapi/routes/cats/ports.py +0 -0
- {engin-0.0.17 → engin-0.0.19}/examples/simple/__init__.py +0 -0
- {engin-0.0.17 → engin-0.0.19}/examples/simple/main.py +0 -0
- {engin-0.0.17 → engin-0.0.19}/mkdocs.yaml +0 -0
- {engin-0.0.17 → engin-0.0.19}/src/engin/_cli/_graph.html +0 -0
- {engin-0.0.17 → engin-0.0.19}/src/engin/_engin.py +0 -0
- {engin-0.0.17 → engin-0.0.19}/src/engin/_graph.py +0 -0
- {engin-0.0.17 → engin-0.0.19}/src/engin/_introspect.py +0 -0
- {engin-0.0.17 → engin-0.0.19}/src/engin/_lifecycle.py +0 -0
- {engin-0.0.17 → engin-0.0.19}/src/engin/_option.py +0 -0
- {engin-0.0.17 → engin-0.0.19}/src/engin/_type_utils.py +0 -0
- {engin-0.0.17/src/engin/ext → engin-0.0.19/src/engin/extensions}/__init__.py +0 -0
- {engin-0.0.17/src/engin/ext → engin-0.0.19/src/engin/extensions}/asgi.py +0 -0
- {engin-0.0.17 → engin-0.0.19}/src/engin/py.typed +0 -0
- {engin-0.0.17 → engin-0.0.19}/tests/__init__.py +0 -0
- {engin-0.0.17 → engin-0.0.19}/tests/acceptance/__init__.py +0 -0
- {engin-0.0.17/tests/cli → engin-0.0.19/tests/benchmarks}/__init__.py +0 -0
- /engin-0.0.17/tests/conftest.py → /engin-0.0.19/tests/cli/__init__.py +0 -0
- {engin-0.0.17 → engin-0.0.19}/tests/test_graph.py +0 -0
- {engin-0.0.17 → engin-0.0.19}/tests/test_lifecycle.py +0 -0
- {engin-0.0.17 → 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,33 @@ 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
|
+
|
28
|
+
## [0.0.18] - 2025-04-25
|
29
|
+
|
30
|
+
### Added
|
31
|
+
|
32
|
+
- A new cli option `engin inspect` that can be used to inspect providers, e.g.
|
33
|
+
`engin inspect examples.simple.main:engin --module httpx`
|
34
|
+
|
35
|
+
|
9
36
|
## [0.0.17] - 2025-04-20
|
10
37
|
|
11
38
|
### 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
|
]
|
@@ -1,7 +1,7 @@
|
|
1
1
|
import asyncio
|
2
2
|
import logging
|
3
3
|
from collections import defaultdict
|
4
|
-
from collections.abc import Iterable
|
4
|
+
from collections.abc import Iterable, Sequence
|
5
5
|
from contextvars import ContextVar
|
6
6
|
from dataclasses import dataclass
|
7
7
|
from inspect import BoundArguments, Signature
|
@@ -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
|
@@ -75,6 +76,11 @@ class Assembler:
|
|
75
76
|
else:
|
76
77
|
self._multiproviders[type_id].append(provider)
|
77
78
|
|
79
|
+
@property
|
80
|
+
def providers(self) -> Sequence[Provide[Any]]:
|
81
|
+
multi_providers = [p for multi in self._multiproviders.values() for p in multi]
|
82
|
+
return [*self._providers.values(), *multi_providers]
|
83
|
+
|
78
84
|
async def assemble(self, dependency: Dependency[Any, T]) -> AssembledDependency[T]:
|
79
85
|
"""
|
80
86
|
Assemble a dependency.
|
@@ -174,8 +180,8 @@ class Assembler:
|
|
174
180
|
"""
|
175
181
|
Add a provider to the Assembler post-initialisation.
|
176
182
|
|
177
|
-
If this replaces an existing provider, this will clear
|
178
|
-
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.
|
179
185
|
|
180
186
|
Args:
|
181
187
|
provider: the Provide instance to add.
|
@@ -185,14 +191,13 @@ class Assembler:
|
|
185
191
|
"""
|
186
192
|
type_id = provider.return_type_id
|
187
193
|
if provider.is_multiprovider:
|
188
|
-
if type_id in self._assembled_outputs:
|
189
|
-
del self._assembled_outputs[type_id]
|
190
194
|
self._multiproviders[type_id].append(provider)
|
191
195
|
else:
|
192
|
-
if type_id in self._assembled_outputs:
|
193
|
-
del self._assembled_outputs[type_id]
|
194
196
|
self._providers[type_id] = provider
|
195
197
|
|
198
|
+
self._assembled_outputs.clear()
|
199
|
+
self._graph_cache.clear()
|
200
|
+
|
196
201
|
def scope(self, scope: str) -> "_ScopeContextManager":
|
197
202
|
return _ScopeContextManager(scope=scope, assembler=self)
|
198
203
|
|
@@ -201,13 +206,14 @@ class Assembler:
|
|
201
206
|
if provider.scope == scope:
|
202
207
|
self._assembled_outputs.pop(type_id, None)
|
203
208
|
|
204
|
-
def _resolve_providers(self, type_id: TypeId) ->
|
209
|
+
def _resolve_providers(self, type_id: TypeId, resolved: set[TypeId]) -> set[Provide]:
|
205
210
|
"""
|
206
211
|
Resolves the chain of providers required to satisfy the provider of a given type.
|
207
212
|
Ordering of the return value is very important!
|
208
|
-
|
209
|
-
# TODO: performance optimisation, do not recurse for already satisfied providers?
|
210
213
|
"""
|
214
|
+
if type_id in self._graph_cache:
|
215
|
+
return self._graph_cache[type_id]
|
216
|
+
|
211
217
|
if type_id.multi:
|
212
218
|
root_providers = self._multiproviders.get(type_id)
|
213
219
|
else:
|
@@ -225,22 +231,28 @@ class Assembler:
|
|
225
231
|
raise LookupError(msg)
|
226
232
|
|
227
233
|
# providers that must be satisfied to satisfy the root level providers
|
228
|
-
|
234
|
+
resolved_providers = {
|
229
235
|
child_provider
|
230
236
|
for root_provider in root_providers
|
231
237
|
for root_provider_param in root_provider.parameter_type_ids
|
232
|
-
for child_provider in self._resolve_providers(root_provider_param)
|
233
|
-
|
234
|
-
|
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
|
235
246
|
|
236
247
|
async def _satisfy(self, target: TypeId) -> None:
|
237
|
-
for provider in self._resolve_providers(target):
|
248
|
+
for provider in self._resolve_providers(target, set()):
|
238
249
|
if (
|
239
250
|
not provider.is_multiprovider
|
240
251
|
and provider.return_type_id in self._assembled_outputs
|
241
252
|
):
|
242
253
|
continue
|
243
254
|
type_id = provider.return_type_id
|
255
|
+
|
244
256
|
bound_args = await self._bind_arguments(provider.signature)
|
245
257
|
try:
|
246
258
|
value = await provider(*bound_args.args, **bound_args.kwargs)
|
@@ -248,6 +260,7 @@ class Assembler:
|
|
248
260
|
raise ProviderError(
|
249
261
|
provider=provider, error_type=type(err), error_message=str(err)
|
250
262
|
) from err
|
263
|
+
|
251
264
|
if provider.is_multiprovider:
|
252
265
|
if type_id in self._assembled_outputs:
|
253
266
|
self._assembled_outputs[type_id].extend(value)
|
@@ -264,8 +277,7 @@ class Assembler:
|
|
264
277
|
args.append(object())
|
265
278
|
continue
|
266
279
|
param_key = TypeId.from_type(param.annotation)
|
267
|
-
|
268
|
-
if not has_dependency:
|
280
|
+
if param_key not in self._assembled_outputs:
|
269
281
|
await self._satisfy(param_key)
|
270
282
|
val = self._assembled_outputs[param_key]
|
271
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
|
+
)
|
@@ -1,3 +1,6 @@
|
|
1
|
+
import logging
|
2
|
+
import sys
|
3
|
+
|
1
4
|
try:
|
2
5
|
import typer
|
3
6
|
except ImportError:
|
@@ -7,7 +10,15 @@ except ImportError:
|
|
7
10
|
) from None
|
8
11
|
|
9
12
|
from engin._cli._graph import cli as graph_cli
|
13
|
+
from engin._cli._inspect import cli as inspect_cli
|
14
|
+
|
15
|
+
# mute logging from importing of files + engin's debug logging.
|
16
|
+
logging.disable()
|
17
|
+
|
18
|
+
# add cwd to path to enable local package imports
|
19
|
+
sys.path.insert(0, "")
|
10
20
|
|
11
21
|
app = typer.Typer()
|
12
22
|
|
13
23
|
app.add_typer(graph_cli)
|
24
|
+
app.add_typer(inspect_cli)
|
@@ -0,0 +1,51 @@
|
|
1
|
+
import importlib
|
2
|
+
from typing import Never
|
3
|
+
|
4
|
+
import typer
|
5
|
+
from rich import print
|
6
|
+
from rich.panel import Panel
|
7
|
+
|
8
|
+
from engin import Engin
|
9
|
+
|
10
|
+
|
11
|
+
def print_error(msg: str) -> Never:
|
12
|
+
print(
|
13
|
+
Panel(
|
14
|
+
title="Error",
|
15
|
+
renderable=msg,
|
16
|
+
title_align="left",
|
17
|
+
border_style="red",
|
18
|
+
highlight=True,
|
19
|
+
)
|
20
|
+
)
|
21
|
+
raise typer.Exit(code=1)
|
22
|
+
|
23
|
+
|
24
|
+
COMMON_HELP = {
|
25
|
+
"app": (
|
26
|
+
"The import path of your Engin instance, in the form 'package:application'"
|
27
|
+
", e.g. 'app.main:engin'"
|
28
|
+
)
|
29
|
+
}
|
30
|
+
|
31
|
+
|
32
|
+
def get_engin_instance(app: str) -> tuple[str, str, Engin]:
|
33
|
+
try:
|
34
|
+
module_name, engin_name = app.split(":", maxsplit=1)
|
35
|
+
except ValueError:
|
36
|
+
print_error("Expected an argument of the form 'module:attribute', e.g. 'myapp:engin'")
|
37
|
+
|
38
|
+
try:
|
39
|
+
module = importlib.import_module(module_name)
|
40
|
+
except ModuleNotFoundError:
|
41
|
+
print_error(f"Unable to find module '{module_name}'")
|
42
|
+
|
43
|
+
try:
|
44
|
+
instance = getattr(module, engin_name)
|
45
|
+
except AttributeError:
|
46
|
+
print_error(f"Module '{module_name}' has no attribute '{engin_name}'")
|
47
|
+
|
48
|
+
if not isinstance(instance, Engin):
|
49
|
+
print_error(f"'{app}' is not an Engin instance")
|
50
|
+
|
51
|
+
return module_name, engin_name, instance
|