1+ # pylint: disable=protected-access
2+ # pylint: disable=redefined-outer-name
3+ # pylint: disable=too-many-arguments
4+ # pylint: disable=unused-argument
5+ # pylint: disable=unused-variable
6+
7+
8+ import logging
19from collections .abc import AsyncIterator
10+ from typing import Any
211
3- import asgi_lifespan
412import pytest
13+ from asgi_lifespan import LifespanManager as ASGILifespanManager
14+ from common_library .errors_classes import OsparcErrorMixin
515from fastapi import FastAPI
6- from fastapi_lifespan_manager import State
16+ from fastapi_lifespan_manager import LifespanManager , State
17+ from pytest_mock import MockerFixture
18+ from pytest_simcore .helpers .logging_tools import log_context
719from servicelib .fastapi .lifespan_utils import combine_lifespans
820
921
@@ -24,7 +36,7 @@ async def cache_lifespan(app: FastAPI) -> AsyncIterator[State]:
2436
2537 capsys .readouterr ()
2638
27- async with asgi_lifespan . LifespanManager (app ):
39+ async with ASGILifespanManager (app ):
2840 messages = capsys .readouterr ().out
2941
3042 assert "setup DB" in messages
@@ -38,3 +50,209 @@ async def cache_lifespan(app: FastAPI) -> AsyncIterator[State]:
3850 assert "setup CACHE" not in messages
3951 assert "shutdown DB" in messages
4052 assert "shutdown CACHE" in messages
53+
54+
55+ @pytest .fixture
56+ def postgres_lifespan () -> LifespanManager :
57+ lifespan_manager = LifespanManager ()
58+
59+ @lifespan_manager .add
60+ async def _setup_postgres_sync_engine (_ ) -> AsyncIterator [State ]:
61+ with log_context (logging .INFO , "postgres_sync_engine" ):
62+ # pass state to children
63+ yield {"postgres" : {"engine" : "Some Engine" }}
64+
65+ @lifespan_manager .add
66+ async def _setup_postgres_async_engine (_ , state : State ) -> AsyncIterator [State ]:
67+ with log_context (logging .INFO , "postgres_async_engine" ):
68+ # pass state to children
69+
70+ state ["postgres" ].update (aengine = "Some Async Engine" )
71+ yield state
72+
73+ return lifespan_manager
74+
75+
76+ @pytest .fixture
77+ def rabbitmq_lifespan () -> LifespanManager :
78+ lifespan_manager = LifespanManager ()
79+
80+ @lifespan_manager .add
81+ async def _setup_rabbitmq (app : FastAPI ) -> AsyncIterator [State ]:
82+ with log_context (logging .INFO , "rabbitmq" ):
83+
84+ with pytest .raises (AttributeError , match = "rabbitmq_rpc_server" ):
85+ _ = app .state .rabbitmq_rpc_server
86+
87+ # pass state to children
88+ yield {"rabbitmq_rpc_server" : "Some RabbitMQ RPC Server" }
89+
90+ return lifespan_manager
91+
92+
93+ async def test_app_lifespan_composition (
94+ postgres_lifespan : LifespanManager , rabbitmq_lifespan : LifespanManager
95+ ):
96+ # The app has its own database and rpc-server to initialize
97+ # this is how you connect the lifespans pre-defined in servicelib
98+
99+ @postgres_lifespan .add
100+ async def setup_database (app : FastAPI , state : State ) -> AsyncIterator [State ]:
101+
102+ with log_context (logging .INFO , "app database" ):
103+ assert state ["postgres" ] == {
104+ "engine" : "Some Engine" ,
105+ "aengine" : "Some Async Engine" ,
106+ }
107+
108+ with pytest .raises (AttributeError , match = "database_engine" ):
109+ _ = app .state .database_engine
110+
111+ app .state .database_engine = state ["postgres" ]["engine" ]
112+
113+ yield {}
114+
115+ # tear-down stage
116+ assert app .state .database_engine
117+
118+ @rabbitmq_lifespan .add
119+ async def setup_rpc_server (app : FastAPI , state : State ) -> AsyncIterator [State ]:
120+ with log_context (logging .INFO , "app rpc-server" ):
121+ assert "rabbitmq_rpc_server" in state
122+
123+ app .state .rpc_server = state ["rabbitmq_rpc_server" ]
124+
125+ yield {}
126+
127+ # Composes lifepans
128+ app_lifespan = LifespanManager ()
129+ app_lifespan .include (postgres_lifespan )
130+ app_lifespan .include (rabbitmq_lifespan )
131+
132+ app = FastAPI (lifespan = app_lifespan )
133+ async with ASGILifespanManager (app ) as asgi_manager :
134+
135+ # asgi_manage state
136+ assert asgi_manager ._state == { # noqa: SLF001
137+ "postgres" : {
138+ "engine" : "Some Engine" ,
139+ "aengine" : "Some Async Engine" ,
140+ },
141+ "rabbitmq_rpc_server" : "Some RabbitMQ RPC Server" ,
142+ }
143+
144+ # app state
145+ assert app .state .database_engine
146+ assert app .state .rpc_server
147+
148+ # Logs shows lifespan execution:
149+ # -> postgres_sync_engine starting ...
150+ # -> postgres_async_engine starting ...
151+ # -> app database starting ...
152+ # -> rabbitmq starting ...
153+ # -> app rpc-server starting ...
154+ # <- app rpc-server done ()
155+ # <- rabbitmq done ()
156+ # <- app database done (1ms)
157+ # <- postgres_async_engine done (1ms)
158+ # <- postgres_sync_engine done (1ms)
159+
160+
161+ class LifespanError (OsparcErrorMixin , RuntimeError ): ...
162+
163+
164+ class LifespanOnStartupError (LifespanError ):
165+ msg_template = "Failed during startup of {module}"
166+
167+
168+ class LifespanOnShutdownError (LifespanError ):
169+ msg_template = "Failed during shutdown of {module}"
170+
171+
172+ @pytest .fixture
173+ def failing_lifespan_manager (mocker : MockerFixture ):
174+ startup_step = mocker .MagicMock ()
175+ shutdown_step = mocker .MagicMock ()
176+ handle_error = mocker .MagicMock ()
177+
178+ def raise_error ():
179+ msg = "failing module"
180+ raise RuntimeError (msg )
181+
182+ async def setup_failing_on_startup (app : FastAPI ) -> AsyncIterator [State ]:
183+ _name = setup_failing_on_startup .__name__
184+
185+ with log_context (logging .INFO , _name ):
186+ try :
187+ raise_error ()
188+ startup_step (_name )
189+ except RuntimeError as exc :
190+ handle_error (_name , exc )
191+ raise LifespanOnStartupError (module = _name ) from exc
192+ yield {}
193+ shutdown_step (_name )
194+
195+ async def setup_failing_on_shutdown (app : FastAPI ) -> AsyncIterator [State ]:
196+ _name = setup_failing_on_shutdown .__name__
197+
198+ with log_context (logging .INFO , _name ):
199+ startup_step (_name )
200+ yield {}
201+ try :
202+ raise_error ()
203+ shutdown_step (_name )
204+ except RuntimeError as exc :
205+ handle_error (_name , exc )
206+ raise LifespanOnShutdownError (module = _name ) from exc
207+
208+ return {
209+ "startup_step" : startup_step ,
210+ "shutdown_step" : shutdown_step ,
211+ "handle_error" : handle_error ,
212+ "setup_failing_on_startup" : setup_failing_on_startup ,
213+ "setup_failing_on_shutdown" : setup_failing_on_shutdown ,
214+ }
215+
216+
217+ async def test_app_lifespan_with_error_on_startup (
218+ failing_lifespan_manager : dict [str , Any ],
219+ ):
220+ app_lifespan = LifespanManager ()
221+ app_lifespan .add (failing_lifespan_manager ["setup_failing_on_startup" ])
222+ app = FastAPI (lifespan = app_lifespan )
223+
224+ with pytest .raises (LifespanOnStartupError ) as err_info :
225+ async with ASGILifespanManager (app ):
226+ ...
227+
228+ exception = err_info .value
229+ assert failing_lifespan_manager ["handle_error" ].called
230+ assert not failing_lifespan_manager ["startup_step" ].called
231+ assert not failing_lifespan_manager ["shutdown_step" ].called
232+ assert exception .error_context () == {
233+ "module" : "setup_failing_on_startup" ,
234+ "message" : "Failed during startup of setup_failing_on_startup" ,
235+ "code" : "RuntimeError.LifespanError.LifespanOnStartupError" ,
236+ }
237+
238+
239+ async def test_app_lifespan_with_error_on_shutdown (
240+ failing_lifespan_manager : dict [str , Any ],
241+ ):
242+ app_lifespan = LifespanManager ()
243+ app_lifespan .add (failing_lifespan_manager ["setup_failing_on_shutdown" ])
244+ app = FastAPI (lifespan = app_lifespan )
245+
246+ with pytest .raises (LifespanOnShutdownError ) as err_info :
247+ async with ASGILifespanManager (app ):
248+ ...
249+
250+ exception = err_info .value
251+ assert failing_lifespan_manager ["handle_error" ].called
252+ assert failing_lifespan_manager ["startup_step" ].called
253+ assert not failing_lifespan_manager ["shutdown_step" ].called
254+ assert exception .error_context () == {
255+ "module" : "setup_failing_on_shutdown" ,
256+ "message" : "Failed during shutdown of setup_failing_on_shutdown" ,
257+ "code" : "RuntimeError.LifespanError.LifespanOnShutdownError" ,
258+ }
0 commit comments