engin 0.0.12__tar.gz → 0.0.14__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 (91) hide show
  1. {engin-0.0.12 → engin-0.0.14}/.readthedocs.yaml +1 -1
  2. {engin-0.0.12 → engin-0.0.14}/CHANGELOG.md +27 -0
  3. {engin-0.0.12 → engin-0.0.14}/PKG-INFO +3 -1
  4. {engin-0.0.12 → engin-0.0.14}/docs/concepts/engin.md +1 -1
  5. {engin-0.0.12 → engin-0.0.14}/docs/concepts/invocations.md +5 -8
  6. {engin-0.0.12 → engin-0.0.14}/docs/getting-started.md +1 -1
  7. {engin-0.0.12 → engin-0.0.14}/docs/guides/fastapi.md +1 -1
  8. engin-0.0.14/docs/js/readthedocs.js +7 -0
  9. {engin-0.0.12 → engin-0.0.14}/mkdocs.yaml +1 -1
  10. {engin-0.0.12 → engin-0.0.14}/pyproject.toml +16 -12
  11. {engin-0.0.12 → engin-0.0.14}/src/engin/__init__.py +4 -1
  12. {engin-0.0.12 → engin-0.0.14}/src/engin/_assembler.py +33 -29
  13. {engin-0.0.12 → engin-0.0.14}/src/engin/_block.py +26 -23
  14. engin-0.0.14/src/engin/_cli/__init__.py +13 -0
  15. engin-0.0.12/src/engin/scripts/graph.py → engin-0.0.14/src/engin/_cli/_graph.py +48 -31
  16. engin-0.0.14/src/engin/_cli/_utils.py +18 -0
  17. {engin-0.0.12 → engin-0.0.14}/src/engin/_dependency.py +28 -21
  18. {engin-0.0.12 → engin-0.0.14}/src/engin/_engin.py +13 -55
  19. engin-0.0.14/src/engin/_introspect.py +34 -0
  20. {engin-0.0.12 → engin-0.0.14}/src/engin/_lifecycle.py +68 -2
  21. engin-0.0.14/src/engin/_option.py +10 -0
  22. {engin-0.0.12 → engin-0.0.14}/src/engin/_type_utils.py +11 -12
  23. {engin-0.0.12 → engin-0.0.14}/src/engin/ext/asgi.py +2 -1
  24. {engin-0.0.12 → engin-0.0.14}/src/engin/ext/fastapi.py +9 -3
  25. engin-0.0.14/tests/cli/test_graph.py +39 -0
  26. {engin-0.0.12 → engin-0.0.14}/tests/test_assembler.py +44 -6
  27. engin-0.0.12/tests/test_modules.py → engin-0.0.14/tests/test_block.py +1 -1
  28. engin-0.0.14/tests/test_graph.py +33 -0
  29. engin-0.0.14/tests/test_lifecycle.py +82 -0
  30. engin-0.0.14/tests/test_utils.py +81 -0
  31. {engin-0.0.12 → engin-0.0.14}/uv.lock +193 -112
  32. engin-0.0.12/docs/js/readthedocs.js +0 -32
  33. engin-0.0.12/tests/test_utils.py +0 -62
  34. {engin-0.0.12 → engin-0.0.14}/.github/workflows/check.yaml +0 -0
  35. {engin-0.0.12 → engin-0.0.14}/.github/workflows/publish.yaml +0 -0
  36. {engin-0.0.12 → engin-0.0.14}/.gitignore +0 -0
  37. {engin-0.0.12 → engin-0.0.14}/LICENSE +0 -0
  38. {engin-0.0.12 → engin-0.0.14}/README.md +0 -0
  39. {engin-0.0.12 → engin-0.0.14}/docs/concepts/lifecycle.md +0 -0
  40. {engin-0.0.12 → engin-0.0.14}/docs/concepts/providers.md +0 -0
  41. {engin-0.0.12 → engin-0.0.14}/docs/guides/dependency_injection.md +0 -0
  42. {engin-0.0.12 → engin-0.0.14}/docs/guides/fastapi-graph.png +0 -0
  43. {engin-0.0.12 → engin-0.0.14}/docs/index.md +0 -0
  44. {engin-0.0.12 → engin-0.0.14}/docs/overrides/main.html +0 -0
  45. {engin-0.0.12 → engin-0.0.14}/docs/reference.md +0 -0
  46. {engin-0.0.12 → engin-0.0.14}/examples/__init__.py +0 -0
  47. {engin-0.0.12 → engin-0.0.14}/examples/asgi/__init__.py +0 -0
  48. {engin-0.0.12 → engin-0.0.14}/examples/asgi/app.py +0 -0
  49. {engin-0.0.12 → engin-0.0.14}/examples/asgi/common/__init__.py +0 -0
  50. {engin-0.0.12 → engin-0.0.14}/examples/asgi/common/db/__init__.py +0 -0
  51. {engin-0.0.12 → engin-0.0.14}/examples/asgi/common/db/adapaters/__init__.py +0 -0
  52. {engin-0.0.12 → engin-0.0.14}/examples/asgi/common/db/adapaters/memory.py +0 -0
  53. {engin-0.0.12 → engin-0.0.14}/examples/asgi/common/db/block.py +0 -0
  54. {engin-0.0.12 → engin-0.0.14}/examples/asgi/common/db/ports.py +0 -0
  55. {engin-0.0.12 → engin-0.0.14}/examples/asgi/common/starlette/__init__.py +0 -0
  56. {engin-0.0.12 → engin-0.0.14}/examples/asgi/common/starlette/endpoint.py +0 -0
  57. {engin-0.0.12 → engin-0.0.14}/examples/asgi/features/__init__.py +0 -0
  58. {engin-0.0.12 → engin-0.0.14}/examples/asgi/features/cats/__init__.py +0 -0
  59. {engin-0.0.12 → engin-0.0.14}/examples/asgi/features/cats/api/__init__.py +0 -0
  60. {engin-0.0.12 → engin-0.0.14}/examples/asgi/features/cats/api/get.py +0 -0
  61. {engin-0.0.12 → engin-0.0.14}/examples/asgi/features/cats/api/post.py +0 -0
  62. {engin-0.0.12 → engin-0.0.14}/examples/asgi/features/cats/block.py +0 -0
  63. {engin-0.0.12 → engin-0.0.14}/examples/asgi/features/cats/domain.py +0 -0
  64. {engin-0.0.12 → engin-0.0.14}/examples/asgi/main.py +0 -0
  65. {engin-0.0.12 → engin-0.0.14}/examples/fastapi/__init__.py +0 -0
  66. {engin-0.0.12 → engin-0.0.14}/examples/fastapi/app.py +0 -0
  67. {engin-0.0.12 → engin-0.0.14}/examples/fastapi/main.py +0 -0
  68. {engin-0.0.12 → engin-0.0.14}/examples/fastapi/routes/__init__.py +0 -0
  69. {engin-0.0.12 → engin-0.0.14}/examples/fastapi/routes/cats/__init__.py +0 -0
  70. {engin-0.0.12 → engin-0.0.14}/examples/fastapi/routes/cats/adapters/__init__.py +0 -0
  71. {engin-0.0.12 → engin-0.0.14}/examples/fastapi/routes/cats/adapters/repository.py +0 -0
  72. {engin-0.0.12 → engin-0.0.14}/examples/fastapi/routes/cats/api.py +0 -0
  73. {engin-0.0.12 → engin-0.0.14}/examples/fastapi/routes/cats/block.py +0 -0
  74. {engin-0.0.12 → engin-0.0.14}/examples/fastapi/routes/cats/domain.py +0 -0
  75. {engin-0.0.12 → engin-0.0.14}/examples/fastapi/routes/cats/ports.py +0 -0
  76. {engin-0.0.12 → engin-0.0.14}/examples/simple/__init__.py +0 -0
  77. {engin-0.0.12 → engin-0.0.14}/examples/simple/main.py +0 -0
  78. {engin-0.0.12 → engin-0.0.14}/src/engin/_exceptions.py +0 -0
  79. {engin-0.0.12 → engin-0.0.14}/src/engin/_graph.py +0 -0
  80. {engin-0.0.12 → engin-0.0.14}/src/engin/ext/__init__.py +0 -0
  81. {engin-0.0.12 → engin-0.0.14}/src/engin/py.typed +0 -0
  82. {engin-0.0.12/src/engin/scripts → engin-0.0.14/tests}/__init__.py +0 -0
  83. {engin-0.0.12/tests → engin-0.0.14/tests/acceptance}/__init__.py +0 -0
  84. {engin-0.0.12 → engin-0.0.14}/tests/acceptance/test_error_in_shutdown.py +0 -0
  85. {engin-0.0.12 → engin-0.0.14}/tests/acceptance/test_error_in_start_up.py +0 -0
  86. {engin-0.0.12 → engin-0.0.14}/tests/acceptance/test_fastapi.py +0 -0
  87. {engin-0.0.12/tests/acceptance → engin-0.0.14/tests/cli}/__init__.py +0 -0
  88. {engin-0.0.12 → engin-0.0.14}/tests/conftest.py +0 -0
  89. {engin-0.0.12 → engin-0.0.14}/tests/deps.py +0 -0
  90. {engin-0.0.12 → engin-0.0.14}/tests/test_dependencies.py +0 -0
  91. {engin-0.0.12 → engin-0.0.14}/tests/test_engin.py +0 -0
@@ -22,4 +22,4 @@ build:
22
22
  - NO_COLOR=1 uv run mkdocs build -f mkdocs.yaml --strict --site-dir $READTHEDOCS_OUTPUT/html
23
23
 
24
24
  mkdocs:
25
- configuration: mkdocs.yaml
25
+ configuration: mkdocs.yaml
@@ -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.14] - 2025-03-23
10
+
11
+ ### Added
12
+
13
+ - `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
+
17
+ ### Changed
18
+
19
+ - `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.
22
+ - `Assembler.add(...)` does not error when adding already registered providers.
23
+ - Use a more performant algorithm for inspecting frame stack.
24
+
25
+ ### Fixed
26
+
27
+ - `ASGIEngin` now properly surfaces startup errors.
28
+
29
+ ## [0.0.13] - 2025-03-22
30
+
31
+ ### Changed
32
+
33
+ - `Provide` now supports union types.
34
+
35
+
9
36
  ## [0.0.12] - 2025-03-03
10
37
 
11
38
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: engin
3
- Version: 0.0.12
3
+ Version: 0.0.14
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/
@@ -10,6 +10,8 @@ License-Expression: MIT
10
10
  License-File: LICENSE
11
11
  Keywords: Application Framework,Dependency Injection
12
12
  Requires-Python: >=3.10
13
+ Provides-Extra: cli
14
+ Requires-Dist: typer>=0.15; extra == 'cli'
13
15
  Description-Content-Type: text/markdown
14
16
 
15
17
  [![codecov](https://codecov.io/gh/invokermain/engin/graph/badge.svg?token=4PJOIMV6IB)](https://codecov.io/gh/invokermain/engin)
@@ -9,4 +9,4 @@ When ran the Engin takes care of the complete application lifecycle:
9
9
  2. All Invocations are run sequentially in the order they were passed in to the Engin.
10
10
  3. Any Lifecycle Startup defined by Provider that were assembled is ran.
11
11
  4. The Engin waits for a stop signal, i.e. SIGINT or SIGTERM.
12
- 5. Any Lifecyce Shutdown task is ran, in the reverse order to the Startup order.
12
+ 5. Any Lifecyce Shutdown tasks are run, in reverse order to the Startup order.
@@ -23,15 +23,12 @@ imports for brevity):
23
23
 
24
24
  ```python
25
25
  def worker_factory(lifecycle: Lifecycle) -> Worker:
26
- worker = Worker()
26
+ worker = Worker()
27
27
 
28
- @asynccontextmanager
29
- async def worker_lifecycle() -> Iterable[None]:
30
- worker.start()
31
- yield
32
- worker.shutdown()
33
-
34
- lifecycle.append(worker_lifecycle)
28
+ lifecycle.hook(
29
+ on_start=worker.start,
30
+ on_stop=worker.shutdown
31
+ )
35
32
 
36
33
  return worker
37
34
  ```
@@ -2,6 +2,6 @@
2
2
 
3
3
  Engin is available on PyPI, install using your favourite dependency manager:
4
4
 
5
- - **pip**:`pip install engin`
5
+ - **pip**: `pip install engin`
6
6
  - **poetry**: `poetry add engin`
7
7
  - **uv**: `uv add engin`
@@ -159,7 +159,7 @@ app = FastAPIEngin(AppBlock())
159
159
 
160
160
  ## Graphing Dependencies
161
161
 
162
- Engin provides dependency visualisation functionality via the `engin-graph` script. When
162
+ Engin provides dependency visualisation functionality via the `engin graph` script. When
163
163
  working with a FastAPI application this can be used to visualise API Routes along with
164
164
  their respective dependencies.
165
165
 
@@ -0,0 +1,7 @@
1
+ document.addEventListener("DOMContentLoaded", function(event) {
2
+ // Trigger Read the Docs' search addon instead of Material MkDocs default
3
+ document.querySelector(".md-search__input").addEventListener("focus", (e) => {
4
+ const event = new CustomEvent("readthedocs-search-show");
5
+ document.dispatchEvent(event);
6
+ });
7
+ });
@@ -57,7 +57,7 @@ plugins:
57
57
  show_signature_annotations: true
58
58
  show_source: false
59
59
  signature_crossrefs: true
60
- import:
60
+ inventories:
61
61
  - url: https://docs.python.org/3/objects.inv
62
62
  domains: [py, std]
63
63
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "engin"
3
- version = "0.0.12"
3
+ version = "0.0.14"
4
4
  description = "An async-first modular application framework"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -8,19 +8,21 @@ license = "MIT"
8
8
  keywords = ["Dependency Injection", "Application Framework"]
9
9
  dependencies = []
10
10
 
11
+ [project.optional-dependencies]
12
+ cli = ["typer>=0.15"]
13
+
14
+ [project.scripts]
15
+ engin = "engin._cli:app"
16
+
11
17
  [project.urls]
12
18
  Homepage = "https://github.com/invokermain/engin"
13
19
  Documentation = "https://engin.readthedocs.io/en/latest/"
14
20
  Repository = "https://github.com/invokermain/engin.git"
15
21
  Changelog = "https://github.com/invokermain/engin/blob/main/CHANGELOG.md"
16
22
 
17
- [build-system]
18
- requires = ["hatchling"]
19
- build-backend = "hatchling.build"
20
-
21
23
 
22
- [tool.uv]
23
- dev-dependencies = [
24
+ [dependency-groups]
25
+ dev = [
24
26
  "fastapi>=0.115.6",
25
27
  "httpx>=0.27.2",
26
28
  "mypy>=1",
@@ -33,18 +35,20 @@ dev-dependencies = [
33
35
  "starlette>=0.39.2",
34
36
  "uvicorn>=0.31.1",
35
37
  "pytest-cov>=6.0.0",
38
+ "typer>=0.15.2",
39
+ "pytest-mock>=3.14.0",
36
40
  ]
37
-
38
-
39
- [dependency-groups]
40
41
  docs = [
41
42
  "mkdocs-material>=9.5.50",
42
43
  "mkdocstrings[python]>=0.27.0",
43
44
  ]
44
45
 
46
+ [tool.uv]
47
+ default-groups = ["dev", "docs"]
45
48
 
46
- [project.scripts]
47
- engin-graph = "engin.scripts.graph:serve_graph"
49
+ [build-system]
50
+ requires = ["hatchling"]
51
+ build-backend = "hatchling.build"
48
52
 
49
53
 
50
54
  [tool.ruff]
@@ -2,9 +2,11 @@ from engin import ext
2
2
  from engin._assembler import Assembler
3
3
  from engin._block import Block, invoke, provide
4
4
  from engin._dependency import Entrypoint, Invoke, Provide, Supply
5
- from engin._engin import Engin, Option
5
+ from engin._engin import Engin
6
6
  from engin._exceptions import ProviderError
7
7
  from engin._lifecycle import Lifecycle
8
+ from engin._option import Option
9
+ from engin._type_utils import TypeId
8
10
 
9
11
  __all__ = [
10
12
  "Assembler",
@@ -17,6 +19,7 @@ __all__ = [
17
19
  "Provide",
18
20
  "ProviderError",
19
21
  "Supply",
22
+ "TypeId",
20
23
  "ext",
21
24
  "invoke",
22
25
  "provide",
@@ -8,7 +8,7 @@ from typing import Any, Generic, TypeVar, cast
8
8
 
9
9
  from engin._dependency import Dependency, Provide, Supply
10
10
  from engin._exceptions import ProviderError
11
- from engin._type_utils import TypeId, type_id_of
11
+ from engin._type_utils import TypeId
12
12
 
13
13
  LOG = logging.getLogger("engin")
14
14
 
@@ -54,8 +54,7 @@ class Assembler:
54
54
  def __init__(self, providers: Iterable[Provide]) -> None:
55
55
  self._providers: dict[TypeId, Provide[Any]] = {}
56
56
  self._multiproviders: dict[TypeId, list[Provide[list[Any]]]] = defaultdict(list)
57
- self._dependencies: dict[TypeId, Any] = {}
58
- self._consumed_providers: set[Provide[Any]] = set()
57
+ self._assembled_outputs: dict[TypeId, Any] = {}
59
58
  self._lock = asyncio.Lock()
60
59
 
61
60
  for provider in providers:
@@ -106,13 +105,14 @@ class Assembler:
106
105
  Returns:
107
106
  The constructed value.
108
107
  """
109
- type_id = type_id_of(type_)
110
- if type_id in self._dependencies:
111
- return cast(T, self._dependencies[type_id])
108
+ type_id = TypeId.from_type(type_)
109
+ if type_id in self._assembled_outputs:
110
+ return cast("T", self._assembled_outputs[type_id])
112
111
  if type_id.multi:
113
- out = []
114
112
  if type_id not in self._multiproviders:
115
113
  raise LookupError(f"no provider found for target type id '{type_id}'")
114
+
115
+ out = []
116
116
  for provider in self._multiproviders[type_id]:
117
117
  assembled_dependency = await self.assemble(provider)
118
118
  try:
@@ -123,11 +123,12 @@ class Assembler:
123
123
  error_type=type(err),
124
124
  error_message=str(err),
125
125
  ) from err
126
- self._dependencies[type_id] = out
126
+ self._assembled_outputs[type_id] = out
127
127
  return out # type: ignore[return-value]
128
128
  else:
129
129
  if type_id not in self._providers:
130
130
  raise LookupError(f"no provider found for target type id '{type_id}'")
131
+
131
132
  assembled_dependency = await self.assemble(self._providers[type_id])
132
133
  try:
133
134
  value = await assembled_dependency()
@@ -137,7 +138,7 @@ class Assembler:
137
138
  error_type=type(err),
138
139
  error_message=str(err),
139
140
  ) from err
140
- self._dependencies[type_id] = value
141
+ self._assembled_outputs[type_id] = value
141
142
  return value # type: ignore[return-value]
142
143
 
143
144
  def has(self, type_: type[T]) -> bool:
@@ -150,7 +151,7 @@ class Assembler:
150
151
  Returns:
151
152
  True if the Assembler has a provider for type else False.
152
153
  """
153
- type_id = type_id_of(type_)
154
+ type_id = TypeId.from_type(type_)
154
155
  if type_id.multi:
155
156
  return type_id in self._multiproviders
156
157
  else:
@@ -160,24 +161,23 @@ class Assembler:
160
161
  """
161
162
  Add a provider to the Assembler post-initialisation.
162
163
 
164
+ If this replaces an existing provider, this will clear any previously assembled
165
+ output for the existing Provider.
166
+
163
167
  Args:
164
168
  provider: the Provide instance to add.
165
169
 
166
170
  Returns:
167
171
  None
168
-
169
- Raises:
170
- ValueError: if a provider for this type already exists.
171
172
  """
172
173
  type_id = provider.return_type_id
173
174
  if provider.is_multiprovider:
174
- if type_id in self._multiproviders:
175
- self._multiproviders[type_id].append(provider)
176
- else:
177
- self._multiproviders[type_id] = [provider]
175
+ if type_id in self._assembled_outputs:
176
+ del self._assembled_outputs[type_id]
177
+ self._multiproviders[type_id].append(provider)
178
178
  else:
179
- if type_id in self._providers:
180
- raise ValueError(f"A provider for '{type_id}' already exists")
179
+ if type_id in self._assembled_outputs:
180
+ del self._assembled_outputs[type_id]
181
181
  self._providers[type_id] = provider
182
182
 
183
183
  def _resolve_providers(self, type_id: TypeId) -> Collection[Provide]:
@@ -192,7 +192,9 @@ class Assembler:
192
192
  # store default to prevent the warning appearing multiple times
193
193
  self._multiproviders[type_id] = providers
194
194
  else:
195
- raise LookupError(f"No Provider registered for dependency '{type_id}'")
195
+ available = sorted(str(k) for k in self._providers)
196
+ msg = f"Missing Provider for type '{type_id}', available: {available}"
197
+ raise LookupError(msg)
196
198
 
197
199
  required_providers: list[Provide[Any]] = []
198
200
  for provider in providers:
@@ -206,9 +208,11 @@ class Assembler:
206
208
 
207
209
  async def _satisfy(self, target: TypeId) -> None:
208
210
  for provider in self._resolve_providers(target):
209
- if provider in self._consumed_providers:
211
+ if (
212
+ not provider.is_multiprovider
213
+ and provider.return_type_id in self._assembled_outputs
214
+ ):
210
215
  continue
211
- self._consumed_providers.add(provider)
212
216
  type_id = provider.return_type_id
213
217
  bound_args = await self._bind_arguments(provider.signature)
214
218
  try:
@@ -218,12 +222,12 @@ class Assembler:
218
222
  provider=provider, error_type=type(err), error_message=str(err)
219
223
  ) from err
220
224
  if provider.is_multiprovider:
221
- if type_id in self._dependencies:
222
- self._dependencies[type_id].extend(value)
225
+ if type_id in self._assembled_outputs:
226
+ self._assembled_outputs[type_id].extend(value)
223
227
  else:
224
- self._dependencies[type_id] = value
228
+ self._assembled_outputs[type_id] = value
225
229
  else:
226
- self._dependencies[type_id] = value
230
+ self._assembled_outputs[type_id] = value
227
231
 
228
232
  async def _bind_arguments(self, signature: Signature) -> BoundArguments:
229
233
  args = []
@@ -232,11 +236,11 @@ class Assembler:
232
236
  if param_name == "self":
233
237
  args.append(object())
234
238
  continue
235
- param_key = type_id_of(param.annotation)
236
- has_dependency = param_key in self._dependencies
239
+ param_key = TypeId.from_type(param.annotation)
240
+ has_dependency = param_key in self._assembled_outputs
237
241
  if not has_dependency:
238
242
  await self._satisfy(param_key)
239
- val = self._dependencies[param_key]
243
+ val = self._assembled_outputs[param_key]
240
244
  if param.kind == param.POSITIONAL_ONLY:
241
245
  args.append(val)
242
246
  else:
@@ -1,8 +1,13 @@
1
1
  import inspect
2
- from collections.abc import Iterable, Iterator
3
- from typing import ClassVar
2
+ from collections.abc import Iterable, Sequence
3
+ from itertools import chain
4
+ from typing import TYPE_CHECKING, ClassVar
4
5
 
5
- from engin._dependency import Func, Invoke, Provide
6
+ from engin._dependency import Dependency, Func, Invoke, Provide
7
+ from engin._option import Option
8
+
9
+ if TYPE_CHECKING:
10
+ from engin._engin import Engin
6
11
 
7
12
 
8
13
  def provide(func: Func) -> Func:
@@ -21,7 +26,7 @@ def invoke(func: Func) -> Func:
21
26
  return func
22
27
 
23
28
 
24
- class Block(Iterable[Provide | Invoke]):
29
+ class Block(Option):
25
30
  """
26
31
  A Block is a collection of providers and invocations.
27
32
 
@@ -48,23 +53,21 @@ class Block(Iterable[Provide | Invoke]):
48
53
  ```
49
54
  """
50
55
 
51
- options: ClassVar[list[Provide | Invoke]] = []
52
-
53
- def __init__(self, /, block_name: str | None = None) -> None:
54
- self._options: list[Provide | Invoke] = self.options[:]
55
- self._name = block_name or f"{type(self).__name__}"
56
- for _, method in inspect.getmembers(self):
57
- if opt := getattr(method, "_opt", None):
58
- if not isinstance(opt, Provide | Invoke):
56
+ name: ClassVar[str | None] = None
57
+ options: ClassVar[Sequence[Option]] = []
58
+
59
+ @classmethod
60
+ def apply(cls, engin: "Engin") -> None:
61
+ block_name = cls.name or f"{cls.__name__}"
62
+ for option in chain(cls.options, cls._method_options()):
63
+ if isinstance(option, Dependency):
64
+ option._block_name = block_name
65
+ option.apply(engin)
66
+
67
+ @classmethod
68
+ def _method_options(cls) -> Iterable[Provide | Invoke]:
69
+ for _, method in inspect.getmembers(cls):
70
+ if option := getattr(method, "_opt", None):
71
+ if not isinstance(option, Provide | Invoke):
59
72
  raise RuntimeError("Block option is not an instance of Provide or Invoke")
60
- opt.set_block_name(self._name)
61
- self._options.append(opt)
62
- for opt in self.options:
63
- opt.set_block_name(self._name)
64
-
65
- @property
66
- def name(self) -> str:
67
- return self._name
68
-
69
- def __iter__(self) -> Iterator[Provide | Invoke]:
70
- return iter(self._options)
73
+ yield option
@@ -0,0 +1,13 @@
1
+ try:
2
+ import typer
3
+ except ImportError:
4
+ raise ImportError(
5
+ "Unable to import typer, to use the engin cli please install the"
6
+ " `cli` extra, e.g. pip install engin[cli]"
7
+ ) from None
8
+
9
+ from engin._cli._graph import cli as graph_cli
10
+
11
+ app = typer.Typer()
12
+
13
+ app.add_typer(graph_cli)
@@ -1,63 +1,76 @@
1
+ import contextlib
1
2
  import importlib
2
3
  import logging
3
4
  import socketserver
4
5
  import sys
5
6
  import threading
6
- from argparse import ArgumentParser
7
7
  from http.server import BaseHTTPRequestHandler
8
8
  from time import sleep
9
- from typing import Any
9
+ from typing import Annotated, Any
10
+
11
+ import typer
12
+ from rich import print
10
13
 
11
14
  from engin import Engin, Entrypoint, Invoke
15
+ from engin._cli._utils import print_error
12
16
  from engin._dependency import Dependency, Provide, Supply
13
17
  from engin.ext.asgi import ASGIEngin
14
- from engin.ext.fastapi import APIRouteDependency
18
+
19
+ try:
20
+ from engin.ext.fastapi import APIRouteDependency
21
+ except ImportError:
22
+ APIRouteDependency = None # type: ignore[assignment,misc]
23
+
24
+ cli = typer.Typer()
25
+
15
26
 
16
27
  # mute logging from importing of files + engin's debug logging.
17
28
  logging.disable()
18
29
 
19
- args = ArgumentParser(
20
- prog="engin-graph",
21
- description="Creates a visualisation of your application's dependencies",
22
- )
23
- args.add_argument(
24
- "app",
25
- help=(
26
- "the import path of your Engin instance, in the form "
27
- "'package:application', e.g. 'app.main:engin'"
28
- ),
29
- )
30
30
 
31
31
  _APP_ORIGIN = ""
32
32
 
33
-
34
- def serve_graph() -> None:
33
+ _CLI_HELP = {
34
+ "app": (
35
+ "The import path of your Engin instance, in the form 'package:application'"
36
+ ", e.g. 'app.main:engin'"
37
+ )
38
+ }
39
+
40
+
41
+ @cli.command(name="graph")
42
+ def serve_graph(
43
+ app: Annotated[
44
+ str,
45
+ typer.Argument(help=_CLI_HELP["app"]),
46
+ ],
47
+ ) -> None:
48
+ """
49
+ Creates a visualisation of your application's dependencies.
50
+ """
35
51
  # add cwd to path to enable local package imports
36
52
  sys.path.insert(0, "")
37
53
 
38
- parsed = args.parse_args()
39
-
40
- app = parsed.app
41
-
42
54
  try:
43
55
  module_name, engin_name = app.split(":", maxsplit=1)
44
56
  except ValueError:
45
- raise ValueError(
46
- "Expected an argument of the form 'module:attribute', e.g. 'myapp:engin'"
47
- ) from None
57
+ print_error("Expected an argument of the form 'module:attribute', e.g. 'myapp:engin'")
48
58
 
49
59
  global _APP_ORIGIN
50
60
  _APP_ORIGIN = module_name.split(".", maxsplit=1)[0]
51
61
 
52
- module = importlib.import_module(module_name)
62
+ try:
63
+ module = importlib.import_module(module_name)
64
+ except ModuleNotFoundError:
65
+ print_error(f"unable to find module '{module_name}'")
53
66
 
54
67
  try:
55
68
  instance = getattr(module, engin_name)
56
- except LookupError:
57
- raise LookupError(f"Module '{module_name}' has no attribute '{engin_name}'") from None
69
+ except AttributeError:
70
+ print_error(f"module '{module_name}' has no attribute '{engin_name}'")
58
71
 
59
72
  if not isinstance(instance, Engin):
60
- raise TypeError(f"'{app}' is not an Engin instance")
73
+ print_error(f"'{app}' is not an Engin instance")
61
74
 
62
75
  nodes = instance.graph()
63
76
 
@@ -96,10 +109,14 @@ def serve_graph() -> None:
96
109
  server_thread.daemon = True # Daemonize the thread so it exits when the main script exits
97
110
  server_thread.start()
98
111
 
99
- try:
100
- sleep(10000)
101
- except KeyboardInterrupt:
102
- print("Exiting the server...")
112
+ with contextlib.suppress(KeyboardInterrupt):
113
+ wait_for_interrupt()
114
+
115
+ print("Exiting the server...")
116
+
117
+
118
+ def wait_for_interrupt() -> None:
119
+ sleep(10000)
103
120
 
104
121
 
105
122
  _BLOCK_IDX: dict[str, int] = {}
@@ -0,0 +1,18 @@
1
+ from typing import Never
2
+
3
+ import typer
4
+ from rich import print
5
+ from rich.panel import Panel
6
+
7
+
8
+ def print_error(msg: str) -> Never:
9
+ print(
10
+ Panel(
11
+ title="Error",
12
+ renderable=msg.capitalize(),
13
+ title_align="left",
14
+ border_style="red",
15
+ highlight=True,
16
+ )
17
+ )
18
+ raise typer.Exit(code=1)