From aea0c5cc3971a26eb1972baea929f9885c0a555a Mon Sep 17 00:00:00 2001 From: Hood Chatham Date: Thu, 6 Aug 2026 12:44:23 -0700 Subject: [PATCH 1/2] Add flask hello world and todo app --- 20-flask/README.md | 14 + 20-flask/package.json | 16 + 20-flask/pyproject.toml | 31 ++ 20-flask/src/worker.py | 26 ++ 20-flask/wrangler.jsonc | 16 + 21-flask-todo-app/.gitignore | 15 + 21-flask-todo-app/README.md | 46 +++ .../migrations/0001_create_notes.sql | 14 + 21-flask-todo-app/public/app.js | 206 ++++++++++++ 21-flask-todo-app/public/index.html | 79 +++++ 21-flask-todo-app/public/style.css | 310 ++++++++++++++++++ 21-flask-todo-app/pyproject.toml | 14 + 21-flask-todo-app/src/app.py | 229 +++++++++++++ 21-flask-todo-app/src/d1.py | 65 ++++ 21-flask-todo-app/src/entry.py | 8 + 21-flask-todo-app/wrangler.jsonc | 25 ++ 16 files changed, 1114 insertions(+) create mode 100644 20-flask/README.md create mode 100644 20-flask/package.json create mode 100644 20-flask/pyproject.toml create mode 100644 20-flask/src/worker.py create mode 100644 20-flask/wrangler.jsonc create mode 100644 21-flask-todo-app/.gitignore create mode 100644 21-flask-todo-app/README.md create mode 100644 21-flask-todo-app/migrations/0001_create_notes.sql create mode 100644 21-flask-todo-app/public/app.js create mode 100644 21-flask-todo-app/public/index.html create mode 100644 21-flask-todo-app/public/style.css create mode 100644 21-flask-todo-app/pyproject.toml create mode 100644 21-flask-todo-app/src/app.py create mode 100644 21-flask-todo-app/src/d1.py create mode 100644 21-flask-todo-app/src/entry.py create mode 100644 21-flask-todo-app/wrangler.jsonc diff --git a/20-flask/README.md b/20-flask/README.md new file mode 100644 index 0000000..2bdd5fc --- /dev/null +++ b/20-flask/README.md @@ -0,0 +1,14 @@ +# Flask Hello World + +A minimal [Flask](https://flask.palletsprojects.com/) example app running on a +Python Worker via the WSGI adapter (`wsgi.fetch`). + +## How to Run + +First ensure that `uv` is installed: +https://docs.astral.sh/uv/getting-started/installation/#standalone-installer + +Now, if you run `uv run pywrangler dev` within this directory, it should use the config +in `wrangler.jsonc` to run the example. + +You can also run `uv run pywrangler deploy` to deploy the example. diff --git a/20-flask/package.json b/20-flask/package.json new file mode 100644 index 0000000..dd0b632 --- /dev/null +++ b/20-flask/package.json @@ -0,0 +1,16 @@ +{ + "name": "flask-worker", + "version": "0.0.0", + "private": true, + "scripts": { + "deploy": "uv run pywrangler deploy", + "dev": "uv run pywrangler dev", + "start": "uv run pywrangler dev" + }, + "devDependencies": { + "wrangler": "^4.46.0" + }, + "dependencies": { + "workerd": "^1.20260505.1" + } +} diff --git a/20-flask/pyproject.toml b/20-flask/pyproject.toml new file mode 100644 index 0000000..e59af12 --- /dev/null +++ b/20-flask/pyproject.toml @@ -0,0 +1,31 @@ +[project] +name = "flask-worker" +version = "0.1.0" +description = "Python flask example" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "webtypy>=0.1.7", + "flask", + # Local workers-runtime-sdk checkout (provides wsgi.py), built from source. + # pywrangler resolves the worker env from [project.dependencies] via + # `uv pip compile`, which ignores [tool.uv.sources] — so the local path must + # live directly in this string. Building a directory source requires the + # `[tool.pywrangler] allow-build` override below (turns off `--no-build`). + "workers-runtime-sdk @ file:///home/hood/Documents/programming/workers-py/packages/runtime-sdk", +] + +[dependency-groups] +dev = [ + "workers-py", +] + +# Use the local workers-py (pywrangler) checkout so the `allow-build` override is +# available. This only affects the host dev tool, not the worker. +[tool.uv.sources] +workers-py = { path = "../../workers-py/packages/cli", editable = true } + +[tool.pywrangler] +# Allow building sdists / local directory sources (drops `uv`'s `--no-build`), +# so the local workers-runtime-sdk directory above builds during sync. +allow-build = true diff --git a/20-flask/src/worker.py b/20-flask/src/worker.py new file mode 100644 index 0000000..adcac57 --- /dev/null +++ b/20-flask/src/worker.py @@ -0,0 +1,26 @@ +import wsgi +from flask import Flask, jsonify, request +from workers import WorkerEntrypoint + + +class Default(WorkerEntrypoint): + async def fetch(self, request): + return await wsgi.fetch(app, request, self.env) + + +app = Flask(__name__) + + +@app.get("/") +def root(): + return jsonify(message="ok") + + +@app.get("/hello/") +def hello(name): + return jsonify(message=f"Hello, {name}!") + + +@app.post("/echo") +def echo(): + return jsonify(received=request.get_json(silent=True), args=request.args.to_dict()) diff --git a/20-flask/wrangler.jsonc b/20-flask/wrangler.jsonc new file mode 100644 index 0000000..84af7e4 --- /dev/null +++ b/20-flask/wrangler.jsonc @@ -0,0 +1,16 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "flask-worker", + "main": "src/worker.py", + "compatibility_date": "2025-11-02", + "compatibility_flags": [ + "python_workers", + "python_process_pth_files" + ], + "vars": { + "MESSAGE": "My env var" + }, + "observability": { + "enabled": true + } +} diff --git a/21-flask-todo-app/.gitignore b/21-flask-todo-app/.gitignore new file mode 100644 index 0000000..34539bc --- /dev/null +++ b/21-flask-todo-app/.gitignore @@ -0,0 +1,15 @@ +# Python environments +.venv/ +.venv-workers/ +__pycache__/ +*.py[cod] + +# Vendored Pyodide wheels, re-created by `pywrangler dev`/`deploy` +python_modules/ + +# Local Wrangler/miniflare state, including the local D1 database +.wrangler/ + +node_modules/ +.dev.vars +.env diff --git a/21-flask-todo-app/README.md b/21-flask-todo-app/README.md new file mode 100644 index 0000000..f527099 --- /dev/null +++ b/21-flask-todo-app/README.md @@ -0,0 +1,46 @@ +# Flask Notes + +Example todo notes app with D1 as the database. + +## How it works + +Uses the `workers.wsgi` adaptor to run flask and `src/d1.py` provides a +synchronous wrapper for d1. + +## Layout + +``` +migrations/0001_create_notes.sql D1 schema +public/ Static frontend (served from the edge) +src/entry.py Worker entrypoint (async → WSGI) +src/app.py Flask app: routes, validation, errors +src/d1.py Synchronous D1 wrapper +wrangler.jsonc Worker config, assets + D1 binding +``` + +## Run locally + +```bash +uv sync +uv run pywrangler d1 migrations apply flask-notes --local +uv run pywrangler dev +``` + + +## Deploy + +1. Create the database and copy the id it prints: + + ```bash + uv run pywrangler d1 create flask-notes + ``` + +2. Put that id in `wrangler.jsonc` under `d1_databases[0].database_id`, + replacing `REPLACE_WITH_YOUR_DATABASE_ID`. + +3. Apply the schema to the real database and deploy: + + ```bash + uv run pywrangler d1 migrations apply flask-notes --remote + uv run pywrangler deploy + ``` diff --git a/21-flask-todo-app/migrations/0001_create_notes.sql b/21-flask-todo-app/migrations/0001_create_notes.sql new file mode 100644 index 0000000..926bee4 --- /dev/null +++ b/21-flask-todo-app/migrations/0001_create_notes.sql @@ -0,0 +1,14 @@ +-- Migration number: 0001 create notes table + +CREATE TABLE IF NOT EXISTS notes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + body TEXT NOT NULL DEFAULT '', + done INTEGER NOT NULL DEFAULT 0 CHECK (done IN (0, 1)), + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) +); + +-- The default listing is "newest first", and the UI filters on `done`. +CREATE INDEX IF NOT EXISTS idx_notes_done_created_at + ON notes (done, created_at DESC); diff --git a/21-flask-todo-app/public/app.js b/21-flask-todo-app/public/app.js new file mode 100644 index 0000000..736da89 --- /dev/null +++ b/21-flask-todo-app/public/app.js @@ -0,0 +1,206 @@ +const $ = (sel) => document.querySelector(sel); + +const els = { + form: $("#create-form"), + title: $("#title"), + body: $("#body"), + list: $("#notes"), + status: $("#status"), + search: $("#search"), + clearDone: $("#clear-done"), + filters: document.querySelectorAll(".filter"), + template: $("#note-template"), +}; + +let filter = "all"; +let search = ""; + +/** Fetch wrapper that surfaces the API's JSON `error` field. */ +async function api(path, options = {}) { + const res = await fetch(path, { + headers: options.body ? { "Content-Type": "application/json" } : {}, + ...options, + }); + if (res.status === 204) return null; + + const data = await res.json().catch(() => null); + if (!res.ok) { + throw new Error(data?.error ?? `Request failed (${res.status})`); + } + return data; +} + +function setStatus(message, isError = false) { + els.status.textContent = message; + els.status.classList.toggle("status--error", isError); +} + +function formatDate(iso) { + // Timestamps are stored as UTC without a timezone-aware parser on the server, + // so normalize to a real Date for locale-aware display. + const date = new Date(iso.endsWith("Z") ? iso : `${iso}Z`); + return Number.isNaN(date.valueOf()) ? iso : date.toLocaleString(); +} + +function renderNote(note) { + const el = els.template.content.firstElementChild.cloneNode(true); + el.dataset.id = note.id; + el.classList.toggle("is-done", note.done); + + const check = el.querySelector(".note__check"); + const title = el.querySelector(".note__title"); + const body = el.querySelector(".note__body"); + + check.checked = note.done; + // textContent (not innerHTML) — note content is untrusted user input. + title.textContent = note.title; + body.textContent = note.body; + title.contentEditable = "plaintext-only"; + body.contentEditable = "plaintext-only"; + el.querySelector(".note__meta").textContent = `Updated ${formatDate(note.updated_at)}`; + + check.addEventListener("change", () => patch(el, { done: check.checked })); + + // Commit inline edits on blur, but only when the text actually changed. + const commit = (field, node, original) => { + const value = node.textContent.trim(); + if (value === original) return; + if (field === "title" && !value) { + node.textContent = original; // titles are required + return; + } + patch(el, { [field]: value }); + }; + + for (const [field, node] of [["title", title], ["body", body]]) { + node.addEventListener("focus", () => { + node.dataset.original = node.textContent.trim(); + }); + node.addEventListener("blur", () => commit(field, node, node.dataset.original)); + node.addEventListener("keydown", (e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + node.blur(); + } else if (e.key === "Escape") { + node.textContent = node.dataset.original; + node.blur(); + } + }); + } + + el.querySelector(".note__delete").addEventListener("click", () => remove(el)); + return el; +} + +function render(notes) { + els.list.replaceChildren(...notes.map(renderNote)); + + if (notes.length === 0) { + const empty = document.createElement("li"); + empty.className = "empty"; + empty.textContent = search + ? `No notes match “${search}”.` + : filter === "done" + ? "Nothing completed yet." + : filter === "active" + ? "All done. Nice." + : "No notes yet — add one above."; + els.list.replaceChildren(empty); + } + + const remaining = notes.filter((n) => !n.done).length; + setStatus( + notes.length === 0 + ? "" + : `${notes.length} note${notes.length === 1 ? "" : "s"} · ${remaining} active`, + ); + els.clearDone.hidden = !notes.some((n) => n.done); +} + +async function load() { + const params = new URLSearchParams({ filter }); + if (search) params.set("q", search); + try { + const { notes } = await api(`/api/notes?${params}`); + render(notes); + } catch (err) { + setStatus(err.message, true); + } +} + +async function patch(el, changes) { + el.classList.add("note--busy"); + try { + await api(`/api/notes/${el.dataset.id}`, { + method: "PATCH", + body: JSON.stringify(changes), + }); + await load(); + } catch (err) { + setStatus(err.message, true); + await load(); // resync so the UI never shows unsaved state + } +} + +async function remove(el) { + el.classList.add("note--busy"); + try { + await api(`/api/notes/${el.dataset.id}`, { method: "DELETE" }); + await load(); + } catch (err) { + setStatus(err.message, true); + el.classList.remove("note--busy"); + } +} + +els.form.addEventListener("submit", async (e) => { + e.preventDefault(); + const title = els.title.value.trim(); + if (!title) return; + + const button = els.form.querySelector("button"); + button.disabled = true; + try { + await api("/api/notes", { + method: "POST", + body: JSON.stringify({ title, body: els.body.value.trim() }), + }); + els.form.reset(); + els.title.focus(); + await load(); + } catch (err) { + setStatus(err.message, true); + } finally { + button.disabled = false; + } +}); + +for (const button of els.filters) { + button.addEventListener("click", () => { + filter = button.dataset.filter; + for (const b of els.filters) b.classList.toggle("is-active", b === button); + load(); + }); +} + +// Debounce search so typing doesn't fire a request per keystroke. +let searchTimer; +els.search.addEventListener("input", () => { + clearTimeout(searchTimer); + searchTimer = setTimeout(() => { + search = els.search.value.trim(); + load(); + }, 200); +}); + +els.clearDone.addEventListener("click", async () => { + if (!confirm("Delete all completed notes?")) return; + try { + await api("/api/notes/delete_completed", { method: "DELETE" }); + await load(); + } catch (err) { + setStatus(err.message, true); + } +}); + +load(); diff --git a/21-flask-todo-app/public/index.html b/21-flask-todo-app/public/index.html new file mode 100644 index 0000000..b014358 --- /dev/null +++ b/21-flask-todo-app/public/index.html @@ -0,0 +1,79 @@ + + + + + + Notes + + + + +
+
+

Notes

+

Flask on a Python Worker, backed by D1.

+
+ +
+ + +
+ +
+
+ +
+
+ + + +
+ + +
+ +

+
    +
    + + + + + + diff --git a/21-flask-todo-app/public/style.css b/21-flask-todo-app/public/style.css new file mode 100644 index 0000000..4ef8b90 --- /dev/null +++ b/21-flask-todo-app/public/style.css @@ -0,0 +1,310 @@ +:root { + --bg: #f6f7f9; + --surface: #ffffff; + --border: #e3e6ea; + --text: #16191d; + --muted: #6b7280; + --accent: #f6821f; + --accent-text: #ffffff; + --danger: #dc2626; + --radius: 10px; + --shadow: 0 1px 2px rgb(16 24 40 / 6%), 0 1px 3px rgb(16 24 40 / 4%); +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #14161a; + --surface: #1c1f24; + --border: #2c3138; + --text: #e8eaed; + --muted: #9aa1ab; + --shadow: none; + } +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + padding: 2.5rem 1rem 4rem; + background: var(--bg); + color: var(--text); + font: 15px/1.55 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, + Helvetica, Arial, sans-serif; +} + +.app { + max-width: 42rem; + margin: 0 auto; +} + +.app__header { + margin-bottom: 1.5rem; +} + +.app__header h1 { + margin: 0; + font-size: 1.75rem; + letter-spacing: -0.02em; +} + +.app__subtitle { + margin: 0.25rem 0 0; + color: var(--muted); + font-size: 0.875rem; +} + +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); +} + +/* ---------- composer ---------- */ + +.composer { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.875rem; +} + +.composer__title, +.composer__body, +.search { + width: 100%; + padding: 0.5rem 0.625rem; + border: 1px solid transparent; + border-radius: 8px; + background: transparent; + color: inherit; + font: inherit; + resize: vertical; +} + +.composer__title { + font-size: 1rem; + font-weight: 600; +} + +.composer__title:focus, +.composer__body:focus, +.search:focus { + outline: none; + border-color: var(--accent); +} + +.composer__actions { + display: flex; + justify-content: flex-end; +} + +/* ---------- buttons ---------- */ + +.btn { + padding: 0.4rem 0.85rem; + border: 1px solid var(--border); + border-radius: 7px; + background: var(--surface); + color: var(--text); + font: inherit; + font-size: 0.875rem; + cursor: pointer; +} + +.btn:hover:not(:disabled) { + border-color: var(--muted); +} + +.btn:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.btn--primary { + background: var(--accent); + border-color: var(--accent); + color: var(--accent-text); + font-weight: 600; +} + +.btn--ghost { + color: var(--muted); +} + +.btn--icon { + padding: 0; + width: 1.75rem; + height: 1.75rem; + border-color: transparent; + background: transparent; + color: var(--muted); + font-size: 1.25rem; + line-height: 1; + opacity: 0; + transition: opacity 0.12s; +} + +.note:hover .btn--icon, +.btn--icon:focus-visible { + opacity: 1; +} + +.btn--icon:hover { + color: var(--danger); +} + +/* ---------- toolbar ---------- */ + +.toolbar { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 1.25rem 0 0.75rem; +} + +.filters { + display: flex; + gap: 0.125rem; + padding: 0.125rem; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); +} + +.filter { + padding: 0.3rem 0.7rem; + border: none; + border-radius: 6px; + background: transparent; + color: var(--muted); + font: inherit; + font-size: 0.8125rem; + cursor: pointer; +} + +.filter.is-active { + background: var(--bg); + color: var(--text); + font-weight: 600; +} + +.search { + flex: 1; + min-width: 0; + border-color: var(--border); + background: var(--surface); +} + +/* On narrow screens all three controls on one row squash the search box down + to a few characters, so give it a row of its own. Must come after the + `.search` rule above, whose `flex` shorthand would otherwise reset + `flex-basis`. */ +@media (max-width: 32rem) { + .toolbar { + flex-wrap: wrap; + } + + .search { + order: 1; + flex-basis: 100%; + } +} + +/* ---------- notes ---------- */ + +.status { + min-height: 1.25rem; + margin: 0 0 0.5rem; + color: var(--muted); + font-size: 0.8125rem; +} + +.status--error { + color: var(--danger); +} + +.notes { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin: 0; + padding: 0; + list-style: none; +} + +.note { + display: flex; + align-items: flex-start; + gap: 0.7rem; + padding: 0.75rem 0.875rem; +} + +.note--busy { + opacity: 0.5; + pointer-events: none; +} + +.note__check { + flex: none; + width: 1.05rem; + height: 1.05rem; + margin-top: 0.2rem; + accent-color: var(--accent); + cursor: pointer; +} + +.note__main { + flex: 1; + min-width: 0; +} + +.note__title, +.note__body { + margin: 0; + padding: 0.1rem 0.25rem; + border-radius: 5px; + overflow-wrap: anywhere; + white-space: pre-wrap; +} + +.note__title { + font-weight: 600; +} + +.note__title:focus, +.note__body:focus { + outline: 2px solid var(--accent); +} + +.note__body { + color: var(--muted); + font-size: 0.875rem; +} + +.note__body:empty { + display: none; +} + +.note__meta { + display: block; + padding-left: 0.25rem; + margin-top: 0.2rem; + color: var(--muted); + font-size: 0.75rem; +} + +.note.is-done .note__title, +.note.is-done .note__body { + text-decoration: line-through; + color: var(--muted); +} + +.empty { + padding: 2.5rem 1rem; + color: var(--muted); + text-align: center; +} diff --git a/21-flask-todo-app/pyproject.toml b/21-flask-todo-app/pyproject.toml new file mode 100644 index 0000000..5484f10 --- /dev/null +++ b/21-flask-todo-app/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "flask-notes-worker" +version = "0.1.0" +description = "Todo notes app on a Python Cloudflare Worker using Flask and D1" +requires-python = ">=3.13" +dependencies = [ + "flask", +] + +[dependency-groups] +dev = [ + "workers-py", + "workers-runtime-sdk", +] diff --git a/21-flask-todo-app/src/app.py b/21-flask-todo-app/src/app.py new file mode 100644 index 0000000..b6cd85c --- /dev/null +++ b/21-flask-todo-app/src/app.py @@ -0,0 +1,229 @@ +"""Flask JSON API for the notes app, backed directly by D1.""" + +from flask import Flask, g, jsonify, request +from werkzeug.exceptions import HTTPException + +from d1 import D1, D1Error + +app = Flask(__name__) +# Preserve our own key ordering in JSON responses rather than sorting them. +app.json.sort_keys = False + +MAX_TITLE_LEN = 200 +MAX_BODY_LEN = 10_000 +VALID_FILTERS = ("all", "active", "done") + + +def get_db() -> D1: + """Return the D1 wrapper for the current request.""" + if "db" not in g: + # Cache on g for the lifetime of the request + g.db = D1(request.environ["workers.env"].DB) + return g.db + + +# -------------------------------------------------------------------------- +# Serialization / validation +# -------------------------------------------------------------------------- + + +def serialize(row: dict) -> dict: + """Convert a D1 row into the JSON shape the frontend expects.""" + return { + "id": row["id"], + "title": row["title"], + "body": row["body"], + # SQLite has no boolean type; `done` round-trips as 0/1. + "done": bool(row["done"]), + "created_at": row["created_at"], + "updated_at": row["updated_at"], + } + + +class ValidationError(ValueError): + """Raised when a request payload is malformed.""" + + +def _payload() -> dict: + data = request.get_json(silent=True) + if not isinstance(data, dict): + raise ValidationError("Request body must be a JSON object.") + return data + + +def _clean_title(value) -> str: + if not isinstance(value, str): + raise ValidationError("'title' must be a string.") + title = value.strip() + if not title: + raise ValidationError("'title' must not be empty.") + if len(title) > MAX_TITLE_LEN: + raise ValidationError(f"'title' must be at most {MAX_TITLE_LEN} characters.") + return title + + +def _clean_body(value) -> str: + if not isinstance(value, str): + raise ValidationError("'body' must be a string.") + body = value.strip() + if len(body) > MAX_BODY_LEN: + raise ValidationError(f"'body' must be at most {MAX_BODY_LEN} characters.") + return body + + +def _clean_done(value) -> int: + if not isinstance(value, bool): + raise ValidationError("'done' must be a boolean.") + return int(value) + + +# -------------------------------------------------------------------------- +# Routes +# -------------------------------------------------------------------------- + + +@app.get("/api/notes") +def list_notes(): + """List notes, newest first, with optional status filter and search.""" + status = request.args.get("filter", "all") + if status not in VALID_FILTERS: + raise ValidationError(f"'filter' must be one of: {', '.join(VALID_FILTERS)}.") + search = request.args.get("q", "").strip() + + # Tie-break on id so ordering is stable for notes created in the same second. + sql = "SELECT * FROM notes {where} ORDER BY done ASC, created_at DESC, id DESC" + where_clauses, params = [], [] + + if status == "active": + where_clauses.append("done = 0") + elif status == "done": + where_clauses.append("done = 1") + + if search: + # Escape LIKE wildcards so a literal % or _ in the query is matched + # literally. SQLite only honours the escape character when an explicit + # ESCAPE clause is given. + escaped = search.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + where_clauses.append(r"(title LIKE ?1 ESCAPE '\' OR body LIKE ?1 ESCAPE '\')") + params.append(f"%{escaped}%") + + # Every element of `where_clauses` is a hardcoded literal; the search term + # itself travels in `params` and is bound to `?1`. `status` is only ever + # compared against VALID_FILTERS, never concatenated into the SQL. + if where_clauses: + where = " WHERE " + " AND ".join(where_clauses) + else: + where = "" + + sql = sql.format(where=where) + + rows = get_db().query(sql, params) + return jsonify({"notes": [serialize(r) for r in rows]}) + + +@app.get("/api/notes/") +def get_note(note_id: int): + row = get_db().first("SELECT * FROM notes WHERE id = ?", (note_id,)) + if row is None: + return jsonify({"error": "Note not found."}), 404 + return jsonify(serialize(row)) + + +@app.post("/api/notes") +def create_note(): + data = _payload() + if "title" not in data: + raise ValidationError("'title' is required.") + title = _clean_title(data["title"]) + body = _clean_body(data.get("body", "")) + + # RETURNING to insert and read the stored row in a single round trip. + row = get_db().first( + "INSERT INTO notes (title, body) VALUES (?, ?) RETURNING *", + (title, body), + ) + return jsonify(serialize(row)), 201 + + +@app.patch("/api/notes/") +def update_note(note_id: int): + data = _payload() + + updates, params = [], [] + if "title" in data: + updates.append("title = ?") + params.append(_clean_title(data["title"])) + if "body" in data: + updates.append("body = ?") + params.append(_clean_body(data["body"])) + if "done" in data: + updates.append("done = ?") + params.append(_clean_done(data["done"])) + + if not updates: + raise ValidationError("Provide at least one of: 'title', 'body', 'done'.") + + updates.append("updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')") + params.append(note_id) + + # The f-string interpolates the updates fragments, every one of which is a + # hardcoded literal from the branches above so this expands to one of seven + # fixed statements. Values are not interpolated. + row = get_db().first( + f"UPDATE notes SET {', '.join(updates)} WHERE id = ? RETURNING *", + params, + ) + if row is None: + return jsonify({"error": "Note not found."}), 404 + return jsonify(serialize(row)) + + +@app.delete("/api/notes/") +def delete_note(note_id: int): + meta = get_db().run("DELETE FROM notes WHERE id = ?", (note_id,)) + if not meta.get("changes"): + return jsonify({"error": "Note not found."}), 404 + return "", 204 + + +@app.delete("/api/notes/delete_completed") +def delete_completed(): + """Bulk-delete every completed note.""" + meta = get_db().run("DELETE FROM notes WHERE done = 1") + return jsonify({"deleted": meta.get("changes", 0)}) + + +@app.get("/api/health") +def health(): + """Confirms the Worker is up and the D1 binding actually responds.""" + get_db().first("SELECT 1 AS ok") + return jsonify({"status": "ok"}) + + +# -------------------------------------------------------------------------- +# Error handling +# -------------------------------------------------------------------------- + + +@app.errorhandler(ValidationError) +def handle_validation_error(exc: ValidationError): + return jsonify({"error": str(exc)}), 400 + + +@app.errorhandler(D1Error) +def handle_d1_error(exc: D1Error): + app.logger.exception("D1 query failed") + return jsonify({"error": "Database error.", "detail": str(exc)}), 500 + + +@app.errorhandler(HTTPException) +def handle_http_error(exc: HTTPException): + """Render Werkzeug's HTTP errors as JSON instead of HTML.""" + concise = {404: "Not found.", 405: "Method not allowed."} + return jsonify({"error": concise.get(exc.code, exc.description)}), exc.code + + +@app.errorhandler(Exception) +def handle_unexpected(exc: Exception): + app.logger.exception("Unhandled error") + return jsonify({"error": "Internal server error."}), 500 diff --git a/21-flask-todo-app/src/d1.py b/21-flask-todo-app/src/d1.py new file mode 100644 index 0000000..2f9b8d2 --- /dev/null +++ b/21-flask-todo-app/src/d1.py @@ -0,0 +1,65 @@ +"""Synchronous D1 access for WSGI (Flask) code running on a Python Worker. + +The D1 binding is a JavaScript object whose methods return promises. Flask is a +WSGI framework, so its view functions are plain synchronous Python and cannot +`await`. We bridge the two with `pyodide.ffi.run_sync`, which suspends the +Python stack until a JS promise settles, using JSPI (JavaScript Promise +Integration) stack switching. + +`run_sync` only works while a JSPI suspender is on the stack. `workers.wsgi.fetch` +invokes the WSGI app from inside the Worker's async `fetch` handler, so that +condition holds for every request routed through `src/entry.py`. + +The Workers SDK already converts D1's JS return values into Python objects: +`.all()` yields a mapping whose `results` key is a `list` of dict-like rows, and +`.first()` yields a dict-like row or `None`. No explicit `to_py()` is needed; +this module just normalizes them to plain `dict`s. +""" + +from pyodide.ffi import run_sync + + +class D1Error(RuntimeError): + """A D1 query failed. Wraps the underlying error message.""" + + +class D1: + """A thin synchronous wrapper around a D1 binding.""" + + def __init__(self, binding): + self._db = binding + + def _prepare(self, sql, params=()): + stmt = self._db.prepare(sql) + if params: + # `bind` is variadic on the JS side. str/int/float/bool/None all + # convert to D1-compatible values automatically. + stmt = stmt.bind(*params) + return stmt + + def _run_sync(self, promise, sql): + try: + return run_sync(promise) + except Exception as exc: + # D1 surfaces SQL errors as JS exceptions; re-raise as a Python + # error the Flask handler can turn into a 500. + raise D1Error(f"{exc} (query: {sql})") from exc + + def query(self, sql, params=()) -> list[dict]: + """Run a SELECT and return all rows as a list of plain dicts.""" + result = self._run_sync(self._prepare(sql, params).all(), sql) + return [dict(row) for row in result["results"]] + + def first(self, sql, params=()) -> dict | None: + """Run a statement and return its first row as a dict, or None.""" + row = self._run_sync(self._prepare(sql, params).first(), sql) + return dict(row) if row is not None else None + + def run(self, sql, params=()) -> dict: + """Run a write statement and return its metadata. + + The returned dict includes `changes`, `last_row_id`, `rows_read` and + `rows_written`. + """ + result = self._run_sync(self._prepare(sql, params).run(), sql) + return dict(result["meta"]) diff --git a/21-flask-todo-app/src/entry.py b/21-flask-todo-app/src/entry.py new file mode 100644 index 0000000..70c8d00 --- /dev/null +++ b/21-flask-todo-app/src/entry.py @@ -0,0 +1,8 @@ +from workers import WorkerEntrypoint, wsgi + +from app import app + + +class Default(WorkerEntrypoint): + async def fetch(self, request): + return await wsgi.fetch(app, request, self.env) diff --git a/21-flask-todo-app/wrangler.jsonc b/21-flask-todo-app/wrangler.jsonc new file mode 100644 index 0000000..285b75f --- /dev/null +++ b/21-flask-todo-app/wrangler.jsonc @@ -0,0 +1,25 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "flask-notes-worker", + "main": "src/entry.py", + "compatibility_date": "2026-08-06", + "compatibility_flags": ["python_workers"], + "observability": { + "enabled": true + }, + // The frontend is served straight from Cloudflare's edge. Requests that do + // not match a file in ./public (e.g. /api/*) fall through to the Worker. + "assets": { + "directory": "./public/" + }, + "d1_databases": [ + { + "binding": "DB", + "database_name": "flask-notes", + // Replace with the id printed by `wrangler d1 create flask-notes`. + // Local development (`pywrangler dev`) works with the placeholder. + "database_id": "REPLACE_WITH_YOUR_DATABASE_ID", + "migrations_dir": "migrations" + } + ] +} From 15e161fc026996c6bd4ea1259fc3cbf2583dc1cc Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 19:45:00 +0000 Subject: [PATCH 2/2] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- 21-flask-todo-app/src/app.py | 3 +-- 21-flask-todo-app/src/entry.py | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/21-flask-todo-app/src/app.py b/21-flask-todo-app/src/app.py index b6cd85c..a01e3ac 100644 --- a/21-flask-todo-app/src/app.py +++ b/21-flask-todo-app/src/app.py @@ -1,10 +1,9 @@ """Flask JSON API for the notes app, backed directly by D1.""" +from d1 import D1, D1Error from flask import Flask, g, jsonify, request from werkzeug.exceptions import HTTPException -from d1 import D1, D1Error - app = Flask(__name__) # Preserve our own key ordering in JSON responses rather than sorting them. app.json.sort_keys = False diff --git a/21-flask-todo-app/src/entry.py b/21-flask-todo-app/src/entry.py index 70c8d00..1a2f1e0 100644 --- a/21-flask-todo-app/src/entry.py +++ b/21-flask-todo-app/src/entry.py @@ -1,6 +1,5 @@ -from workers import WorkerEntrypoint, wsgi - from app import app +from workers import WorkerEntrypoint, wsgi class Default(WorkerEntrypoint):