engin 0.0.14__tar.gz → 0.0.16__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 (92) hide show
  1. {engin-0.0.14 → engin-0.0.16}/CHANGELOG.md +35 -4
  2. {engin-0.0.14 → engin-0.0.16}/PKG-INFO +1 -1
  3. engin-0.0.16/docs/concepts/lifecycle.md +100 -0
  4. {engin-0.0.14 → engin-0.0.16}/docs/concepts/providers.md +36 -23
  5. {engin-0.0.14 → engin-0.0.16}/examples/fastapi/main.py +1 -1
  6. {engin-0.0.14 → engin-0.0.16}/mkdocs.yaml +0 -1
  7. {engin-0.0.14 → engin-0.0.16}/pyproject.toml +1 -1
  8. {engin-0.0.14 → engin-0.0.16}/src/engin/_assembler.py +79 -28
  9. {engin-0.0.14 → engin-0.0.16}/src/engin/_block.py +23 -7
  10. engin-0.0.16/src/engin/_cli/_graph.html +78 -0
  11. {engin-0.0.14 → engin-0.0.16}/src/engin/_cli/_graph.py +28 -53
  12. {engin-0.0.14 → engin-0.0.16}/src/engin/_dependency.py +64 -18
  13. {engin-0.0.14 → engin-0.0.16}/src/engin/_engin.py +9 -5
  14. engin-0.0.16/src/engin/_exceptions.py +54 -0
  15. {engin-0.0.14 → engin-0.0.16}/src/engin/ext/asgi.py +3 -2
  16. {engin-0.0.14 → engin-0.0.16}/src/engin/ext/fastapi.py +3 -2
  17. {engin-0.0.14 → engin-0.0.16}/tests/acceptance/test_fastapi.py +36 -3
  18. {engin-0.0.14 → engin-0.0.16}/tests/test_assembler.py +55 -12
  19. {engin-0.0.14 → engin-0.0.16}/tests/test_block.py +9 -3
  20. {engin-0.0.14 → engin-0.0.16}/tests/test_dependencies.py +38 -0
  21. {engin-0.0.14 → engin-0.0.16}/uv.lock +218 -193
  22. engin-0.0.14/docs/concepts/lifecycle.md +0 -0
  23. engin-0.0.14/docs/guides/dependency_injection.md +0 -4
  24. engin-0.0.14/src/engin/_exceptions.py +0 -26
  25. {engin-0.0.14 → engin-0.0.16}/.github/workflows/check.yaml +0 -0
  26. {engin-0.0.14 → engin-0.0.16}/.github/workflows/publish.yaml +0 -0
  27. {engin-0.0.14 → engin-0.0.16}/.gitignore +0 -0
  28. {engin-0.0.14 → engin-0.0.16}/.readthedocs.yaml +0 -0
  29. {engin-0.0.14 → engin-0.0.16}/LICENSE +0 -0
  30. {engin-0.0.14 → engin-0.0.16}/README.md +0 -0
  31. {engin-0.0.14 → engin-0.0.16}/docs/concepts/engin.md +0 -0
  32. {engin-0.0.14 → engin-0.0.16}/docs/concepts/invocations.md +0 -0
  33. {engin-0.0.14 → engin-0.0.16}/docs/getting-started.md +0 -0
  34. {engin-0.0.14 → engin-0.0.16}/docs/guides/fastapi-graph.png +0 -0
  35. {engin-0.0.14 → engin-0.0.16}/docs/guides/fastapi.md +0 -0
  36. {engin-0.0.14 → engin-0.0.16}/docs/index.md +0 -0
  37. {engin-0.0.14 → engin-0.0.16}/docs/js/readthedocs.js +0 -0
  38. {engin-0.0.14 → engin-0.0.16}/docs/overrides/main.html +0 -0
  39. {engin-0.0.14 → engin-0.0.16}/docs/reference.md +0 -0
  40. {engin-0.0.14 → engin-0.0.16}/examples/__init__.py +0 -0
  41. {engin-0.0.14 → engin-0.0.16}/examples/asgi/__init__.py +0 -0
  42. {engin-0.0.14 → engin-0.0.16}/examples/asgi/app.py +0 -0
  43. {engin-0.0.14 → engin-0.0.16}/examples/asgi/common/__init__.py +0 -0
  44. {engin-0.0.14 → engin-0.0.16}/examples/asgi/common/db/__init__.py +0 -0
  45. {engin-0.0.14 → engin-0.0.16}/examples/asgi/common/db/adapaters/__init__.py +0 -0
  46. {engin-0.0.14 → engin-0.0.16}/examples/asgi/common/db/adapaters/memory.py +0 -0
  47. {engin-0.0.14 → engin-0.0.16}/examples/asgi/common/db/block.py +0 -0
  48. {engin-0.0.14 → engin-0.0.16}/examples/asgi/common/db/ports.py +0 -0
  49. {engin-0.0.14 → engin-0.0.16}/examples/asgi/common/starlette/__init__.py +0 -0
  50. {engin-0.0.14 → engin-0.0.16}/examples/asgi/common/starlette/endpoint.py +0 -0
  51. {engin-0.0.14 → engin-0.0.16}/examples/asgi/features/__init__.py +0 -0
  52. {engin-0.0.14 → engin-0.0.16}/examples/asgi/features/cats/__init__.py +0 -0
  53. {engin-0.0.14 → engin-0.0.16}/examples/asgi/features/cats/api/__init__.py +0 -0
  54. {engin-0.0.14 → engin-0.0.16}/examples/asgi/features/cats/api/get.py +0 -0
  55. {engin-0.0.14 → engin-0.0.16}/examples/asgi/features/cats/api/post.py +0 -0
  56. {engin-0.0.14 → engin-0.0.16}/examples/asgi/features/cats/block.py +0 -0
  57. {engin-0.0.14 → engin-0.0.16}/examples/asgi/features/cats/domain.py +0 -0
  58. {engin-0.0.14 → engin-0.0.16}/examples/asgi/main.py +0 -0
  59. {engin-0.0.14 → engin-0.0.16}/examples/fastapi/__init__.py +0 -0
  60. {engin-0.0.14 → engin-0.0.16}/examples/fastapi/app.py +0 -0
  61. {engin-0.0.14 → engin-0.0.16}/examples/fastapi/routes/__init__.py +0 -0
  62. {engin-0.0.14 → engin-0.0.16}/examples/fastapi/routes/cats/__init__.py +0 -0
  63. {engin-0.0.14 → engin-0.0.16}/examples/fastapi/routes/cats/adapters/__init__.py +0 -0
  64. {engin-0.0.14 → engin-0.0.16}/examples/fastapi/routes/cats/adapters/repository.py +0 -0
  65. {engin-0.0.14 → engin-0.0.16}/examples/fastapi/routes/cats/api.py +0 -0
  66. {engin-0.0.14 → engin-0.0.16}/examples/fastapi/routes/cats/block.py +0 -0
  67. {engin-0.0.14 → engin-0.0.16}/examples/fastapi/routes/cats/domain.py +0 -0
  68. {engin-0.0.14 → engin-0.0.16}/examples/fastapi/routes/cats/ports.py +0 -0
  69. {engin-0.0.14 → engin-0.0.16}/examples/simple/__init__.py +0 -0
  70. {engin-0.0.14 → engin-0.0.16}/examples/simple/main.py +0 -0
  71. {engin-0.0.14 → engin-0.0.16}/src/engin/__init__.py +0 -0
  72. {engin-0.0.14 → engin-0.0.16}/src/engin/_cli/__init__.py +0 -0
  73. {engin-0.0.14 → engin-0.0.16}/src/engin/_cli/_utils.py +0 -0
  74. {engin-0.0.14 → engin-0.0.16}/src/engin/_graph.py +0 -0
  75. {engin-0.0.14 → engin-0.0.16}/src/engin/_introspect.py +0 -0
  76. {engin-0.0.14 → engin-0.0.16}/src/engin/_lifecycle.py +0 -0
  77. {engin-0.0.14 → engin-0.0.16}/src/engin/_option.py +0 -0
  78. {engin-0.0.14 → engin-0.0.16}/src/engin/_type_utils.py +0 -0
  79. {engin-0.0.14 → engin-0.0.16}/src/engin/ext/__init__.py +0 -0
  80. {engin-0.0.14 → engin-0.0.16}/src/engin/py.typed +0 -0
  81. {engin-0.0.14 → engin-0.0.16}/tests/__init__.py +0 -0
  82. {engin-0.0.14 → engin-0.0.16}/tests/acceptance/__init__.py +0 -0
  83. {engin-0.0.14 → engin-0.0.16}/tests/acceptance/test_error_in_shutdown.py +0 -0
  84. {engin-0.0.14 → engin-0.0.16}/tests/acceptance/test_error_in_start_up.py +0 -0
  85. {engin-0.0.14 → engin-0.0.16}/tests/cli/__init__.py +0 -0
  86. {engin-0.0.14 → engin-0.0.16}/tests/cli/test_graph.py +0 -0
  87. {engin-0.0.14 → engin-0.0.16}/tests/conftest.py +0 -0
  88. {engin-0.0.14 → engin-0.0.16}/tests/deps.py +0 -0
  89. {engin-0.0.14 → engin-0.0.16}/tests/test_engin.py +0 -0
  90. {engin-0.0.14 → engin-0.0.16}/tests/test_graph.py +0 -0
  91. {engin-0.0.14 → engin-0.0.16}/tests/test_lifecycle.py +0 -0
  92. {engin-0.0.14 → engin-0.0.16}/tests/test_utils.py +0 -0
@@ -6,25 +6,56 @@ 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.16] - 2025-04-16
10
+
11
+ ### Added
12
+
13
+ - Preliminary support for scoped providers. Scoped providers are only accessible when
14
+ the assembler is in the matching scope, and the built output is only cached until the
15
+ assembler leaves the matching scope. This can be used for example to have request scoped
16
+ providers in a Web Server.
17
+
18
+ ### Changed
19
+
20
+ - Minor improvements to the work-in-progress dependency grapher.
21
+
22
+
23
+ ## [0.0.15] - 2025-03-25
24
+
25
+ ### Changed
26
+
27
+ - `Provide` & `Supply` will now raise an error if overriding an existing provider from the
28
+ same package. This is to prevent accidental overrides. Users can explicitly allow
29
+ overrides by specifying the `override` parameter when defining the provider
30
+ `Provide(..., override=True)` or `@provide(override=True)`.
31
+ - Lifecycle startup tasks will now timeout after 15 seconds and raise an error.
32
+ - Assembler's `get` method has been renamed to `build`.
33
+ - Supply's `type_hint` parameter has been renamed to `as_type`.
34
+
35
+ ### Fixed
36
+
37
+ - `Assembler` would occasionally fail to call all multiproviders due to inconsistent
38
+ ordering.
39
+
40
+
9
41
  ## [0.0.14] - 2025-03-23
10
42
 
11
43
  ### Added
12
44
 
13
45
  - `LifecycleHook` class to help build simple lifecycles with a start and stop call.
14
- - `TypeId` now attempts to introspect type aliases, this is experimental and currently on
15
- used to enrich error messages.
16
46
 
17
47
  ### Changed
18
48
 
19
49
  - `engin-graph` has been replaced by `engin graph`.
20
- - Engin now uses `typer` for an improved cli experience.
21
- - The package now has an extra `cli` which must be installed to use the developer cli.
50
+ - Engin now uses `typer` for an improved cli experience. Note the package now has an extra `cli` which must be installed to use the cli.
22
51
  - `Assembler.add(...)` does not error when adding already registered providers.
23
52
  - Use a more performant algorithm for inspecting frame stack.
24
53
 
25
54
  ### Fixed
26
55
 
27
56
  - `ASGIEngin` now properly surfaces startup errors.
57
+ - `Engin.run()` doing a double shutdown.
58
+
28
59
 
29
60
  ## [0.0.13] - 2025-03-22
30
61
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: engin
3
- Version: 0.0.14
3
+ Version: 0.0.16
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,100 @@
1
+ from contextlib import asynccontextmanager
2
+
3
+ # Lifecycle
4
+
5
+ Certain types of object naturally have some form of startup and shutdown behaviour
6
+ associated with them, these steps need to be tied the lifecycle of the application itself
7
+ in order to be useful. For example, a database connection manager might want to fill its
8
+ connection pool on startup, and gracefully release the connections on shutdown.
9
+
10
+ Doing this yourself can be tricky and is application dependent: most will not have any
11
+ special support for this and will expect you to manage your lifecycle concerns in your
12
+ entrypoint function, leading to unwieldy code in larger applications, whilst other
13
+ types application might expected you to translate the lifecyle tasks into something they
14
+ offer, e.g. an ASGI server would expect you to manage this via its lifespan. In both cases
15
+ you end up managing lifecycle in a completely different place to where you declare your
16
+ objects, which make the codebase more complicated to understand.
17
+
18
+ Luckily, engin makes declaring lifecycle tasks a breeze, and it can be done in the same
19
+ provider that build your object keeping your code nicely collocated.
20
+
21
+ ## The Lifecycle type
22
+
23
+ Engin automatically provides a special type called `Lifecycle` that can be used like any
24
+ other provided type. This type allows you to register lifecycle tasks with the Engin which
25
+ will automatically be run as part of your application lifecycle.
26
+
27
+ ## Registering lifecycle tasks
28
+
29
+ There are a few different ways to declare and register your lifecycle tasks, they all do
30
+ the same thing, so which one to use depends on whichever is easiest for your specific
31
+ lifecycle tasks.
32
+
33
+ ### 1. Existing context manager
34
+
35
+ If your type exposes a context manager interface to handle its lifecycle, registering it
36
+ is as easy as calling `lifecycle.append(...)`, this works for sync and async context
37
+ managers.
38
+
39
+ Let's look at an example using `httpx.AsyncClient`:
40
+
41
+ ```python
42
+ from engin import Lifecycle
43
+ from httpx import AsyncClient
44
+
45
+
46
+ def httpx_client(lifecycle: Lifecycle) -> AsyncClient:
47
+ client = AsyncClient()
48
+ lifecycle.append(client) # register the lifecycle tasks
49
+ return client
50
+ ```
51
+
52
+ ### 2. Explict startup & shutdown methods
53
+
54
+ If your type exposes meathods that must be called as part of the lifecycle, e.g. `start()`
55
+ & `stop()`, then `lifecycle.hook(on_start=..., on_stop=...)` is the way.
56
+
57
+ Let's look at an example using `piccolo.engine.PostgresEngin`:
58
+
59
+ ```python
60
+ from engin import Lifecycle
61
+ from piccolo.engine import PostgresEngine
62
+
63
+ def postgres_engine(lifecyle: Lifecycle) -> PostgresEngine:
64
+ db_engine = PostgresEngine(...) # fill in actual connection details
65
+
66
+ lifecyle.hook(
67
+ on_start=db_engine.start_connection_pool(),
68
+ on_stop=db_engine.close_connection_pool(),
69
+ )
70
+
71
+ return db_engine
72
+ ```
73
+
74
+ ### 3. Custom context managers
75
+
76
+ For more advanced use cases you can always define your own context manager.
77
+
78
+ In this example assume that `worker.run()` will not return to us when we await it, and
79
+ therefore we want to manage it as a task.
80
+
81
+
82
+ ```python
83
+ import asyncio
84
+ from engin import Lifecycle
85
+ from some_package import BlockingAsyncWorker
86
+
87
+ def blocking_worker(lifecycle: Lifecycle) -> BlockingWorker:
88
+ worker = BlockingAsyncWorker()
89
+
90
+ @asynccontextmanager
91
+ async def worker_lifecycle() -> AsyncIterator[None]:
92
+ task = asyncio.create_task(worker.run())
93
+ yield None
94
+ worker.stop()
95
+ del task
96
+
97
+ lifecycle.append(worker_lifecycle())
98
+
99
+ return worker
100
+ ```
@@ -17,17 +17,19 @@ class: `Provide`.
17
17
  ```python
18
18
  from engin import Engin, Provide
19
19
 
20
+
20
21
  # define our constructor
21
22
  def string_factory() -> str:
22
- return "hello"
23
+ return "hello"
24
+
23
25
 
24
26
  # register it as a provider with the Engin
25
27
  engin = Engin(Provide(string_factory))
26
28
 
27
29
  # construct the string
28
- a_string = await engin.assembler.get(str)
30
+ a_string = await engin.assembler.build(str)
29
31
 
30
- print(a_string) # hello
32
+ print(a_string) # hello
31
33
  ```
32
34
 
33
35
  Providers can be asynchronous as well, this factory function would work exactly the same
@@ -45,27 +47,31 @@ Providers that construct more interesting objects generally require their own pa
45
47
  ```python
46
48
  from engin import Engin, Provide
47
49
 
50
+
48
51
  class Greeter:
49
52
  def __init__(self, greeting: str) -> None:
50
53
  self._greeting = greeting
51
-
54
+
52
55
  def greet(self, name: str) -> None:
53
56
  print(f"{self._greeting}, {name}!")
54
-
57
+
58
+
55
59
  # define our constructors
56
60
  def string_factory() -> str:
57
- return "hello"
61
+ return "hello"
62
+
58
63
 
59
64
  def greeter_factory(greeting: str) -> Greeter:
60
65
  return Greeter(greeting=greeting)
61
66
 
67
+
62
68
  # register them as providers with the Engin
63
69
  engin = Engin(Provide(string_factory), Provide(greeter_factory))
64
70
 
65
71
  # construct the Greeter
66
- greeter = await engin.assembler.get(Greeter)
72
+ greeter = await engin.assembler.build(Greeter)
67
73
 
68
- greeter.greet("Bob") # hello, Bob!
74
+ greeter.greet("Bob") # hello, Bob!
69
75
  ```
70
76
 
71
77
 
@@ -81,19 +87,21 @@ from engin import Engin, Provide
81
87
 
82
88
  # define our constructors
83
89
  def string_factory() -> str:
84
- return "hello"
90
+ return "hello"
91
+
85
92
 
86
93
  def evil_factory() -> int:
87
94
  raise RuntimeError("I have ruined your plans")
88
95
 
96
+
89
97
  # register them as providers with the Engin
90
98
  engin = Engin(Provide(string_factory), Provide(evil_factory))
91
99
 
92
100
  # this will not raise an error
93
- await engin.assembler.get(str)
101
+ await engin.assembler.build(str)
94
102
 
95
103
  # this will raise an error
96
- await engin.assembler.get(int)
104
+ await engin.assembler.build(int)
97
105
  ```
98
106
 
99
107
 
@@ -109,20 +117,23 @@ To turn a factory into a multiprovider, simply return a list:
109
117
  ```python
110
118
  from engin import Engin, Provide
111
119
 
120
+
112
121
  # define our constructors
113
122
  def animal_names_factory() -> list[str]:
114
- return ["cat", "dog"]
123
+ return ["cat", "dog"]
124
+
115
125
 
116
126
  def other_animal_names_factory() -> list[str]:
117
- return ["horse", "cow"]
127
+ return ["horse", "cow"]
128
+
118
129
 
119
130
  # register them as providers with the Engin
120
131
  engin = Engin(Provide(animal_names_factory), Provide(other_animal_names_factory))
121
132
 
122
133
  # construct the list of strings
123
- animal_names = await engin.assembler.get(list[str])
134
+ animal_names = await engin.assembler.build(list[str])
124
135
 
125
- print(animal_names) # ["cat", "dog", "horse", "cow"]
136
+ print(animal_names) # ["cat", "dog", "horse", "cow"]
126
137
  ```
127
138
 
128
139
 
@@ -133,25 +144,28 @@ Providers of the same type can be discriminated using annotations.
133
144
  ```python
134
145
  from engin import Engin, Provide
135
146
  from typing import Annotated
136
-
147
+
148
+
137
149
  # define our constructors
138
150
  def greeting_factory() -> Annotated[str, "greeting"]:
139
- return "hello"
151
+ return "hello"
152
+
140
153
 
141
154
  def name_factory() -> Annotated[str, "name"]:
142
155
  return "Jelena"
143
156
 
157
+
144
158
  # register them as providers with the Engin
145
159
  engin = Engin(Provide(greeting_factory), Provide(name_factory))
146
160
 
147
161
  # this will return "hello"
148
- await engin.assembler.get(Annotated[str, "greeting"])
162
+ await engin.assembler.build(Annotated[str, "greeting"])
149
163
 
150
164
  # this will return "Jelena"
151
- await engin.assembler.get(Annotated[str, "name"])
165
+ await engin.assembler.build(Annotated[str, "name"])
152
166
 
153
167
  # N.B. this will raise an error!
154
- await engin.assembler.get(str)
168
+ await engin.assembler.build(str)
155
169
  ```
156
170
 
157
171
 
@@ -162,7 +176,6 @@ provided type is automatically inferred.
162
176
 
163
177
  For example the first example on this page could be rewritten as:
164
178
 
165
-
166
179
  ```python
167
180
  from engin import Engin, Supply
168
181
 
@@ -170,7 +183,7 @@ from engin import Engin, Supply
170
183
  engin = Engin(Supply("hello"))
171
184
 
172
185
  # construct the string
173
- a_string = await engin.assembler.get(str)
186
+ a_string = await engin.assembler.build(str)
174
187
 
175
- print(a_string) # hello
188
+ print(a_string) # hello
176
189
  ```
@@ -9,7 +9,7 @@ from examples.fastapi.routes.cats.block import CatBlock
9
9
 
10
10
  logging.basicConfig(level=logging.DEBUG)
11
11
 
12
- app = FastAPIEngin(AppBlock(), CatBlock(), Supply(AppConfig(debug=True)))
12
+ app = FastAPIEngin(AppBlock(), CatBlock(), Supply(AppConfig(debug=True), override=True))
13
13
 
14
14
 
15
15
  if __name__ == "__main__":
@@ -34,7 +34,6 @@ nav:
34
34
  - Invocations: "concepts/invocations.md"
35
35
  - Lifecycle: "concepts/lifecycle.md"
36
36
  - Guides:
37
- - Dependency Injection: "guides/dependency_injection.md"
38
37
  - FastAPI: "guides/fastapi.md"
39
38
  - Reference: "reference.md"
40
39
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "engin"
3
- version = "0.0.14"
3
+ version = "0.0.16"
4
4
  description = "An async-first modular application framework"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -1,18 +1,27 @@
1
1
  import asyncio
2
2
  import logging
3
3
  from collections import defaultdict
4
- from collections.abc import Collection, Iterable
4
+ from collections.abc import Iterable
5
+ from contextvars import ContextVar
5
6
  from dataclasses import dataclass
6
7
  from inspect import BoundArguments, Signature
8
+ from types import TracebackType
7
9
  from typing import Any, Generic, TypeVar, cast
8
10
 
9
11
  from engin._dependency import Dependency, Provide, Supply
10
- from engin._exceptions import ProviderError
12
+ from engin._exceptions import NotInScopeError, ProviderError
11
13
  from engin._type_utils import TypeId
12
14
 
13
15
  LOG = logging.getLogger("engin")
14
16
 
15
17
  T = TypeVar("T")
18
+ _SCOPE: ContextVar[list[str] | None] = ContextVar("_SCOPE", default=None)
19
+
20
+
21
+ def _get_scope() -> list[str]:
22
+ if _SCOPE.get() is None:
23
+ _SCOPE.set([])
24
+ return cast("list[str]", _SCOPE.get())
16
25
 
17
26
 
18
27
  @dataclass(slots=True, kw_only=True, frozen=True)
@@ -39,7 +48,7 @@ class Assembler:
39
48
  A container for Providers that is responsible for building provided types.
40
49
 
41
50
  The Assembler acts as a cache for previously built types, meaning repeat calls
42
- to `get` will produce the same value.
51
+ to `build` will produce the same value.
43
52
 
44
53
  Examples:
45
54
  ```python
@@ -47,7 +56,7 @@ class Assembler:
47
56
  return "foo"
48
57
 
49
58
  a = Assembler([Provide(build_str)])
50
- await a.get(str)
59
+ await a.build(str)
51
60
  ```
52
61
  """
53
62
 
@@ -85,17 +94,15 @@ class Assembler:
85
94
  bound_args=await self._bind_arguments(dependency.signature),
86
95
  )
87
96
 
88
- async def get(self, type_: type[T]) -> T:
97
+ async def build(self, type_: type[T]) -> T:
89
98
  """
90
- Return the constructed value for the given type.
99
+ Build the type from Assembler's factories.
91
100
 
92
- This method assembles the required Providers and constructs their corresponding
93
- values.
94
-
95
- If the
101
+ If the type has been built previously the value will be cached and will return the
102
+ same instance.
96
103
 
97
104
  Args:
98
- type_: the type of the desired value.
105
+ type_: the type of the desired value to build.
99
106
 
100
107
  Raises:
101
108
  LookupError: When no provider is found for the given type.
@@ -114,6 +121,8 @@ class Assembler:
114
121
 
115
122
  out = []
116
123
  for provider in self._multiproviders[type_id]:
124
+ if provider.scope and provider.scope not in _get_scope():
125
+ raise NotInScopeError(provider=provider, scope_stack=_get_scope())
117
126
  assembled_dependency = await self.assemble(provider)
118
127
  try:
119
128
  out.extend(await assembled_dependency())
@@ -129,12 +138,16 @@ class Assembler:
129
138
  if type_id not in self._providers:
130
139
  raise LookupError(f"no provider found for target type id '{type_id}'")
131
140
 
132
- assembled_dependency = await self.assemble(self._providers[type_id])
141
+ provider = self._providers[type_id]
142
+ if provider.scope and provider.scope not in _get_scope():
143
+ raise NotInScopeError(provider=provider, scope_stack=_get_scope())
144
+
145
+ assembled_dependency = await self.assemble(provider)
133
146
  try:
134
147
  value = await assembled_dependency()
135
148
  except Exception as err:
136
149
  raise ProviderError(
137
- provider=self._providers[type_id],
150
+ provider=provider,
138
151
  error_type=type(err),
139
152
  error_message=str(err),
140
153
  ) from err
@@ -180,31 +193,45 @@ class Assembler:
180
193
  del self._assembled_outputs[type_id]
181
194
  self._providers[type_id] = provider
182
195
 
183
- def _resolve_providers(self, type_id: TypeId) -> Collection[Provide]:
196
+ def scope(self, scope: str) -> "_ScopeContextManager":
197
+ return _ScopeContextManager(scope=scope, assembler=self)
198
+
199
+ def _exit_scope(self, scope: str) -> None:
200
+ for type_id, provider in self._providers.items():
201
+ if provider.scope == scope:
202
+ self._assembled_outputs.pop(type_id, None)
203
+
204
+ def _resolve_providers(self, type_id: TypeId) -> Iterable[Provide]:
205
+ """
206
+ Resolves the chain of providers required to satisfy the provider of a given type.
207
+ Ordering of the return value is very important!
208
+
209
+ # TODO: performance optimisation, do not recurse for already satisfied providers?
210
+ """
184
211
  if type_id.multi:
185
- providers = self._multiproviders.get(type_id)
212
+ root_providers = self._multiproviders.get(type_id)
186
213
  else:
187
- providers = [provider] if (provider := self._providers.get(type_id)) else None
188
- if not providers:
214
+ root_providers = [provider] if (provider := self._providers.get(type_id)) else None
215
+
216
+ if not root_providers:
189
217
  if type_id.multi:
190
218
  LOG.warning(f"no provider for '{type_id}' defaulting to empty list")
191
- providers = [(Supply([], type_hint=list[type_id.type]))] # type: ignore[name-defined]
219
+ root_providers = [(Supply([], as_type=list[type_id.type]))] # type: ignore[name-defined]
192
220
  # store default to prevent the warning appearing multiple times
193
- self._multiproviders[type_id] = providers
221
+ self._multiproviders[type_id] = root_providers
194
222
  else:
195
223
  available = sorted(str(k) for k in self._providers)
196
224
  msg = f"Missing Provider for type '{type_id}', available: {available}"
197
225
  raise LookupError(msg)
198
226
 
199
- required_providers: list[Provide[Any]] = []
200
- for provider in providers:
201
- required_providers.extend(
202
- provider
203
- for provider_param in provider.parameter_types
204
- for provider in self._resolve_providers(provider_param)
205
- )
206
-
207
- return {*required_providers, *providers}
227
+ # providers that must be satisfied to satisfy the root level providers
228
+ yield from (
229
+ child_provider
230
+ for root_provider in root_providers
231
+ for root_provider_param in root_provider.parameter_types
232
+ for child_provider in self._resolve_providers(root_provider_param)
233
+ )
234
+ yield from root_providers
208
235
 
209
236
  async def _satisfy(self, target: TypeId) -> None:
210
237
  for provider in self._resolve_providers(target):
@@ -247,3 +274,27 @@ class Assembler:
247
274
  kwargs[param.name] = val
248
275
 
249
276
  return signature.bind(*args, **kwargs)
277
+
278
+
279
+ class _ScopeContextManager:
280
+ def __init__(self, scope: str, assembler: Assembler) -> None:
281
+ self._scope = scope
282
+ self._assembler = assembler
283
+
284
+ def __enter__(self) -> Assembler:
285
+ _get_scope().append(self._scope)
286
+ return self._assembler
287
+
288
+ def __exit__(
289
+ self,
290
+ exc_type: type[BaseException] | None,
291
+ exc_value: BaseException | None,
292
+ traceback: TracebackType | None,
293
+ /,
294
+ ) -> None:
295
+ popped = _get_scope().pop()
296
+ if popped != self._scope:
297
+ raise RuntimeError(
298
+ f"Exited scope '{popped}' is not the expected scope '{self._scope}'"
299
+ )
300
+ self._assembler._exit_scope(self._scope)
@@ -1,5 +1,5 @@
1
1
  import inspect
2
- from collections.abc import Iterable, Sequence
2
+ from collections.abc import Callable, Iterable, Sequence
3
3
  from itertools import chain
4
4
  from typing import TYPE_CHECKING, ClassVar
5
5
 
@@ -10,20 +10,36 @@ if TYPE_CHECKING:
10
10
  from engin._engin import Engin
11
11
 
12
12
 
13
- def provide(func: Func) -> Func:
13
+ def provide(
14
+ func_: Func | None = None, *, scope: str | None = None, override: bool = False
15
+ ) -> Func | Callable[[Func], Func]:
14
16
  """
15
17
  A decorator for defining a Provider in a Block.
16
18
  """
17
- func._opt = Provide(func) # type: ignore[attr-defined]
18
- return func
19
19
 
20
+ def _inner(func: Func) -> Func:
21
+ func._opt = Provide(func, override=override, scope=scope) # type: ignore[attr-defined]
22
+ return func
20
23
 
21
- def invoke(func: Func) -> Func:
24
+ if func_ is None:
25
+ return _inner
26
+ else:
27
+ return _inner(func_)
28
+
29
+
30
+ def invoke(func_: Func | None = None) -> Func | Callable[[Func], Func]:
22
31
  """
23
32
  A decorator for defining an Invocation in a Block.
24
33
  """
25
- func._opt = Invoke(func) # type: ignore[attr-defined]
26
- return func
34
+
35
+ def _inner(func: Func) -> Func:
36
+ func._opt = Invoke(func) # type: ignore[attr-defined]
37
+ return func
38
+
39
+ if func_ is None:
40
+ return _inner
41
+ else:
42
+ return _inner(func_)
27
43
 
28
44
 
29
45
  class Block(Option):
@@ -0,0 +1,78 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <style>
4
+ #mermaid-container {
5
+ width: 100%;
6
+ height: 100%;
7
+ overflow: auto; /* Enables scrolling */
8
+ border: 1px solid #ddd;
9
+ cursor: grab;
10
+ position: relative;
11
+ white-space: nowrap; /* Prevents wrapping */
12
+ }
13
+
14
+ #mermaid-content {
15
+ width: max-content; /* Ensures content can expand */
16
+ height: max-content;
17
+ }
18
+ </style>
19
+ <script type="module">
20
+ import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
21
+ let config = { flowchart: { useMaxWidth: false, htmlLabels: true, defaultRenderer: "elk" } };
22
+ mermaid.initialize(config);
23
+
24
+ // Drag-to-Move Functionality
25
+ const container = document.getElementById("mermaid-container");
26
+
27
+ let isDragging = false;
28
+ let startX, startY, scrollLeft, scrollTop;
29
+
30
+ container.addEventListener("pointerdown", (e) => {
31
+ isDragging = true;
32
+ startX = e.clientX;
33
+ startY = e.clientY;
34
+ scrollLeft = container.scrollLeft;
35
+ scrollTop = container.scrollTop;
36
+ container.style.cursor = "grabbing";
37
+ });
38
+
39
+ container.addEventListener("pointermove", (e) => {
40
+ if (!isDragging) return;
41
+ const x = e.clientX - startX;
42
+ const y = e.clientY - startY;
43
+ container.scrollLeft = scrollLeft - x;
44
+ container.scrollTop = scrollTop - y;
45
+ });
46
+
47
+ container.addEventListener("pointerup", () => {
48
+ isDragging = false;
49
+ container.style.cursor = "grab";
50
+ });
51
+
52
+ container.addEventListener("pointerleave", () => {
53
+ isDragging = false;
54
+ container.style.cursor = "grab";
55
+ });
56
+ </script>
57
+ <body>
58
+ <div style="border-style:outset">
59
+ <p>LEGEND</p>
60
+ <pre class="mermaid" id="legend">
61
+ graph LR
62
+ %%LEGEND%%
63
+ classDef b0 fill:#7fc97f;
64
+ classDef external stroke-dasharray: 5 5;
65
+ </pre>
66
+ </div>
67
+ <div id="mermaid-container" style="width: 100%; overflow-x: auto; border: 1px solid #ddd; cursor: grab; position: relative;">
68
+ <div id="mermaid-content" style="width: max-content; height: max-content;">
69
+ <pre class="mermaid" id="graph">
70
+ %%{init: {"flowchart": {"defaultRenderer": "elk"}} }%%
71
+ graph LR
72
+ %%DATA%%
73
+ classDef external stroke-dasharray: 5 5;
74
+ </pre>
75
+ </div>
76
+ </div>
77
+ </body>
78
+ </html>