From 10ef502e5ce89d23b4ba0653bfeb196c117aff93 Mon Sep 17 00:00:00 2001 From: Sanskar Jethi Date: Sat, 15 Jan 2022 17:35:11 +0000 Subject: [PATCH 1/6] Add event handlers --- integration_tests/base_routes.py | 12 ++++- robyn/__init__.py | 77 +++++++++++++++++++++++--------- robyn/processpool.py | 23 +++++++--- src/processor.rs | 23 ++++++++++ src/server.rs | 44 ++++++++++++++---- 5 files changed, 143 insertions(+), 36 deletions(-) diff --git a/integration_tests/base_routes.py b/integration_tests/base_routes.py index 873f18c7d..ae0ebdbed 100644 --- a/integration_tests/base_routes.py +++ b/integration_tests/base_routes.py @@ -35,7 +35,7 @@ def message(): async def hello(request): global callCount callCount += 1 - message = "Called " + str(callCount) + " times" + _message = "Called " + str(callCount) + " times" return jsonify(request) @@ -111,10 +111,20 @@ def blocker(): return "blocker function" +async def startup_handler(): + print("Starting up") + + +@app.shutdown_handler +def shutdown_handler(): + print("Shutting down") + + if __name__ == "__main__": ROBYN_URL = os.getenv("ROBYN_URL", '0.0.0.0') app.add_header("server", "robyn") current_file_path = pathlib.Path(__file__).parent.resolve() os.path.join(current_file_path, "build") app.add_directory(route="/test_dir",directory_path=os.path.join(current_file_path, "build/"), index_file="index.html") + app.startup_handler(startup_handler) app.start(port=5000, url=ROBYN_URL) diff --git a/robyn/__init__.py b/robyn/__init__.py index 3cf7bad98..75254b45e 100644 --- a/robyn/__init__.py +++ b/robyn/__init__.py @@ -21,13 +21,12 @@ class Robyn: - """This is the python wrapper for the Robyn binaries. - """ + """This is the python wrapper for the Robyn binaries.""" + def __init__(self, file_object): directory_path = os.path.dirname(os.path.abspath(file_object)) self.file_path = file_object self.directory_path = directory_path - self.server = Server(directory_path) self.parser = ArgumentParser() self.dev = self.parser.is_dev() self.processes = self.parser.num_processes() @@ -37,6 +36,7 @@ def __init__(self, file_object): self.routes = [] self.directories = [] self.web_sockets = {} + self.event_handlers = {} def add_route(self, route_type, endpoint, handler): """ @@ -51,25 +51,44 @@ def add_route(self, route_type, endpoint, handler): """ number_of_params = len(signature(handler).parameters) self.routes.append( - (route_type, - endpoint, - handler, - asyncio.iscoroutinefunction(handler), number_of_params) + ( + route_type, + endpoint, + handler, + asyncio.iscoroutinefunction(handler), + number_of_params, + ) ) - def add_directory(self, route, directory_path, index_file=None, show_files_listing=False): + def add_directory( + self, route, directory_path, index_file=None, show_files_listing=False + ): self.directories.append((route, directory_path, index_file, show_files_listing)) def add_header(self, key, value): self.headers.append((key, value)) - def remove_header(self, key): - self.server.remove_header(key) - def add_web_socket(self, endpoint, ws): self.web_sockets[endpoint] = ws - def start(self, url="127.0.0.1", port=5000): + def _add_event_handler(self, event_type: str, handler): + print(f"Add event {event_type} handler") + if event_type.lower() not in {"startup", "shutdown"}: + return + + is_async = asyncio.iscoroutinefunction(handler) + if event_type.lower() == "startup": + self.event_handlers["startup_handler"] = (handler, is_async) + else: + self.event_handlers["shutdown_handler"] = (handler, is_async) + + def startup_handler(self, handler): + self._add_event_handler("startup", handler) + + def shutdown_handler(self, handler): + self._add_event_handler("shutdown", handler) + + def start(self, url="128.0.0.1", port=5000): """ [Starts the server] @@ -78,13 +97,19 @@ def start(self, url="127.0.0.1", port=5000): if not self.dev: workers = self.workers socket = SocketHeld(url, port) - for process_number in range(self.processes): - copied = socket.try_clone() + for _ in range(self.processes): + copied_socket = socket.try_clone() p = Process( target=spawn_process, - args=(url, port, self.directories, self.headers, - self.routes, self.web_sockets, copied, - f"Process {process_number}", workers), + args=( + self.directories, + self.headers, + self.routes, + self.web_sockets, + self.event_handlers, + copied_socket, + workers, + ), ) p.start() @@ -92,11 +117,11 @@ def start(self, url="127.0.0.1", port=5000): else: event_handler = EventHandler(self.file_path) event_handler.start_server_first_time() - print(f"{Colors.OKBLUE}Dev server initialised with the directory_path : {self.directory_path}{Colors.ENDC}") + print( + f"{Colors.OKBLUE}Dev server initialised with the directory_path : {self.directory_path}{Colors.ENDC}" + ) observer = Observer() - observer.schedule(event_handler, - path=self.directory_path, - recursive=True) + observer.schedule(event_handler, path=self.directory_path, recursive=True) observer.start() try: while True: @@ -111,6 +136,7 @@ def get(self, endpoint): :param endpoint [str]: [endpoint to server the route] """ + def inner(handler): self.add_route("GET", endpoint, handler) @@ -122,6 +148,7 @@ def post(self, endpoint): :param endpoint [str]: [endpoint to server the route] """ + def inner(handler): self.add_route("POST", endpoint, handler) @@ -133,6 +160,7 @@ def put(self, endpoint): :param endpoint [str]: [endpoint to server the route] """ + def inner(handler): self.add_route("PUT", endpoint, handler) @@ -144,6 +172,7 @@ def delete(self, endpoint): :param endpoint [str]: [endpoint to server the route] """ + def inner(handler): self.add_route("DELETE", endpoint, handler) @@ -155,6 +184,7 @@ def patch(self, endpoint): :param endpoint [str]: [endpoint to server the route] """ + def inner(handler): self.add_route("PATCH", endpoint, handler) @@ -166,6 +196,7 @@ def head(self, endpoint): :param endpoint [str]: [endpoint to server the route] """ + def inner(handler): self.add_route("HEAD", endpoint, handler) @@ -177,6 +208,7 @@ def options(self, endpoint): :param endpoint [str]: [endpoint to server the route] """ + def inner(handler): self.add_route("OPTIONS", endpoint, handler) @@ -188,6 +220,7 @@ def connect(self, endpoint): :param endpoint [str]: [endpoint to server the route] """ + def inner(handler): self.add_route("CONNECT", endpoint, handler) @@ -199,8 +232,8 @@ def trace(self, endpoint): :param endpoint [str]: [endpoint to server the route] """ + def inner(handler): self.add_route("TRACE", endpoint, handler) return inner - diff --git a/robyn/processpool.py b/robyn/processpool.py index bb3cf2211..1260f060b 100644 --- a/robyn/processpool.py +++ b/robyn/processpool.py @@ -3,13 +3,16 @@ import sys import multiprocessing as mp import asyncio + # import platform mp.allow_connection_pickling() -def spawn_process(url, port, directories, headers, routes, web_sockets, socket, process_name, workers): +def spawn_process( + directories, headers, routes, web_sockets, event_handlers, socket, workers +): """ This function is called by the main process handler to create a server runtime. This functions allows one runtime per process. @@ -31,14 +34,13 @@ def spawn_process(url, port, directories, headers, routes, web_sockets, socket, # uv loop doesn't support windows or arm machines at the moment # but uv loop is much faster than native asyncio import uvloop + uvloop.install() loop = uvloop.new_event_loop() asyncio.set_event_loop(loop) server = Server() - print(directories) - for directory in directories: route, directory_path, index_file, show_files_listing = directory server.add_directory(route, directory_path, index_file, show_files_listing) @@ -50,10 +52,21 @@ def spawn_process(url, port, directories, headers, routes, web_sockets, socket, route_type, endpoint, handler, is_async, number_of_params = route server.add_route(route_type, endpoint, handler, is_async, number_of_params) + if "startup_handler" in event_handlers: + server.add_startup_handler(event_handlers["startup_handler"][0], event_handlers["startup_handler"][1]) + + if "shutdown_handler" in event_handlers: + server.add_shutdown_handler(event_handlers["shutdown_handler"][0], event_handlers["shutdown_handler"][1]) + for endpoint in web_sockets: web_socket = web_sockets[endpoint] print(web_socket.methods) - server.add_web_socket_route(endpoint, web_socket.methods["connect"], web_socket.methods["close"], web_socket.methods["message"]) + server.add_web_socket_route( + endpoint, + web_socket.methods["connect"], + web_socket.methods["close"], + web_socket.methods["message"], + ) - server.start(url, port, socket, process_name, workers) + server.start(socket, workers) asyncio.get_event_loop().run_forever() diff --git a/src/processor.rs b/src/processor.rs index 640416999..52f47e54b 100644 --- a/src/processor.rs +++ b/src/processor.rs @@ -210,3 +210,26 @@ async fn execute_http_function( } } } + +pub async fn execute_event_handler(event_handler: Option, event_loop: Py) { + match event_handler { + Some(handler) => match handler { + PyFunction::SyncFunction(function) => { + println!("Startup event handler"); + Python::with_gil(|py| { + function.call0(py).unwrap(); + }); + } + PyFunction::CoRoutine(function) => { + let future = Python::with_gil(|py| { + println!("Startup event handler async"); + + let coroutine = function.as_ref(py).call0().unwrap(); + pyo3_asyncio::into_future_with_loop(event_loop.as_ref(py), coroutine).unwrap() + }); + future.await.unwrap(); + } + }, + None => {} + } +} diff --git a/src/server.rs b/src/server.rs index e252d343c..a0e1419e1 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,15 +1,15 @@ -use crate::processor::{apply_headers, handle_request}; +use crate::processor::{apply_headers, execute_event_handler, handle_request}; use crate::router::Router; use crate::shared_socket::SocketHeld; -use crate::types::Headers; +use crate::types::{Headers, PyFunction}; use crate::web_socket_connection::start_web_socket; +use std::collections::HashMap; use std::convert::TryInto; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering::{Relaxed, SeqCst}; use std::sync::{Arc, RwLock}; use std::thread; -use std::collections::HashMap; use actix_files::Files; use actix_http::KeepAlive; @@ -34,6 +34,8 @@ pub struct Server { router: Arc, headers: Arc>, directories: Arc>>, + startup_handler: Option, + shutdown_handler: Option, } #[pymethods] @@ -44,16 +46,15 @@ impl Server { router: Arc::new(Router::new()), headers: Arc::new(DashMap::new()), directories: Arc::new(RwLock::new(Vec::new())), + startup_handler: None, + shutdown_handler: None, } } pub fn start( &mut self, py: Python, - _url: String, - _port: u16, socket: &PyCell, - _name: String, workers: usize, ) -> PyResult<()> { if STARTED @@ -81,15 +82,19 @@ impl Server { .call_method1("set_event_loop", (event_loop,)) .unwrap(); let event_loop_hdl = PyObject::from(event_loop); + let startup_handler = self.startup_handler.clone(); + let shutdown_handler = self.shutdown_handler.clone(); thread::spawn(move || { //init_current_thread_once(); + let copied_event_loop = event_loop_hdl.clone(); actix_web::rt::System::new().block_on(async move { println!("The number of workers are {}", workers.clone()); + execute_event_handler(startup_handler, copied_event_loop.clone()).await; HttpServer::new(move || { let mut app = App::new(); - let event_loop_hdl = event_loop_hdl.clone(); + let event_loop_hdl = copied_event_loop.clone(); let directories = directories.read().unwrap(); let router_copy = router.clone(); @@ -157,6 +162,8 @@ impl Server { .run() .await .unwrap(); + + execute_event_handler(shutdown_handler, event_loop_hdl.clone()).await; }); }); @@ -219,6 +226,27 @@ impl Server { self.router .add_websocket_route(route, connect_route, close_route, message_route); } + + /// Add a new startup handler + pub fn add_startup_handler(&mut self, handler: Py, is_async: bool) { + println!("Adding startup handler"); + match is_async { + true => self.startup_handler = Some(PyFunction::CoRoutine(handler)), + false => self.startup_handler = Some(PyFunction::SyncFunction(handler)), + }; + println!("{:?}", self.startup_handler); + } + + /// Add a new shutdown handler + pub fn add_shutdown_handler(&mut self, handler: Py, is_async: bool) { + println!("Adding shutdown handler"); + match is_async { + true => self.shutdown_handler = Some(PyFunction::CoRoutine(handler)), + false => self.shutdown_handler = Some(PyFunction::SyncFunction(handler)), + }; + println!("{:?}", self.startup_handler); + println!("{:?}", self.shutdown_handler); + } } impl Default for Server { @@ -236,7 +264,7 @@ async fn index( req: HttpRequest, ) -> impl Responder { let mut queries = HashMap::new(); - + if req.query_string().len() > 0 { let split = req.query_string().split("&"); for s in split { From 946030af66240b1b99e4b289b84a1e2f41dc28e8 Mon Sep 17 00:00:00 2001 From: Sanskar Jethi Date: Sat, 15 Jan 2022 17:37:16 +0000 Subject: [PATCH 2/6] Fix formatting of base routes --- integration_tests/base_routes.py | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/integration_tests/base_routes.py b/integration_tests/base_routes.py index ae0ebdbed..832a62dd0 100644 --- a/integration_tests/base_routes.py +++ b/integration_tests/base_routes.py @@ -7,22 +7,25 @@ websocket = WS(app, "/web_socket") i = -1 + @websocket.on("message") async def connect(): global i - i+=1 - if i==0: + i += 1 + if i == 0: return "Whaaat??" - elif i==1: + elif i == 1: return "Whooo??" - elif i==2: + elif i == 2: i = -1 return "*chika* *chika* Slim Shady." + @websocket.on("close") def close(): return "GoodBye world, from ws" + @websocket.on("connect") def message(): return "Hello world, from ws" @@ -47,10 +50,12 @@ async def test(request): return static_file(html_file) + @app.get("/jsonify") async def json_get(): return jsonify({"hello": "world"}) + @app.get("/query") async def query_get(request): query_data = request["queries"] @@ -62,18 +67,22 @@ async def json(request): print(request["params"]["id"]) return jsonify({"hello": "world"}) + @app.post("/post") async def post(): return "POST Request" + @app.post("/post_with_body") async def postreq_with_body(request): return bytearray(request["body"]).decode("utf-8") + @app.put("/put") async def put(request): return "PUT Request" + @app.put("/put_with_body") async def putreq_with_body(request): print(request) @@ -84,6 +93,7 @@ async def putreq_with_body(request): async def delete(): return "DELETE Request" + @app.delete("/delete_with_body") async def deletereq_with_body(request): return bytearray(request["body"]).decode("utf-8") @@ -93,6 +103,7 @@ async def deletereq_with_body(request): async def patch(): return "PATCH Request" + @app.patch("/patch_with_body") async def patchreq_with_body(request): return bytearray(request["body"]).decode("utf-8") @@ -107,6 +118,7 @@ async def sleeper(): @app.get("/blocker") def blocker(): import time + time.sleep(10) return "blocker function" @@ -121,10 +133,14 @@ def shutdown_handler(): if __name__ == "__main__": - ROBYN_URL = os.getenv("ROBYN_URL", '0.0.0.0') + ROBYN_URL = os.getenv("ROBYN_URL", "0.0.0.0") app.add_header("server", "robyn") current_file_path = pathlib.Path(__file__).parent.resolve() os.path.join(current_file_path, "build") - app.add_directory(route="/test_dir",directory_path=os.path.join(current_file_path, "build/"), index_file="index.html") + app.add_directory( + route="/test_dir", + directory_path=os.path.join(current_file_path, "build/"), + index_file="index.html", + ) app.startup_handler(startup_handler) app.start(port=5000, url=ROBYN_URL) From 5f97e894b8ecbbf72c5b04b37b48e6298fafc004 Mon Sep 17 00:00:00 2001 From: Sanskar Jethi Date: Sun, 16 Jan 2022 15:43:30 +0000 Subject: [PATCH 3/6] Cleanup handler --- src/server.rs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/server.rs b/src/server.rs index a0e1419e1..d538c79bd 100644 --- a/src/server.rs +++ b/src/server.rs @@ -82,6 +82,7 @@ impl Server { .call_method1("set_event_loop", (event_loop,)) .unwrap(); let event_loop_hdl = PyObject::from(event_loop); + let event_loop_cleanup = PyObject::from(event_loop); let startup_handler = self.startup_handler.clone(); let shutdown_handler = self.shutdown_handler.clone(); @@ -162,12 +163,21 @@ impl Server { .run() .await .unwrap(); - - execute_event_handler(shutdown_handler, event_loop_hdl.clone()).await; }); }); - event_loop.call_method0("run_forever").unwrap(); + let event_loop = event_loop.call_method0("run_forever"); + if event_loop.is_err() { + println!("Ctrl c handler"); + Python::with_gil(|py| { + let event_loop_hdl = event_loop_cleanup.clone(); + pyo3_asyncio::tokio::run(py, async move { + execute_event_handler(shutdown_handler, event_loop_hdl.clone()).await; + Ok(()) + }) + .unwrap(); + }) + } Ok(()) } From 57ad25a9450088b33dbb9c16e002f98c16f628f4 Mon Sep 17 00:00:00 2001 From: Sanskar Jethi Date: Sun, 16 Jan 2022 20:42:37 +0000 Subject: [PATCH 4/6] Add integration tests --- integration_tests/base_routes.py | 8 ++++++-- integration_tests/test_event_handlers.py | 11 +++++++++++ integration_tests/test_get_requests.py | 14 +++++++++----- 3 files changed, 26 insertions(+), 7 deletions(-) create mode 100644 integration_tests/test_event_handlers.py diff --git a/integration_tests/base_routes.py b/integration_tests/base_routes.py index 832a62dd0..e06f6ca15 100644 --- a/integration_tests/base_routes.py +++ b/integration_tests/base_routes.py @@ -2,6 +2,10 @@ import asyncio import os import pathlib +import logging + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) app = Robyn(__file__) websocket = WS(app, "/web_socket") @@ -124,12 +128,12 @@ def blocker(): async def startup_handler(): - print("Starting up") + logger.log(logging.INFO, "Starting up") @app.shutdown_handler def shutdown_handler(): - print("Shutting down") + logger.log(logging.INFO, "Shutting down") if __name__ == "__main__": diff --git a/integration_tests/test_event_handlers.py b/integration_tests/test_event_handlers.py new file mode 100644 index 000000000..0f3979e9d --- /dev/null +++ b/integration_tests/test_event_handlers.py @@ -0,0 +1,11 @@ +import logging + + +def test_startup_event(session, caplog): + with caplog.at_level(logging.INFO): + assert "Starting up" in caplog.text + + +def test_shutdown_event(session, caplog): + with caplog.at_level(logging.INFO): + assert "Shutting down" in caplog.text diff --git a/integration_tests/test_get_requests.py b/integration_tests/test_get_requests.py index 629d25d45..016704532 100644 --- a/integration_tests/test_get_requests.py +++ b/integration_tests/test_get_requests.py @@ -2,22 +2,26 @@ BASE_URL = "http://127.0.0.1:5000" + def test_index_request(session): res = requests.get(f"{BASE_URL}") - assert(res.status_code == 200) + assert res.status_code == 200 + def test_jsonify(session): r = requests.get(f"{BASE_URL}/jsonify") - assert r.json()=={"hello":"world"} - assert r.status_code==200 + assert r.json() == {"hello": "world"} + assert r.status_code == 200 + def test_html(session): r = requests.get(f"{BASE_URL}/test/123") assert "Hello world. How are you?" in r.text + def test_queries(session): r = requests.get(f"{BASE_URL}/query?hello=robyn") - assert r.json()=={"hello":"robyn"} + assert r.json() == {"hello": "robyn"} r = requests.get(f"{BASE_URL}/query") - assert r.json()=={} + assert r.json() == {} From 7c03e3ae1709e2fb6510c083a573a2a143dcda7e Mon Sep 17 00:00:00 2001 From: Sanskar Jethi Date: Sun, 16 Jan 2022 21:04:06 +0000 Subject: [PATCH 5/6] Add enum --- robyn/__init__.py | 12 +++++------- robyn/events.py | 3 +++ robyn/processpool.py | 13 +++++++------ 3 files changed, 15 insertions(+), 13 deletions(-) create mode 100644 robyn/events.py diff --git a/robyn/__init__.py b/robyn/__init__.py index 75254b45e..450633f8d 100644 --- a/robyn/__init__.py +++ b/robyn/__init__.py @@ -3,6 +3,7 @@ import asyncio from inspect import signature import multiprocessing as mp +from robyn.events import Events # custom imports and exports from .robyn import Server, SocketHeld @@ -73,20 +74,17 @@ def add_web_socket(self, endpoint, ws): def _add_event_handler(self, event_type: str, handler): print(f"Add event {event_type} handler") - if event_type.lower() not in {"startup", "shutdown"}: + if event_type not in {Events.STARTUP, Events.SHUTDOWN}: return is_async = asyncio.iscoroutinefunction(handler) - if event_type.lower() == "startup": - self.event_handlers["startup_handler"] = (handler, is_async) - else: - self.event_handlers["shutdown_handler"] = (handler, is_async) + self.event_handlers[event_type] = (handler, is_async) def startup_handler(self, handler): - self._add_event_handler("startup", handler) + self._add_event_handler(Events.STARTUP, handler) def shutdown_handler(self, handler): - self._add_event_handler("shutdown", handler) + self._add_event_handler(Events.SHUTDOWN, handler) def start(self, url="128.0.0.1", port=5000): """ diff --git a/robyn/events.py b/robyn/events.py new file mode 100644 index 000000000..522bcb702 --- /dev/null +++ b/robyn/events.py @@ -0,0 +1,3 @@ +class Events: + STARTUP = "startup" + SHUTDOWN = "shutdown" diff --git a/robyn/processpool.py b/robyn/processpool.py index 1260f060b..fec6d82eb 100644 --- a/robyn/processpool.py +++ b/robyn/processpool.py @@ -1,4 +1,5 @@ from .robyn import Server +from .events import Events import sys import multiprocessing as mp @@ -17,11 +18,11 @@ def spawn_process( This function is called by the main process handler to create a server runtime. This functions allows one runtime per process. - :param url string: the base url at which the server will listen - :param port string: the port at which the url will listen to :param directories tuple: the list of all the directories and related data in a tuple :param headers tuple: All the global headers in a tuple :param routes tuple: The routes touple, containing the description about every route. + :param web_sockets list: This is a list of all the web socket routes + :param event_handlers Dict: This is an event dict that contains the event handlers :param socket Socket: This is the main tcp socket, which is being shared across multiple processes. :param process_name string: This is the name given to the process to identify the process :param workers number: This is the name given to the process to identify the process @@ -52,11 +53,11 @@ def spawn_process( route_type, endpoint, handler, is_async, number_of_params = route server.add_route(route_type, endpoint, handler, is_async, number_of_params) - if "startup_handler" in event_handlers: - server.add_startup_handler(event_handlers["startup_handler"][0], event_handlers["startup_handler"][1]) + if "startup" in event_handlers: + server.add_startup_handler(event_handlers[Events.STARTUP][0], event_handlers[Events.STARTUP][1]) - if "shutdown_handler" in event_handlers: - server.add_shutdown_handler(event_handlers["shutdown_handler"][0], event_handlers["shutdown_handler"][1]) + if "shutdown" in event_handlers: + server.add_shutdown_handler(event_handlers[Events.SHUTDOWN][0], event_handlers[Events.SHUTDOWN][1]) for endpoint in web_sockets: web_socket = web_sockets[endpoint] From cfe74e091d4a2ceed18f8340f19f1098498c0dbd Mon Sep 17 00:00:00 2001 From: Sanskar Jethi Date: Sun, 16 Jan 2022 22:07:34 +0000 Subject: [PATCH 6/6] Shutdown cannot be tested without sig int --- integration_tests/test_event_handlers.py | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100644 integration_tests/test_event_handlers.py diff --git a/integration_tests/test_event_handlers.py b/integration_tests/test_event_handlers.py deleted file mode 100644 index 0f3979e9d..000000000 --- a/integration_tests/test_event_handlers.py +++ /dev/null @@ -1,11 +0,0 @@ -import logging - - -def test_startup_event(session, caplog): - with caplog.at_level(logging.INFO): - assert "Starting up" in caplog.text - - -def test_shutdown_event(session, caplog): - with caplog.at_level(logging.INFO): - assert "Shutting down" in caplog.text