From 960d291a087e3b7d39845446f62c6e3283179143 Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Thu, 22 Jan 2026 17:05:27 -0600 Subject: [PATCH 1/4] Add ConfigStore support Mirroring the JS SDK, we provide a simple get() interface. I opted to not trying to provide a dict facade or similar. That is possible but it doesn't seem to represent a significant ergonomic improvement and might make it more likely that user's fail to keep in mind the potential exception behavior. As with the Rust SDK, we intelligently use hint information from the host to get values with a big enough buffer if our original size is insufficient. --- Makefile | 2 +- examples/config-store/config-store.py | 75 ++++++++++ examples/config-store/pyproject.toml | 15 ++ examples/config-store/uv.lock | 49 +++++++ fastly_compute/config_store.py | 200 ++++++++++++++++++++++++++ tests/test_config_store.py | 118 +++++++++++++++ 6 files changed, 458 insertions(+), 1 deletion(-) create mode 100644 examples/config-store/config-store.py create mode 100644 examples/config-store/pyproject.toml create mode 100644 examples/config-store/uv.lock create mode 100644 fastly_compute/config_store.py create mode 100644 tests/test_config_store.py diff --git a/Makefile b/Makefile index 534e163..48ffe80 100644 --- a/Makefile +++ b/Makefile @@ -28,7 +28,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 +EXAMPLES := bottle-app flask-app backend-requests game-of-life config-store # Default example for serve target EXAMPLE ?= bottle-app diff --git a/examples/config-store/config-store.py b/examples/config-store/config-store.py new file mode 100644 index 0000000..de6b0ab --- /dev/null +++ b/examples/config-store/config-store.py @@ -0,0 +1,75 @@ +"""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("/get_with_initial_buf_len///") +@handle_request +def test_get_with_initial_buf_len(store_name, key, initial_buf_len): + """Proxy endpoint to test get with custom initial_buf_len using raw API.""" + config = ConfigStore.open(store_name) + # Use _get_raw to test buffer sizing without automatic retry + value = config._get_raw(key, initial_buf_len) + 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 = config.contains(key) + 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 new file mode 100644 index 0000000..ab88e20 --- /dev/null +++ b/examples/config-store/pyproject.toml @@ -0,0 +1,15 @@ +[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 new file mode 100644 index 0000000..6254801 --- /dev/null +++ b/examples/config-store/uv.lock @@ -0,0 +1,49 @@ +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/fastly_compute/config_store.py b/fastly_compute/config_store.py new file mode 100644 index 0000000..d0dbad7 --- /dev/null +++ b/fastly_compute/config_store.py @@ -0,0 +1,200 @@ +"""Config Store API for Fastly Compute. + +This module provides access to Fastly Config Stores, which allow you to store +configuration data that can be updated without redeploying your service. + +Example:: + + from fastly_compute.config_store import ConfigStore + + with ConfigStore.open("my-config") as config: + api_url = config.get("api_url", "https://api.example.com") +""" + +from wit_world.imports import config_store as wit_config_store +from wit_world.imports.types import Error_BufferLen, Error_InvalidArgument, OpenError + +from fastly_compute.exceptions import FastlyError, remap_wit_errors + + +class ConfigStoreError(FastlyError): + """Base exception for all config store errors.""" + + +class ConfigStoreNotFoundError(ConfigStoreError): + """The requested config store does not exist.""" + + +class ConfigStoreInvalidNameError(ConfigStoreError): + """The config store name is invalid.""" + + +class ConfigStoreBufferTooSmallError(ConfigStoreError): + """The buffer provided was too small for the config value. + + The required buffer size is available in the `required_size` attribute. + """ + + def __init__(self, error: Error_BufferLen): + """Initialize with the WIT error containing the required size. + + :param error: The WIT Error_BufferLen dataclass with the required size + """ + self.required_size = error.value + super().__init__( + f"Buffer too small for config store value. Required size: {self.required_size} bytes" + ) + + +class ConfigStoreInvalidKeyError(ConfigStoreError): + """The key provided is invalid.""" + + +class ConfigStore: + """Interface to Fastly Config Store. + + Config Stores provide read-only access to configuration data that can be + updated without redeploying your service. + + Example:: + + with ConfigStore.open("app-config") as config: + api_url = config.get("api_url", "https://api.example.com") + """ + + def __init__(self, store: wit_config_store.Store): + """Private constructor. Use ConfigStore.open() instead.""" + self._store = store + + @classmethod + @remap_wit_errors( + { + OpenError.NOT_FOUND: ConfigStoreNotFoundError, + OpenError.INVALID_SYNTAX: ConfigStoreInvalidNameError, + OpenError.NAME_TOO_LONG: ConfigStoreInvalidNameError, + OpenError.RESERVED: ConfigStoreInvalidNameError, + } + ) + def open(cls, name: str) -> "ConfigStore": + """Open a config store by name. + + :param name: The name of the config store + :return: ConfigStore instance + :raises ConfigStoreNotFoundError: If the config store doesn't exist + :raises ConfigStoreInvalidNameError: If the name is invalid or too long + + Example:: + + config = ConfigStore.open("my-config") + """ + store = wit_config_store.Store.open(name) + return cls(store) + + def get( + self, key: str, default: str | None = None, initial_buf_len: int = 1024 + ) -> str | None: + """Get a configuration value with automatic buffer resizing. + + This method automatically handles buffer sizing for config store values. + If the initial buffer is too small, it will automatically retry once with + the exact size required by the host. + + :param key: The configuration key + :param default: Default value if key not found + :param initial_buf_len: Initial buffer size hint in bytes (default: 1024). + This can be tuned for performance if you know your values are typically + larger or smaller than 1KB. + :return: Configuration value or default if not found + :raises ConfigStoreInvalidKeyError: If the key is invalid + :raises ConfigStoreBufferTooSmallError: If the value is too large even after + automatic resizing (should not happen unless there's a host-level bug) + + Example:: + + config = ConfigStore.open("app-config") + # Basic usage with default buffer size + api_url = config.get("api_url", "https://api.example.com") + + # Optimize for large values by using a larger initial buffer + large_value = config.get("large_config", initial_buf_len=16384) + """ + # Try with the initial buffer size + try: + result = self._get_raw(key, initial_buf_len) + except ConfigStoreBufferTooSmallError as e: + # Buffer was too small. The exception contains the exact required size. + # Retry ONCE with the exact size needed. If this second attempt fails, + # let the exception propagate (no infinite recursion). + result = self._get_raw(key, e.required_size) + + if result is None: + result = default + + return result + + @remap_wit_errors( + { + Error_BufferLen: ConfigStoreBufferTooSmallError, + Error_InvalidArgument: ConfigStoreInvalidKeyError, + } + ) + def _get_raw(self, key: str, max_len: int) -> str | None: + """Internal method to get a value with a specific buffer size. + + :param key: The configuration key + :param max_len: Maximum buffer length + :return: Configuration value or None if not found + :raises ConfigStoreBufferTooSmallError: If the buffer is too small + :raises ConfigStoreInvalidKeyError: If the key is invalid + """ + return self._store.get(key, max_len) + + def contains(self, key: str) -> bool: + """Check if a key exists in the config store. + + Uses a zero-length buffer to efficiently check for key existence without + retrieving the value. + + :param key: The configuration key + :return: True if the key exists, False otherwise + :raises ConfigStoreInvalidKeyError: If the key is invalid + + Example:: + + config = ConfigStore.open("app-config") + if config.contains("feature_flag"): + print("Feature flag exists") + """ + try: + # Use a 0-length buffer to check existence without retrieving the value + result = self._get_raw(key, 0) + return result is not None + except ConfigStoreBufferTooSmallError: + # Buffer too small means the key exists with a non-empty value + return True + except ConfigStoreInvalidKeyError: + # Re-raise invalid key errors + raise + + def close(self) -> None: + """Explicitly close the config store, releasing its resources. + + This is called automatically when using the config store as a context + manager. If not called explicitly, resources will eventually be freed + by the garbage collector. + + Note: Attempting to use the config store after it is closed will result + in a trap. + """ + self._store.__exit__(None, None, None) + + def __enter__(self) -> "ConfigStore": + """Context manager entry. + + Allows use of ConfigStore in a 'with' statement. + """ + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Context manager exit.""" + self._store.__exit__(exc_type, exc_val, exc_tb) diff --git a/tests/test_config_store.py b/tests/test_config_store.py new file mode 100644 index 0000000..9dcec0b --- /dev/null +++ b/tests/test_config_store.py @@ -0,0 +1,118 @@ +"""Integration tests for Config Store functionality.""" + +from fastly_compute.testing import ViceroyTestBase + + +class TestConfigStore(ViceroyTestBase): + """Config store integration tests.""" + + WASM_FILE = "build/config-store.composed.wasm" + + VICEROY_CONFIG = { + "local_server": { + "config_stores": { + "test-config": { + "format": "inline-toml", + "contents": { + "string_key": "hello world", + "empty_string": "", + "whitespace": " ", + "special_chars": "!@#$%^&*()", + "unicode": "Hello δΈ–η•Œ 🌍", + "large_value": "x" * 100, # 100 byte value for buffer tests + }, + } + } + } + } + + def assert_get_value(self, store: str, key: str, expected: str | 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} + + 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 + + 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"] == "ConfigStoreNotFoundError" + + def test_get_string_value(self): + """Test getting a string value.""" + self.assert_get_value("test-config", "string_key", "hello world") + + def test_get_nonexistent_key(self): + """Test getting a nonexistent key returns None.""" + self.assert_get_value("test-config", "nonexistent", None) + + 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" + ) + + def test_empty_string_value(self): + """Test handling of empty string values.""" + self.assert_get_value("test-config", "empty_string", "") + + def test_whitespace_value(self): + """Test handling of whitespace values.""" + self.assert_get_value("test-config", "whitespace", " ") + + def test_special_characters(self): + """Test handling of special characters.""" + self.assert_get_value("test-config", "special_chars", "!@#$%^&*()") + + def test_unicode_support(self): + """Test handling of unicode characters.""" + self.assert_get_value("test-config", "unicode", "Hello δΈ–η•Œ 🌍") + + def test_automatic_buffer_resize_for_large_values(self): + """Test that automatic buffer resizing handles large values.""" + # The large_value is 100 bytes. With automatic resizing, this should work + # even with a small initial buffer (default 1024 bytes). + self.assert_get_value("test-config", "large_value", "x" * 100) + + def test_buffer_too_small_with_raw_api(self): + """Test that _get_raw API can still raise buffer errors when called directly.""" + # The /get_with_initial_buf_len endpoint uses a small buffer without retry + # to test that buffer errors can still occur at the lower level + response = self.get("/get_with_initial_buf_len/test-config/large_value/10") + assert response.status_code == 500 + data = response.json() + assert data["error_type"] == "ConfigStoreBufferTooSmallError" + + 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} + + 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} + + 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} From e803f6933e369056f10f596112b0eeb5a7493603 Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Tue, 3 Feb 2026 15:30:13 -0600 Subject: [PATCH 2/4] Update config_store to use generated exceptions Also, use larger buffer size rather than doing unecessary resizes and address a few other comments from the PR. --- examples/config-store/config-store.py | 10 --- fastly_compute/config_store.py | 125 +++++--------------------- tests/test_config_store.py | 21 ++--- 3 files changed, 25 insertions(+), 131 deletions(-) diff --git a/examples/config-store/config-store.py b/examples/config-store/config-store.py index de6b0ab..e36c24d 100644 --- a/examples/config-store/config-store.py +++ b/examples/config-store/config-store.py @@ -52,16 +52,6 @@ def test_get(store_name, key, default=None): return {"value": value} -@app.route("/get_with_initial_buf_len///") -@handle_request -def test_get_with_initial_buf_len(store_name, key, initial_buf_len): - """Proxy endpoint to test get with custom initial_buf_len using raw API.""" - config = ConfigStore.open(store_name) - # Use _get_raw to test buffer sizing without automatic retry - value = config._get_raw(key, initial_buf_len) - return {"value": value} - - @app.route("/contains//") @handle_request def test_contains(store_name, key): diff --git a/fastly_compute/config_store.py b/fastly_compute/config_store.py index d0dbad7..4ce59e8 100644 --- a/fastly_compute/config_store.py +++ b/fastly_compute/config_store.py @@ -12,42 +12,11 @@ """ from wit_world.imports import config_store as wit_config_store -from wit_world.imports.types import Error_BufferLen, Error_InvalidArgument, OpenError -from fastly_compute.exceptions import FastlyError, remap_wit_errors - - -class ConfigStoreError(FastlyError): - """Base exception for all config store errors.""" - - -class ConfigStoreNotFoundError(ConfigStoreError): - """The requested config store does not exist.""" - - -class ConfigStoreInvalidNameError(ConfigStoreError): - """The config store name is invalid.""" - - -class ConfigStoreBufferTooSmallError(ConfigStoreError): - """The buffer provided was too small for the config value. - - The required buffer size is available in the `required_size` attribute. - """ - - def __init__(self, error: Error_BufferLen): - """Initialize with the WIT error containing the required size. - - :param error: The WIT Error_BufferLen dataclass with the required size - """ - self.required_size = error.value - super().__init__( - f"Buffer too small for config store value. Required size: {self.required_size} bytes" - ) - - -class ConfigStoreInvalidKeyError(ConfigStoreError): - """The key provided is invalid.""" +# The maximum value for a u32, used to signal that we don't want to cap +# the length of values returned by the host. In practice, this limit +# is at 8KB, though that could change. +_MAX_U32 = 0xFFFFFFFF class ConfigStore: @@ -67,21 +36,14 @@ def __init__(self, store: wit_config_store.Store): self._store = store @classmethod - @remap_wit_errors( - { - OpenError.NOT_FOUND: ConfigStoreNotFoundError, - OpenError.INVALID_SYNTAX: ConfigStoreInvalidNameError, - OpenError.NAME_TOO_LONG: ConfigStoreInvalidNameError, - OpenError.RESERVED: ConfigStoreInvalidNameError, - } - ) def open(cls, name: str) -> "ConfigStore": """Open a config store by name. :param name: The name of the config store :return: ConfigStore instance - :raises ConfigStoreNotFoundError: If the config store doesn't exist - :raises ConfigStoreInvalidNameError: If the name is invalid or too long + :raises ~fastly_compute.exceptions.types.open_error.NotFound: If the config store doesn't exist + :raises ~fastly_compute.exceptions.types.open_error.InvalidSyntax: If the name is invalid + :raises ~fastly_compute.exceptions.types.open_error.NameTooLong: If the name is too long Example:: @@ -90,74 +52,31 @@ def open(cls, name: str) -> "ConfigStore": store = wit_config_store.Store.open(name) return cls(store) - def get( - self, key: str, default: str | None = None, initial_buf_len: int = 1024 - ) -> str | None: - """Get a configuration value with automatic buffer resizing. - - This method automatically handles buffer sizing for config store values. - If the initial buffer is too small, it will automatically retry once with - the exact size required by the host. + def get(self, key: str, default: str | None = None) -> str | None: + """Get a configuration value. :param key: The configuration key :param default: Default value if key not found - :param initial_buf_len: Initial buffer size hint in bytes (default: 1024). - This can be tuned for performance if you know your values are typically - larger or smaller than 1KB. :return: Configuration value or default if not found - :raises ConfigStoreInvalidKeyError: If the key is invalid - :raises ConfigStoreBufferTooSmallError: If the value is too large even after - automatic resizing (should not happen unless there's a host-level bug) + :raises ~fastly_compute.exceptions.types.error.InvalidArgument: If the key is invalid Example:: config = ConfigStore.open("app-config") - # Basic usage with default buffer size api_url = config.get("api_url", "https://api.example.com") - - # Optimize for large values by using a larger initial buffer - large_value = config.get("large_config", initial_buf_len=16384) """ - # Try with the initial buffer size - try: - result = self._get_raw(key, initial_buf_len) - except ConfigStoreBufferTooSmallError as e: - # Buffer was too small. The exception contains the exact required size. - # Retry ONCE with the exact size needed. If this second attempt fails, - # let the exception propagate (no infinite recursion). - result = self._get_raw(key, e.required_size) - + result = self._store.get(key, _MAX_U32) if result is None: result = default return result - @remap_wit_errors( - { - Error_BufferLen: ConfigStoreBufferTooSmallError, - Error_InvalidArgument: ConfigStoreInvalidKeyError, - } - ) - def _get_raw(self, key: str, max_len: int) -> str | None: - """Internal method to get a value with a specific buffer size. - - :param key: The configuration key - :param max_len: Maximum buffer length - :return: Configuration value or None if not found - :raises ConfigStoreBufferTooSmallError: If the buffer is too small - :raises ConfigStoreInvalidKeyError: If the key is invalid - """ - return self._store.get(key, max_len) - def contains(self, key: str) -> bool: """Check if a key exists in the config store. - Uses a zero-length buffer to efficiently check for key existence without - retrieving the value. - :param key: The configuration key :return: True if the key exists, False otherwise - :raises ConfigStoreInvalidKeyError: If the key is invalid + :raises ~fastly_compute.exceptions.types.error.InvalidArgument: If the key is invalid Example:: @@ -165,16 +84,7 @@ def contains(self, key: str) -> bool: if config.contains("feature_flag"): print("Feature flag exists") """ - try: - # Use a 0-length buffer to check existence without retrieving the value - result = self._get_raw(key, 0) - return result is not None - except ConfigStoreBufferTooSmallError: - # Buffer too small means the key exists with a non-empty value - return True - except ConfigStoreInvalidKeyError: - # Re-raise invalid key errors - raise + return self.get(key) is not None def close(self) -> None: """Explicitly close the config store, releasing its resources. @@ -196,5 +106,10 @@ def __enter__(self) -> "ConfigStore": return self def __exit__(self, exc_type, exc_val, exc_tb): - """Context manager exit.""" - self._store.__exit__(exc_type, exc_val, exc_tb) + """Context manager exit. + + Use of the context manager will free up the underlying host resource on + exit. Referencing the resource after context manager exit will result in + a trap. + """ + self.close() diff --git a/tests/test_config_store.py b/tests/test_config_store.py index 9dcec0b..4f740b5 100644 --- a/tests/test_config_store.py +++ b/tests/test_config_store.py @@ -19,7 +19,7 @@ class TestConfigStore(ViceroyTestBase): "whitespace": " ", "special_chars": "!@#$%^&*()", "unicode": "Hello δΈ–η•Œ 🌍", - "large_value": "x" * 100, # 100 byte value for buffer tests + "large_value": "x" * 8000, # 8000 byte value for buffer tests }, } } @@ -52,7 +52,7 @@ def test_open_nonexistent_store(self): response = self.get("/get/nonexistent/key") assert response.status_code == 500 data = response.json() - assert data["error_type"] == "ConfigStoreNotFoundError" + assert data["error_type"] == "NotFound" def test_get_string_value(self): """Test getting a string value.""" @@ -84,20 +84,9 @@ def test_unicode_support(self): """Test handling of unicode characters.""" self.assert_get_value("test-config", "unicode", "Hello δΈ–η•Œ 🌍") - def test_automatic_buffer_resize_for_large_values(self): - """Test that automatic buffer resizing handles large values.""" - # The large_value is 100 bytes. With automatic resizing, this should work - # even with a small initial buffer (default 1024 bytes). - self.assert_get_value("test-config", "large_value", "x" * 100) - - def test_buffer_too_small_with_raw_api(self): - """Test that _get_raw API can still raise buffer errors when called directly.""" - # The /get_with_initial_buf_len endpoint uses a small buffer without retry - # to test that buffer errors can still occur at the lower level - response = self.get("/get_with_initial_buf_len/test-config/large_value/10") - assert response.status_code == 500 - data = response.json() - assert data["error_type"] == "ConfigStoreBufferTooSmallError" + def test_large_values(self): + """Test that large values are handled correctly.""" + self.assert_get_value("test-config", "large_value", "x" * 8000) def test_contains_existing_key(self): """Test that contains returns True for existing keys.""" From 129d4324c44f4ff4f4cd2e97c4af8241a2b99486 Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Fri, 13 Feb 2026 17:31:57 -0600 Subject: [PATCH 3/4] Convert config_store to use __contains__ --- examples/config-store/config-store.py | 2 +- fastly_compute/config_store.py | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/examples/config-store/config-store.py b/examples/config-store/config-store.py index e36c24d..5d97929 100644 --- a/examples/config-store/config-store.py +++ b/examples/config-store/config-store.py @@ -57,7 +57,7 @@ def test_get(store_name, key, default=None): def test_contains(store_name, key): """Proxy endpoint to test contains.""" config = ConfigStore.open(store_name) - contains = config.contains(key) + contains = key in config return {"contains": contains} diff --git a/fastly_compute/config_store.py b/fastly_compute/config_store.py index 4ce59e8..b24f4e5 100644 --- a/fastly_compute/config_store.py +++ b/fastly_compute/config_store.py @@ -71,19 +71,22 @@ def get(self, key: str, default: str | None = None) -> str | None: return result - def contains(self, key: str) -> bool: + def __contains__(self, key: str) -> bool: """Check if a key exists in the config store. :param key: The configuration key :return: True if the key exists, False otherwise :raises ~fastly_compute.exceptions.types.error.InvalidArgument: If the key is invalid + :raises KeyError: If the key is not a str Example:: config = ConfigStore.open("app-config") - if config.contains("feature_flag"): + if "feature_flag" in config: print("Feature flag exists") """ + if not isinstance(key, str): + raise KeyError("Key must be a str") return self.get(key) is not None def close(self) -> None: From c9028895ebce1414e62c8a97d4acbba8ec316c6a Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Tue, 17 Feb 2026 12:13:39 -0600 Subject: [PATCH 4/4] Address PR nit related to a period. --- fastly_compute/config_store.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastly_compute/config_store.py b/fastly_compute/config_store.py index b24f4e5..13b4390 100644 --- a/fastly_compute/config_store.py +++ b/fastly_compute/config_store.py @@ -1,4 +1,4 @@ -"""Config Store API for Fastly Compute. +"""Config Store API for Fastly Compute This module provides access to Fastly Config Stores, which allow you to store configuration data that can be updated without redeploying your service.