Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ The framework has a complete, tested feature set covering the full Beta roadmap.
| **FastAPI** | ✅ Supported | `[fastapi]` | Includes JinjaX renderer and WebSocket adapter |
| **Django** | ✅ Supported | `[django]` | Includes Channels, Cotton, and template renderer |
| **Litestar** | ✅ Supported | `[litestar]` | HTTP + WebSocket adapters (0.4.0+) |
| **Flask** | 🗓 Planned | | [Tracking issue #5](https://github.com/fsecada01/component-framework/issues/5) |
| **Flask** | ✅ Supported | `[flask]` | Jinja2 renderer + HTTP blueprint |

---

Expand All @@ -46,8 +46,11 @@ pip install "component-framework[fastapi]"
# Litestar projects
pip install "component-framework[litestar]"

# Flask projects
pip install "component-framework[flask]"

# Multiple adapters
pip install "component-framework[fastapi,django,litestar]"
pip install "component-framework[fastapi,django,litestar,flask]"

# Everything
pip install "component-framework[all]"
Expand Down Expand Up @@ -171,6 +174,26 @@ python manage.py runserver
# Open http://localhost:8000
```

### Flask Example

```bash
pip install "component-framework[flask]"
python examples/flask_example.py
# Open http://localhost:5000
```

Wire the endpoint and a shared renderer into your own app:

```python
from flask import Flask
from component_framework.adapters.flask import FlaskRenderer, register_component_routes
from component_framework.core.component import Component

app = Flask(__name__)
Component.renderer = FlaskRenderer(app) # shares app.jinja_env (filters/globals)
register_component_routes(app) # POST /components/<name>
```

---

## Documentation
Expand Down
87 changes: 87 additions & 0 deletions examples/flask_example.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"""Minimal Flask example for the component framework.

Run::

pip install "component-framework[flask]"
python examples/flask_example.py
# open http://localhost:5000

A server-rendered counter component. Clicking the buttons POSTs the event to
``/components/counter``; the bundled ``component-client.js`` swaps in the
authoritative server render.
"""

from flask import Flask, render_template_string
from jinja2 import DictLoader

from component_framework.adapters.flask import FlaskRenderer, register_component_routes
from component_framework.core.component import Component
from component_framework.core.registry import registry

app = Flask(__name__)

# Register a component template into the app's Jinja environment so the shared
# FlaskRenderer can resolve it (the renderer uses app.jinja_env).
app.jinja_env.loader = DictLoader(
{
"counter.html": (
'<div id="{{ component_id }}" data-component="counter"'
" data-state='{{ state | tojson }}' data-endpoint=\"/components/\">"
" <p>Count: {{ state.count }}</p>"
' <button data-event="increment" data-payload=\'{"amount": 1}\'>+</button>'
' <button data-event="decrement" data-payload=\'{"amount": 1}\'>-</button>'
"</div>"
)
}
)

# Share the app's Jinja environment with the component renderer.
Component.renderer = FlaskRenderer(app)

# Wire POST /components/<name>.
register_component_routes(app)


@registry.register("counter")
class Counter(Component):
"""A simple counter with increment/decrement handlers."""

template_name = "counter.html"

def mount(self):
super().mount()
self.state["count"] = self.params.get("initial", 0)

def on_increment(self, amount: int = 1):
self.state["count"] = self.state.get("count", 0) + amount

def on_decrement(self, amount: int = 1):
self.state["count"] = self.state.get("count", 0) - amount


INDEX = """<!doctype html>
<html>
<head><title>Flask + Component Framework</title></head>
<body>
<h1>Counter</h1>
{{ counter_html | safe }}
<script type="module">
import { componentClient } from
"https://cdn.jsdelivr.net/gh/fsecada01/component-framework/src/component_framework/static/component_framework/js/component-client.js";
componentClient.bind();
</script>
</body>
</html>
"""


@app.route("/")
def index():
"""Render the page with an initial counter component."""
counter = Counter(initial=0)
result = counter.dispatch() # mount + render
return render_template_string(INDEX, counter_html=result["html"])


if __name__ == "__main__":
app.run(debug=True, port=5000)
7 changes: 5 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ litestar = [
"litestar>=2.0",
"jinja2>=3.1",
]
flask = [
"flask>=3.0",
]
websockets = [
"websockets>=12.0",
]
Expand All @@ -55,13 +58,13 @@ dev-base = [
"ty==0.0.25",
]
dev = [
"component-framework[dev-base,fastapi,django,litestar,websockets]",
"component-framework[dev-base,fastapi,django,litestar,flask,websockets]",
"pre-commit>=3.5.0",
"prek>=0.3",
"pdoc>=14.0",
]
all = [
"component-framework[fastapi,django,litestar,websockets]",
"component-framework[fastapi,django,litestar,flask,websockets]",
]

[project.urls]
Expand Down
195 changes: 195 additions & 0 deletions src/component_framework/adapters/flask.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
"""Flask adapter for component endpoints.

Provides a Jinja2-backed :class:`FlaskRenderer` and a synchronous HTTP endpoint
(exposed as a Flask blueprint) that dispatches events to the component registry,
mirroring the protocol used by the FastAPI, Litestar, and Django adapters.

Install with the ``flask`` extra::

pip install "component-framework[flask]"
"""

import json
import logging

try:
from flask import Blueprint, jsonify, request
except ImportError as e:
from . import _require_extra

raise _require_extra("flask", "flask") from e

from ..core import Renderer, StateSerializer, registry

logger = logging.getLogger(__name__)


class FlaskRenderer(Renderer):
"""Renderer backed by a Jinja2 environment.

Pass the Flask application (or its ``jinja_env``) so component templates
inherit the app's configured filters, globals, and extensions rather than a
fresh, empty environment::

from flask import Flask
from component_framework.adapters.flask import FlaskRenderer
from component_framework.core.component import Component

app = Flask(__name__)
Component.renderer = FlaskRenderer(app) # shares app.jinja_env
"""

def __init__(self, app_or_env):
"""
Initialize the renderer.

Args:
app_or_env: A Flask application (anything exposing a ``jinja_env``
attribute) or a Jinja2 ``Environment`` directly. Sharing the
app's environment keeps component templates consistent with the
rest of the app.
"""
self.env = getattr(app_or_env, "jinja_env", app_or_env)

def render(self, template_name: str, context: dict) -> str:
"""Render ``template_name`` with ``context`` via the Jinja2 environment."""
return self.env.get_template(template_name).render(**context)


def _parse_json_str(value, default: dict | None = None) -> dict | None:
"""Parse a value that may be a JSON string, a dict, or None."""
if value is None:
return default
if isinstance(value, dict):
return value
try:
return json.loads(value)
except (json.JSONDecodeError, ValueError):
return default


def _parse_request_data() -> dict:
"""Parse the current Flask request body from JSON or form-encoded data.

HTMX sends ``application/x-www-form-urlencoded`` by default; the bundled
``component-client.js`` sends ``application/json``. Both normalise into a
dict carrying ``event``, ``payload``, ``state``, and ``params`` keys.

Raises:
ValueError: If a JSON content type is declared but the body is invalid.
"""
if request.is_json:
data = request.get_json(silent=True)
if data is None:
raise ValueError("Invalid JSON body")
return data
return request.form.to_dict()


def _extract_params(data: dict) -> tuple[dict, str | None, dict, dict | None]:
"""Extract and normalise event, payload, state, and params from parsed data.

Returns:
A ``(params, event, payload, state)`` tuple ready for component dispatch.

Raises:
ValueError: If the supplied state cannot be deserialized.
"""
params = _parse_json_str(data.get("params"), default={}) or {}
event = data.get("event")
payload = _parse_json_str(data.get("payload"), default={}) or {}
state_raw = data.get("state")

state = None
if state_raw:
try:
state = (
StateSerializer.deserialize(state_raw) if isinstance(state_raw, str) else state_raw
)
except Exception as e:
raise ValueError(f"Invalid state: {e}")

return params, event, payload, state


def component_view(name: str):
"""
Generic component endpoint for Flask.

POST /components/<name>
Body (JSON or form-encoded)::

{"event": "event_name", "payload": {...}, "state": "serialized_state"}

Returns JSON::

{"html": "...", "state": "...", "component_id": "...", "slots": {...}}

Args:
name: Registered component name from the URL.

Returns:
A Flask JSON response with the rendered component, or a JSON error with
status 404 (unknown component), 400 (bad request), or 500.
"""
component_cls = registry.get(name)
if not component_cls:
return jsonify({"error": f"Component '{name}' not found"}), 404

try:
data = _parse_request_data()
params, event, payload, state = _extract_params(data)
except ValueError as e:
return jsonify({"error": str(e)}), 400

try:
component = component_cls(**params)
result = component.dispatch(event=event, payload=payload, state=state)
result["state"] = StateSerializer.serialize(result["state"])
return jsonify(result), 200
except Exception:
logger.exception(f"Error processing component '{name}'")
return jsonify({"error": "Internal server error"}), 500


def create_component_blueprint(url_prefix: str = "/components") -> "Blueprint":
"""
Build a Flask blueprint exposing the component endpoint.

Args:
url_prefix: URL prefix the blueprint is mounted under.

Returns:
A :class:`flask.Blueprint` with ``POST <url_prefix>/<name>`` wired to
:func:`component_view`.

Usage::

from flask import Flask
from component_framework.adapters.flask import create_component_blueprint

app = Flask(__name__)
app.register_blueprint(create_component_blueprint())
"""
bp = Blueprint("components", __name__, url_prefix=url_prefix)
# strict_slashes=False so both "/components/<name>" and "/components/<name>/"
# match — the bundled component-client.js posts to the trailing-slash form.
bp.add_url_rule(
"/<name>",
endpoint="component",
view_func=component_view,
methods=["POST"],
strict_slashes=False,
)
return bp


def register_component_routes(app, url_prefix: str = "/components") -> None:
"""
Register the component blueprint on a Flask application.

Args:
app: The Flask application.
url_prefix: URL prefix to mount the component endpoint under.
"""
app.register_blueprint(create_component_blueprint(url_prefix=url_prefix))
Loading
Loading