From 773d1480102d1822add2916236799955d253a513 Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Tue, 13 Jan 2026 16:27:06 -0600 Subject: [PATCH 01/26] Add project tracking doc --- planning/todo.md | 56 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 planning/todo.md diff --git a/planning/todo.md b/planning/todo.md new file mode 100644 index 0000000..a0215a4 --- /dev/null +++ b/planning/todo.md @@ -0,0 +1,56 @@ +# Fastly Compute Python SDK - Work Overview + +This document provides an overview of the development work planned for the Fastly Compute Python SDK. Each feature links to a detailed design document. + +## Core SDK Features + +### HTTP Request & Response +- **Full Request Coverage** - [request-response.md](./request-response.md) +- **Full Response Coverage** - [request-response.md](./request-response.md) +- **Trailers** - [trailers.md](./trailers.md) + +### Security & Access Control +- **ACL (Blocklists)** - [acl.md](./acl.md) +- **NGWAF (Security)** - [ngwaf.md](./ngwaf.md) + +### Geographic & Client Intelligence +- **Geo API** - [geo.md](./geo.md) +- **Device Detection** - [device-detection.md](./device-detection.md) + +### Rate Limiting +- **Edge Rate Limiting (ERL)** - [erl.md](./erl.md) + +### Data Storage +- **KV Store** - [kv-store.md](./kv-store.md) +- **Secret Store** - [secret-store.md](./secret-store.md) +- **Config Store** - [config-store.md](./config-store.md) + +### Caching +- **Core Cache API** - [cache.md](./cache.md) +- **HTTP Cache API** - [http-cache.md](./http-cache.md) +- **Cache Purge** - [cache-purge.md](./cache-purge.md) + +### Content Optimization +- **Image Optimization** - [image-opto.md](./image-opto.md) + +### Runtime & Diagnostics +- **General Runtime Info** - [runtime-info.md](./runtime-info.md) +- **Logging** - [logging.md](./logging.md) + +### API Improvements +- **WIT drop() Support** - [wit-drop.md](./wit-drop.md) +- **Idiomatic Exceptions** - [exceptions.md](./exceptions.md) + +## Developer Tooling +- **Fastly-Py Build Tool** - [devtools.md](./devtools.md) +- **Fastly CLI Integration** - [cli-integration.md](./cli-integration.md) + +## Documentation +- **Documentation Infrastructure (Sphinx)** - [docs-infra.md](./docs-infra.md) +- **Documentation & Polish** - [docs-polish.md](./docs-polish.md) + +## Deferred / Exploratory +- **Async Support** - [async.md](./async.md) (Analysis complete, implementation deferred) +- **Shielding API** - Testing challenges; lower priority +- **Unify Testing with JS** - Out of scope but noted for consideration +- **Numpy 2, Pandas, SciPy** - Gated by Cython compatibility issues From 9246b09e3ad76f834cb424e169be69655fd2dfa4 Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Tue, 13 Jan 2026 16:27:06 -0600 Subject: [PATCH 02/26] Add Request/Response design --- planning/request-response.md | 238 +++++++++++++++++++++++++++++++++++ 1 file changed, 238 insertions(+) create mode 100644 planning/request-response.md diff --git a/planning/request-response.md b/planning/request-response.md new file mode 100644 index 0000000..ca70ff0 --- /dev/null +++ b/planning/request-response.md @@ -0,0 +1,238 @@ +# Request & Response API Design + +## Overview + +Design for comprehensive HTTP Request and Response handling in the Fastly Compute Python SDK. + +## WIT Interface Reference + +The design is based on `http-req`, `http-resp`, and `http-downstream` interfaces in `wit/deps/fastly/compute.wit`. + +## API Design + +```python +from typing import Optional, Dict, List, Tuple, Any +from enum import Enum + +class HttpVersion(Enum): + HTTP_09 = "HTTP/0.9" + HTTP_10 = "HTTP/1.0" + HTTP_11 = "HTTP/1.1" + H2 = "HTTP/2" + H3 = "HTTP/3" + +class Request: + def __init__(self, method: str = "GET", url: str = "", headers: Optional[Dict[str, str]] = None, body: Optional[bytes] = None): + pass + + @property + def method(self) -> str: pass + + @method.setter + def method(self, value: str) -> None: pass + + @property + def url(self) -> str: pass + + @url.setter + def url(self, value: str) -> None: pass + + @property + def version(self) -> HttpVersion: pass + + @version.setter + def version(self, value: HttpVersion) -> None: pass + + @property + def headers(self) -> 'Headers': pass + + @property + def body(self) -> 'Body': pass + + def read(self) -> bytes: pass + + def text(self, encoding: str = 'utf-8') -> str: pass + + def json(self) -> Any: pass + + def set_cache_override(self, ttl: Optional[int] = None, stale_while_revalidate: Optional[int] = None, pci: bool = False, surrogate_key: Optional[str] = None) -> None: pass + + def set_auto_decompress(self, gzip: bool = True) -> None: pass + + def __enter__(self): return self + def __exit__(self, exc_type, exc_val, exc_tb): pass + +class DownstreamRequest(Request): + """Represents a downstream (client) request with additional metadata.""" + + @property + def client_ip(self) -> Optional[str]: pass + + @property + def server_ip(self) -> Optional[str]: pass + + @property + def client_request_id(self) -> Optional[str]: pass + + @property + def tls_cipher(self) -> Optional[str]: pass + + @property + def tls_protocol(self) -> Optional[str]: pass + + @property + def tls_client_hello(self) -> Optional[bytes]: pass + + @property + def tls_client_certificate(self) -> Optional[str]: pass + + @property + def tls_server_name(self) -> Optional[str]: pass + + @property + def tls_ja3_md5(self) -> Optional[bytes]: pass + + @property + def tls_ja4(self) -> Optional[str]: pass + + @property + def h2_fingerprint(self) -> Optional[str]: pass + + @property + def header_fingerprint(self) -> Optional[str]: pass + + @property + def ddos_detected(self) -> bool: pass + + @property + def compliance_region(self) -> Optional[str]: pass + +class Response: + def __init__(self, status: int = 200, headers: Optional[Dict[str, str]] = None, body: Optional[bytes] = None): + pass + + @property + def status(self) -> int: pass + + @status.setter + def status(self, value: int) -> None: pass + + @property + def version(self) -> HttpVersion: pass + + @version.setter + def version(self, value: HttpVersion) -> None: pass + + @property + def headers(self) -> 'Headers': pass + + @property + def body(self) -> 'Body': pass + + @property + def content(self) -> bytes: pass + + @property + def text(self) -> str: pass + + def json(self) -> Any: pass + + @property + def remote_ip(self) -> Optional[str]: pass + + @property + def remote_port(self) -> Optional[int]: pass + + def send_downstream(self, streaming: bool = False) -> None: pass + + def __enter__(self): return self + def __exit__(self, exc_type, exc_val, exc_tb): pass + +class Headers: + def __getitem__(self, key: str) -> str: pass + def __setitem__(self, key: str, value: str) -> None: pass + def __delitem__(self, key: str) -> None: pass + def __contains__(self, key: str) -> bool: pass + def __iter__(self): pass + def __len__(self) -> int: pass + + def get(self, key: str, default: Optional[str] = None) -> Optional[str]: pass + def get_all(self, key: str) -> List[str]: pass + def set_all(self, key: str, values: List[str]) -> None: pass + def append(self, key: str, value: str) -> None: pass + def items(self) -> List[Tuple[str, str]]: pass + def keys(self) -> List[str]: pass + def values(self) -> List[str]: pass + +class Body: + @classmethod + def empty(cls) -> 'Body': pass + + def read(self, size: Optional[int] = None) -> bytes: pass + def write(self, data: bytes) -> int: pass + def write_str(self, text: str, encoding: str = 'utf-8') -> int: pass + def append(self, other: 'Body') -> None: pass + def close(self) -> None: pass + + @property + def known_length(self) -> Optional[int]: pass + + def __enter__(self): return self + def __exit__(self, exc_type, exc_val, exc_tb): self.close() +``` + +## High-Level API + +```python +def send_request( + method: str, + url: str, + headers: Optional[Dict[str, str]] = None, + body: Optional[bytes] = None, + backend: str = "default", + timeout: Optional[int] = None, +) -> Response: + pass + +def get(url: str, **kwargs) -> Response: pass +def post(url: str, body: Optional[bytes] = None, **kwargs) -> Response: pass +def put(url: str, body: Optional[bytes] = None, **kwargs) -> Response: pass +def delete(url: str, **kwargs) -> Response: pass +``` + +## Deferred Features + +- **Streaming Request Body**: Full streaming support for outgoing requests is deferred. +- **WebSocket**: WebSocket upgrade support is deferred. +- **Trailer Support**: Full trailer manipulation is deferred. + +## Implementation Plan + +### 1. Core Module (`fastly_compute._core`) + +Create a new internal module (or `fastly_compute.http` if exposed) to house the canonical implementations of the core HTTP objects. This module will depend directly on the WIT bindings. + +- **`Body`**: Wraps `http_body.Body`. Implements streaming read/write. +- **`Headers`**: Wraps header logic. Handles case-insensitivity and multi-value storage. +- **`Request`**: Wraps `http_req.Request`. Can be initialized from WIT handle (incoming) or from scratch (outgoing). +- **`Response`**: Wraps `http_resp.Response`. Can be initialized from WIT handle (incoming from backend) or from scratch (outgoing to client). + +### 2. Integration with `fastly_compute.requests` + +The existing `requests` facade is valuable and should remain the primary high-level API for making backend requests. + +- **Refactor**: Update `fastly_compute.requests.request()` to use the new `fastly_compute.http.Request` object internally for constructing the request. +- **Response Wrapper**: `fastly_compute.requests.FastlyResponse` can either inherit from `fastly_compute.http.Response` or wrap it, adding the specific `requests`-compatible API (like `raise_for_status`, `json()` helper). + +### 3. WSGI Adapter Update + +Update `fastly_compute.wsgi` to use the new core objects. + +- **Incoming Request**: Construct a `fastly_compute.http.Request` from the incoming WIT handle. +- **Outgoing Response**: Construct a `fastly_compute.http.Response` from the WSGI app's output, then send it using `send_downstream`. + +### 4. Backward Compatibility + +- Ensure `fastly_compute.requests` public API remains unchanged. +- `fastly_compute.wsgi.WsgiHttpIncoming` should remain the entry point for WSGI apps. + From 0d2189a4c2d60d9a6441d1d82b95a6c75ebb1d62 Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Tue, 13 Jan 2026 16:27:06 -0600 Subject: [PATCH 03/26] Add KV Store design --- planning/kv-store.md | 176 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 planning/kv-store.md diff --git a/planning/kv-store.md b/planning/kv-store.md new file mode 100644 index 0000000..deb118f --- /dev/null +++ b/planning/kv-store.md @@ -0,0 +1,176 @@ +# KV Store API Design + +## Overview + +Design for the KV Store API, providing access to Fastly's distributed key-value store for persistent data storage at the edge. + +## WIT Interface Reference + +```wit +interface kv-store { + use types.{error, open-error}; + use http-body.{body}; + + resource store { + open: static func(name: string) -> result; + lookup: func(key: string) -> result, kv-error>; + lookup-async: func(key: string) -> result; + insert: func(key: string, body: body, options: insert-options) -> result<_, kv-error>; + insert-async: func(key: string, body: body, options: insert-options) -> result; + delete: func(key: string) -> result; + delete-async: func(key: string) -> result; + %list: func(options: list-options) -> result; + list-async: func(options: list-options) -> result; + } + + resource entry { + take-body: func() -> option; + metadata: func(max-len: u64) -> result, error>; + generation: func() -> u64; + } + + enum insert-mode { overwrite, add, append, prepend } + + record insert-options { + background-fetch: bool, + if-generation-match: option, + metadata: option, + time-to-live-sec: option, + mode: insert-mode, + extra: option>, + } + + enum list-mode { strong, eventual } + + record list-options { + mode: list-mode, + cursor: option, + limit: option, + prefix: option, + extra: option>, + } + + enum kv-error { + bad-request, precondition-failed, payload-too-large, + internal-error, too-many-requests, generic-error, + } +} +``` + +Generated stubs: `stubs/wit_world/imports/kv_store.py` + +## API Design + +```python +from typing import Optional, Dict, Any, Iterator +from enum import Enum +from dataclasses import dataclass + +class InsertMode(Enum): + OVERWRITE = "overwrite" + ADD = "add" + APPEND = "append" + PREPEND = "prepend" + +class ListMode(Enum): + STRONG = "strong" + EVENTUAL = "eventual" + +@dataclass +class InsertOptions: + mode: InsertMode = InsertMode.OVERWRITE + time_to_live_sec: Optional[int] = None + metadata: Optional[str] = None + if_generation_match: Optional[int] = None + background_fetch: bool = False + +@dataclass +class ListOptions: + mode: ListMode = ListMode.STRONG + cursor: Optional[str] = None + limit: Optional[int] = None + prefix: Optional[str] = None + +class KVStoreEntry: + @property + def body(self) -> bytes: pass + + @property + def text(self) -> str: pass + + def json(self) -> Any: pass + + @property + def metadata(self) -> Optional[str]: pass + + @property + def generation(self) -> int: pass + + def __enter__(self): return self + def __exit__(self, exc_type, exc_val, exc_tb): pass + +class KVStore: + @classmethod + def open(cls, name: str) -> 'KVStore': pass + + # Synchronous operations + def lookup(self, key: str) -> Optional[KVStoreEntry]: pass + + def insert(self, key: str, value: bytes, options: Optional[InsertOptions] = None) -> None: pass + + def insert_text(self, key: str, text: str, options: Optional[InsertOptions] = None) -> None: pass + + def insert_json(self, key: str, data: Any, options: Optional[InsertOptions] = None) -> None: pass + + def delete(self, key: str) -> bool: pass + + def list(self, options: Optional[ListOptions] = None) -> 'KVStoreList': pass + + # Dict-like interface + def __getitem__(self, key: str) -> bytes: pass + def __setitem__(self, key: str, value: bytes) -> None: pass + def __delitem__(self, key: str) -> None: pass + def __contains__(self, key: str) -> bool: pass + + def get(self, key: str, default: Optional[bytes] = None) -> Optional[bytes]: pass + def keys(self, prefix: Optional[str] = None) -> Iterator[str]: pass + + def __enter__(self): return self + def __exit__(self, exc_type, exc_val, exc_tb): pass + +class KVStoreList: + def __iter__(self) -> Iterator[str]: pass + @property + def cursor(self) -> Optional[str]: pass + +# Async variants +class AsyncKVStore(KVStore): + async def lookup(self, key: str) -> Optional[KVStoreEntry]: pass + async def insert(self, key: str, value: bytes, options: Optional[InsertOptions] = None) -> None: pass + async def delete(self, key: str) -> bool: pass + async def list(self, options: Optional[ListOptions] = None) -> 'KVStoreList': pass +``` + +## Usage Examples + +```python +# Basic usage +from fastly_compute import KVStore + +store = KVStore.open("my-store") + +# Simple get/set +store["user:123"] = b"John Doe" + +# Advanced insert +from fastly_compute import InsertMode, InsertOptions + +options = InsertOptions(mode=InsertMode.ADD, time_to_live_sec=3600) +store.insert("session:abc", session_data, options) +``` + +## Deferred Features + +- **Batch Operations**: Bulk insert/delete is not supported by the underlying API. +- **Complex Queries**: Only prefix listing is supported. +- **Transactions**: No multi-key transactions available. \ No newline at end of file From 099d92702def6c2b5268e76e431b539e610dcf15 Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Tue, 13 Jan 2026 16:27:06 -0600 Subject: [PATCH 04/26] Add Secret Store design --- planning/secret-store.md | 117 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 planning/secret-store.md diff --git a/planning/secret-store.md b/planning/secret-store.md new file mode 100644 index 0000000..069c2d3 --- /dev/null +++ b/planning/secret-store.md @@ -0,0 +1,117 @@ +# Secret Store API Design + +## Overview + +Design for the Secret Store API, providing secure access to sensitive credentials and secrets at the edge. + +## WIT Interface Reference + +```wit +interface secret-store { + use types.{error, open-error}; + + resource secret { + from-bytes: static func(bytes: list) -> result; + plaintext: func(max-len: u64) -> result, error>; + } + + resource store { + open: static func(name: string) -> result; + get: func(key: string) -> result, error>; + } +} +``` + +Generated stubs: `stubs/wit_world/imports/secret_store.py` + +## API Design + +```python +from typing import Optional + +class Secret: + """A secret value retrieved from Secret Store.""" + + @classmethod + def from_bytes(cls, data: bytes) -> 'Secret': + """Create a secret from raw bytes (not recommended for production).""" + pass + + def plaintext(self) -> bytes: + """Get the plaintext value of this secret.""" + pass + + def plaintext_str(self, encoding: str = 'utf-8') -> str: + """Get the plaintext value as a string.""" + return self.plaintext().decode(encoding) + + def __enter__(self): return self + def __exit__(self, exc_type, exc_val, exc_tb): pass + + # Prevent accidental exposure + def __str__(self) -> str: return "" + def __repr__(self) -> str: return "" + +class SecretStore: + """Interface to a Fastly Secret Store.""" + + @classmethod + def open(cls, name: str) -> 'SecretStore': pass + + def get(self, key: str) -> Optional[Secret]: pass + + # Dict-like interface + def __getitem__(self, key: str) -> Secret: pass + def __contains__(self, key: str) -> bool: pass + + def __enter__(self): return self + def __exit__(self, exc_type, exc_val, exc_tb): pass +``` + +## Usage Examples + +```python +from fastly_compute import SecretStore + +# Open a secret store +secrets = SecretStore.open("production-secrets") + +# Retrieve a secret +if "api-key" in secrets: + api_key_secret = secrets["api-key"] + # Use api_key... + +# Use with backend client certificates +from fastly_compute import Backend + +cert = secrets["client-cert"].plaintext_str() +key = secrets["client-key"] # Keep as Secret! + +backend = Backend.register_dynamic( + "secure-backend", + "api.example.com:443", + client_cert=cert, + client_key=key # Pass Secret directly +) +``` + +## Deferred Features + +- **Secret Management**: Creating or updating secrets via the API is not supported by the underlying platform. +- **Async Access**: Secret store operations are currently synchronous only. + +## Implementation Notes + +1. **WIT Resource Wrapping**: Properly wrap WIT secret resource handles +2. **Memory Safety**: Ensure plaintext secrets are cleared from memory when possible +3. **String Representation**: Override __str__ and __repr__ to prevent accidental logging +4. **API Integration**: Ensure Secret objects work with Backend and other APIs expecting secrets + +### Comparison with Other SDKs + +- **Rust SDK**: The `Secret` object design is very similar: + - Wraps a handle + - Lazy plaintext access + - `from_bytes` exists but warns about usage + - Returns `Bytes` (copy-on-write ref counting) to avoid unnecessary copies +- **Python SDK**: Matches this pattern, ensuring a consistent security model across SDKs. \ No newline at end of file From 2d314070b4bccbd3218fc6a72f5429d26f279f5f Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Tue, 13 Jan 2026 16:27:06 -0600 Subject: [PATCH 05/26] Add Config Store design --- planning/config-store.md | 176 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 planning/config-store.md diff --git a/planning/config-store.md b/planning/config-store.md new file mode 100644 index 0000000..5cbec25 --- /dev/null +++ b/planning/config-store.md @@ -0,0 +1,176 @@ +# Config Store API Design + +## Overview + +Design for the Config Store API (and legacy Dictionary API), providing access to configuration key-value pairs. + +## WIT Interface Reference + +```wit +interface config-store { + use types.{error, open-error}; + + resource store { + open: static func(name: string) -> result; + get: func(key: string, max-len: u64) -> result, error>; + } +} +``` + +Generated stubs: `stubs/wit_world/imports/config_store.py` + +## API Design + +```python +from typing import Optional, Any +import json + +class ConfigStore: + """Interface to Fastly Config Store (or Dictionary). + + Config Stores provide read-only access to configuration data + that can be updated without redeploying code. + """ + + @classmethod + def open(cls, name: str) -> 'ConfigStore': + """Open a Config Store by name. + + Args: + name: The name of the Config Store + + Returns: + ConfigStore instance + + Raises: + ValueError: If name is invalid + RuntimeError: If store doesn't exist + """ + pass + + def get(self, key: str, default: Optional[str] = None) -> Optional[str]: + """Get a configuration value. + + Args: + key: The configuration key + default: Default value if key not found + + Returns: + Configuration value or default + """ + pass + + def has(self, key: str) -> bool: + """Check if a key exists in the config store. + + Args: + key: The configuration key + + Returns: + True if key exists, False otherwise + """ + pass + + def get_int(self, key: str, default: Optional[int] = None) -> Optional[int]: + """Get a configuration value as an integer.""" + value = self.get(key) + if value is None: + return default + try: + return int(value) + except ValueError: + return default + + def get_bool(self, key: str, default: Optional[bool] = None) -> Optional[bool]: + """Get a configuration value as a boolean. + + Recognizes: true/false, yes/no, 1/0, on/off (case-insensitive) + """ + value = self.get(key) + if value is None: + return default + + lower = value.lower() + if lower in ('true', 'yes', '1', 'on'): + return True + elif lower in ('false', 'no', '0', 'off'): + return False + else: + return default + + def get_float(self, key: str, default: Optional[float] = None) -> Optional[float]: + """Get a configuration value as a float.""" + value = self.get(key) + if value is None: + return default + try: + return float(value) + except ValueError: + return default + + def get_json(self, key: str, default: Any = None) -> Any: + """Get a configuration value parsed as JSON.""" + value = self.get(key) + if value is None: + return default + try: + return json.loads(value) + except json.JSONDecodeError: + return default + + # Dict-like interface + + def __getitem__(self, key: str) -> str: + """Get value using dict syntax: value = config[key]""" + value = self.get(key) + if value is None: + raise KeyError(key) + return value + + def __contains__(self, key: str) -> bool: + """Check if key exists: key in config""" + return self.has(key) + + # Context manager + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + pass + +# Legacy alias +Dictionary = ConfigStore +``` + +## Usage Examples + +```python +from fastly_compute import ConfigStore + +# Open a config store +config = ConfigStore.open("app-config") + +# Basic string access +api_url = config.get("api_url", "https://api.example.com") + +# Type conversion helpers +max_retries = config.get_int("max_retries", default=3) +debug_mode = config.get_bool("debug", default=False) +timeout = config.get_float("timeout_sec", default=30.0) + +# JSON configuration +features = config.get_json("features", default={ + "new_ui": False, + "beta_api": False +}) + +# Dict-like access +if "feature_flag_x" in config: + enabled = config.get_bool("feature_flag_x") +``` + +## Deferred Features + +- **Environment Variable Fallback**: Explicitly out of scope. We follow the Rust/Go pattern of strictly wrapping the Config Store API. +- **Write Operations**: Config Stores are read-only at the edge. +- **Iteration**: Listing all keys is not supported by the underlying API. From b8b5529e14cb4341d12d6d4d596704583782ff9a Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Tue, 13 Jan 2026 16:27:06 -0600 Subject: [PATCH 06/26] Add Geo API design --- planning/geo.md | 156 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 planning/geo.md diff --git a/planning/geo.md b/planning/geo.md new file mode 100644 index 0000000..44cfb99 --- /dev/null +++ b/planning/geo.md @@ -0,0 +1,156 @@ +# Geo API Design + +## Overview + +Design for the Geo API, providing geographic and network intelligence based on IP addresses. + +## WIT Interface Reference + +```wit +/// [Geographic data] for IP addresses. +/// +/// [Geographic data]: https://www.fastly.com/blog/improve-performance-and-gain-better-end-user-intelligence-geoip-geography-detection +interface geo { + use types.{error, ip-address}; + + /// Looks up the geographic data associated with a particular IP address. + /// + /// Returns a list of bytes containing JSON-encoded geographic data. See [here] for descriptions + /// of the JSON fields. + /// + /// [here]: https://www.fastly.com/documentation/reference/vcl/variables/geolocation/ + lookup: func(ip-addr: ip-address, max-len: u64) -> result; +} +``` + +Generated stubs: `stubs/wit_world/imports/geo.py` + +## API Design + +```python +from typing import Optional, Any +from dataclasses import dataclass, field +from ipaddress import IPv4Address, IPv6Address, ip_address + +@dataclass +class Geo: + """Geographic and network information for an IP address. + + Provides strongly-typed access to geographic data. + """ + # Location + city: str = "" + country_code: str = "" # ISO 3166-1 alpha-2 + country_code3: str = "" # ISO 3166-1 alpha-3 + country_name: str = "" + continent_code: str = "" + region: Optional[str] = None # ISO 3166-2 region code + postal_code: str = "" + + # Coordinates + latitude: float = 0.0 + longitude: float = 0.0 + metro_code: int = 0 + + # Network + as_number: int = 0 # Autonomous System Number + as_name: str = "" # Autonomous System Name + conn_speed: str = "" # e.g., "broadband", "mobile" + conn_type: str = "" # e.g., "wired", "wireless" + + # Proxy detection + proxy_type: str = "" # e.g., "anonymous", "transparent" + proxy_description: str = "" + + # Time zone + utc_offset: int = 0 # Offset in seconds + + # Raw JSON data for forward compatibility + _raw: dict = field(default_factory=dict, repr=False) + + @classmethod + def from_dict(cls, data: dict) -> 'Geo': + """Create Geo instance from dictionary.""" + # Helper to safely get values with correct types + def get_float(k): + v = data.get(k) + return float(v) if v is not None else 0.0 + + def get_int(k): + v = data.get(k) + return int(v) if v is not None else 0 + + return cls( + city=data.get("city", ""), + country_code=data.get("country_code", "") or data.get("country.code", ""), + country_code3=data.get("country_code3", "") or data.get("country.code3", ""), + country_name=data.get("country_name", "") or data.get("country.name", ""), + continent_code=data.get("continent_code", "") or data.get("continent", ""), + region=data.get("region"), + postal_code=data.get("postal_code", "") or data.get("postal.code", ""), + latitude=get_float("latitude"), + longitude=get_float("longitude"), + metro_code=get_int("metro_code") or get_int("metro.code"), + as_number=get_int("as_number") or get_int("as.number"), + as_name=data.get("as_name", "") or data.get("as.name", ""), + conn_speed=data.get("conn_speed", "") or data.get("conn.speed", ""), + conn_type=data.get("conn_type", "") or data.get("conn.type", ""), + proxy_type=data.get("proxy_type", "") or data.get("proxy.type", ""), + proxy_description=data.get("proxy_description", "") or data.get("proxy.description", ""), + utc_offset=get_int("utc_offset") or get_int("utc.offset"), + _raw=data + ) + +def lookup(ip: str | IPv4Address | IPv6Address) -> Optional[Geo]: + """Look up geographic information for an IP address. + + Args: + ip: IP address as string or ipaddress object + + Returns: + Geo object if lookup succeeds, None otherwise (e.g. private IP) + """ + pass + +def lookup_client(request: 'Request') -> Optional[Geo]: + """Look up geographic information for the client IP. + + Convenience method that extracts the client IP from the + request and performs a lookup. + """ + pass +``` + +## Usage Examples + +```python +from fastly_compute import geo + +# Basic lookup +location = geo.lookup("203.0.113.42") +if location: + print(f"Location: {location.city}, {location.country_name}") + print(f"Coordinates: {location.latitude}, {location.longitude}") + print(f"AS Number: {location.as_number}") + +# Look up client IP from request +def handle_request(request): + client_geo = geo.lookup_client(request) + if client_geo: + # Customize response based on location + if client_geo.country_code == "US": + return Response(body=b"Hello from the US!") + elif client_geo.continent_code == "EU": + return Response( + body=b"Hello from Europe!", + headers={"Content-Language": "en-GB"} + ) + + return Response(body=b"Hello, World!") +``` + +## Deferred Features + +- **Complex Enums**: Unlike Rust/Go, we use strings for enums (ConnType, ProxyType, etc.) for simplicity. +- **Advanced Caching**: Built-in caching is out of scope; users can implement their own if needed. +- **IP Parsing**: We rely on standard library `ipaddress` rather than implementing custom parsing logic. From 282618f37d4c6583cb04fe84eb0a752383ad69ec Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Tue, 13 Jan 2026 16:27:06 -0600 Subject: [PATCH 07/26] Add Logging API design --- planning/logging.md | 84 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 planning/logging.md diff --git a/planning/logging.md b/planning/logging.md new file mode 100644 index 0000000..d71104e --- /dev/null +++ b/planning/logging.md @@ -0,0 +1,84 @@ +# Logging API Design + +## Overview + +Design for the Logging API, allowing Compute services to send logs to configured Fastly log endpoints. + +## WIT Interface Reference + +```wit +interface log { + use types.{error, open-error}; + + /// A logging endpoint. + resource endpoint { + /// Tries to get an endpoint by name. + open: static func(name: string) -> result; + + /// Writes a data to the given endpoint. + write: func(msg: list); + } +} +``` + +Generated stubs: `stubs/wit_world/imports/log.py` + +## API Design + +```python +from typing import Optional +import logging + +class LogEndpoint: + """A Fastly logging endpoint.""" + + @classmethod + def open(cls, name: str) -> 'LogEndpoint': pass + + def write(self, msg: bytes | str) -> None: pass + + def __enter__(self): return self + def __exit__(self, exc_type, exc_val, exc_tb): pass + +class FastlyLogHandler(logging.Handler): + """A logging handler that sends logs to a Fastly endpoint.""" + + def __init__(self, endpoint_name: str, level=logging.NOTSET): + """Initialize the handler with an endpoint name.""" + pass + + def emit(self, record: logging.LogRecord): + """Emit a record to the Fastly logging endpoint.""" + pass +``` + +## Usage Examples + +```python +# Direct usage +from fastly_compute import LogEndpoint + +endpoint = LogEndpoint.open("my_logs") +endpoint.write("Hello from Fastly Compute!") + +# Using Python standard logging +import logging +from fastly_compute import FastlyLogHandler + +logger = logging.getLogger("my_app") +logger.setLevel(logging.INFO) +logger.addHandler(FastlyLogHandler("my_logs")) + +logger.info("Structured log message", extra={"user_id": 123}) +``` + +## Deferred Features + +- **Async Logging**: The underlying API is synchronous. Async wrappers could be added later if needed. +- **Log Formatting**: Users can use standard Python formatters with the handler. + +## Implementation Notes + +1. **Error Handling**: `open()` should raise specific exceptions for missing endpoints. +2. **Encoding**: `write()` should handle both bytes and strings (UTF-8 encoding). +3. **Resource Management**: Endpoints are resources but don't strictly require closing; however, context manager support is good practice. From 26f6be4e1f12dcafa72b7904144e49eb07a066f5 Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Tue, 13 Jan 2026 16:27:06 -0600 Subject: [PATCH 08/26] Add Core Cache design --- planning/cache.md | 173 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 planning/cache.md diff --git a/planning/cache.md b/planning/cache.md new file mode 100644 index 0000000..479b604 --- /dev/null +++ b/planning/cache.md @@ -0,0 +1,173 @@ +# Cache API Design + +## Overview + +Design for the Core Cache API, providing access to Fastly's advanced caching capabilities including request collapsing, stale-while-revalidate, and streaming. + +## WIT Interface Reference + +```wit +interface cache { + use types.{error}; + use http-body.{body}; + use http-req.{request}; + + resource entry { + lookup: static func(key: list, options: lookup-options) -> result; + transaction-lookup: static func(key: list, options: lookup-options) -> result; + transaction-insert: func(options: write-options) -> result; + transaction-update: func(options: write-options) -> result<_, error>; + + get-state: func() -> result; + get-user-metadata: func(max-len: u64) -> result>, error>; + get-body: func(options: get-body-options) -> result; + get-length: func() -> result, error>; + get-max-age-ns: func() -> result, error>; + get-stale-while-revalidate-ns: func() -> result, error>; + get-age-ns: func() -> result, error>; + get-hits: func() -> result, error>; + transaction-cancel: func() -> result<_, error>; + } + + record lookup-options { + request-headers: option>, + always-use-requested-range: bool, + extra: option>, + } + + record write-options { + max-age-ns: duration-ns, + request: borrow, + vary: list, + initial-age-ns: duration-ns, + stale-while-revalidate-ns: duration-ns, + surrogate-keys: list, + length: object-length, + user-metadata: list, + sensitive-data: bool, + } +} +``` + +Generated stubs: `stubs/wit_world/imports/cache.py` + +## API Design + +```python +from typing import Optional, List, Any +from dataclasses import dataclass +from enum import Enum + +class LookupState(Enum): + FOUND = "found" + USABLE = "usable" + STALE = "stale" + MUST_INSERT_OR_UPDATE = "must-insert-or-update" + +@dataclass +class LookupOptions: + request_headers: Optional['Request'] = None + +@dataclass +class WriteOptions: + max_age_ns: int + request: 'Request' + vary: List[str] + initial_age_ns: int = 0 + stale_while_revalidate_ns: int = 0 + surrogate_keys: List[str] = None + length: int = 0 + user_metadata: bytes = b"" + sensitive_data: bool = False + +class CacheEntry: + """Represents a handle to a cache entry or transaction.""" + + @staticmethod + def lookup(key: str | bytes, options: Optional[LookupOptions] = None) -> 'CacheEntry': pass + + @staticmethod + def transaction_lookup(key: str | bytes, options: Optional[LookupOptions] = None) -> 'CacheEntry': pass + + def insert(self, options: WriteOptions) -> 'Body': + """Insert an object into the cache. Returns a writable body stream.""" + pass + + def update(self, options: WriteOptions) -> None: + """Update metadata without changing the body.""" + pass + + def get_state(self) -> LookupState: pass + + def get_body(self) -> 'Body': pass + + @property + def length(self) -> Optional[int]: pass + + @property + def hits(self) -> Optional[int]: pass + + @property + def age_ns(self) -> Optional[int]: pass + + def cancel(self) -> None: pass + + def __enter__(self): return self + def __exit__(self, exc_type, exc_val, exc_tb): pass + +class SimpleCache: + """Simplified synchronous cache API.""" + + @staticmethod + def get(key: str) -> Optional[bytes]: pass + + @staticmethod + def set(key: str, value: bytes, ttl: int) -> None: pass + + @staticmethod + def get_stream(key: str) -> Optional['Body']: pass +``` + +## Usage Examples + +```python +from fastly_compute import CacheEntry, WriteOptions, LookupOptions + +# Simple Lookup +entry = CacheEntry.lookup("my-key") +if entry.get_state() == "found": + body = entry.get_body() + data = body.read() + +# Transaction (Read-Through Caching) +entry = CacheEntry.transaction_lookup("my-key") +if entry.get_state() == "must-insert-or-update": + # Cache miss - fetch and insert + backend_resp = fetch_from_backend(...) + + options = WriteOptions( + max_age_ns=60_000_000_000, # 60s + request=req, + vary=[] + ) + writer = entry.insert(options) + writer.write(backend_resp.body.read()) + writer.close() + + # Return newly cached body + return Response(body=entry.get_body()) +else: + # Cache hit + return Response(body=entry.get_body()) +``` + +## Deferred Features + +- **Async Cache API**: `transaction-lookup-async` and other async variants are deferred for initial release. +- **Partial Updates**: Not supported by core API currently. + +## Implementation Notes + +1. **State Management**: `CacheEntry` encapsulates the state returned by `get-state`. +2. **Body Integration**: Uses the standard SDK `Body` class for streaming I/O. +3. **Strings/Bytes**: Keys can be string or bytes (auto-encoded). From d6d5f3ba23721adf677f0e4995c2f84c5758ad25 Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Tue, 13 Jan 2026 16:27:06 -0600 Subject: [PATCH 09/26] Add HTTP Cache design --- planning/http-cache.md | 93 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 planning/http-cache.md diff --git a/planning/http-cache.md b/planning/http-cache.md new file mode 100644 index 0000000..4fbe5c6 --- /dev/null +++ b/planning/http-cache.md @@ -0,0 +1,93 @@ +# HTTP Cache API Design + +## Overview + +Design for the HTTP Cache API, which allows caching of full HTTP responses (transactional caching). This is distinct from the Core Cache API which caches byte streams. + +## WIT Interface Reference + +```wit +interface http-cache { + use http-req.{request}; + use http-resp.{response, response-with-body}; + use http-body.{body}; + + resource entry { + transaction-lookup: static func( + req-handle: borrow, + options: lookup-options, + ) -> result; + + transaction-insert: func( + resp-handle: response, + options: write-options, + ) -> result; + + transaction-insert-and-stream-back: func( + resp-handle: response, + options: write-options, + ) -> result, error>; + + transaction-update: func( + resp-handle: response, + options: write-options, + ) -> result<_, error>; + } +} +``` + +Generated stubs: `stubs/wit_world/imports/http_cache.py` + +## API Design + +```python +from typing import Optional +from dataclasses import dataclass + +@dataclass +class HttpCacheOptions: + max_age_ns: int = 0 + # ... other options similar to Core Cache but for HTTP + +class HttpCacheEntry: + """Transaction handle for HTTP Cache.""" + + @staticmethod + def lookup(request: 'Request', options: Optional[HttpCacheOptions] = None) -> 'HttpCacheEntry': + pass + + def insert(self, response: 'Response', options: Optional[HttpCacheOptions] = None) -> 'Body': + """Insert response into cache and return a body stream for writing the payload.""" + pass + + def insert_and_stream_back(self, response: 'Response') -> tuple['Body', 'HttpCacheEntry']: + pass +``` + +## Usage Examples + +```python +from fastly_compute import HttpCacheEntry, Request + +req = Request("GET", "https://example.com/api") +entry = HttpCacheEntry.lookup(req) + +if entry.state == "must-insert-or-update": + # Fetch from backend + backend_resp = fetch(req) + + # Insert into cache + writer = entry.insert(backend_resp) + writer.write(backend_resp.body.read()) + writer.close() + + return Response(body=entry.get_body()) +``` + +## Deferred Features + +- **Complex Updates**: Advanced transaction update scenarios. + +## Implementation Notes + +1. **Response Handling**: The `insert` method consumes the `Response` object (its headers/metadata) but returns a `Body` for the data. From d3d03a7c21bed17ac649612d0acfaec7a0cb7001 Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Tue, 13 Jan 2026 16:27:06 -0600 Subject: [PATCH 10/26] Add ERL design --- planning/erl.md | 152 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 planning/erl.md diff --git a/planning/erl.md b/planning/erl.md new file mode 100644 index 0000000..8769413 --- /dev/null +++ b/planning/erl.md @@ -0,0 +1,152 @@ +# Edge Rate Limiting (ERL) API Design + +## Overview + +Design for Edge Rate Limiting, providing rate counters and penalty boxes to control traffic. + +## WIT Interface Reference + +```wit +interface erl { + use types.{error, open-error}; + + resource rate-counter { + open: static func(name: string) -> result; + get-name: func() -> string; + check-rate: func( + entry: string, + delta: u32, + window: u32, + limit: u32, + penalty-box: borrow, + ttl: u32, + ) -> result; + increment: func(entry: string, delta: u32) -> result<_, error>; + lookup-rate: func(entry: string, window: u32) -> result; + lookup-count: func(entry: string, duration: u32) -> result; + } + + resource penalty-box { + open: static func(name: string) -> result; + get-name: func() -> string; + add: func(entry: string, ttl: u32) -> result<_, error>; + has: func(entry: string) -> result; + } +} +``` + +Generated stubs: `stubs/wit_world/imports/erl.py` + +## API Design + +```python +from typing import Optional + +class PenaltyBox: + """A penalty box for temporarily blocking entries.""" + + @classmethod + def open(cls, name: str) -> 'PenaltyBox': pass + + @property + def name(self) -> str: pass + + def add(self, entry: str, ttl: int) -> None: + """Add an entry to the penalty box for a duration (in minutes).""" + pass + + def has(self, entry: str) -> bool: + """Check if an entry is in the penalty box.""" + pass + + def __enter__(self): return self + def __exit__(self, exc_type, exc_val, exc_tb): pass + +class RateCounter: + """A counter for tracking request rates.""" + + @classmethod + def open(cls, name: str) -> 'RateCounter': pass + + @property + def name(self) -> str: pass + + def check_rate( + self, + entry: str, + delta: int, + window: int, + limit: int, + penalty_box: PenaltyBox, + ttl: int + ) -> bool: + """Check rate and potentially add to penalty box. + + Args: + entry: The key to check (e.g., IP address) + delta: Amount to increment by + window: Time window in seconds (10 or 60) + limit: Request limit + penalty_box: Penalty box to add to if limit exceeded + ttl: Duration to penalize in minutes + + Returns: + True if penalized, False otherwise + """ + pass + + def increment(self, entry: str, delta: int = 1) -> None: + """Increment the counter for an entry.""" + pass + + def lookup_rate(self, entry: str, window: int) -> int: + """Look up current rate (RPS) for a window.""" + pass + + def lookup_count(self, entry: str, duration: int) -> int: + """Look up total count for a duration.""" + pass + + def __enter__(self): return self + def __exit__(self, exc_type, exc_val, exc_tb): pass +``` + +## Usage Examples + +```python +from fastly_compute import RateCounter, PenaltyBox + +# Open resources +rc = RateCounter.open("requests") +pb = PenaltyBox.open("abuse") + +client_ip = "192.0.2.1" + +# check_rate atomically increments and checks against limit +# If rate > 100 rps over 60s window, add to penalty box for 10 minutes +is_blocked = rc.check_rate( + entry=client_ip, + delta=1, + window=60, + limit=100, + penalty_box=pb, + ttl=10 +) + +if is_blocked: + return Response(status=429, body=b"Rate limit exceeded") + +# Check penalty box directly (e.g. for previously blocked clients) +if pb.has(client_ip): + return Response(status=429, body=b"You are in the penalty box") +``` + +## Deferred Features + +- **Async Support**: Operations are synchronous. +- **Complex Policies**: The SDK provides low-level primitives; higher-level policies (e.g., sliding window logic) are left to user implementation if not covered by `check_rate`. + +## Implementation Notes + +1. **TTL Units**: Note that TTLs for `check_rate` and `add` are in **minutes**, while windows are in **seconds**. +2. **Window Limits**: Valid windows are typically 1s, 10s, 60s depending on platform configuration. From 18de4e7b2faf7dec5edee6b498e62baad1ee1d2a Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Tue, 13 Jan 2026 16:27:06 -0600 Subject: [PATCH 11/26] Add ACL design --- planning/acl.md | 79 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 planning/acl.md diff --git a/planning/acl.md b/planning/acl.md new file mode 100644 index 0000000..a4e2f38 --- /dev/null +++ b/planning/acl.md @@ -0,0 +1,79 @@ +# ACL API Design + +## Overview + +Design for interacting with Access Control Lists (ACLs). + +## WIT Interface Reference + +```wit +interface acl { + use types.{error, open-error, ip-address}; + use http-body.{body}; + + resource acl { + open: static func(name: string) -> result; + lookup: func(ip-addr: ip-address) -> result, acl-error>; + } + + enum acl-error { too-many-requests, generic-error } +} +``` + +Generated stubs: `stubs/wit_world/imports/acl.py` + +## API Design + +```python +from typing import Optional, Any +from ipaddress import IPv4Address, IPv6Address +import json + +class ACL: + """An Access Control List.""" + + @classmethod + def open(cls, name: str) -> 'ACL': pass + + def lookup(self, ip: str | IPv4Address | IPv6Address) -> Optional[dict]: + """Look up an IP address in the ACL. + + Returns: + Dictionary of metadata if found, None if not found. + """ + pass + + def __enter__(self): return self + def __exit__(self, exc_type, exc_val, exc_tb): pass +``` + +## Usage Examples + +```python +from fastly_compute import ACL + +# Open an ACL +blocklist = ACL.open("my-blocklist") + +client_ip = "203.0.113.1" + +# Check if IP is in the ACL +entry = blocklist.lookup(client_ip) +if entry: + # IP found in blocklist + print(f"Blocked: {entry.get('reason', 'no reason')}") + return Response(status=403, body=b"Access Denied") + +# IP not found +return Response(status=200, body=b"OK") +``` + +## Deferred Features + +- **ACL Management**: Creating or updating ACLs via this API is not supported (read-only). +- **Non-IP Lookups**: Currently only IP address lookup is supported. + +## Implementation Notes + +1. **Body Parsing**: The WIT interface returns a `body` handle on match. The SDK should read and parse this body (assumed JSON) automatically for convenience. +2. **Error Handling**: Map `acl-error` to appropriate Python exceptions. From 2b4e577a7ed0a05e1c6dd1bbdc8c4d99d4730850 Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Tue, 13 Jan 2026 16:27:06 -0600 Subject: [PATCH 12/26] Add NGWAF design --- planning/ngwaf.md | 92 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 planning/ngwaf.md diff --git a/planning/ngwaf.md b/planning/ngwaf.md new file mode 100644 index 0000000..c13da68 --- /dev/null +++ b/planning/ngwaf.md @@ -0,0 +1,92 @@ +# NGWAF (Security) API Design + +## Overview + +Design for the Next-Gen WAF (Security) API, providing request inspection using the NGWAF lookaside service. + +## WIT Interface Reference + +```wit +interface security { + use http-req.{request, body, ip-address, error}; + + /// Inspects request HTTP traffic using the [NGWAF] lookaside service. + /// + /// Returns a JSON-encoded string. + /// + /// [NGWAF]: https://docs.fastly.com/en/ngwaf/ + inspect: func( + request: borrow, + body: borrow, + options: inspect-options, + max-len: u64 + ) -> result; + + record inspect-options { + corp: option, + workspace: option, + override-client-ip: option, + extra: option>, + } + + resource extra-inspect-options {} +} +``` + +Generated stubs: `stubs/wit_world/imports/security.py` + +## API Design + +```python +from typing import Optional, Dict, Any +from dataclasses import dataclass +from ipaddress import IPv4Address, IPv6Address + +@dataclass +class InspectOptions: + corp: Optional[str] = None + workspace: Optional[str] = None + override_client_ip: Optional[str | IPv4Address | IPv6Address] = None + +class Security: + """Interface to Fastly NGWAF (Security).""" + + @staticmethod + def inspect( + request: 'Request', + body: 'Body', + options: Optional[InspectOptions] = None + ) -> Dict[str, Any]: + """Inspect request traffic using NGWAF. + + Returns: + Dictionary containing inspection results (tags, signals, etc). + """ + pass +``` + +## Usage Examples + +```python +from fastly_compute import Security, InspectOptions + +def handle_request(req): + # Basic inspection + result = Security.inspect(req, req.body) + + if result.get("action") == "block": + return Response(status=403, body=b"Blocked by NGWAF") + + # Inspection with options + options = InspectOptions( + corp="my-corp", + workspace="production", + override_client_ip="1.2.3.4" + ) + result = Security.inspect(req, req.body, options) +``` + +## Implementation Notes + +1. **JSON Response**: The `inspect` function returns a JSON string. The SDK should parse this into a Python dict. +2. **Body Handling**: Note that `inspect` takes a `borrow`. It does not consume the body, allowing it to be read later. From 202aead0496a5914b7c6fea23061e00673ae583b Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Tue, 13 Jan 2026 16:27:06 -0600 Subject: [PATCH 13/26] Add Cache Purge design --- planning/cache-purge.md | 79 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 planning/cache-purge.md diff --git a/planning/cache-purge.md b/planning/cache-purge.md new file mode 100644 index 0000000..f3b7d71 --- /dev/null +++ b/planning/cache-purge.md @@ -0,0 +1,79 @@ +# Cache Purge API Design + +## Overview + +Design for programmatically purging content from the Fastly cache. + +## WIT Interface Reference + +```wit +interface purge { + use types.{error}; + + record purge-options { + soft-purge: bool, + extra: option>, + } + + purge-surrogate-key: func( + surrogate-keys: string, + purge-options: purge-options, + ) -> result<_, error>; + + purge-surrogate-key-verbose: func( + surrogate-keys: string, + purge-options: purge-options, + max-len: u64, + ) -> result; +} +``` + +Generated stubs: `stubs/wit_world/imports/purge.py` + +## API Design + +```python +from typing import Optional + +def purge_surrogate_key(surrogate_key: str, soft: bool = False) -> None: + """Purge content associated with a surrogate key. + + Args: + surrogate_key: Key to purge (space-separated for multiple) + soft: If True, mark as stale rather than removing (Soft Purge) + """ + pass + +def purge_surrogate_key_verbose(surrogate_key: str, soft: bool = False) -> str: + """Purge content and return a purge ID. + + Returns: + JSON string containing purge ID (e.g. {"1234-1234..."}) + """ + pass +``` + +## Usage Examples + +```python +from fastly_compute import purge_surrogate_key + +# Hard purge +purge_surrogate_key("product:123") + +# Soft purge (mark as stale) +purge_surrogate_key("catalog", soft=True) + +# Multiple keys +purge_surrogate_key("product:123 category:electronics") +``` + +## Deferred Features + +- **URL Purge**: Not supported by this interface (Surrogate Key purge is best practice). +- **Global Purge**: `purge_all` is not exposed here. + +## Implementation Notes + +1. **Surrogate Keys**: Fastly supports space-separated keys in a single string. +2. **Error Handling**: Raises exception on failure. From f4c37a680fd8f9d91b8c0531da1bf2a1682972a4 Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Tue, 13 Jan 2026 16:27:06 -0600 Subject: [PATCH 14/26] Add Trailers design --- planning/trailers.md | 76 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 planning/trailers.md diff --git a/planning/trailers.md b/planning/trailers.md new file mode 100644 index 0000000..bae85c0 --- /dev/null +++ b/planning/trailers.md @@ -0,0 +1,76 @@ +# Trailers API Design + +## Overview + +Design for handling HTTP Trailers in Request and Response bodies. + +## WIT Interface Reference + +```wit +interface http-body { + resource body { + append-trailer: func(name: string, value: list) -> result<_, error>; + get-trailer-names: func(max-len: u64, cursor: u32) -> result>, trailer-error>; + get-trailer-value: func(name: string, max-len: u64) -> result>, trailer-error>; + get-trailer-values: func(name: string, max-len: u64, cursor: u32) -> result, option>, trailer-error>; + } + + variant trailer-error { + in-progress, + generic-error + } +} +``` + +Generated stubs: `stubs/wit_world/imports/http_body.py` + +## API Design + +Trailers are attached to the `Body` object. + +```python +from typing import Optional, Dict, List + +class Body: + # Existing methods... + + def append_trailer(self, name: str, value: str) -> None: + """Append a trailer to the body.""" + pass + + def get_trailer(self, name: str) -> Optional[str]: + """Get a trailer value.""" + pass + + def get_trailers(self) -> Dict[str, str]: + """Get all trailers.""" + pass +``` + +## Usage Examples + +```python +from fastly_compute import Response, Body + +def handle_request(req): + # Sending trailers + body = Body() + body.write(b"Chunk 1") + body.write(b"Chunk 2") + body.append_trailer("Server-Timing", "cpu;dur=2.4") + + return Response(body=body) + +# Reading trailers requires consuming the body +def read_trailers(resp): + data = resp.body.read() + timing = resp.body.get_trailer("Server-Timing") +``` + +## Deferred Features + +- **Async Trailers**: Reading trailers asynchronously while streaming body. + +## Implementation Notes + +1. **Trailer Availability**: Trailers are only available after the body has been fully read (for incoming) or before it's closed (for outgoing). From f42448b582f48661c8e723f8fa7293778d522435 Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Tue, 13 Jan 2026 16:27:06 -0600 Subject: [PATCH 15/26] Add Image Opto design --- planning/image-opto.md | 84 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 planning/image-opto.md diff --git a/planning/image-opto.md b/planning/image-opto.md new file mode 100644 index 0000000..a8a2567 --- /dev/null +++ b/planning/image-opto.md @@ -0,0 +1,84 @@ +# Image Optimization API Design + +## Overview + +Design for the Image Optimization API, allowing programmatic transformation of images via Fastly's Image Optimizer. + +## WIT Interface Reference + +```wit +interface image-optimizer { + use http-body.{body}; + use http-req.{request}; + use http-resp.{response-with-body}; + use backend.{backend}; + + record image-optimizer-transform-options { + sdk-claims-opts: option, + extra: option>, + } + + transform-image-optimizer-request: func( + origin-image-request: borrow, + origin-image-request-body: option, + origin-image-request-backend: borrow, + io-transform-options: image-optimizer-transform-options, + ) -> result; +} +``` + +Generated stubs: `stubs/wit_world/imports/image_optimizer.py` + +## API Design + +```python +from typing import Optional + +class ImageOptimizer: + """Interface to Fastly Image Optimizer.""" + + @staticmethod + def transform( + request: 'Request', + backend: str, + body: Optional['Body'] = None, + options: Optional[dict] = None + ) -> 'Response': + """Transform an image request using Fastly IO. + + Args: + request: The request containing image parameters (query string) + backend: The backend to fetch the original image from + body: Optional request body + options: Additional transform options + + Returns: + Response containing the transformed image + """ + pass +``` + +## Usage Examples + +```python +from fastly_compute import ImageOptimizer, Request + +def handle_image(req): + # Create a request for the original image with IO params + # e.g. /image.jpg?width=300&format=webp + io_req = Request("GET", "/image.jpg?width=300&format=webp") + + # Transform using IO + resp = ImageOptimizer.transform(io_req, backend="origin_images") + + return resp +``` + +## Deferred Features + +- **Parameter Builders**: Helper classes to build IO query strings (e.g. `ImageOptions().width(300)`). Users should construct query strings manually or use standard URL tools. + +## Implementation Notes + +1. **Backend**: Must be a valid backend capable of serving the source image. +2. **Request**: The request URI's query parameters control the transformation. From 81db62851d54173a23120b5692d5fc5ae2167b73 Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Tue, 13 Jan 2026 16:27:06 -0600 Subject: [PATCH 16/26] Add Runtime Info design --- planning/runtime-info.md | 70 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 planning/runtime-info.md diff --git a/planning/runtime-info.md b/planning/runtime-info.md new file mode 100644 index 0000000..ae9b32c --- /dev/null +++ b/planning/runtime-info.md @@ -0,0 +1,70 @@ +# Runtime Info API Design + +## Overview + +Design for accessing Fastly Compute runtime information such as memory usage, vCPU usage, and environment details. + +## WIT Interface Reference + +```wit +interface compute-runtime { + type vcpu-ms = u64; + type memory-mib = u32; + + get-vcpu-ms: func() -> vcpu-ms; + get-heap-mib: func() -> memory-mib; + get-sandbox-id: func() -> string; + + // Environment variable equivalents + // FASTLY_HOSTNAME / server.hostname + // FASTLY_REGION / server.region + // FASTLY_SERVICE_ID / req.service_id + // FASTLY_SERVICE_VERSION / req.service_version + // FASTLY_TRACE_ID / req.trace_id (same as sandbox-id) +} +``` + +Generated stubs: `stubs/wit_world/imports/compute_runtime.py` + +## API Design + +```python +class Runtime: + """Access to Compute runtime information.""" + + @staticmethod + def vcpu_ms() -> int: + """Get vCPU time consumed in milliseconds.""" + pass + + @staticmethod + def heap_usage_mib() -> int: + """Get current heap usage in MiB.""" + pass + + @staticmethod + def sandbox_id() -> str: + """Get the unique ID for the current sandbox instance.""" + pass +``` + +## Usage Examples + +```python +from fastly_compute import Runtime + +# Log resource usage +print(f"Memory: {Runtime.heap_usage_mib()} MiB") +print(f"CPU: {Runtime.vcpu_ms()} ms") + +# Trace correlation +trace_id = Runtime.sandbox_id() +``` + +## Deferred Features + +- **Environment Variables**: Access to other env vars (Service ID, Version, Hostname, Region) is typically done via `os.environ` which is populated by the runtime. We do not need explicit wrappers if standard `os.environ` works. + +## Implementation Notes + +1. **Benchmarking**: `vcpu_ms` includes hostcall time and should be used with caution for benchmarking. From 00e358db218ededbe63a8c1dbf525c0b9f5d641e Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Tue, 13 Jan 2026 16:27:06 -0600 Subject: [PATCH 17/26] Add Device Detection design --- planning/device-detection.md | 59 ++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 planning/device-detection.md diff --git a/planning/device-detection.md b/planning/device-detection.md new file mode 100644 index 0000000..7e240dd --- /dev/null +++ b/planning/device-detection.md @@ -0,0 +1,59 @@ +# Device Detection API Design + +## Overview + +Design for the Device Detection API, allowing lookup of device capabilities based on User-Agent strings. + +## WIT Interface Reference + +```wit +interface device-detection { + use types.{error}; + + /// Looks up the data associated with a particular User-Agent string. + /// + /// Returns a list of bytes containing JSON-encoded device data. + lookup: func(user-agent: string, max-len: u64) -> result, error>; +} +``` + +Generated stubs: `stubs/wit_world/imports/device_detection.py` + +## API Design + +```python +from typing import Optional, Dict, Any + +class DeviceDetection: + """Interface for device detection lookups.""" + + @staticmethod + def lookup(user_agent: str) -> Optional[Dict[str, Any]]: + """Look up device information for a User-Agent string. + + Returns: + Dictionary containing device data (brand, model, is_mobile, etc) + if found, None otherwise. + """ + pass +``` + +## Usage Examples + +```python +from fastly_compute import DeviceDetection + +user_agent = "Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X)..." +device = DeviceDetection.lookup(user_agent) + +if device: + if device.get("is_mobile"): + # Serve mobile content + pass + print(f"Device: {device.get('brand')} {device.get('model')}") +``` + +## Implementation Notes + +1. **JSON Parsing**: The SDK parses the JSON response into a dict. +2. **Field Names**: Preserves field names as returned by the platform. From d489e52cb7ebf3d7d34324dfee0dbdfe0c19fa41 Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Tue, 13 Jan 2026 16:27:06 -0600 Subject: [PATCH 18/26] Add Exceptions design --- planning/exceptions.md | 84 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 planning/exceptions.md diff --git a/planning/exceptions.md b/planning/exceptions.md new file mode 100644 index 0000000..f7b7aad --- /dev/null +++ b/planning/exceptions.md @@ -0,0 +1,84 @@ +# Idiomatic Exceptions Design + +## Overview + +Design for a Pythonic exception hierarchy and a mechanism to map low-level WIT errors to these exceptions via automated codegen. + +## Exception Hierarchy + +```python +class FastlyError(Exception): + """Base class for all Fastly Compute exceptions.""" + def __init__(self, message: str, wit_error: Optional[Any] = None): + super().__init__(message) + self.wit_error = wit_error + +class ResourceError(FastlyError): + """Resource open/access errors.""" + pass + +class ResourceNotFound(ResourceError): + """Resource not found (e.g. KV store name invalid).""" + pass + +class ResourceLimitExceeded(ResourceError): + """Quotas or limits exceeded.""" + pass + +class BackendError(FastlyError): + """Backend communication errors.""" + pass + +# KV Store Specific +class KVStoreError(FastlyError): pass +class KVKeyFound(KVStoreError): pass +class KVPreconditionFailed(KVStoreError): pass +class KVPayloadTooLarge(KVStoreError): pass +``` + +## Mapping Strategy: Automated Decorators + +We use a code generation tool (`tools/codegen.py`) to produce "safe" wrappers for all WIT functions. These wrappers use a decorator (`map_wit_error`) to handle exception translation automatically. + +### 1. The Decorator + +```python +def map_wit_error(mapping: Dict[str, Type[FastlyError]], default: Type[FastlyError]): + def decorator(func): + @wraps(func) + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except Err as e: + # Map error string (variant name) to Exception class + variant = str(e.value) + if variant in mapping: + raise mapping[variant](variant) from e + raise default(f"Generic error: {e.value}") from e + return wrapper + return decorator +``` + +### 2. Generated Code + +The codegen tool parses the WIT definitions to determine which errors a function can raise and generates the appropriate mapping dictionary and wrapped function. + +```python +# fastly_compute/wit/imports/kv_store.py (Generated) + +_KV_ERROR_MAP = { + 'bad-request': BadRequestError, + 'precondition-failed': KVPreconditionFailed, + # ... +} + +@map_wit_error(_KV_ERROR_MAP, default=KVStoreError) +def insert(key, body, options): + return _raw.insert(key, body, options) +``` + +## Benefits + +1. **Zero Boilerplate**: SDK developers use `fastly_compute.wit.imports.kv_store` which behaves like a normal Python module but raises proper exceptions. +2. **Consistency**: Exception mapping is derived directly from the WIT source of truth. +3. **Maintainability**: Regenerating bindings updates all mappings instantly. From a2464b84d40a15a44a7b1d6bd6e629d5113679c0 Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Tue, 13 Jan 2026 16:27:06 -0600 Subject: [PATCH 19/26] Add DevTools design --- planning/devtools.md | 69 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 planning/devtools.md diff --git a/planning/devtools.md b/planning/devtools.md new file mode 100644 index 0000000..e815419 --- /dev/null +++ b/planning/devtools.md @@ -0,0 +1,69 @@ +# Developer Tooling Design + +## Overview + +Design for the build chain and tooling required to convert Python applications into Fastly Compute WebAssembly components. + +## Goals + +1. **Zero-Config Defaults**: Works out of the box for standard structures. +2. **Fastly CLI Integration**: integrates seamlessly with `fastly compute build`. +3. **Single Binary**: Distributed as a standalone tool to minimize environment dependency issues. +4. **Performance**: Optimizes build time. + +## The `fastly-py` Tool + +We will provide a Rust-based CLI tool named **`fastly-py`**. + +### Architecture + +- **Language**: Rust +- **Distribution**: Standalone binary (via cargo install or downloadable releases). +- **Core Responsibility**: Orchestrate the build process by embedding or managing necessary sub-tools like `componentize-py` and `wac`. + +### Build Process (`fastly-py build`) + +1. **Discovery**: Analyze the project structure (`pyproject.toml`). +2. **Environment Prep**: Ensure a suitable Python environment is available. The tool will check for `uv` managed environments as the preferred path. +3. **Componentization**: + - Executes the componentization logic (wrapping `componentize-py`). + - Uses the `fastly:compute/service` WIT world. + - bundles necessary WIT files automatically. +4. **Composition**: + - Automatically composes the result with the `wasiless.wasm` adapter (embedded in the binary). +5. **Output**: Generates the final Wasm artifact (default: `bin/main.wasm`). + +### CLI Arguments + +```text +usage: fastly-py build [options] [entrypoint] + +positional arguments: + entrypoint Entry point module/file (default: main.py or app.py) + +options: + -o, --output FILE Output Wasm file (default: bin/main.wasm) + -v, --verbose Verbose output +``` + +## Moving Parts Strategy + +1. **`componentize-py`**: The Rust binary will manage the execution of componentization. It may bundle the python parts or manage a dedicated venv to ensure the correct version is used. +2. **`wasiless.wasm`**: This adapter will be embedded directly into the `fastly-py` Rust binary (using `include_bytes!`), eliminating the need for external file dependencies. +3. **WIT Definitions**: The authoritative `compute.wit` will also be embedded in the binary. + +## Implementation Plan + +1. **Project Location**: `src/fastly-py` (or similar) in the repo. +2. **Dependencies**: + - `clap`: For CLI argument parsing. + - `anyhow`: For error handling. + - `wac` (library): Use the `wac` crate directly if available, or bundle the binary. +3. **Embedding**: Use Rust's `include_bytes!` macro to compile static assets (adapter, WITs) into the binary. + +## Advantages of Rust-based Tool + +- **Portability**: Single binary, easier to distribute via Fastly CLI or CI/CD. +- **Robustness**: Static typing and better error handling for the build orchestration. +- **Speed**: Startup time is negligible; heavy lifting is still done by the Wasm engine but the driver is fast. +- **Isolation**: Can shield the user from dependency conflicts by managing the build environment explicitly. From 05c3423f052a2dbd1dd4fa8c8c1e7440a53c7c98 Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Tue, 13 Jan 2026 16:27:06 -0600 Subject: [PATCH 20/26] Add CLI Integration design --- planning/cli-integration.md | 81 +++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 planning/cli-integration.md diff --git a/planning/cli-integration.md b/planning/cli-integration.md new file mode 100644 index 0000000..dbe0431 --- /dev/null +++ b/planning/cli-integration.md @@ -0,0 +1,81 @@ +# Fastly CLI Integration Design + +## Overview + +Design for integrating the Python SDK with the Fastly CLI (`fastly compute ...`). + +## `fastly.toml` Configuration + +The `fastly.toml` file is the manifest for Compute services. + +```toml +manifest_version = 4 +service_id = "..." +name = "my-python-service" +language = "other" # Currently no native "python" support in CLI, so "other" + scripts + +[scripts] + build = "fastly-py build" + post_init = "uv sync" + +[local_server] + # Viceroy integration + # The SDK build tool outputs to bin/main.wasm + bin = "bin/main.wasm" +``` + +## Project Structure + +A standard Python Compute project structure (using `uv`): + +```text +. +├── fastly.toml +├── pyproject.toml +├── src/ +│ └── main.py +└── README.md +``` + +## Starter Kits + +Fastly provides [Starter Kits](https://www.fastly.com/documentation/solutions/starters/) to help developers get up and running quickly. For the Python SDK alpha, we will provide a limited set of high-quality starters. + +### Proposed Alpha Starters + +1. **Python Default (WSGI/Flask)** + - **Repo**: `fastly/compute-starter-kit-python-flask` (Proposed) + - **Focus**: The "paved path" for most developers. Uses Flask to demonstrate compatibility with standard Python web frameworks. + - **Features**: Routing, JSON handling, Middleware. + +2. **Python Raw (No Framework)** + - **Repo**: `fastly/compute-starter-kit-python-raw` (Proposed) + - **Focus**: Minimalist example using the SDK's `Request` and `Response` objects directly. + - **Features**: Low-level control, maximum performance, zero dependencies (other than SDK). + +### Template Structure + +Each starter kit should follow the standard structure: + +```text +. +├── fastly.toml +├── pyproject.toml +├── src/ +│ └── main.py +└── README.md +``` + +## Dependency Management + +The "paved path" for dependency management is **`uv`** with `pyproject.toml`. + +- **Initialization**: `uv init` +- **Adding Dependencies**: `uv add fastly-compute` +- **Build**: `fastly-py build` will detect `pyproject.toml` and use `uv sync` if needed to ensure the environment is ready. + + +## Implementation Notes + +1. **Language Support**: Ideally, we work with the Fastly CLI team to get `language = "python"` supported natively, which would set up these defaults automatically. +2. **Viceroy**: The `fastly compute serve` command runs Viceroy. Our build tool must ensure the artifact is compatible with the Viceroy version bundled with the CLI. From ba473894825c1a063747aadd6da9ff8a9e1ba2ff Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Tue, 13 Jan 2026 16:27:06 -0600 Subject: [PATCH 21/26] Add WIT Resource Drop design --- planning/wit-drop.md | 66 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 planning/wit-drop.md diff --git a/planning/wit-drop.md b/planning/wit-drop.md new file mode 100644 index 0000000..b824d53 --- /dev/null +++ b/planning/wit-drop.md @@ -0,0 +1,66 @@ +# WIT Resource Drop Design + +## Overview + +Design for managing the lifecycle of WIT resources (handles) in Python, ensuring they are properly dropped (released) on the host side. + +## Problem + +Fastly Compute resources (Bodies, KV Stores, etc.) are backed by host-side handles. If these handles are not explicitly dropped, they leak, potentially causing resource exhaustion or logic errors (e.g. infinite pending requests). Python's Garbage Collection (`__del__`) is non-deterministic and thus insufficient for timely resource release. + +## Strategy + +1. **Context Managers**: The primary mechanism for resource management. +2. **Explicit Close**: `close()` methods on all resource wrappers. +3. **Owner Ownership**: Parent objects own child resources (e.g. Response owns Body). + +## API Design + +```python +class Resource: + """Base class for WIT resource wrappers.""" + + def __init__(self, handle): + self._handle = handle + self._closed = False + + def close(self): + """Release the underlying WIT resource.""" + if not self._closed: + # Call WIT drop function + self._handle.drop() + self._closed = True + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.close() + + def __del__(self): + """Last-resort cleanup.""" + if not self._closed: + # Warning: __del__ behavior is tricky in Python, + # but better than nothing for leaks. + self.close() +``` + +## Specific Resources + +### Body +- Must close to signal EOF to reader. +- `__exit__` calls `close()`. + +### KV/Config/Secret Stores +- Less critical for immediate closure, but good practice. +- `__enter__`/`__exit__` provided. + +### Request/Response +- Ownership of Body is transferred. +- When Request/Response is closed, it should close its Body? Or Body is independent? + - *Decision*: Body is independent but often accessed via property. If the user accesses `req.body`, they own it. If they don't, the Request wrapper should probably clean it up when the Request is done. + +## Implementation Notes + +1. **Componentize-Py**: Ensure `componentize-py` generated bindings expose the `drop` method for resources. +2. **Validation**: Use `fastly_compute.utils` to validate handle liveness if needed. From 55f526ab4c3636f9305b085a050d03d0523c833b Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Tue, 13 Jan 2026 16:27:06 -0600 Subject: [PATCH 22/26] Add Documentation Infra design --- planning/docs-infra.md | 53 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 planning/docs-infra.md diff --git a/planning/docs-infra.md b/planning/docs-infra.md new file mode 100644 index 0000000..5899424 --- /dev/null +++ b/planning/docs-infra.md @@ -0,0 +1,53 @@ +# Documentation Infrastructure Design + +## Overview + +Proposal for the documentation generation and hosting infrastructure for the Fastly Compute Python SDK. + +## Tooling Selection + +**Sphinx** with **reStructuredText (RST)**. + +**Rationale**: +- The standard for Python documentation (used by Python docs, Requests, Django, etc.). +- Robust support for Python domain (signatures, types, cross-referencing). +- `autodoc` extension provides powerful API documentation generation from docstrings. +- Flexible and extensible. + +## Configuration + +`conf.py`: + +```python +project = 'Fastly Compute Python SDK' +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.napoleon', # Support for Google/NumPy style docstrings + 'sphinx.ext.viewcode', + 'sphinx.ext.intersphinx', + 'sphinx_rtd_theme', # Read the Docs theme (standard choice) +] + +html_theme = 'sphinx_rtd_theme' +``` + +## Structure + +```text +docs/ + conf.py + index.rst # Landing page + guides/ + index.rst + getting-started.rst + kv-store.rst + ... + reference/ + index.rst + api.rst # .. automodule:: fastly_compute +``` + +## Automation + +- **GitHub Actions**: Build via `sphinx-build` and deploy to `gh-pages` branch on merge to main. +- **Versioning**: Can use `sphinx-multiversion` or similar if needed. From 9c7488757862907e1629e64f01c86ed6bad6ab3b Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Tue, 13 Jan 2026 16:27:06 -0600 Subject: [PATCH 23/26] Add Async Analysis --- planning/async.md | 84 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 planning/async.md diff --git a/planning/async.md b/planning/async.md new file mode 100644 index 0000000..6b19402 --- /dev/null +++ b/planning/async.md @@ -0,0 +1,84 @@ +# Async Python on Fastly Compute + +## Overview + +Analysis of asynchronous Python support on Fastly Compute, leveraging the `async-io` host interface. + +## Reference Architecture + +The **Spin Python SDK** provides a strong reference implementation for running `asyncio` on WASI-based platforms that lack full OS-level socket support but provide pollable host resources. + +- **Reference**: `spin_sdk.http.poll_loop` +- **Mechanism**: Custom `asyncio.AbstractEventLoop` backed by host-side polling (`wasi:io/poll` in Spin, `fastly:compute/async-io` in Fastly). + +## Fastly Async Primitives + +The `compute.wit` defines the `async-io` interface: + +```wit +interface async-io { + resource pollable { ... } + select: func(handles: list>) -> u32; + select-with-timeout: func(handles: list>, timeout-ms: u32) -> option; +} +``` + +Pollable resources include: +- `pending-response` (from `http-req.send-async`) +- `body` (HTTP bodies for streaming) +- `pending-lookup`, `pending-insert` (KV Store) +- `pending-entry` (Cache) + +## Proposed Architecture + +### 1. FastlyEventLoop + +A custom event loop that implements `asyncio.AbstractEventLoop`. + +- **Registry**: Maintains a mapping of `pollable` handles to Python `Future`s (wakers). +- **Run Loop**: + 1. Executed synchronous tasks (`_run_once`). + 2. Collects all active `pollable` handles. + 3. Calls `async_io.select(handles)`. + 4. Wakes up the corresponding Future for the ready handle. +- **Restrictions**: Methods relying on OS sockets (`create_connection`, `add_reader` for file descriptors) will raise `NotImplementedError`. + +### 2. Async Primitives + +We must provide async-native wrappers for host calls: + +```python +async def send_async(request): + pending = http_req.send_async(request) + await wait_for(pending) # Registers with loop and yields + return pending.wait() +``` + +## Candidate Features for Async + +| Feature | Async Benefit | Status | +| :--- | :--- | :--- | +| **Backend Requests** | **High**. Allows concurrent fetches (fan-out). | `send-async` exists. | +| **KV Store** | **High**. Non-blocking lookups. | `lookup-async` exists. | +| **Cache** | **Medium**. | `transaction-lookup-async` exists. | +| **Body Streaming** | **High**. Non-blocking read/write of streams. | Bodies are pollable. | +| **WebSockets** | **High**. Essential for websocket handling. | Future consideration. | + +## ASGI Support + +With `FastlyEventLoop`, we can support **ASGI** applications (FastAPI, Starlette, Quart). + +- **Adapter**: `AsgiHttpIncoming` (similar to `WsgiHttpIncoming`). +- **Lifecycle**: Manages the ASGI `scope`, `receive`, and `send` channels mapping to Fastly Request/Response/Body. + +## Risks & Challenges + +1. **Socket Incompatibility**: Standard Python async libraries (e.g., `asyncpg`, `redis`, `aiohttp`) rely on `selector` based loops and TCP sockets. They **will not work** out of the box. Users must use Fastly-native clients (KV Store, Backend fetch) instead. +2. **Performance**: The overhead of the Python event loop in Wasm needs measurement. +3. **Maintenance**: Custom event loops are complex to maintain and ensure correctness against stdlib changes. + +## Roadmap + +1. **Phase 1 (PoC)**: Implement `FastlyEventLoop` and a simple `async_sleep` (using `select_with_timeout`). +2. **Phase 2 (HTTP)**: Implement `AsyncClient` for backend requests. +3. **Phase 3 (ASGI)**: Prototype a simple ASGI adapter for FastAPI. From 7f1994c03800ab180dd3fa42e7ce5abbc0364283 Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Tue, 13 Jan 2026 16:27:06 -0600 Subject: [PATCH 24/26] Add Codegen Strategy --- planning/codegen.md | 99 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 planning/codegen.md diff --git a/planning/codegen.md b/planning/codegen.md new file mode 100644 index 0000000..3bbf06b --- /dev/null +++ b/planning/codegen.md @@ -0,0 +1,99 @@ +# Safe Bindings Codegen Strategy + +## Overview + +Proposal to mechanically generate a "Wrapped Bindings" layer (`fastly_compute.wit`) that wraps the raw WIT-generated stubs (`fastly_compute.witraw`). This layer handles exception mapping automatically based on the WIT definitions, providing a seamless, Pythonic interface for the core SDK to build upon. + +## The Goal + +Instead of manually wrapping WIT calls: + +```python +# Manual approach (using raw bindings) +from fastly_compute.witraw.imports import kv_store +try: + kv_store.insert(...) +except Err as e: + if e.value == kv_store.KvError.BAD_REQUEST: + raise BadRequest(...) +``` + +We want to auto-generate wrappers so SDK code looks like: + +```python +# Generated Wrapped approach +from fastly_compute.wit import kv_store +# This automatically raises fastly_compute.exceptions.BadRequest +kv_store.insert(...) +``` + +## Package Structure + +- **`fastly_compute.witraw`**: The raw bindings generated by `componentize-py`. These return `Result` types (or raise generic `Err`). +- **`fastly_compute.wit`**: The generated wrappers. These raise specific Python exceptions mapped from WIT error enums. + +## How It Works + +### 1. Source of Truth: `compute.wit` + +The WIT file contains the exact error contract: + +```wit +interface kv-store { + enum kv-error { bad-request, ... } + insert: func(...) -> result<_, kv-error>; +} +``` + +**Decision**: We will use **`wasm-tools component wit --json`** to parse the WIT definitions into a structured JSON format. + +*Rationale*: This delegates the complex task of parsing WIT syntax (and handling imports/dependencies) to the standard tooling (`wasm-tools`). We then only need to traverse the JSON to extract the function-to-error mappings. + +### 2. The Generator Script + +We implement a tool (e.g., `tools/gen-safe-bindings.py`) that: +1. **Invokes `wasm-tools`**: Runs `wasm-tools component wit wit --json` to get the AST. +2. **Analyzes JSON**: Traverses the AST to find functions returning `Result` with specific error types. +3. **Maps Errors**: Associates WIT error enums (`kv-error`) with SDK Exception classes (`KVStoreError`). +4. **Generates Code**: Produces Python modules mirroring the WIT structure. + +We implement a tool (e.g., `tools/gen-safe-bindings.py`) that: +1. **Parses WIT**: Reads `wit/deps/fastly/compute.wit`. +2. **Maps Errors**: Associates WIT error enums (`kv-error`) with SDK Exception classes (`KVStoreError`). +3. **Generates Code**: Produces Python modules mirroring the WIT structure. + +### 3. Generated Code Structure + +```python +# fastly_compute/wit/imports/kv_store.py + +from fastly_compute.witraw.imports import kv_store as _raw +from fastly_compute.witraw.imports.types import Err +from fastly_compute.exceptions import KVStoreError, BadRequest, ... + +def insert(key, body, options): + try: + return _raw.insert(key, body, options) + except Err as e: + # Generated mapping logic based on 'kv-error' enum + err_val = e.value + if err_val == _raw.KvError.BAD_REQUEST: + raise BadRequest(str(err_val)) from e + elif err_val == _raw.KvError.TOO_MANY_REQUESTS: + raise RateLimitExceeded(str(err_val)) from e + # ... other variants ... + raise KVStoreError(f"Generic error: {err_val}") from e +``` + +## Integration Plan + +1. **Move Raw Bindings**: Configure `componentize-py` to generate bindings into `fastly_compute/witraw` instead of `wit_world` or `stubs`. +2. **Define Exception Mapping**: Create a registry mapping WIT Enum names (`kv-error`, `acl-error`) to base SDK Exceptions (`KVStoreError`, `ACLError`) and variant names (`bad-request`) to specific Exceptions (`BadRequestError`). +3. **Build Step**: Add the codegen step to the `Makefile` (or `fastly-py build` process). +4. **Type Hints**: The generator should also produce `.pyi` files so IDEs know that `fastly_compute.wit...` functions raise exceptions. + +## Benefits + +- **Consistency**: Every WIT function is wrapped identically. +- **Maintenance**: If Fastly adds a new error variant, re-running the generator (and updating the mapping config) covers it everywhere. +- **Cleanliness**: Core SDK logic (`fastly_compute/kv_store.py`) focuses on high-level ergonomics (objects, convenience methods) rather than low-level error switching. From 205d35acdf9ca5ab63ce5c3c8a9781c2ab3d5c60 Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Tue, 13 Jan 2026 16:27:12 -0600 Subject: [PATCH 25/26] Add codegen tool and exception hierarchy - Added tools/codegen.py to generate safe WIT bindings. - Added fastly_compute/exceptions.py to define the exception hierarchy. - Updated Makefile to include codegen target. --- Makefile | 6 +- fastly_compute/exceptions.py | 73 +++++++++ tools/codegen.py | 280 +++++++++++++++++++++++++++++++++++ 3 files changed, 358 insertions(+), 1 deletion(-) create mode 100644 fastly_compute/exceptions.py create mode 100644 tools/codegen.py diff --git a/Makefile b/Makefile index 9370daa..66967e2 100644 --- a/Makefile +++ b/Makefile @@ -75,7 +75,11 @@ build-all: all # Clean build artifacts clean: - rm -rf $(BUILD_DIR) $(STUBS_DIR) + rm -rf $(BUILD_DIR) $(STUBS_DIR) fastly_compute/wit + +# Code Generation +codegen: $(STUBS_DIR) + uv run python3 tools/codegen.py # Development tools lint: | $(STUBS_DIR) diff --git a/fastly_compute/exceptions.py b/fastly_compute/exceptions.py new file mode 100644 index 0000000..6b19a1e --- /dev/null +++ b/fastly_compute/exceptions.py @@ -0,0 +1,73 @@ +from typing import Any + + +class FastlyError(Exception): + """Base class for all Fastly Compute exceptions.""" + + def __init__(self, message: str, wit_error: Any | None = None): + super().__init__(message) + self.wit_error = wit_error + + +class ResourceError(FastlyError): + """Resource open/access errors.""" + + pass + + +class ResourceOpenError(ResourceError): + """Error opening a resource.""" + + pass + + +class ResourceNotFound(ResourceError): + """Resource not found.""" + + pass + + +class ResourceLimitExceeded(ResourceError): + """Quotas or limits exceeded.""" + + pass + + +class BackendError(FastlyError): + """Backend communication errors.""" + + pass + + +class BadRequestError(FastlyError): + """Bad Request.""" + + pass + + +class RateLimitExceeded(FastlyError): + """Rate limit exceeded.""" + + pass + + +# KV Store Specific +class KVStoreError(FastlyError): + pass + + +class KVKeyFound(KVStoreError): + pass + + +class KVPreconditionFailed(KVStoreError): + pass + + +class KVPayloadTooLarge(KVStoreError): + pass + + +# ACL Specific +class ACLError(FastlyError): + pass diff --git a/tools/codegen.py b/tools/codegen.py new file mode 100644 index 0000000..12ea9e2 --- /dev/null +++ b/tools/codegen.py @@ -0,0 +1,280 @@ +#!/usr/bin/env python3 +import json +import os +import re +import subprocess +import sys +from pathlib import Path +from typing import Any + +# Configuration +WIT_DIR = Path("wit") +OUTPUT_DIR = Path("fastly_compute/wit") +RAW_BINDINGS_PACKAGE = "wit_world" # For now, use existing location + +# Mapping from WIT Error Enum Names to Base Exception Classes +# and specific variants to specific exceptions. +# Format: "wit-enum-name": ("BaseException", {"variant": "SpecificException"}) +ERROR_MAPPING = { + "kv-error": ( + "KVStoreError", + { + "bad-request": "BadRequestError", + "precondition-failed": "KVPreconditionFailed", + "payload-too-large": "KVPayloadTooLarge", + "too-many-requests": "RateLimitExceeded", + "internal-error": "BackendError", + }, + ), + "acl-error": ( + "ACLError", + { + "too-many-requests": "RateLimitExceeded", + }, + ), + "open-error": ( + "ResourceOpenError", + { + "name-empty": "ValueError", + "name-too-long": "ValueError", + "name-contains-invalid-char": "ValueError", + "file-not-found": "ResourceNotFound", # Assuming implicit + }, + ), + "error": ("FastlyError", {}), + "error-with-detail": ("FastlyError", {}), # Generic error with string +} + + +def to_snake_case(name: str) -> str: + return name.replace("-", "_") + + +def to_camel_case(name: str) -> str: + parts = name.split("-") + return "".join(p.capitalize() for p in parts) + + +def run_wasm_tools(wit_dir: Path) -> dict[str, Any]: + """Run wasm-tools to get JSON representation of WIT.""" + try: + result = subprocess.run( + ["wasm-tools", "component", "wit", str(wit_dir), "--json"], + capture_output=True, + text=True, + check=True, + ) + return json.loads(result.stdout) + except subprocess.CalledProcessError as e: + print(f"Error running wasm-tools: {e.stderr}", file=sys.stderr) + sys.exit(1) + except FileNotFoundError: + print("wasm-tools not found in PATH", file=sys.stderr) + sys.exit(1) + + +def resolve_type(types: list[dict], type_id: int) -> dict: + if type_id < len(types): + return types[type_id] + return {} + + +def find_error_type(types: list[dict], type_id: Any) -> str | None: + """Trace a type ID to find if it's a Result with an Error Enum.""" + if isinstance(type_id, int): + t = resolve_type(types, type_id) + kind = t.get("kind", {}) + if "result" in kind: + result = kind["result"] + err_id = result.get("err") + if err_id is not None: + err_type = resolve_type(types, err_id) + # Check if it has a name (Enum or Resource) + return err_type.get("name") + elif "type" in kind: + # Alias + return find_error_type(types, kind["type"]) + return None + + +def generate_utils_module(output_dir: Path) -> None: + content = """ +from functools import wraps +from typing import Any, Callable, Dict, Type, Optional +from fastly_compute.witraw.imports.types import Err +from fastly_compute.exceptions import FastlyError + +def map_wit_error(mapping: Dict[str, Type[FastlyError]], default: Type[FastlyError] = FastlyError): + def decorator(func: Callable) -> Callable: + @wraps(func) + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except Err as e: + err_val = e.value + variant = str(err_val) + exc_cls = mapping.get(variant) + if exc_cls: + raise exc_cls(variant) from e + raise default(f"Generic error: {err_val}") from e + return wrapper + return decorator +""" + with open(output_dir / "utils.py", "w") as f: + f.write(content.strip()) + + +def generate_module(interface: dict, types: list[dict], module_name: str) -> str: + functions = interface.get("functions", {}) + if not functions: + return "" + + lines = [] + lines.append("# Generated by tools/codegen.py. DO NOT EDIT.") + lines.append(f"from fastly_compute.witraw.imports import {module_name} as _raw") + lines.append("from fastly_compute.wit.utils import map_wit_error") + lines.append("from fastly_compute.exceptions import *") + lines.append("") + + # Determine error mapping for this module + # We scan all functions to find the common error enum(s) + # Ideally, an interface uses one main error type (e.g. kv-error) + # If multiple, we might need multiple maps or a merged one. + + # Collect all error types used in this module + error_types = set() + for func_def in functions.values(): + res = func_def.get("result") + err_name = find_error_type(types, res) + if err_name: + error_types.add(err_name) + + # Generate Mappings + for err_name in error_types: + if err_name in ERROR_MAPPING: + base_exc, variants = ERROR_MAPPING[err_name] + # Generate dict definition + lines.append(f"_{to_snake_case(err_name).upper()}_MAP = {{") + for var, exc in variants.items(): + lines.append(f" '{var}': {exc},") + lines.append("}") + lines.append("") + + # Group functions by resource (same as before) + resources = {} + freestanding = [] + + for func_key, func_def in functions.items(): + name = func_def["name"] + if "." in name and not name.startswith( + "[" + ): # Handle [method] prefix removal logic + pass # Logic handles below + + # Robust parsing of "[kind]resource.method" + clean_name = re.sub(r"\[.*?\]", "", name) + if "." in clean_name: + res_name, method_name = clean_name.split(".", 1) + if res_name not in resources: + resources[res_name] = [] + resources[res_name].append((method_name, func_def)) + else: + freestanding.append((name, func_def)) + + # Helper to get decorator string + def get_decorator(func_def): + res = func_def.get("result") + err_name = find_error_type(types, res) + if err_name and err_name in ERROR_MAPPING: + map_name = f"_{to_snake_case(err_name).upper()}_MAP" + base_exc = ERROR_MAPPING[err_name][0] + return f"@map_wit_error({map_name}, default={base_exc})" + return None + + # Generate Freestanding + for name, func_def in freestanding: + py_name = to_snake_case(name) + deco = get_decorator(func_def) + if deco: + lines.append(deco) + lines.append(f"def {py_name}(*args, **kwargs):") + lines.append(f" return _raw.{to_snake_case(name)}(*args, **kwargs)") + lines.append("") + + # Generate Classes + for res_name, methods in resources.items(): + class_name = to_camel_case(res_name) + lines.append(f"class {class_name}:") + lines.append(" def __init__(self, handle):") + lines.append(" self._handle = handle") + lines.append("") + + for name, func_def in methods: + py_name = to_snake_case(name) + deco = get_decorator(func_def) + if deco: + lines.append(f" {deco}") + + # Check static vs method + is_static = "[static]" in func_def["name"] + + if is_static: + # Static method + lines.append(" @classmethod") + lines.append(f" def {py_name}(cls, *args, **kwargs):") + lines.append( + f" return _raw.{class_name}.{to_snake_case(name)}(*args, **kwargs)" + ) + else: + # Instance method + lines.append(f" def {py_name}(self, *args, **kwargs):") + # Pass self._handle as first arg if raw expects it? + # Raw bindings for methods usually take 'self' handle as first arg. + lines.append( + f" return _raw.{class_name}.{to_snake_case(name)}(self._handle, *args, **kwargs)" + ) + lines.append("") + + return "\n".join(lines) + + +def main(): + # Ensure output dir + if not OUTPUT_DIR.exists(): + os.makedirs(OUTPUT_DIR) + + imports_dir = OUTPUT_DIR / "imports" + if not imports_dir.exists(): + os.makedirs(imports_dir) + + (OUTPUT_DIR / "__init__.py").touch() + (imports_dir / "__init__.py").touch() + + # Generate utils + generate_utils_module(OUTPUT_DIR) + + print(f"Parsing WIT from {WIT_DIR}...") + data = run_wasm_tools(WIT_DIR) + + types = data.get("types", []) + interfaces = data.get("interfaces", []) + + for interface in interfaces: + name = interface.get("name") + if not name: + continue + + print(f"Generating wrapper for {name}...") + py_mod_name = to_snake_case(name) + + content = generate_module(interface, types, py_mod_name) + + if content: + with open(imports_dir / f"{py_mod_name}.py", "w") as f: + f.write(content) + + print("Done.") + + +if __name__ == "__main__": + main() From d5ff922c8d2b10da040d048e075ab04e272cd957 Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Wed, 14 Jan 2026 17:12:48 -0600 Subject: [PATCH 26/26] Updates to cli integration from review/discussion - Address feedback from kevin - Flesh out CLI tool more, packaging together with SDK code - Simplify `uv` interfacing with simplier `uv run build` with build moved into pyproject.toml (akin to npm integration). - Include directory structure considerations. - Some basic notes on building the package (maturin). --- planning/cli-integration.md | 160 ++++++++++++++++++++-- planning/devtools.md | 259 +++++++++++++++++++++++++++++++----- 2 files changed, 370 insertions(+), 49 deletions(-) diff --git a/planning/cli-integration.md b/planning/cli-integration.md index dbe0431..a663614 100644 --- a/planning/cli-integration.md +++ b/planning/cli-integration.md @@ -4,6 +4,46 @@ Design for integrating the Python SDK with the Fastly CLI (`fastly compute ...`). +## CLI Tool: `fastly-compute-py` + +The Python SDK includes a CLI tool named `fastly-compute-py` that handles building Python applications into WebAssembly modules compatible with Fastly Compute. + +The build tool is **bundled into the `fastly-compute` package** rather than distributed separately: +- **`fastly-compute`**: The Python SDK library with embedded Rust build tool +- **`fastly-compute-py`** (optional): Also available as a standalone binary via crates.io for Rust users + +### Why Bundle the Build Tool? + +1. **WIT Definition Alignment**: The build tool requires WIT definitions that exactly match the SDK version. Bundling ensures they stay in sync. +2. **Prevents Version Mismatches**: Users cannot accidentally use incompatible SDK/build-tool versions (e.g., SDK 0.7.0 with build tool 0.6.0). +3. **Simpler User Experience**: One `uv add fastly-compute` provides everything needed to build and run Fastly Compute applications. +4. **Shared Version Control**: SDK features and build tool capabilities are versioned together in lockstep. + +This bundling strategy: +- Eliminates version mismatch footguns +- Ensures WIT definitions always match the SDK +- Simplifies dependency management +- Works seamlessly in virtual environments + +### Installation & Usage + +The build tool is automatically included when you install the SDK: + +```bash +uv add fastly-compute +``` + +Projects invoke the tool via `uv run` scripts defined in `pyproject.toml`: + +```bash +uv run build +``` + +### Commands + +- `fastly-compute-py build` - Build the Python application into a WASM module +- `fastly-compute-py version` - Display version information + ## `fastly.toml` Configuration The `fastly.toml` file is the manifest for Compute services. @@ -12,16 +52,61 @@ The `fastly.toml` file is the manifest for Compute services. manifest_version = 4 service_id = "..." name = "my-python-service" -language = "other" # Currently no native "python" support in CLI, so "other" + scripts +language = "python" [scripts] - build = "fastly-py build" - post_init = "uv sync" +build = "uv run build" +post_init = "uv sync" +``` + +## `pyproject.toml` Configuration + +A typical Flask-based Fastly Compute project's `pyproject.toml`: + +```toml +[project] +name = "my-python-service" +version = "0.1.0" +description = "A Fastly Compute service built with Python" +requires-python = ">=3.12" +dependencies = [ + "fastly-compute>=0.1.0", + "flask>=3.0.0", +] + +[dependency-groups] +dev = [ + "fastly-compute-py>=0.1.0", +] + +[tool.uv.scripts] +build = "fastly-compute-py build" +``` + +Key elements: +- **requires-python**: We embed 3.14 currently but 3.12+ is more likely to be available on host systems. +- **dependencies**: Just `fastly-compute` - the build tool is included +- **tool.uv.scripts**: Simple script that invokes the bundled build tool +- **No [build-system]**: Not needed since we're building to WASM, not distributing as a Python package + +### How It Works + +The `fastly-compute` package includes: +- Python SDK runtime code (`fastly_compute/`) +- Rust-based build tool (via PyO3 extension module) +- WIT definitions (embedded in the build tool) +- CLI entry point (`fastly-compute-py` command) + +When you run `uv run build`, it invokes the `fastly-compute-py` command which is provided by the `fastly-compute` package you've already installed. + +### Developer Workflow -[local_server] - # Viceroy integration - # The SDK build tool outputs to bin/main.wasm - bin = "bin/main.wasm" +```bash +# Sync dependencies +uv sync + +# Build the WASM module +uv run build ``` ## Project Structure @@ -32,6 +117,7 @@ A standard Python Compute project structure (using `uv`): . ├── fastly.toml ├── pyproject.toml +├── uv.lock ├── src/ │ └── main.py └── README.md @@ -68,14 +154,62 @@ Each starter kit should follow the standard structure: ## Dependency Management -The "paved path" for dependency management is **`uv`** with `pyproject.toml`. +The "paved path" for dependency management is **`uv`** with `pyproject.toml`. Nothing would preclude customers from using other tooling (e.g. pip, etc.) but `uv` now has wide adoption and seems reasonable to have it be what is documented as the paved path. + +### Option 1: Manual Initialization + +```bash +# Initialize a new project +uv init my-python-service +cd my-python-service + +# Add the Fastly Compute SDK (includes build tool) +uv add fastly-compute flask + +# Add uv script to pyproject.toml (or do this manually) +cat >> pyproject.toml < Result<(), Error> { + // Build logic: componentize-py, wac composition, etc. +} + +#[cfg(feature = "python")] +use pyo3::prelude::*; -1. **Discovery**: Analyze the project structure (`pyproject.toml`). -2. **Environment Prep**: Ensure a suitable Python environment is available. The tool will check for `uv` managed environments as the preferred path. -3. **Componentization**: - - Executes the componentization logic (wrapping `componentize-py`). - - Uses the `fastly:compute/service` WIT world. - - bundles necessary WIT files automatically. -4. **Composition**: - - Automatically composes the result with the `wasiless.wasm` adapter (embedded in the binary). -5. **Output**: Generates the final Wasm artifact (default: `bin/main.wasm`). +#[cfg(feature = "python")] +#[pyfunction] +fn build(entry: String, output: String) -> PyResult<()> { + build_component(&entry, &output) + .map_err(|e| PyErr::new::(e.to_string())) +} + +#[cfg(feature = "python")] +#[pymodule] +fn _fastly_compute_py(_py: Python, m: &PyModule) -> PyResult<()> { + m.add_function(wrap_pyfunction!(build, m)?)?; + Ok(()) +} +``` + +**src/main.rs:** +```rust +use clap::Parser; + +#[derive(Parser)] +#[command(name = "fastly-compute-py")] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + Build { + #[arg(short, long, default_value = "bin/main.wasm")] + output: String, + #[arg(short, long)] + verbose: bool, + }, +} + +fn main() { + let cli = Cli::parse(); + match cli.command { + Command::Build { output, verbose } => { + fastly_compute_py::build_component("main.py", &output).unwrap(); + } + } +} +``` + +### Python CLI Wrapper + +**fastly_compute/cli.py:** +```python +"""Thin wrapper that delegates to Rust CLI.""" +import sys +import subprocess + +def main(): + # Just exec the Rust binary directly + from fastly_compute._fastly_compute_py import __file__ as lib_path + import os + binary = os.path.join(os.path.dirname(lib_path), "fastly-compute-py") + if sys.platform == "win32": + binary += ".exe" + + result = subprocess.run([binary] + sys.argv[1:]) + sys.exit(result.returncode) +``` + +### Workspace Configuration + +**Root Cargo.toml:** +```toml +[workspace] +members = ["crates/fastly-compute-py", "crates/wasiless"] +resolver = "2" +``` + +**Root pyproject.toml:** +```toml +[build-system] +requires = ["maturin>=1.0"] +build-backend = "maturin" + +[project.scripts] +fastly-compute-py = "fastly_compute.cli:main" + +[tool.maturin] +python-source = "." +manifest-path = "crates/fastly-compute-py/Cargo.toml" +features = ["python"] +module-name = "fastly_compute._fastly_compute_py" +``` + +### Build Process + +1. **Discovery**: Analyze `pyproject.toml` +2. **Componentization**: Use `componentize-py` with embedded WIT +3. **Composition**: Compose with embedded `wasiless.wasm` via `wac` +4. **Output**: Write to `bin/main.wasm` (default) ### CLI Arguments -```text -usage: fastly-py build [options] [entrypoint] +``` +fastly-compute-py build [--output FILE] [--verbose] +``` -positional arguments: - entrypoint Entry point module/file (default: main.py or app.py) +## Distribution Strategy -options: - -o, --output FILE Output Wasm file (default: bin/main.wasm) - -v, --verbose Verbose output +**Usage:** +```bash +uv add fastly-compute # Installs SDK + build tool +uv run build # Invokes fastly-compute-py ``` -## Moving Parts Strategy +**Building wheels:** +```bash +maturin develop --features python +maturin build --release --features python +``` -1. **`componentize-py`**: The Rust binary will manage the execution of componentization. It may bundle the python parts or manage a dedicated venv to ensure the correct version is used. -2. **`wasiless.wasm`**: This adapter will be embedded directly into the `fastly-py` Rust binary (using `include_bytes!`), eliminating the need for external file dependencies. -3. **WIT Definitions**: The authoritative `compute.wit` will also be embedded in the binary. +**Publishing standalone binary:** +```bash +cd crates/fastly-compute-py +cargo publish +cargo install fastly-compute-py +``` ## Implementation Plan -1. **Project Location**: `src/fastly-py` (or similar) in the repo. -2. **Dependencies**: - - `clap`: For CLI argument parsing. - - `anyhow`: For error handling. - - `wac` (library): Use the `wac` crate directly if available, or bundle the binary. -3. **Embedding**: Use Rust's `include_bytes!` macro to compile static assets (adapter, WITs) into the binary. +1. **Move wasiless**: `vendor/wasiless` → `crates/wasiless` +2. **Create `crates/fastly-compute-py/`** with Rust CLI +3. **Embed assets**: WIT definitions, `wasiless.wasm` via `include_bytes!` +4. **Maturin setup**: Configure for platform-specific wheels with PyO3 +5. **CI/CD**: GitHub Actions for multi-platform wheel builds -## Advantages of Rust-based Tool +## Advantages of This Approach -- **Portability**: Single binary, easier to distribute via Fastly CLI or CI/CD. -- **Robustness**: Static typing and better error handling for the build orchestration. -- **Speed**: Startup time is negligible; heavy lifting is still done by the Wasm engine but the driver is fast. +- **Version Alignment**: Build tool and SDK WIT definitions are always in sync, preventing runtime errors from version mismatches. +- **Single Package**: Users install one package (`fastly-compute`) and get everything needed. +- **Dual Distribution**: Same codebase supports both PyPI (Python users) and crates.io (Rust users). +- **Simple Invocation**: `uv run build` is concise and integrates with `fastly.toml`. +- **Portability**: Platform-specific wheels ensure the tool works across different operating systems. +- **Robustness**: Static typing and better error handling for the build orchestration (via Rust). +- **Speed**: Native Rust performance for build orchestration. - **Isolation**: Can shield the user from dependency conflicts by managing the build environment explicitly.