From b3a04ef1adc36d887129467be958a855854b22c8 Mon Sep 17 00:00:00 2001 From: Ahmed Lemine Date: Thu, 20 Aug 2026 17:22:09 +0400 Subject: [PATCH] Add Bottle adapter, example, and tests --- examples/bottle/app.py | 63 +++++++++++++++++++++++ src/datastar_py/bottle.py | 91 ++++++++++++++++++++++++++++++++++ tests/test_decorator_matrix.py | 5 ++ 3 files changed, 159 insertions(+) create mode 100644 examples/bottle/app.py create mode 100644 src/datastar_py/bottle.py diff --git a/examples/bottle/app.py b/examples/bottle/app.py new file mode 100644 index 0000000..c4d2634 --- /dev/null +++ b/examples/bottle/app.py @@ -0,0 +1,63 @@ +import time +from datetime import datetime +from bottle import route, run, template +from datastar_py.bottle import ServerSentEventGenerator, datastar_response + + +html = """\ + + + + DATASTAR on Bottle (WSGI) + + + + + +
+
+ Current time from element: CURRENT_TIME +
+
+ Current time from signal: CURRENT_TIME +
+
+ + +""" + + +@route("/") +def home(): + return template(html.replace("CURRENT_TIME", f"{datetime.isoformat(datetime.now())}")) + + +@route("/updates/") +@datastar_response +def updates(): + while True: + yield ServerSentEventGenerator.patch_elements( + f"""{datetime.now().isoformat()}""" + ) + + time.sleep(0.5) + + yield ServerSentEventGenerator.patch_signals({"currentTime": datetime.now().isoformat()}) + + time.sleep(0.5) + + +if __name__ == "__main__": + run(host="localhost", port=8080) diff --git a/src/datastar_py/bottle.py b/src/datastar_py/bottle.py new file mode 100644 index 0000000..7645a6d --- /dev/null +++ b/src/datastar_py/bottle.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from collections.abc import Callable, Mapping +from functools import wraps +from typing import Any, ParamSpec + +from bottle import HTTPResponse +from bottle import request as _request + +from . import _read_signals +from .sse import ( + SSE_HEADERS, + DatastarEvent, + DatastarEvents, + ServerSentEventGenerator, +) + +__all__ = [ + "SSE_HEADERS", + "DatastarResponse", + "ServerSentEventGenerator", + "datastar_response", + "read_signals", +] + + +class DatastarResponse(HTTPResponse): + """Respond with 0..N `DatastarEvent`s. + + Bottle natively streams iterable response bodies, so generators are + passed through unchanged and consumed as they produce events. + """ + + default_headers: dict[str, str] = SSE_HEADERS.copy() + + def __init__( + self, + content: DatastarEvents = None, + *, + status: int | None = None, + headers: Mapping[str, str] | None = None, + ) -> None: + if content is None: + status = status or 204 + content = "" + headers = headers or {} + else: + status = status or 200 + headers = {**self.default_headers, **(headers or {})} + + if isinstance(content, DatastarEvent): + content = (content,) + + super().__init__( + body=content, + status=status, + headers=headers, + ) + + +P = ParamSpec("P") + + +def datastar_response( + func: Callable[P, DatastarEvents], +) -> Callable[P, DatastarResponse]: + """Wrap a route callback's result in a `DatastarResponse`. + + The wrapped function may return a single event, an iterable of events, + or a generator yielding events. + """ + + @wraps(func) + def wrapper(*args: P.args, **kwargs: P.kwargs) -> DatastarResponse: + return DatastarResponse(func(*args, **kwargs)) + + wrapper.__annotations__["return"] = DatastarResponse + return wrapper + + +def read_signals() -> dict[str, Any] | None: + body = _request.body + if hasattr(body, "read"): + body = body.read() + + return _read_signals( + _request.method, + _request.headers, + _request.query, + body, + ) diff --git a/tests/test_decorator_matrix.py b/tests/test_decorator_matrix.py index 00fd50f..05f2d4e 100644 --- a/tests/test_decorator_matrix.py +++ b/tests/test_decorator_matrix.py @@ -18,6 +18,7 @@ ("fastapi", "datastar_py.fastapi", "body_iterator"), ("litestar", "datastar_py.litestar", "iterator"), ("django", "datastar_py.django", None), + ("bottle", "datastar_py.bottle", None), # Quart and Sanic need full request contexts; covered elsewhere ("quart", "datastar_py.quart", None), ("sanic", "datastar_py.sanic", None), @@ -65,6 +66,10 @@ async def test_datastar_response_matrix( """Ensure decorator works for sync/async and generator/non-generator functions.""" if framework_name in {"quart", "sanic"}: pytest.skip(f"{framework_name} decorator requires full request context to exercise") + + if framework_name == "bottle" and variant in {"async_value", "async_generator"}: + pytest.skip("Bottle is synchronous and does not support async route callbacks") + if framework_name == "django": from django.conf import settings