engin 0.1.0rc2__tar.gz → 0.1.0rc3__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.1.0rc2 → engin-0.1.0rc3}/.gitignore +2 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/PKG-INFO +1 -1
- engin-0.1.0rc3/docs/tutorial/1_empty_application.md +37 -0
- engin-0.1.0rc3/docs/tutorial/2_create_a_publisher.md +91 -0
- engin-0.1.0rc3/docs/tutorial/3_run_the_application.md +26 -0
- engin-0.1.0rc3/docs/tutorial/4_create_the_consumer.md +37 -0
- engin-0.1.0rc3/docs/tutorial/5_refactor_valkey_client.md +52 -0
- engin-0.1.0rc3/docs/tutorial/index.md +10 -0
- engin-0.1.0rc3/examples/tutorial/app.py +21 -0
- engin-0.1.0rc3/examples/tutorial/consumer.py +36 -0
- engin-0.1.0rc3/examples/tutorial/publisher.py +28 -0
- engin-0.1.0rc3/examples/tutorial/valkey_client.py +23 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/mkdocs.yaml +9 -1
- {engin-0.1.0rc2 → engin-0.1.0rc3}/pyproject.toml +3 -2
- {engin-0.1.0rc2 → engin-0.1.0rc3}/src/engin/_cli/_check.py +0 -7
- {engin-0.1.0rc2 → engin-0.1.0rc3}/src/engin/_cli/_graph.html +1 -1
- {engin-0.1.0rc2 → engin-0.1.0rc3}/src/engin/_engin.py +4 -1
- {engin-0.1.0rc2 → engin-0.1.0rc3}/src/engin/_supervisor.py +1 -1
- {engin-0.1.0rc2 → engin-0.1.0rc3}/tests/cli/test_check.py +0 -1
- engin-0.1.0rc3/tests/conftest.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/uv.lock +181 -132
- engin-0.1.0rc2/docs/tutorial.md +0 -15
- {engin-0.1.0rc2 → engin-0.1.0rc3}/.github/workflows/benchmark.yaml +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/.github/workflows/check.yaml +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/.github/workflows/publish.yaml +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/.readthedocs.yaml +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/CHANGELOG.md +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/LICENSE +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/README.md +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/docs/cli.md +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/docs/concepts/blocks.md +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/docs/concepts/engin.md +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/docs/concepts/invocations.md +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/docs/concepts/lifecycle.md +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/docs/concepts/providers.md +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/docs/concepts/supervisor.md +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/docs/engin-graph-output.png +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/docs/index.md +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/docs/integrations/fastapi-graph.png +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/docs/integrations/fastapi.md +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/docs/js/readthedocs.js +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/docs/overrides/main.html +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/docs/reference.md +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/__init__.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/asgi/__init__.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/asgi/app.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/asgi/common/__init__.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/asgi/common/db/__init__.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/asgi/common/db/adapaters/__init__.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/asgi/common/db/adapaters/memory.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/asgi/common/db/block.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/asgi/common/db/ports.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/asgi/common/starlette/__init__.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/asgi/common/starlette/endpoint.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/asgi/features/__init__.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/asgi/features/cats/__init__.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/asgi/features/cats/api/__init__.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/asgi/features/cats/api/get.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/asgi/features/cats/api/post.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/asgi/features/cats/block.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/asgi/features/cats/domain.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/asgi/main.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/fastapi/__init__.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/fastapi/app.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/fastapi/main.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/fastapi/routes/__init__.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/fastapi/routes/cats/__init__.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/fastapi/routes/cats/adapters/__init__.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/fastapi/routes/cats/adapters/repository.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/fastapi/routes/cats/api.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/fastapi/routes/cats/block.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/fastapi/routes/cats/domain.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/fastapi/routes/cats/ports.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/simple/__init__.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/simple/main.py +0 -0
- {engin-0.1.0rc2/src/engin/extensions → engin-0.1.0rc3/examples/tutorial}/__init__.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/src/engin/__init__.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/src/engin/_assembler.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/src/engin/_block.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/src/engin/_cli/__init__.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/src/engin/_cli/_common.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/src/engin/_cli/_graph.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/src/engin/_cli/_inspect.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/src/engin/_dependency.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/src/engin/_graph.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/src/engin/_introspect.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/src/engin/_lifecycle.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/src/engin/_option.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/src/engin/_type_utils.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/src/engin/exceptions.py +0 -0
- {engin-0.1.0rc2/tests → engin-0.1.0rc3/src/engin/extensions}/__init__.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/src/engin/extensions/asgi.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/src/engin/extensions/fastapi.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/src/engin/py.typed +0 -0
- {engin-0.1.0rc2/tests/acceptance → engin-0.1.0rc3/tests}/__init__.py +0 -0
- {engin-0.1.0rc2/tests/benchmarks → engin-0.1.0rc3/tests/acceptance}/__init__.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/tests/acceptance/test_engin_signal_handling.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/tests/acceptance/test_error_in_invocation.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/tests/acceptance/test_error_in_lifecycle_shutdown.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/tests/acceptance/test_error_in_lifecycle_startup.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/tests/acceptance/test_error_in_provider.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/tests/acceptance/test_error_in_supervisor_task.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/tests/acceptance/test_fastapi.py +0 -0
- {engin-0.1.0rc2/tests/cli → engin-0.1.0rc3/tests/benchmarks}/__init__.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/tests/benchmarks/conftest.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/tests/benchmarks/test_bench_assembler.py +0 -0
- /engin-0.1.0rc2/tests/conftest.py → /engin-0.1.0rc3/tests/cli/__init__.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/tests/cli/test_get_engin_instance.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/tests/cli/test_graph.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/tests/cli/test_inspect.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/tests/deps.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/tests/test_assembler.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/tests/test_block.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/tests/test_dependencies.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/tests/test_engin.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/tests/test_graph.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/tests/test_lifecycle.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/tests/test_supervisor.py +0 -0
- {engin-0.1.0rc2 → engin-0.1.0rc3}/tests/test_type_id.py +0 -0
@@ -0,0 +1,37 @@
|
|
1
|
+
Let's start by creating a minimal application using Engin. We do this by instantiating the
|
2
|
+
Engin class which will serve as our application runtime.
|
3
|
+
|
4
|
+
```python title="app.py"
|
5
|
+
import asyncio
|
6
|
+
import logging
|
7
|
+
|
8
|
+
from engin import Engin
|
9
|
+
|
10
|
+
engin = Engin()
|
11
|
+
|
12
|
+
if __name__ == "__main__":
|
13
|
+
logging.basicConfig(level=logging.INFO)
|
14
|
+
asyncio.run(engin.run())
|
15
|
+
```
|
16
|
+
|
17
|
+
If we run the application using `python app.py` we will see the following output:
|
18
|
+
|
19
|
+
```
|
20
|
+
INFO:engin:starting engin
|
21
|
+
INFO:engin:startup complete
|
22
|
+
```
|
23
|
+
|
24
|
+
And if we stop the application using `ctrl + c` or equivalent we will see:
|
25
|
+
|
26
|
+
```
|
27
|
+
INFO:engin:stopping engin
|
28
|
+
INFO:engin:shutdown complete
|
29
|
+
```
|
30
|
+
|
31
|
+
Now that we have an empty application, let's give it something to do.
|
32
|
+
|
33
|
+
!!! note
|
34
|
+
|
35
|
+
Engin is typically used for long running application such as Web Servers or an Event
|
36
|
+
Consumers. In these scenarios the engin would run until it receives a SIGINT signal (for
|
37
|
+
example when a new deployment happens) at which point it would shutdown.
|
@@ -0,0 +1,91 @@
|
|
1
|
+
Let's write our Publisher class next. To simulate some sort of real work we can sleep
|
2
|
+
and publish a number in a loop, mimicking a sensor reader for example.
|
3
|
+
|
4
|
+
```python title="publisher.py"
|
5
|
+
import asyncio
|
6
|
+
import logging
|
7
|
+
import random
|
8
|
+
|
9
|
+
from valkey.asyncio import Valkey
|
10
|
+
|
11
|
+
|
12
|
+
class Publisher:
|
13
|
+
def __init__(self, valkey: Valkey) -> None:
|
14
|
+
self._valkey = valkey
|
15
|
+
|
16
|
+
async def run(self) -> None:
|
17
|
+
while True:
|
18
|
+
number = random.randint(-100, 100)
|
19
|
+
logging.info(f"Publishing: {number}")
|
20
|
+
await self._valkey.xadd("numbers", {"number": str(number)})
|
21
|
+
await asyncio.sleep(1)
|
22
|
+
|
23
|
+
```
|
24
|
+
|
25
|
+
!!! note
|
26
|
+
The Publisher asking for the Valkey instance when being initialised is a form of
|
27
|
+
[Dependency Injection](https://en.wikipedia.org/wiki/Dependency_injection) (specifically
|
28
|
+
Constructor injection). Doing this separates out the concerns of configuring the client and
|
29
|
+
using it.
|
30
|
+
|
31
|
+
|
32
|
+
Let's register the Publisher with our engin. We can do this by making a simple factory function
|
33
|
+
below the `Publisher`.
|
34
|
+
|
35
|
+
```python
|
36
|
+
def publisher_factory(valkey: Valkey) -> Publisher:
|
37
|
+
return Publisher(valkey=valkey)
|
38
|
+
```
|
39
|
+
|
40
|
+
This isn't enough though as we need to tell the engin how to run the publisher as well. We want
|
41
|
+
our engin to call `Publisher.run` when the application is run, we can do that by using the
|
42
|
+
`Supervisor` type which is always provided by Engin.
|
43
|
+
|
44
|
+
```python
|
45
|
+
def publisher_factory(valkey: Valkey, supervisor: Supervisor) -> Publisher:
|
46
|
+
publisher = Publisher(valkey=valkey)
|
47
|
+
|
48
|
+
# run the publisher as a supervised application task
|
49
|
+
supervisor.supervise(publisher.run)
|
50
|
+
|
51
|
+
return publisher
|
52
|
+
```
|
53
|
+
|
54
|
+
!!! tip
|
55
|
+
|
56
|
+
Supervised tasks can handle exceptions in different ways, controlled by the `OnException`
|
57
|
+
enum. By default if the supervised task errors then it will cause the engin to shutdown,
|
58
|
+
but you can also choose for the error to be ignored or the task to be restarted.
|
59
|
+
|
60
|
+
Now we just need to register our `publisher_factory` with the engin. We can do this using the
|
61
|
+
`Provide` marker class which allows us to "provide a dependency" to the engin.
|
62
|
+
|
63
|
+
```python title="app.py"
|
64
|
+
# ... existing code ...
|
65
|
+
from engin import Provide
|
66
|
+
from examples.tutorial.publisher import publisher_factory
|
67
|
+
|
68
|
+
|
69
|
+
engin = Engin(Provide(publisher_factory))
|
70
|
+
```
|
71
|
+
|
72
|
+
Our publisher requires a Valkey client, so let's create a factory for that too.
|
73
|
+
|
74
|
+
```python title="valkey_client.py"
|
75
|
+
from valkey.asyncio import Valkey
|
76
|
+
|
77
|
+
def valkey_client_factory() -> Valkey:
|
78
|
+
return Valkey.from_url("valkey://localhost:6379")
|
79
|
+
```
|
80
|
+
|
81
|
+
And let's provide this factory to the engin.
|
82
|
+
|
83
|
+
```python title="app.py"
|
84
|
+
# ... existing code ...
|
85
|
+
from engin import Provide
|
86
|
+
from examples.tutorial.publisher import publisher_factory
|
87
|
+
from examples.tutorial.valkey_client import valkey_client_factory
|
88
|
+
|
89
|
+
|
90
|
+
engin = Engin(Provide(publisher_factory), Provide(valkey_client_factory))
|
91
|
+
```
|
@@ -0,0 +1,26 @@
|
|
1
|
+
Let's try to run our application.
|
2
|
+
|
3
|
+
```bash
|
4
|
+
$ python examples/tutorial/main.py
|
5
|
+
INFO:engin:starting engin
|
6
|
+
INFO:engin:startup complete
|
7
|
+
INFO:engin:stopping engin
|
8
|
+
INFO:engin:shutdown complete
|
9
|
+
```
|
10
|
+
|
11
|
+
You'll notice that the application starts and then immediately shuts down. This is because we haven't told `engin` to actually *do* anything. We have registered providers, but nothing is using them. The `engin` will only assemble dependencies that are required by an `Invocation` or `Entrypoint`.
|
12
|
+
|
13
|
+
To fix this, we'll register the `Publisher` as an `Entrypoint`. This tells the `engin` that the `Publisher` is a core component of our application and should be started.
|
14
|
+
|
15
|
+
```python
|
16
|
+
# examples/tutorial/main.py
|
17
|
+
# ...
|
18
|
+
from engin import Entrypoint
|
19
|
+
|
20
|
+
engin = Engin(
|
21
|
+
Provide(valkey_client_factory),
|
22
|
+
Entrypoint(Publisher),
|
23
|
+
)
|
24
|
+
```
|
25
|
+
|
26
|
+
Now if you run the application, you will see the publisher running and logging messages.
|
@@ -0,0 +1,37 @@
|
|
1
|
+
|
2
|
+
Next, we'll create a `Consumer` to process the messages from our `Publisher`. This class will read from the `numbers` stream, parse the number from the message, and keep a running total. Just like the `Publisher`, we'll supervise the `run` method to execute it as a background task.
|
3
|
+
|
4
|
+
```python
|
5
|
+
# examples/tutorial/main.py
|
6
|
+
# ...
|
7
|
+
|
8
|
+
class Consumer:
|
9
|
+
def __init__(self, valkey: Valkey, supervisor: Supervisor):
|
10
|
+
self._valkey = valkey
|
11
|
+
self._total = 0
|
12
|
+
supervisor.supervise(self.run)
|
13
|
+
|
14
|
+
async def run(self) -> None:
|
15
|
+
logging.info("Consumer starting")
|
16
|
+
await self._valkey.xgroup_create("numbers", "total", mkstream=True)
|
17
|
+
while True:
|
18
|
+
messages = await self._valkey.xreadgroup("total", "consumer", {"numbers": ">"})
|
19
|
+
for _, message in messages:
|
20
|
+
number = int(message[b"number"])
|
21
|
+
self._total += number
|
22
|
+
logging.info(f"Consumed: {number}, Total: {self._total}")
|
23
|
+
|
24
|
+
```
|
25
|
+
|
26
|
+
Now, we'll also register the `Consumer` as an `Entrypoint`. Since `engin` is an asynchronous framework, it can manage multiple entrypoints concurrently. Both the `Publisher` and `Consumer` will run in the same event loop.
|
27
|
+
|
28
|
+
```python
|
29
|
+
# examples/tutorial/main.py
|
30
|
+
# ...
|
31
|
+
|
32
|
+
engin = Engin(
|
33
|
+
Provide(valkey_client_factory),
|
34
|
+
Entrypoint(Publisher),
|
35
|
+
Entrypoint(Consumer),
|
36
|
+
)
|
37
|
+
```
|
@@ -0,0 +1,52 @@
|
|
1
|
+
We'll use `pydantic-settings` to manage our configuration. Let's define a `ValkeyConfig` class.
|
2
|
+
|
3
|
+
```python
|
4
|
+
# examples/tutorial/_valkey.py
|
5
|
+
from pydantic_settings import BaseSettings
|
6
|
+
from valkey import Valkey
|
7
|
+
|
8
|
+
|
9
|
+
class ValkeyConfig(BaseSettings):
|
10
|
+
valkey_url: str = "..."
|
11
|
+
|
12
|
+
|
13
|
+
def valkey_client_factory() -> Valkey:
|
14
|
+
config = ValkeyConfig()
|
15
|
+
return Valkey.from_url(config.valkey_url)
|
16
|
+
```
|
17
|
+
|
18
|
+
Our application is growing, and the `engin` definition is getting more complex. To keep our code organized, we can group related dependencies into a `Block`. A `Block` is a reusable component that can provide dependencies to the `engin`.
|
19
|
+
|
20
|
+
Let's create a `ValkeyBlock` to house our Valkey-related components.
|
21
|
+
|
22
|
+
```python
|
23
|
+
# examples/tutorial/main.py
|
24
|
+
# ...
|
25
|
+
from engin import Block, provide
|
26
|
+
|
27
|
+
class ValkeyBlock(Block):
|
28
|
+
@provide
|
29
|
+
def valkey_config_factory(self) -> ValkeyConfig:
|
30
|
+
return ValkeyConfig()
|
31
|
+
|
32
|
+
@provide
|
33
|
+
def valkey_client_factory(self, config: ValkeyConfig) -> Valkey:
|
34
|
+
return Valkey.from_url(config.valkey_url)
|
35
|
+
```
|
36
|
+
|
37
|
+
We've moved the `ValkeyConfig` and `valkey_client_factory` into the `ValkeyBlock` and decorated them with `@provide`. This tells the `engin` that these methods are providers.
|
38
|
+
|
39
|
+
Now, we can update our `engin` to use the `ValkeyBlock`.
|
40
|
+
|
41
|
+
```python
|
42
|
+
# examples/tutorial/main.py
|
43
|
+
# ...
|
44
|
+
|
45
|
+
engin = Engin(
|
46
|
+
ValkeyBlock(),
|
47
|
+
Entrypoint(Publisher),
|
48
|
+
Entrypoint(Consumer),
|
49
|
+
)
|
50
|
+
```
|
51
|
+
|
52
|
+
Our `engin` is now much cleaner and easier to read. The `ValkeyBlock` encapsulates the details of creating the Valkey client, making our application more modular and maintainable.
|
@@ -0,0 +1,10 @@
|
|
1
|
+
# Tutorial
|
2
|
+
|
3
|
+
In this tutorial we will build a small toy application from scratch.
|
4
|
+
|
5
|
+
Our application will publish random numbers to a Valkey stream, and then consume them and
|
6
|
+
update a running total. This will be enough to cover most of the features of Engin.
|
7
|
+
|
8
|
+
The final code can be found as
|
9
|
+
[an example](https://github.com/invokermain/engin/tree/main/examples/tutorial) in the Github
|
10
|
+
repo.
|
@@ -0,0 +1,21 @@
|
|
1
|
+
import asyncio
|
2
|
+
import logging
|
3
|
+
|
4
|
+
from engin import Engin, Entrypoint, Provide
|
5
|
+
from examples.tutorial.consumer import Consumer, consumer_factory
|
6
|
+
from examples.tutorial.publisher import Publisher, publisher_factory
|
7
|
+
from examples.tutorial.valkey_client import ValkeyBlock
|
8
|
+
|
9
|
+
logging.basicConfig(level=logging.INFO)
|
10
|
+
|
11
|
+
engin = Engin(
|
12
|
+
ValkeyBlock(),
|
13
|
+
Provide(publisher_factory),
|
14
|
+
Provide(consumer_factory),
|
15
|
+
Entrypoint(Publisher),
|
16
|
+
Entrypoint(Consumer),
|
17
|
+
)
|
18
|
+
|
19
|
+
|
20
|
+
if __name__ == "__main__":
|
21
|
+
asyncio.run(engin.run())
|
@@ -0,0 +1,36 @@
|
|
1
|
+
import logging
|
2
|
+
|
3
|
+
from valkey import ResponseError
|
4
|
+
from valkey.asyncio import Valkey
|
5
|
+
|
6
|
+
from engin import Supervisor
|
7
|
+
|
8
|
+
|
9
|
+
class Consumer:
|
10
|
+
def __init__(self, valkey: Valkey) -> None:
|
11
|
+
self._valkey = valkey
|
12
|
+
self._total = 0
|
13
|
+
|
14
|
+
async def run(self) -> None:
|
15
|
+
logging.info("Consumer starting")
|
16
|
+
try:
|
17
|
+
await self._valkey.xgroup_create("numbers", "total", mkstream=True)
|
18
|
+
except ResponseError:
|
19
|
+
pass # group already exists
|
20
|
+
|
21
|
+
while True:
|
22
|
+
messages = await self._valkey.xreadgroup("total", "consumer", {"numbers": ">"})
|
23
|
+
for _, stream_messages in messages:
|
24
|
+
for _, message in stream_messages:
|
25
|
+
number = int(message[b"number"])
|
26
|
+
self._total += number
|
27
|
+
logging.info(f"Consumed: {number}, Total: {self._total}")
|
28
|
+
|
29
|
+
|
30
|
+
def consumer_factory(valkey: Valkey, supervisor: Supervisor) -> Consumer:
|
31
|
+
consumer = Consumer(valkey=valkey)
|
32
|
+
|
33
|
+
# run the consumer as a supervised application task
|
34
|
+
supervisor.supervise(consumer.run)
|
35
|
+
|
36
|
+
return consumer
|
@@ -0,0 +1,28 @@
|
|
1
|
+
import asyncio
|
2
|
+
import logging
|
3
|
+
import random
|
4
|
+
|
5
|
+
from valkey.asyncio import Valkey
|
6
|
+
|
7
|
+
from engin import Supervisor
|
8
|
+
|
9
|
+
|
10
|
+
class Publisher:
|
11
|
+
def __init__(self, valkey: Valkey) -> None:
|
12
|
+
self._valkey = valkey
|
13
|
+
|
14
|
+
async def run(self) -> None:
|
15
|
+
while True:
|
16
|
+
number = random.randint(-100, 100)
|
17
|
+
logging.info(f"Publishing: {number}")
|
18
|
+
await self._valkey.xadd("numbers", {"number": str(number)})
|
19
|
+
await asyncio.sleep(1)
|
20
|
+
|
21
|
+
|
22
|
+
def publisher_factory(valkey: Valkey, supervisor: Supervisor) -> Publisher:
|
23
|
+
publisher = Publisher(valkey=valkey)
|
24
|
+
|
25
|
+
# run the publisher as a supervised application task
|
26
|
+
supervisor.supervise(publisher.run)
|
27
|
+
|
28
|
+
return publisher
|
@@ -0,0 +1,23 @@
|
|
1
|
+
from pydantic_settings import BaseSettings
|
2
|
+
from valkey.asyncio import Valkey
|
3
|
+
|
4
|
+
from engin import Block, Lifecycle, provide
|
5
|
+
|
6
|
+
|
7
|
+
class ValkeyConfig(BaseSettings):
|
8
|
+
valkey_url: str = "valkey://localhost:6379"
|
9
|
+
|
10
|
+
|
11
|
+
class ValkeyBlock(Block):
|
12
|
+
@provide
|
13
|
+
def config(self) -> ValkeyConfig:
|
14
|
+
return ValkeyConfig()
|
15
|
+
|
16
|
+
@provide
|
17
|
+
def client(self, config: ValkeyConfig, lifecycle: Lifecycle) -> Valkey:
|
18
|
+
client: Valkey = Valkey.from_url(config.valkey_url)
|
19
|
+
|
20
|
+
# close the client when the app is shutting down
|
21
|
+
lifecycle.hook(on_stop=client.aclose)
|
22
|
+
|
23
|
+
return client
|
@@ -6,8 +6,9 @@ theme:
|
|
6
6
|
name: 'material'
|
7
7
|
custom_dir: 'docs/overrides'
|
8
8
|
features:
|
9
|
-
- navigation.instant
|
10
9
|
- content.code.copy
|
10
|
+
- navigation.instant
|
11
|
+
- navigation.indexes
|
11
12
|
palette:
|
12
13
|
- scheme: 'default'
|
13
14
|
media: '(prefers-color-scheme: light)'
|
@@ -27,6 +28,13 @@ edit_uri: ""
|
|
27
28
|
|
28
29
|
nav:
|
29
30
|
- Home: "index.md"
|
31
|
+
- Tutorial:
|
32
|
+
- "tutorial/index.md"
|
33
|
+
- "Setup an Empty Application": "tutorial/1_empty_application.md"
|
34
|
+
- "Create the Publisher": "tutorial/2_create_a_publisher.md"
|
35
|
+
- "Run the Application": "tutorial/3_run_the_application.md"
|
36
|
+
- "Create the Consumer": "tutorial/4_create_the_consumer.md"
|
37
|
+
- "Refactor the Valkey client": "tutorial/5_refactor_valkey_client.md"
|
30
38
|
- Concepts:
|
31
39
|
- Engin: "concepts/engin.md"
|
32
40
|
- Providers: "concepts/providers.md"
|
@@ -1,6 +1,6 @@
|
|
1
1
|
[project]
|
2
2
|
name = "engin"
|
3
|
-
version = "0.1.
|
3
|
+
version = "0.1.0rc3"
|
4
4
|
description = "An async-first modular application framework"
|
5
5
|
readme = "README.md"
|
6
6
|
requires-python = ">=3.10"
|
@@ -43,6 +43,7 @@ dev = [
|
|
43
43
|
"pytest-mock>=3.14.0",
|
44
44
|
"pytest-benchmark>=5.1.0",
|
45
45
|
"websockets>=15.0.1",
|
46
|
+
"valkey>=6.1.0",
|
46
47
|
]
|
47
48
|
docs = [
|
48
49
|
"mkdocs-material>=9.5.50",
|
@@ -78,7 +79,7 @@ ignore = [
|
|
78
79
|
"**/src/*" = ["PT"]
|
79
80
|
"**/tests/*" = ["S", "ANN"]
|
80
81
|
# allow print statements in examples/scripts
|
81
|
-
"**/examples/*" = ["T201"]
|
82
|
+
"**/examples/*" = ["T201", "LOG015", "S", "SIM105"]
|
82
83
|
"**/scripts/*" = ["T201"]
|
83
84
|
|
84
85
|
|
@@ -50,13 +50,6 @@ def check_dependencies(
|
|
50
50
|
for missing_type in sorted_missing:
|
51
51
|
console.print(f" • {missing_type}", style="red")
|
52
52
|
|
53
|
-
available_providers = sorted(
|
54
|
-
str(provider.return_type_id) for provider in assembler.providers
|
55
|
-
)
|
56
|
-
console.print("\nAvailable providers:", style="yellow")
|
57
|
-
for available_type in available_providers:
|
58
|
-
console.print(f" • {available_type}", style="yellow")
|
59
|
-
|
60
53
|
raise typer.Exit(code=1)
|
61
54
|
else:
|
62
55
|
console.print("✅ All dependencies are satisfied!", style="green bold")
|
@@ -179,7 +179,9 @@ class Engin:
|
|
179
179
|
try:
|
180
180
|
async with supervisor:
|
181
181
|
await self._stop_requested_event.wait()
|
182
|
-
|
182
|
+
|
183
|
+
# shutdown after stopping supervised tasks
|
184
|
+
await self._shutdown()
|
183
185
|
except BaseException:
|
184
186
|
await self._shutdown()
|
185
187
|
|
@@ -262,6 +264,7 @@ async def _stop_engin_on_signal(stop_requested_event: Event) -> None:
|
|
262
264
|
|
263
265
|
# windows does not support signal_handlers, so this is the workaround
|
264
266
|
def ctrlc_handler(sig: int, frame: FrameType | None) -> None:
|
267
|
+
LOG.debug(f"received {signal.SIGINT.name} signal")
|
265
268
|
nonlocal should_stop
|
266
269
|
if should_stop:
|
267
270
|
raise KeyboardInterrupt("Forced keyboard interrupt")
|
@@ -63,7 +63,6 @@ class _SupervisorTask:
|
|
63
63
|
raise
|
64
64
|
except Exception as err:
|
65
65
|
self.last_exception = err
|
66
|
-
|
67
66
|
if self.on_exception == OnException.IGNORE:
|
68
67
|
LOG.warning(
|
69
68
|
f"supervisor task '{self.name}' raised {type(err).__name__} "
|
@@ -122,6 +121,7 @@ class Supervisor:
|
|
122
121
|
self._task_group = await anyio.create_task_group().__aenter__()
|
123
122
|
|
124
123
|
for task in self._tasks:
|
124
|
+
LOG.info(f"supervising task: {task.name}")
|
125
125
|
self._task_group.start_soon(task, name=task.name)
|
126
126
|
|
127
127
|
async def __aexit__(
|
@@ -46,7 +46,6 @@ def test_check_missing_dependencies():
|
|
46
46
|
assert result.exit_code == 1
|
47
47
|
assert "❌ Missing providers found:" in result.output
|
48
48
|
assert "int" in result.output
|
49
|
-
assert "Available providers:" in result.output
|
50
49
|
|
51
50
|
|
52
51
|
def test_check_complex_missing_dependencies():
|
File without changes
|