engin 0.1a1__tar.gz → 0.1.0b2__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 (105) hide show
  1. {engin-0.1a1 → engin-0.1.0b2}/CHANGELOG.md +4 -0
  2. engin-0.1.0b2/PKG-INFO +108 -0
  3. engin-0.1.0b2/README.md +90 -0
  4. {engin-0.1a1 → engin-0.1.0b2}/docs/concepts/invocations.md +3 -3
  5. {engin-0.1a1 → engin-0.1.0b2}/docs/concepts/lifecycle.md +0 -2
  6. engin-0.1.0b2/docs/index.md +62 -0
  7. {engin-0.1a1 → engin-0.1.0b2}/mkdocs.yaml +10 -6
  8. {engin-0.1a1 → engin-0.1.0b2}/pyproject.toml +1 -1
  9. {engin-0.1a1 → engin-0.1.0b2}/src/engin/__init__.py +0 -2
  10. {engin-0.1a1 → engin-0.1.0b2}/src/engin/_engin.py +89 -67
  11. {engin-0.1a1 → engin-0.1.0b2}/src/engin/_supervisor.py +9 -27
  12. engin-0.1.0b2/tests/acceptance/test_engin_signal_handling.py +24 -0
  13. {engin-0.1a1 → engin-0.1.0b2}/tests/acceptance/test_error_in_shutdown.py +2 -0
  14. {engin-0.1a1 → engin-0.1.0b2}/tests/acceptance/test_error_in_start_up.py +4 -3
  15. engin-0.1.0b2/tests/acceptance/test_error_in_supervised_task.py +29 -0
  16. {engin-0.1a1 → engin-0.1.0b2}/tests/test_supervisor.py +21 -23
  17. {engin-0.1a1 → engin-0.1.0b2}/uv.lock +111 -110
  18. engin-0.1a1/PKG-INFO +0 -73
  19. engin-0.1a1/README.md +0 -55
  20. engin-0.1a1/docs/getting-started.md +0 -7
  21. engin-0.1a1/docs/index.md +0 -21
  22. engin-0.1a1/src/engin/_shutdown.py +0 -4
  23. engin-0.1a1/tests/acceptance/test_error_in_supervised_task.py +0 -17
  24. {engin-0.1a1 → engin-0.1.0b2}/.github/workflows/benchmark.yaml +0 -0
  25. {engin-0.1a1 → engin-0.1.0b2}/.github/workflows/check.yaml +0 -0
  26. {engin-0.1a1 → engin-0.1.0b2}/.github/workflows/publish.yaml +0 -0
  27. {engin-0.1a1 → engin-0.1.0b2}/.gitignore +0 -0
  28. {engin-0.1a1 → engin-0.1.0b2}/.readthedocs.yaml +0 -0
  29. {engin-0.1a1 → engin-0.1.0b2}/LICENSE +0 -0
  30. {engin-0.1a1 → engin-0.1.0b2}/docs/concepts/blocks.md +0 -0
  31. {engin-0.1a1 → engin-0.1.0b2}/docs/concepts/engin.md +0 -0
  32. {engin-0.1a1 → engin-0.1.0b2}/docs/concepts/providers.md +0 -0
  33. {engin-0.1a1 → engin-0.1.0b2}/docs/guides/fastapi-graph.png +0 -0
  34. {engin-0.1a1 → engin-0.1.0b2}/docs/guides/fastapi.md +0 -0
  35. {engin-0.1a1 → engin-0.1.0b2}/docs/js/readthedocs.js +0 -0
  36. {engin-0.1a1 → engin-0.1.0b2}/docs/overrides/main.html +0 -0
  37. {engin-0.1a1 → engin-0.1.0b2}/docs/reference.md +0 -0
  38. {engin-0.1a1 → engin-0.1.0b2}/examples/__init__.py +0 -0
  39. {engin-0.1a1 → engin-0.1.0b2}/examples/asgi/__init__.py +0 -0
  40. {engin-0.1a1 → engin-0.1.0b2}/examples/asgi/app.py +0 -0
  41. {engin-0.1a1 → engin-0.1.0b2}/examples/asgi/common/__init__.py +0 -0
  42. {engin-0.1a1 → engin-0.1.0b2}/examples/asgi/common/db/__init__.py +0 -0
  43. {engin-0.1a1 → engin-0.1.0b2}/examples/asgi/common/db/adapaters/__init__.py +0 -0
  44. {engin-0.1a1 → engin-0.1.0b2}/examples/asgi/common/db/adapaters/memory.py +0 -0
  45. {engin-0.1a1 → engin-0.1.0b2}/examples/asgi/common/db/block.py +0 -0
  46. {engin-0.1a1 → engin-0.1.0b2}/examples/asgi/common/db/ports.py +0 -0
  47. {engin-0.1a1 → engin-0.1.0b2}/examples/asgi/common/starlette/__init__.py +0 -0
  48. {engin-0.1a1 → engin-0.1.0b2}/examples/asgi/common/starlette/endpoint.py +0 -0
  49. {engin-0.1a1 → engin-0.1.0b2}/examples/asgi/features/__init__.py +0 -0
  50. {engin-0.1a1 → engin-0.1.0b2}/examples/asgi/features/cats/__init__.py +0 -0
  51. {engin-0.1a1 → engin-0.1.0b2}/examples/asgi/features/cats/api/__init__.py +0 -0
  52. {engin-0.1a1 → engin-0.1.0b2}/examples/asgi/features/cats/api/get.py +0 -0
  53. {engin-0.1a1 → engin-0.1.0b2}/examples/asgi/features/cats/api/post.py +0 -0
  54. {engin-0.1a1 → engin-0.1.0b2}/examples/asgi/features/cats/block.py +0 -0
  55. {engin-0.1a1 → engin-0.1.0b2}/examples/asgi/features/cats/domain.py +0 -0
  56. {engin-0.1a1 → engin-0.1.0b2}/examples/asgi/main.py +0 -0
  57. {engin-0.1a1 → engin-0.1.0b2}/examples/fastapi/__init__.py +0 -0
  58. {engin-0.1a1 → engin-0.1.0b2}/examples/fastapi/app.py +0 -0
  59. {engin-0.1a1 → engin-0.1.0b2}/examples/fastapi/main.py +0 -0
  60. {engin-0.1a1 → engin-0.1.0b2}/examples/fastapi/routes/__init__.py +0 -0
  61. {engin-0.1a1 → engin-0.1.0b2}/examples/fastapi/routes/cats/__init__.py +0 -0
  62. {engin-0.1a1 → engin-0.1.0b2}/examples/fastapi/routes/cats/adapters/__init__.py +0 -0
  63. {engin-0.1a1 → engin-0.1.0b2}/examples/fastapi/routes/cats/adapters/repository.py +0 -0
  64. {engin-0.1a1 → engin-0.1.0b2}/examples/fastapi/routes/cats/api.py +0 -0
  65. {engin-0.1a1 → engin-0.1.0b2}/examples/fastapi/routes/cats/block.py +0 -0
  66. {engin-0.1a1 → engin-0.1.0b2}/examples/fastapi/routes/cats/domain.py +0 -0
  67. {engin-0.1a1 → engin-0.1.0b2}/examples/fastapi/routes/cats/ports.py +0 -0
  68. {engin-0.1a1 → engin-0.1.0b2}/examples/simple/__init__.py +0 -0
  69. {engin-0.1a1 → engin-0.1.0b2}/examples/simple/main.py +0 -0
  70. {engin-0.1a1 → engin-0.1.0b2}/src/engin/_assembler.py +0 -0
  71. {engin-0.1a1 → engin-0.1.0b2}/src/engin/_block.py +0 -0
  72. {engin-0.1a1 → engin-0.1.0b2}/src/engin/_cli/__init__.py +0 -0
  73. {engin-0.1a1 → engin-0.1.0b2}/src/engin/_cli/_common.py +0 -0
  74. {engin-0.1a1 → engin-0.1.0b2}/src/engin/_cli/_graph.html +0 -0
  75. {engin-0.1a1 → engin-0.1.0b2}/src/engin/_cli/_graph.py +0 -0
  76. {engin-0.1a1 → engin-0.1.0b2}/src/engin/_cli/_inspect.py +0 -0
  77. {engin-0.1a1 → engin-0.1.0b2}/src/engin/_dependency.py +0 -0
  78. {engin-0.1a1 → engin-0.1.0b2}/src/engin/_graph.py +0 -0
  79. {engin-0.1a1 → engin-0.1.0b2}/src/engin/_introspect.py +0 -0
  80. {engin-0.1a1 → engin-0.1.0b2}/src/engin/_lifecycle.py +0 -0
  81. {engin-0.1a1 → engin-0.1.0b2}/src/engin/_option.py +0 -0
  82. {engin-0.1a1 → engin-0.1.0b2}/src/engin/_type_utils.py +0 -0
  83. {engin-0.1a1 → engin-0.1.0b2}/src/engin/exceptions.py +0 -0
  84. {engin-0.1a1 → engin-0.1.0b2}/src/engin/extensions/__init__.py +0 -0
  85. {engin-0.1a1 → engin-0.1.0b2}/src/engin/extensions/asgi.py +0 -0
  86. {engin-0.1a1 → engin-0.1.0b2}/src/engin/extensions/fastapi.py +0 -0
  87. {engin-0.1a1 → engin-0.1.0b2}/src/engin/py.typed +0 -0
  88. {engin-0.1a1 → engin-0.1.0b2}/tests/__init__.py +0 -0
  89. {engin-0.1a1 → engin-0.1.0b2}/tests/acceptance/__init__.py +0 -0
  90. {engin-0.1a1 → engin-0.1.0b2}/tests/acceptance/test_fastapi.py +0 -0
  91. {engin-0.1a1 → engin-0.1.0b2}/tests/benchmarks/__init__.py +0 -0
  92. {engin-0.1a1 → engin-0.1.0b2}/tests/benchmarks/conftest.py +0 -0
  93. {engin-0.1a1 → engin-0.1.0b2}/tests/benchmarks/test_bench_assembler.py +0 -0
  94. {engin-0.1a1 → engin-0.1.0b2}/tests/cli/__init__.py +0 -0
  95. {engin-0.1a1 → engin-0.1.0b2}/tests/cli/test_graph.py +0 -0
  96. {engin-0.1a1 → engin-0.1.0b2}/tests/cli/test_inspect.py +0 -0
  97. {engin-0.1a1 → engin-0.1.0b2}/tests/conftest.py +0 -0
  98. {engin-0.1a1 → engin-0.1.0b2}/tests/deps.py +0 -0
  99. {engin-0.1a1 → engin-0.1.0b2}/tests/test_assembler.py +0 -0
  100. {engin-0.1a1 → engin-0.1.0b2}/tests/test_block.py +0 -0
  101. {engin-0.1a1 → engin-0.1.0b2}/tests/test_dependencies.py +0 -0
  102. {engin-0.1a1 → engin-0.1.0b2}/tests/test_engin.py +0 -0
  103. {engin-0.1a1 → engin-0.1.0b2}/tests/test_graph.py +0 -0
  104. {engin-0.1a1 → engin-0.1.0b2}/tests/test_lifecycle.py +0 -0
  105. {engin-0.1a1 → engin-0.1.0b2}/tests/test_type_id.py +0 -0
@@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
11
11
 
12
12
  - Supervisor class which can safely supervise long running tasks.
13
13
 
14
+ ### Changed
15
+
16
+ - `ASGIEngin.run()` now raises an error to prevent incorrect usage.
17
+
14
18
 
15
19
  ## [0.0.20] - 2025-06-18
16
20
 
engin-0.1.0b2/PKG-INFO ADDED
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.4
2
+ Name: engin
3
+ Version: 0.1.0b2
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
10
+ License-File: LICENSE
11
+ Keywords: Application Framework,Dependency Injection
12
+ Requires-Python: >=3.10
13
+ Requires-Dist: anyio>=4
14
+ Requires-Dist: exceptiongroup>=1
15
+ Provides-Extra: cli
16
+ Requires-Dist: typer>=0.15; extra == 'cli'
17
+ Description-Content-Type: text/markdown
18
+
19
+ # Engin 🏎️
20
+
21
+ [![codecov](https://codecov.io/gh/invokermain/engin/graph/badge.svg?token=4PJOIMV6IB)](https://codecov.io/gh/invokermain/engin)
22
+
23
+ ---
24
+
25
+ **Documentation**: [https://engin.readthedocs.io/](https://engin.readthedocs.io/)
26
+
27
+ **Source Code**: [https://github.com/invokermain/engin](https://github.com/invokermain/engin)
28
+
29
+ ---
30
+
31
+ Engin is a lightweight application framework powered by dependency injection, it helps
32
+ you build and maintain large monoliths and many microservices.
33
+
34
+
35
+ ## Features
36
+
37
+ The Engin framework includes:
38
+
39
+ - A fully-featured dependency injection system.
40
+ - A robust application runtime with lifecycle hooks and supervised background tasks.
41
+ - Zero boiler-plate code reuse across multiple applications.
42
+ - Integrations for other frameworks such as FastAPI.
43
+ - Full async support.
44
+ - CLI commands to aid local development.
45
+
46
+
47
+ ## Installation
48
+
49
+ Engin is available on PyPI, install using your favourite dependency manager:
50
+
51
+ - `pip install engin`
52
+ - `poetry add engin`
53
+ - `uv add engin`
54
+
55
+ ## Example
56
+
57
+ A small example which shows some of the runtime features of Engin. This application
58
+ makes a http request and then performs a shutdown.
59
+
60
+ ```python
61
+ import asyncio
62
+ from httpx import AsyncClient
63
+ from engin import Engin, Invoke, Lifecycle, Provide, Supervisor
64
+
65
+
66
+ def httpx_client_factory(lifecycle: Lifecycle) -> AsyncClient:
67
+ # create our http client
68
+ client = AsyncClient()
69
+ # this will open and close the AsyncClient as part of the application's lifecycle
70
+ lifecycle.append(client)
71
+ return client
72
+
73
+
74
+ async def main(
75
+ httpx_client: AsyncClient,
76
+ supervisor: Supervisor,
77
+ ) -> None:
78
+ async def http_request():
79
+ await httpx_client.get("https://httpbin.org/get")
80
+ # one we've made the http request shutdown the application
81
+ raise asyncio.CancelledError("Forcing shutdown")
82
+
83
+ # supervise the http request as part of the application's lifecycle
84
+ supervisor.supervise(http_request)
85
+
86
+ # define our modular application
87
+ engin = Engin(Provide(httpx_client_factory), Invoke(main))
88
+
89
+ # run it!
90
+ asyncio.run(engin.run())
91
+ ```
92
+
93
+ With logs enabled this will output:
94
+
95
+ ```shell
96
+ INFO:engin:starting engin
97
+ INFO:engin:startup complete
98
+ INFO:httpx:HTTP Request: GET https://httpbin.org/get "HTTP/1.1 200 OK"
99
+ INFO:engin:stopping engin
100
+ INFO:engin:shutdown complete
101
+ ```
102
+
103
+ ## Inspiration
104
+
105
+ Engin is heavily inspired by [Uber's Fx framework for Go](https://github.com/uber-go/fx)
106
+ and the [Injector framework for Python](https://github.com/python-injector/injector).
107
+
108
+ They are both great projects, check them out.
@@ -0,0 +1,90 @@
1
+ # Engin 🏎️
2
+
3
+ [![codecov](https://codecov.io/gh/invokermain/engin/graph/badge.svg?token=4PJOIMV6IB)](https://codecov.io/gh/invokermain/engin)
4
+
5
+ ---
6
+
7
+ **Documentation**: [https://engin.readthedocs.io/](https://engin.readthedocs.io/)
8
+
9
+ **Source Code**: [https://github.com/invokermain/engin](https://github.com/invokermain/engin)
10
+
11
+ ---
12
+
13
+ Engin is a lightweight application framework powered by dependency injection, it helps
14
+ you build and maintain large monoliths and many microservices.
15
+
16
+
17
+ ## Features
18
+
19
+ The Engin framework includes:
20
+
21
+ - A fully-featured dependency injection system.
22
+ - A robust application runtime with lifecycle hooks and supervised background tasks.
23
+ - Zero boiler-plate code reuse across multiple applications.
24
+ - Integrations for other frameworks such as FastAPI.
25
+ - Full async support.
26
+ - CLI commands to aid local development.
27
+
28
+
29
+ ## Installation
30
+
31
+ Engin is available on PyPI, install using your favourite dependency manager:
32
+
33
+ - `pip install engin`
34
+ - `poetry add engin`
35
+ - `uv add engin`
36
+
37
+ ## Example
38
+
39
+ A small example which shows some of the runtime features of Engin. This application
40
+ makes a http request and then performs a shutdown.
41
+
42
+ ```python
43
+ import asyncio
44
+ from httpx import AsyncClient
45
+ from engin import Engin, Invoke, Lifecycle, Provide, Supervisor
46
+
47
+
48
+ def httpx_client_factory(lifecycle: Lifecycle) -> AsyncClient:
49
+ # create our http client
50
+ client = AsyncClient()
51
+ # this will open and close the AsyncClient as part of the application's lifecycle
52
+ lifecycle.append(client)
53
+ return client
54
+
55
+
56
+ async def main(
57
+ httpx_client: AsyncClient,
58
+ supervisor: Supervisor,
59
+ ) -> None:
60
+ async def http_request():
61
+ await httpx_client.get("https://httpbin.org/get")
62
+ # one we've made the http request shutdown the application
63
+ raise asyncio.CancelledError("Forcing shutdown")
64
+
65
+ # supervise the http request as part of the application's lifecycle
66
+ supervisor.supervise(http_request)
67
+
68
+ # define our modular application
69
+ engin = Engin(Provide(httpx_client_factory), Invoke(main))
70
+
71
+ # run it!
72
+ asyncio.run(engin.run())
73
+ ```
74
+
75
+ With logs enabled this will output:
76
+
77
+ ```shell
78
+ INFO:engin:starting engin
79
+ INFO:engin:startup complete
80
+ INFO:httpx:HTTP Request: GET https://httpbin.org/get "HTTP/1.1 200 OK"
81
+ INFO:engin:stopping engin
82
+ INFO:engin:shutdown complete
83
+ ```
84
+
85
+ ## Inspiration
86
+
87
+ Engin is heavily inspired by [Uber's Fx framework for Go](https://github.com/uber-go/fx)
88
+ and the [Injector framework for Python](https://github.com/python-injector/injector).
89
+
90
+ They are both great projects, check them out.
@@ -3,8 +3,8 @@
3
3
  Invocations define the behaviour of your application, therefore without any Invocations
4
4
  your application will not do anything.
5
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.
6
+ Like providers, invocations are functions that take one or more dependencies as
7
+ parameters, but they should always return None as the return value will not used by Engin.
8
8
 
9
9
  As part of the Engin's startup, all declared invocations will be called sequentially in
10
10
  the order they were registered.
@@ -13,7 +13,7 @@ Invocations can be used to define behaviour in two ways.
13
13
 
14
14
  **Implicit: Provider Lifecycle**
15
15
 
16
- Invocations are always called therefore their dependencies are always assembled. This
16
+ Invocations are always called and therefore their dependencies are always assembled. This
17
17
  means that any providers with lifecycles will register their lifecycles with the
18
18
  application if directly or indirectly used by an invocation.
19
19
 
@@ -1,5 +1,3 @@
1
- from contextlib import asynccontextmanager
2
-
3
1
  # Lifecycle
4
2
 
5
3
  Certain types of object naturally have some form of startup and shutdown behaviour
@@ -0,0 +1,62 @@
1
+ # Engin 🏎️
2
+
3
+ Engin is a lightweight application framework powered by dependency injection, it helps
4
+ you build both large monoliths and multiple microservices.
5
+
6
+
7
+ ## Features
8
+
9
+ The Engin framework includes:
10
+
11
+ - A fully-featured dependency injection system.
12
+ - A robust application runtime with lifecycle hooks and supervised background tasks.
13
+ - Zero boiler-plate code reuse across multiple applications.
14
+ - Integrations for other frameworks such as FastAPI.
15
+ - Full async support.
16
+ - CLI commands to aid local development.
17
+
18
+
19
+ ## Installation
20
+
21
+ === "uv"
22
+
23
+ ```shell
24
+ uv add engin
25
+ ```
26
+
27
+ === "poetry"
28
+
29
+ ```shell
30
+ poetry add engin
31
+ ```
32
+
33
+ === "pip"
34
+
35
+ ```shell
36
+ pip install engin
37
+ ```
38
+
39
+ ## Getting Started
40
+
41
+ A minimal example:
42
+
43
+ ```python
44
+ import asyncio
45
+
46
+ from httpx import AsyncClient
47
+
48
+ from engin import Engin, Invoke, Provide
49
+
50
+
51
+ def httpx_client() -> AsyncClient:
52
+ return AsyncClient()
53
+
54
+
55
+ async def main(http_client: AsyncClient) -> None:
56
+ print(await http_client.get("https://httpbin.org/get"))
57
+
58
+ engin = Engin(Provide(httpx_client), Invoke(main))
59
+
60
+ asyncio.run(engin.run())
61
+ ```
62
+
@@ -7,7 +7,7 @@ theme:
7
7
  custom_dir: 'docs/overrides'
8
8
  features:
9
9
  - navigation.instant
10
- - navigation.tabs
10
+ - content.code.copy
11
11
  palette:
12
12
  - scheme: 'default'
13
13
  media: '(prefers-color-scheme: light)'
@@ -27,7 +27,6 @@ edit_uri: ""
27
27
 
28
28
  nav:
29
29
  - Home: "index.md"
30
- - Getting Started: "getting-started.md"
31
30
  - Concepts:
32
31
  - Engin: "concepts/engin.md"
33
32
  - Providers: "concepts/providers.md"
@@ -60,6 +59,7 @@ plugins:
60
59
  inventories:
61
60
  - url: https://docs.python.org/3/objects.inv
62
61
  domains: [py, std]
62
+ - search
63
63
 
64
64
  watch:
65
65
  - src
@@ -71,9 +71,13 @@ markdown_extensions:
71
71
  - pymdownx.blocks.caption
72
72
  - pymdownx.details
73
73
  - pymdownx.highlight:
74
- anchor_linenums: true
75
- line_spans: __span
76
- pygments_lang_class: true
74
+ anchor_linenums: true
75
+ line_spans: __span
76
+ pygments_lang_class: true
77
77
  - pymdownx.inlinehilite
78
- - pymdownx.snippets
78
+ - pymdownx.snippets:
79
+ base_path: [!relative $config_dir]
80
+ check_paths: true
79
81
  - pymdownx.superfences
82
+ - pymdownx.tabbed:
83
+ alternate_style: true
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "engin"
3
- version = "0.1.a1"
3
+ version = "0.1.0b2"
4
4
  description = "An async-first modular application framework"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -4,7 +4,6 @@ from engin._dependency import Entrypoint, Invoke, Provide, Supply
4
4
  from engin._engin import Engin
5
5
  from engin._lifecycle import Lifecycle
6
6
  from engin._option import Option
7
- from engin._shutdown import ShutdownSwitch
8
7
  from engin._supervisor import OnException, Supervisor
9
8
  from engin._type_utils import TypeId
10
9
 
@@ -18,7 +17,6 @@ __all__ = [
18
17
  "OnException",
19
18
  "Option",
20
19
  "Provide",
21
- "ShutdownSwitch",
22
20
  "Supervisor",
23
21
  "Supply",
24
22
  "TypeId",
@@ -2,28 +2,46 @@ import asyncio
2
2
  import logging
3
3
  import os
4
4
  import signal
5
- from asyncio import Event, Task
5
+ from asyncio import Event
6
6
  from collections import defaultdict
7
7
  from contextlib import AsyncExitStack
8
+ from enum import Enum
8
9
  from itertools import chain
9
10
  from types import FrameType
10
11
  from typing import ClassVar
11
12
 
12
- from anyio import CancelScope, create_task_group, get_cancelled_exc_class
13
+ from anyio import create_task_group, get_cancelled_exc_class
13
14
 
14
15
  from engin._assembler import AssembledDependency, Assembler
15
16
  from engin._dependency import Invoke, Provide, Supply
16
17
  from engin._graph import DependencyGrapher, Node
17
18
  from engin._lifecycle import Lifecycle
18
19
  from engin._option import Option
19
- from engin._shutdown import ShutdownSwitch
20
20
  from engin._supervisor import Supervisor
21
21
  from engin._type_utils import TypeId
22
+ from engin.exceptions import EnginError
22
23
 
23
24
  _OS_IS_WINDOWS = os.name == "nt"
24
25
  LOG = logging.getLogger("engin")
25
26
 
26
27
 
28
+ class _EnginState(Enum):
29
+ IDLE = 0
30
+ """
31
+ Engin is not yet started.
32
+ """
33
+
34
+ RUNNING = 1
35
+ """
36
+ Engin is currently running.
37
+ """
38
+
39
+ SHUTDOWN = 2
40
+ """
41
+ Engin has performed shutdown
42
+ """
43
+
44
+
27
45
  class Engin:
28
46
  """
29
47
  The Engin is a modular application defined by a collection of options.
@@ -83,16 +101,16 @@ class Engin:
83
101
  Args:
84
102
  *options: an instance of Provide, Supply, Invoke, Entrypoint or a Block.
85
103
  """
86
- self._stop_requested_event = ShutdownSwitch()
104
+ self._state = _EnginState.IDLE
105
+ self._start_complete_event = Event()
106
+ self._stop_requested_event = Event()
87
107
  self._stop_complete_event = Event()
88
- self._exit_stack: AsyncExitStack = AsyncExitStack()
89
- self._shutdown_task: Task | None = None
90
- self._run_task: Task | None = None
108
+ self._exit_stack = AsyncExitStack()
91
109
  self._assembler = Assembler([])
110
+ self._async_context_run_task: asyncio.Task | None = None
92
111
 
93
112
  self._providers: dict[TypeId, Provide] = {
94
113
  TypeId.from_type(Assembler): Supply(self._assembler),
95
- TypeId.from_type(ShutdownSwitch): Supply(self._stop_requested_event),
96
114
  }
97
115
  self._multiproviders: dict[TypeId, list[Provide]] = defaultdict(list)
98
116
  self._invocations: list[Invoke] = []
@@ -117,26 +135,9 @@ class Engin:
117
135
  The engin will run until it is stopped via an external signal (i.e. SIGTERM or
118
136
  SIGINT), the `stop` method is called on the engin, or a lifecycle task errors.
119
137
  """
120
- await self.start()
121
- if self._shutdown_task:
122
- self._shutdown_task.cancel("redundant")
123
- async with create_task_group() as tg:
124
- tg.start_soon(_stop_engin_on_signal, self._stop_requested_event)
125
- try:
126
- await self._stop_requested_event.wait()
127
- await self._shutdown()
128
- except get_cancelled_exc_class():
129
- with CancelScope(shield=True):
130
- await self._shutdown()
131
-
132
- async def start(self) -> None:
133
- """
134
- Start the engin.
138
+ if self._state != _EnginState.IDLE:
139
+ raise EnginError("Engin is not idle, unable to start")
135
140
 
136
- This is an alternative to calling `run`. This method waits for the startup
137
- lifecycle to complete and then returns. The caller is then responsible for
138
- calling `stop`.
139
- """
140
141
  LOG.info("starting engin")
141
142
  assembled_invocations: list[AssembledDependency] = [
142
143
  await self._assembler.assemble(invocation) for invocation in self._invocations
@@ -151,10 +152,6 @@ class Engin:
151
152
  return
152
153
 
153
154
  lifecycle = await self._assembler.build(Lifecycle)
154
- supervisor = await self._assembler.build(Supervisor)
155
-
156
- if not supervisor.empty:
157
- lifecycle.append(supervisor)
158
155
 
159
156
  try:
160
157
  for hook in lifecycle.list():
@@ -168,9 +165,30 @@ class Engin:
168
165
  await self._shutdown()
169
166
  return
170
167
 
168
+ supervisor = await self._assembler.build(Supervisor)
169
+
171
170
  LOG.info("startup complete")
171
+ self._state = _EnginState.RUNNING
172
+ self._start_complete_event.set()
173
+
174
+ async with create_task_group() as tg:
175
+ tg.start_soon(_stop_engin_on_signal, self._stop_requested_event)
176
+
177
+ try:
178
+ async with supervisor:
179
+ await self._stop_requested_event.wait()
180
+ except get_cancelled_exc_class():
181
+ pass
182
+ tg.cancel_scope.cancel()
183
+ await self._shutdown()
172
184
 
173
- self._shutdown_task = asyncio.create_task(self._shutdown_when_stopped())
185
+ async def start(self) -> None:
186
+ """
187
+ Starts the engin in the background. This method will wait until the engin is fully
188
+ started to return so it is safe to use immediately after.
189
+ """
190
+ self._async_context_run_task = asyncio.create_task(self.run())
191
+ await self._start_complete_event.wait()
174
192
 
175
193
  async def stop(self) -> None:
176
194
  """
@@ -181,55 +199,59 @@ class Engin:
181
199
  started.
182
200
  """
183
201
  self._stop_requested_event.set()
184
- if self._shutdown_task is None:
185
- return
186
202
  await self._stop_complete_event.wait()
187
203
 
188
204
  def graph(self) -> list[Node]:
205
+ """
206
+ Creates a graph representation of the engin's dependencies which can be used for
207
+ introspection or visualisations.
208
+
209
+ Returns: a list of Node objects.
210
+ """
189
211
  grapher = DependencyGrapher({**self._providers, **self._multiproviders})
190
212
  return grapher.resolve(self._invocations)
191
213
 
214
+ def is_running(self) -> bool:
215
+ return self._state == _EnginState.RUNNING
216
+
217
+ def is_stopped(self) -> bool:
218
+ return self._state == _EnginState.SHUTDOWN
219
+
192
220
  async def _shutdown(self) -> None:
193
221
  LOG.info("stopping engin")
194
222
  await self._exit_stack.aclose()
195
223
  self._stop_complete_event.set()
196
224
  LOG.info("shutdown complete")
197
-
198
- async def _shutdown_when_stopped(self) -> None:
199
- await self._stop_requested_event.wait()
200
- await self._shutdown()
225
+ self._state = _EnginState.SHUTDOWN
201
226
 
202
227
 
203
228
  async def _stop_engin_on_signal(stop_requested_event: Event) -> None:
204
229
  """
205
230
  A task that waits for a stop signal (SIGINT/SIGTERM) and notifies the given event.
206
231
  """
207
- try:
208
- # try to gracefully handle sigint/sigterm
209
- if not _OS_IS_WINDOWS:
210
- loop = asyncio.get_running_loop()
211
- for signame in (signal.SIGINT, signal.SIGTERM):
212
- loop.add_signal_handler(signame, stop_requested_event.set)
213
-
214
- await stop_requested_event.wait()
215
- else:
216
- should_stop = False
217
-
218
- # windows does not support signal_handlers, so this is the workaround
219
- def ctrlc_handler(sig: int, frame: FrameType | None) -> None:
220
- nonlocal should_stop
221
- if should_stop:
222
- raise KeyboardInterrupt("Forced keyboard interrupt")
223
- should_stop = True
224
-
225
- signal.signal(signal.SIGINT, ctrlc_handler)
226
-
227
- while not should_stop:
228
- # In case engin is stopped via external `stop` call.
229
- if stop_requested_event.is_set():
230
- return
231
- await asyncio.sleep(0.1)
232
-
233
- stop_requested_event.set()
234
- except asyncio.CancelledError:
235
- pass
232
+ # try to gracefully handle sigint/sigterm
233
+ if not _OS_IS_WINDOWS:
234
+ loop = asyncio.get_running_loop()
235
+ for signame in (signal.SIGINT, signal.SIGTERM):
236
+ loop.add_signal_handler(signame, stop_requested_event.set)
237
+
238
+ await stop_requested_event.wait()
239
+ else:
240
+ should_stop = False
241
+
242
+ # windows does not support signal_handlers, so this is the workaround
243
+ def ctrlc_handler(sig: int, frame: FrameType | None) -> None:
244
+ nonlocal should_stop
245
+ if should_stop:
246
+ raise KeyboardInterrupt("Forced keyboard interrupt")
247
+ should_stop = True
248
+
249
+ signal.signal(signal.SIGINT, ctrlc_handler)
250
+
251
+ while not should_stop:
252
+ # In case engin is stopped via external `stop` call.
253
+ if stop_requested_event.is_set():
254
+ return
255
+ await asyncio.sleep(0.1)
256
+
257
+ stop_requested_event.set()
@@ -2,17 +2,13 @@ import inspect
2
2
  import logging
3
3
  import typing
4
4
  from collections.abc import Awaitable, Callable
5
- from contextlib import AsyncExitStack
6
5
  from dataclasses import dataclass
7
6
  from enum import Enum
8
7
  from types import TracebackType
9
- from typing import TypeAlias
8
+ from typing import TypeAlias, assert_never
10
9
 
11
10
  import anyio
12
11
  from anyio import get_cancelled_exc_class
13
- from exceptiongroup import BaseExceptionGroup, catch
14
-
15
- from engin._shutdown import ShutdownSwitch
16
12
 
17
13
  if typing.TYPE_CHECKING:
18
14
  from anyio.abc import TaskGroup
@@ -72,13 +68,12 @@ class _SupervisorTask:
72
68
  if self.on_exception == OnException.IGNORE:
73
69
  self.complete = True
74
70
  return
75
-
76
71
  if self.on_exception == OnException.RETRY:
77
72
  continue
78
-
79
73
  if self.on_exception == OnException.SHUTDOWN:
80
74
  self.complete = True
81
- raise
75
+ raise get_cancelled_exc_class() from None
76
+ assert_never(self.on_exception)
82
77
 
83
78
  @property
84
79
  def name(self) -> str:
@@ -93,12 +88,8 @@ class _SupervisorTask:
93
88
 
94
89
 
95
90
  class Supervisor:
96
- def __init__(self, shutdown: ShutdownSwitch) -> None:
91
+ def __init__(self) -> None:
97
92
  self._tasks: list[_SupervisorTask] = []
98
- self._shutdown = shutdown
99
- self._is_complete: bool = False
100
-
101
- self._exit_stack: AsyncExitStack | None = None
102
93
  self._task_group: TaskGroup | None = None
103
94
 
104
95
  def supervise(
@@ -114,15 +105,7 @@ class Supervisor:
114
105
  if not self._tasks:
115
106
  return
116
107
 
117
- def _handler(_: BaseExceptionGroup) -> None:
118
- self._shutdown.set()
119
-
120
- self._exit_stack = AsyncExitStack()
121
- await self._exit_stack.__aenter__()
122
- self._exit_stack.enter_context(catch({Exception: _handler}))
123
- self._task_group = await self._exit_stack.enter_async_context(
124
- anyio.create_task_group()
125
- )
108
+ self._task_group = await anyio.create_task_group().__aenter__()
126
109
 
127
110
  for task in self._tasks:
128
111
  self._task_group.start_soon(task, name=task.name)
@@ -134,8 +117,7 @@ class Supervisor:
134
117
  traceback: TracebackType | None,
135
118
  /,
136
119
  ) -> None:
137
- if not self._tasks:
138
- return
139
-
140
- if self._exit_stack:
141
- await self._exit_stack.__aexit__(exc_type, exc_value, traceback)
120
+ if self._task_group:
121
+ if not self._task_group.cancel_scope.cancel_called:
122
+ self._task_group.cancel_scope.cancel()
123
+ await self._task_group.__aexit__(exc_type, exc_value, traceback)