diff --git a/Makefile b/Makefile index b79aad8..cb8c0f0 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,6 @@ DEV_MODE ?= 1 # Rust crate path FASTLY_COMPUTE_PY_MANIFEST := $(abspath crates/fastly-compute-py/Cargo.toml) -FASTLY_COMPUTE_PY_BIN := target/release/fastly_compute_py_build # Select build tool based on DEV_MODE ifeq ($(DEV_MODE),1) @@ -28,7 +27,7 @@ EXAMPLES_DIR := examples COMPUTE_WIT := wit/deps/fastly/compute.wit # Define all available examples (add new ones here) -EXAMPLES := bottle-app flask-app backend-requests game-of-life config-store logging +EXAMPLES := bottle-app flask-app backend-requests game-of-life logging # Default example for serve target EXAMPLE ?= bottle-app diff --git a/crates/fastly-compute-py/src/cli.rs b/crates/fastly-compute-py/src/cli.rs index 75e5273..bf41327 100644 --- a/crates/fastly-compute-py/src/cli.rs +++ b/crates/fastly-compute-py/src/cli.rs @@ -30,5 +30,10 @@ pub enum Command { /// Entry point module (default: main or auto-detect) #[arg(short, long)] entry: Option, + + /// Virtual environment in which to look for modules (default: + /// VIRTUAL_ENV env var or .venv) + #[arg(short, long)] + virtualenv: Option, }, } diff --git a/crates/fastly-compute-py/src/config.rs b/crates/fastly-compute-py/src/config.rs index 6981066..4414dac 100644 --- a/crates/fastly-compute-py/src/config.rs +++ b/crates/fastly-compute-py/src/config.rs @@ -9,6 +9,7 @@ use crate::cli::Command; pub struct ConfigSource { pub entry: Option, pub output: Option, + pub virtualenv: Option, } #[derive(Deserialize, Debug)] @@ -61,12 +62,17 @@ impl ConfigBuilder { /// Add CLI command arguments as a configuration source pub fn with_command(mut self, command: &Command) -> Self { match command { - Command::Build { entry, output } => { - if entry.is_some() || output.is_some() { - log::debug!("Config from CLI: entry={entry:?}, output={output:?}"); + Command::Build { + entry, + output, + virtualenv, + } => { + if entry.is_some() || output.is_some() || virtualenv.is_some() { + log::debug!("Config from CLI: entry={entry:?}, output={output:?}, virtualenv={virtualenv:?}"); } self.cli.entry = entry.clone(); self.cli.output = output.clone(); + self.cli.virtualenv = virtualenv.clone(); } } self @@ -89,7 +95,11 @@ impl ConfigBuilder { PathBuf::from("bin/main.wasm") }); - Config { entry, output } + Config { + entry, + output, + virtualenv: self.cli.virtualenv, + } } } @@ -97,4 +107,5 @@ impl ConfigBuilder { pub struct Config { pub entry: String, pub output: PathBuf, + pub virtualenv: Option, } diff --git a/crates/fastly-compute-py/src/lib.rs b/crates/fastly-compute-py/src/lib.rs index f490a6b..26aa8c9 100644 --- a/crates/fastly-compute-py/src/lib.rs +++ b/crates/fastly-compute-py/src/lib.rs @@ -93,7 +93,7 @@ pub fn run_main(cli: &Cli) -> Result<()> { log::info!(" Entry point: {}", config.entry); log::info!(" Output: {}", config.output.display()); - build(config.output.clone(), config.entry)?; + build(config.output.clone(), config.entry, config.virtualenv)?; log::info!("✓ Build complete: {}", config.output.display()); @@ -118,7 +118,7 @@ fn _fastly_compute_py(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { Ok(()) } -pub fn build(output: PathBuf, entry_name: String) -> Result<()> { +pub fn build(output: PathBuf, entry_name: String, virtualenv: Option) -> Result<()> { let temp_dir = TempDir::new()?; let temp_path = temp_dir.path(); @@ -132,7 +132,7 @@ pub fn build(output: PathBuf, entry_name: String) -> Result<()> { let temp_component_wasm_path = temp_path.join("component.wasm"); log::info!(" Resolving Python dependencies..."); - let python_path = site_packages::build_python_path()?; + let python_path = site_packages::build_python_path(&virtualenv)?; log::debug!("Using python_path: {:?}", python_path); let python_path_refs: Vec<&str> = python_path.iter().map(|s| s.as_str()).collect(); diff --git a/crates/fastly-compute-py/src/site_packages.rs b/crates/fastly-compute-py/src/site_packages.rs index f525bac..daed6cb 100644 --- a/crates/fastly-compute-py/src/site_packages.rs +++ b/crates/fastly-compute-py/src/site_packages.rs @@ -5,13 +5,13 @@ use std::path::{Path, PathBuf}; /// Build a python_path list suitable for componentize-py. /// This includes the current directory and all site-packages paths. -pub fn build_python_path() -> Result> { +pub fn build_python_path(virtualenv: &Option) -> Result> { let cwd = env::current_dir()?; log::debug!("Current directory: {}", cwd.display()); let mut python_path = vec![cwd.to_string_lossy().to_string()]; - if let Some(site_packages) = find_site_packages()? { + if let Some(site_packages) = find_site_packages(virtualenv)? { log::debug!( "Adding site-packages to python_path: {}", site_packages.display() @@ -38,8 +38,8 @@ pub fn build_python_path() -> Result> { } /// Find the site-packages directory within a virtualenv. -pub fn find_site_packages() -> Result> { - let venv_path = find_venv()?; +pub fn find_site_packages(virtualenv: &Option) -> Result> { + let venv_path = virtualenv.to_owned().or(find_venv()?); if let Some(venv) = venv_path { log::debug!("Found virtualenv: {}", venv.display()); diff --git a/examples/backend-requests/uv.lock b/examples/backend-requests/uv.lock index 88d12b0..236eeba 100644 --- a/examples/backend-requests/uv.lock +++ b/examples/backend-requests/uv.lock @@ -34,6 +34,7 @@ source = { editable = "../../" } [package.metadata] requires-dist = [ { name = "bottle", marker = "extra == 'examples'", specifier = ">=0.12.25" }, + { name = "bottle", marker = "extra == 'test'", specifier = ">=0.12.25" }, { name = "componentize-py", marker = "extra == 'dev'", specifier = ">=0.19.3,<0.20" }, { name = "flask", marker = "extra == 'examples'", specifier = ">=3.1.2,<4.0" }, { name = "pyrefly", marker = "extra == 'dev'", specifier = ">=0.49.0,<0.50" }, diff --git a/examples/bottle-app/uv.lock b/examples/bottle-app/uv.lock index 05dbfde..a0d5055 100644 --- a/examples/bottle-app/uv.lock +++ b/examples/bottle-app/uv.lock @@ -34,6 +34,7 @@ source = { editable = "../../" } [package.metadata] requires-dist = [ { name = "bottle", marker = "extra == 'examples'", specifier = ">=0.12.25" }, + { name = "bottle", marker = "extra == 'test'", specifier = ">=0.12.25" }, { name = "componentize-py", marker = "extra == 'dev'", specifier = ">=0.19.3,<0.20" }, { name = "flask", marker = "extra == 'examples'", specifier = ">=3.1.2,<4.0" }, { name = "pyrefly", marker = "extra == 'dev'", specifier = ">=0.49.0,<0.50" }, diff --git a/examples/config-store/config-store.py b/examples/config-store/config-store.py deleted file mode 100644 index 5d97929..0000000 --- a/examples/config-store/config-store.py +++ /dev/null @@ -1,65 +0,0 @@ -"""Config Store example application. - -Demonstrates Fastly Config Store usage with minimal test endpoints. -""" - -import json -import traceback -from typing import Any - -from bottle import Bottle, response - -from fastly_compute.config_store import ConfigStore -from fastly_compute.wsgi import WsgiHttpIncoming - -app = Bottle() - - -def json_response(data: dict[str, Any], status_code: int = 200) -> str: - """Create a JSON response.""" - response.content_type = "application/json" - response.status = status_code - return json.dumps(data, indent=2) - - -def handle_request(handler): - """Decorator to handle common request/response patterns.""" - - def wrapper(*args, **kwargs): - try: - result = handler(*args, **kwargs) - return json_response(result) - except Exception as e: - return json_response( - { - "error": repr(e), - "error_type": type(e).__name__, - "traceback": traceback.format_exc(), - }, - status_code=500, - ) - - return wrapper - - -@app.route("/get//") -@app.route("/get///") -@handle_request -def test_get(store_name, key, default=None): - """Proxy endpoint to issue ConfigStore gets with optional default.""" - with ConfigStore.open(store_name) as config: - value = config.get(key, default) - return {"value": value} - - -@app.route("/contains//") -@handle_request -def test_contains(store_name, key): - """Proxy endpoint to test contains.""" - config = ConfigStore.open(store_name) - contains = key in config - return {"contains": contains} - - -# Create the HTTP handler for Fastly Compute -HttpIncoming = WsgiHttpIncoming(app) diff --git a/examples/config-store/pyproject.toml b/examples/config-store/pyproject.toml deleted file mode 100644 index ab88e20..0000000 --- a/examples/config-store/pyproject.toml +++ /dev/null @@ -1,15 +0,0 @@ -[project] -name = "config-store" -version = "0.1.0" -description = "Fastly Compute example demonstrating Config Store usage" -requires-python = ">=3.12" -dependencies = [ - "bottle>=0.12.25", - "fastly-compute", -] - -[tool.uv.sources] -fastly-compute = { path = "../../", editable = true } - -[tool.fastly-compute] -entry = "config-store" diff --git a/examples/config-store/uv.lock b/examples/config-store/uv.lock deleted file mode 100644 index 6254801..0000000 --- a/examples/config-store/uv.lock +++ /dev/null @@ -1,49 +0,0 @@ -version = 1 -revision = 3 -requires-python = ">=3.12" - -[[package]] -name = "bottle" -version = "0.13.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7a/71/cca6167c06d00c81375fd668719df245864076d284f7cb46a694cbeb5454/bottle-0.13.4.tar.gz", hash = "sha256:787e78327e12b227938de02248333d788cfe45987edca735f8f88e03472c3f47", size = 98717, upload-time = "2025-06-15T10:08:59.439Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/83/f6/b55ec74cfe68c6584163faa311503c20b0da4c09883a41e8e00d6726c954/bottle-0.13.4-py2.py3-none-any.whl", hash = "sha256:045684fbd2764eac9cdeb824861d1551d113e8b683d8d26e296898d3dd99a12e", size = 103807, upload-time = "2025-06-15T10:08:57.691Z" }, -] - -[[package]] -name = "config-store" -version = "0.1.0" -source = { virtual = "." } -dependencies = [ - { name = "bottle" }, - { name = "fastly-compute" }, -] - -[package.metadata] -requires-dist = [ - { name = "bottle", specifier = ">=0.12.25" }, - { name = "fastly-compute", editable = "../../" }, -] - -[[package]] -name = "fastly-compute" -version = "0.1.0" -source = { editable = "../../" } - -[package.metadata] -requires-dist = [ - { name = "bottle", marker = "extra == 'examples'", specifier = ">=0.12.25" }, - { name = "componentize-py", marker = "extra == 'dev'", specifier = ">=0.19.3,<0.20" }, - { name = "flask", marker = "extra == 'examples'", specifier = ">=3.1.2,<4.0" }, - { name = "pyrefly", marker = "extra == 'dev'", specifier = ">=0.49.0,<0.50" }, - { name = "pytest", marker = "extra == 'test'", specifier = ">=8.4.0,<9.0.0" }, - { name = "requests", marker = "extra == 'test'", specifier = ">=2.32.5,<3.0.0" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.12.11,<0.13.0" }, - { name = "syrupy", marker = "extra == 'test'", specifier = "==5.0.0" }, - { name = "tomli-w", marker = "extra == 'test'", specifier = ">=1.0.0,<2.0.0" }, -] -provides-extras = ["test", "dev", "examples"] - -[package.metadata.requires-dev] -dev = [{ name = "maturin", specifier = ">=1.11.5" }] diff --git a/examples/flask-app/uv.lock b/examples/flask-app/uv.lock index 1358147..11ace26 100644 --- a/examples/flask-app/uv.lock +++ b/examples/flask-app/uv.lock @@ -40,6 +40,7 @@ source = { editable = "../../" } [package.metadata] requires-dist = [ { name = "bottle", marker = "extra == 'examples'", specifier = ">=0.12.25" }, + { name = "bottle", marker = "extra == 'test'", specifier = ">=0.12.25" }, { name = "componentize-py", marker = "extra == 'dev'", specifier = ">=0.19.3,<0.20" }, { name = "flask", marker = "extra == 'examples'", specifier = ">=3.1.2,<4.0" }, { name = "pyrefly", marker = "extra == 'dev'", specifier = ">=0.49.0,<0.50" }, diff --git a/examples/game-of-life/uv.lock b/examples/game-of-life/uv.lock index d401e93..ce6f7a5 100644 --- a/examples/game-of-life/uv.lock +++ b/examples/game-of-life/uv.lock @@ -40,6 +40,7 @@ source = { editable = "../../" } [package.metadata] requires-dist = [ { name = "bottle", marker = "extra == 'examples'", specifier = ">=0.12.25" }, + { name = "bottle", marker = "extra == 'test'", specifier = ">=0.12.25" }, { name = "componentize-py", marker = "extra == 'dev'", specifier = ">=0.19.3,<0.20" }, { name = "flask", marker = "extra == 'examples'", specifier = ">=3.1.2,<4.0" }, { name = "pyrefly", marker = "extra == 'dev'", specifier = ">=0.49.0,<0.50" }, diff --git a/examples/logging/uv.lock b/examples/logging/uv.lock index d6756f8..9c083d1 100644 --- a/examples/logging/uv.lock +++ b/examples/logging/uv.lock @@ -19,6 +19,7 @@ source = { editable = "../../" } [package.metadata] requires-dist = [ { name = "bottle", marker = "extra == 'examples'", specifier = ">=0.12.25" }, + { name = "bottle", marker = "extra == 'test'", specifier = ">=0.12.25" }, { name = "componentize-py", marker = "extra == 'dev'", specifier = ">=0.19.3,<0.20" }, { name = "flask", marker = "extra == 'examples'", specifier = ">=3.1.2,<4.0" }, { name = "pyrefly", marker = "extra == 'dev'", specifier = ">=0.49.0,<0.50" }, @@ -31,7 +32,10 @@ requires-dist = [ provides-extras = ["test", "dev", "examples"] [package.metadata.requires-dev] -dev = [{ name = "maturin", specifier = ">=1.11.5" }] +dev = [ + { name = "jinja2", specifier = ">=3.1.6" }, + { name = "maturin", specifier = ">=1.11.5" }, +] [[package]] name = "logging" diff --git a/fastly_compute/config_store.py b/fastly_compute/config_store.py index 13b4390..5c31135 100644 --- a/fastly_compute/config_store.py +++ b/fastly_compute/config_store.py @@ -11,6 +11,8 @@ api_url = config.get("api_url", "https://api.example.com") """ +from typing import Self + from wit_world.imports import config_store as wit_config_store # The maximum value for a u32, used to signal that we don't want to cap @@ -36,7 +38,7 @@ def __init__(self, store: wit_config_store.Store): self._store = store @classmethod - def open(cls, name: str) -> "ConfigStore": + def open(cls, name: str) -> Self: """Open a config store by name. :param name: The name of the config store @@ -101,7 +103,7 @@ def close(self) -> None: """ self._store.__exit__(None, None, None) - def __enter__(self) -> "ConfigStore": + def __enter__(self) -> Self: """Context manager entry. Allows use of ConfigStore in a 'with' statement. diff --git a/fastly_compute/requests/timeout.py b/fastly_compute/requests/timeout.py index 4e06421..0ed5a47 100644 --- a/fastly_compute/requests/timeout.py +++ b/fastly_compute/requests/timeout.py @@ -4,7 +4,7 @@ requests-compatible timeouts and Fastly-specific granular timeout controls. """ -from typing import override +from typing import Self, override class TimeoutConfig: @@ -52,9 +52,7 @@ def between_bytes_ms(self) -> int: return int(self.between_bytes * 1000) @classmethod - def from_requests_timeout( - cls, timeout: None | float | tuple[float, float] - ) -> "TimeoutConfig": + def from_requests_timeout(cls, timeout: None | float | tuple[float, float]) -> Self: """Create TimeoutConfig from requests-compatible timeout parameter. Args: diff --git a/fastly_compute/testing.py b/fastly_compute/testing.py index 05e67d0..7a481f5 100644 --- a/fastly_compute/testing.py +++ b/fastly_compute/testing.py @@ -9,13 +9,21 @@ """ import os +import pickle import socket import subprocess +import sys import threading import time +from contextlib import chdir, contextmanager from dataclasses import dataclass +from functools import wraps from pathlib import Path -from tempfile import NamedTemporaryFile +from shutil import rmtree +from tempfile import NamedTemporaryFile, mkdtemp +from types import MethodType +from typing import Any +from urllib.parse import quote import pytest import requests @@ -56,11 +64,11 @@ def test_my_endpoint(self): WASM_FILE = "build/bottle-app.composed.wasm" # Default to the main example _server: ViceroyServer | None = None # Will be set by the fixture - @property - def server(self) -> ViceroyServer: + @classmethod + def server(cls) -> ViceroyServer: """Access server properties.""" - assert self._server is not None - return self._server + assert cls._server is not None + return cls._server # Configuration for backend testing VICEROY_CONFIG = None # Dict with viceroy config, or None for no config @@ -261,7 +269,8 @@ def capture_output_thread(): pass # Ignore cleanup errors cls._config_file_path = None - def get(self, path: str, **kwargs) -> requests.Response: + @classmethod + def get(cls, path: str, **kwargs) -> requests.Response: """Make a GET request to the viceroy server. Args: @@ -271,9 +280,10 @@ def get(self, path: str, **kwargs) -> requests.Response: Returns: requests.Response: The HTTP response """ - return self.request("GET", path, **kwargs) + return cls.request("GET", path, **kwargs) - def post(self, path: str, **kwargs) -> requests.Response: + @classmethod + def post(cls, path: str, **kwargs) -> requests.Response: """Make a POST request to the viceroy server. Args: @@ -283,9 +293,10 @@ def post(self, path: str, **kwargs) -> requests.Response: Returns: requests.Response: The HTTP response """ - return self.request("POST", path, **kwargs) + return cls.request("POST", path, **kwargs) - def request(self, method: str, path: str, **kwargs) -> requests.Response: + @classmethod + def request(cls, method: str, path: str, **kwargs) -> requests.Response: """Make an HTTP request to the viceroy server. Args: @@ -296,8 +307,186 @@ def request(self, method: str, path: str, **kwargs) -> requests.Response: Returns: requests.Response: The HTTP response """ - timeout = kwargs.pop("timeout", self.REQUEST_TIMEOUT) + timeout = kwargs.pop("timeout", cls.REQUEST_TIMEOUT) response = requests.request( - method, f"{self.server.base_url}{path}", timeout=timeout, **kwargs + method, f"{cls.server().base_url}{path}", timeout=timeout, **kwargs ) return response + + +@contextmanager +def _temp_directory(): + """Make a temporary directory, and delete it afterward.""" + dir = Path(mkdtemp()) + yield dir + rmtree(dir) + + +class AutoViceroyTestBase(ViceroyTestBase): + """Test base class which tests against an ephemeral, generated WASM application. + + Whereas :class:`ViceroyTestBase` works against an on-disk WASM file, this + lets you put your WASM-side code next to your testrunner code through the + use of the :func:`on_viceroy` decorator, factoring away the build process, + the HTTP requests to Viceroy, and the serialization protocol used in those + requests. + """ + + # Whether this process is running (as wasm) on Viceroy: + _is_on_viceroy = False + + @pytest.fixture(scope="class", autouse=True) + @classmethod + def ephemeral_wasm(cls): + """Build an ad hoc WASM which performs the server-side half of the + :method:`on_viceroy` magic. + + The ``viceroy_server`` fixture then actually runs what we emit. + """ + # Import the module where the tests we'll be running are defined. Having + # them imported in a statically analyzable way allows componentize-py to + # walk them and include all transitive dependencies into the wasm. + code = f'''"""Bottle app that serves as a remote runner for chunks of test code that need +to execute in Viceroy +""" + +import pickle +from urllib.parse import unquote + +import bottle +from bottle import Bottle, post + +from fastly_compute.testing import AutoViceroyTestBase, ViceroyException, ViceroyReturn +AutoViceroyTestBase._is_on_viceroy = True + +import {cls.__module__} +from fastly_compute.wsgi import WsgiHttpIncoming + + +bottle.debug(True) +app = Bottle() + + +@app.post("/") +def run_viceroy_chunk(func_path: str) -> dict[str, str | bool]: + """Run an `@on_viceroy`-decorated method from a test class in Viceroy, and + return its result over HTTP. + + :arg func_path: Fully qualified name of the function to run, typically like + "TestClass.test_method". + """ + func_path = unquote(func_path) + body = bottle.request.body.read() + shipped_args, shipped_kwargs = pickle.loads(body) + + # Walk down the dotted path to get method to run: + method = {cls.__module__} + for part in func_path.split("."): + class_ = method + method = getattr(method, part) + + try: + return_value = method(class_, *shipped_args, **shipped_kwargs) + except Exception as exc: + result = ViceroyException(exc) + else: + result = ViceroyReturn(return_value) + return pickle.dumps(result) + + +HttpIncoming = WsgiHttpIncoming(app) +''' + with _temp_directory() as temp_dir: + (temp_dir / "main.py").write_text(code) + cls.WASM_FILE = str(temp_dir / "viceroy_test_code.wasm") + try: + with chdir(temp_dir): # fastly-compute-py -e arg is unreliable. + # Import the native _fastly_compute_py locally so + # componentize-py can wrap this testing.py module for use + # under Viceroy, where non-WASI native modules don't work: + from fastly_compute.fastly_compute_py import ( + run_main_py as fastly_compute_py, + ) + + # Rather than creating a new venv, we build within the one + # we're in right now. That is guaranteed to have the libs + # needed to load both the customer code (because the + # customer took responsibility for installing their deps) + # and fastly_compute (or we wouldn't be here). + fastly_compute_py( + [ + "dummy", + "build", + "--output", + cls.WASM_FILE, + "--virtualenv", + sys.prefix, + ] + ) + yield + finally: + del cls.WASM_FILE + + +def _as_class_method(method) -> classmethod: + """If a method is not already a class method, make it one.""" + return classmethod(method) if isinstance(method, MethodType) else method + + +class ViceroyException: + """An exception passed back from Viceroy-dwelling code""" + + def __init__(self, exception: Exception): + self.exception = exception + + def raise_or_return_value(self): + """Raise the exception I contain""" + raise self.exception + + +class ViceroyReturn: + """A function return value passed back from Viceroy-dwelling code""" + + def __init__(self, return_value: Any): + self.return_value = return_value + + def raise_or_return_value(self): + """Return the return value I contain""" + return self.return_value + + +def on_viceroy(method) -> classmethod: + """Decorator for making a method run on the testrunner's Viceroy server + + Decorate a method with this, and it will automagically run under Viceroy + when called. The method must be in a subclass of AutoViceroyTestBase. + + Notes and caveats: + * Return values and raised exceptions must be pickleable. + * If the decorated method is not already a class method, we make it one, in + service to conciseness. + """ + # TODO: Complain if the decorated method isn't in a subclass of AutoViceroyTestBase. + + # Advise users in the readme to put their tests within their package, + # not outside it. They need to be importable, because the + # test-code-runner template needs to be able to import them. + if AutoViceroyTestBase._is_on_viceroy: + return _as_class_method(method) + else: + # I'm on the host, in the testrunner. + @wraps(method) + def ask_viceroy_to_call_method(cls, *args, **kwargs): + """Make a request to Viceroy, passing along a path to a function to + run within it. + """ + response = cls.post( + "/" + quote(method.__qualname__), data=pickle.dumps((args, kwargs)) + ) + response.raise_for_status() + # Unpickle response. Return retval or raise exception. (Yes, raise. + # We want exceptions to not be forgotten by default.) + result = pickle.loads(response.content) + return result.raise_or_return_value() + + return _as_class_method(ask_viceroy_to_call_method) diff --git a/tests/README.md b/fastly_compute/tests/README.md similarity index 100% rename from tests/README.md rename to fastly_compute/tests/README.md diff --git a/fastly_compute/tests/__init__.py b/fastly_compute/tests/__init__.py new file mode 100644 index 0000000..5b82554 --- /dev/null +++ b/fastly_compute/tests/__init__.py @@ -0,0 +1,16 @@ +"""Tests for Fastly Compute""" + +import sys +from pathlib import Path + +# If we are not operating inside a WebAssembly host, make the WIT stubs +# importable. +# +# This allows us to have compatible definitions around for testing and +# typechecking. +try: + from componentize_py_types import Err +except ImportError: + sys.path.append(str(Path(__file__).parent.parent.parent / "stubs")) +else: + del Err diff --git a/tests/__snapshots__/test_backend_requests.ambr b/fastly_compute/tests/__snapshots__/test_backend_requests.ambr similarity index 100% rename from tests/__snapshots__/test_backend_requests.ambr rename to fastly_compute/tests/__snapshots__/test_backend_requests.ambr diff --git a/tests/conftest.py b/fastly_compute/tests/conftest.py similarity index 100% rename from tests/conftest.py rename to fastly_compute/tests/conftest.py diff --git a/tests/test_backend_requests.py b/fastly_compute/tests/test_backend_requests.py similarity index 100% rename from tests/test_backend_requests.py rename to fastly_compute/tests/test_backend_requests.py diff --git a/tests/test_bottle_example.py b/fastly_compute/tests/test_bottle_example.py similarity index 100% rename from tests/test_bottle_example.py rename to fastly_compute/tests/test_bottle_example.py diff --git a/tests/test_config_store.py b/fastly_compute/tests/test_config_store.py similarity index 57% rename from tests/test_config_store.py rename to fastly_compute/tests/test_config_store.py index 4f740b5..82ab571 100644 --- a/tests/test_config_store.py +++ b/fastly_compute/tests/test_config_store.py @@ -1,12 +1,14 @@ """Integration tests for Config Store functionality.""" -from fastly_compute.testing import ViceroyTestBase +from pytest import raises +from fastly_compute.config_store import ConfigStore +from fastly_compute.exceptions.types.open_error import NotFound +from fastly_compute.testing import AutoViceroyTestBase, on_viceroy -class TestConfigStore(ViceroyTestBase): - """Config store integration tests.""" - WASM_FILE = "build/config-store.composed.wasm" +class TestConfigStore(AutoViceroyTestBase): + """Config store integration tests.""" VICEROY_CONFIG = { "local_server": { @@ -26,33 +28,29 @@ class TestConfigStore(ViceroyTestBase): } } - def assert_get_value(self, store: str, key: str, expected: str | None) -> None: + def assert_get_value( + self, store: str, key: str, expected: str | None, default: str | None = None + ) -> None: """Assert that getting a key returns the expected value.""" - response = self.get(f"/get/{store}/{key}") - assert response.status_code == 200 - assert response.json() == {"value": expected} + value = self.config_get(store, key, default=default) + assert value == expected - def assert_get_value_with_default( - self, store: str, key: str, default: str, expected: str - ) -> None: - """Assert that getting a key with a default returns the expected value.""" - response = self.get(f"/get/{store}/{key}/{default}") - assert response.status_code == 200 - assert response.json() == {"value": expected} - - def assert_get_error(self, store: str, key: str, error_type: str) -> None: - """Assert that getting a key raises an error.""" - response = self.get(f"/get/{store}/{key}") - assert response.status_code == 500 - data = response.json() - assert data["error_type"] == error_type + @on_viceroy + def config_get(cls, store_name, key, default=None): + """Return the value associated with a config store key.""" + with ConfigStore.open(store_name) as config: + return config.get(key, default) + + @on_viceroy + def config_contains(cls, store_name, key): + """Return whether a given key exists in a config store.""" + with ConfigStore.open(store_name) as config: + return key in config def test_open_nonexistent_store(self): """Test opening a non-existent config store raises error.""" - response = self.get("/get/nonexistent/key") - assert response.status_code == 500 - data = response.json() - assert data["error_type"] == "NotFound" + with raises(NotFound): + self.config_get("nonexistent", "key") def test_get_string_value(self): """Test getting a string value.""" @@ -64,9 +62,7 @@ def test_get_nonexistent_key(self): def test_get_with_default(self): """Test getting with a default value.""" - self.assert_get_value_with_default( - "test-config", "nonexistent", "my_default", "my_default" - ) + self.assert_get_value("test-config", "nonexistent", "my_default", "my_default") def test_empty_string_value(self): """Test handling of empty string values.""" @@ -90,18 +86,12 @@ def test_large_values(self): def test_contains_existing_key(self): """Test that contains returns True for existing keys.""" - response = self.get("/contains/test-config/string_key") - assert response.status_code == 200 - assert response.json() == {"contains": True} + assert self.config_contains("test-config", "string_key") def test_contains_nonexistent_key(self): """Test that contains returns False for non-existent keys.""" - response = self.get("/contains/test-config/nonexistent") - assert response.status_code == 200 - assert response.json() == {"contains": False} + assert not self.config_contains("test-config", "nonexistent") def test_contains_empty_string_value(self): """Test that contains returns True for keys with empty string values.""" - response = self.get("/contains/test-config/empty_string") - assert response.status_code == 200 - assert response.json() == {"contains": True} + assert self.config_contains("test-config", "empty_string") diff --git a/fastly_compute/tests/test_exception_remapping.py b/fastly_compute/tests/test_exception_remapping.py new file mode 100644 index 0000000..72386d1 --- /dev/null +++ b/fastly_compute/tests/test_exception_remapping.py @@ -0,0 +1,119 @@ +"""Show (and test) some motivating examples of how the ``remap_wit_errors`` +decorator makes WIT's ``result``-driven errors more Pythonic. +""" + +from componentize_py_types import Err +from pytest import raises +from wit_world.imports.types import Error_BufferLen, OpenError + +from fastly_compute.exceptions import ( + FastlyError, + UnexpectedFastlyError, +) +from fastly_compute.runtime_patching.decorators import remap_wit_errors +from fastly_compute.testing import AutoViceroyTestBase, on_viceroy + + +class BufferTooShortError(FastlyError): + # A "nice" version of a WIT exception takes the WIT error as the sole arg of + # its constructor. While it would make the exception class more + # constructable by customer code if we took, for example, simply an int here + # and added a from_wit_error() class method, this would complicate the + # calling contract of remap_wit_errors() for "escape-hatch" callables which + # conditionally choose exception mappings. It remains to be seen if we ever + # need those. + def __init__(self, wit_error: Error_BufferLen): + self.length = wit_error.value + + # Freed of the generated skeletal dataclasses, we can add niceties like good + # error messages. + def __str__(self): + return f"Buffer was too short to hold the result. At least {self.length} is needed." + + +class NegativeHeightError(FastlyError): + def __init__(self, height: int): + self.height = height + + +class InvalidSyntaxError(FastlyError): + pass + + +class NotFoundError(FastlyError): + pass + + +enum_map = { + OpenError.INVALID_SYNTAX: InvalidSyntaxError, + OpenError.NOT_FOUND: NotFoundError, +} + + +class TestExceptionRemapping(AutoViceroyTestBase): + @on_viceroy + @remap_wit_errors({int: NegativeHeightError}) + def raise_int(cls): + """Raise a primitive value, which is expected and gets wrapped in a descriptive exception.""" + raise Err(value=-3) + + def test_primitive(self): + """Show that a primitive type can be mapped to a meaningful exception.""" + try: + self.raise_int() + except NegativeHeightError as e: + assert e.height == -3 + + @on_viceroy + @remap_wit_errors() + def raise_int_by_surprise(cls): + """Raise a primitive value, which is a type we didn't expect.""" + raise Err(value=-3) + + def test_unexpected(self): + """For unexpected error types, an UnexpectedFastlyError should be raised. + + This preserves the value of the original error and the ability for customers + to catch all Fastly API errors by catching FastlyError. It also keeps them + insulated from componentize-py's Err class, lest we move away from it + someday. + """ + try: + self.raise_int_by_surprise() + except UnexpectedFastlyError as e: + assert e.value == -3 + + @on_viceroy + @remap_wit_errors({Error_BufferLen: BufferTooShortError}) + def raise_variant(cls): + """Raise an Err whose value is a case of our generic ``error`` variant.""" + raise Err(value=Error_BufferLen(64)) + + def test_variant(self): + """Show how a WIT variant case can be concisely mapped into a more idiomatic exception.""" + try: + self.raise_variant() + except BufferTooShortError as e: + assert e.length == 64 + + @on_viceroy + @remap_wit_errors(enum_map) + def raise_one_enum(cls): + raise Err(value=OpenError.INVALID_SYNTAX) + + @on_viceroy + @remap_wit_errors(enum_map) + def raise_other_enum(cls): + raise Err(value=OpenError.NOT_FOUND) + + def test_enum(self): + """Show how we can also map individual enum cases to exception classes.""" + try: + self.raise_one_enum() + except InvalidSyntaxError as e: + assert len(e.args) == 0, ( + "Exceptions raised based on enum members should receive no constructor args." + ) + + with raises(NotFoundError): + self.raise_other_enum() diff --git a/tests/test_flask_example.py b/fastly_compute/tests/test_flask_example.py similarity index 100% rename from tests/test_flask_example.py rename to fastly_compute/tests/test_flask_example.py diff --git a/tests/test_game_of_life_example.py b/fastly_compute/tests/test_game_of_life_example.py similarity index 99% rename from tests/test_game_of_life_example.py rename to fastly_compute/tests/test_game_of_life_example.py index deeaf8d..90bbf92 100644 --- a/tests/test_game_of_life_example.py +++ b/fastly_compute/tests/test_game_of_life_example.py @@ -47,4 +47,4 @@ def test_reuse_sandboxes(self): # Reports about crashers in the post-response code come *after* the # request has succeeded. And it seems to take awhile to show up. sleep(0.5) # .3 is not enough. - assert "WebAssembly trapped" not in "\n".join(self.server.output_lines) + assert "WebAssembly trapped" not in "\n".join(self.server().output_lines) diff --git a/tests/test_log.py b/fastly_compute/tests/test_log.py similarity index 99% rename from tests/test_log.py rename to fastly_compute/tests/test_log.py index 5ffa6de..bb9305c 100644 --- a/tests/test_log.py +++ b/fastly_compute/tests/test_log.py @@ -58,7 +58,7 @@ def _get_logs_for_endpoint(self, endpoint_name): """ log_prefix = f"{endpoint_name} :: " logs = [] - for line in self.server.output_lines: + for line in self.server().output_lines: if log_prefix in line: log_message = line.split(log_prefix, 1)[1] logs.append(log_message) diff --git a/tests/test_testing.py b/fastly_compute/tests/test_testing.py similarity index 88% rename from tests/test_testing.py rename to fastly_compute/tests/test_testing.py index 669b1fb..86fdc8f 100644 --- a/tests/test_testing.py +++ b/fastly_compute/tests/test_testing.py @@ -12,17 +12,18 @@ class TestViceroyTestingFramework(ViceroyTestBase): def test_viceroy_server_fixture_provides_server_info(self): """Test that the viceroy_server fixture provides expected attributes.""" # Check that the fixture sets up a ViceroyServer with expected attributes - assert hasattr(self.server, "process") - assert hasattr(self.server, "base_url") - assert hasattr(self.server, "output_lines") + server = self.server() + assert hasattr(server, "process") + assert hasattr(server, "base_url") + assert hasattr(server, "output_lines") # Check that base_url is properly formatted - assert self.server.base_url.startswith("http://127.0.0.1:") + assert server.base_url.startswith("http://127.0.0.1:") # Check that output_lines contains viceroy startup output - assert len(self.server.output_lines) > 0 + assert len(server.output_lines) > 0 listening_lines = [ - line for line in self.server.output_lines if "Listening on" in line + line for line in server.output_lines if "Listening on" in line ] assert len(listening_lines) > 0 diff --git a/fastly_compute/wsgi.py b/fastly_compute/wsgi.py index da3834b..265aff2 100644 --- a/fastly_compute/wsgi.py +++ b/fastly_compute/wsgi.py @@ -11,11 +11,12 @@ import sys import traceback from collections.abc import Callable +from io import BytesIO, Reader from typing import Any from urllib.parse import urlparse from wit_world.exports import HttpIncoming as WitHttpIncoming -from wit_world.imports import http_body, http_resp +from wit_world.imports import async_io, http_body, http_req, http_resp from wit_world.imports.http_downstream import ( NextRequestOptions, await_request, @@ -27,8 +28,8 @@ def serve_wsgi_request( - req: Any, - body: Any, + req: http_req.Request, + body: Reader[bytes], app: Callable, handle_errors: bool = False, ) -> None: @@ -76,7 +77,7 @@ def start_response( "wsgi.errors": sys.stderr, "wsgi.version": (1, 0), "wsgi.url_scheme": url.scheme or "http", - "wsgi.input": sys.stdin.buffer, + "wsgi.input": body, "wsgi.multithread": False, "wsgi.multiprocess": False, "wsgi.run_once": True, @@ -205,12 +206,19 @@ def __call__(self): """ return self - def handle(self, request: Any, body: Any) -> None: + def handle(self, request: http_req.Request, body: async_io.Pollable) -> None: """Handle incoming HTTP requests by serving them through the WSGI app.""" + + def body_reader(body: async_io.Pollable) -> Reader[bytes]: + """Given a Fastly HTTP body object, return a file-like object + containing the body's content. + """ + return BytesIO(http_body.read(body, 2**32 - 1)) + with request: # Ensure dropping of request resource before trying to get another one. This dodges a crash. serve_wsgi_request( request, - body, + body_reader(body), self.wsgi_app, handle_errors=self.handle_errors, ) @@ -233,7 +241,7 @@ def handle(self, request: Any, body: Any) -> None: with request: serve_wsgi_request( request, - body, + body_reader(body), self.wsgi_app, handle_errors=self.handle_errors, ) diff --git a/pyproject.toml b/pyproject.toml index 472842c..ac30aac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,7 @@ dependencies = [] [project.optional-dependencies] test = [ + "bottle (>=0.12.25)", "pytest (>=8.4.0,<9.0.0)", "requests (>=2.32.5,<3.0.0)", "tomli-w (>=1.0.0,<2.0.0)", @@ -25,7 +26,7 @@ examples = [ ] [tool.pytest.ini_options] -testpaths = ["tests"] +testpaths = ["fastly_compute/tests"] python_files = ["test_*.py"] python_classes = ["Test*"] python_functions = ["test_*"] @@ -36,7 +37,7 @@ addopts = [ ] [tool.ruff] -target-version = "py312" +target-version = "py314" line-length = 88 [tool.ruff.lint] @@ -71,7 +72,7 @@ convention = "google" # Ignore doc lints for tests/examples [tool.ruff.lint.per-file-ignores] -"tests/*" = ["D"] +"fastly_compute/tests/*" = ["D"] "examples/*" = ["D"] # What can one say about __main__? "__main__.py" = ["D100"] @@ -89,6 +90,7 @@ dev = [ [tool.maturin] module-name = "fastly_compute._fastly_compute_py" manifest-path = "crates/fastly-compute-py/Cargo.toml" +exclude = ["fastly_compute/tests/**/*"] [project.scripts] fastly-compute-py = "fastly_compute.fastly_compute_py:main" @@ -97,13 +99,13 @@ fastly-compute-py = "fastly_compute.fastly_compute_py:main" py-modules = ["app"] [tool.pyrefly] -python-version = "3.12" +python-version = "3.14" search-path = ["stubs"] use-ignore-files = true # Type-check source code, tests, and examples project-includes = [ "fastly_compute/**/*.py", - "tests/**/*.py", + "fastly_compute/tests/**/*.py", "examples/**/*.py", "scripts", ] diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index 6ad7227..0000000 --- a/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Test package for Fastly Compute tests.""" diff --git a/tests/test_nice_exceptions.py b/tests/test_nice_exceptions.py deleted file mode 100644 index b3f20d2..0000000 --- a/tests/test_nice_exceptions.py +++ /dev/null @@ -1,122 +0,0 @@ -"""Show (and test) some motivating examples of how the ``remap_wit_errors`` -decorator makes WIT's ``result``-driven errors more Pythonic.""" - -import sys -from pathlib import Path - -from pytest import raises - -# Bring in stubs for local testing: -sys.path.append(str(Path(__file__).parent.parent / "stubs")) - -from componentize_py_types import Err -from wit_world.imports.types import Error_BufferLen, OpenError - -from fastly_compute.exceptions import ( - FastlyError, - UnexpectedFastlyError, -) -from fastly_compute.runtime_patching.decorators import remap_wit_errors - - -class BufferTooShortError(FastlyError): - # A "nice" version of a WIT exception takes the WIT error as the sole arg of - # its constructor. While it would make the exception class more - # constructable by customer code if we took, for example, simply an int here - # and added a from_wit_error() class method, this would complicate the - # calling contract of remap_wit_errors() for "escape-hatch" callables which - # conditionally choose exception mappings. It remains to be seen if we ever - # need those. - def __init__(self, wit_error: Error_BufferLen): - self.length = wit_error.value - - # Freed of the generated skeletal dataclasses, we can add niceties like good - # error messages. - def __str__(self): - return f"Buffer was too short to hold the result. At least {self.length} is needed." - - -class NegativeHeightError(FastlyError): - def __init__(self, height: int): - self.height = height - - -def test_primitive(): - """Show that a primitive type can be mapped to a meaningful exception.""" - - @remap_wit_errors({int: NegativeHeightError}) - def raise_int() -> Err: - """Raise a primitive value, which is expected and gets wrapped in a descriptive exception.""" - raise Err(value=-3) - - try: - raise_int() - except NegativeHeightError as e: - assert e.height == -3 - - -def test_unexpected(): - """For unexpected error types, an UnexpectedFastlyError should be raised. - - This preserves the value of the original error and the ability for customers - to catch all Fastly API errors by catching FastlyError. It also keeps them - insulated from componentize-py's Err class, lest we move away from it - someday. - """ - - @remap_wit_errors() - def raise_int_by_surprise() -> Err: - """Raise a primitive value, which is a type we didn't expect.""" - raise Err(value=-3) - - try: - raise_int_by_surprise() - except UnexpectedFastlyError as e: - assert e.value == -3 - - -def test_variant(): - """Show how a WIT variant case can be concisely mapped into a more idiomatic exception.""" - - @remap_wit_errors({Error_BufferLen: BufferTooShortError}) - def raise_variant() -> Err: - """Raise an Err whose value is a case of our generic ``error`` variant.""" - raise Err(value=Error_BufferLen(64)) - - try: - raise_variant() - except BufferTooShortError as e: - assert e.length == 64 - - -def test_enum(): - """Show how we can also map individual enum cases to exception classes.""" - - class InvalidSyntaxError(FastlyError): - pass - - class NotFoundError(FastlyError): - pass - - enum_map = { - OpenError.INVALID_SYNTAX: InvalidSyntaxError, - OpenError.NOT_FOUND: NotFoundError, - } - - @remap_wit_errors(enum_map) - def raise_one_enum() -> Err: - raise Err(value=OpenError.INVALID_SYNTAX) - - @remap_wit_errors(enum_map) - def raise_other_enum() -> Err: - raise Err(value=OpenError.NOT_FOUND) - - try: - raise_one_enum() - except InvalidSyntaxError as e: - assert len(e.args) == 0, ( - "Exceptions raised based on enum members should receive no constructor args." - ) - - with raises(NotFoundError): - raise_other_enum() diff --git a/uv.lock b/uv.lock index 75a9734..e11b597 100644 --- a/uv.lock +++ b/uv.lock @@ -136,6 +136,7 @@ examples = [ { name = "flask" }, ] test = [ + { name = "bottle" }, { name = "pytest" }, { name = "requests" }, { name = "syrupy" }, @@ -151,6 +152,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "bottle", marker = "extra == 'examples'", specifier = ">=0.12.25" }, + { name = "bottle", marker = "extra == 'test'", specifier = ">=0.12.25" }, { name = "componentize-py", marker = "extra == 'dev'", specifier = ">=0.19.3,<0.20" }, { name = "flask", marker = "extra == 'examples'", specifier = ">=3.1.2,<4.0" }, { name = "pyrefly", marker = "extra == 'dev'", specifier = ">=0.49.0,<0.50" },