engin 0.0.14__tar.gz → 0.0.15__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 (90) hide show
  1. {engin-0.0.14 → engin-0.0.15}/CHANGELOG.md +21 -4
  2. {engin-0.0.14 → engin-0.0.15}/PKG-INFO +1 -1
  3. engin-0.0.15/docs/concepts/lifecycle.md +100 -0
  4. {engin-0.0.14 → engin-0.0.15}/docs/concepts/providers.md +36 -23
  5. {engin-0.0.14 → engin-0.0.15}/mkdocs.yaml +0 -1
  6. {engin-0.0.14 → engin-0.0.15}/pyproject.toml +1 -1
  7. {engin-0.0.14 → engin-0.0.15}/src/engin/_assembler.py +29 -25
  8. {engin-0.0.14 → engin-0.0.15}/src/engin/_block.py +23 -7
  9. {engin-0.0.14 → engin-0.0.15}/src/engin/_dependency.py +56 -18
  10. {engin-0.0.14 → engin-0.0.15}/src/engin/_engin.py +9 -5
  11. {engin-0.0.14 → engin-0.0.15}/src/engin/ext/asgi.py +1 -1
  12. {engin-0.0.14 → engin-0.0.15}/src/engin/ext/fastapi.py +3 -2
  13. {engin-0.0.14 → engin-0.0.15}/tests/test_assembler.py +11 -11
  14. {engin-0.0.14 → engin-0.0.15}/tests/test_block.py +9 -3
  15. {engin-0.0.14 → engin-0.0.15}/tests/test_dependencies.py +38 -0
  16. {engin-0.0.14 → engin-0.0.15}/uv.lock +13 -13
  17. engin-0.0.14/docs/concepts/lifecycle.md +0 -0
  18. engin-0.0.14/docs/guides/dependency_injection.md +0 -4
  19. {engin-0.0.14 → engin-0.0.15}/.github/workflows/check.yaml +0 -0
  20. {engin-0.0.14 → engin-0.0.15}/.github/workflows/publish.yaml +0 -0
  21. {engin-0.0.14 → engin-0.0.15}/.gitignore +0 -0
  22. {engin-0.0.14 → engin-0.0.15}/.readthedocs.yaml +0 -0
  23. {engin-0.0.14 → engin-0.0.15}/LICENSE +0 -0
  24. {engin-0.0.14 → engin-0.0.15}/README.md +0 -0
  25. {engin-0.0.14 → engin-0.0.15}/docs/concepts/engin.md +0 -0
  26. {engin-0.0.14 → engin-0.0.15}/docs/concepts/invocations.md +0 -0
  27. {engin-0.0.14 → engin-0.0.15}/docs/getting-started.md +0 -0
  28. {engin-0.0.14 → engin-0.0.15}/docs/guides/fastapi-graph.png +0 -0
  29. {engin-0.0.14 → engin-0.0.15}/docs/guides/fastapi.md +0 -0
  30. {engin-0.0.14 → engin-0.0.15}/docs/index.md +0 -0
  31. {engin-0.0.14 → engin-0.0.15}/docs/js/readthedocs.js +0 -0
  32. {engin-0.0.14 → engin-0.0.15}/docs/overrides/main.html +0 -0
  33. {engin-0.0.14 → engin-0.0.15}/docs/reference.md +0 -0
  34. {engin-0.0.14 → engin-0.0.15}/examples/__init__.py +0 -0
  35. {engin-0.0.14 → engin-0.0.15}/examples/asgi/__init__.py +0 -0
  36. {engin-0.0.14 → engin-0.0.15}/examples/asgi/app.py +0 -0
  37. {engin-0.0.14 → engin-0.0.15}/examples/asgi/common/__init__.py +0 -0
  38. {engin-0.0.14 → engin-0.0.15}/examples/asgi/common/db/__init__.py +0 -0
  39. {engin-0.0.14 → engin-0.0.15}/examples/asgi/common/db/adapaters/__init__.py +0 -0
  40. {engin-0.0.14 → engin-0.0.15}/examples/asgi/common/db/adapaters/memory.py +0 -0
  41. {engin-0.0.14 → engin-0.0.15}/examples/asgi/common/db/block.py +0 -0
  42. {engin-0.0.14 → engin-0.0.15}/examples/asgi/common/db/ports.py +0 -0
  43. {engin-0.0.14 → engin-0.0.15}/examples/asgi/common/starlette/__init__.py +0 -0
  44. {engin-0.0.14 → engin-0.0.15}/examples/asgi/common/starlette/endpoint.py +0 -0
  45. {engin-0.0.14 → engin-0.0.15}/examples/asgi/features/__init__.py +0 -0
  46. {engin-0.0.14 → engin-0.0.15}/examples/asgi/features/cats/__init__.py +0 -0
  47. {engin-0.0.14 → engin-0.0.15}/examples/asgi/features/cats/api/__init__.py +0 -0
  48. {engin-0.0.14 → engin-0.0.15}/examples/asgi/features/cats/api/get.py +0 -0
  49. {engin-0.0.14 → engin-0.0.15}/examples/asgi/features/cats/api/post.py +0 -0
  50. {engin-0.0.14 → engin-0.0.15}/examples/asgi/features/cats/block.py +0 -0
  51. {engin-0.0.14 → engin-0.0.15}/examples/asgi/features/cats/domain.py +0 -0
  52. {engin-0.0.14 → engin-0.0.15}/examples/asgi/main.py +0 -0
  53. {engin-0.0.14 → engin-0.0.15}/examples/fastapi/__init__.py +0 -0
  54. {engin-0.0.14 → engin-0.0.15}/examples/fastapi/app.py +0 -0
  55. {engin-0.0.14 → engin-0.0.15}/examples/fastapi/main.py +0 -0
  56. {engin-0.0.14 → engin-0.0.15}/examples/fastapi/routes/__init__.py +0 -0
  57. {engin-0.0.14 → engin-0.0.15}/examples/fastapi/routes/cats/__init__.py +0 -0
  58. {engin-0.0.14 → engin-0.0.15}/examples/fastapi/routes/cats/adapters/__init__.py +0 -0
  59. {engin-0.0.14 → engin-0.0.15}/examples/fastapi/routes/cats/adapters/repository.py +0 -0
  60. {engin-0.0.14 → engin-0.0.15}/examples/fastapi/routes/cats/api.py +0 -0
  61. {engin-0.0.14 → engin-0.0.15}/examples/fastapi/routes/cats/block.py +0 -0
  62. {engin-0.0.14 → engin-0.0.15}/examples/fastapi/routes/cats/domain.py +0 -0
  63. {engin-0.0.14 → engin-0.0.15}/examples/fastapi/routes/cats/ports.py +0 -0
  64. {engin-0.0.14 → engin-0.0.15}/examples/simple/__init__.py +0 -0
  65. {engin-0.0.14 → engin-0.0.15}/examples/simple/main.py +0 -0
  66. {engin-0.0.14 → engin-0.0.15}/src/engin/__init__.py +0 -0
  67. {engin-0.0.14 → engin-0.0.15}/src/engin/_cli/__init__.py +0 -0
  68. {engin-0.0.14 → engin-0.0.15}/src/engin/_cli/_graph.py +0 -0
  69. {engin-0.0.14 → engin-0.0.15}/src/engin/_cli/_utils.py +0 -0
  70. {engin-0.0.14 → engin-0.0.15}/src/engin/_exceptions.py +0 -0
  71. {engin-0.0.14 → engin-0.0.15}/src/engin/_graph.py +0 -0
  72. {engin-0.0.14 → engin-0.0.15}/src/engin/_introspect.py +0 -0
  73. {engin-0.0.14 → engin-0.0.15}/src/engin/_lifecycle.py +0 -0
  74. {engin-0.0.14 → engin-0.0.15}/src/engin/_option.py +0 -0
  75. {engin-0.0.14 → engin-0.0.15}/src/engin/_type_utils.py +0 -0
  76. {engin-0.0.14 → engin-0.0.15}/src/engin/ext/__init__.py +0 -0
  77. {engin-0.0.14 → engin-0.0.15}/src/engin/py.typed +0 -0
  78. {engin-0.0.14 → engin-0.0.15}/tests/__init__.py +0 -0
  79. {engin-0.0.14 → engin-0.0.15}/tests/acceptance/__init__.py +0 -0
  80. {engin-0.0.14 → engin-0.0.15}/tests/acceptance/test_error_in_shutdown.py +0 -0
  81. {engin-0.0.14 → engin-0.0.15}/tests/acceptance/test_error_in_start_up.py +0 -0
  82. {engin-0.0.14 → engin-0.0.15}/tests/acceptance/test_fastapi.py +0 -0
  83. {engin-0.0.14 → engin-0.0.15}/tests/cli/__init__.py +0 -0
  84. {engin-0.0.14 → engin-0.0.15}/tests/cli/test_graph.py +0 -0
  85. {engin-0.0.14 → engin-0.0.15}/tests/conftest.py +0 -0
  86. {engin-0.0.14 → engin-0.0.15}/tests/deps.py +0 -0
  87. {engin-0.0.14 → engin-0.0.15}/tests/test_engin.py +0 -0
  88. {engin-0.0.14 → engin-0.0.15}/tests/test_graph.py +0 -0
  89. {engin-0.0.14 → engin-0.0.15}/tests/test_lifecycle.py +0 -0
  90. {engin-0.0.14 → engin-0.0.15}/tests/test_utils.py +0 -0
@@ -6,25 +6,42 @@ 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.15] - 2025-03-25
10
+
11
+ ### Changed
12
+
13
+ - `Provide` & `Supply` will now raise an error if overriding an existing provider from the
14
+ same package. This is to prevent accidental overrides. Users can explicitly allow
15
+ overrides by specifying the `override` parameter when defining the provider
16
+ `Provide(..., override=True)` or `@provide(override=True)`.
17
+ - Lifecycle startup tasks will now timeout after 15 seconds and raise an error.
18
+ - Assembler's `get` method has been renamed to `build`.
19
+ - Supply's `type_hint` parameter has been renamed to `as_type`.
20
+
21
+ ### Fixed
22
+
23
+ - `Assembler` would occasionally fail to call all multiproviders due to inconsistent
24
+ ordering.
25
+
26
+
9
27
  ## [0.0.14] - 2025-03-23
10
28
 
11
29
  ### Added
12
30
 
13
31
  - `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
32
 
17
33
  ### Changed
18
34
 
19
35
  - `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.
36
+ - 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
37
  - `Assembler.add(...)` does not error when adding already registered providers.
23
38
  - Use a more performant algorithm for inspecting frame stack.
24
39
 
25
40
  ### Fixed
26
41
 
27
42
  - `ASGIEngin` now properly surfaces startup errors.
43
+ - `Engin.run()` doing a double shutdown.
44
+
28
45
 
29
46
  ## [0.0.13] - 2025-03-22
30
47
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: engin
3
- Version: 0.0.14
3
+ Version: 0.0.15
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
  ```
@@ -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.15"
4
4
  description = "An async-first modular application framework"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -1,7 +1,7 @@
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
5
  from dataclasses import dataclass
6
6
  from inspect import BoundArguments, Signature
7
7
  from typing import Any, Generic, TypeVar, cast
@@ -39,7 +39,7 @@ class Assembler:
39
39
  A container for Providers that is responsible for building provided types.
40
40
 
41
41
  The Assembler acts as a cache for previously built types, meaning repeat calls
42
- to `get` will produce the same value.
42
+ to `build` will produce the same value.
43
43
 
44
44
  Examples:
45
45
  ```python
@@ -47,7 +47,7 @@ class Assembler:
47
47
  return "foo"
48
48
 
49
49
  a = Assembler([Provide(build_str)])
50
- await a.get(str)
50
+ await a.build(str)
51
51
  ```
52
52
  """
53
53
 
@@ -85,17 +85,15 @@ class Assembler:
85
85
  bound_args=await self._bind_arguments(dependency.signature),
86
86
  )
87
87
 
88
- async def get(self, type_: type[T]) -> T:
88
+ async def build(self, type_: type[T]) -> T:
89
89
  """
90
- Return the constructed value for the given type.
90
+ Build the type from Assembler's factories.
91
91
 
92
- This method assembles the required Providers and constructs their corresponding
93
- values.
94
-
95
- If the
92
+ If the type has been built previously the value will be cached and will return the
93
+ same instance.
96
94
 
97
95
  Args:
98
- type_: the type of the desired value.
96
+ type_: the type of the desired value to build.
99
97
 
100
98
  Raises:
101
99
  LookupError: When no provider is found for the given type.
@@ -180,31 +178,37 @@ class Assembler:
180
178
  del self._assembled_outputs[type_id]
181
179
  self._providers[type_id] = provider
182
180
 
183
- def _resolve_providers(self, type_id: TypeId) -> Collection[Provide]:
181
+ def _resolve_providers(self, type_id: TypeId) -> Iterable[Provide]:
182
+ """
183
+ Resolves the chain of providers required to satisfy the provider of a given type.
184
+ Ordering of the return value is very important!
185
+
186
+ # TODO: performance optimisation, do not recurse for already satisfied providers?
187
+ """
184
188
  if type_id.multi:
185
- providers = self._multiproviders.get(type_id)
189
+ root_providers = self._multiproviders.get(type_id)
186
190
  else:
187
- providers = [provider] if (provider := self._providers.get(type_id)) else None
188
- if not providers:
191
+ root_providers = [provider] if (provider := self._providers.get(type_id)) else None
192
+
193
+ if not root_providers:
189
194
  if type_id.multi:
190
195
  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]
196
+ root_providers = [(Supply([], as_type=list[type_id.type]))] # type: ignore[name-defined]
192
197
  # store default to prevent the warning appearing multiple times
193
- self._multiproviders[type_id] = providers
198
+ self._multiproviders[type_id] = root_providers
194
199
  else:
195
200
  available = sorted(str(k) for k in self._providers)
196
201
  msg = f"Missing Provider for type '{type_id}', available: {available}"
197
202
  raise LookupError(msg)
198
203
 
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}
204
+ # providers that must be satisfied to satisfy the root level providers
205
+ yield from (
206
+ child_provider
207
+ for root_provider in root_providers
208
+ for root_provider_param in root_provider.parameter_types
209
+ for child_provider in self._resolve_providers(root_provider_param)
210
+ )
211
+ yield from root_providers
208
212
 
209
213
  async def _satisfy(self, target: TypeId) -> None:
210
214
  for provider in self._resolve_providers(target):
@@ -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, *, 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) # 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):
@@ -30,11 +30,11 @@ def _noop(*args: Any, **kwargs: Any) -> None: ...
30
30
 
31
31
 
32
32
  class Dependency(ABC, Option, Generic[P, T]):
33
- def __init__(self, func: Func[P, T], block_name: str | None = None) -> None:
33
+ def __init__(self, func: Func[P, T]) -> None:
34
34
  self._func = func
35
35
  self._is_async = iscoroutinefunction(func)
36
36
  self._signature = inspect.signature(self._func)
37
- self._block_name = block_name
37
+ self._block_name: str | None = None
38
38
 
39
39
  source_frame = get_first_external_frame()
40
40
  self._source_package = cast("str", source_frame.frame.f_globals["__package__"])
@@ -88,9 +88,6 @@ class Dependency(ABC, Option, Generic[P, T]):
88
88
  def signature(self) -> Signature:
89
89
  return self._signature
90
90
 
91
- def set_block_name(self, name: str) -> None:
92
- self._block_name = name
93
-
94
91
  async def __call__(self, *args: P.args, **kwargs: P.kwargs) -> T:
95
92
  if self._is_async:
96
93
  return await cast("Awaitable[T]", self._func(*args, **kwargs))
@@ -117,8 +114,8 @@ class Invoke(Dependency):
117
114
  ```
118
115
  """
119
116
 
120
- def __init__(self, invocation: Func[P, T], block_name: str | None = None) -> None:
121
- super().__init__(func=invocation, block_name=block_name)
117
+ def __init__(self, invocation: Func[P, T]) -> None:
118
+ super().__init__(func=invocation)
122
119
 
123
120
  def apply(self, engin: "Engin") -> None:
124
121
  engin._invocations.append(self)
@@ -134,9 +131,9 @@ class Entrypoint(Invoke):
134
131
  Entrypoints are a short hand for no-op Invocations that can be used to
135
132
  """
136
133
 
137
- def __init__(self, type_: type[Any], *, block_name: str | None = None) -> None:
134
+ def __init__(self, type_: type[Any]) -> None:
138
135
  self._type = type_
139
- super().__init__(invocation=_noop, block_name=block_name)
136
+ super().__init__(invocation=_noop)
140
137
 
141
138
  @property
142
139
  def parameter_types(self) -> list[TypeId]:
@@ -155,8 +152,17 @@ class Entrypoint(Invoke):
155
152
 
156
153
 
157
154
  class Provide(Dependency[Any, T]):
158
- def __init__(self, builder: Func[P, T], block_name: str | None = None) -> None:
159
- super().__init__(func=builder, block_name=block_name)
155
+ def __init__(self, builder: Func[P, T], *, override: bool = False) -> None:
156
+ """
157
+ Provide a type via a builder or factory function.
158
+
159
+ Args:
160
+ builder: the builder function that returns the type.
161
+ override: allow this provider to override existing providers from the same
162
+ package.
163
+ """
164
+ super().__init__(func=builder)
165
+ self._override = override
160
166
  self._is_multi = typing.get_origin(self.return_type) is list
161
167
 
162
168
  # Validate that the provider does to depend on its own output value, as this will
@@ -198,10 +204,28 @@ class Provide(Dependency[Any, T]):
198
204
  return self._is_multi
199
205
 
200
206
  def apply(self, engin: "Engin") -> None:
207
+ type_id = self.return_type_id
201
208
  if self.is_multiprovider:
202
- engin._multiproviders[self.return_type_id].append(self)
203
- else:
204
- engin._providers[self.return_type_id] = self
209
+ engin._multiproviders[type_id].append(self)
210
+ return
211
+
212
+ if type_id not in engin._providers:
213
+ engin._providers[type_id] = self
214
+ return
215
+
216
+ existing_provider = engin._providers[type_id]
217
+ is_same_package = existing_provider.source_package == self.source_package
218
+
219
+ # overwriting a dependency from the same package must be explicit
220
+ if is_same_package and not self._override:
221
+ msg = (
222
+ f"Provider '{self.name}' is implicitly overriding "
223
+ f"'{existing_provider.name}', if this is intended specify "
224
+ "`override=True` for the overriding Provider"
225
+ )
226
+ raise RuntimeError(msg)
227
+
228
+ engin._providers[type_id] = self
205
229
 
206
230
  def __hash__(self) -> int:
207
231
  return hash(self.return_type_id)
@@ -212,13 +236,27 @@ class Provide(Dependency[Any, T]):
212
236
 
213
237
  class Supply(Provide, Generic[T]):
214
238
  def __init__(
215
- self, value: T, *, type_hint: type | None = None, block_name: str | None = None
239
+ self, value: T, *, as_type: type | None = None, override: bool = False
216
240
  ) -> None:
241
+ """
242
+ Supply a value.
243
+
244
+ This is a shorthand which under the hood creates a Provider with a noop factory
245
+ function.
246
+
247
+ Args:
248
+ value: the value to Supply
249
+ as_type: allows you to specify the provided type, useful for type erasing,
250
+ e.g. Supply a concrete value but specify it as an interface or other
251
+ abstraction.
252
+ override: allow this provider to override existing providers from the same
253
+ package.
254
+ """
217
255
  self._value = value
218
- self._type_hint = type_hint
256
+ self._type_hint = as_type
219
257
  if self._type_hint is not None:
220
- self._get_val.__annotations__["return"] = type_hint
221
- super().__init__(builder=self._get_val, block_name=block_name)
258
+ self._get_val.__annotations__["return"] = as_type
259
+ super().__init__(builder=self._get_val, override=override)
222
260
 
223
261
  @property
224
262
  def return_type(self) -> type[T]:
@@ -84,7 +84,7 @@ class Engin:
84
84
  self._run_task: Task | None = None
85
85
 
86
86
  self._providers: dict[TypeId, Provide] = {
87
- TypeId.from_type(Engin): Supply(self, type_hint=Engin)
87
+ TypeId.from_type(Engin): Supply(self, as_type=Engin)
88
88
  }
89
89
  self._multiproviders: dict[TypeId, list[Provide]] = defaultdict(list)
90
90
  self._invocations: list[Invoke] = []
@@ -132,14 +132,18 @@ class Engin:
132
132
  LOG.error(f"invocation '{name}' errored, exiting", exc_info=err)
133
133
  return
134
134
 
135
- lifecycle = await self._assembler.get(Lifecycle)
135
+ lifecycle = await self._assembler.build(Lifecycle)
136
136
 
137
137
  try:
138
138
  for hook in lifecycle.list():
139
- await self._exit_stack.enter_async_context(hook)
139
+ await asyncio.wait_for(self._exit_stack.enter_async_context(hook), timeout=15)
140
140
  except Exception as err:
141
- LOG.error("lifecycle startup error, exiting", exc_info=err)
142
- await self._exit_stack.aclose()
141
+ if isinstance(err, TimeoutError):
142
+ msg = "lifecycle startup task timed out after 15s, exiting"
143
+ else:
144
+ msg = "lifecycle startup task errored, exiting"
145
+ LOG.error(msg, exc_info=err)
146
+ await self._shutdown()
143
147
  return
144
148
 
145
149
  LOG.info("startup complete")
@@ -50,7 +50,7 @@ class ASGIEngin(Engin, ASGIType):
50
50
  await self._asgi_app(scope, receive, send)
51
51
 
52
52
  async def _startup(self) -> None:
53
- self._asgi_app = await self._assembler.get(self._asgi_type)
53
+ self._asgi_app = await self._assembler.build(self._asgi_type)
54
54
  await self.start()
55
55
 
56
56
  def graph(self) -> list[Node]:
@@ -58,7 +58,7 @@ def Inject(interface: type[T]) -> Depends:
58
58
  assembler: Assembler = conn.app.state.assembler
59
59
  except AttributeError:
60
60
  raise RuntimeError("Assembler is not attached to Application state") from None
61
- return await assembler.get(interface)
61
+ return await assembler.build(interface)
62
62
 
63
63
  dep = Depends(inner)
64
64
  dep.__engin__ = True # type: ignore[attr-defined]
@@ -143,7 +143,8 @@ class APIRouteDependency(Dependency):
143
143
  """
144
144
  Warning: this should never be constructed in application code.
145
145
  """
146
- super().__init__(_noop, wraps.block_name)
146
+ super().__init__(_noop)
147
+ self._block_name = wraps.block_name
147
148
  self._wrapped = wraps
148
149
  self._route = route
149
150
  self._signature = inspect.signature(route.endpoint)
@@ -60,18 +60,18 @@ def test_assembler_with_duplicate_provider_errors():
60
60
  async def test_assembler_get():
61
61
  assembler = Assembler([Provide(make_int), Provide(make_many_int)])
62
62
 
63
- assert await assembler.get(int)
64
- assert await assembler.get(list[int])
63
+ assert await assembler.build(int)
64
+ assert await assembler.build(list[int])
65
65
 
66
66
 
67
67
  async def test_assembler_with_unknown_type_raises_lookup_error():
68
68
  assembler = Assembler([])
69
69
 
70
70
  with pytest.raises(LookupError):
71
- await assembler.get(str)
71
+ await assembler.build(str)
72
72
 
73
73
  with pytest.raises(LookupError):
74
- await assembler.get(list[str])
74
+ await assembler.build(list[str])
75
75
 
76
76
  with pytest.raises(LookupError):
77
77
  await assembler.assemble(Entrypoint(str))
@@ -87,10 +87,10 @@ async def test_assembler_with_erroring_provider_raises_provider_error():
87
87
  assembler = Assembler([Provide(make_str), Provide(make_many_str)])
88
88
 
89
89
  with pytest.raises(ProviderError):
90
- await assembler.get(str)
90
+ await assembler.build(str)
91
91
 
92
92
  with pytest.raises(ProviderError):
93
- await assembler.get(list[str])
93
+ await assembler.build(list[str])
94
94
 
95
95
 
96
96
  async def test_annotations():
@@ -103,10 +103,10 @@ async def test_annotations():
103
103
  assembler = Assembler([Provide(make_str_1), Provide(make_str_2)])
104
104
 
105
105
  with pytest.raises(LookupError):
106
- await assembler.get(str)
106
+ await assembler.build(str)
107
107
 
108
- assert await assembler.get(Annotated[str, "1"]) == "bar"
109
- assert await assembler.get(Annotated[str, "2"]) == "foo"
108
+ assert await assembler.build(Annotated[str, "1"]) == "bar"
109
+ assert await assembler.build(Annotated[str, "2"]) == "foo"
110
110
 
111
111
 
112
112
  async def test_assembler_has():
@@ -153,8 +153,8 @@ async def test_assembler_add_overrides():
153
153
  assembler = Assembler([])
154
154
  assembler.add(Provide(return_one))
155
155
 
156
- assert await assembler.get(int) == 1
156
+ assert await assembler.build(int) == 1
157
157
 
158
158
  assembler.add(Provide(return_two))
159
159
 
160
- assert await assembler.get(int) == 2
160
+ assert await assembler.build(int) == 2
@@ -8,12 +8,18 @@ def test_block():
8
8
  return 3
9
9
 
10
10
  @invoke
11
- def invoke_square(self, some: int) -> None:
12
- return some**2
11
+ def invoke_square(self, some: int) -> None: ...
12
+
13
+ @provide()
14
+ def provide_str(self) -> str:
15
+ return "3"
16
+
17
+ @invoke()
18
+ def invoke_str(self, some: str) -> None: ...
13
19
 
14
20
  my_block = MyBlock()
15
21
 
16
22
  options = list(my_block._method_options())
17
- assert len(options) == 2
18
23
 
24
+ assert len(options) == 4
19
25
  assert Engin(my_block)
@@ -1,4 +1,5 @@
1
1
  from typing import Annotated
2
+ from unittest.mock import Mock
2
3
 
3
4
  import pytest
4
5
 
@@ -91,3 +92,40 @@ def test_provider_cannot_depend_on_self():
91
92
 
92
93
  with pytest.raises(ValueError, match="return type"):
93
94
  Provide(invalid_provider_2)
95
+
96
+
97
+ def test_provides_implicit_overrides():
98
+ provide_a = Provide(make_int)
99
+ provide_b = Provide(make_int)
100
+
101
+ engin = Mock()
102
+ engin._providers = {}
103
+
104
+ provide_a.apply(engin)
105
+
106
+ with pytest.raises(RuntimeError, match="implicit"):
107
+ provide_b.apply(engin)
108
+
109
+
110
+ def test_provides_explicit_overrides_allowed():
111
+ provide_a = Provide(make_int)
112
+ provide_b = Provide(make_int, override=True)
113
+
114
+ engin = Mock()
115
+ engin._providers = {}
116
+
117
+ provide_a.apply(engin)
118
+ provide_b.apply(engin)
119
+
120
+
121
+ def test_provides_implicit_overrides_allowed_when_3rd_party():
122
+ provide_a = Provide(make_int)
123
+ provide_b = Provide(make_int)
124
+
125
+ provide_a._source_package = "foo"
126
+
127
+ engin = Mock()
128
+ engin._providers = {}
129
+
130
+ provide_a.apply(engin)
131
+ provide_b.apply(engin)
@@ -206,7 +206,7 @@ toml = [
206
206
 
207
207
  [[package]]
208
208
  name = "engin"
209
- version = "0.0.14"
209
+ version = "0.0.15"
210
210
  source = { editable = "." }
211
211
 
212
212
  [package.optional-dependencies]
@@ -273,16 +273,16 @@ wheels = [
273
273
 
274
274
  [[package]]
275
275
  name = "fastapi"
276
- version = "0.115.11"
276
+ version = "0.115.12"
277
277
  source = { registry = "https://pypi.org/simple" }
278
278
  dependencies = [
279
279
  { name = "pydantic" },
280
280
  { name = "starlette" },
281
281
  { name = "typing-extensions" },
282
282
  ]
283
- sdist = { url = "https://files.pythonhosted.org/packages/b5/28/c5d26e5860df807241909a961a37d45e10533acef95fc368066c7dd186cd/fastapi-0.115.11.tar.gz", hash = "sha256:cc81f03f688678b92600a65a5e618b93592c65005db37157147204d8924bf94f", size = 294441 }
283
+ sdist = { url = "https://files.pythonhosted.org/packages/f4/55/ae499352d82338331ca1e28c7f4a63bfd09479b16395dce38cf50a39e2c2/fastapi-0.115.12.tar.gz", hash = "sha256:1e2c2a2646905f9e83d32f04a3f86aff4a286669c6c950ca95b5fd68c2602681", size = 295236 }
284
284
  wheels = [
285
- { url = "https://files.pythonhosted.org/packages/b3/5d/4d8bbb94f0dbc22732350c06965e40740f4a92ca560e90bb566f4f73af41/fastapi-0.115.11-py3-none-any.whl", hash = "sha256:32e1541b7b74602e4ef4a0260ecaf3aadf9d4f19590bba3e1bf2ac4666aa2c64", size = 94926 },
285
+ { url = "https://files.pythonhosted.org/packages/50/b3/b51f09c2ba432a576fe63758bddc81f78f0c6309d9e5c10d194313bf021e/fastapi-0.115.12-py3-none-any.whl", hash = "sha256:e94613d6c05e27be7ffebdd6ea5f388112e5e430c8f7d6494a9d1d88d43e814d", size = 95164 },
286
286
  ]
287
287
 
288
288
  [[package]]
@@ -580,7 +580,7 @@ python = [
580
580
 
581
581
  [[package]]
582
582
  name = "mkdocstrings-python"
583
- version = "1.16.7"
583
+ version = "1.16.8"
584
584
  source = { registry = "https://pypi.org/simple" }
585
585
  dependencies = [
586
586
  { name = "griffe" },
@@ -588,9 +588,9 @@ dependencies = [
588
588
  { name = "mkdocstrings" },
589
589
  { name = "typing-extensions", marker = "python_full_version < '3.11'" },
590
590
  ]
591
- sdist = { url = "https://files.pythonhosted.org/packages/52/e0/cc35acb47593c138efbfc9dc296ccc26b7ad4452e868fd309f05f6ba0ded/mkdocstrings_python-1.16.7.tar.gz", hash = "sha256:cdfc1a99fe5f6f0d90446a364ef7cac12014a4ef46114b2677a58cec84007117", size = 1475398 }
591
+ sdist = { url = "https://files.pythonhosted.org/packages/8e/b8/62190ea298fdb1e84670ef548590748c633ab4e05b35bcf902e89f2f28c6/mkdocstrings_python-1.16.8.tar.gz", hash = "sha256:9453ccae69be103810c1cf6435ce71c8f714ae37fef4d87d16aa92a7c800fe1d", size = 205119 }
592
592
  wheels = [
593
- { url = "https://files.pythonhosted.org/packages/ab/5e/dc978b9fd6331e2070369579ad8f52145e9ef22a69bfc2811110be95e6d4/mkdocstrings_python-1.16.7-py3-none-any.whl", hash = "sha256:a5589a5be247a28ba651287f83630c69524042f8055d93b5c203d804a3409333", size = 1998312 },
593
+ { url = "https://files.pythonhosted.org/packages/67/d0/ef6e82f7a68c7ac02e1a01815fbe88773f4f9e40728ed35bd1664a5d76f2/mkdocstrings_python-1.16.8-py3-none-any.whl", hash = "sha256:211b7aaf776cd45578ecb531e5ad0d3a35a8be9101a6bfa10de38a69af9d8fd8", size = 124116 },
594
594
  ]
595
595
 
596
596
  [[package]]
@@ -851,14 +851,14 @@ wheels = [
851
851
 
852
852
  [[package]]
853
853
  name = "pytest-asyncio"
854
- version = "0.25.3"
854
+ version = "0.26.0"
855
855
  source = { registry = "https://pypi.org/simple" }
856
856
  dependencies = [
857
857
  { name = "pytest" },
858
858
  ]
859
- sdist = { url = "https://files.pythonhosted.org/packages/f2/a8/ecbc8ede70921dd2f544ab1cadd3ff3bf842af27f87bbdea774c7baa1d38/pytest_asyncio-0.25.3.tar.gz", hash = "sha256:fc1da2cf9f125ada7e710b4ddad05518d4cee187ae9412e9ac9271003497f07a", size = 54239 }
859
+ sdist = { url = "https://files.pythonhosted.org/packages/8e/c4/453c52c659521066969523e87d85d54139bbd17b78f09532fb8eb8cdb58e/pytest_asyncio-0.26.0.tar.gz", hash = "sha256:c4df2a697648241ff39e7f0e4a73050b03f123f760673956cf0d72a4990e312f", size = 54156 }
860
860
  wheels = [
861
- { url = "https://files.pythonhosted.org/packages/67/17/3493c5624e48fd97156ebaec380dcaafee9506d7e2c46218ceebbb57d7de/pytest_asyncio-0.25.3-py3-none-any.whl", hash = "sha256:9e89518e0f9bd08928f97a3482fdc4e244df17529460bc038291ccaf8f85c7c3", size = 19467 },
861
+ { url = "https://files.pythonhosted.org/packages/20/7f/338843f449ace853647ace35870874f69a764d251872ed1b4de9f234822c/pytest_asyncio-0.26.0-py3-none-any.whl", hash = "sha256:7b51ed894f4fbea1340262bdae5135797ebbe21d8638978e35d31c6d19f72fb0", size = 19694 },
862
862
  ]
863
863
 
864
864
  [[package]]
@@ -900,11 +900,11 @@ wheels = [
900
900
 
901
901
  [[package]]
902
902
  name = "python-dotenv"
903
- version = "1.0.1"
903
+ version = "1.1.0"
904
904
  source = { registry = "https://pypi.org/simple" }
905
- sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115 }
905
+ sdist = { url = "https://files.pythonhosted.org/packages/88/2c/7bb1416c5620485aa793f2de31d3df393d3686aa8a8506d11e10e13c5baf/python_dotenv-1.1.0.tar.gz", hash = "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5", size = 39920 }
906
906
  wheels = [
907
- { url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863 },
907
+ { url = "https://files.pythonhosted.org/packages/1e/18/98a99ad95133c6a6e2005fe89faedf294a748bd5dc803008059409ac9b1e/python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d", size = 20256 },
908
908
  ]
909
909
 
910
910
  [[package]]
File without changes
@@ -1,4 +0,0 @@
1
-
2
- ## Dependency Injection
3
-
4
- TODO
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes