diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index 1b1fab8..36528d9 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -27,8 +27,6 @@ jobs: run: uv sync - name: Install viceroy run: cargo install --git https://github.com/fastly/Viceroy.git --branch sunfishcode/sync-wit viceroy - - name: Build WebAssembly component - run: make app.wasm - name: Install dependencies run: uv sync --extra dev --extra test - name: Check formatting diff --git a/.gitignore b/.gitignore index 19f7e6d..497a726 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,7 @@ uv.lock /stubs/ __pycache__ -app.wasm + +# Build artifacts +/build/ +*.wasm diff --git a/Makefile b/Makefile index 260849f..cd866fc 100644 --- a/Makefile +++ b/Makefile @@ -1,18 +1,56 @@ -all: app.wasm +# Fastly Compute Python SDK +# Configuration STUBS_DIR := stubs +BUILD_DIR := build +EXAMPLES_DIR := examples -app.wasm: wit/viceroy.wit wit/deps/fastly/compute.wit app.py - rm -rf ${STUBS_DIR} - uv run componentize-py -d wit -w viceroy bindings ${STUBS_DIR} - uv run componentize-py -d wit -w viceroy componentize app -o app.wasm +# Define all available examples (add new ones here) +EXAMPLES := wit-bottle flask-app -serve: app.wasm - viceroy serve app.wasm +# Default example for serve target +EXAMPLE ?= wit-bottle +WASM_FILE := $(BUILD_DIR)/$(EXAMPLE).wasm -test: app.wasm +# Generate WASM file paths for all examples +EXAMPLE_WASMS := $(foreach example,$(EXAMPLES),$(BUILD_DIR)/$(example).wasm) + +# Default target builds all examples +all: $(EXAMPLE_WASMS) + +# Pattern rule for building any example +$(BUILD_DIR)/%.wasm: $(EXAMPLES_DIR)/%.py wit/viceroy.wit wit/deps/fastly/compute.wit | $(BUILD_DIR) + @echo "Building $* example..." + rm -rf $(STUBS_DIR) + uv run componentize-py -d wit -w viceroy bindings $(STUBS_DIR) + uv run componentize-py -d wit -w viceroy componentize $* -p $(EXAMPLES_DIR) -p . -o $@ + +# Create build directory +$(BUILD_DIR): + mkdir -p $(BUILD_DIR) + +# Serve the specified example (default: wit-bottle) +serve: $(WASM_FILE) + @echo "Serving $(EXAMPLE) example on http://127.0.0.1:7676" + viceroy serve $(WASM_FILE) + +# Test all examples (requires all WASM files to be built) +test: $(EXAMPLE_WASMS) uv run --extra test pytest +# List available examples +list-examples: + @echo "Available examples:" + @for example in $(EXAMPLES); do echo " $$example"; done + +# Build all examples (alias for 'all') +build-all: all + +# Clean build artifacts +clean: + rm -rf $(BUILD_DIR) $(STUBS_DIR) + +# Development tools lint: uv run --extra dev ruff check . @@ -25,4 +63,29 @@ format: format-check: uv run --extra dev ruff format --check . -.PHONY: all serve test lint format format-check +# Help target +help: + @echo "Fastly Compute Python SDK" + @echo "" + @echo "Targets:" + @echo " all Build all examples" + @echo " serve [EXAMPLE=name] Serve example (default: $(EXAMPLE))" + @echo " test Run integration tests (builds all examples)" + @echo " build-all Build all examples (alias for 'all')" + @echo " list-examples List available examples" + @echo " clean Clean build artifacts" + @echo " lint Run linter" + @echo " lint-fix Run linter with auto-fix" + @echo " format Format code" + @echo " format-check Check code formatting" + @echo "" + @echo "Examples:" + @echo " make # Build all examples" + @echo " make serve # Serve wit-bottle example" + @echo " make serve EXAMPLE=flask-app # Serve flask-app example" + @echo " make build/flask-app.wasm # Build specific example" + @echo "" + @echo "Available examples: $(EXAMPLES)" + +.PHONY: all serve test list-examples build-all clean lint lint-fix format format-check help + diff --git a/README.md b/README.md index ca4eaf0..8aad1ad 100644 --- a/README.md +++ b/README.md @@ -1,65 +1,68 @@ -This is the experimental beginning of a Python SDK for [Fastly -Compute](https://www.fastly.com/products/edge-compute). +# Fastly Compute Python SDK -# Status +Experimental Python SDK for [Fastly Compute](https://www.fastly.com/products/edge-compute) services. -Currently, this demonstrates… +## Features -* Building arbitrary pure Python into a component -* Creating Python bindings from Fastly's WIT files -* Hosting arbitrary web frameworks by adapting Fastly's API, through those - bindings, to WSGI +- **Multiple Framework Support**: Examples with Bottle, Flask, and more +- **WIT Bindings**: Auto-generated Python bindings from Fastly's WIT files +- **WSGI Compatibility**: Host any WSGI-compatible web framework +- **Testing Framework**: Comprehensive viceroy-based integration testing +- **Type Safety**: Full type hints and IDE support -# Caveats +## Quick Start -* Any native Python modules need to be compiled against WASI. Few are at the - moment. However, [Joel has done - some](https://github.com/dicej/wasi-wheels/releases/), and the changes needed - aren't extensive. -* Most popular web frameworks we tried [wouldn't work because they depended on - zlib](https://github.com/bytecodealliance/componentize-py/issues/96) and other - native-code compression stdlibs which haven't been compiled against WASI yet. - Moving componentize-py to a new Python may help, as [WASIp1 is now a Tier 2 - supported platform](https://peps.python.org/pep-0011/#tier-2). +### Build and Run +```bash +make serve # Serve default example (Bottle) +make serve EXAMPLE=flask-app # Serve Flask example +``` -# Install Dependencies +Visit http://127.0.0.1:7676/hello/world or http://127.0.0.1:7676/info -1. [Install `uv`](https://docs.astral.sh/uv/getting-started/installation/) -2. Install [Viceroy](https://github.com/fastly/Viceroy). Make sure you have [a - branch with up-to-date WIT - files](https://github.com/fastly/Viceroy/tree/sunfishcode/sync-wit) so it can - run components. +### Available Examples -# Build and Run +```bash +make list-examples # List all examples +make build-all # Build all examples +``` -1. `make serve` -2. Visit http://127.0.0.1:7676/hello/world or http://127.0.0.1:7676/info in a browser. +### Testing +```bash +make test # Run integration tests +``` -You are seeing Bottle, a simple Python web framework, run on a Fastly Compute -worker! +## Development -# Testing +### Code Quality +```bash +make format # Format code +make lint # Run linter +make lint-fix # Auto-fix linting issues +``` +### Building Examples ```bash -# Install dependencies and run tests -uv sync --extra dev --extra test -make test +make build/my-app.wasm # Build specific example +make clean # Clean build artifacts ``` -The tests automatically build the WebAssembly component, start viceroy, and verify all endpoints work correctly with the WIT APIs. +## Status -# Development +Currently demonstrates: +- Building pure Python into WebAssembly components +- Creating Python bindings from Fastly's WIT files +- Hosting web frameworks by adapting Fastly's API to WSGI +- Comprehensive testing with viceroy integration -```bash -# Format code -make format +## Caveats -# Check formatting -make format-check +- Any native Python modules need to be compiled against WASI. Few are at the + moment. However, [Joel has done + some](https://github.com/dicej/wasi-wheels/releases/), and the changes needed + aren't extensive. -# Run linting -make lint +## Prerequisites -# Run linting and apply automatic fixes -make lint-fix -``` +1. [Install `uv`](https://docs.astral.sh/uv/getting-started/installation/) +2. Install [Viceroy](https://github.com/fastly/Viceroy) with component support diff --git a/app.py b/app.py deleted file mode 100644 index d564a76..0000000 --- a/app.py +++ /dev/null @@ -1,79 +0,0 @@ -import sys -from urllib.parse import urlparse - -from bottle import Bottle -from wit_world.exports import HttpIncoming as BaseHttpIncoming -from wit_world.imports import compute_runtime, http_body, http_resp -from wit_world.imports.http_resp import send_downstream - -# Enable a bit more debug logging from the framework. -app = Bottle() - - -@app.route("/hello/") -def hello(name): - return f"Hello {name}!" - - -@app.route("/info") -def info(): - """Return JSON with request information we can test against""" - from bottle import request - - # Get some runtime info we can test - vcpu_time = compute_runtime.get_vcpu_ms() - - return { - "service": "fastly-compute-python", - "status": "ok", - "message": "Hello from Fastly Compute!", - "vcpu_time_ms": vcpu_time, - "request_method": request.environ.get("REQUEST_METHOD"), - "path_info": request.environ.get("PATH_INFO"), - } - - -@app.route("/error") -def error(): - """Endpoint that intentionally raises an exception to test error handling.""" - raise RuntimeError("This is an intentional error for testing purposes") - - -def serve_wsgi_request(req, body, app): - """Pass a WSGI application a single request, and adapt its behavior back - to the Fastly API.""" - - response = http_resp.Response.new() - response_body = http_body.new() - - def write(body_data: bytes): - """Implement a mostly deprecated alternative body-writing mechanism of - WSGI.""" - http_body.write(response_body, body_data, http_body.WriteEnd.BACK) - - def start_response(status: str, headers: list[tuple], exc_info=None): - code, _description = status.split(" ", 1) - response.set_status(int(code)) - for header, value in headers: - response.append_header(header, value.encode()) - return write - - url = urlparse(req.get_uri(2048)) - environ = { - "REQUEST_METHOD": req.get_method(12), - "PATH_INFO": url.path, - "QUERY_STRING": url.query, - "SERVER_NAME": url.hostname, - "SERVER_PORT": str(url.port), - "wsgi.errors": sys.stderr, - } - for body_chunk in app(environ, start_response): - # TODO: this would be a good place to stream, but for now we just - # write to the buffer and send once the handler is done. - write(body_chunk) - send_downstream(response, response_body) - - -class HttpIncoming(BaseHttpIncoming): - def handle(self, request, body): - serve_wsgi_request(request, body, app) diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..7463394 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,73 @@ +# Fastly Compute Python Examples + +This directory contains example applications demonstrating different approaches to building Fastly Compute services with Python. + +## Available Examples + +### `wit-bottle.py` +- **Framework**: Bottle (lightweight WSGI framework) +- **Features**: Basic routing, JSON responses, WIT API integration +- **Use Case**: Simple services, proof-of-concept applications + +### `flask-app.py` +- **Framework**: Flask (popular Python web framework) +- **Features**: Flask routing, request handling, error handling +- **Use Case**: More complex applications, familiar Flask patterns + +## Building and Running Examples + +### Build a Specific Example +```bash +make build/flask-app.wasm # Build Flask example +make build/wit-bottle.wasm # Build Bottle example +``` + +### Serve an Example +```bash +make serve # Serve default (wit-bottle) +make serve EXAMPLE=flask-app # Serve Flask example +``` + +### Build All Examples +```bash +make build-all +``` + +### List Available Examples +```bash +make list-examples +``` + +## Testing Examples + +The integration tests use the default example (wit-bottle). To test other examples: + +```bash +# Update the test to use a different example +EXAMPLE=flask-app make test +``` + +## Creating New Examples + +1. Create a new `.py` file in this directory +2. Implement your WSGI application +3. Include the `serve_wsgi_request` function and `HttpIncoming` class +4. Build with `make build/your-example.wasm` +5. Test with `make serve EXAMPLE=your-example` + +## Framework Requirements + +All examples must: +- Be WSGI-compatible applications +- Include the WIT integration boilerplate (`serve_wsgi_request`, `HttpIncoming`) +- Handle the standard test endpoints for integration tests: + - `/hello/` - Returns "Hello {name}!" + - `/info` - Returns JSON with service info and WIT data + - `/error` - Raises an exception for error testing + +## Dependencies + +Examples may use different web frameworks, but they all rely on: +- `wit_world` - Generated WIT bindings +- `componentize-py` - Python to WebAssembly compilation +- Framework-specific dependencies (bottle, flask, etc.) \ No newline at end of file diff --git a/examples/flask-app.py b/examples/flask-app.py new file mode 100644 index 0000000..03244a7 --- /dev/null +++ b/examples/flask-app.py @@ -0,0 +1,44 @@ +# Import and install WASI shims before importing Flask +from shims import install_shims + +install_shims() + +# Now we can import Flask and Fastly Compute modules +from flask import Flask, request # noqa: E402 +from wit_world.imports import compute_runtime # noqa: E402 + +from fastly_compute.wsgi import WsgiHttpIncoming # noqa: E402 + +# Create Flask app +app = Flask(__name__) + + +@app.route("/hello/") +def hello(name): + return f"Hello {name}!" + + +@app.route("/info") +def info(): + """Return JSON with request information we can test against""" + # Get some runtime info we can test + vcpu_time = compute_runtime.get_vcpu_ms() + + return { + "service": "fastly-compute-python-flask", + "status": "ok", + "message": "Hello from Fastly Compute with Flask!", + "vcpu_time_ms": vcpu_time, + "request_method": request.environ.get("REQUEST_METHOD"), + "path_info": request.environ.get("PATH_INFO"), + } + + +@app.route("/error") +def error(): + """Endpoint that intentionally raises an exception to test error handling.""" + raise RuntimeError("This is an intentional error for testing purposes") + + +# Create the HTTP handler using the shared WSGI infrastructure +HttpIncoming = WsgiHttpIncoming(app) diff --git a/examples/shims.py b/examples/shims.py new file mode 100644 index 0000000..695fde9 --- /dev/null +++ b/examples/shims.py @@ -0,0 +1,100 @@ +"""WASI compatibility shims for common Python modules. + +This module provides minimal implementations of native modules that +aren't available in WASI but are required by popular frameworks. +""" + +import sys + + +class ZlibShim: + """Minimal zlib implementation for WASI compatibility.""" + + # Constants that frameworks might expect + DEFLATED = 8 + MAX_WBITS = 15 + DEF_BUF_SIZE = 16384 + + def adler32(self, data, value=1): + raise NotImplementedError("shim") + + def compress(self, data, level=6): + raise NotImplementedError("shim") + + def decompress(self, data): + raise NotImplementedError("shim") + + def crc32(self, data, value=0): + raise NotImplementedError("shim") + + def compressobj( + self, level=6, method=None, wbits=None, memLevel=None, strategy=None + ): + return CompressObj() + + def decompressobj(self, wbits=None): + return DecompressObj() + + +class CompressObj: + """Mock compression object.""" + + def compress(self, data): + raise NotImplementedError("shim") + + def flush(self): + raise NotImplementedError("shim") + + +class DecompressObj: + """Mock decompression object.""" + + def decompress(self, data): + raise NotImplementedError("shim") + + def flush(self): + raise NotImplementedError("shim") + + +def install_shims(): + """Install all WASI compatibility shims.""" + # Install zlib shim + sys.modules["zlib"] = ZlibShim() + + # Install idna encoding shim + import codecs + + def idna_encode(input, errors="strict"): + """Simple IDNA encoder - just return ASCII for basic domains.""" + if isinstance(input, str): + # For basic ASCII domains, just encode to bytes + try: + return input.encode("ascii"), len(input) + except UnicodeEncodeError: + # For non-ASCII, try to handle basic cases + # This is a very simplified implementation + return input.encode("utf-8"), len(input) + return input, len(input) + + def idna_decode(input, errors="strict"): + """Simple IDNA decoder.""" + if isinstance(input, bytes): + return input.decode("ascii", errors), len(input) + return input, len(input) + + def idna_search(name): + """Search function for IDNA codec.""" + if name in ("idna", "idna-2003", "idna-2008"): + return codecs.CodecInfo( + name="idna", + encode=idna_encode, + decode=idna_decode, + ) + return None + + # Register the IDNA codec + codecs.register(idna_search) + + # Add other shims as needed + # sys.modules['_ssl'] = SSLShim() # Future + # sys.modules['_socket'] = SocketShim() # Future diff --git a/examples/wit-bottle.py b/examples/wit-bottle.py new file mode 100644 index 0000000..61ea7ce --- /dev/null +++ b/examples/wit-bottle.py @@ -0,0 +1,41 @@ +from bottle import Bottle +from wit_world.imports import compute_runtime + +from fastly_compute.wsgi import WsgiHttpIncoming + +# Enable a bit more debug logging from the framework. +app = Bottle() + + +@app.route("/hello/") +def hello(name): + return f"Hello {name}!" + + +@app.route("/info") +def info(): + """Return JSON with request information we can test against""" + from bottle import request + + # Get some runtime info we can test + vcpu_time = compute_runtime.get_vcpu_ms() + + return { + "service": "fastly-compute-python", + "status": "ok", + "message": "Hello from Fastly Compute!", + "vcpu_time_ms": vcpu_time, + "request_method": request.environ.get("REQUEST_METHOD"), + "path_info": request.environ.get("PATH_INFO"), + } + + +@app.route("/error") +def error(): + """Endpoint that intentionally raises an exception to test error handling.""" + raise RuntimeError("This is an intentional error for testing purposes") + + +# Create the HTTP handler using the shared WSGI infrastructure +# Use basic environ for Bottle (doesn't need enhanced WSGI variables like Flask) +HttpIncoming = WsgiHttpIncoming(app) diff --git a/fastly_compute/testing.py b/fastly_compute/testing.py index 22164d3..c685abb 100644 --- a/fastly_compute/testing.py +++ b/fastly_compute/testing.py @@ -43,14 +43,14 @@ class ViceroyTestBase: from fastly_compute.testing import ViceroyTestBase class TestMyService(ViceroyTestBase): - def test_my_endpoint(self, viceroy_server): + def test_my_endpoint(self): response = self.get("/my-endpoint") assert response.status_code == 200 ``` """ REQUEST_TIMEOUT = 10 - WASM_FILE = "app.wasm" # Override this in subclasses if needed + WASM_FILE = "build/wit-bottle.wasm" # Default to the main example server: ViceroyServer = None # Will be set by the fixture @staticmethod diff --git a/fastly_compute/wsgi.py b/fastly_compute/wsgi.py new file mode 100644 index 0000000..cccd636 --- /dev/null +++ b/fastly_compute/wsgi.py @@ -0,0 +1,141 @@ +"""WSGI adapter for Fastly Compute services. + +This module provides utilities for running WSGI applications on Fastly Compute +by adapting between the Fastly WIT API and the WSGI specification. +""" + +import sys +import traceback +from collections.abc import Callable +from typing import Any +from urllib.parse import urlparse + +from wit_world.exports import HttpIncoming as WitHttpIncoming +from wit_world.imports import http_body, http_resp +from wit_world.imports.http_resp import send_downstream + + +def serve_wsgi_request( + req: Any, + body: Any, + app: Callable, + handle_errors: bool = False, +) -> None: + """Serve a single WSGI request using the Fastly Compute API. + + This function adapts between Fastly's WIT-based HTTP API and the WSGI + specification, allowing any WSGI-compatible web framework to run on + Fastly Compute. + + Args: + req: Fastly HTTP request object from WIT bindings + body: Fastly HTTP body object from WIT bindings + app: WSGI application callable + handle_errors: If True, the wrapper will log exceptions and return 500; if not, + then it will be handled by the server (or will be handled by + the WSGI app/framework itself). + """ + response = http_resp.Response.new() + response_body = http_body.new() + + def write(body_data: bytes) -> None: + """Write response body data (deprecated WSGI mechanism).""" + http_body.write(response_body, body_data, http_body.WriteEnd.BACK) + + def start_response( + status: str, headers: list[tuple[str, str]], exc_info: Any | None = None + ) -> Callable[[bytes], None]: + """WSGI start_response callable.""" + code, _description = status.split(" ", 1) + response.set_status(int(code)) + for header, value in headers: + response.append_header(header, value.encode()) + return write + + # Parse request URL + url = urlparse(req.get_uri(2048)) + + # Build WSGI environ dict + environ = { + "REQUEST_METHOD": req.get_method(12), + "PATH_INFO": url.path, + "QUERY_STRING": url.query, + "SERVER_NAME": url.hostname or "localhost", + "SERVER_PORT": str(url.port or 80), + "wsgi.errors": sys.stderr, + "wsgi.version": (1, 0), + "wsgi.url_scheme": url.scheme or "http", + "wsgi.input": sys.stdin.buffer, + "wsgi.multithread": False, + "wsgi.multiprocess": False, + "wsgi.run_once": True, + "SCRIPT_NAME": "", + "CONTENT_TYPE": "", + "CONTENT_LENGTH": "", + "HTTP_HOST": url.netloc or "localhost", + } + + try: + # Call the WSGI app and collect response body chunks + for body_chunk in app(environ, start_response): + # TODO: this would be a good place to stream, but for now we just + # write to the buffer and send once the handler is done. + write(body_chunk) + + # Send the complete response downstream + send_downstream(response, response_body) + + except Exception as e: + if not handle_errors: + raise + + print(f"Error in serve_wsgi_request: {e}", file=sys.stderr) + traceback.print_exc(file=sys.stderr) + + # Send 500 error response + error_response = http_resp.Response.new() + error_body = http_body.new() + error_response.set_status(500) + error_response.append_header("content-type", b"text/plain") + error_message = f"Internal Server Error: {e}" + http_body.write(error_body, error_message.encode(), http_body.WriteEnd.BACK) + send_downstream(error_response, error_body) + + +class WsgiHttpIncoming(WitHttpIncoming): + """HTTP request handler that serves WSGI applications. + + This class provides a convenient base class for serving WSGI applications + on Fastly Compute. Subclass this and set the `app` attribute to your WSGI + application. + + Example: + ```python + from flask import Flask + from fastly_compute.wsgi import WsgiHttpIncoming + + app = Flask(__name__) + + @app.route("/hello") + def hello(): + return "Hello, World!" + + HttpIncoming = WsgiHttpIncoming(app) + ``` + """ + + def __init__(self, wsgi_app: Callable, handle_errors: bool = False): + self.wsgi_app = wsgi_app + self.handle_errors = handle_errors + + def __call__(self): + return self + + def handle(self, request: Any, body: Any) -> None: + """Handle incoming HTTP request by serving it through the WSGI app.""" + serve_wsgi_request( + request, + body, + self.wsgi_app, + handle_errors=self.handle_errors, + ) diff --git a/pyproject.toml b/pyproject.toml index 317ac56..d11aa4e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,7 @@ readme = "README.md" requires-python = ">=3.12" dependencies = [ "bottle", + "flask", "componentize-py", ] diff --git a/tests/test_flask_example.py b/tests/test_flask_example.py new file mode 100644 index 0000000..5d5540f --- /dev/null +++ b/tests/test_flask_example.py @@ -0,0 +1,43 @@ +"""Tests for the Flask example application.""" + +import pytest + +from fastly_compute.testing import ViceroyTestBase + + +class TestFlaskApp(ViceroyTestBase): + """Integration tests for the Flask example application.""" + + WASM_FILE = "build/flask-app.wasm" + + def test_hello_endpoint(self): + """Test the hello endpoint returns expected content.""" + response = self.get("/hello/flask") + + assert response.status_code == 200 + assert response.text == "Hello flask!" + + def test_info_endpoint(self): + """Test the info endpoint returns expected JSON with WIT data.""" + response = self.get("/info") + + assert response.status_code == 200 + assert response.headers.get("content-type", "").startswith("application/json") + + data = response.json() + + # Check basic service info + assert data["service"] == "fastly-compute-python-flask" + assert data["status"] == "ok" + assert "message" in data + + # Check WIT API data + assert "vcpu_time_ms" in data + assert isinstance(data["vcpu_time_ms"], int) + + def test_error_endpoint_handling(self): + """Test that the error endpoint returns 500.""" + response = self.get("/error") + + # The endpoint should return a 500 error due to the exception + assert response.status_code == 500