engin 0.0.9__tar.gz → 0.0.11__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.9 → engin-0.0.11}/.github/workflows/check.yaml +6 -1
  2. {engin-0.0.9 → engin-0.0.11}/CHANGELOG.md +24 -0
  3. {engin-0.0.9 → engin-0.0.11}/PKG-INFO +3 -1
  4. {engin-0.0.9 → engin-0.0.11}/README.md +2 -0
  5. {engin-0.0.9 → engin-0.0.11}/docs/concepts/engin.md +1 -0
  6. {engin-0.0.9 → engin-0.0.11}/docs/concepts/invocations.md +2 -1
  7. engin-0.0.11/docs/getting-started.md +7 -0
  8. engin-0.0.11/docs/guides/fastapi-graph.png +0 -0
  9. engin-0.0.11/docs/guides/fastapi.md +174 -0
  10. {engin-0.0.9 → engin-0.0.11}/docs/index.md +4 -12
  11. engin-0.0.11/docs/reference.md +12 -0
  12. {engin-0.0.9 → engin-0.0.11}/mkdocs.yaml +22 -0
  13. {engin-0.0.9 → engin-0.0.11}/pyproject.toml +7 -2
  14. {engin-0.0.9 → engin-0.0.11}/src/engin/_assembler.py +2 -0
  15. {engin-0.0.9 → engin-0.0.11}/src/engin/_dependency.py +24 -10
  16. {engin-0.0.9 → engin-0.0.11}/src/engin/_engin.py +1 -0
  17. {engin-0.0.9 → engin-0.0.11}/src/engin/ext/asgi.py +26 -2
  18. {engin-0.0.9 → engin-0.0.11}/src/engin/ext/fastapi.py +17 -4
  19. {engin-0.0.9 → engin-0.0.11}/src/engin/scripts/graph.py +28 -3
  20. {engin-0.0.9 → engin-0.0.11}/tests/test_dependencies.py +20 -2
  21. {engin-0.0.9 → engin-0.0.11}/uv.lock +144 -120
  22. engin-0.0.9/docs/engin.md +0 -1
  23. {engin-0.0.9 → engin-0.0.11}/.github/workflows/publish.yaml +0 -0
  24. {engin-0.0.9 → engin-0.0.11}/.gitignore +0 -0
  25. {engin-0.0.9 → engin-0.0.11}/.readthedocs.yaml +0 -0
  26. {engin-0.0.9 → engin-0.0.11}/LICENSE +0 -0
  27. {engin-0.0.9 → engin-0.0.11}/docs/concepts/lifecycle.md +0 -0
  28. {engin-0.0.9 → engin-0.0.11}/docs/concepts/providers.md +0 -0
  29. {engin-0.0.9 → engin-0.0.11}/docs/guides/dependency_injection.md +0 -0
  30. {engin-0.0.9 → engin-0.0.11}/docs/js/readthedocs.js +0 -0
  31. {engin-0.0.9 → engin-0.0.11}/docs/overrides/main.html +0 -0
  32. {engin-0.0.9 → engin-0.0.11}/examples/__init__.py +0 -0
  33. {engin-0.0.9 → engin-0.0.11}/examples/asgi/__init__.py +0 -0
  34. {engin-0.0.9 → engin-0.0.11}/examples/asgi/app.py +0 -0
  35. {engin-0.0.9 → engin-0.0.11}/examples/asgi/common/__init__.py +0 -0
  36. {engin-0.0.9 → engin-0.0.11}/examples/asgi/common/db/__init__.py +0 -0
  37. {engin-0.0.9 → engin-0.0.11}/examples/asgi/common/db/adapaters/__init__.py +0 -0
  38. {engin-0.0.9 → engin-0.0.11}/examples/asgi/common/db/adapaters/memory.py +0 -0
  39. {engin-0.0.9 → engin-0.0.11}/examples/asgi/common/db/block.py +0 -0
  40. {engin-0.0.9 → engin-0.0.11}/examples/asgi/common/db/ports.py +0 -0
  41. {engin-0.0.9 → engin-0.0.11}/examples/asgi/common/starlette/__init__.py +0 -0
  42. {engin-0.0.9 → engin-0.0.11}/examples/asgi/common/starlette/endpoint.py +0 -0
  43. {engin-0.0.9 → engin-0.0.11}/examples/asgi/features/__init__.py +0 -0
  44. {engin-0.0.9 → engin-0.0.11}/examples/asgi/features/cats/__init__.py +0 -0
  45. {engin-0.0.9 → engin-0.0.11}/examples/asgi/features/cats/api/__init__.py +0 -0
  46. {engin-0.0.9 → engin-0.0.11}/examples/asgi/features/cats/api/get.py +0 -0
  47. {engin-0.0.9 → engin-0.0.11}/examples/asgi/features/cats/api/post.py +0 -0
  48. {engin-0.0.9 → engin-0.0.11}/examples/asgi/features/cats/block.py +0 -0
  49. {engin-0.0.9 → engin-0.0.11}/examples/asgi/features/cats/domain.py +0 -0
  50. {engin-0.0.9 → engin-0.0.11}/examples/asgi/main.py +0 -0
  51. {engin-0.0.9 → engin-0.0.11}/examples/fastapi/__init__.py +0 -0
  52. {engin-0.0.9 → engin-0.0.11}/examples/fastapi/app.py +0 -0
  53. {engin-0.0.9 → engin-0.0.11}/examples/fastapi/main.py +0 -0
  54. {engin-0.0.9 → engin-0.0.11}/examples/fastapi/routes/__init__.py +0 -0
  55. {engin-0.0.9 → engin-0.0.11}/examples/fastapi/routes/cats/__init__.py +0 -0
  56. {engin-0.0.9 → engin-0.0.11}/examples/fastapi/routes/cats/adapters/__init__.py +0 -0
  57. {engin-0.0.9 → engin-0.0.11}/examples/fastapi/routes/cats/adapters/repository.py +0 -0
  58. {engin-0.0.9 → engin-0.0.11}/examples/fastapi/routes/cats/api.py +0 -0
  59. {engin-0.0.9 → engin-0.0.11}/examples/fastapi/routes/cats/block.py +0 -0
  60. {engin-0.0.9 → engin-0.0.11}/examples/fastapi/routes/cats/domain.py +0 -0
  61. {engin-0.0.9 → engin-0.0.11}/examples/fastapi/routes/cats/ports.py +0 -0
  62. {engin-0.0.9 → engin-0.0.11}/examples/simple/__init__.py +0 -0
  63. {engin-0.0.9 → engin-0.0.11}/examples/simple/main.py +0 -0
  64. {engin-0.0.9 → engin-0.0.11}/src/engin/__init__.py +0 -0
  65. {engin-0.0.9 → engin-0.0.11}/src/engin/_block.py +0 -0
  66. {engin-0.0.9 → engin-0.0.11}/src/engin/_exceptions.py +0 -0
  67. {engin-0.0.9 → engin-0.0.11}/src/engin/_graph.py +0 -0
  68. {engin-0.0.9 → engin-0.0.11}/src/engin/_lifecycle.py +0 -0
  69. {engin-0.0.9 → engin-0.0.11}/src/engin/_type_utils.py +0 -0
  70. {engin-0.0.9 → engin-0.0.11}/src/engin/ext/__init__.py +0 -0
  71. {engin-0.0.9 → engin-0.0.11}/src/engin/py.typed +0 -0
  72. {engin-0.0.9 → engin-0.0.11}/src/engin/scripts/__init__.py +0 -0
  73. {engin-0.0.9 → engin-0.0.11}/tests/__init__.py +0 -0
  74. {engin-0.0.9 → engin-0.0.11}/tests/acceptance/__init__.py +0 -0
  75. {engin-0.0.9 → engin-0.0.11}/tests/acceptance/test_error_in_shutdown.py +0 -0
  76. {engin-0.0.9 → engin-0.0.11}/tests/acceptance/test_error_in_start_up.py +0 -0
  77. {engin-0.0.9 → engin-0.0.11}/tests/conftest.py +0 -0
  78. {engin-0.0.9 → engin-0.0.11}/tests/deps.py +0 -0
  79. {engin-0.0.9 → engin-0.0.11}/tests/test_assembler.py +0 -0
  80. {engin-0.0.9 → engin-0.0.11}/tests/test_engin.py +0 -0
  81. {engin-0.0.9 → engin-0.0.11}/tests/test_modules.py +0 -0
  82. {engin-0.0.9 → engin-0.0.11}/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,30 @@ 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.11] - 2025-03-02
10
+
11
+ ### Added
12
+
13
+ - Dependency types now have two new attributes `source_module` & `source_package`.
14
+
15
+ ### Changed
16
+
17
+ - `engin-graph` now highlights external dependencies.
18
+
19
+
20
+ ## [0.0.10] - 2025-02-27
21
+
22
+ ### Added
23
+
24
+ - A utility function for ASGI extension `engin_to_lifespan` enabling users to easily
25
+ integrate Engin into an existing ASGI application.
26
+ - Further documentation work, including a FastAPI guide.
27
+
28
+ ### Fixed
29
+
30
+ - The warning for missing multiproviders is only logged once for each given type now.
31
+
32
+
9
33
  ## [0.0.9] - 2025-02-22
10
34
 
11
35
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: engin
3
- Version: 0.0.9
3
+ Version: 0.0.11
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.
@@ -3,6 +3,7 @@
3
3
  The Engin is a self-contained modular application.
4
4
 
5
5
  When ran the Engin takes care of the complete application lifecycle:
6
+
6
7
  1. The Engin assembles all Invocations. Only Providers that are required to satisfy
7
8
  the Invocations parameters are assembled.
8
9
  2. All Invocations are run sequentially in the order they were passed in to the Engin.
@@ -71,7 +71,8 @@ considered idiomatic as they have more explicit semantics.
71
71
 
72
72
  Sometimes you want to have logic that runs before the lifecycle startup occurs and after
73
73
  the dependency graph is built, some examples might be:
74
- - Pining a server to check its healthy.
74
+
75
+ - Pinging a server to check its healthy.
75
76
  - Running database migrations.
76
77
  - Configuring a logger.
77
78
 
@@ -0,0 +1,7 @@
1
+ ## Installation
2
+
3
+ Engin is available on PyPI, install using your favourite dependency manager:
4
+
5
+ - **pip**:`pip install engin`
6
+ - **poetry**: `poetry add engin`
7
+ - **uv**: `uv add engin`
@@ -0,0 +1,174 @@
1
+ # FastAPI
2
+
3
+ Engin ships with a FastAPI integration that is available under the `engin.ext.fastapi`
4
+ module. The integration allows one to write idiomatic FastAPI code whilst leveraging Engin
5
+ for Dependency Injection and modularising the application.
6
+
7
+ !!! note
8
+
9
+ There is also a
10
+ [fastapi example](https://github.com/invokermain/engin/tree/main/examples/fastapi) in
11
+ the Github repo if you want to see it in action.
12
+
13
+
14
+ ## Setup
15
+
16
+ To run an empty FastAPI server with Engin, simply use the `FastAPIEngin` class and
17
+ provide an instance of a `FastAPI` application:
18
+
19
+ ```python
20
+ from engin import Supply
21
+ from engin.ext.fastapi import FastAPIEngin
22
+ from fastapi import FastAPI
23
+ import uvicorn
24
+
25
+ app = FastAPIEngin(Supply(FastAPI()))
26
+
27
+ if __name__ == "__main__":
28
+ uvicorn.run(app)
29
+ ```
30
+
31
+ The `FastAPIEngin` instance is just a thin wrapper on top of the FastAPI application and
32
+ exposes the normal ASGI application interface and therefore can be run by uvicorn or
33
+ other server implementations. Under the hood it will just pass calls to the `FastAPI`
34
+ instance that you provided.
35
+
36
+
37
+ !!!tip
38
+
39
+ It is also easy to integrate Engin with an existing FastAPI application by using the
40
+ `engin_to_lifespan` function in the `engin.ext.asgi` module.
41
+
42
+
43
+ ## Dependency Injection
44
+
45
+ FastAPI comes with its own simple dependency injection system which allows you inject
46
+ depenendencies into a route by declaring a parameter with a special type hint of the form
47
+ `Annotated[T, Depends(func)]`. For example if we wanted to inject an instance of
48
+ `SomeClass`:
49
+
50
+ ```python
51
+ async def make_some_class():
52
+ return SomeClass(a=1, b=2)
53
+
54
+ @app.get("/")
55
+ async def read_items(some_class: Annotated[SomeClass, Depends(make_some_class)]):
56
+ # do something with some_class
57
+ return "hello"
58
+ ```
59
+
60
+ Engin ships with a similar marker, called `Inject`, which can be used to inject
61
+ dependencies it has providers for, for example if Engin provided `SomeClass` instead:
62
+
63
+ ```python
64
+ @app.get("/")
65
+ async def read_items(some_class: Annotated[SomeClass, Inject(SomeClass)]):
66
+ # do something with some_class
67
+ return "hello"
68
+ ```
69
+
70
+ The `Inject` marker can be used anywhere that `Depends` can be used. This can be useful as
71
+ FastAPI dependencies can have per request lifecycle, for example if we wanted to have a
72
+ reusable SQL session per request, we could use a nested dependency:
73
+
74
+ ```python
75
+ from typing import Annotated, AsyncIterable
76
+
77
+ from engin.ext.fastapi import Inject
78
+ from fastapi import Depends
79
+
80
+ async def database_session(
81
+ database: Annotated[Database, Inject(Database)])
82
+ ) -> AsyncIterable[Session]:
83
+ with database.new_session() as session:
84
+ yield session
85
+ session.commit()
86
+
87
+ @app.post("/{id}")
88
+ async def add_item(session: Annotated[Session, Depends(database_session)]):
89
+ session.add(MyORMModel(...))
90
+ ```
91
+
92
+
93
+ ## Attaching Routers to Engin
94
+
95
+ The usual way to declare an `APIRouter` is as a module level variable, for example:
96
+
97
+ ```python title="api.py"
98
+ from fastapi import APIRouter
99
+
100
+ users_router = APIRouter(prefix="/users")
101
+
102
+ @users_router.get("/{user_id}")
103
+ def get_user(user_id: int) -> dict[str, Any]:
104
+ return {"id": user_id, "name": "Rakim"}
105
+ ```
106
+
107
+ To attach this to our `FastAPIEngin`, we need to provide it. The recommended way to do
108
+ this is to use `Supply` as the router is already instantiated. We also need to add the
109
+ `APIRouter` to our `FastAPI` application, we can do this in the provider for `FastAPI`.
110
+
111
+ ```python title="app.py"
112
+ from engin.ext.fastapi import FastAPIEngin
113
+ from fastapi import FastAPI
114
+
115
+ from api import users_router
116
+
117
+ def create_fastapi_app(api_routers: list[APIRouter]) -> FastAPI:
118
+ app = FastAPI()
119
+
120
+ for api_router in api_routers:
121
+ app.include_router(api_router)
122
+
123
+ return app
124
+
125
+
126
+ app = FastAPIEngin(Provide(create_fastapi_app), Supply([users_router]))
127
+ ```
128
+
129
+ !!!info
130
+
131
+ Notice that the `users_router` is supplied in a list, as we want to be able to
132
+ support multiple APIRouters as our application grows.
133
+
134
+ Or similarly, we could use a block instead:
135
+
136
+ ```python title="app.py"
137
+ from engin import Block, provide
138
+ from engin.ext.fastapi import FastAPIEngin
139
+ from fastapi import FastAPI
140
+
141
+ from api import users_router
142
+
143
+ class AppBlock(Block):
144
+ options = [Supply([users_router])]
145
+
146
+ @provide
147
+ def create_fastapi_app(self, api_routers: list[APIRouter]) -> FastAPI:
148
+ app = FastAPI()
149
+
150
+ for api_router in api_routers:
151
+ app.include_router(api_router)
152
+
153
+ return app
154
+
155
+
156
+ app = FastAPIEngin(AppBlock())
157
+ ```
158
+
159
+
160
+ ## Graphing Dependencies
161
+
162
+ Engin provides dependency visualisation functionality via the `engin-graph` script. When
163
+ working with a FastAPI application this can be used to visualise API Routes along with
164
+ their respective dependencies.
165
+
166
+ ![fastapi-graph.png](fastapi-graph.png){ width="500", loading=lazy }
167
+ /// caption
168
+ Visualisation of the FastAPI example's dependency graph.
169
+ ///
170
+
171
+ Note that due to the split between Engin's dependency injection framework and FastAPI's,
172
+ resolving API Routers and their dependencies is slightly harder for Engin. Due to this
173
+ there is currently a limitation where Engin will only be aware of APIRouters that have
174
+ been provided using `Supply` and not via `Provide` or `@provide` in a Block.
@@ -9,21 +9,13 @@ Engin is inspired by [Uber's Fx framework for Go](https://github.com/uber-go/fx)
9
9
 
10
10
  - **Dependency Injection** - Engin includes a fully-featured Dependency Injection system,
11
11
  powered by type hints.
12
- - **Lifecycle Management** - Engin provides a simple & portable approach for attaching
13
- startup and shutdown tasks to the application's lifecycle.
12
+ - **Applicaton Management** - Engin can run your whole application from start to end with
13
+ a simple call to `run()` including managing lifecycle startup and shutdown tasks.
14
14
  - **Code Reuse** - Engin's modular components, called Blocks, work great as distributed
15
15
  packages allowing zero boiler-plate code reuse across multiple applications. Perfect for
16
16
  maintaining many services across your organisation.
17
17
  - **Ecosystem Compatability** - Engin ships with integrations for popular frameworks that
18
- provide their own Dependency Injection, for example FastAPI, allowing you to integrate
18
+ provide their own Dependency Injection, such as FastAPI, allowing you to integrate
19
19
  Engin into existing code bases incrementally.
20
20
  - **Async Native**: Engin is an async framework, meaning first class support for async
21
- dependencies. However Engin will happily run synchronous code as well.
22
-
23
- ## Installation
24
-
25
- Engin is available on PyPI, install using your favourite dependency manager:
26
-
27
- - **pip**:`pip install engin`
28
- - **poetry**: `poetry add engin`
29
- - **uv**: `uv add engin`
21
+ dependencies and applications, but can easily run synchronous code as well.
@@ -0,0 +1,12 @@
1
+
2
+ ## Engin
3
+
4
+ ::: engin.Engin
5
+
6
+ ## Provide
7
+
8
+ ::: engin.Provide
9
+
10
+ ## Lifecycle
11
+
12
+ ::: engin.Lifecycle
@@ -5,6 +5,9 @@ site_url: !ENV READTHEDOCS_CANONICAL_URL
5
5
  theme:
6
6
  name: 'material'
7
7
  custom_dir: 'docs/overrides'
8
+ features:
9
+ - navigation.instant
10
+ - navigation.tabs
8
11
  palette:
9
12
  - scheme: 'default'
10
13
  media: '(prefers-color-scheme: light)'
@@ -22,6 +25,19 @@ repo_name: invokermain/engin
22
25
  repo_url: https://github.com/invokermain/engin/
23
26
  edit_uri: ""
24
27
 
28
+ nav:
29
+ - Home: "index.md"
30
+ - Getting Started: "getting-started.md"
31
+ - Concepts:
32
+ - Engin: "concepts/engin.md"
33
+ - Providers: "concepts/providers.md"
34
+ - Invocations: "concepts/invocations.md"
35
+ - Lifecycle: "concepts/lifecycle.md"
36
+ - Guides:
37
+ - Dependency Injection: "guides/dependency_injection.md"
38
+ - FastAPI: "guides/fastapi.md"
39
+ - Reference: "reference.md"
40
+
25
41
  extra_javascript:
26
42
  - js/readthedocs.js
27
43
 
@@ -39,6 +55,7 @@ plugins:
39
55
  ignore_init_summary: true
40
56
  merge_init_into_class: true
41
57
  show_signature_annotations: true
58
+ show_source: false
42
59
  signature_crossrefs: true
43
60
  import:
44
61
  - url: https://docs.python.org/3/objects.inv
@@ -48,6 +65,11 @@ watch:
48
65
  - src
49
66
 
50
67
  markdown_extensions:
68
+ - admonition
69
+ - attr_list
70
+ - md_in_html
71
+ - pymdownx.blocks.caption
72
+ - pymdownx.details
51
73
  - pymdownx.highlight:
52
74
  anchor_linenums: true
53
75
  line_spans: __span
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "engin"
3
- version = "0.0.9"
3
+ version = "0.0.11"
4
4
  description = "An async-first modular application framework"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -40,6 +40,9 @@ docs = [
40
40
  "mkdocs-material>=9.5.50",
41
41
  "mkdocstrings[python]>=0.27.0",
42
42
  ]
43
+ dev = [
44
+ "pytest-cov>=6.0.0",
45
+ ]
43
46
 
44
47
 
45
48
  [project.scripts]
@@ -100,6 +103,8 @@ check.sequence = [
100
103
  ]
101
104
 
102
105
  fix.default_item_type = "cmd"
103
- fix.sequence = ["ruff check src tests --fix"]
106
+ fix.sequence = ["ruff check src tests examples --fix"]
104
107
 
105
108
  test = "pytest -s tests"
109
+ ci-test = "pytest -s tests --cov-branch --cov-report=xml"
110
+ docs = "mkdocs serve"
@@ -152,6 +152,8 @@ class Assembler:
152
152
  if type_id.multi:
153
153
  LOG.warning(f"no provider for '{type_id}' defaulting to empty list")
154
154
  providers = [(Supply([], type_hint=list[type_id.type]))] # type: ignore[name-defined]
155
+ # store default to prevent the warning appearing multiple times
156
+ self._multiproviders[type_id] = providers
155
157
  else:
156
158
  raise LookupError(f"No Provider registered for dependency '{type_id}'")
157
159
 
@@ -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)]
@@ -186,10 +204,6 @@ class Supply(Provide, Generic[T]):
186
204
  self._get_val.__annotations__["return"] = type_hint
187
205
  super().__init__(builder=self._get_val, block_name=block_name)
188
206
 
189
- @property
190
- def origin(self) -> str:
191
- return self._value.__module__
192
-
193
207
  @property
194
208
  def return_type(self) -> type[T]:
195
209
  if self._type_hint is not None:
@@ -39,6 +39,7 @@ class Engin:
39
39
  but for advanced usecases it can be easier to use the `start` and `stop` methods.
40
40
 
41
41
  When ran the Engin takes care of the complete application lifecycle:
42
+
42
43
  1. The Engin assembles all Invocations. Only Providers that are required to satisfy
43
44
  the Invoke options parameters are assembled.
44
45
  2. All Invocations are run sequentially in the order they were passed in to the Engin.
@@ -1,10 +1,11 @@
1
1
  import traceback
2
- from collections.abc import Awaitable, Callable, MutableMapping
2
+ from collections.abc import AsyncIterator, Awaitable, Callable, MutableMapping
3
+ from contextlib import AbstractAsyncContextManager, asynccontextmanager
3
4
  from typing import Any, ClassVar, Protocol, TypeAlias
4
5
 
5
6
  from engin import Engin, Entrypoint, Option
6
7
 
7
- __all__ = ["ASGIEngin", "ASGIType"]
8
+ __all__ = ["ASGIEngin", "ASGIType", "engin_to_lifespan"]
8
9
 
9
10
  from engin._graph import DependencyGrapher, Node
10
11
 
@@ -61,3 +62,26 @@ class _Rereceive:
61
62
 
62
63
  async def __call__(self, *args: Any, **kwargs: Any) -> _Message:
63
64
  return self._message
65
+
66
+
67
+ def engin_to_lifespan(engin: Engin) -> Callable[[ASGIType], AbstractAsyncContextManager[None]]:
68
+ """
69
+ Transforms the Engin instance into an ASGI lifespan task.
70
+
71
+ This is to enable users to use the Engin framework with existing ASGI applications,
72
+ where it is not desired to replace the ASGI application with an ASGIEngin.
73
+
74
+ Args:
75
+ engin: the engin instance to transform.
76
+
77
+ Returns:
78
+ An ASGI lifespan task.
79
+ """
80
+
81
+ @asynccontextmanager
82
+ async def engin_lifespan(_: ASGIType) -> AsyncIterator[None]:
83
+ await engin.start()
84
+ yield
85
+ await engin.stop()
86
+
87
+ return engin_lifespan
@@ -7,7 +7,7 @@ from typing import ClassVar, TypeVar
7
7
  from fastapi.routing import APIRoute
8
8
 
9
9
  from engin import Engin, Entrypoint, Invoke, Option
10
- from engin._dependency import Dependency, Supply
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
@@ -113,7 +113,7 @@ def _extract_routes_from_supply(supply: Supply) -> list[Dependency]:
113
113
  inner = supply._value[0]
114
114
  if isinstance(inner, APIRouter):
115
115
  return [
116
- APIRouteDependency(route, block_name=supply.block_name)
116
+ APIRouteDependency(supply, route)
117
117
  for route in inner.routes
118
118
  if isinstance(route, APIRoute)
119
119
  ]
@@ -128,13 +128,26 @@ class APIRouteDependency(Dependency):
128
128
  This class should never be constructed in application code.
129
129
  """
130
130
 
131
- def __init__(self, route: APIRoute, block_name: str | None = None) -> None:
131
+ def __init__(
132
+ self,
133
+ wraps: Dependency,
134
+ route: APIRoute,
135
+ ) -> None:
132
136
  """
133
137
  Warning: this should never be constructed in application code.
134
138
  """
139
+ super().__init__(_noop, wraps.block_name)
140
+ self._wrapped = wraps
135
141
  self._route = route
136
142
  self._signature = inspect.signature(route.endpoint)
137
- self._block_name = block_name
143
+
144
+ @property
145
+ def source_module(self) -> str:
146
+ return self._wrapped.source_module
147
+
148
+ @property
149
+ def source_package(self) -> str:
150
+ return self._wrapped.source_package
138
151
 
139
152
  @property
140
153
  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]]"
@@ -1,8 +1,8 @@
1
1
  from typing import Annotated
2
2
 
3
3
  from engin import Provide
4
- from engin._dependency import Supply
5
- from tests.deps import make_aliased_int
4
+ from engin._dependency import Entrypoint, Supply
5
+ from tests.deps import make_aliased_int, make_int
6
6
 
7
7
 
8
8
  def test_provide_discriminates_singular():
@@ -57,3 +57,21 @@ def test_provide_with_annotation():
57
57
 
58
58
  assert provider.return_type_id.type
59
59
  assert str(provider.return_type_id) == "Annotated[str, 1]"
60
+
61
+
62
+ def test_dependency_sources():
63
+ provide = Provide(make_int)
64
+ assert provide.source_module == "tests.test_dependencies"
65
+ assert provide.source_package == "tests"
66
+
67
+ supply = Supply(3)
68
+ assert supply.source_module == "tests.test_dependencies"
69
+ assert supply.source_package == "tests"
70
+
71
+ invoke = Provide(make_int)
72
+ assert invoke.source_module == "tests.test_dependencies"
73
+ assert invoke.source_package == "tests"
74
+
75
+ entrypoint = Entrypoint(3)
76
+ assert entrypoint.source_module == "tests.test_dependencies"
77
+ assert entrypoint.source_package == "tests"