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.
- {engin-0.0.6 → engin-0.0.8}/CHANGELOG.md +15 -0
- {engin-0.0.6 → engin-0.0.8}/PKG-INFO +7 -1
- engin-0.0.8/docs/concepts/engin.md +11 -0
- engin-0.0.8/docs/concepts/invocations.md +132 -0
- engin-0.0.8/docs/concepts/providers.md +176 -0
- {engin-0.0.6 → engin-0.0.8}/examples/fastapi/main.py +3 -1
- {engin-0.0.6 → engin-0.0.8}/examples/simple/main.py +2 -1
- {engin-0.0.6 → engin-0.0.8}/pyproject.toml +15 -1
- {engin-0.0.6 → engin-0.0.8}/src/engin/_dependency.py +5 -2
- {engin-0.0.6 → engin-0.0.8}/src/engin/_engin.py +6 -5
- engin-0.0.8/src/engin/_graph.py +39 -0
- engin-0.0.8/src/engin/scripts/graph.py +122 -0
- engin-0.0.8/tests/acceptance/__init__.py +0 -0
- engin-0.0.8/tests/conftest.py +0 -0
- {engin-0.0.6 → engin-0.0.8}/tests/test_assembler.py +18 -0
- {engin-0.0.6 → engin-0.0.8}/tests/test_dependencies.py +14 -2
- {engin-0.0.6 → engin-0.0.8}/tests/test_engin.py +20 -0
- {engin-0.0.6 → engin-0.0.8}/uv.lock +92 -91
- engin-0.0.6/docs/concepts/providers.md +0 -2
- {engin-0.0.6 → engin-0.0.8}/.github/workflows/check.yaml +0 -0
- {engin-0.0.6 → engin-0.0.8}/.github/workflows/publish.yaml +0 -0
- {engin-0.0.6 → engin-0.0.8}/.gitignore +0 -0
- {engin-0.0.6 → engin-0.0.8}/.readthedocs.yaml +0 -0
- {engin-0.0.6 → engin-0.0.8}/LICENSE +0 -0
- {engin-0.0.6 → engin-0.0.8}/README.md +0 -0
- /engin-0.0.6/examples/__init__.py → /engin-0.0.8/docs/concepts/lifecycle.md +0 -0
- {engin-0.0.6 → engin-0.0.8}/docs/engin.md +0 -0
- {engin-0.0.6 → engin-0.0.8}/docs/guides/dependency_injection.md +0 -0
- {engin-0.0.6 → engin-0.0.8}/docs/index.md +0 -0
- {engin-0.0.6 → engin-0.0.8}/docs/js/readthedocs.js +0 -0
- {engin-0.0.6 → engin-0.0.8}/docs/overrides/main.html +0 -0
- {engin-0.0.6/examples/asgi → engin-0.0.8/examples}/__init__.py +0 -0
- {engin-0.0.6/examples/asgi/common → engin-0.0.8/examples/asgi}/__init__.py +0 -0
- {engin-0.0.6 → engin-0.0.8}/examples/asgi/app.py +0 -0
- {engin-0.0.6/examples/asgi/common/db → engin-0.0.8/examples/asgi/common}/__init__.py +0 -0
- {engin-0.0.6/examples/asgi/common/db/adapaters → engin-0.0.8/examples/asgi/common/db}/__init__.py +0 -0
- {engin-0.0.6/examples/asgi/common/starlette → engin-0.0.8/examples/asgi/common/db/adapaters}/__init__.py +0 -0
- {engin-0.0.6 → engin-0.0.8}/examples/asgi/common/db/adapaters/memory.py +0 -0
- {engin-0.0.6 → engin-0.0.8}/examples/asgi/common/db/block.py +0 -0
- {engin-0.0.6 → engin-0.0.8}/examples/asgi/common/db/ports.py +0 -0
- {engin-0.0.6/examples/asgi/features → engin-0.0.8/examples/asgi/common/starlette}/__init__.py +0 -0
- {engin-0.0.6 → engin-0.0.8}/examples/asgi/common/starlette/endpoint.py +0 -0
- {engin-0.0.6/examples/asgi/features/cats → engin-0.0.8/examples/asgi/features}/__init__.py +0 -0
- {engin-0.0.6/examples/asgi/features/cats/api → engin-0.0.8/examples/asgi/features/cats}/__init__.py +0 -0
- {engin-0.0.6/examples/fastapi → engin-0.0.8/examples/asgi/features/cats/api}/__init__.py +0 -0
- {engin-0.0.6 → engin-0.0.8}/examples/asgi/features/cats/api/get.py +0 -0
- {engin-0.0.6 → engin-0.0.8}/examples/asgi/features/cats/api/post.py +0 -0
- {engin-0.0.6 → engin-0.0.8}/examples/asgi/features/cats/block.py +0 -0
- {engin-0.0.6 → engin-0.0.8}/examples/asgi/features/cats/domain.py +0 -0
- {engin-0.0.6 → engin-0.0.8}/examples/asgi/main.py +0 -0
- {engin-0.0.6/examples/fastapi/routes → engin-0.0.8/examples/fastapi}/__init__.py +0 -0
- {engin-0.0.6 → engin-0.0.8}/examples/fastapi/app.py +0 -0
- {engin-0.0.6/examples/fastapi/routes/cats → engin-0.0.8/examples/fastapi/routes}/__init__.py +0 -0
- {engin-0.0.6/examples/fastapi/routes/cats/adapters → engin-0.0.8/examples/fastapi/routes/cats}/__init__.py +0 -0
- {engin-0.0.6/examples/simple → engin-0.0.8/examples/fastapi/routes/cats/adapters}/__init__.py +0 -0
- {engin-0.0.6 → engin-0.0.8}/examples/fastapi/routes/cats/adapters/repository.py +0 -0
- {engin-0.0.6 → engin-0.0.8}/examples/fastapi/routes/cats/api.py +0 -0
- {engin-0.0.6 → engin-0.0.8}/examples/fastapi/routes/cats/block.py +0 -0
- {engin-0.0.6 → engin-0.0.8}/examples/fastapi/routes/cats/domain.py +0 -0
- {engin-0.0.6 → engin-0.0.8}/examples/fastapi/routes/cats/ports.py +0 -0
- {engin-0.0.6/src/engin/ext → engin-0.0.8/examples/simple}/__init__.py +0 -0
- {engin-0.0.6 → engin-0.0.8}/mkdocs.yaml +0 -0
- {engin-0.0.6 → engin-0.0.8}/src/engin/__init__.py +0 -0
- {engin-0.0.6 → engin-0.0.8}/src/engin/_assembler.py +0 -0
- {engin-0.0.6 → engin-0.0.8}/src/engin/_block.py +0 -0
- {engin-0.0.6 → engin-0.0.8}/src/engin/_exceptions.py +0 -0
- {engin-0.0.6 → engin-0.0.8}/src/engin/_lifecycle.py +0 -0
- {engin-0.0.6 → engin-0.0.8}/src/engin/_type_utils.py +0 -0
- {engin-0.0.6/tests → engin-0.0.8/src/engin/ext}/__init__.py +0 -0
- {engin-0.0.6 → engin-0.0.8}/src/engin/ext/asgi.py +0 -0
- {engin-0.0.6 → engin-0.0.8}/src/engin/ext/fastapi.py +0 -0
- {engin-0.0.6 → engin-0.0.8}/src/engin/py.typed +0 -0
- {engin-0.0.6/tests/acceptance → engin-0.0.8/src/engin/scripts}/__init__.py +0 -0
- /engin-0.0.6/tests/conftest.py → /engin-0.0.8/tests/__init__.py +0 -0
- {engin-0.0.6 → engin-0.0.8}/tests/acceptance/test_error_in_shutdown.py +0 -0
- {engin-0.0.6 → engin-0.0.8}/tests/acceptance/test_error_in_start_up.py +0 -0
- {engin-0.0.6 → engin-0.0.8}/tests/deps.py +0 -0
- {engin-0.0.6 → engin-0.0.8}/tests/test_modules.py +0 -0
- {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.
|
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
|
+
```
|
@@ -1,11 +1,19 @@
|
|
1
1
|
[project]
|
2
2
|
name = "engin"
|
3
|
-
version = "0.0.
|
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
|
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"
|