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.
Files changed (119) hide show
  1. {engin-0.1.0rc2 → engin-0.1.0rc3}/.gitignore +2 -0
  2. {engin-0.1.0rc2 → engin-0.1.0rc3}/PKG-INFO +1 -1
  3. engin-0.1.0rc3/docs/tutorial/1_empty_application.md +37 -0
  4. engin-0.1.0rc3/docs/tutorial/2_create_a_publisher.md +91 -0
  5. engin-0.1.0rc3/docs/tutorial/3_run_the_application.md +26 -0
  6. engin-0.1.0rc3/docs/tutorial/4_create_the_consumer.md +37 -0
  7. engin-0.1.0rc3/docs/tutorial/5_refactor_valkey_client.md +52 -0
  8. engin-0.1.0rc3/docs/tutorial/index.md +10 -0
  9. engin-0.1.0rc3/examples/tutorial/app.py +21 -0
  10. engin-0.1.0rc3/examples/tutorial/consumer.py +36 -0
  11. engin-0.1.0rc3/examples/tutorial/publisher.py +28 -0
  12. engin-0.1.0rc3/examples/tutorial/valkey_client.py +23 -0
  13. {engin-0.1.0rc2 → engin-0.1.0rc3}/mkdocs.yaml +9 -1
  14. {engin-0.1.0rc2 → engin-0.1.0rc3}/pyproject.toml +3 -2
  15. {engin-0.1.0rc2 → engin-0.1.0rc3}/src/engin/_cli/_check.py +0 -7
  16. {engin-0.1.0rc2 → engin-0.1.0rc3}/src/engin/_cli/_graph.html +1 -1
  17. {engin-0.1.0rc2 → engin-0.1.0rc3}/src/engin/_engin.py +4 -1
  18. {engin-0.1.0rc2 → engin-0.1.0rc3}/src/engin/_supervisor.py +1 -1
  19. {engin-0.1.0rc2 → engin-0.1.0rc3}/tests/cli/test_check.py +0 -1
  20. engin-0.1.0rc3/tests/conftest.py +0 -0
  21. {engin-0.1.0rc2 → engin-0.1.0rc3}/uv.lock +181 -132
  22. engin-0.1.0rc2/docs/tutorial.md +0 -15
  23. {engin-0.1.0rc2 → engin-0.1.0rc3}/.github/workflows/benchmark.yaml +0 -0
  24. {engin-0.1.0rc2 → engin-0.1.0rc3}/.github/workflows/check.yaml +0 -0
  25. {engin-0.1.0rc2 → engin-0.1.0rc3}/.github/workflows/publish.yaml +0 -0
  26. {engin-0.1.0rc2 → engin-0.1.0rc3}/.readthedocs.yaml +0 -0
  27. {engin-0.1.0rc2 → engin-0.1.0rc3}/CHANGELOG.md +0 -0
  28. {engin-0.1.0rc2 → engin-0.1.0rc3}/LICENSE +0 -0
  29. {engin-0.1.0rc2 → engin-0.1.0rc3}/README.md +0 -0
  30. {engin-0.1.0rc2 → engin-0.1.0rc3}/docs/cli.md +0 -0
  31. {engin-0.1.0rc2 → engin-0.1.0rc3}/docs/concepts/blocks.md +0 -0
  32. {engin-0.1.0rc2 → engin-0.1.0rc3}/docs/concepts/engin.md +0 -0
  33. {engin-0.1.0rc2 → engin-0.1.0rc3}/docs/concepts/invocations.md +0 -0
  34. {engin-0.1.0rc2 → engin-0.1.0rc3}/docs/concepts/lifecycle.md +0 -0
  35. {engin-0.1.0rc2 → engin-0.1.0rc3}/docs/concepts/providers.md +0 -0
  36. {engin-0.1.0rc2 → engin-0.1.0rc3}/docs/concepts/supervisor.md +0 -0
  37. {engin-0.1.0rc2 → engin-0.1.0rc3}/docs/engin-graph-output.png +0 -0
  38. {engin-0.1.0rc2 → engin-0.1.0rc3}/docs/index.md +0 -0
  39. {engin-0.1.0rc2 → engin-0.1.0rc3}/docs/integrations/fastapi-graph.png +0 -0
  40. {engin-0.1.0rc2 → engin-0.1.0rc3}/docs/integrations/fastapi.md +0 -0
  41. {engin-0.1.0rc2 → engin-0.1.0rc3}/docs/js/readthedocs.js +0 -0
  42. {engin-0.1.0rc2 → engin-0.1.0rc3}/docs/overrides/main.html +0 -0
  43. {engin-0.1.0rc2 → engin-0.1.0rc3}/docs/reference.md +0 -0
  44. {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/__init__.py +0 -0
  45. {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/asgi/__init__.py +0 -0
  46. {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/asgi/app.py +0 -0
  47. {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/asgi/common/__init__.py +0 -0
  48. {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/asgi/common/db/__init__.py +0 -0
  49. {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/asgi/common/db/adapaters/__init__.py +0 -0
  50. {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/asgi/common/db/adapaters/memory.py +0 -0
  51. {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/asgi/common/db/block.py +0 -0
  52. {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/asgi/common/db/ports.py +0 -0
  53. {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/asgi/common/starlette/__init__.py +0 -0
  54. {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/asgi/common/starlette/endpoint.py +0 -0
  55. {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/asgi/features/__init__.py +0 -0
  56. {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/asgi/features/cats/__init__.py +0 -0
  57. {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/asgi/features/cats/api/__init__.py +0 -0
  58. {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/asgi/features/cats/api/get.py +0 -0
  59. {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/asgi/features/cats/api/post.py +0 -0
  60. {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/asgi/features/cats/block.py +0 -0
  61. {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/asgi/features/cats/domain.py +0 -0
  62. {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/asgi/main.py +0 -0
  63. {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/fastapi/__init__.py +0 -0
  64. {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/fastapi/app.py +0 -0
  65. {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/fastapi/main.py +0 -0
  66. {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/fastapi/routes/__init__.py +0 -0
  67. {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/fastapi/routes/cats/__init__.py +0 -0
  68. {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/fastapi/routes/cats/adapters/__init__.py +0 -0
  69. {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/fastapi/routes/cats/adapters/repository.py +0 -0
  70. {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/fastapi/routes/cats/api.py +0 -0
  71. {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/fastapi/routes/cats/block.py +0 -0
  72. {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/fastapi/routes/cats/domain.py +0 -0
  73. {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/fastapi/routes/cats/ports.py +0 -0
  74. {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/simple/__init__.py +0 -0
  75. {engin-0.1.0rc2 → engin-0.1.0rc3}/examples/simple/main.py +0 -0
  76. {engin-0.1.0rc2/src/engin/extensions → engin-0.1.0rc3/examples/tutorial}/__init__.py +0 -0
  77. {engin-0.1.0rc2 → engin-0.1.0rc3}/src/engin/__init__.py +0 -0
  78. {engin-0.1.0rc2 → engin-0.1.0rc3}/src/engin/_assembler.py +0 -0
  79. {engin-0.1.0rc2 → engin-0.1.0rc3}/src/engin/_block.py +0 -0
  80. {engin-0.1.0rc2 → engin-0.1.0rc3}/src/engin/_cli/__init__.py +0 -0
  81. {engin-0.1.0rc2 → engin-0.1.0rc3}/src/engin/_cli/_common.py +0 -0
  82. {engin-0.1.0rc2 → engin-0.1.0rc3}/src/engin/_cli/_graph.py +0 -0
  83. {engin-0.1.0rc2 → engin-0.1.0rc3}/src/engin/_cli/_inspect.py +0 -0
  84. {engin-0.1.0rc2 → engin-0.1.0rc3}/src/engin/_dependency.py +0 -0
  85. {engin-0.1.0rc2 → engin-0.1.0rc3}/src/engin/_graph.py +0 -0
  86. {engin-0.1.0rc2 → engin-0.1.0rc3}/src/engin/_introspect.py +0 -0
  87. {engin-0.1.0rc2 → engin-0.1.0rc3}/src/engin/_lifecycle.py +0 -0
  88. {engin-0.1.0rc2 → engin-0.1.0rc3}/src/engin/_option.py +0 -0
  89. {engin-0.1.0rc2 → engin-0.1.0rc3}/src/engin/_type_utils.py +0 -0
  90. {engin-0.1.0rc2 → engin-0.1.0rc3}/src/engin/exceptions.py +0 -0
  91. {engin-0.1.0rc2/tests → engin-0.1.0rc3/src/engin/extensions}/__init__.py +0 -0
  92. {engin-0.1.0rc2 → engin-0.1.0rc3}/src/engin/extensions/asgi.py +0 -0
  93. {engin-0.1.0rc2 → engin-0.1.0rc3}/src/engin/extensions/fastapi.py +0 -0
  94. {engin-0.1.0rc2 → engin-0.1.0rc3}/src/engin/py.typed +0 -0
  95. {engin-0.1.0rc2/tests/acceptance → engin-0.1.0rc3/tests}/__init__.py +0 -0
  96. {engin-0.1.0rc2/tests/benchmarks → engin-0.1.0rc3/tests/acceptance}/__init__.py +0 -0
  97. {engin-0.1.0rc2 → engin-0.1.0rc3}/tests/acceptance/test_engin_signal_handling.py +0 -0
  98. {engin-0.1.0rc2 → engin-0.1.0rc3}/tests/acceptance/test_error_in_invocation.py +0 -0
  99. {engin-0.1.0rc2 → engin-0.1.0rc3}/tests/acceptance/test_error_in_lifecycle_shutdown.py +0 -0
  100. {engin-0.1.0rc2 → engin-0.1.0rc3}/tests/acceptance/test_error_in_lifecycle_startup.py +0 -0
  101. {engin-0.1.0rc2 → engin-0.1.0rc3}/tests/acceptance/test_error_in_provider.py +0 -0
  102. {engin-0.1.0rc2 → engin-0.1.0rc3}/tests/acceptance/test_error_in_supervisor_task.py +0 -0
  103. {engin-0.1.0rc2 → engin-0.1.0rc3}/tests/acceptance/test_fastapi.py +0 -0
  104. {engin-0.1.0rc2/tests/cli → engin-0.1.0rc3/tests/benchmarks}/__init__.py +0 -0
  105. {engin-0.1.0rc2 → engin-0.1.0rc3}/tests/benchmarks/conftest.py +0 -0
  106. {engin-0.1.0rc2 → engin-0.1.0rc3}/tests/benchmarks/test_bench_assembler.py +0 -0
  107. /engin-0.1.0rc2/tests/conftest.py → /engin-0.1.0rc3/tests/cli/__init__.py +0 -0
  108. {engin-0.1.0rc2 → engin-0.1.0rc3}/tests/cli/test_get_engin_instance.py +0 -0
  109. {engin-0.1.0rc2 → engin-0.1.0rc3}/tests/cli/test_graph.py +0 -0
  110. {engin-0.1.0rc2 → engin-0.1.0rc3}/tests/cli/test_inspect.py +0 -0
  111. {engin-0.1.0rc2 → engin-0.1.0rc3}/tests/deps.py +0 -0
  112. {engin-0.1.0rc2 → engin-0.1.0rc3}/tests/test_assembler.py +0 -0
  113. {engin-0.1.0rc2 → engin-0.1.0rc3}/tests/test_block.py +0 -0
  114. {engin-0.1.0rc2 → engin-0.1.0rc3}/tests/test_dependencies.py +0 -0
  115. {engin-0.1.0rc2 → engin-0.1.0rc3}/tests/test_engin.py +0 -0
  116. {engin-0.1.0rc2 → engin-0.1.0rc3}/tests/test_graph.py +0 -0
  117. {engin-0.1.0rc2 → engin-0.1.0rc3}/tests/test_lifecycle.py +0 -0
  118. {engin-0.1.0rc2 → engin-0.1.0rc3}/tests/test_supervisor.py +0 -0
  119. {engin-0.1.0rc2 → engin-0.1.0rc3}/tests/test_type_id.py +0 -0
@@ -163,3 +163,5 @@ cython_debug/
163
163
  # and can be added to the global gitignore or merged into this file. For a more nuclear
164
164
  # option (not recommended) you can uncomment the following to ignore the entire idea folder.
165
165
  .idea/
166
+
167
+ scrap/**
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: engin
3
- Version: 0.1.0rc2
3
+ Version: 0.1.0rc3
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/
@@ -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.0rc2"
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")
@@ -400,7 +400,7 @@
400
400
  }
401
401
 
402
402
  if (node.style_classes && node.style_classes.length > 0) {
403
- styleClasses = `:::${node.style_classes.join(' ')}`;
403
+ styleClasses = `:::${node.style_classes.join(',')}`;
404
404
  }
405
405
 
406
406
  return shape + styleClasses;
@@ -179,7 +179,9 @@ class Engin:
179
179
  try:
180
180
  async with supervisor:
181
181
  await self._stop_requested_event.wait()
182
- await self._shutdown()
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