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.
Files changed (98) hide show
  1. engin-0.0.19/.github/workflows/benchmark.yaml +38 -0
  2. {engin-0.0.17 → engin-0.0.19}/.github/workflows/check.yaml +6 -1
  3. {engin-0.0.17 → engin-0.0.19}/CHANGELOG.md +27 -0
  4. {engin-0.0.17 → engin-0.0.19}/PKG-INFO +1 -1
  5. engin-0.0.19/docs/concepts/block.md +82 -0
  6. {engin-0.0.17 → engin-0.0.19}/docs/guides/fastapi.md +21 -15
  7. {engin-0.0.17 → engin-0.0.19}/examples/asgi/app.py +1 -1
  8. {engin-0.0.17 → engin-0.0.19}/examples/asgi/main.py +1 -1
  9. {engin-0.0.17 → engin-0.0.19}/examples/fastapi/main.py +1 -1
  10. {engin-0.0.17 → engin-0.0.19}/examples/fastapi/routes/cats/api.py +1 -1
  11. {engin-0.0.17 → engin-0.0.19}/pyproject.toml +28 -3
  12. {engin-0.0.17 → engin-0.0.19}/src/engin/__init__.py +0 -4
  13. {engin-0.0.17 → engin-0.0.19}/src/engin/_assembler.py +30 -18
  14. {engin-0.0.17 → engin-0.0.19}/src/engin/_block.py +16 -4
  15. {engin-0.0.17 → engin-0.0.19}/src/engin/_cli/__init__.py +11 -0
  16. engin-0.0.19/src/engin/_cli/_common.py +51 -0
  17. {engin-0.0.17 → engin-0.0.19}/src/engin/_cli/_graph.py +6 -37
  18. engin-0.0.19/src/engin/_cli/_inspect.py +94 -0
  19. {engin-0.0.17 → engin-0.0.19}/src/engin/_dependency.py +24 -20
  20. engin-0.0.17/src/engin/_exceptions.py → engin-0.0.19/src/engin/exceptions.py +27 -1
  21. {engin-0.0.17/src/engin/ext → engin-0.0.19/src/engin/extensions}/fastapi.py +1 -1
  22. {engin-0.0.17 → engin-0.0.19}/tests/acceptance/test_error_in_shutdown.py +1 -1
  23. {engin-0.0.17 → engin-0.0.19}/tests/acceptance/test_error_in_start_up.py +1 -1
  24. {engin-0.0.17 → engin-0.0.19}/tests/acceptance/test_fastapi.py +2 -2
  25. engin-0.0.19/tests/benchmarks/conftest.py +21 -0
  26. engin-0.0.19/tests/benchmarks/test_bench_assembler.py +114 -0
  27. {engin-0.0.17 → engin-0.0.19}/tests/cli/test_graph.py +24 -3
  28. engin-0.0.19/tests/cli/test_inspect.py +16 -0
  29. engin-0.0.19/tests/conftest.py +0 -0
  30. {engin-0.0.17 → engin-0.0.19}/tests/deps.py +8 -1
  31. {engin-0.0.17 → engin-0.0.19}/tests/test_assembler.py +28 -13
  32. engin-0.0.19/tests/test_block.py +54 -0
  33. {engin-0.0.17 → engin-0.0.19}/tests/test_dependencies.py +11 -15
  34. {engin-0.0.17 → engin-0.0.19}/tests/test_engin.py +2 -1
  35. {engin-0.0.17 → engin-0.0.19}/uv.lock +64 -40
  36. engin-0.0.17/src/engin/_cli/_utils.py +0 -18
  37. engin-0.0.17/tests/test_block.py +0 -25
  38. {engin-0.0.17 → engin-0.0.19}/.github/workflows/publish.yaml +0 -0
  39. {engin-0.0.17 → engin-0.0.19}/.gitignore +0 -0
  40. {engin-0.0.17 → engin-0.0.19}/.readthedocs.yaml +0 -0
  41. {engin-0.0.17 → engin-0.0.19}/LICENSE +0 -0
  42. {engin-0.0.17 → engin-0.0.19}/README.md +0 -0
  43. {engin-0.0.17 → engin-0.0.19}/docs/concepts/engin.md +0 -0
  44. {engin-0.0.17 → engin-0.0.19}/docs/concepts/invocations.md +0 -0
  45. {engin-0.0.17 → engin-0.0.19}/docs/concepts/lifecycle.md +0 -0
  46. {engin-0.0.17 → engin-0.0.19}/docs/concepts/providers.md +0 -0
  47. {engin-0.0.17 → engin-0.0.19}/docs/getting-started.md +0 -0
  48. {engin-0.0.17 → engin-0.0.19}/docs/guides/fastapi-graph.png +0 -0
  49. {engin-0.0.17 → engin-0.0.19}/docs/index.md +0 -0
  50. {engin-0.0.17 → engin-0.0.19}/docs/js/readthedocs.js +0 -0
  51. {engin-0.0.17 → engin-0.0.19}/docs/overrides/main.html +0 -0
  52. {engin-0.0.17 → engin-0.0.19}/docs/reference.md +0 -0
  53. {engin-0.0.17 → engin-0.0.19}/examples/__init__.py +0 -0
  54. {engin-0.0.17 → engin-0.0.19}/examples/asgi/__init__.py +0 -0
  55. {engin-0.0.17 → engin-0.0.19}/examples/asgi/common/__init__.py +0 -0
  56. {engin-0.0.17 → engin-0.0.19}/examples/asgi/common/db/__init__.py +0 -0
  57. {engin-0.0.17 → engin-0.0.19}/examples/asgi/common/db/adapaters/__init__.py +0 -0
  58. {engin-0.0.17 → engin-0.0.19}/examples/asgi/common/db/adapaters/memory.py +0 -0
  59. {engin-0.0.17 → engin-0.0.19}/examples/asgi/common/db/block.py +0 -0
  60. {engin-0.0.17 → engin-0.0.19}/examples/asgi/common/db/ports.py +0 -0
  61. {engin-0.0.17 → engin-0.0.19}/examples/asgi/common/starlette/__init__.py +0 -0
  62. {engin-0.0.17 → engin-0.0.19}/examples/asgi/common/starlette/endpoint.py +0 -0
  63. {engin-0.0.17 → engin-0.0.19}/examples/asgi/features/__init__.py +0 -0
  64. {engin-0.0.17 → engin-0.0.19}/examples/asgi/features/cats/__init__.py +0 -0
  65. {engin-0.0.17 → engin-0.0.19}/examples/asgi/features/cats/api/__init__.py +0 -0
  66. {engin-0.0.17 → engin-0.0.19}/examples/asgi/features/cats/api/get.py +0 -0
  67. {engin-0.0.17 → engin-0.0.19}/examples/asgi/features/cats/api/post.py +0 -0
  68. {engin-0.0.17 → engin-0.0.19}/examples/asgi/features/cats/block.py +0 -0
  69. {engin-0.0.17 → engin-0.0.19}/examples/asgi/features/cats/domain.py +0 -0
  70. {engin-0.0.17 → engin-0.0.19}/examples/fastapi/__init__.py +0 -0
  71. {engin-0.0.17 → engin-0.0.19}/examples/fastapi/app.py +0 -0
  72. {engin-0.0.17 → engin-0.0.19}/examples/fastapi/routes/__init__.py +0 -0
  73. {engin-0.0.17 → engin-0.0.19}/examples/fastapi/routes/cats/__init__.py +0 -0
  74. {engin-0.0.17 → engin-0.0.19}/examples/fastapi/routes/cats/adapters/__init__.py +0 -0
  75. {engin-0.0.17 → engin-0.0.19}/examples/fastapi/routes/cats/adapters/repository.py +0 -0
  76. {engin-0.0.17 → engin-0.0.19}/examples/fastapi/routes/cats/block.py +0 -0
  77. {engin-0.0.17 → engin-0.0.19}/examples/fastapi/routes/cats/domain.py +0 -0
  78. {engin-0.0.17 → engin-0.0.19}/examples/fastapi/routes/cats/ports.py +0 -0
  79. {engin-0.0.17 → engin-0.0.19}/examples/simple/__init__.py +0 -0
  80. {engin-0.0.17 → engin-0.0.19}/examples/simple/main.py +0 -0
  81. {engin-0.0.17 → engin-0.0.19}/mkdocs.yaml +0 -0
  82. {engin-0.0.17 → engin-0.0.19}/src/engin/_cli/_graph.html +0 -0
  83. {engin-0.0.17 → engin-0.0.19}/src/engin/_engin.py +0 -0
  84. {engin-0.0.17 → engin-0.0.19}/src/engin/_graph.py +0 -0
  85. {engin-0.0.17 → engin-0.0.19}/src/engin/_introspect.py +0 -0
  86. {engin-0.0.17 → engin-0.0.19}/src/engin/_lifecycle.py +0 -0
  87. {engin-0.0.17 → engin-0.0.19}/src/engin/_option.py +0 -0
  88. {engin-0.0.17 → engin-0.0.19}/src/engin/_type_utils.py +0 -0
  89. {engin-0.0.17/src/engin/ext → engin-0.0.19/src/engin/extensions}/__init__.py +0 -0
  90. {engin-0.0.17/src/engin/ext → engin-0.0.19/src/engin/extensions}/asgi.py +0 -0
  91. {engin-0.0.17 → engin-0.0.19}/src/engin/py.typed +0 -0
  92. {engin-0.0.17 → engin-0.0.19}/tests/__init__.py +0 -0
  93. {engin-0.0.17 → engin-0.0.19}/tests/acceptance/__init__.py +0 -0
  94. {engin-0.0.17/tests/cli → engin-0.0.19/tests/benchmarks}/__init__.py +0 -0
  95. /engin-0.0.17/tests/conftest.py → /engin-0.0.19/tests/cli/__init__.py +0 -0
  96. {engin-0.0.17 → engin-0.0.19}/tests/test_graph.py +0 -0
  97. {engin-0.0.17 → engin-0.0.19}/tests/test_lifecycle.py +0 -0
  98. {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,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,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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: engin
3
- Version: 0.0.17
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.17"
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 any previously assembled
178
- 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.
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) -> Iterable[Provide]:
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
- yield from (
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
- 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
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
- has_dependency = param_key in self._assembled_outputs
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(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
+ )
@@ -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