From 8dc9fc19de035de35d08cb98dd447374a685d9ac Mon Sep 17 00:00:00 2001 From: florimondmanca Date: Sun, 16 Dec 2018 01:15:31 +0100 Subject: [PATCH 1/4] add startup and shutdown events --- bocadillo/api.py | 11 ++++++-- bocadillo/lifespan.py | 64 ++++++++++++++++++++++++++++++++++++++++++ tests/test_lifespan.py | 27 ++++++++++++++++++ 3 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 bocadillo/lifespan.py create mode 100644 tests/test_lifespan.py diff --git a/bocadillo/api.py b/bocadillo/api.py index 6eb11789..14016290 100644 --- a/bocadillo/api.py +++ b/bocadillo/api.py @@ -14,6 +14,7 @@ from .cors import DEFAULT_CORS_CONFIG from .error_handlers import ErrorHandler, convert_exception_to_response +from .lifespan import LifespanMixin from .exceptions import HTTPError from .hooks import HooksMixin from .media import Media @@ -28,7 +29,9 @@ from .types import ASGIApp, ASGIAppInstance, WSGIApp -class API(TemplatesMixin, RoutingMixin, HooksMixin, metaclass=APIMeta): +class API( + TemplatesMixin, RoutingMixin, HooksMixin, LifespanMixin, metaclass=APIMeta +): """The all-mighty API class. This class implements the [ASGI](https://asgi.readthedocs.io) protocol. @@ -338,6 +341,9 @@ def find_app(self, scope: dict) -> ASGIAppInstance: An ASGI application instance (either `self` or an instance of a sub-app). """ + if scope["type"] == "lifespan": + return self.lifespan_handler(scope) + path: str = scope["path"] # Return a sub-mounted extra app, if found @@ -433,4 +439,5 @@ def run( run(self, host=host, port=port) def __call__(self, scope: dict) -> ASGIAppInstance: - return self.find_app(scope) + scope["app"] = self.find_app + return super().__call__(scope) diff --git a/bocadillo/lifespan.py b/bocadillo/lifespan.py new file mode 100644 index 00000000..8cd811a2 --- /dev/null +++ b/bocadillo/lifespan.py @@ -0,0 +1,64 @@ +from typing import Callable, List, Tuple + +from starlette.middleware.lifespan import LifespanMiddleware + +from .types import ASGIApp, ASGIAppInstance + +EventHandler = Callable[[], None] + + +class LifespanMixin: + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._events: List[Tuple[str, EventHandler]] = [] + + def on(self, event: str) -> Callable: + """Register an event handler. + + # Parameters + event (str): + Either "startup" (when the server boots) or "shutdown" (when the + server stops). + """ + + def decorate(f: EventHandler): + self.add_event_handler(event, f) + return f + + return decorate + + def add_event_handler(self, event: str, handler: EventHandler): + self._events.append((event, handler)) + + def _get_lifespan_middleware(self, app: ASGIApp): + middleware = LifespanMiddleware(app) + for event, func in self._events: + middleware.add_event_handler(event, func) + return middleware + + @staticmethod + def lifespan_handler(scope: dict) -> ASGIAppInstance: + """Strict implementation of the ASGI lifespan spec. + + This is required because the Starlette `LifespanMiddleware` + does not send the `complete` responses. + + It runs before the Bocadillo app itself (which it wraps around), + so this handler can just send the `complete` responses without + doing anything special. + """ + + async def handle(receive, send): + message = await receive() + assert message["type"] == "lifespan.startup" + await send({"type": "lifespan.startup.complete"}) + + message = await receive() + assert message["type"] == "lifespan.shutdown" + await send({"type": "lifespan.shutdown.complete"}) + + return handle + + def __call__(self, scope: dict): + app = scope.pop("app") + return self._get_lifespan_middleware(app)(scope) diff --git a/tests/test_lifespan.py b/tests/test_lifespan.py new file mode 100644 index 00000000..030e5067 --- /dev/null +++ b/tests/test_lifespan.py @@ -0,0 +1,27 @@ +from bocadillo import API + + +def test_startup_and_shutdown(api: API): + data = None + + @api.on("startup") + async def setup(): + nonlocal data + data = {"message": "foo"} + + @api.on("shutdown") + async def cleanup(): + nonlocal data + data = None + + @api.route("/") + async def index(req, res): + res.media = data + + # The Starlette TestClient calls startup and shutdown events when + # used as a context manager. + with api.client: + assert data == {"message": "foo"} + response = api.client.get("/") + assert response.json() == data + assert data is None From bbdbd188937b7ca765ca20524bc2a6fc2bd86706 Mon Sep 17 00:00:00 2001 From: florimondmanca Date: Sun, 16 Dec 2018 01:34:19 +0100 Subject: [PATCH 2/4] add events docs, refactor, add non-decorator syntax --- bocadillo/api.py | 2 +- bocadillo/lifespan.py | 24 ++++++++++++------- docs/.vuepress/config.js | 1 + docs/api/api.md | 12 ++++++++++ docs/topics/features/events.md | 38 ++++++++++++++++++++++++++++++ tests/test_lifespan.py | 43 +++++++++++++++++++++++++++------- 6 files changed, 102 insertions(+), 18 deletions(-) create mode 100644 docs/topics/features/events.md diff --git a/bocadillo/api.py b/bocadillo/api.py index 14016290..04457696 100644 --- a/bocadillo/api.py +++ b/bocadillo/api.py @@ -342,7 +342,7 @@ def find_app(self, scope: dict) -> ASGIAppInstance: (either `self` or an instance of a sub-app). """ if scope["type"] == "lifespan": - return self.lifespan_handler(scope) + return self._lifespan_handler(scope) path: str = scope["path"] diff --git a/bocadillo/lifespan.py b/bocadillo/lifespan.py index 8cd811a2..e9dc38da 100644 --- a/bocadillo/lifespan.py +++ b/bocadillo/lifespan.py @@ -1,4 +1,4 @@ -from typing import Callable, List, Tuple +from typing import Callable, List, Tuple, Optional from starlette.middleware.lifespan import LifespanMiddleware @@ -12,22 +12,30 @@ def __init__(self, **kwargs): super().__init__(**kwargs) self._events: List[Tuple[str, EventHandler]] = [] - def on(self, event: str) -> Callable: + def on(self, event: str, handler: Optional[EventHandler] = None): """Register an event handler. # Parameters event (str): Either "startup" (when the server boots) or "shutdown" (when the server stops). + handler (callback, optional): + The event handler. If not given, this should be used as a + decorator. """ - def decorate(f: EventHandler): - self.add_event_handler(event, f) - return f + if handler is not None: + self._add_event_handler(event, handler) + return + else: - return decorate + def decorate(f: EventHandler): + self._add_event_handler(event, f) + return f - def add_event_handler(self, event: str, handler: EventHandler): + return decorate + + def _add_event_handler(self, event: str, handler: EventHandler): self._events.append((event, handler)) def _get_lifespan_middleware(self, app: ASGIApp): @@ -37,7 +45,7 @@ def _get_lifespan_middleware(self, app: ASGIApp): return middleware @staticmethod - def lifespan_handler(scope: dict) -> ASGIAppInstance: + def _lifespan_handler(scope: dict) -> ASGIAppInstance: """Strict implementation of the ASGI lifespan spec. This is required because the Starlette `LifespanMiddleware` diff --git a/docs/.vuepress/config.js b/docs/.vuepress/config.js index 8b5e024c..f342bf31 100644 --- a/docs/.vuepress/config.js +++ b/docs/.vuepress/config.js @@ -97,6 +97,7 @@ module.exports = { '/topics/features/cors', '/topics/features/hsts', '/topics/features/gzip', + '/topics/features/events', '/topics/features/middleware', ], }, diff --git a/docs/api/api.md b/docs/api/api.md index 4d9298a6..42b27e9e 100644 --- a/docs/api/api.md +++ b/docs/api/api.md @@ -117,6 +117,18 @@ __Example__ ... pass ``` +## on +```python +API.on(self, event: str) -> Callable +``` +Register an event handler. + +__Parameters__ + +- __event (str)__: + Either "startup" (when the server boots) or "shutdown" (when the + server stops). + ## url_for ```python API.url_for(self, name: str, **kwargs) -> str diff --git a/docs/topics/features/events.md b/docs/topics/features/events.md new file mode 100644 index 00000000..5617015a --- /dev/null +++ b/docs/topics/features/events.md @@ -0,0 +1,38 @@ +# Events + +Bocadillo implements the [ASGI Lifespan protocol](https://asgi.readthedocs.io/en/latest/specs/lifespan.html), which allows you to hook into the server's initialization and teardown via events and event handlers. + +Events are especially helpful when you need to setup resources on server startup and make sure you clean them up when the server stops. + +## Registering events + +Event handlers are callbacks, i.e. functions with the signature `() -> None`. + +They can be registered using the `@api.on()` decorator: + +```python +@api.on("startup") +async def setup(): + # Perform setup when server boots + pass + +@api.on("shutdown") +async def cleanup(): + # Perform cleanup when server shuts down + pass +``` + +A non-decorator syntax is also available: + +```python +async def setup(): + pass + +api.on("shutdown", setup) +``` + +Only the `"startup"` and `"shutdown"` events are supported. + +::: tip +Event handlers can also be regular, non-async functions. +::: diff --git a/tests/test_lifespan.py b/tests/test_lifespan.py index 030e5067..5cf159b0 100644 --- a/tests/test_lifespan.py +++ b/tests/test_lifespan.py @@ -2,26 +2,51 @@ def test_startup_and_shutdown(api: API): - data = None + message = None @api.on("startup") async def setup(): - nonlocal data - data = {"message": "foo"} + nonlocal message + message = "hi" @api.on("shutdown") async def cleanup(): - nonlocal data - data = None + nonlocal message + message = None @api.route("/") async def index(req, res): - res.media = data + res.text = message # The Starlette TestClient calls startup and shutdown events when # used as a context manager. with api.client: - assert data == {"message": "foo"} + assert message == "hi" response = api.client.get("/") - assert response.json() == data - assert data is None + assert response.text == "hi" + assert message is None + + +def test_sync_handler(api: API): + message = None + + @api.on("startup") + def setup(): + nonlocal message + message = "hi" + + with api.client: + assert message == "hi" + + +def test_non_decorator_syntax(api: API): + message = None + + async def setup(): + nonlocal message + message = "hi" + + api.on("startup", setup) + + with api.client: + assert message == "hi" From 493b53389b2d8cc58a0bbf7d84e5307fadb4af2f Mon Sep 17 00:00:00 2001 From: florimondmanca Date: Sun, 16 Dec 2018 01:40:04 +0100 Subject: [PATCH 3/4] refactor further --- bocadillo/api.py | 9 ++++---- bocadillo/{lifespan.py => events.py} | 27 ++++++++-------------- tests/{test_lifespan.py => test_events.py} | 0 3 files changed, 13 insertions(+), 23 deletions(-) rename bocadillo/{lifespan.py => events.py} (71%) rename tests/{test_lifespan.py => test_events.py} (100%) diff --git a/bocadillo/api.py b/bocadillo/api.py index 04457696..28b6bb00 100644 --- a/bocadillo/api.py +++ b/bocadillo/api.py @@ -14,7 +14,7 @@ from .cors import DEFAULT_CORS_CONFIG from .error_handlers import ErrorHandler, convert_exception_to_response -from .lifespan import LifespanMixin +from .events import EventsMixin from .exceptions import HTTPError from .hooks import HooksMixin from .media import Media @@ -30,7 +30,7 @@ class API( - TemplatesMixin, RoutingMixin, HooksMixin, LifespanMixin, metaclass=APIMeta + TemplatesMixin, RoutingMixin, HooksMixin, EventsMixin, metaclass=APIMeta ): """The all-mighty API class. @@ -342,7 +342,7 @@ def find_app(self, scope: dict) -> ASGIAppInstance: (either `self` or an instance of a sub-app). """ if scope["type"] == "lifespan": - return self._lifespan_handler(scope) + return self.handle_lifespan(scope) path: str = scope["path"] @@ -439,5 +439,4 @@ def run( run(self, host=host, port=port) def __call__(self, scope: dict) -> ASGIAppInstance: - scope["app"] = self.find_app - return super().__call__(scope) + return self.find_app(scope) diff --git a/bocadillo/lifespan.py b/bocadillo/events.py similarity index 71% rename from bocadillo/lifespan.py rename to bocadillo/events.py index e9dc38da..11148a76 100644 --- a/bocadillo/lifespan.py +++ b/bocadillo/events.py @@ -7,7 +7,9 @@ EventHandler = Callable[[], None] -class LifespanMixin: +class EventsMixin: + """Provide server event handling capabilities.""" + def __init__(self, **kwargs): super().__init__(**kwargs) self._events: List[Tuple[str, EventHandler]] = [] @@ -44,19 +46,12 @@ def _get_lifespan_middleware(self, app: ASGIApp): middleware.add_event_handler(event, func) return middleware - @staticmethod - def _lifespan_handler(scope: dict) -> ASGIAppInstance: - """Strict implementation of the ASGI lifespan spec. - - This is required because the Starlette `LifespanMiddleware` - does not send the `complete` responses. + def handle_lifespan(self, scope: dict) -> ASGIAppInstance: + # Strict implementation of the ASGI lifespan spec. + # This is required because the Starlette `LifespanMiddleware` + # does not send the `complete` responses. - It runs before the Bocadillo app itself (which it wraps around), - so this handler can just send the `complete` responses without - doing anything special. - """ - - async def handle(receive, send): + async def app(receive, send): message = await receive() assert message["type"] == "lifespan.startup" await send({"type": "lifespan.startup.complete"}) @@ -65,8 +60,4 @@ async def handle(receive, send): assert message["type"] == "lifespan.shutdown" await send({"type": "lifespan.shutdown.complete"}) - return handle - - def __call__(self, scope: dict): - app = scope.pop("app") - return self._get_lifespan_middleware(app)(scope) + return self._get_lifespan_middleware(lambda s: app)(scope) diff --git a/tests/test_lifespan.py b/tests/test_events.py similarity index 100% rename from tests/test_lifespan.py rename to tests/test_events.py From 29d638eda940f0330943a3016f7bdb8089fcc057 Mon Sep 17 00:00:00 2001 From: florimondmanca Date: Sun, 16 Dec 2018 01:40:29 +0100 Subject: [PATCH 4/4] regenerate API docs --- docs/api/api.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/api/api.md b/docs/api/api.md index 42b27e9e..3815880c 100644 --- a/docs/api/api.md +++ b/docs/api/api.md @@ -119,7 +119,7 @@ __Example__ ## on ```python -API.on(self, event: str) -> Callable +API.on(self, event: str, handler: Union[Callable[[], NoneType], NoneType] = None) ``` Register an event handler. @@ -128,6 +128,9 @@ __Parameters__ - __event (str)__: Either "startup" (when the server boots) or "shutdown" (when the server stops). +- __handler (callback, optional)__: + The event handler. If not given, this should be used as a + decorator. ## url_for ```python