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.
Files changed (97) hide show
  1. engin-0.0.19/.github/workflows/benchmark.yaml +38 -0
  2. {engin-0.0.18 → engin-0.0.19}/.github/workflows/check.yaml +6 -1
  3. {engin-0.0.18 → engin-0.0.19}/CHANGELOG.md +19 -0
  4. {engin-0.0.18 → engin-0.0.19}/PKG-INFO +1 -1
  5. engin-0.0.19/docs/concepts/block.md +82 -0
  6. {engin-0.0.18 → engin-0.0.19}/docs/guides/fastapi.md +21 -15
  7. {engin-0.0.18 → engin-0.0.19}/examples/asgi/app.py +1 -1
  8. {engin-0.0.18 → engin-0.0.19}/examples/asgi/main.py +1 -1
  9. {engin-0.0.18 → engin-0.0.19}/examples/fastapi/main.py +1 -1
  10. {engin-0.0.18 → engin-0.0.19}/examples/fastapi/routes/cats/api.py +1 -1
  11. {engin-0.0.18 → engin-0.0.19}/pyproject.toml +28 -3
  12. {engin-0.0.18 → engin-0.0.19}/src/engin/__init__.py +0 -4
  13. {engin-0.0.18 → engin-0.0.19}/src/engin/_assembler.py +24 -17
  14. {engin-0.0.18 → engin-0.0.19}/src/engin/_block.py +16 -4
  15. {engin-0.0.18 → engin-0.0.19}/src/engin/_cli/_graph.py +2 -2
  16. {engin-0.0.18 → engin-0.0.19}/src/engin/_dependency.py +24 -20
  17. engin-0.0.18/src/engin/_exceptions.py → engin-0.0.19/src/engin/exceptions.py +27 -1
  18. {engin-0.0.18/src/engin/ext → engin-0.0.19/src/engin/extensions}/fastapi.py +1 -1
  19. {engin-0.0.18 → engin-0.0.19}/tests/acceptance/test_error_in_shutdown.py +1 -1
  20. {engin-0.0.18 → engin-0.0.19}/tests/acceptance/test_error_in_start_up.py +1 -1
  21. {engin-0.0.18 → engin-0.0.19}/tests/acceptance/test_fastapi.py +2 -2
  22. engin-0.0.19/tests/benchmarks/conftest.py +21 -0
  23. engin-0.0.19/tests/benchmarks/test_bench_assembler.py +114 -0
  24. {engin-0.0.18 → engin-0.0.19}/tests/cli/test_graph.py +23 -2
  25. {engin-0.0.18 → engin-0.0.19}/tests/cli/test_inspect.py +4 -1
  26. engin-0.0.19/tests/conftest.py +0 -0
  27. {engin-0.0.18 → engin-0.0.19}/tests/deps.py +8 -1
  28. {engin-0.0.18 → engin-0.0.19}/tests/test_assembler.py +28 -13
  29. engin-0.0.19/tests/test_block.py +54 -0
  30. {engin-0.0.18 → engin-0.0.19}/tests/test_dependencies.py +11 -15
  31. {engin-0.0.18 → engin-0.0.19}/tests/test_engin.py +2 -1
  32. {engin-0.0.18 → engin-0.0.19}/uv.lock +28 -4
  33. engin-0.0.18/tests/test_block.py +0 -25
  34. {engin-0.0.18 → engin-0.0.19}/.github/workflows/publish.yaml +0 -0
  35. {engin-0.0.18 → engin-0.0.19}/.gitignore +0 -0
  36. {engin-0.0.18 → engin-0.0.19}/.readthedocs.yaml +0 -0
  37. {engin-0.0.18 → engin-0.0.19}/LICENSE +0 -0
  38. {engin-0.0.18 → engin-0.0.19}/README.md +0 -0
  39. {engin-0.0.18 → engin-0.0.19}/docs/concepts/engin.md +0 -0
  40. {engin-0.0.18 → engin-0.0.19}/docs/concepts/invocations.md +0 -0
  41. {engin-0.0.18 → engin-0.0.19}/docs/concepts/lifecycle.md +0 -0
  42. {engin-0.0.18 → engin-0.0.19}/docs/concepts/providers.md +0 -0
  43. {engin-0.0.18 → engin-0.0.19}/docs/getting-started.md +0 -0
  44. {engin-0.0.18 → engin-0.0.19}/docs/guides/fastapi-graph.png +0 -0
  45. {engin-0.0.18 → engin-0.0.19}/docs/index.md +0 -0
  46. {engin-0.0.18 → engin-0.0.19}/docs/js/readthedocs.js +0 -0
  47. {engin-0.0.18 → engin-0.0.19}/docs/overrides/main.html +0 -0
  48. {engin-0.0.18 → engin-0.0.19}/docs/reference.md +0 -0
  49. {engin-0.0.18 → engin-0.0.19}/examples/__init__.py +0 -0
  50. {engin-0.0.18 → engin-0.0.19}/examples/asgi/__init__.py +0 -0
  51. {engin-0.0.18 → engin-0.0.19}/examples/asgi/common/__init__.py +0 -0
  52. {engin-0.0.18 → engin-0.0.19}/examples/asgi/common/db/__init__.py +0 -0
  53. {engin-0.0.18 → engin-0.0.19}/examples/asgi/common/db/adapaters/__init__.py +0 -0
  54. {engin-0.0.18 → engin-0.0.19}/examples/asgi/common/db/adapaters/memory.py +0 -0
  55. {engin-0.0.18 → engin-0.0.19}/examples/asgi/common/db/block.py +0 -0
  56. {engin-0.0.18 → engin-0.0.19}/examples/asgi/common/db/ports.py +0 -0
  57. {engin-0.0.18 → engin-0.0.19}/examples/asgi/common/starlette/__init__.py +0 -0
  58. {engin-0.0.18 → engin-0.0.19}/examples/asgi/common/starlette/endpoint.py +0 -0
  59. {engin-0.0.18 → engin-0.0.19}/examples/asgi/features/__init__.py +0 -0
  60. {engin-0.0.18 → engin-0.0.19}/examples/asgi/features/cats/__init__.py +0 -0
  61. {engin-0.0.18 → engin-0.0.19}/examples/asgi/features/cats/api/__init__.py +0 -0
  62. {engin-0.0.18 → engin-0.0.19}/examples/asgi/features/cats/api/get.py +0 -0
  63. {engin-0.0.18 → engin-0.0.19}/examples/asgi/features/cats/api/post.py +0 -0
  64. {engin-0.0.18 → engin-0.0.19}/examples/asgi/features/cats/block.py +0 -0
  65. {engin-0.0.18 → engin-0.0.19}/examples/asgi/features/cats/domain.py +0 -0
  66. {engin-0.0.18 → engin-0.0.19}/examples/fastapi/__init__.py +0 -0
  67. {engin-0.0.18 → engin-0.0.19}/examples/fastapi/app.py +0 -0
  68. {engin-0.0.18 → engin-0.0.19}/examples/fastapi/routes/__init__.py +0 -0
  69. {engin-0.0.18 → engin-0.0.19}/examples/fastapi/routes/cats/__init__.py +0 -0
  70. {engin-0.0.18 → engin-0.0.19}/examples/fastapi/routes/cats/adapters/__init__.py +0 -0
  71. {engin-0.0.18 → engin-0.0.19}/examples/fastapi/routes/cats/adapters/repository.py +0 -0
  72. {engin-0.0.18 → engin-0.0.19}/examples/fastapi/routes/cats/block.py +0 -0
  73. {engin-0.0.18 → engin-0.0.19}/examples/fastapi/routes/cats/domain.py +0 -0
  74. {engin-0.0.18 → engin-0.0.19}/examples/fastapi/routes/cats/ports.py +0 -0
  75. {engin-0.0.18 → engin-0.0.19}/examples/simple/__init__.py +0 -0
  76. {engin-0.0.18 → engin-0.0.19}/examples/simple/main.py +0 -0
  77. {engin-0.0.18 → engin-0.0.19}/mkdocs.yaml +0 -0
  78. {engin-0.0.18 → engin-0.0.19}/src/engin/_cli/__init__.py +0 -0
  79. {engin-0.0.18 → engin-0.0.19}/src/engin/_cli/_common.py +0 -0
  80. {engin-0.0.18 → engin-0.0.19}/src/engin/_cli/_graph.html +0 -0
  81. {engin-0.0.18 → engin-0.0.19}/src/engin/_cli/_inspect.py +0 -0
  82. {engin-0.0.18 → engin-0.0.19}/src/engin/_engin.py +0 -0
  83. {engin-0.0.18 → engin-0.0.19}/src/engin/_graph.py +0 -0
  84. {engin-0.0.18 → engin-0.0.19}/src/engin/_introspect.py +0 -0
  85. {engin-0.0.18 → engin-0.0.19}/src/engin/_lifecycle.py +0 -0
  86. {engin-0.0.18 → engin-0.0.19}/src/engin/_option.py +0 -0
  87. {engin-0.0.18 → engin-0.0.19}/src/engin/_type_utils.py +0 -0
  88. {engin-0.0.18/src/engin/ext → engin-0.0.19/src/engin/extensions}/__init__.py +0 -0
  89. {engin-0.0.18/src/engin/ext → engin-0.0.19/src/engin/extensions}/asgi.py +0 -0
  90. {engin-0.0.18 → engin-0.0.19}/src/engin/py.typed +0 -0
  91. {engin-0.0.18 → engin-0.0.19}/tests/__init__.py +0 -0
  92. {engin-0.0.18 → engin-0.0.19}/tests/acceptance/__init__.py +0 -0
  93. {engin-0.0.18/tests/cli → engin-0.0.19/tests/benchmarks}/__init__.py +0 -0
  94. /engin-0.0.18/tests/conftest.py → /engin-0.0.19/tests/cli/__init__.py +0 -0
  95. {engin-0.0.18 → engin-0.0.19}/tests/test_graph.py +0 -0
  96. {engin-0.0.18 → engin-0.0.19}/tests/test_lifecycle.py +0 -0
  97. {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,7 +6,12 @@ on:
6
6
  jobs:
7
7
  check:
8
8
  name: python
9
- runs-on: ubuntu-latest
9
+
10
+ strategy:
11
+ matrix:
12
+ os: [ubuntu-latest, windows-latest, macos-latest]
13
+
14
+ runs-on: ${{ matrix.os }}
10
15
 
11
16
  permissions:
12
17
  id-token: write
@@ -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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: engin
3
- Version: 0.0.18
3
+ Version: 0.0.19
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/
@@ -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.ext.fastapi import FastAPIEngin
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.ext.fastapi import Inject
77
+ from engin.extensions.fastapi import Inject
78
78
  from fastapi import Depends
79
79
 
80
+
80
81
  async def database_session(
81
- database: Annotated[Database, Inject(Database)])
82
- ) -> AsyncIterable[Session]:
82
+ database: Annotated[Database, Inject(Database)])
83
+
84
+ ) -> AsyncIterable[Session]:
83
85
  with database.new_session() as session:
84
86
  yield session
85
- session.commit()
87
+ session.commit()
88
+
89
+ @ app.post("/{id}")
90
+ async
86
91
 
87
- @app.post("/{id}")
88
- async def add_item(session: Annotated[Session, Depends(database_session)]):
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.ext.fastapi import FastAPIEngin
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.ext.fastapi import FastAPIEngin
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
 
@@ -6,7 +6,7 @@ from starlette.responses import JSONResponse, Response
6
6
  from starlette.routing import Mount, Route
7
7
 
8
8
  from engin import Block, provide
9
- from engin.ext.asgi import ASGIType
9
+ from engin.extensions.asgi import ASGIType
10
10
 
11
11
 
12
12
  class AppConfig(BaseSettings):
@@ -3,7 +3,7 @@ import logging
3
3
  import uvicorn
4
4
 
5
5
  from engin import Supply
6
- from engin.ext.asgi import ASGIEngin
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.ext.fastapi import FastAPIEngin
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.ext.fastapi import Inject
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.18"
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 any previously assembled
183
- output for the existing Provider.
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) -> Iterable[Provide]:
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
- yield from (
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
- yield from root_providers
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
- has_dependency = param_key in self._assembled_outputs
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(Option):
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 f"{cls.__name__}"
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 _, method in inspect.getmembers(cls):
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 RuntimeError("Block option is not an instance of Provide or Invoke")
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.ext.asgi import ASGIEngin
15
+ from engin.extensions.asgi import ASGIEngin
16
16
 
17
17
  try:
18
- from engin.ext.fastapi import APIRouteDependency
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.return_type) is list
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.return_type == param.annotation
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.return_type)
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.return_type}'"
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
- if self._explicit_type is not None:
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 TypeId.from_type(self.return_type)
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
- @property
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
+ ]