Skip to content

Commit 6b1980c

Browse files
fsecada01claude
andauthored
feat(flask): add Flask adapter (closes #5) (#19)
* feat(flask): add Flask adapter (closes #5) Adds a Flask adapter so users can `pip install "component-framework[flask]"` and serve server-side components from Flask, mirroring the FastAPI/Litestar/ Django adapters and following the constitution's Adapter Contract. - adapters/flask.py: FlaskRenderer (Jinja2; shares the app's jinja_env for consistent filters/globals/extensions) + a synchronous component endpoint exposed via a blueprint (create_component_blueprint / register_component_routes). Parses JSON and HTMX form-encoded bodies; JSON 404/400/500 errors. Import-guarded with _require_extra. - pyproject: `flask` extra (flask>=3.0 only — no FastAPI/Django/JinjaX); added to `all` and `dev`. - examples/flask_example.py: runnable counter app. - tests/test_flask_adapter.py: renderer + endpoint coverage (dispatch, mount, form-encoded, 404/400/500), guarded by pytest.importorskip("flask"). - tests/test_optional_extras.py: ImportError guard test for the flask adapter. - README: Flask marked supported (adapter table, install matrix, Quick Start). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017hdmTV88wAXU8ew1rorMjt * fix(flask): match trailing-slash URLs from the JS client Review caught a real integration bug: component-client.js posts to "/components/<name>/" (trailing slash), but the blueprint rule only matched the no-slash form, so real usage 404'd while the no-slash tests passed. Add strict_slashes=False and a regression test for the trailing-slash URL. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017hdmTV88wAXU8ew1rorMjt --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent cd48f91 commit 6b1980c

6 files changed

Lines changed: 492 additions & 4 deletions

File tree

README.md

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ The framework has a complete, tested feature set covering the full Beta roadmap.
2828
| **FastAPI** | ✅ Supported | `[fastapi]` | Includes JinjaX renderer and WebSocket adapter |
2929
| **Django** | ✅ Supported | `[django]` | Includes Channels, Cotton, and template renderer |
3030
| **Litestar** | ✅ Supported | `[litestar]` | HTTP + WebSocket adapters (0.4.0+) |
31-
| **Flask** | 🗓 Planned | | [Tracking issue #5](https://github.com/fsecada01/component-framework/issues/5) |
31+
| **Flask** | ✅ Supported | `[flask]` | Jinja2 renderer + HTTP blueprint |
3232

3333
---
3434

@@ -46,8 +46,11 @@ pip install "component-framework[fastapi]"
4646
# Litestar projects
4747
pip install "component-framework[litestar]"
4848

49+
# Flask projects
50+
pip install "component-framework[flask]"
51+
4952
# Multiple adapters
50-
pip install "component-framework[fastapi,django,litestar]"
53+
pip install "component-framework[fastapi,django,litestar,flask]"
5154

5255
# Everything
5356
pip install "component-framework[all]"
@@ -202,6 +205,26 @@ python manage.py runserver
202205
# Open http://localhost:8000
203206
```
204207

208+
### Flask Example
209+
210+
```bash
211+
pip install "component-framework[flask]"
212+
python examples/flask_example.py
213+
# Open http://localhost:5000
214+
```
215+
216+
Wire the endpoint and a shared renderer into your own app:
217+
218+
```python
219+
from flask import Flask
220+
from component_framework.adapters.flask import FlaskRenderer, register_component_routes
221+
from component_framework.core.component import Component
222+
223+
app = Flask(__name__)
224+
Component.renderer = FlaskRenderer(app) # shares app.jinja_env (filters/globals)
225+
register_component_routes(app) # POST /components/<name>
226+
```
227+
205228
---
206229

207230
## Documentation

examples/flask_example.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"""Minimal Flask example for the component framework.
2+
3+
Run::
4+
5+
pip install "component-framework[flask]"
6+
python examples/flask_example.py
7+
# open http://localhost:5000
8+
9+
A server-rendered counter component. Clicking the buttons POSTs the event to
10+
``/components/counter``; the bundled ``component-client.js`` swaps in the
11+
authoritative server render.
12+
"""
13+
14+
from flask import Flask, render_template_string
15+
from jinja2 import DictLoader
16+
17+
from component_framework.adapters.flask import FlaskRenderer, register_component_routes
18+
from component_framework.core.component import Component
19+
from component_framework.core.registry import registry
20+
21+
app = Flask(__name__)
22+
23+
# Register a component template into the app's Jinja environment so the shared
24+
# FlaskRenderer can resolve it (the renderer uses app.jinja_env).
25+
app.jinja_env.loader = DictLoader(
26+
{
27+
"counter.html": (
28+
'<div id="{{ component_id }}" data-component="counter"'
29+
" data-state='{{ state | tojson }}' data-endpoint=\"/components/\">"
30+
" <p>Count: {{ state.count }}</p>"
31+
' <button data-event="increment" data-payload=\'{"amount": 1}\'>+</button>'
32+
' <button data-event="decrement" data-payload=\'{"amount": 1}\'>-</button>'
33+
"</div>"
34+
)
35+
}
36+
)
37+
38+
# Share the app's Jinja environment with the component renderer.
39+
Component.renderer = FlaskRenderer(app)
40+
41+
# Wire POST /components/<name>.
42+
register_component_routes(app)
43+
44+
45+
@registry.register("counter")
46+
class Counter(Component):
47+
"""A simple counter with increment/decrement handlers."""
48+
49+
template_name = "counter.html"
50+
51+
def mount(self):
52+
super().mount()
53+
self.state["count"] = self.params.get("initial", 0)
54+
55+
def on_increment(self, amount: int = 1):
56+
self.state["count"] = self.state.get("count", 0) + amount
57+
58+
def on_decrement(self, amount: int = 1):
59+
self.state["count"] = self.state.get("count", 0) - amount
60+
61+
62+
INDEX = """<!doctype html>
63+
<html>
64+
<head><title>Flask + Component Framework</title></head>
65+
<body>
66+
<h1>Counter</h1>
67+
{{ counter_html | safe }}
68+
<script type="module">
69+
import { componentClient } from
70+
"https://cdn.jsdelivr.net/gh/fsecada01/component-framework/src/component_framework/static/component_framework/js/component-client.js";
71+
componentClient.bind();
72+
</script>
73+
</body>
74+
</html>
75+
"""
76+
77+
78+
@app.route("/")
79+
def index():
80+
"""Render the page with an initial counter component."""
81+
counter = Counter(initial=0)
82+
result = counter.dispatch() # mount + render
83+
return render_template_string(INDEX, counter_html=result["html"])
84+
85+
86+
if __name__ == "__main__":
87+
app.run(debug=True, port=5000)

pyproject.toml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ litestar = [
4040
"litestar>=2.0",
4141
"jinja2>=3.1",
4242
]
43+
flask = [
44+
"flask>=3.0",
45+
]
4346
websockets = [
4447
"websockets>=12.0",
4548
]
@@ -55,13 +58,13 @@ dev-base = [
5558
"ty==0.0.25",
5659
]
5760
dev = [
58-
"component-framework[dev-base,fastapi,django,litestar,websockets]",
61+
"component-framework[dev-base,fastapi,django,litestar,flask,websockets]",
5962
"pre-commit>=3.5.0",
6063
"prek>=0.3",
6164
"pdoc>=14.0",
6265
]
6366
all = [
64-
"component-framework[fastapi,django,litestar,websockets]",
67+
"component-framework[fastapi,django,litestar,flask,websockets]",
6568
]
6669

6770
[project.urls]
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
"""Flask adapter for component endpoints.
2+
3+
Provides a Jinja2-backed :class:`FlaskRenderer` and a synchronous HTTP endpoint
4+
(exposed as a Flask blueprint) that dispatches events to the component registry,
5+
mirroring the protocol used by the FastAPI, Litestar, and Django adapters.
6+
7+
Install with the ``flask`` extra::
8+
9+
pip install "component-framework[flask]"
10+
"""
11+
12+
import json
13+
import logging
14+
15+
try:
16+
from flask import Blueprint, jsonify, request
17+
except ImportError as e:
18+
from . import _require_extra
19+
20+
raise _require_extra("flask", "flask") from e
21+
22+
from ..core import Renderer, StateSerializer, registry
23+
24+
logger = logging.getLogger(__name__)
25+
26+
27+
class FlaskRenderer(Renderer):
28+
"""Renderer backed by a Jinja2 environment.
29+
30+
Pass the Flask application (or its ``jinja_env``) so component templates
31+
inherit the app's configured filters, globals, and extensions rather than a
32+
fresh, empty environment::
33+
34+
from flask import Flask
35+
from component_framework.adapters.flask import FlaskRenderer
36+
from component_framework.core.component import Component
37+
38+
app = Flask(__name__)
39+
Component.renderer = FlaskRenderer(app) # shares app.jinja_env
40+
"""
41+
42+
def __init__(self, app_or_env):
43+
"""
44+
Initialize the renderer.
45+
46+
Args:
47+
app_or_env: A Flask application (anything exposing a ``jinja_env``
48+
attribute) or a Jinja2 ``Environment`` directly. Sharing the
49+
app's environment keeps component templates consistent with the
50+
rest of the app.
51+
"""
52+
self.env = getattr(app_or_env, "jinja_env", app_or_env)
53+
54+
def render(self, template_name: str, context: dict) -> str:
55+
"""Render ``template_name`` with ``context`` via the Jinja2 environment."""
56+
return self.env.get_template(template_name).render(**context)
57+
58+
59+
def _parse_json_str(value, default: dict | None = None) -> dict | None:
60+
"""Parse a value that may be a JSON string, a dict, or None."""
61+
if value is None:
62+
return default
63+
if isinstance(value, dict):
64+
return value
65+
try:
66+
return json.loads(value)
67+
except (json.JSONDecodeError, ValueError):
68+
return default
69+
70+
71+
def _parse_request_data() -> dict:
72+
"""Parse the current Flask request body from JSON or form-encoded data.
73+
74+
HTMX sends ``application/x-www-form-urlencoded`` by default; the bundled
75+
``component-client.js`` sends ``application/json``. Both normalise into a
76+
dict carrying ``event``, ``payload``, ``state``, and ``params`` keys.
77+
78+
Raises:
79+
ValueError: If a JSON content type is declared but the body is invalid.
80+
"""
81+
if request.is_json:
82+
data = request.get_json(silent=True)
83+
if data is None:
84+
raise ValueError("Invalid JSON body")
85+
return data
86+
return request.form.to_dict()
87+
88+
89+
def _extract_params(data: dict) -> tuple[dict, str | None, dict, dict | None]:
90+
"""Extract and normalise event, payload, state, and params from parsed data.
91+
92+
Returns:
93+
A ``(params, event, payload, state)`` tuple ready for component dispatch.
94+
95+
Raises:
96+
ValueError: If the supplied state cannot be deserialized.
97+
"""
98+
params = _parse_json_str(data.get("params"), default={}) or {}
99+
event = data.get("event")
100+
payload = _parse_json_str(data.get("payload"), default={}) or {}
101+
state_raw = data.get("state")
102+
103+
state = None
104+
if state_raw:
105+
try:
106+
state = (
107+
StateSerializer.deserialize(state_raw) if isinstance(state_raw, str) else state_raw
108+
)
109+
except Exception as e:
110+
raise ValueError(f"Invalid state: {e}")
111+
112+
return params, event, payload, state
113+
114+
115+
def component_view(name: str):
116+
"""
117+
Generic component endpoint for Flask.
118+
119+
POST /components/<name>
120+
Body (JSON or form-encoded)::
121+
122+
{"event": "event_name", "payload": {...}, "state": "serialized_state"}
123+
124+
Returns JSON::
125+
126+
{"html": "...", "state": "...", "component_id": "...", "slots": {...}}
127+
128+
Args:
129+
name: Registered component name from the URL.
130+
131+
Returns:
132+
A Flask JSON response with the rendered component, or a JSON error with
133+
status 404 (unknown component), 400 (bad request), or 500.
134+
"""
135+
component_cls = registry.get(name)
136+
if not component_cls:
137+
return jsonify({"error": f"Component '{name}' not found"}), 404
138+
139+
try:
140+
data = _parse_request_data()
141+
params, event, payload, state = _extract_params(data)
142+
except ValueError as e:
143+
return jsonify({"error": str(e)}), 400
144+
145+
try:
146+
component = component_cls(**params)
147+
result = component.dispatch(event=event, payload=payload, state=state)
148+
result["state"] = StateSerializer.serialize(result["state"])
149+
return jsonify(result), 200
150+
except Exception:
151+
logger.exception(f"Error processing component '{name}'")
152+
return jsonify({"error": "Internal server error"}), 500
153+
154+
155+
def create_component_blueprint(url_prefix: str = "/components") -> "Blueprint":
156+
"""
157+
Build a Flask blueprint exposing the component endpoint.
158+
159+
Args:
160+
url_prefix: URL prefix the blueprint is mounted under.
161+
162+
Returns:
163+
A :class:`flask.Blueprint` with ``POST <url_prefix>/<name>`` wired to
164+
:func:`component_view`.
165+
166+
Usage::
167+
168+
from flask import Flask
169+
from component_framework.adapters.flask import create_component_blueprint
170+
171+
app = Flask(__name__)
172+
app.register_blueprint(create_component_blueprint())
173+
"""
174+
bp = Blueprint("components", __name__, url_prefix=url_prefix)
175+
# strict_slashes=False so both "/components/<name>" and "/components/<name>/"
176+
# match — the bundled component-client.js posts to the trailing-slash form.
177+
bp.add_url_rule(
178+
"/<name>",
179+
endpoint="component",
180+
view_func=component_view,
181+
methods=["POST"],
182+
strict_slashes=False,
183+
)
184+
return bp
185+
186+
187+
def register_component_routes(app, url_prefix: str = "/components") -> None:
188+
"""
189+
Register the component blueprint on a Flask application.
190+
191+
Args:
192+
app: The Flask application.
193+
url_prefix: URL prefix to mount the component endpoint under.
194+
"""
195+
app.register_blueprint(create_component_blueprint(url_prefix=url_prefix))

0 commit comments

Comments
 (0)