Bottle is a single-file Python micro web framework with no dependencies. pip install bottle. App: from bottle import Bottle; app = Bottle(). Route: @app.route("/hello"); def hello(): return "Hi". GET/POST: @app.get("/users"); @app.post("/users"). Path params: @app.route("/users/<uid:int>"); def user(uid): .... Wildcard: <path:path>. Request: from bottle import request; request.query.name; request.forms.email; request.json. Headers: request.headers["Authorization"]. Cookies: request.get_cookie("session"). Response: from bottle import response; response.content_type = "application/json"; return json.dumps({}). Status: from bottle import abort; abort(404,"Not found"). Redirect: from bottle import redirect; redirect("/login"). HTTPError: from bottle import HTTPError; raise HTTPError(400,"bad input"). Template: from bottle import template; return template("index.html", name=name). Jinja2: from bottle import jinja2_template as template. Static: from bottle import static_file; return static_file("file.css", root="/static/"). Hook: @app.hook("before_request"); def check_auth(): .... Plugin: app.install(plugin). Run: app.run(host="0.0.0.0", port=8080, debug=True, reloader=True). WSGI: app.run(server="gunicorn"). app.run(server="gevent"). application = app for uWSGI. Default app: from bottle import route, run (module-level singleton). Claude Code generates Bottle REST APIs, auth middleware, CRUD handlers, and WSGI deployment configs.
CLAUDE.md for Bottle
## Bottle Stack
- Version: bottle >= 0.12 | pip install bottle
- App: app = Bottle() | @app.route("/path", method=["GET","POST"])
- Request: request.query.key | request.forms.key | request.json | request.headers
- Response: return {"key": "val"} (auto-JSON) | response.status = 201 | abort(404)
- Hook: @app.hook("before_request") | @app.hook("after_request")
- Run: app.run(host="0.0.0.0", port=8080, server="gunicorn")
Bottle REST API Pipeline
# app/api.py — Bottle routes, JSON helpers, auth, CRUD, hooks, error handling
from __future__ import annotations
import functools
import hashlib
import hmac
import json
import logging
import time
from dataclasses import dataclass, asdict, field
from typing import Any, Callable
from bottle import (
Bottle,
HTTPError,
HTTPResponse,
abort,
redirect,
request,
response,
static_file,
)
log = logging.getLogger(__name__)
app = Bottle()
# ─────────────────────────────────────────────────────────────────────────────
# 1. Response helpers
# ─────────────────────────────────────────────────────────────────────────────
def json_response(data: Any, status: int = 200) -> str:
"""
Return JSON string and set Content-Type to application/json.
Bottle auto-converts dict returns, but this gives explicit status control.
Example:
return json_response({"id": 42, "name": "Widget"}, status=201)
"""
response.content_type = "application/json"
response.status = status
return json.dumps(data, default=str)
def error_response(message: str, status: int = 400, code: str | None = None) -> str:
"""Return a structured JSON error response."""
body = {"error": message}
if code:
body["code"] = code
return json_response(body, status=status)
def paginate(items: list, page: int = 1, per_page: int = 20) -> dict:
"""Paginate a list and return metadata dict."""
total = len(items)
offset = (page - 1) * per_page
return {
"items": items[offset: offset + per_page],
"total": total,
"page": page,
"per_page": per_page,
"pages": (total + per_page - 1) // per_page,
}
def get_json_body(required: list[str] | None = None) -> dict:
"""
Parse request JSON body, validating required keys.
Raises HTTPError(400) on missing keys or invalid JSON.
"""
try:
body = request.json
except Exception:
raise HTTPError(400, "Invalid JSON")
if body is None:
raise HTTPError(400, "JSON body required")
for key in required or []:
if key not in body:
raise HTTPError(422, f"Missing required field: {key}")
return body
# ─────────────────────────────────────────────────────────────────────────────
# 2. Auth middleware
# ─────────────────────────────────────────────────────────────────────────────
_VALID_TOKENS: set[str] = {"dev-token-123", "api-key-abc"}
def require_auth(fn: Callable) -> Callable:
"""
Decorator: require Authorization: Bearer <token> header.
Grants request.environ["auth_token"] to the handler.
Usage:
@app.get("/protected")
@require_auth
def protected():
return json_response({"user": "authenticated"})
"""
@functools.wraps(fn)
def wrapper(*args, **kwargs):
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer "):
return json_response({"error": "missing token"}, status=401)
token = auth[len("Bearer "):]
if token not in _VALID_TOKENS:
return json_response({"error": "invalid token"}, status=403)
request.environ["auth_token"] = token
return fn(*args, **kwargs)
return wrapper
def require_json(fn: Callable) -> Callable:
"""Decorator: require Content-Type: application/json."""
@functools.wraps(fn)
def wrapper(*args, **kwargs):
ct = request.content_type or ""
if "application/json" not in ct:
return json_response({"error": "Content-Type must be application/json"}, status=415)
return fn(*args, **kwargs)
return wrapper
# ─────────────────────────────────────────────────────────────────────────────
# 3. Request lifecycle hooks
# ─────────────────────────────────────────────────────────────────────────────
@app.hook("before_request")
def log_request():
request.environ["_start_time"] = time.monotonic()
log.debug("%s %s", request.method, request.path)
@app.hook("after_request")
def add_cors():
response.headers["Access-Control-Allow-Origin"] = "*"
response.headers["Access-Control-Allow-Headers"] = "Authorization, Content-Type"
response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, PATCH, DELETE, OPTIONS"
elapsed = time.monotonic() - request.environ.get("_start_time", time.monotonic())
response.headers["X-Response-Time"] = f"{elapsed*1000:.1f}ms"
@app.route("/<path:path>", method="OPTIONS")
def handle_options(path: str):
return json_response({}, status=204)
# ─────────────────────────────────────────────────────────────────────────────
# 4. Error handlers
# ─────────────────────────────────────────────────────────────────────────────
@app.error(400)
def bad_request(e):
response.content_type = "application/json"
return json.dumps({"error": str(e.body), "status": 400})
@app.error(401)
def unauthorized(e):
response.content_type = "application/json"
return json.dumps({"error": "unauthorized", "status": 401})
@app.error(404)
def not_found(e):
response.content_type = "application/json"
return json.dumps({"error": "not found", "status": 404})
@app.error(500)
def server_error(e):
log.exception("Internal server error")
response.content_type = "application/json"
return json.dumps({"error": "internal server error", "status": 500})
# ─────────────────────────────────────────────────────────────────────────────
# 5. CRUD resource
# ─────────────────────────────────────────────────────────────────────────────
@dataclass
class Item:
id: int
name: str
tags: list[str] = field(default_factory=list)
active: bool = True
_items: dict[int, Item] = {}
_next_id = 1
def _new_id() -> int:
global _next_id
nid = _next_id
_next_id += 1
return nid
@app.get("/health")
def health():
return json_response({"status": "ok", "items": len(_items)})
@app.get("/items")
def list_items():
page = int(request.query.get("page", 1))
per_page = int(request.query.get("per_page", 20))
q = request.query.get("q", "").lower()
items = list(_items.values())
if q:
items = [i for i in items if q in i.name.lower()]
result = paginate([asdict(i) for i in items], page, per_page)
return json_response(result)
@app.post("/items")
@require_json
def create_item():
body = get_json_body(required=["name"])
item = Item(
id=_new_id(),
name=body["name"],
tags=body.get("tags", []),
active=body.get("active", True),
)
_items[item.id] = item
return json_response(asdict(item), status=201)
@app.get("/items/<item_id:int>")
def get_item(item_id: int):
item = _items.get(item_id)
if not item:
abort(404, f"Item {item_id} not found")
return json_response(asdict(item))
@app.put("/items/<item_id:int>")
@require_json
def update_item(item_id: int):
item = _items.get(item_id)
if not item:
abort(404, f"Item {item_id} not found")
body = request.json or {}
if "name" in body: item.name = body["name"]
if "tags" in body: item.tags = body["tags"]
if "active" in body: item.active = body["active"]
return json_response(asdict(item))
@app.delete("/items/<item_id:int>")
def delete_item(item_id: int):
if item_id not in _items:
abort(404, f"Item {item_id} not found")
del _items[item_id]
return json_response({"deleted": item_id})
@app.get("/items/search")
def search_items():
q = request.query.get("q", "").strip()
if not q:
abort(400, "Search query 'q' required")
matched = [asdict(i) for i in _items.values() if q.lower() in i.name.lower()]
return json_response({"results": matched, "count": len(matched)})
@app.get("/protected")
@require_auth
def protected():
return json_response({"message": "secret data", "token": request.environ["auth_token"]})
# ─────────────────────────────────────────────────────────────────────────────
# 6. Static files and templates
# ─────────────────────────────────────────────────────────────────────────────
@app.get("/static/<filename:path>")
def serve_static(filename: str):
return static_file(filename, root="./static/")
# ─────────────────────────────────────────────────────────────────────────────
# Demo
# ─────────────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
import urllib.request as req
# Seed data
_items[1] = Item(id=1, name="Widget", tags=["hardware"])
_items[2] = Item(id=2, name="Gadget", tags=["electronics"])
_items[3] = Item(id=3, name="Doohickey", tags=["misc"])
_next_id = 4
from bottle import run as bottle_run
import threading, time
def start():
bottle_run(app, host="127.0.0.1", port=18080, quiet=True)
t = threading.Thread(target=start, daemon=True)
t.start()
time.sleep(0.5)
base = "http://127.0.0.1:18080"
print("=== GET /health ===")
r = req.urlopen(f"{base}/health")
print(f" {r.status}: {r.read().decode()}")
print("\n=== GET /items ===")
r = req.urlopen(f"{base}/items")
data = json.loads(r.read())
print(f" total={data['total']}, items={[i['name'] for i in data['items']]}")
print("\n=== POST /items ===")
payload = json.dumps({"name": "SuperWidget", "tags": ["new"]}).encode()
r = req.urlopen(req.Request(f"{base}/items", data=payload,
headers={"Content-Type": "application/json"},
method="POST"))
print(f" {r.status}: {r.read().decode()}")
print("\nBottle app ready. Run with:")
print(" python api.py # uses built-in wsgiref server")
print(" gunicorn api:app -w 4 -b 0.0.0.0:8080")
For the Flask alternative — Flask has a larger ecosystem (Flask-Login, Flask-SQLAlchemy, Flask-Migrate, Flask-WTF), Blueprints for modular apps, and an application factory pattern; Bottle is a single file (no dependencies) making it ideal for embeddable scripts, quick prototypes, and microservices where installing Flask’s full dependency tree is undesirable — use Bottle for truly minimal single-file services, Flask for applications that will grow past a few routes. For the FastAPI alternative — FastAPI provides automatic request validation via Pydantic models, Swagger UI documentation, async request handlers, and OpenAPI schema generation; Bottle is synchronous and has no built-in validation — use FastAPI when you need type-safe APIs with auto-docs, Bottle when you want a zero-dependency WSGI server you can deploy by copying a single .py file. The Claude Skills 360 bundle includes Bottle skill sets covering json_response()/error_response()/paginate() helpers, get_json_body() with required-fields validation, require_auth()/require_json() decorators, before_request/after_request hooks with CORS and timing, error handlers for 400/401/404/500, CRUD routes with GET/POST/PUT/DELETE, search endpoint, OPTIONS preflight handler, static_file() serving, and gunicorn deployment configuration. Start with the free tier to try single-file micro web framework code generation.