engin 0.0.10__tar.gz → 0.0.12__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 (82) hide show
  1. {engin-0.0.10 → engin-0.0.12}/.github/workflows/check.yaml +6 -1
  2. {engin-0.0.10 → engin-0.0.12}/CHANGELOG.md +26 -0
  3. {engin-0.0.10 → engin-0.0.12}/PKG-INFO +3 -1
  4. {engin-0.0.10 → engin-0.0.12}/README.md +2 -0
  5. {engin-0.0.10 → engin-0.0.12}/pyproject.toml +10 -3
  6. {engin-0.0.10 → engin-0.0.12}/src/engin/_assembler.py +38 -1
  7. {engin-0.0.10 → engin-0.0.12}/src/engin/_dependency.py +33 -10
  8. {engin-0.0.10 → engin-0.0.12}/src/engin/ext/asgi.py +9 -2
  9. {engin-0.0.10 → engin-0.0.12}/src/engin/ext/fastapi.py +31 -14
  10. {engin-0.0.10 → engin-0.0.12}/src/engin/scripts/graph.py +28 -3
  11. engin-0.0.12/tests/acceptance/test_fastapi.py +72 -0
  12. {engin-0.0.10 → engin-0.0.12}/tests/test_assembler.py +37 -0
  13. {engin-0.0.10 → engin-0.0.12}/tests/test_dependencies.py +36 -2
  14. {engin-0.0.10 → engin-0.0.12}/uv.lock +144 -120
  15. {engin-0.0.10 → engin-0.0.12}/.github/workflows/publish.yaml +0 -0
  16. {engin-0.0.10 → engin-0.0.12}/.gitignore +0 -0
  17. {engin-0.0.10 → engin-0.0.12}/.readthedocs.yaml +0 -0
  18. {engin-0.0.10 → engin-0.0.12}/LICENSE +0 -0
  19. {engin-0.0.10 → engin-0.0.12}/docs/concepts/engin.md +0 -0
  20. {engin-0.0.10 → engin-0.0.12}/docs/concepts/invocations.md +0 -0
  21. {engin-0.0.10 → engin-0.0.12}/docs/concepts/lifecycle.md +0 -0
  22. {engin-0.0.10 → engin-0.0.12}/docs/concepts/providers.md +0 -0
  23. {engin-0.0.10 → engin-0.0.12}/docs/getting-started.md +0 -0
  24. {engin-0.0.10 → engin-0.0.12}/docs/guides/dependency_injection.md +0 -0
  25. {engin-0.0.10 → engin-0.0.12}/docs/guides/fastapi-graph.png +0 -0
  26. {engin-0.0.10 → engin-0.0.12}/docs/guides/fastapi.md +0 -0
  27. {engin-0.0.10 → engin-0.0.12}/docs/index.md +0 -0
  28. {engin-0.0.10 → engin-0.0.12}/docs/js/readthedocs.js +0 -0
  29. {engin-0.0.10 → engin-0.0.12}/docs/overrides/main.html +0 -0
  30. {engin-0.0.10 → engin-0.0.12}/docs/reference.md +0 -0
  31. {engin-0.0.10 → engin-0.0.12}/examples/__init__.py +0 -0
  32. {engin-0.0.10 → engin-0.0.12}/examples/asgi/__init__.py +0 -0
  33. {engin-0.0.10 → engin-0.0.12}/examples/asgi/app.py +0 -0
  34. {engin-0.0.10 → engin-0.0.12}/examples/asgi/common/__init__.py +0 -0
  35. {engin-0.0.10 → engin-0.0.12}/examples/asgi/common/db/__init__.py +0 -0
  36. {engin-0.0.10 → engin-0.0.12}/examples/asgi/common/db/adapaters/__init__.py +0 -0
  37. {engin-0.0.10 → engin-0.0.12}/examples/asgi/common/db/adapaters/memory.py +0 -0
  38. {engin-0.0.10 → engin-0.0.12}/examples/asgi/common/db/block.py +0 -0
  39. {engin-0.0.10 → engin-0.0.12}/examples/asgi/common/db/ports.py +0 -0
  40. {engin-0.0.10 → engin-0.0.12}/examples/asgi/common/starlette/__init__.py +0 -0
  41. {engin-0.0.10 → engin-0.0.12}/examples/asgi/common/starlette/endpoint.py +0 -0
  42. {engin-0.0.10 → engin-0.0.12}/examples/asgi/features/__init__.py +0 -0
  43. {engin-0.0.10 → engin-0.0.12}/examples/asgi/features/cats/__init__.py +0 -0
  44. {engin-0.0.10 → engin-0.0.12}/examples/asgi/features/cats/api/__init__.py +0 -0
  45. {engin-0.0.10 → engin-0.0.12}/examples/asgi/features/cats/api/get.py +0 -0
  46. {engin-0.0.10 → engin-0.0.12}/examples/asgi/features/cats/api/post.py +0 -0
  47. {engin-0.0.10 → engin-0.0.12}/examples/asgi/features/cats/block.py +0 -0
  48. {engin-0.0.10 → engin-0.0.12}/examples/asgi/features/cats/domain.py +0 -0
  49. {engin-0.0.10 → engin-0.0.12}/examples/asgi/main.py +0 -0
  50. {engin-0.0.10 → engin-0.0.12}/examples/fastapi/__init__.py +0 -0
  51. {engin-0.0.10 → engin-0.0.12}/examples/fastapi/app.py +0 -0
  52. {engin-0.0.10 → engin-0.0.12}/examples/fastapi/main.py +0 -0
  53. {engin-0.0.10 → engin-0.0.12}/examples/fastapi/routes/__init__.py +0 -0
  54. {engin-0.0.10 → engin-0.0.12}/examples/fastapi/routes/cats/__init__.py +0 -0
  55. {engin-0.0.10 → engin-0.0.12}/examples/fastapi/routes/cats/adapters/__init__.py +0 -0
  56. {engin-0.0.10 → engin-0.0.12}/examples/fastapi/routes/cats/adapters/repository.py +0 -0
  57. {engin-0.0.10 → engin-0.0.12}/examples/fastapi/routes/cats/api.py +0 -0
  58. {engin-0.0.10 → engin-0.0.12}/examples/fastapi/routes/cats/block.py +0 -0
  59. {engin-0.0.10 → engin-0.0.12}/examples/fastapi/routes/cats/domain.py +0 -0
  60. {engin-0.0.10 → engin-0.0.12}/examples/fastapi/routes/cats/ports.py +0 -0
  61. {engin-0.0.10 → engin-0.0.12}/examples/simple/__init__.py +0 -0
  62. {engin-0.0.10 → engin-0.0.12}/examples/simple/main.py +0 -0
  63. {engin-0.0.10 → engin-0.0.12}/mkdocs.yaml +0 -0
  64. {engin-0.0.10 → engin-0.0.12}/src/engin/__init__.py +0 -0
  65. {engin-0.0.10 → engin-0.0.12}/src/engin/_block.py +0 -0
  66. {engin-0.0.10 → engin-0.0.12}/src/engin/_engin.py +0 -0
  67. {engin-0.0.10 → engin-0.0.12}/src/engin/_exceptions.py +0 -0
  68. {engin-0.0.10 → engin-0.0.12}/src/engin/_graph.py +0 -0
  69. {engin-0.0.10 → engin-0.0.12}/src/engin/_lifecycle.py +0 -0
  70. {engin-0.0.10 → engin-0.0.12}/src/engin/_type_utils.py +0 -0
  71. {engin-0.0.10 → engin-0.0.12}/src/engin/ext/__init__.py +0 -0
  72. {engin-0.0.10 → engin-0.0.12}/src/engin/py.typed +0 -0
  73. {engin-0.0.10 → engin-0.0.12}/src/engin/scripts/__init__.py +0 -0
  74. {engin-0.0.10 → engin-0.0.12}/tests/__init__.py +0 -0
  75. {engin-0.0.10 → engin-0.0.12}/tests/acceptance/__init__.py +0 -0
  76. {engin-0.0.10 → engin-0.0.12}/tests/acceptance/test_error_in_shutdown.py +0 -0
  77. {engin-0.0.10 → engin-0.0.12}/tests/acceptance/test_error_in_start_up.py +0 -0
  78. {engin-0.0.10 → engin-0.0.12}/tests/conftest.py +0 -0
  79. {engin-0.0.10 → engin-0.0.12}/tests/deps.py +0 -0
  80. {engin-0.0.10 → engin-0.0.12}/tests/test_engin.py +0 -0
  81. {engin-0.0.10 → engin-0.0.12}/tests/test_modules.py +0 -0
  82. {engin-0.0.10 → engin-0.0.12}/tests/test_utils.py +0 -0
@@ -32,4 +32,9 @@ jobs:
32
32
  run: uv run poe check
33
33
 
34
34
  - name: Test
35
- run: uv run poe test
35
+ run: uv run poe ci-test
36
+
37
+ - name: Upload coverage reports to Codecov
38
+ uses: codecov/codecov-action@v5
39
+ with:
40
+ token: ${{ secrets.CODECOV_TOKEN }}
@@ -6,6 +6,32 @@ 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.12] - 2025-03-03
10
+
11
+ ### Added
12
+
13
+ - `Assembler` has a new method `add(provider: Provide) -> None` which allows adding a
14
+ provider to the Assembler post initialisation.
15
+
16
+ ### Changed
17
+
18
+ - `Provide` now raises a `ValueError` if the factory function is circular, i.e. one of its
19
+ parameters is the same as its return type as the behaviour of this is undefined.
20
+ - The ASGI utility method `engin_to_lifespan` has been improved so that it works "out of
21
+ the box" for more use cases now.
22
+
23
+
24
+ ## [0.0.11] - 2025-03-02
25
+
26
+ ### Added
27
+
28
+ - Dependency types now have two new attributes `source_module` & `source_package`.
29
+
30
+ ### Changed
31
+
32
+ - `engin-graph` now highlights external dependencies.
33
+
34
+
9
35
  ## [0.0.10] - 2025-02-27
10
36
 
11
37
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: engin
3
- Version: 0.0.10
3
+ Version: 0.0.12
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/
@@ -12,6 +12,8 @@ Keywords: Application Framework,Dependency Injection
12
12
  Requires-Python: >=3.10
13
13
  Description-Content-Type: text/markdown
14
14
 
15
+ [![codecov](https://codecov.io/gh/invokermain/engin/graph/badge.svg?token=4PJOIMV6IB)](https://codecov.io/gh/invokermain/engin)
16
+
15
17
  # Engin 🏎️
16
18
 
17
19
  Engin is a zero-dependency application framework for modern Python.
@@ -1,3 +1,5 @@
1
+ [![codecov](https://codecov.io/gh/invokermain/engin/graph/badge.svg?token=4PJOIMV6IB)](https://codecov.io/gh/invokermain/engin)
2
+
1
3
  # Engin 🏎️
2
4
 
3
5
  Engin is a zero-dependency application framework for modern Python.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "engin"
3
- version = "0.0.10"
3
+ version = "0.0.12"
4
4
  description = "An async-first modular application framework"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -32,6 +32,7 @@ dev-dependencies = [
32
32
  "ruff>=0",
33
33
  "starlette>=0.39.2",
34
34
  "uvicorn>=0.31.1",
35
+ "pytest-cov>=6.0.0",
35
36
  ]
36
37
 
37
38
 
@@ -72,12 +73,17 @@ ignore = [
72
73
 
73
74
 
74
75
  [tool.pytest.ini_options]
75
- log_cli = true
76
+ log_cli = false
76
77
  log_cli_level = "DEBUG"
77
78
  asyncio_mode = "auto"
78
79
  asyncio_default_fixture_loop_scope = "session"
79
80
 
80
81
 
82
+ [tool.coverage.run]
83
+ source = ["src"]
84
+ omit = ["src/engin/scripts/**"]
85
+
86
+
81
87
  [tool.mypy]
82
88
  strict = true
83
89
  disable_error_code = [
@@ -102,5 +108,6 @@ check.sequence = [
102
108
  fix.default_item_type = "cmd"
103
109
  fix.sequence = ["ruff check src tests examples --fix"]
104
110
 
105
- test = "pytest -s tests"
111
+ test = "pytest tests"
112
+ ci-test = "pytest --cov=engin --cov-branch --cov-report=xml tests"
106
113
  docs = "mkdocs serve"
@@ -141,7 +141,44 @@ class Assembler:
141
141
  return value # type: ignore[return-value]
142
142
 
143
143
  def has(self, type_: type[T]) -> bool:
144
- return type_id_of(type_) in self._providers
144
+ """
145
+ Returns True if this Assembler has a provider for the given type.
146
+
147
+ Args:
148
+ type_: the type to check.
149
+
150
+ Returns:
151
+ True if the Assembler has a provider for type else False.
152
+ """
153
+ type_id = type_id_of(type_)
154
+ if type_id.multi:
155
+ return type_id in self._multiproviders
156
+ else:
157
+ return type_id in self._providers
158
+
159
+ def add(self, provider: Provide) -> None:
160
+ """
161
+ Add a provider to the Assembler post-initialisation.
162
+
163
+ Args:
164
+ provider: the Provide instance to add.
165
+
166
+ Returns:
167
+ None
168
+
169
+ Raises:
170
+ ValueError: if a provider for this type already exists.
171
+ """
172
+ type_id = provider.return_type_id
173
+ 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]
178
+ else:
179
+ if type_id in self._providers:
180
+ raise ValueError(f"A provider for '{type_id}' already exists")
181
+ self._providers[type_id] = provider
145
182
 
146
183
  def _resolve_providers(self, type_id: TypeId) -> Collection[Provide]:
147
184
  if type_id.multi:
@@ -3,6 +3,7 @@ import typing
3
3
  from abc import ABC
4
4
  from collections.abc import Awaitable, Callable
5
5
  from inspect import Parameter, Signature, isclass, iscoroutinefunction
6
+ from types import FrameType
6
7
  from typing import (
7
8
  Any,
8
9
  Generic,
@@ -23,22 +24,43 @@ Func: TypeAlias = Callable[P, T]
23
24
  def _noop(*args: Any, **kwargs: Any) -> None: ...
24
25
 
25
26
 
27
+ def _walk_stack() -> FrameType:
28
+ stack = inspect.stack()[1]
29
+ frame = stack.frame
30
+ while True:
31
+ if frame.f_globals["__package__"] != "engin" or frame.f_back is None:
32
+ return frame
33
+ else:
34
+ frame = frame.f_back
35
+
36
+
26
37
  class Dependency(ABC, Generic[P, T]):
27
38
  def __init__(self, func: Func[P, T], block_name: str | None = None) -> None:
28
39
  self._func = func
29
40
  self._is_async = iscoroutinefunction(func)
30
41
  self._signature = inspect.signature(self._func)
31
42
  self._block_name = block_name
43
+ self._source_frame = _walk_stack()
32
44
 
33
45
  @property
34
- def origin(self) -> str:
46
+ def source_module(self) -> str:
35
47
  """
36
48
  The module that this Dependency originated from.
37
49
 
38
50
  Returns:
39
51
  A string, e.g. "examples.fastapi.app"
40
52
  """
41
- return self._func.__module__
53
+ return self._source_frame.f_globals["__name__"] # type: ignore[no-any-return]
54
+
55
+ @property
56
+ def source_package(self) -> str:
57
+ """
58
+ The package that this Dependency originated from.
59
+
60
+ Returns:
61
+ A string, e.g. "engin"
62
+ """
63
+ return self._source_frame.f_globals["__package__"] # type: ignore[no-any-return]
42
64
 
43
65
  @property
44
66
  def block_name(self) -> str | None:
@@ -115,10 +137,6 @@ class Entrypoint(Invoke):
115
137
  self._type = type_
116
138
  super().__init__(invocation=_noop, block_name=block_name)
117
139
 
118
- @property
119
- def origin(self) -> str:
120
- return self._type.__module__
121
-
122
140
  @property
123
141
  def parameter_types(self) -> list[TypeId]:
124
142
  return [type_id_of(self._type)]
@@ -140,6 +158,15 @@ class Provide(Dependency[Any, T]):
140
158
  super().__init__(func=builder, block_name=block_name)
141
159
  self._is_multi = typing.get_origin(self.return_type) is list
142
160
 
161
+ # Validate that the provider does to depend on its own output value, as this will
162
+ # cause a recursion error and is undefined behaviour wise.
163
+ if any(
164
+ self.return_type == param.annotation
165
+ for param in self.signature.parameters.values()
166
+ ):
167
+ raise ValueError("A provider cannot depend on its own return type")
168
+
169
+ # Validate that multiproviders only return a list of one type.
143
170
  if self._is_multi:
144
171
  args = typing.get_args(self.return_type)
145
172
  if len(args) != 1:
@@ -186,10 +213,6 @@ class Supply(Provide, Generic[T]):
186
213
  self._get_val.__annotations__["return"] = type_hint
187
214
  super().__init__(builder=self._get_val, block_name=block_name)
188
215
 
189
- @property
190
- def origin(self) -> str:
191
- return self._value.__module__
192
-
193
216
  @property
194
217
  def return_type(self) -> type[T]:
195
218
  if self._type_hint is not None:
@@ -1,9 +1,10 @@
1
+ import contextlib
1
2
  import traceback
2
3
  from collections.abc import AsyncIterator, Awaitable, Callable, MutableMapping
3
4
  from contextlib import AbstractAsyncContextManager, asynccontextmanager
4
5
  from typing import Any, ClassVar, Protocol, TypeAlias
5
6
 
6
- from engin import Engin, Entrypoint, Option
7
+ from engin import Engin, Entrypoint, Option, Supply
7
8
 
8
9
  __all__ = ["ASGIEngin", "ASGIType", "engin_to_lifespan"]
9
10
 
@@ -79,7 +80,13 @@ def engin_to_lifespan(engin: Engin) -> Callable[[ASGIType], AbstractAsyncContext
79
80
  """
80
81
 
81
82
  @asynccontextmanager
82
- async def engin_lifespan(_: ASGIType) -> AsyncIterator[None]:
83
+ async def engin_lifespan(app: ASGIType) -> AsyncIterator[None]:
84
+ # ensure the Engin
85
+ with contextlib.suppress(ValueError):
86
+ engin.assembler.add(Supply(app))
87
+
88
+ app.state.assembler = engin.assembler # type: ignore[attr-defined]
89
+
83
90
  await engin.start()
84
91
  yield
85
92
  await engin.stop()
@@ -6,8 +6,8 @@ from typing import ClassVar, TypeVar
6
6
 
7
7
  from fastapi.routing import APIRoute
8
8
 
9
- from engin import Engin, Entrypoint, Invoke, Option
10
- from engin._dependency import Dependency, Supply
9
+ from engin import Assembler, Engin, Entrypoint, Invoke, Option
10
+ from engin._dependency import Dependency, Supply, _noop
11
11
  from engin._graph import DependencyGrapher, Node
12
12
  from engin._type_utils import TypeId, type_id_of
13
13
  from engin.ext.asgi import ASGIEngin
@@ -24,15 +24,16 @@ except ImportError as err:
24
24
  __all__ = ["APIRouteDependency", "FastAPIEngin", "Inject"]
25
25
 
26
26
 
27
- def _attach_engin(
28
- app: FastAPI,
29
- engin: Engin,
30
- ) -> None:
31
- app.state.engin = engin
27
+ def _attach_assembler(app: FastAPI, engin: Engin) -> None:
28
+ """
29
+ An invocation that attaches the Engin's Assembler to the FastAPI application, enabling
30
+ the Inject marker.
31
+ """
32
+ app.state.assembler = engin.assembler
32
33
 
33
34
 
34
35
  class FastAPIEngin(ASGIEngin):
35
- _LIB_OPTIONS: ClassVar[list[Option]] = [*ASGIEngin._LIB_OPTIONS, Invoke(_attach_engin)]
36
+ _LIB_OPTIONS: ClassVar[list[Option]] = [*ASGIEngin._LIB_OPTIONS, Invoke(_attach_assembler)]
36
37
  _asgi_type = FastAPI
37
38
 
38
39
  def graph(self) -> list[Node]:
@@ -40,7 +41,7 @@ class FastAPIEngin(ASGIEngin):
40
41
  return grapher.resolve(
41
42
  [
42
43
  Entrypoint(self._asgi_type),
43
- *[i for i in self._invocations if i.func_name != "_attach_engin"],
44
+ *[i for i in self._invocations if i.func_name != "_attach_assembler"],
44
45
  ]
45
46
  )
46
47
 
@@ -50,8 +51,11 @@ T = TypeVar("T")
50
51
 
51
52
  def Inject(interface: type[T]) -> Depends:
52
53
  async def inner(conn: HTTPConnection) -> T:
53
- engin: Engin = conn.app.state.engin
54
- return await engin.assembler.get(interface)
54
+ try:
55
+ assembler: Assembler = conn.app.state.assembler
56
+ except AttributeError:
57
+ raise RuntimeError("Assembler is not attached to Application state") from None
58
+ return await assembler.get(interface)
55
59
 
56
60
  dep = Depends(inner)
57
61
  dep.__engin__ = True # type: ignore[attr-defined]
@@ -113,7 +117,7 @@ def _extract_routes_from_supply(supply: Supply) -> list[Dependency]:
113
117
  inner = supply._value[0]
114
118
  if isinstance(inner, APIRouter):
115
119
  return [
116
- APIRouteDependency(route, block_name=supply.block_name)
120
+ APIRouteDependency(supply, route)
117
121
  for route in inner.routes
118
122
  if isinstance(route, APIRoute)
119
123
  ]
@@ -128,13 +132,26 @@ class APIRouteDependency(Dependency):
128
132
  This class should never be constructed in application code.
129
133
  """
130
134
 
131
- def __init__(self, route: APIRoute, block_name: str | None = None) -> None:
135
+ def __init__(
136
+ self,
137
+ wraps: Dependency,
138
+ route: APIRoute,
139
+ ) -> None:
132
140
  """
133
141
  Warning: this should never be constructed in application code.
134
142
  """
143
+ super().__init__(_noop, wraps.block_name)
144
+ self._wrapped = wraps
135
145
  self._route = route
136
146
  self._signature = inspect.signature(route.endpoint)
137
- self._block_name = block_name
147
+
148
+ @property
149
+ def source_module(self) -> str:
150
+ return self._wrapped.source_module
151
+
152
+ @property
153
+ def source_package(self) -> str:
154
+ return self._wrapped.source_package
138
155
 
139
156
  @property
140
157
  def route(self) -> APIRoute:
@@ -28,6 +28,8 @@ args.add_argument(
28
28
  ),
29
29
  )
30
30
 
31
+ _APP_ORIGIN = ""
32
+
31
33
 
32
34
  def serve_graph() -> None:
33
35
  # add cwd to path to enable local package imports
@@ -44,6 +46,9 @@ def serve_graph() -> None:
44
46
  "Expected an argument of the form 'module:attribute', e.g. 'myapp:engin'"
45
47
  ) from None
46
48
 
49
+ global _APP_ORIGIN
50
+ _APP_ORIGIN = module_name.split(".", maxsplit=1)[0]
51
+
47
52
  module = importlib.import_module(module_name)
48
53
 
49
54
  try:
@@ -112,7 +117,17 @@ def _render_node(node: Dependency) -> str:
112
117
  if n not in _BLOCK_IDX:
113
118
  _BLOCK_IDX[n] = len(_SEEN_BLOCKS) % 8
114
119
  _SEEN_BLOCKS.append(n)
115
- style = f":::b{_BLOCK_IDX[n]}"
120
+ style = f"b{_BLOCK_IDX[n]}"
121
+
122
+ node_root_package = node.source_package.split(".", maxsplit=1)[0]
123
+ if node_root_package != _APP_ORIGIN:
124
+ if style:
125
+ style += "E"
126
+ else:
127
+ style = "external"
128
+
129
+ if style:
130
+ style = f":::{style}"
116
131
 
117
132
  if isinstance(node, Supply):
118
133
  md += f"{node.return_type_id}"
@@ -144,6 +159,7 @@ _GRAPH_HTML = """
144
159
  graph LR
145
160
  %%LEGEND%%
146
161
  classDef b0 fill:#7fc97f;
162
+ classDef external stroke-dasharray: 5 5;
147
163
  </pre>
148
164
  </div>
149
165
  <pre class="mermaid">
@@ -157,6 +173,15 @@ _GRAPH_HTML = """
157
173
  classDef b5 fill:#f0027f;
158
174
  classDef b6 fill:#bf5b17;
159
175
  classDef b7 fill:#666666;
176
+ classDef b0E fill:#7fc97f,stroke-dasharray: 5 5;
177
+ classDef b1E fill:#beaed4,stroke-dasharray: 5 5;
178
+ classDef b2E fill:#fdc086,stroke-dasharray: 5 5;
179
+ classDef b3E fill:#ffff99,stroke-dasharray: 5 5;
180
+ classDef b4E fill:#386cb0,stroke-dasharray: 5 5;
181
+ classDef b5E fill:#f0027f,stroke-dasharray: 5 5;
182
+ classDef b6E fill:#bf5b17,stroke-dasharray: 5 5;
183
+ classDef b7E fill:#666666,stroke-dasharray: 5 5;
184
+ classDef external stroke-dasharray: 5 5;
160
185
  </pre>
161
186
  <script type="module">
162
187
  import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
@@ -169,6 +194,6 @@ _GRAPH_HTML = """
169
194
 
170
195
  DEFAULT_LEGEND = (
171
196
  "0[/Invoke/] ~~~ 1[/Entrypoint\\] ~~~ 2[Provide] ~~~ 3(Supply)"
172
- ' ~~~ 4["`Block Grouping`"]:::b0'
197
+ ' ~~~ 4["`Block Grouping`"]:::b0 ~~~ 5[External Dependency]:::external'
173
198
  )
174
- ASGI_ENGIN_LEGEND = DEFAULT_LEGEND + " ~~~ 5[[API Route]]"
199
+ ASGI_ENGIN_LEGEND = DEFAULT_LEGEND + " ~~~ 6[[API Route]]"
@@ -0,0 +1,72 @@
1
+ from typing import Annotated
2
+
3
+ import pytest
4
+ import starlette.testclient
5
+ from fastapi import APIRouter, FastAPI
6
+
7
+ from engin import Engin, Provide, Supply
8
+ from engin.ext.asgi import engin_to_lifespan
9
+ from engin.ext.fastapi import APIRouteDependency, FastAPIEngin, Inject
10
+
11
+ ROUTER = APIRouter(prefix="")
12
+
13
+
14
+ @ROUTER.get("/")
15
+ async def hello_world() -> str:
16
+ return "hello world"
17
+
18
+
19
+ @ROUTER.get("/inject")
20
+ async def route_with_dep(some_int: Annotated[int, Inject(int)]) -> int:
21
+ return some_int
22
+
23
+
24
+ def app_factory(routers: list[APIRouter]) -> FastAPI:
25
+ app = FastAPI()
26
+ for router in routers:
27
+ app.include_router(router)
28
+ return app
29
+
30
+
31
+ async def test_fastapi():
32
+ engin = FastAPIEngin(Provide(app_factory), Supply([ROUTER]))
33
+
34
+ with starlette.testclient.TestClient(engin) as client:
35
+ result = client.get("http://127.0.0.1:8000/")
36
+
37
+ assert result.json() == "hello world"
38
+
39
+
40
+ async def test_inject():
41
+ engin = FastAPIEngin(Provide(app_factory), Supply([ROUTER]), Supply(10))
42
+
43
+ with starlette.testclient.TestClient(engin) as client:
44
+ result = client.get("http://127.0.0.1:8000/inject")
45
+
46
+ assert result.json() == 10
47
+
48
+
49
+ async def test_graph():
50
+ engin = FastAPIEngin(Provide(app_factory), Supply([ROUTER]), Supply(10))
51
+
52
+ nodes = engin.graph()
53
+
54
+ assert len(nodes) == 5
55
+ assert len([node for node in nodes if isinstance(node.node, APIRouteDependency)]) == 2
56
+
57
+
58
+ async def test_invalid_engin():
59
+ with pytest.raises(LookupError, match="FastAPI"):
60
+ FastAPIEngin()
61
+
62
+
63
+ async def test_engin_to_lifespan():
64
+ engin = Engin(Supply(10))
65
+
66
+ app = FastAPI(lifespan=engin_to_lifespan(engin))
67
+ app.include_router(ROUTER)
68
+
69
+ with starlette.testclient.TestClient(app) as client:
70
+ result = client.get("http://127.0.0.1:8000/inject")
71
+
72
+ assert result.json() == 10
@@ -83,3 +83,40 @@ async def test_annotations():
83
83
 
84
84
  assert await assembler.get(Annotated[str, "1"]) == "bar"
85
85
  assert await assembler.get(Annotated[str, "2"]) == "foo"
86
+
87
+
88
+ async def test_assembler_has():
89
+ def make_str() -> str:
90
+ raise RuntimeError("foo")
91
+
92
+ assembler = Assembler([Provide(make_str)])
93
+
94
+ assert assembler.has(str)
95
+ assert not assembler.has(int)
96
+ assert not assembler.has(list[str])
97
+
98
+
99
+ async def test_assembler_has_multi():
100
+ def make_str() -> list[str]:
101
+ raise RuntimeError("foo")
102
+
103
+ assembler = Assembler([Provide(make_str)])
104
+
105
+ assert assembler.has(list[str])
106
+ assert not assembler.has(int)
107
+ assert not assembler.has(str)
108
+
109
+
110
+ async def test_assembler_add():
111
+ assembler = Assembler([])
112
+ assembler.add(Provide(make_int))
113
+ assembler.add(Provide(make_many_int))
114
+
115
+ assert assembler.has(int)
116
+ assert assembler.has(list[int])
117
+
118
+ with pytest.raises(ValueError, match="exists"):
119
+ assembler.add(Provide(make_int))
120
+
121
+ # can always add more multiproviders
122
+ assembler.add(Provide(make_many_int))
@@ -1,8 +1,10 @@
1
1
  from typing import Annotated
2
2
 
3
+ import pytest
4
+
3
5
  from engin import Provide
4
- from engin._dependency import Supply
5
- from tests.deps import make_aliased_int
6
+ from engin._dependency import Entrypoint, Supply
7
+ from tests.deps import make_aliased_int, make_int
6
8
 
7
9
 
8
10
  def test_provide_discriminates_singular():
@@ -57,3 +59,35 @@ def test_provide_with_annotation():
57
59
 
58
60
  assert provider.return_type_id.type
59
61
  assert str(provider.return_type_id) == "Annotated[str, 1]"
62
+
63
+
64
+ def test_dependency_sources():
65
+ provide = Provide(make_int)
66
+ assert provide.source_module == "tests.test_dependencies"
67
+ assert provide.source_package == "tests"
68
+
69
+ supply = Supply(3)
70
+ assert supply.source_module == "tests.test_dependencies"
71
+ assert supply.source_package == "tests"
72
+
73
+ invoke = Provide(make_int)
74
+ assert invoke.source_module == "tests.test_dependencies"
75
+ assert invoke.source_package == "tests"
76
+
77
+ entrypoint = Entrypoint(3)
78
+ assert entrypoint.source_module == "tests.test_dependencies"
79
+ assert entrypoint.source_package == "tests"
80
+
81
+
82
+ def test_provider_cannot_depend_on_self():
83
+ def invalid_provider_1(a: int) -> int:
84
+ return 1
85
+
86
+ def invalid_provider_2(a: list[int]) -> list[int]:
87
+ return [1]
88
+
89
+ with pytest.raises(ValueError, match="return type"):
90
+ Provide(invalid_provider_1)
91
+
92
+ with pytest.raises(ValueError, match="return type"):
93
+ Provide(invalid_provider_2)