diff --git a/README.md b/README.md index 8d40b44..ce3f5ae 100644 --- a/README.md +++ b/README.md @@ -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 | --- @@ -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]" @@ -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/ +``` + --- ## Documentation diff --git a/examples/flask_example.py b/examples/flask_example.py new file mode 100644 index 0000000..5c85ac3 --- /dev/null +++ b/examples/flask_example.py @@ -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": ( + '
" + "

Count: {{ state.count }}

" + ' ' + ' ' + "
" + ) + } +) + +# Share the app's Jinja environment with the component renderer. +Component.renderer = FlaskRenderer(app) + +# Wire POST /components/. +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 = """ + + Flask + Component Framework + +

Counter

+ {{ counter_html | safe }} + + + +""" + + +@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) diff --git a/pyproject.toml b/pyproject.toml index d3ba640..cf794b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,9 @@ litestar = [ "litestar>=2.0", "jinja2>=3.1", ] +flask = [ + "flask>=3.0", +] websockets = [ "websockets>=12.0", ] @@ -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] diff --git a/src/component_framework/adapters/flask.py b/src/component_framework/adapters/flask.py new file mode 100644 index 0000000..ce76779 --- /dev/null +++ b/src/component_framework/adapters/flask.py @@ -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/ + 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 /`` 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/" and "/components//" + # match — the bundled component-client.js posts to the trailing-slash form. + bp.add_url_rule( + "/", + 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)) diff --git a/tests/test_flask_adapter.py b/tests/test_flask_adapter.py new file mode 100644 index 0000000..6958d55 --- /dev/null +++ b/tests/test_flask_adapter.py @@ -0,0 +1,166 @@ +"""Tests for the Flask adapter (issue #5). + +Constitution: adapter tests must guard the optional dependency with +``pytest.importorskip("flask")`` so the suite still runs without the extra. +""" + +from __future__ import annotations + +import json + +import pytest + +pytest.importorskip("flask") + +from flask import Flask # noqa: E402 +from jinja2 import DictLoader, Environment # noqa: E402 + +from component_framework.adapters.flask import ( # noqa: E402 + FlaskRenderer, + register_component_routes, +) +from component_framework.core import Component, Renderer # noqa: E402 +from component_framework.core.registry import ComponentRegistry # noqa: E402 + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +class MockRenderer(Renderer): + """Minimal renderer so dispatch tests need no real templates.""" + + def render(self, template_name: str, context: dict) -> str: + state = context.get("state", {}) + return f'
count={state.get("count", 0)}
' + + +@pytest.fixture(autouse=True) +def _setup_renderer(): + old = Component.renderer + Component.renderer = MockRenderer() + yield + Component.renderer = old + + +@pytest.fixture +def fresh_registry(monkeypatch): + reg = ComponentRegistry() + monkeypatch.setattr("component_framework.adapters.flask.registry", reg) + return reg + + +@pytest.fixture +def counter_cls(fresh_registry): + @fresh_registry.register("counter") + class Counter(Component): + 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 + + return Counter + + +@pytest.fixture +def client(counter_cls): + app = Flask(__name__) + register_component_routes(app) + return app.test_client() + + +# --------------------------------------------------------------------------- +# FlaskRenderer +# --------------------------------------------------------------------------- + + +class TestFlaskRenderer: + def test_renders_via_jinja_environment(self): + env = Environment(loader=DictLoader({"hello.html": "Hi {{ name }}"})) + renderer = FlaskRenderer(env) + assert renderer.render("hello.html", {"name": "world"}) == "Hi world" + + def test_accepts_flask_app_and_shares_its_env(self): + app = Flask(__name__) + app.jinja_env.loader = DictLoader({"g.html": "{{ greet }}"}) + app.jinja_env.globals["greet"] = "hello" + renderer = FlaskRenderer(app) + # The renderer uses the app's environment, so app globals are available. + assert renderer.env is app.jinja_env + assert renderer.render("g.html", {}) == "hello" + + +# --------------------------------------------------------------------------- +# HTTP endpoint +# --------------------------------------------------------------------------- + + +class TestComponentEndpoint: + def test_dispatch_increment_json(self, client): + resp = client.post( + "/components/counter", + data=json.dumps( + {"event": "increment", "payload": {"amount": 2}, "state": json.dumps({"count": 5})} + ), + content_type="application/json", + ) + assert resp.status_code == 200 + data = resp.get_json() + assert data["component_id"] + assert json.loads(data["state"])["count"] == 7 + assert "count=7" in data["html"] + + def test_mount_without_event(self, client): + resp = client.post( + "/components/counter", + data=json.dumps({"params": {"initial": 3}}), + content_type="application/json", + ) + assert resp.status_code == 200 + assert json.loads(resp.get_json()["state"])["count"] == 3 + + def test_form_encoded_dispatch(self, client): + # HTMX-style form-encoded request. + resp = client.post( + "/components/counter", + data={ + "event": "increment", + "payload": json.dumps({"amount": 1}), + "state": json.dumps({"count": 0}), + }, + ) + assert resp.status_code == 200 + assert json.loads(resp.get_json()["state"])["count"] == 1 + + def test_trailing_slash_matches(self, client): + # The bundled component-client.js posts to "/components//". + resp = client.post( + "/components/counter/", + data=json.dumps({"event": "increment", "state": json.dumps({"count": 0})}), + content_type="application/json", + ) + assert resp.status_code == 200 + assert json.loads(resp.get_json()["state"])["count"] == 1 + + def test_unknown_component_404(self, client): + resp = client.post("/components/nope", json={"event": "x"}) + assert resp.status_code == 404 + assert "not found" in resp.get_json()["error"] + + def test_invalid_json_400(self, client): + resp = client.post("/components/counter", data="{not json", content_type="application/json") + assert resp.status_code == 400 + + def test_handler_error_500(self, client): + # 'decrement' has no handler -> EventNotFoundError -> 500. + resp = client.post( + "/components/counter", + data=json.dumps({"event": "decrement", "state": json.dumps({"count": 0})}), + content_type="application/json", + ) + assert resp.status_code == 500 + assert resp.get_json()["error"] == "Internal server error" diff --git a/tests/test_optional_extras.py b/tests/test_optional_extras.py index 385304c..b4c80a7 100644 --- a/tests/test_optional_extras.py +++ b/tests/test_optional_extras.py @@ -136,6 +136,20 @@ def test_jinjax_renderer_raises_on_missing_jinjax(self): assert "component-framework[fastapi]" in str(err) +# --------------------------------------------------------------------------- +# T001d — Flask adapter ImportError guard +# --------------------------------------------------------------------------- + + +class TestFlaskAdapterGuard: + """Verify adapters/flask.py raises an actionable ImportError when flask absent.""" + + def test_flask_adapter_raises_on_missing_flask(self): + err = _reload_adapter("component_framework.adapters.flask", "flask") + assert "flask" in str(err).lower() + assert "component-framework[flask]" in str(err) + + # --------------------------------------------------------------------------- # T001c — Django adapter ImportError guards # ---------------------------------------------------------------------------