From 1c8291fe79d1d6a8a2ae213421f4523433843249 Mon Sep 17 00:00:00 2001 From: Erik Rose Date: Fri, 6 Feb 2026 22:46:33 -0500 Subject: [PATCH 1/9] Make testing certain pieces of code on Viceroy as simple as adding a decorator. * Add `@on_viceroy` decorator and supporting `AutoViceroyTestBase` test subclass. These work together to generate ephemeral Fastly projects under which Viceroy-side code is run. * Add `--virtualenv` option to `fastly-compute-py` so we can direct it to the env we're running under, even if it's not called ".venv" or if it's not pointed to by `VIRTUAL_ENV`. * Add Bottle to testing deps because we use it to communicate with the in-Viceroy code. * Move exceptions tests (which I'm using as a PoC) inside the package because their path needs to be importable for the machinery to work. But don't worry: we still don't ship them with the distro. * Remove unused FASTLY_COMPUTE_PY_BIN from makefile. * Import stubs (once and only once) iff we're not under Viceroy. It turns out they are needed if we are to run typechecking on the host. In addition, some tests that can get along perfectly well with only stubs, saving a spin-up of Viceroy. Finally, a universal import of stubs on the host means we don't have to segment modules like utils.py into guest and host halves to avoid import errors. * Add Jinja to config-store's uv.lock, which should have been in 87f875f577de55dc17cfe0beae01f34dcc293f53. It got neglected in the rapid-fire landing of the config-store API and exception polishing. --- Makefile | 1 - crates/fastly-compute-py/src/cli.rs | 5 + crates/fastly-compute-py/src/config.rs | 19 +- crates/fastly-compute-py/src/lib.rs | 6 +- crates/fastly-compute-py/src/site_packages.rs | 8 +- examples/backend-requests/uv.lock | 1 + examples/bottle-app/uv.lock | 1 + examples/config-store/uv.lock | 6 +- examples/flask-app/uv.lock | 1 + examples/game-of-life/uv.lock | 1 + fastly_compute/testing.py | 190 +++++++++++++++++- fastly_compute/tests/__init__.py | 16 ++ .../tests/test_exception_remapping.py | 119 +++++++++++ pyproject.toml | 5 +- tests/test_nice_exceptions.py | 122 ----------- uv.lock | 2 + 16 files changed, 359 insertions(+), 144 deletions(-) create mode 100644 fastly_compute/tests/__init__.py create mode 100644 fastly_compute/tests/test_exception_remapping.py delete mode 100644 tests/test_nice_exceptions.py diff --git a/Makefile b/Makefile index c0b520c..d48abce 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) 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/uv.lock b/examples/config-store/uv.lock index 6254801..e04da64 100644 --- a/examples/config-store/uv.lock +++ b/examples/config-store/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" }, @@ -46,4 +47,7 @@ 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" }, +] 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/fastly_compute/testing.py b/fastly_compute/testing.py index 05e67d0..0edcc6d 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 base64 import a85decode +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 urllib.parse import quote import pytest import requests @@ -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,171 @@ 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. + """ + # 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 +""" + +from base64 import a85encode +import pickle +from urllib.parse import unquote + +import bottle +from bottle import Bottle + +from fastly_compute.testing import AutoViceroyTestBase +AutoViceroyTestBase._is_on_viceroy = True + +import {cls.__module__} +from fastly_compute.wsgi import WsgiHttpIncoming + + +bottle.debug(True) +app = Bottle() + + +@app.route("/") +def run_viceroy_chunk(func_path: str) -> dict[str, str | bool]: + """Run a method from a test class in Viceroy, and return its result over + HTTP. + + The method must be a class method so we don't have to instantiate the class. + (Once upon a time, we relaxed this by requiring the class to be instantiable + with no args. We could do it again.) + + :arg func_path: Fully qualified name of the ``@in_viceroy``-decorated + function to run, typically like "TestClass.test_method". + """ + func_path = unquote(func_path) + + # 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: + result = method(class_) + is_exception = False + except Exception as exc: + result = exc + is_exception = True + + return {{"result": a85encode(pickle.dumps(result)).decode("ascii"), + "is_exception": is_exception}} + + +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 + + +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. + # + # In the future, we could support incoming params. + + @wraps(method) + def ask_viceroy_to_call_method(cls): + """Make a request to Viceroy, passing along a path to a function to + run within it. + """ + response = cls.get("/" + quote(method.__qualname__)) + response.raise_for_status() + data = response.json() + # Unpickle response. Return retval or raise exception. (Yes, raise. + # We want exceptions to not be forgotten by default.) + result = pickle.loads(a85decode(data["result"])) + if data["is_exception"]: + raise result + return result + + return _as_class_method(ask_viceroy_to_call_method) diff --git a/fastly_compute/tests/__init__.py b/fastly_compute/tests/__init__.py new file mode 100644 index 0000000..b073297 --- /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 # noqa +except ImportError: + sys.path.append(str(Path(__file__).parent.parent.parent / "stubs")) +else: + del Err 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/pyproject.toml b/pyproject.toml index 472842c..2875551 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 = ["tests", "fastly_compute/tests"] python_files = ["test_*.py"] python_classes = ["Test*"] python_functions = ["test_*"] @@ -72,6 +73,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 +91,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" 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" }, From 478dd79dec43a76c15eece43603dc7fe657048ae Mon Sep 17 00:00:00 2001 From: Erik Rose Date: Tue, 17 Feb 2026 10:45:32 -0500 Subject: [PATCH 2/9] Move other tests inside the `fastly_compute` package... ...for consistency and to allow future use of `@on_viceroy`. We still exclude them when we publish the distribution. --- {tests => fastly_compute/tests}/README.md | 0 .../tests}/__snapshots__/test_backend_requests.ambr | 0 {tests => fastly_compute/tests}/conftest.py | 0 {tests => fastly_compute/tests}/test_backend_requests.py | 0 {tests => fastly_compute/tests}/test_bottle_example.py | 0 {tests => fastly_compute/tests}/test_config_store.py | 0 {tests => fastly_compute/tests}/test_flask_example.py | 0 {tests => fastly_compute/tests}/test_game_of_life_example.py | 0 {tests => fastly_compute/tests}/test_testing.py | 0 pyproject.toml | 5 ++--- tests/__init__.py | 1 - 11 files changed, 2 insertions(+), 4 deletions(-) rename {tests => fastly_compute/tests}/README.md (100%) rename {tests => fastly_compute/tests}/__snapshots__/test_backend_requests.ambr (100%) rename {tests => fastly_compute/tests}/conftest.py (100%) rename {tests => fastly_compute/tests}/test_backend_requests.py (100%) rename {tests => fastly_compute/tests}/test_bottle_example.py (100%) rename {tests => fastly_compute/tests}/test_config_store.py (100%) rename {tests => fastly_compute/tests}/test_flask_example.py (100%) rename {tests => fastly_compute/tests}/test_game_of_life_example.py (100%) rename {tests => fastly_compute/tests}/test_testing.py (100%) delete mode 100644 tests/__init__.py 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/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 100% rename from tests/test_config_store.py rename to fastly_compute/tests/test_config_store.py 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 100% rename from tests/test_game_of_life_example.py rename to fastly_compute/tests/test_game_of_life_example.py diff --git a/tests/test_testing.py b/fastly_compute/tests/test_testing.py similarity index 100% rename from tests/test_testing.py rename to fastly_compute/tests/test_testing.py diff --git a/pyproject.toml b/pyproject.toml index 2875551..5b678ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ examples = [ ] [tool.pytest.ini_options] -testpaths = ["tests", "fastly_compute/tests"] +testpaths = ["fastly_compute/tests"] python_files = ["test_*.py"] python_classes = ["Test*"] python_functions = ["test_*"] @@ -72,7 +72,6 @@ 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__? @@ -106,7 +105,7 @@ 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.""" From 1531687b7d5bd7b1ae003be2173baa8b335e0d25 Mon Sep 17 00:00:00 2001 From: Erik Rose Date: Thu, 12 Feb 2026 14:32:24 -0500 Subject: [PATCH 3/9] Make the `server` property a class method instead. This better reflects what it is: `_server` is initialized when the class is defined. More immediately, it lets `@on_viceroy` call `.request()` without needing to access a private attr. --- fastly_compute/testing.py | 10 +++++----- fastly_compute/tests/test_game_of_life_example.py | 2 +- fastly_compute/tests/test_testing.py | 13 +++++++------ 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/fastly_compute/testing.py b/fastly_compute/testing.py index 0edcc6d..f09c45e 100644 --- a/fastly_compute/testing.py +++ b/fastly_compute/testing.py @@ -64,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 @@ -309,7 +309,7 @@ def request(cls, method: str, path: str, **kwargs) -> requests.Response: """ timeout = kwargs.pop("timeout", cls.REQUEST_TIMEOUT) response = requests.request( - method, f"{cls._server.base_url}{path}", timeout=timeout, **kwargs + method, f"{cls.server().base_url}{path}", timeout=timeout, **kwargs ) return response diff --git a/fastly_compute/tests/test_game_of_life_example.py b/fastly_compute/tests/test_game_of_life_example.py index deeaf8d..90bbf92 100644 --- a/fastly_compute/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/fastly_compute/tests/test_testing.py b/fastly_compute/tests/test_testing.py index 669b1fb..86fdc8f 100644 --- a/fastly_compute/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 From ecb4a1b3caf2d4b7508002f860b466d1c6286622 Mon Sep 17 00:00:00 2001 From: Erik Rose Date: Fri, 13 Feb 2026 14:37:07 -0500 Subject: [PATCH 4/9] Support reading the HTTP request body in our WSGI wrapper. Previously, we were just sucking in stdin, which isn't hooked up to anything useful. --- fastly_compute/wsgi.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) 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, ) From 632908ab96b525e978411aa886a5afe52f0f8d9e Mon Sep 17 00:00:00 2001 From: Erik Rose Date: Fri, 13 Feb 2026 14:40:34 -0500 Subject: [PATCH 5/9] Support passing params to `@on_viceroy`-decorated methods. --- fastly_compute/testing.py | 72 ++++++++++++++++++++++++--------------- 1 file changed, 45 insertions(+), 27 deletions(-) diff --git a/fastly_compute/testing.py b/fastly_compute/testing.py index f09c45e..3bf9b8e 100644 --- a/fastly_compute/testing.py +++ b/fastly_compute/testing.py @@ -15,7 +15,6 @@ import sys import threading import time -from base64 import a85decode from contextlib import chdir, contextmanager from dataclasses import dataclass from functools import wraps @@ -23,6 +22,7 @@ from shutil import rmtree from tempfile import NamedTemporaryFile, mkdtemp from types import MethodType +from typing import Any from urllib.parse import quote import pytest @@ -340,6 +340,8 @@ class AutoViceroyTestBase(ViceroyTestBase): 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 @@ -348,14 +350,13 @@ def ephemeral_wasm(cls): to execute in Viceroy """ -from base64 import a85encode import pickle from urllib.parse import unquote import bottle -from bottle import Bottle +from bottle import Bottle, post -from fastly_compute.testing import AutoViceroyTestBase +from fastly_compute.testing import AutoViceroyTestBase, ViceroyException, ViceroyReturn AutoViceroyTestBase._is_on_viceroy = True import {cls.__module__} @@ -366,19 +367,17 @@ def ephemeral_wasm(cls): app = Bottle() -@app.route("/") +@app.post("/") def run_viceroy_chunk(func_path: str) -> dict[str, str | bool]: - """Run a method from a test class in Viceroy, and return its result over - HTTP. - - The method must be a class method so we don't have to instantiate the class. - (Once upon a time, we relaxed this by requiring the class to be instantiable - with no args. We could do it again.) + """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 ``@in_viceroy``-decorated - function to run, typically like "TestClass.test_method". + :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__} @@ -387,14 +386,12 @@ def run_viceroy_chunk(func_path: str) -> dict[str, str | bool]: method = getattr(method, part) try: - result = method(class_) - is_exception = False + return_value = method(class_, *shipped_args, **shipped_kwargs) except Exception as exc: - result = exc - is_exception = True - - return {{"result": a85encode(pickle.dumps(result)).decode("ascii"), - "is_exception": is_exception}} + result = ViceroyException(exc) + else: + result = ViceroyReturn(return_value) + return pickle.dumps(result) HttpIncoming = WsgiHttpIncoming(app) @@ -436,6 +433,28 @@ def _as_class_method(method) -> classmethod: 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 @@ -460,18 +479,17 @@ def on_viceroy(method) -> classmethod: # In the future, we could support incoming params. @wraps(method) - def ask_viceroy_to_call_method(cls): + 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.get("/" + quote(method.__qualname__)) + response = cls.post( + "/" + quote(method.__qualname__), data=pickle.dumps((args, kwargs)) + ) response.raise_for_status() - data = response.json() # Unpickle response. Return retval or raise exception. (Yes, raise. # We want exceptions to not be forgotten by default.) - result = pickle.loads(a85decode(data["result"])) - if data["is_exception"]: - raise result - return result + result = pickle.loads(response.content) + return result.raise_or_return_value() return _as_class_method(ask_viceroy_to_call_method) From 3f5febfc0dd6d36a9a673bf236b9e083724691f5 Mon Sep 17 00:00:00 2001 From: Erik Rose Date: Tue, 17 Feb 2026 11:13:52 -0500 Subject: [PATCH 6/9] Bump linter targets to Python 3.14. In ruff's case this solves a bunch of really weird complaints about class methods: ``` ERROR Expected a callable, got `classmethod[Unknown, [**kwargs: Any], Any]` [not-callable] --> fastly_compute/tests/test_exception_remapping.py:82:13 | 82 | self.raise_int_by_surprise() ``` --- fastly_compute/requests/timeout.py | 6 ++---- pyproject.toml | 4 ++-- 2 files changed, 4 insertions(+), 6 deletions(-) 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/pyproject.toml b/pyproject.toml index 5b678ba..ac30aac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,7 @@ addopts = [ ] [tool.ruff] -target-version = "py312" +target-version = "py314" line-length = 88 [tool.ruff.lint] @@ -99,7 +99,7 @@ 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 From c2ef65acc01cac749428708426e8ab3838872d68 Mon Sep 17 00:00:00 2001 From: Erik Rose Date: Tue, 17 Feb 2026 17:16:40 -0500 Subject: [PATCH 7/9] Port `ConfigStore` tests to `@on_viceroy` as another (in fact, a better) proof of concept. --- Makefile | 2 +- examples/config-store/config-store.py | 65 ---------------------- examples/config-store/pyproject.toml | 15 ------ examples/config-store/uv.lock | 53 ------------------ fastly_compute/config_store.py | 6 ++- fastly_compute/tests/test_config_store.py | 66 ++++++++++------------- 6 files changed, 33 insertions(+), 174 deletions(-) delete mode 100644 examples/config-store/config-store.py delete mode 100644 examples/config-store/pyproject.toml delete mode 100644 examples/config-store/uv.lock diff --git a/Makefile b/Makefile index d48abce..23bd421 100644 --- a/Makefile +++ b/Makefile @@ -27,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 +EXAMPLES := bottle-app flask-app backend-requests game-of-life # Default example for serve target EXAMPLE ?= bottle-app 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 e04da64..0000000 --- a/examples/config-store/uv.lock +++ /dev/null @@ -1,53 +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 = "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" }, - { 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 = "jinja2", specifier = ">=3.1.6" }, - { name = "maturin", specifier = ">=1.11.5" }, -] 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/tests/test_config_store.py b/fastly_compute/tests/test_config_store.py index 4f740b5..82ab571 100644 --- a/fastly_compute/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") From 19e8330c372d18e1016506fa719972e59336fec4 Mon Sep 17 00:00:00 2001 From: Erik Rose Date: Wed, 18 Feb 2026 15:18:52 -0500 Subject: [PATCH 8/9] Correct a comment: I did implement param handling. --- fastly_compute/testing.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/fastly_compute/testing.py b/fastly_compute/testing.py index 3bf9b8e..7a481f5 100644 --- a/fastly_compute/testing.py +++ b/fastly_compute/testing.py @@ -475,9 +475,6 @@ def on_viceroy(method) -> classmethod: return _as_class_method(method) else: # I'm on the host, in the testrunner. - # - # In the future, we could support incoming params. - @wraps(method) def ask_viceroy_to_call_method(cls, *args, **kwargs): """Make a request to Viceroy, passing along a path to a function to From b14cca442388afdf97193780f9dcd5d3ec4bc4ba Mon Sep 17 00:00:00 2001 From: Erik Rose Date: Wed, 18 Feb 2026 15:22:26 -0500 Subject: [PATCH 9/9] Don't need this "noqa" anymore. `del`ing the symbol counts as a use. --- fastly_compute/tests/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastly_compute/tests/__init__.py b/fastly_compute/tests/__init__.py index b073297..5b82554 100644 --- a/fastly_compute/tests/__init__.py +++ b/fastly_compute/tests/__init__.py @@ -9,7 +9,7 @@ # This allows us to have compatible definitions around for testing and # typechecking. try: - from componentize_py_types import Err # noqa + from componentize_py_types import Err except ImportError: sys.path.append(str(Path(__file__).parent.parent.parent / "stubs")) else: