From 4318b4adefa50541192d2b9096397b40dd947894 Mon Sep 17 00:00:00 2001 From: Hood Chatham Date: Thu, 6 Aug 2026 12:44:23 -0700 Subject: [PATCH 1/4] Add flask hello world and todo app --- 17-flask/README.md | 14 + 17-flask/package.json | 16 + 17-flask/pyproject.toml | 14 + 17-flask/src/worker.py | 25 ++ 17-flask/wrangler.jsonc | 15 + 18-flask-todo-app/.gitignore | 15 + 18-flask-todo-app/README.md | 46 +++ .../migrations/0001_create_notes.sql | 14 + 18-flask-todo-app/public/app.js | 206 ++++++++++++ 18-flask-todo-app/public/index.html | 79 +++++ 18-flask-todo-app/public/style.css | 310 ++++++++++++++++++ 18-flask-todo-app/pyproject.toml | 14 + 18-flask-todo-app/src/app.py | 229 +++++++++++++ 18-flask-todo-app/src/d1.py | 65 ++++ 18-flask-todo-app/src/entry.py | 8 + 18-flask-todo-app/wrangler.jsonc | 25 ++ 16 files changed, 1095 insertions(+) create mode 100644 17-flask/README.md create mode 100644 17-flask/package.json create mode 100644 17-flask/pyproject.toml create mode 100644 17-flask/src/worker.py create mode 100644 17-flask/wrangler.jsonc create mode 100644 18-flask-todo-app/.gitignore create mode 100644 18-flask-todo-app/README.md create mode 100644 18-flask-todo-app/migrations/0001_create_notes.sql create mode 100644 18-flask-todo-app/public/app.js create mode 100644 18-flask-todo-app/public/index.html create mode 100644 18-flask-todo-app/public/style.css create mode 100644 18-flask-todo-app/pyproject.toml create mode 100644 18-flask-todo-app/src/app.py create mode 100644 18-flask-todo-app/src/d1.py create mode 100644 18-flask-todo-app/src/entry.py create mode 100644 18-flask-todo-app/wrangler.jsonc diff --git a/17-flask/README.md b/17-flask/README.md new file mode 100644 index 0000000..2bdd5fc --- /dev/null +++ b/17-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/17-flask/package.json b/17-flask/package.json new file mode 100644 index 0000000..d78d3d1 --- /dev/null +++ b/17-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.114.0" + }, + "dependencies": { + "workerd": "^1.20260505.1" + } +} diff --git a/17-flask/pyproject.toml b/17-flask/pyproject.toml new file mode 100644 index 0000000..0ca7e25 --- /dev/null +++ b/17-flask/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "flask-worker" +version = "0.1.0" +description = "Python flask example" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "flask", +] + +[dependency-groups] +dev = [ + "workers-py", +] diff --git a/17-flask/src/worker.py b/17-flask/src/worker.py new file mode 100644 index 0000000..adc08fa --- /dev/null +++ b/17-flask/src/worker.py @@ -0,0 +1,25 @@ +from flask import Flask, jsonify, request +from workers import WorkerEntrypoint, wsgi + + +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/17-flask/wrangler.jsonc b/17-flask/wrangler.jsonc new file mode 100644 index 0000000..bc3739d --- /dev/null +++ b/17-flask/wrangler.jsonc @@ -0,0 +1,15 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "flask-worker", + "main": "src/worker.py", + "compatibility_date": "2026-06-01", + "compatibility_flags": [ + "python_workers" + ], + "vars": { + "MESSAGE": "My env var" + }, + "observability": { + "enabled": true + } +} diff --git a/18-flask-todo-app/.gitignore b/18-flask-todo-app/.gitignore new file mode 100644 index 0000000..34539bc --- /dev/null +++ b/18-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/18-flask-todo-app/README.md b/18-flask-todo-app/README.md new file mode 100644 index 0000000..f527099 --- /dev/null +++ b/18-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/18-flask-todo-app/migrations/0001_create_notes.sql b/18-flask-todo-app/migrations/0001_create_notes.sql new file mode 100644 index 0000000..926bee4 --- /dev/null +++ b/18-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/18-flask-todo-app/public/app.js b/18-flask-todo-app/public/app.js new file mode 100644 index 0000000..736da89 --- /dev/null +++ b/18-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/18-flask-todo-app/public/index.html b/18-flask-todo-app/public/index.html new file mode 100644 index 0000000..b014358 --- /dev/null +++ b/18-flask-todo-app/public/index.html @@ -0,0 +1,79 @@ + + + + + + Notes + + + + +
+
+

Notes

+

Flask on a Python Worker, backed by D1.

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

+
    +
    + + + + + + diff --git a/18-flask-todo-app/public/style.css b/18-flask-todo-app/public/style.css new file mode 100644 index 0000000..4ef8b90 --- /dev/null +++ b/18-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/18-flask-todo-app/pyproject.toml b/18-flask-todo-app/pyproject.toml new file mode 100644 index 0000000..5484f10 --- /dev/null +++ b/18-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/18-flask-todo-app/src/app.py b/18-flask-todo-app/src/app.py new file mode 100644 index 0000000..b6cd85c --- /dev/null +++ b/18-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/18-flask-todo-app/src/d1.py b/18-flask-todo-app/src/d1.py new file mode 100644 index 0000000..2f9b8d2 --- /dev/null +++ b/18-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/18-flask-todo-app/src/entry.py b/18-flask-todo-app/src/entry.py new file mode 100644 index 0000000..70c8d00 --- /dev/null +++ b/18-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/18-flask-todo-app/wrangler.jsonc b/18-flask-todo-app/wrangler.jsonc new file mode 100644 index 0000000..285b75f --- /dev/null +++ b/18-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 c7cf003cdfd1ab7956f633bc3b6049148eb9802f Mon Sep 17 00:00:00 2001 From: Hood Chatham Date: Fri, 7 Aug 2026 11:20:56 -0700 Subject: [PATCH 2/4] Inline d1 calls --- 18-flask-todo-app/src/app.py | 55 +++++++++++++++++++++--------- 18-flask-todo-app/src/d1.py | 65 ------------------------------------ 2 files changed, 39 insertions(+), 81 deletions(-) delete mode 100644 18-flask-todo-app/src/d1.py diff --git a/18-flask-todo-app/src/app.py b/18-flask-todo-app/src/app.py index b6cd85c..720224e 100644 --- a/18-flask-todo-app/src/app.py +++ b/18-flask-todo-app/src/app.py @@ -1,10 +1,10 @@ """Flask JSON API for the notes app, backed directly by D1.""" +from asyncio import get_event_loop + 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 @@ -14,14 +14,27 @@ VALID_FILTERS = ("all", "active", "done") -def get_db() -> D1: +class D1Error(RuntimeError): + """A D1 query failed. Wraps the underlying error message.""" + + +def get_db(): """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) + g.db = request.environ["workers.env"].DB return g.db +def run_db(task): + try: + return get_event_loop().run_until_complete(task) + except Exception as exc: + # D1 surfaces SQL errors as JS exceptions; re-raise as a Python error so + # we can register a custom error handler. + raise D1Error(f"{exc}") from exc + + # -------------------------------------------------------------------------- # Serialization / validation # -------------------------------------------------------------------------- @@ -117,13 +130,15 @@ def list_notes(): sql = sql.format(where=where) - rows = get_db().query(sql, params) - return jsonify({"notes": [serialize(r) for r in rows]}) + rows = run_db(get_db().prepare(sql).bind(*params).all())["results"] + return jsonify({"notes": [serialize(dict(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,)) + row = run_db( + get_db().prepare("SELECT * FROM notes WHERE id = ?").bind(note_id).first() + ) if row is None: return jsonify({"error": "Note not found."}), 404 return jsonify(serialize(row)) @@ -138,9 +153,13 @@ def create_note(): 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), + row = run_db( + get_db() + .prepare( + "INSERT INTO notes (title, body) VALUES (?, ?) RETURNING *", + ) + .bind(title, body) + .first() ) return jsonify(serialize(row)), 201 @@ -169,9 +188,11 @@ def update_note(note_id: int): # 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, + row = run_db( + get_db() + .prepare(f"UPDATE notes SET {', '.join(updates)} WHERE id = ? RETURNING *") + .bind(*params) + .first() ) if row is None: return jsonify({"error": "Note not found."}), 404 @@ -180,7 +201,9 @@ def update_note(note_id: int): @app.delete("/api/notes/") def delete_note(note_id: int): - meta = get_db().run("DELETE FROM notes WHERE id = ?", (note_id,)) + meta = run_db( + get_db().prepare("DELETE FROM notes WHERE id = ?").bind(note_id).run() + )["meta"] if not meta.get("changes"): return jsonify({"error": "Note not found."}), 404 return "", 204 @@ -189,14 +212,14 @@ def delete_note(note_id: int): @app.delete("/api/notes/delete_completed") def delete_completed(): """Bulk-delete every completed note.""" - meta = get_db().run("DELETE FROM notes WHERE done = 1") + meta = run_db(get_db().prepare("DELETE FROM notes WHERE done = 1").run())["meta"] 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") + run_db(get_db().prepare("SELECT 1 AS ok").first()) return jsonify({"status": "ok"}) diff --git a/18-flask-todo-app/src/d1.py b/18-flask-todo-app/src/d1.py deleted file mode 100644 index 2f9b8d2..0000000 --- a/18-flask-todo-app/src/d1.py +++ /dev/null @@ -1,65 +0,0 @@ -"""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"]) From 98823685031955f9a123cbf9f57dd1030912f43f Mon Sep 17 00:00:00 2001 From: Hood Chatham Date: Fri, 7 Aug 2026 12:23:32 -0700 Subject: [PATCH 3/4] Fix numbers --- {17-flask => 18-flask}/README.md | 0 {17-flask => 18-flask}/package.json | 0 {17-flask => 18-flask}/pyproject.toml | 0 {17-flask => 18-flask}/src/worker.py | 0 {17-flask => 18-flask}/wrangler.jsonc | 0 {18-flask-todo-app => 19-flask-todo-app}/.gitignore | 0 {18-flask-todo-app => 19-flask-todo-app}/README.md | 0 .../migrations/0001_create_notes.sql | 0 {18-flask-todo-app => 19-flask-todo-app}/public/app.js | 0 {18-flask-todo-app => 19-flask-todo-app}/public/index.html | 0 {18-flask-todo-app => 19-flask-todo-app}/public/style.css | 0 {18-flask-todo-app => 19-flask-todo-app}/pyproject.toml | 0 {18-flask-todo-app => 19-flask-todo-app}/src/app.py | 0 {18-flask-todo-app => 19-flask-todo-app}/src/entry.py | 0 {18-flask-todo-app => 19-flask-todo-app}/wrangler.jsonc | 0 15 files changed, 0 insertions(+), 0 deletions(-) rename {17-flask => 18-flask}/README.md (100%) rename {17-flask => 18-flask}/package.json (100%) rename {17-flask => 18-flask}/pyproject.toml (100%) rename {17-flask => 18-flask}/src/worker.py (100%) rename {17-flask => 18-flask}/wrangler.jsonc (100%) rename {18-flask-todo-app => 19-flask-todo-app}/.gitignore (100%) rename {18-flask-todo-app => 19-flask-todo-app}/README.md (100%) rename {18-flask-todo-app => 19-flask-todo-app}/migrations/0001_create_notes.sql (100%) rename {18-flask-todo-app => 19-flask-todo-app}/public/app.js (100%) rename {18-flask-todo-app => 19-flask-todo-app}/public/index.html (100%) rename {18-flask-todo-app => 19-flask-todo-app}/public/style.css (100%) rename {18-flask-todo-app => 19-flask-todo-app}/pyproject.toml (100%) rename {18-flask-todo-app => 19-flask-todo-app}/src/app.py (100%) rename {18-flask-todo-app => 19-flask-todo-app}/src/entry.py (100%) rename {18-flask-todo-app => 19-flask-todo-app}/wrangler.jsonc (100%) diff --git a/17-flask/README.md b/18-flask/README.md similarity index 100% rename from 17-flask/README.md rename to 18-flask/README.md diff --git a/17-flask/package.json b/18-flask/package.json similarity index 100% rename from 17-flask/package.json rename to 18-flask/package.json diff --git a/17-flask/pyproject.toml b/18-flask/pyproject.toml similarity index 100% rename from 17-flask/pyproject.toml rename to 18-flask/pyproject.toml diff --git a/17-flask/src/worker.py b/18-flask/src/worker.py similarity index 100% rename from 17-flask/src/worker.py rename to 18-flask/src/worker.py diff --git a/17-flask/wrangler.jsonc b/18-flask/wrangler.jsonc similarity index 100% rename from 17-flask/wrangler.jsonc rename to 18-flask/wrangler.jsonc diff --git a/18-flask-todo-app/.gitignore b/19-flask-todo-app/.gitignore similarity index 100% rename from 18-flask-todo-app/.gitignore rename to 19-flask-todo-app/.gitignore diff --git a/18-flask-todo-app/README.md b/19-flask-todo-app/README.md similarity index 100% rename from 18-flask-todo-app/README.md rename to 19-flask-todo-app/README.md diff --git a/18-flask-todo-app/migrations/0001_create_notes.sql b/19-flask-todo-app/migrations/0001_create_notes.sql similarity index 100% rename from 18-flask-todo-app/migrations/0001_create_notes.sql rename to 19-flask-todo-app/migrations/0001_create_notes.sql diff --git a/18-flask-todo-app/public/app.js b/19-flask-todo-app/public/app.js similarity index 100% rename from 18-flask-todo-app/public/app.js rename to 19-flask-todo-app/public/app.js diff --git a/18-flask-todo-app/public/index.html b/19-flask-todo-app/public/index.html similarity index 100% rename from 18-flask-todo-app/public/index.html rename to 19-flask-todo-app/public/index.html diff --git a/18-flask-todo-app/public/style.css b/19-flask-todo-app/public/style.css similarity index 100% rename from 18-flask-todo-app/public/style.css rename to 19-flask-todo-app/public/style.css diff --git a/18-flask-todo-app/pyproject.toml b/19-flask-todo-app/pyproject.toml similarity index 100% rename from 18-flask-todo-app/pyproject.toml rename to 19-flask-todo-app/pyproject.toml diff --git a/18-flask-todo-app/src/app.py b/19-flask-todo-app/src/app.py similarity index 100% rename from 18-flask-todo-app/src/app.py rename to 19-flask-todo-app/src/app.py diff --git a/18-flask-todo-app/src/entry.py b/19-flask-todo-app/src/entry.py similarity index 100% rename from 18-flask-todo-app/src/entry.py rename to 19-flask-todo-app/src/entry.py diff --git a/18-flask-todo-app/wrangler.jsonc b/19-flask-todo-app/wrangler.jsonc similarity index 100% rename from 18-flask-todo-app/wrangler.jsonc rename to 19-flask-todo-app/wrangler.jsonc From b47e4e8a1a020bf5e14bc79ff49149e83b6ae3fb Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:29:50 +0000 Subject: [PATCH 4/4] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- 19-flask-todo-app/src/entry.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/19-flask-todo-app/src/entry.py b/19-flask-todo-app/src/entry.py index 70c8d00..1a2f1e0 100644 --- a/19-flask-todo-app/src/entry.py +++ b/19-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):