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