engin 0.0.6__tar.gz → 0.0.8__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 (79) hide show
  1. {engin-0.0.6 → engin-0.0.8}/CHANGELOG.md +15 -0
  2. {engin-0.0.6 → engin-0.0.8}/PKG-INFO +7 -1
  3. engin-0.0.8/docs/concepts/engin.md +11 -0
  4. engin-0.0.8/docs/concepts/invocations.md +132 -0
  5. engin-0.0.8/docs/concepts/providers.md +176 -0
  6. {engin-0.0.6 → engin-0.0.8}/examples/fastapi/main.py +3 -1
  7. {engin-0.0.6 → engin-0.0.8}/examples/simple/main.py +2 -1
  8. {engin-0.0.6 → engin-0.0.8}/pyproject.toml +15 -1
  9. {engin-0.0.6 → engin-0.0.8}/src/engin/_dependency.py +5 -2
  10. {engin-0.0.6 → engin-0.0.8}/src/engin/_engin.py +6 -5
  11. engin-0.0.8/src/engin/_graph.py +39 -0
  12. engin-0.0.8/src/engin/scripts/graph.py +122 -0
  13. engin-0.0.8/tests/acceptance/__init__.py +0 -0
  14. engin-0.0.8/tests/conftest.py +0 -0
  15. {engin-0.0.6 → engin-0.0.8}/tests/test_assembler.py +18 -0
  16. {engin-0.0.6 → engin-0.0.8}/tests/test_dependencies.py +14 -2
  17. {engin-0.0.6 → engin-0.0.8}/tests/test_engin.py +20 -0
  18. {engin-0.0.6 → engin-0.0.8}/uv.lock +92 -91
  19. engin-0.0.6/docs/concepts/providers.md +0 -2
  20. {engin-0.0.6 → engin-0.0.8}/.github/workflows/check.yaml +0 -0
  21. {engin-0.0.6 → engin-0.0.8}/.github/workflows/publish.yaml +0 -0
  22. {engin-0.0.6 → engin-0.0.8}/.gitignore +0 -0
  23. {engin-0.0.6 → engin-0.0.8}/.readthedocs.yaml +0 -0
  24. {engin-0.0.6 → engin-0.0.8}/LICENSE +0 -0
  25. {engin-0.0.6 → engin-0.0.8}/README.md +0 -0
  26. /engin-0.0.6/examples/__init__.py → /engin-0.0.8/docs/concepts/lifecycle.md +0 -0
  27. {engin-0.0.6 → engin-0.0.8}/docs/engin.md +0 -0
  28. {engin-0.0.6 → engin-0.0.8}/docs/guides/dependency_injection.md +0 -0
  29. {engin-0.0.6 → engin-0.0.8}/docs/index.md +0 -0
  30. {engin-0.0.6 → engin-0.0.8}/docs/js/readthedocs.js +0 -0
  31. {engin-0.0.6 → engin-0.0.8}/docs/overrides/main.html +0 -0
  32. {engin-0.0.6/examples/asgi → engin-0.0.8/examples}/__init__.py +0 -0
  33. {engin-0.0.6/examples/asgi/common → engin-0.0.8/examples/asgi}/__init__.py +0 -0
  34. {engin-0.0.6 → engin-0.0.8}/examples/asgi/app.py +0 -0
  35. {engin-0.0.6/examples/asgi/common/db → engin-0.0.8/examples/asgi/common}/__init__.py +0 -0
  36. {engin-0.0.6/examples/asgi/common/db/adapaters → engin-0.0.8/examples/asgi/common/db}/__init__.py +0 -0
  37. {engin-0.0.6/examples/asgi/common/starlette → engin-0.0.8/examples/asgi/common/db/adapaters}/__init__.py +0 -0
  38. {engin-0.0.6 → engin-0.0.8}/examples/asgi/common/db/adapaters/memory.py +0 -0
  39. {engin-0.0.6 → engin-0.0.8}/examples/asgi/common/db/block.py +0 -0
  40. {engin-0.0.6 → engin-0.0.8}/examples/asgi/common/db/ports.py +0 -0
  41. {engin-0.0.6/examples/asgi/features → engin-0.0.8/examples/asgi/common/starlette}/__init__.py +0 -0
  42. {engin-0.0.6 → engin-0.0.8}/examples/asgi/common/starlette/endpoint.py +0 -0
  43. {engin-0.0.6/examples/asgi/features/cats → engin-0.0.8/examples/asgi/features}/__init__.py +0 -0
  44. {engin-0.0.6/examples/asgi/features/cats/api → engin-0.0.8/examples/asgi/features/cats}/__init__.py +0 -0
  45. {engin-0.0.6/examples/fastapi → engin-0.0.8/examples/asgi/features/cats/api}/__init__.py +0 -0
  46. {engin-0.0.6 → engin-0.0.8}/examples/asgi/features/cats/api/get.py +0 -0
  47. {engin-0.0.6 → engin-0.0.8}/examples/asgi/features/cats/api/post.py +0 -0
  48. {engin-0.0.6 → engin-0.0.8}/examples/asgi/features/cats/block.py +0 -0
  49. {engin-0.0.6 → engin-0.0.8}/examples/asgi/features/cats/domain.py +0 -0
  50. {engin-0.0.6 → engin-0.0.8}/examples/asgi/main.py +0 -0
  51. {engin-0.0.6/examples/fastapi/routes → engin-0.0.8/examples/fastapi}/__init__.py +0 -0
  52. {engin-0.0.6 → engin-0.0.8}/examples/fastapi/app.py +0 -0
  53. {engin-0.0.6/examples/fastapi/routes/cats → engin-0.0.8/examples/fastapi/routes}/__init__.py +0 -0
  54. {engin-0.0.6/examples/fastapi/routes/cats/adapters → engin-0.0.8/examples/fastapi/routes/cats}/__init__.py +0 -0
  55. {engin-0.0.6/examples/simple → engin-0.0.8/examples/fastapi/routes/cats/adapters}/__init__.py +0 -0
  56. {engin-0.0.6 → engin-0.0.8}/examples/fastapi/routes/cats/adapters/repository.py +0 -0
  57. {engin-0.0.6 → engin-0.0.8}/examples/fastapi/routes/cats/api.py +0 -0
  58. {engin-0.0.6 → engin-0.0.8}/examples/fastapi/routes/cats/block.py +0 -0
  59. {engin-0.0.6 → engin-0.0.8}/examples/fastapi/routes/cats/domain.py +0 -0
  60. {engin-0.0.6 → engin-0.0.8}/examples/fastapi/routes/cats/ports.py +0 -0
  61. {engin-0.0.6/src/engin/ext → engin-0.0.8/examples/simple}/__init__.py +0 -0
  62. {engin-0.0.6 → engin-0.0.8}/mkdocs.yaml +0 -0
  63. {engin-0.0.6 → engin-0.0.8}/src/engin/__init__.py +0 -0
  64. {engin-0.0.6 → engin-0.0.8}/src/engin/_assembler.py +0 -0
  65. {engin-0.0.6 → engin-0.0.8}/src/engin/_block.py +0 -0
  66. {engin-0.0.6 → engin-0.0.8}/src/engin/_exceptions.py +0 -0
  67. {engin-0.0.6 → engin-0.0.8}/src/engin/_lifecycle.py +0 -0
  68. {engin-0.0.6 → engin-0.0.8}/src/engin/_type_utils.py +0 -0
  69. {engin-0.0.6/tests → engin-0.0.8/src/engin/ext}/__init__.py +0 -0
  70. {engin-0.0.6 → engin-0.0.8}/src/engin/ext/asgi.py +0 -0
  71. {engin-0.0.6 → engin-0.0.8}/src/engin/ext/fastapi.py +0 -0
  72. {engin-0.0.6 → engin-0.0.8}/src/engin/py.typed +0 -0
  73. {engin-0.0.6/tests/acceptance → engin-0.0.8/src/engin/scripts}/__init__.py +0 -0
  74. /engin-0.0.6/tests/conftest.py → /engin-0.0.8/tests/__init__.py +0 -0
  75. {engin-0.0.6 → engin-0.0.8}/tests/acceptance/test_error_in_shutdown.py +0 -0
  76. {engin-0.0.6 → engin-0.0.8}/tests/acceptance/test_error_in_start_up.py +0 -0
  77. {engin-0.0.6 → engin-0.0.8}/tests/deps.py +0 -0
  78. {engin-0.0.6 → engin-0.0.8}/tests/test_modules.py +0 -0
  79. {engin-0.0.6 → engin-0.0.8}/tests/test_utils.py +0 -0
@@ -6,6 +6,21 @@ 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.8] - 2025-02-22
10
+
11
+ ### Added
12
+
13
+ - A package script, `engin-graph` for visualising the dependency graph.
14
+
15
+
16
+ ## [0.0.7] - 2025-02-20
17
+
18
+ ### Changed
19
+
20
+ - TypeId retains Annotations allowing them to be used to discriminate otherwise identical
21
+ types.
22
+
23
+
9
24
  ## [0.0.6] - 2025-02-19
10
25
 
11
26
  ### Fixed
@@ -1,8 +1,14 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: engin
3
- Version: 0.0.6
3
+ Version: 0.0.8
4
4
  Summary: An async-first modular application framework
5
+ Project-URL: Homepage, https://github.com/invokermain/engin
6
+ Project-URL: Documentation, https://engin.readthedocs.io/en/latest/
7
+ Project-URL: Repository, https://github.com/invokermain/engin.git
8
+ Project-URL: Changelog, https://github.com/invokermain/engin/blob/main/CHANGELOG.md
9
+ License-Expression: MIT
5
10
  License-File: LICENSE
11
+ Keywords: Application Framework,Dependency Injection
6
12
  Requires-Python: >=3.10
7
13
  Description-Content-Type: text/markdown
8
14
 
@@ -0,0 +1,11 @@
1
+ # The Engin
2
+
3
+ The Engin is a self-contained modular application.
4
+
5
+ When ran the Engin takes care of the complete application lifecycle:
6
+ 1. The Engin assembles all Invocations. Only Providers that are required to satisfy
7
+ the Invocations parameters are assembled.
8
+ 2. All Invocations are run sequentially in the order they were passed in to the Engin.
9
+ 3. Any Lifecycle Startup defined by Provider that were assembled is ran.
10
+ 4. The Engin waits for a stop signal, i.e. SIGINT or SIGTERM.
11
+ 5. Any Lifecyce Shutdown task is ran, in the reverse order to the Startup order.
@@ -0,0 +1,132 @@
1
+ # Invocations
2
+
3
+ Invocations define the behaviour of your application, therefore without any Invocations
4
+ your application will not do anything.
5
+
6
+ Like providers invocations are functions that take one or more dependencies as parameters,
7
+ and they should always return None as the return value will not used by the Engin.
8
+
9
+ As part of the Engin's startup, all declared invocations will be called sequentially in
10
+ the order they were registered.
11
+
12
+ Invocations can be used to define behaviour in two ways.
13
+
14
+ **Implicit: Provider Lifecycle**
15
+
16
+ Invocations are always called therefore their dependencies are always assembled. This
17
+ means that any providers with lifecycles will register their lifecycles with the
18
+ application if directly or indirectly used by an invocation.
19
+
20
+ To illustrate this, imagine we have a provider for an imaginary `Worker` type that is our
21
+ applications primary functionality. Our factory might look like the below (omitting
22
+ imports for brevity):
23
+
24
+ ```python
25
+ def worker_factory(lifecycle: Lifecycle) -> Worker:
26
+ worker = Worker()
27
+
28
+ @asynccontextmanager
29
+ async def worker_lifecycle() -> Iterable[None]:
30
+ worker.start()
31
+ yield
32
+ worker.shutdown()
33
+
34
+ lifecycle.append(worker_lifecycle)
35
+
36
+ return worker
37
+ ```
38
+
39
+ We can register it with the Engin as a provider.
40
+
41
+ ```python
42
+ engin = Engin(Provide(worker_factory))
43
+ ```
44
+
45
+ However, when we run the `engin` nothing happens. It will not start the `Worker` as it is
46
+ not required by any invocations. Let us fix that by adding the invocation.
47
+
48
+ ```python
49
+ def use_worker(worker: Worker) -> None:
50
+ return None
51
+
52
+
53
+ engin = Engin(Provide(worker_factory), Invoke(use_worker))
54
+ ```
55
+
56
+ Now when we run the `engin` a `Worker` is constructed and it starts up. This invocation
57
+ has no behaviour of its own, hence we can call it implicit, but by registering it with the
58
+ Engin it declares the behaviour we want our modular application to have at runtime.
59
+
60
+ This pattern of empty invocations as declaration of intended behaviour is very common, so
61
+ the framework has a marker class called `Entrypoint` as a shorthand for the above.
62
+
63
+ ```python
64
+ engin = Engin(Provide(worker_factory), Entrypoint(Worker))
65
+ ```
66
+
67
+ This `engin` has the same behaviour as the one declared above. Entrypoints can be
68
+ considered idiomatic as they have more explicit semantics.
69
+
70
+ **Explicit: Invoked Behaviour**
71
+
72
+ Sometimes you want to have logic that runs before the lifecycle startup occurs and after
73
+ the dependency graph is built, some examples might be:
74
+ - Pining a server to check its healthy.
75
+ - Running database migrations.
76
+ - Configuring a logger.
77
+
78
+ In these cases you would simply write an invocation that does these things, for example:
79
+
80
+ ```python
81
+ def run_database_migrations(conn: SQLConnection) -> None:
82
+ print("running database migrations")
83
+ required_migrations = get_migrations(conn)
84
+ for migration in required_migrations:
85
+ print(f"migrating database to version {migration.version}")
86
+ migration.execute(conn)
87
+
88
+ engin = Engin(Provide(sql_connection_factory), Invoke(run_database_migrations), ...)
89
+ ```
90
+
91
+
92
+ ## Defining an invocation
93
+
94
+ Any function can be turned into an invocation by using the marker class: `Invoke`.
95
+
96
+ ```python
97
+ import asyncio
98
+ from engin import Engin, Invoke
99
+
100
+ # define a function with some behaviour
101
+ def print_hello_world() -> None:
102
+ print("hello world!")
103
+
104
+ # register it as a invocation with the Engin
105
+ engin = Engin(Invoke(print_hello_world))
106
+
107
+ # run your application
108
+ asyncio.run(engin.run()) # hello world!
109
+ ```
110
+
111
+
112
+ ## Invocations can use provided types
113
+
114
+ Invocations can use any types as long as they have the matching providers.
115
+
116
+ ```python
117
+ import asyncio
118
+ from engin import Engin, Invoke
119
+
120
+ # define a constructor
121
+ def name_factory() -> str:
122
+ return "Dmitrii"
123
+
124
+ def print_hello(name: str) -> None:
125
+ print(f"hello {name}!")
126
+
127
+ # register it as a invocation with the Engin
128
+ engin = Engin(Provide(name_factory()), Invoke(hello_world))
129
+
130
+ # run your application
131
+ asyncio.run(engin.run()) # hello Dmitrii!
132
+ ```
@@ -0,0 +1,176 @@
1
+ # Providers
2
+
3
+ Providers are the factories of your application, they are reponsible for the construction
4
+ of the objects that your application needs.
5
+
6
+ Remember, the Engin only calls the providers that are necessary to run your application.
7
+ More specifically: when starting up the Engin will call all providers necessary to run its
8
+ invocations, and the Assembler (the component responsible for constructing types) will
9
+ call any providers that these providers require and so on.
10
+
11
+
12
+ ## Defining a provider
13
+
14
+ Any function that returns an object can be turned into a provider by using the marker
15
+ class: `Provide`.
16
+
17
+ ```python
18
+ from engin import Engin, Provide
19
+
20
+ # define our constructor
21
+ def string_factory() -> str:
22
+ return "hello"
23
+
24
+ # register it as a provider with the Engin
25
+ engin = Engin(Provide(string_factory))
26
+
27
+ # construct the string
28
+ a_string = await engin.assembler.get(str)
29
+
30
+ print(a_string) # hello
31
+ ```
32
+
33
+ Providers can be asynchronous as well, this factory function would work exactly the same
34
+ in the above example.
35
+
36
+ ```python
37
+ async def string_factory() -> str:
38
+ return "hello"
39
+ ```
40
+
41
+ ## Providers can use other providers
42
+
43
+ Providers that construct more interesting objects generally require their own parameters.
44
+
45
+ ```python
46
+ from engin import Engin, Provide
47
+
48
+ class Greeter:
49
+ def __init__(self, greeting: str) -> None:
50
+ self._greeting = greeting
51
+
52
+ def greet(self, name: str) -> None:
53
+ print(f"{self._greeting}, {name}!")
54
+
55
+ # define our constructors
56
+ def string_factory() -> str:
57
+ return "hello"
58
+
59
+ def greeter_factory(greeting: str) -> Greeter:
60
+ return Greeter(greeting=greeting)
61
+
62
+ # register them as providers with the Engin
63
+ engin = Engin(Provide(string_factory), Provide(greeter_factory))
64
+
65
+ # construct the Greeter
66
+ greeter = await engin.assembler.get(Greeter)
67
+
68
+ greeter.greet("Bob") # hello, Bob!
69
+ ```
70
+
71
+
72
+ ## Providers are only called when required
73
+
74
+ The Assembler will only call a provider when the type is requested, directly or indirectly
75
+ when constructing an object. This means that your application will do the minimum work
76
+ required on startup.
77
+
78
+ ```python
79
+ from engin import Engin, Provide
80
+
81
+
82
+ # define our constructors
83
+ def string_factory() -> str:
84
+ return "hello"
85
+
86
+ def evil_factory() -> int:
87
+ raise RuntimeError("I have ruined your plans")
88
+
89
+ # register them as providers with the Engin
90
+ engin = Engin(Provide(string_factory), Provide(evil_factory))
91
+
92
+ # this will not raise an error
93
+ await engin.assembler.get(str)
94
+
95
+ # this will raise an error
96
+ await engin.assembler.get(int)
97
+ ```
98
+
99
+
100
+ ## Multiproviders
101
+
102
+ Sometimes it is useful for many providers to construct a single collection of objects,
103
+ these are called multiproviders. For example in a web application, many
104
+ distinct providers could register one or more routes, and the root of the application
105
+ would handle registering them.
106
+
107
+ To turn a factory into a multiprovider, simply return a list:
108
+
109
+ ```python
110
+ from engin import Engin, Provide
111
+
112
+ # define our constructors
113
+ def animal_names_factory() -> list[str]:
114
+ return ["cat", "dog"]
115
+
116
+ def other_animal_names_factory() -> list[str]:
117
+ return ["horse", "cow"]
118
+
119
+ # register them as providers with the Engin
120
+ engin = Engin(Provide(animal_names_factory), Provide(other_animal_names_factory))
121
+
122
+ # construct the list of strings
123
+ animal_names = await engin.assembler.get(list[str])
124
+
125
+ print(animal_names) # ["cat", "dog", "horse", "cow"]
126
+ ```
127
+
128
+
129
+ ## Discriminating providers of the same type
130
+
131
+ Providers of the same type can be discriminated using annotations.
132
+
133
+ ```python
134
+ from engin import Engin, Provide
135
+ from typing import Annotated
136
+
137
+ # define our constructors
138
+ def greeting_factory() -> Annotated[str, "greeting"]:
139
+ return "hello"
140
+
141
+ def name_factory() -> Annotated[str, "name"]:
142
+ return "Jelena"
143
+
144
+ # register them as providers with the Engin
145
+ engin = Engin(Provide(greeting_factory), Provide(name_factory))
146
+
147
+ # this will return "hello"
148
+ await engin.assembler.get(Annotated[str, "greeting"])
149
+
150
+ # this will return "Jelena"
151
+ await engin.assembler.get(Annotated[str, "name"])
152
+
153
+ # N.B. this will raise an error!
154
+ await engin.assembler.get(str)
155
+ ```
156
+
157
+
158
+ ## Supply can be used for static objects
159
+
160
+ The `Supply` marker class can be used as a shorthand when provided static objects. The
161
+ provided type is automatically inferred.
162
+
163
+ For example the first example on this page could be rewritten as:
164
+
165
+
166
+ ```python
167
+ from engin import Engin, Supply
168
+
169
+ # Supply the Engin with a str value
170
+ engin = Engin(Supply("hello"))
171
+
172
+ # construct the string
173
+ a_string = await engin.assembler.get(str)
174
+
175
+ print(a_string) # hello
176
+ ```
@@ -11,4 +11,6 @@ logging.basicConfig(level=logging.DEBUG)
11
11
 
12
12
  app = FastAPIEngin(AppBlock(), CatBlock(), Supply(AppConfig(debug=True)))
13
13
 
14
- uvicorn.run(app)
14
+
15
+ if __name__ == "__main__":
16
+ uvicorn.run(app)
@@ -19,4 +19,5 @@ async def main(http_client: AsyncClient) -> None:
19
19
 
20
20
  engin = Engin(Provide(new_httpx_client), Invoke(main))
21
21
 
22
- asyncio.run(engin.run())
22
+ if __name__ == "__main__":
23
+ asyncio.run(engin.run())
@@ -1,11 +1,19 @@
1
1
  [project]
2
2
  name = "engin"
3
- version = "0.0.6"
3
+ version = "0.0.8"
4
4
  description = "An async-first modular application framework"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
7
+ license = "MIT"
8
+ keywords = ["Dependency Injection", "Application Framework"]
7
9
  dependencies = []
8
10
 
11
+ [project.urls]
12
+ Homepage = "https://github.com/invokermain/engin"
13
+ Documentation = "https://engin.readthedocs.io/en/latest/"
14
+ Repository = "https://github.com/invokermain/engin.git"
15
+ Changelog = "https://github.com/invokermain/engin/blob/main/CHANGELOG.md"
16
+
9
17
  [build-system]
10
18
  requires = ["hatchling"]
11
19
  build-backend = "hatchling.build"
@@ -34,6 +42,10 @@ docs = [
34
42
  ]
35
43
 
36
44
 
45
+ [project.scripts]
46
+ engin-graph = "engin.scripts.graph:serve_graph"
47
+
48
+
37
49
  [tool.ruff]
38
50
  line-length = 95
39
51
  target-version = "py310"
@@ -54,7 +66,9 @@ ignore = [
54
66
  [tool.ruff.lint.per-file-ignores]
55
67
  "**/src/*" = ["PT"]
56
68
  "**/tests/*" = ["S", "ANN"]
69
+ # allow print statements in examples/scripts
57
70
  "**/examples/*" = ["T201"]
71
+ "**/scripts/*" = ["T201"]
58
72
 
59
73
 
60
74
  [tool.pytest.ini_options]
@@ -18,7 +18,6 @@ from engin._type_utils import TypeId, type_id_of
18
18
  P = ParamSpec("P")
19
19
  T = TypeVar("T")
20
20
  Func: TypeAlias = Callable[P, T]
21
- _SELF = object()
22
21
 
23
22
 
24
23
  def _noop(*args: Any, **kwargs: Any) -> None: ...
@@ -31,6 +30,10 @@ class Dependency(ABC, Generic[P, T]):
31
30
  self._signature = inspect.signature(self._func)
32
31
  self._block_name = block_name
33
32
 
33
+ @property
34
+ def module(self) -> str:
35
+ return self._func.__module__
36
+
34
37
  @property
35
38
  def block_name(self) -> str | None:
36
39
  return self._block_name
@@ -136,7 +139,7 @@ class Provide(Dependency[Any, T]):
136
139
  return_type = self._func # __init__ returns self
137
140
  else:
138
141
  try:
139
- return_type = get_type_hints(self._func)["return"]
142
+ return_type = get_type_hints(self._func, include_extras=True)["return"]
140
143
  except KeyError as err:
141
144
  raise RuntimeError(
142
145
  f"Dependency '{self.name}' requires a return typehint"
@@ -13,6 +13,7 @@ from engin import Entrypoint
13
13
  from engin._assembler import AssembledDependency, Assembler
14
14
  from engin._block import Block
15
15
  from engin._dependency import Dependency, Invoke, Provide, Supply
16
+ from engin._graph import DependencyGrapher, Node
16
17
  from engin._lifecycle import Lifecycle
17
18
  from engin._type_utils import TypeId
18
19
 
@@ -35,8 +36,7 @@ class Engin:
35
36
  Supply) and at least one invocation (Invoke or Entrypoint).
36
37
 
37
38
  When instantiated the Engin can be run. This is typically done via the `run` method,
38
- but certain use cases, e.g. testing, it can be easier to use the `start` and `stop`
39
- methods.
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
  1. The Engin assembles all Invocations. Only Providers that are required to satisfy
@@ -81,7 +81,6 @@ class Engin:
81
81
  Args:
82
82
  *options: an instance of Provide, Supply, Invoke, Entrypoint or a Block.
83
83
  """
84
-
85
84
  self._stop_requested_event = Event()
86
85
  self._stop_complete_event = Event()
87
86
  self._exit_stack: AsyncExitStack = AsyncExitStack()
@@ -96,8 +95,6 @@ class Engin:
96
95
  self._destruct_options(chain(self._LIB_OPTIONS, options))
97
96
  multi_providers = [p for multi in self._multiproviders.values() for p in multi]
98
97
  self._assembler = Assembler(chain(self._providers.values(), multi_providers))
99
- self._providers.clear()
100
- self._multiproviders.clear()
101
98
 
102
99
  @property
103
100
  def assembler(self) -> Assembler:
@@ -163,6 +160,10 @@ class Engin:
163
160
  return
164
161
  await self._stop_complete_event.wait()
165
162
 
163
+ def graph(self) -> list[Node]:
164
+ grapher = DependencyGrapher({**self._providers, **self._multiproviders})
165
+ return grapher.resolve(self._invocations)
166
+
166
167
  async def _shutdown(self) -> None:
167
168
  LOG.info("stopping engin")
168
169
  await self._exit_stack.aclose()
@@ -0,0 +1,39 @@
1
+ from collections.abc import Iterable
2
+ from typing import TypedDict
3
+
4
+ from engin._dependency import Dependency, Provide
5
+ from engin._type_utils import TypeId
6
+
7
+
8
+ class Node(TypedDict):
9
+ node: Dependency
10
+ parent: Dependency | None
11
+
12
+
13
+ class DependencyGrapher:
14
+ def __init__(self, providers: dict[TypeId, Provide | list[Provide]]) -> None:
15
+ self._providers: dict[TypeId, Provide | list[Provide]] = providers
16
+
17
+ def resolve(self, roots: Iterable[Dependency]) -> list[Node]:
18
+ seen: set[TypeId] = set()
19
+ nodes: list[Node] = []
20
+
21
+ for root in roots:
22
+ for parameter in root.parameter_types:
23
+ if parameter in seen:
24
+ continue
25
+
26
+ seen.add(parameter)
27
+ provider = self._providers[parameter]
28
+
29
+ # multiprovider
30
+ if isinstance(provider, list):
31
+ for p in provider:
32
+ nodes.append({"node": p, "parent": root})
33
+ nodes.extend(self.resolve([p]))
34
+ # single provider
35
+ else:
36
+ nodes.append({"node": provider, "parent": root})
37
+ nodes.extend(self.resolve([provider]))
38
+
39
+ return nodes
@@ -0,0 +1,122 @@
1
+ import importlib
2
+ import logging
3
+ import socketserver
4
+ import sys
5
+ import threading
6
+ from argparse import ArgumentParser
7
+ from http.server import BaseHTTPRequestHandler
8
+ from time import sleep
9
+ from typing import Any
10
+
11
+ from engin import Engin
12
+ from engin._dependency import Dependency, Provide
13
+
14
+ # mute logging from importing of files + engin's debug logging.
15
+ logging.disable()
16
+
17
+ args = ArgumentParser(
18
+ prog="engin-graph",
19
+ description="Creates a visualisation of your application's dependencies",
20
+ )
21
+ args.add_argument(
22
+ "-e", "--exclude", help="a list of packages or module to exclude", default=["engin"]
23
+ )
24
+ args.add_argument(
25
+ "app",
26
+ help=(
27
+ "the import path of your Engin instance, in the form "
28
+ "'package:application', e.g. 'app.main:engin'"
29
+ ),
30
+ )
31
+
32
+
33
+ def serve_graph() -> None:
34
+ # add cwd to path to enable local package imports
35
+ sys.path.insert(0, "")
36
+
37
+ parsed = args.parse_args()
38
+
39
+ app = parsed.app
40
+ excluded_modules = parsed.exclude
41
+
42
+ try:
43
+ module_name, engin_name = app.split(":", maxsplit=1)
44
+ except ValueError:
45
+ raise ValueError(
46
+ "Expected an argument of the form 'module:attribute', e.g. 'myapp:engin'"
47
+ ) from None
48
+
49
+ module = importlib.import_module(module_name)
50
+
51
+ try:
52
+ instance = getattr(module, engin_name)
53
+ except LookupError:
54
+ raise LookupError(f"Module '{module_name}' has no attribute '{engin_name}'") from None
55
+
56
+ if not isinstance(instance, Engin):
57
+ raise TypeError(f"'{app}' is not an Engin instance")
58
+
59
+ nodes = instance.graph()
60
+
61
+ # transform dependencies into mermaid syntax
62
+ dependencies = [
63
+ f"{_render_node(node['parent'])} --> {_render_node(node['node'])}"
64
+ for node in nodes
65
+ if node["parent"] is not None
66
+ and not _should_exclude(node["node"].module, excluded_modules)
67
+ ]
68
+
69
+ html = _GRAPH_HTML.replace("%%DATA%%", "\n".join(dependencies)).encode("utf8")
70
+
71
+ class Handler(BaseHTTPRequestHandler):
72
+ def do_GET(self) -> None:
73
+ self.send_response(200, "OK")
74
+ self.send_header("Content-type", "html")
75
+ self.end_headers()
76
+ self.wfile.write(html)
77
+
78
+ def log_message(self, format: str, *args: Any) -> None:
79
+ return
80
+
81
+ def _start_server() -> None:
82
+ with socketserver.TCPServer(("localhost", 8123), Handler) as httpd:
83
+ print("Serving dependency graph on http://localhost:8123")
84
+ httpd.serve_forever()
85
+
86
+ server_thread = threading.Thread(target=_start_server)
87
+ server_thread.daemon = True # Daemonize the thread so it exits when the main script exits
88
+ server_thread.start()
89
+
90
+ try:
91
+ sleep(10000)
92
+ except KeyboardInterrupt:
93
+ print("Exiting the server...")
94
+
95
+
96
+ def _render_node(node: Dependency) -> str:
97
+ if isinstance(node, Provide):
98
+ return str(node.return_type_id)
99
+ else:
100
+ return node.name
101
+
102
+
103
+ def _should_exclude(module: str, excluded: list[str]) -> bool:
104
+ return any(module.startswith(e) for e in excluded)
105
+
106
+
107
+ _GRAPH_HTML = """
108
+ <!doctype html>
109
+ <html lang="en">
110
+ <body>
111
+ <pre class="mermaid">
112
+ graph TD
113
+ %%DATA%%
114
+ </pre>
115
+ <script type="module">
116
+ import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
117
+ let config = { flowchart: { useMaxWidth: false, htmlLabels: true } };
118
+ mermaid.initialize(config);
119
+ </script>
120
+ </body>
121
+ </html>
122
+ """
File without changes
File without changes
@@ -1,3 +1,5 @@
1
+ from typing import Annotated
2
+
1
3
  import pytest
2
4
 
3
5
  from engin import Assembler, Invoke, Provide
@@ -65,3 +67,19 @@ async def test_assembler_with_unknown_type_raises_assembly_error():
65
67
 
66
68
  with pytest.raises(ProviderError):
67
69
  await assembler.get(str)
70
+
71
+
72
+ async def test_annotations():
73
+ def make_str_1() -> Annotated[str, "1"]:
74
+ return "bar"
75
+
76
+ def make_str_2() -> Annotated[str, "2"]:
77
+ return "foo"
78
+
79
+ assembler = Assembler([Provide(make_str_1), Provide(make_str_2)])
80
+
81
+ with pytest.raises(LookupError):
82
+ await assembler.get(str)
83
+
84
+ assert await assembler.get(Annotated[str, "1"]) == "bar"
85
+ assert await assembler.get(Annotated[str, "2"]) == "foo"