Skip to content

Commit e8a1357

Browse files
committed
Add flask todo parallel to fastapi-todo
1 parent 1a2e99f commit e8a1357

8 files changed

Lines changed: 306 additions & 2 deletions

File tree

README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,12 @@ Need to deploy your Worker to Cloudflare? Python Workers are in open beta and ha
3232
- [**`chatroom/`**](chatroom) - A real-time chatroom using WebSocket.
3333
- [**`sync-http-clients/`**](sync-http-clients) — demonstrates outbound HTTP with synchronous Python clients (`requests`, `urllib3`, and `httpx.Client`).
3434
- [**`dynamic-py-py/`**](dynamic-py-py) — shows how to load and run a Python Worker dynamically at runtime using a [Worker Loader](https://developers.cloudflare.com/workers/runtime-apis/bindings/worker-loader/) binding.
35-
- [**`django/`**](django) — runs a naive Django WSGI application directly on Python Workers.
36-
- [**`django-todo-d1/`**](django-todo-d1) — implements the Todo-Backend API with Django and D1.
3735
- [**`image-redraw/`**](image-redraw) — an example that combines [FastAPI](https://fastapi.tiangolo.com/), [R2](https://developers.cloudflare.com/r2/), [Queues](https://developers.cloudflare.com/queues/), [Workflows](https://developers.cloudflare.com/workflows/) and [Workers AI](https://developers.cloudflare.com/workers-ai/) to redraw uploaded images.
36+
- [**`django/`**](django) — runs a naive Django WSGI application directly on Python Workers.
37+
- [**`django-todo-d1/`**](django-todo-d1) — uses Django with D1 for a basic TODO application.
38+
- [**`fastapi-todo/`**](fastapi-todo) — implements the [Todo-Backend](https://todobackend.com) spec with FastAPI (ASGI) and D1.
39+
- [**`flask-todo/`**](flask-todo) — implements the same [Todo-Backend](https://todobackend.com) API with [Flask](https://flask.palletsprojects.com/) (WSGI) and D1.
40+
3841

3942

4043
## Open Beta and Limits

flask-todo/README.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Flask Todo Backend
2+
3+
A Python Flask implementation of the [Todo-Backend](https://todobackend.com) spec, running on Cloudflare Workers with D1 for storage.
4+
5+
## Development
6+
7+
Initialize the local D1 database and start the dev server:
8+
9+
```sh
10+
uv run pywrangler d1 execute todos --local --file db_init.sql
11+
uv run pywrangler dev
12+
```
13+
14+
## Testing with the Todo-Backend spec runner
15+
16+
Start the dev server, then open the spec runner pointing at your local instance:
17+
18+
```
19+
https://todobackend.com/specs/index.html?http://localhost:8787/todos
20+
```
21+
22+
You can also use the Todo-Backend client app:
23+
24+
```
25+
https://todobackend.com/client/index.html?http://localhost:8787/todos
26+
```
27+
28+
## API
29+
30+
| Method | Path | Description |
31+
| -------- | ---------------- | ------------------ |
32+
| `GET` | `/todos` | List all todos |
33+
| `POST` | `/todos` | Create a todo |
34+
| `DELETE` | `/todos` | Delete all todos |
35+
| `GET` | `/todos/{id}` | Get a single todo |
36+
| `PATCH` | `/todos/{id}` | Update a todo |
37+
| `DELETE` | `/todos/{id}` | Delete a todo |

flask-todo/db_init.sql

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
CREATE TABLE IF NOT EXISTS todos (
2+
id TEXT PRIMARY KEY,
3+
title TEXT NOT NULL DEFAULT '',
4+
completed INTEGER NOT NULL DEFAULT 0,
5+
"order" INTEGER
6+
);

flask-todo/package.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"name": "flask-todo",
3+
"version": "0.0.0",
4+
"private": true,
5+
"scripts": {
6+
"deploy": "uv run pywrangler deploy",
7+
"dev": "uv run pywrangler dev",
8+
"start": "uv run pywrangler dev"
9+
},
10+
"devDependencies": {
11+
"wrangler": "^4.114.0"
12+
}
13+
}

flask-todo/pyproject.toml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
[project]
2+
name = "flask-todo"
3+
version = "0.1.0"
4+
description = "Flask todo backend conforming to the todobackend.com spec"
5+
readme = "README.md"
6+
requires-python = ">=3.12"
7+
dependencies = [
8+
"flask",
9+
"flask-cors",
10+
"workers-runtime-sdk>=1.8.2",
11+
]
12+
13+
[dependency-groups]
14+
dev = [
15+
"workers-py",
16+
"workers-runtime-sdk"
17+
]

flask-todo/src/worker.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import uuid
2+
3+
from flask import Flask, jsonify, request
4+
from flask_cors import CORS
5+
from pyodide.ffi import run_sync
6+
from werkzeug.exceptions import HTTPException
7+
from workers import WorkerEntrypoint, wsgi
8+
9+
app = Flask(__name__)
10+
# Preserve the field order used below rather than sorting keys alphabetically.
11+
app.json.sort_keys = False
12+
13+
# The todobackend.com spec runner calls this Worker from another origin.
14+
CORS(app, origins="*", send_wildcard=True, allow_headers="*", expose_headers="*")
15+
16+
17+
@app.errorhandler(HTTPException)
18+
def _json_error(err: HTTPException):
19+
"""Render Werkzeug's HTTP errors as JSON instead of HTML."""
20+
return jsonify({"detail": err.name}), err.code
21+
22+
23+
def _base_url() -> str:
24+
"""Return the root URL for the todos collection."""
25+
return request.url_root.rstrip("/") + "/todos"
26+
27+
28+
def _row_to_todo(row) -> dict:
29+
"""Convert a D1 row into a todo dict with the absolute ``url`` field."""
30+
return {
31+
"id": row.id,
32+
"title": row.title,
33+
"completed": bool(row.completed),
34+
"order": row.order,
35+
"url": f"{_base_url()}/{row.id}",
36+
}
37+
38+
39+
def _db():
40+
"""Get the D1 database binding from the WSGI environ."""
41+
return request.environ["workers.env"].DB
42+
43+
44+
@app.get("/todos")
45+
def list_todos():
46+
results = run_sync(_db().prepare("SELECT * FROM todos").all())
47+
return jsonify([_row_to_todo(r) for r in results.results])
48+
49+
50+
@app.post("/todos")
51+
def create_todo():
52+
body = request.get_json(silent=True) or {}
53+
todo_id = str(uuid.uuid4())
54+
title = body.get("title", "")
55+
completed = 1 if body.get("completed", False) else 0
56+
order = body.get("order")
57+
58+
run_sync(
59+
_db()
60+
.prepare(
61+
'INSERT INTO todos (id, title, completed, "order") VALUES (?, ?, ?, ?)'
62+
)
63+
.bind(todo_id, title, completed, order)
64+
.run()
65+
)
66+
67+
row = run_sync(
68+
_db().prepare("SELECT * FROM todos WHERE id = ?").bind(todo_id).first()
69+
)
70+
71+
return jsonify(_row_to_todo(row))
72+
73+
74+
@app.delete("/todos")
75+
def delete_all_todos():
76+
run_sync(_db().prepare("DELETE FROM todos").run())
77+
return jsonify([])
78+
79+
80+
@app.get("/todos/<todo_id>")
81+
def get_todo(todo_id: str):
82+
row = run_sync(
83+
_db().prepare("SELECT * FROM todos WHERE id = ?").bind(todo_id).first()
84+
)
85+
if row is None:
86+
return jsonify({"error": "not found"})
87+
return jsonify(_row_to_todo(row))
88+
89+
90+
@app.patch("/todos/<todo_id>")
91+
def update_todo(todo_id: str):
92+
body = request.get_json(silent=True) or {}
93+
sets = []
94+
values = []
95+
if "title" in body:
96+
sets.append("title = ?")
97+
values.append(body["title"])
98+
if "completed" in body:
99+
sets.append("completed = ?")
100+
values.append(1 if body["completed"] else 0)
101+
if "order" in body:
102+
sets.append('"order" = ?')
103+
values.append(body["order"])
104+
105+
if sets:
106+
values.append(todo_id)
107+
run_sync(
108+
_db()
109+
.prepare(f"UPDATE todos SET {', '.join(sets)} WHERE id = ?")
110+
.bind(*values)
111+
.run()
112+
)
113+
114+
row = run_sync(
115+
_db().prepare("SELECT * FROM todos WHERE id = ?").bind(todo_id).first()
116+
)
117+
if row is None:
118+
return jsonify({"error": "not found"})
119+
return jsonify(_row_to_todo(row))
120+
121+
122+
@app.delete("/todos/<todo_id>")
123+
def delete_todo(todo_id: str):
124+
run_sync(_db().prepare("DELETE FROM todos WHERE id = ?").bind(todo_id).run())
125+
return jsonify([])
126+
127+
128+
Default = wsgi.entrypoint(app)

flask-todo/wrangler.jsonc

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"$schema": "node_modules/wrangler/config-schema.json",
3+
"name": "flask-todo",
4+
"main": "src/worker.py",
5+
"compatibility_date": "2026-08-01",
6+
"compatibility_flags": [
7+
"python_workers",
8+
],
9+
"d1_databases": [
10+
{
11+
"binding": "DB",
12+
"database_name": "todos",
13+
"database_id": "00000000-0000-0000-0000-000000000000"
14+
}
15+
],
16+
"observability": {
17+
"enabled": true
18+
}
19+
}

tests/test_examples.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,87 @@ def test_fastapi_todo(init_fastapi_todo_db, dev_server):
256256
assert_todo_backend(dev_server)
257257

258258

259+
@pytest.fixture
260+
def init_flask_todo_db():
261+
subprocess.run(
262+
[
263+
"uv",
264+
"run",
265+
"pywrangler",
266+
"d1",
267+
"execute",
268+
"todos",
269+
"--local",
270+
"--file",
271+
"db_init.sql",
272+
],
273+
cwd=REPO_ROOT / "flask-todo",
274+
check=True,
275+
)
276+
277+
278+
def test_flask_todo(init_flask_todo_db, dev_server):
279+
port = dev_server
280+
base = f"http://localhost:{port}/todos"
281+
282+
# DELETE all todos
283+
response = requests.delete(base)
284+
assert response.status_code == 200
285+
286+
# GET should return empty list
287+
response = requests.get(base)
288+
assert response.status_code == 200
289+
assert response.json() == []
290+
291+
# POST a new todo
292+
response = requests.post(base, json={"title": "walk the dog"})
293+
assert response.status_code == 200
294+
todo = response.json()
295+
assert todo["title"] == "walk the dog"
296+
assert todo["completed"] is False
297+
assert "url" in todo
298+
todo_url = todo["url"]
299+
300+
# GET the individual todo by its url
301+
response = requests.get(todo_url)
302+
assert response.status_code == 200
303+
assert response.json()["title"] == "walk the dog"
304+
305+
# PATCH the todo
306+
response = requests.patch(
307+
todo_url, json={"title": "bathe the cat", "completed": True}
308+
)
309+
assert response.status_code == 200
310+
patched = response.json()
311+
assert patched["title"] == "bathe the cat"
312+
assert patched["completed"] is True
313+
314+
# POST a todo with an order field
315+
response = requests.post(base, json={"title": "ordered todo", "order": 42})
316+
assert response.status_code == 200
317+
assert response.json()["order"] == 42
318+
319+
# GET all todos should return 2
320+
response = requests.get(base)
321+
assert response.status_code == 200
322+
assert len(response.json()) == 2
323+
324+
# DELETE individual todo
325+
response = requests.delete(todo_url)
326+
assert response.status_code == 200
327+
328+
# GET all todos should return 1
329+
response = requests.get(base)
330+
assert response.status_code == 200
331+
assert len(response.json()) == 1
332+
333+
# DELETE all
334+
response = requests.delete(base)
335+
assert response.status_code == 200
336+
response = requests.get(base)
337+
assert response.json() == []
338+
339+
259340
def test_django(dev_server):
260341
port = dev_server
261342
response = requests.get(f"http://localhost:{port}")

0 commit comments

Comments
 (0)