diff --git a/Makefile b/Makefile index fc90f60..bedc62e 100644 --- a/Makefile +++ b/Makefile @@ -51,16 +51,26 @@ $(STUBS_DIR): $(COMPUTE_WIT) uv run --extra dev componentize-py -d wit --world-module wit_world -w $(TARGET_WORLD) bindings $(STUBS_DIR) # Build our composed wasm using fastly-compute-py build -$(BUILD_DIR)/%.composed.wasm: wit/viceroy.wit wit/deps/fastly/compute.wit fastly_compute/wsgi.py fastly_compute/runtime_patching/patches.py | $(BUILD_DIR) $(STUBS_DIR) +$(BUILD_DIR)/%.composed.wasm: wit/viceroy.wit wit/deps/fastly/compute.wit fastly_compute/wsgi.py fastly_compute/_bindings/__init__.py | $(BUILD_DIR) $(STUBS_DIR) @echo "Building $* example with fastly-compute-py..." @test -d $(EXAMPLES_DIR)/$* || (echo "Error: Example directory $(EXAMPLES_DIR)/$* not found" && exit 1) cd $(EXAMPLES_DIR)/$* && $(FASTLY_COMPUTE_PY) build --output ../../$@ -# The script that writes the exceptions and the patches always rewrites -# everything, so we can depend on the mod date of only 1 file. We choose -# patches.py, because its name doesn't depend on the WIT contents. -fastly_compute/runtime_patching/patches.py: scripts/generate_patches/*.py $(shell find scripts/generate_patches/templates -name "*.jinja") $(COMPUTE_WIT) - uv run python -m scripts.generate_patches +# The generate_bindings script regenerates all _bindings/ modules and +# _error_mapping.py together. We depend on _bindings/__init__.py as the +# sentinel since the script always rewrites every file. +fastly_compute/_bindings/__init__.py: scripts/generate_bindings/*.py \ + scripts/wit/*.py \ + $(shell find scripts/generate_bindings/templates -name "*.jinja") \ + $(COMPUTE_WIT) + uv run python -m scripts.generate_bindings + +# The generate_exceptions script generates the exception hierarchy. +fastly_compute/exceptions/types/error.py: scripts/generate_exceptions/*.py \ + scripts/wit/*.py \ + $(shell find scripts/generate_exceptions/templates -name "*.jinja") \ + $(COMPUTE_WIT) + uv run python -m scripts.generate_exceptions # Create build directory $(BUILD_DIR): @@ -86,13 +96,12 @@ list-examples: # Clean build artifacts clean: - rm -rf $(BUILD_DIR) $(STUBS_DIR) - rm -f fastly_compute/runtime_patching/patches.py + rm -rf $(BUILD_DIR) $(STUBS_DIR) fastly_compute/_bindings cd fastly_compute/exceptions && rm -rf acl http_body http_req kv_store types cd crates/fastly-compute-py && cargo clean # Development tools -lint: fastly_compute/runtime_patching/patches.py | $(STUBS_DIR) +lint: fastly_compute/_bindings/__init__.py fastly_compute/exceptions/types/error.py | $(STUBS_DIR) @echo "Checking version synchronization..." uv run python scripts/check_version_sync.py @echo "Linting Python code..." @@ -101,7 +110,7 @@ lint: fastly_compute/runtime_patching/patches.py | $(STUBS_DIR) @echo "Linting Rust code..." cd crates/fastly-compute-py && cargo clippy -- -D warnings -lint-fix: fastly_compute/runtime_patching/patches.py +lint-fix: fastly_compute/_bindings/__init__.py fastly_compute/exceptions/types/error.py @echo "Fixing Python code..." uv run --extra dev ruff check --fix . @echo "Fixing Rust code..." diff --git a/fastly_compute/__init__.py b/fastly_compute/__init__.py index 35a6eed..84534fc 100644 --- a/fastly_compute/__init__.py +++ b/fastly_compute/__init__.py @@ -5,9 +5,3 @@ # Testing utilities are available but not imported by default # Users can import them explicitly: from fastly_compute.testing import ViceroyTestBase - -from fastly_compute.runtime_patching.patches import patch - -# Before anything from the fastly_compute package is used, do our monkeypatching -# to make the WIT-generated code act more Pythonically: -patch() diff --git a/fastly_compute/_bindings/__init__.py b/fastly_compute/_bindings/__init__.py new file mode 100644 index 0000000..5749af7 --- /dev/null +++ b/fastly_compute/_bindings/__init__.py @@ -0,0 +1,2 @@ +# This package is automatically generated by scripts/generate_bindings. +# Do not edit directly. diff --git a/fastly_compute/_bindings/acl.py b/fastly_compute/_bindings/acl.py new file mode 100644 index 0000000..847bd49 --- /dev/null +++ b/fastly_compute/_bindings/acl.py @@ -0,0 +1,48 @@ +# This file is automatically generated by scripts/generate_bindings. +# Do not edit directly. +"""Blocklists using [Access Control Lists] (ACLs) + +[Access Control Lists]: https://www.fastly.com/documentation/reference/api/acls/ +""" + +from __future__ import annotations + +from typing import Self + +from wit_world.imports import acl as _wit +from wit_world.imports.types import IpAddress + +from fastly_compute._bindings.async_io import Pollable +from fastly_compute._error_mapping import MAPPINGS, remap_wit_errors +from fastly_compute._resource import FastlyResource + +__all__ = [ + "Acl", + "IpAddress", +] + + +class Acl(FastlyResource[_wit.Acl]): + """An ACL.""" + + @classmethod + @remap_wit_errors(MAPPINGS) + def open(cls, name: str) -> Self: + """Opens an ACL linked to the current service with the given link name. + + :raises ~fastly_compute.exceptions.types.open_error.OpenError: + """ + return cls(_wit.Acl.open(name)) + + @remap_wit_errors(MAPPINGS) + def lookup(self, ip_addr: IpAddress) -> Pollable | None: + """Performs a lookup of the given IP address in the ACL. + + If any matches are found, the result is a JSON-encoded HTTP body. + + If no matches are found, then `None` is returned. This corresponds + to an HTTP error code of 204, “No Content”. + + :raises ~fastly_compute.exceptions.acl.acl_error.AclError: + """ + return Pollable(self._wit_resource.lookup(ip_addr)) diff --git a/fastly_compute/_bindings/async_io.py b/fastly_compute/_bindings/async_io.py new file mode 100644 index 0000000..fddb562 --- /dev/null +++ b/fastly_compute/_bindings/async_io.py @@ -0,0 +1,97 @@ +# This file is automatically generated by scripts/generate_bindings. +# Do not edit directly. +"""Async IO support. + +This module provides several utilities for performing I/O asynchronously. +See the documentation for `async-io.pollable` for a description of the kinds +of events it supports. + +In the future, this interface is expected to be replaced by +[integrated async features]. + +[integrated async features]: https://github.com/WebAssembly/component-model/blob/main/design/mvp/Async.md#-async-explainer +""" + +from __future__ import annotations + +from typing import Self + +from wit_world.imports import async_io as _wit + +from fastly_compute._resource import FastlyResource + +__all__ = [ + "Pollable", + "select", + "select_with_timeout", +] + + +class Pollable(FastlyResource[_wit.Pollable]): + """An object supporting generic async operations. + + Can be a `http-body.body`, `http-req.pending-response`, `http-req.pending-request`, + `cache.pending-entry`. `kv-store.pending-lookup`, `kv-store.pending-insert`, + `kv-store.pending-delete`, or `kv-store.pending-list`. + + Each async item has an associated I/O action: + + * Pending requests: awaiting the response headers / `response` object + * Normal bodies: reading bytes from the body + * Streaming bodies: writing bytes to the body + + For writing bytes, there is a large buffer associated with the handle that bytes + can eagerly be written into, even before the origin itself consumes that data. + """ + + def is_ready(self) -> bool: + """Make a nonblocking attempt to complete the I/O operation. + + Returns `True` if the given async item is “ready” for its associated I/O action, `False` + otherwise. + + If an object is ready, the I/O action is guaranteed to complete without blocking. + + Valid object handles includes bodies and pending requests. See the `async-io.pollable` + definition for more details, including what I/O actions are associated with each handle + type. + """ + return self._wit_resource.is_ready() + + @classmethod + def new_ready(cls) -> Self: + """Create a new trivial `pollable` which reports being immediately ready.""" + return cls(_wit.Pollable.new_ready()) + + +def select(handles: list[Pollable]) -> int: + """Blocks until one of the given objects is ready for I/O. + + If an object is ready, the I/O action is guaranteed to complete without blocking. + + Valid object handles includes bodies and pending requests. See the `async-io.pollable` + definition for more details, including what I/O actions are associated with each handle + type. + + Returns the *index* (not handle!) of the first object that is ready. + + Traps if the list is empty. + """ + return _wit.select(handles) + + +def select_with_timeout(handles: list[Pollable], timeout_ms: int) -> int | None: + """Blocks until one of the given objects is ready for I/O, or the timeout expires. + + If an object is ready, the I/O action is guaranteed to complete without blocking. + + Valid object handles includes bodies and pending requests. See the `async-io.pollable` + definition for more details, including what I/O actions are associated with each handle + type. + + The timeout is specified in milliseconds. + + Returns the *index* (not handle!) of the first object that is ready, or `None` if the + timeout expires before any objects are ready for I/O. + """ + return _wit.select_with_timeout(handles, timeout_ms) diff --git a/fastly_compute/_bindings/backend.py b/fastly_compute/_bindings/backend.py new file mode 100644 index 0000000..283d64f --- /dev/null +++ b/fastly_compute/_bindings/backend.py @@ -0,0 +1,442 @@ +# This file is automatically generated by scripts/generate_bindings. +# Do not edit directly. +"""[Backends] API. + +A backend represents a service that the application can send requests to, potentially +caching the responses received. + +Backends come in one of two flavors: + * **Static Backends**: These backends are created using the Fastly UI or API, + and are predefined by the user. Static backends typically have short names that are + usable across every instance of a service. + * **Dynamic Backends**: These backends are created programmatically using the + `register-dynamic-backend` API. They are defined at runtime, and may or may not + be shared across sandboxes depending on how they are configured. + +To use a backend, pass it to a `send*` function. + +Future versions of this function may return an error if your service does not have a backend +with this name. + +[Backends]: https://www.fastly.com/documentation/guides/integrations/non-fastly-services/developer-guide-backends/ +""" + +from __future__ import annotations + +from typing import Self + +from wit_world.imports import backend as _wit +from wit_world.imports.backend import BackendHealth + +from fastly_compute._bindings.secret_store import Secret +from fastly_compute._error_mapping import MAPPINGS, remap_wit_errors +from fastly_compute._resource import FastlyResource + +__all__ = [ + "Backend", + "BackendHealth", + "DynamicBackendOptions", + "register_dynamic_backend", +] + + +class DynamicBackendOptions(FastlyResource[_wit.DynamicBackendOptions]): + """Options for `register-dynamic-backend`.""" + + @classmethod + def new(cls) -> Self: + """Constructs an options resource with default values for all other possible fields for the + backend, which can be overridden using the other methods provided. + """ + return cls(_wit.DynamicBackendOptions()) + + def override_host(self, value: str) -> None: + """Sets a host header override when contacting this backend. + + This will force the value of the “Host” header to the given string when sending out the + origin request. If this is not set and no header already exists, the “Host” header will + default to the target. + + For more information, see [the Fastly documentation on override hosts]. + + [the Fastly documentation on override hosts]: https://docs.fastly.com/en/guides/specifying-an-override-host> + """ + return self._wit_resource.override_host(value) + + def connect_timeout(self, value: int) -> None: + """Sets the connection timeout, in milliseconds, for this backend. + + Defaults to 1,000ms (1s). + """ + return self._wit_resource.connect_timeout(value) + + def first_byte_timeout(self, value: int) -> None: + """Sets a timeout, in milliseconds, that applies between the time of connection and the time we + get the first byte back. + + Defaults to 15,000ms (15s). + """ + return self._wit_resource.first_byte_timeout(value) + + def between_bytes_timeout(self, value: int) -> None: + """Sets a timeout, in milliseconds, that applies between any two bytes we receive across the + wire. + + Defaults to 10,000ms (10s). + """ + return self._wit_resource.between_bytes_timeout(value) + + def use_tls(self, value: bool) -> None: + """Enables or disables TLS to connect to the backend. + + When using TLS, Fastly checks the validity of the backend’s certificate, and fails the + connection if the certificate is invalid. This check is not optional: an invalid + certificate will cause the backend connection to fail (but read on). + + By default, the validity check does not require that the certificate hostname matches the + hostname of your request. You can use `cert_hostname` to request a check of the + certificate hostname. + + By default, certificate validity uses a set of public certificate authorities. You can + specify an alternative CA using `ca_certificate`. + """ + return self._wit_resource.use_tls(value) + + def tls_min_version(self, value: int) -> None: + """Sets the minimum TLS version for connecting to the backend. + + Setting this will enable TLS for the connection as a side effect. + """ + return self._wit_resource.tls_min_version(value) + + def tls_max_version(self, value: int) -> None: + """Sets the maximum TLS version for connecting to the backend. + + Setting this will enable TLS for the connection as a side effect. ( + """ + return self._wit_resource.tls_max_version(value) + + def cert_hostname(self, value: str) -> None: + """Defines the hostname that the server certificate should declare, and turn on validation + during backend connections. + + You should enable this if you are using TLS, and setting this will enable TLS for the + connection as a side effect. + + If `cert_hostname` is not provided (default), the server certificate’s hostname may + have any value. + """ + return self._wit_resource.cert_hostname(value) + + def ca_certificate(self, value: str) -> None: + """Sets the CA certificate to use when checking the validity of the backend. + + Setting this will enable TLS for the connection as a side effect. + + If `ca_certificate` is not provided (default), the backends’s certificate is validated + using a set of public root CAs. + """ + return self._wit_resource.ca_certificate(value) + + def tls_ciphers(self, value: str) -> None: + """Sets the acceptable cipher suites to use for TLS 1.0 - 1.2 connections. + + Setting this will enable TLS for the connection as a side effect. + """ + return self._wit_resource.tls_ciphers(value) + + def sni_hostname(self, value: str) -> None: + """Sets the SNI hostname for the backend connection. + + Setting this will enable TLS for the connection as a side effect. + """ + return self._wit_resource.sni_hostname(value) + + def client_cert(self, client_cert: str, key: Secret) -> None: + """Provides the given client certificate to the server as part of the TLS handshake. + + Setting this will enable TLS for the connection as a side effect. Both the certificate and + the key to use should be in standard PEM format; providing the information in another + format will lead to an error. We suggest that (at least the) key should be held in + something like the Fastly secret store for security, with the handle passed to this + function without unpacking it via `secret.plaintext`; the certificate can be held in a less + secure medium. + + (If it is absolutely necessary to get the key from another source, we suggest the use of + `secret.from-bytes`. + """ + return self._wit_resource.client_cert(client_cert, key._wit_resource) + + def http_keepalive_time_ms(self, value: int) -> None: + """Configures up to how long to allow HTTP keepalive connections to remain idle in the + connection pool. + """ + return self._wit_resource.http_keepalive_time_ms(value) + + def tcp_keepalive_enable(self, value: int) -> None: + """Configures whether or not to use TCP keepalive on the connection to the backend.""" + return self._wit_resource.tcp_keepalive_enable(value) + + def tcp_keepalive_interval_secs(self, value: int) -> None: + """Configures how long to wait in between each TCP keepalive probe sent to the backend.""" + return self._wit_resource.tcp_keepalive_interval_secs(value) + + def tcp_keepalive_probes(self, value: int) -> None: + """Configures up to how many TCP keepalive probes to send to the backend before the connection + is considered dead. + """ + return self._wit_resource.tcp_keepalive_probes(value) + + def tcp_keepalive_time_secs(self, value: int) -> None: + """Configures how long to wait after the last sent data over the TCP connection before starting + to send TCP keepalive probes. + """ + return self._wit_resource.tcp_keepalive_time_secs(value) + + def max_connections(self, value: int) -> None: + """Configures the maximum number of connections to keep in the local connection pool. + + `0` is unlimited. + + Note that this limit is best determined experimentally, since the total number of + connections to the backend will depend on POP sizes, HTTP keepalive limits, and the + traffic patterns for individual POPs. + """ + return self._wit_resource.max_connections(value) + + def max_use(self, value: int) -> None: + """Configures how many times a pooled connection can be used. + + `0` is unlimited. + """ + return self._wit_resource.max_use(value) + + def max_lifetime_ms(self, value: int) -> None: + """Configures an upper bound for how long an HTTP keepalive connection can be open before we + stop trying to reuse it. + + `0` is unlimited. + """ + return self._wit_resource.max_lifetime_ms(value) + + def pooling(self, value: bool) -> None: + """Determines whether or not connections to the same backend should be pooled across different + sandboxes. + + Fastly considers two backends “the same” if they’re registered with the same name and + the exact same settings. In those cases, when pooling is enabled, if one sandbox + opens a connection to this backend it will be left open, and can be re-used by a different + sandbox. This can help improve backend latency, by removing the need for the initial + network / TLS handshake(s). + + By default, pooling is enabled for dynamic backends. + """ + return self._wit_resource.pooling(value) + + def grpc(self, value: bool) -> None: + """Sets whether or not this backend will be used for gRPC traffic. + + Warning: Setting this for backends that will not be used with gRPC may have unpredictable + effects. Fastly only currently guarantees that this connection will work for gRPC traffic. + """ + return self._wit_resource.grpc(value) + + def prefer_ipv6(self, value: bool) -> None: + """Whether to prefer attempting connections to IPv6 addresses over IPv4 addresses when a + hostname has both A and AAAA records. + + The Compute platform defaults to `True`, and will attempt IPv6 first if a AAAA record + is present. + """ + return self._wit_resource.prefer_ipv6(value) + + +class Backend(FastlyResource[_wit.Backend]): + """Backend.""" + + @classmethod + @remap_wit_errors(MAPPINGS) + def open(cls, name: str) -> Self: + """Attempts to open the named static backend. + + :raises ~fastly_compute.exceptions.types.open_error.OpenError: + """ + return cls(_wit.Backend.open(name)) + + def get_name(self) -> str: + """Returns the name of this backend.""" + return self._wit_resource.get_name() + + @remap_wit_errors(MAPPINGS) + def is_healthy(self) -> BackendHealth: + """Returns the health of the backend if configured and currently known. + + For backends without a configured healthcheck, this will always return + `backend-health.unknown`. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.is_healthy() + + @remap_wit_errors(MAPPINGS) + def is_dynamic(self) -> bool: + """Returns `True` if the backend is a “dynamic” backend. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.is_dynamic() + + @remap_wit_errors(MAPPINGS) + def get_host(self, max_len: int) -> str: + """Gets the host of this backend. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.get_host(max_len) + + @remap_wit_errors(MAPPINGS) + def get_override_host(self, max_len: int) -> bytes | None: + """Gets the “override host” for this backend. + + This is used to change the `Host` header sent to the backend. See + [the Fastly documentation on override hosts]. + + [the Fastly documentation on override hosts]: https://docs.fastly.com/en/guides/specifying-an-override-host + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.get_override_host(max_len) + + @remap_wit_errors(MAPPINGS) + def get_port(self) -> int: + """Gets the remote TCP port of the backend connection for the request. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.get_port() + + @remap_wit_errors(MAPPINGS) + def get_connect_timeout_ms(self) -> int: + """Gets the connection timeout of the backend. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.get_connect_timeout_ms() + + @remap_wit_errors(MAPPINGS) + def get_first_byte_timeout_ms(self) -> int: + """Gets the first byte timeout of the backend. + + This timeout applies between the time of connection and the time we get the first byte back. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.get_first_byte_timeout_ms() + + @remap_wit_errors(MAPPINGS) + def get_between_bytes_timeout_ms(self) -> int: + """Gets the between byte timeout of the backend. + + This timeout applies between any two bytes we receive across the wire. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.get_between_bytes_timeout_ms() + + @remap_wit_errors(MAPPINGS) + def is_tls(self) -> bool: + """Returns `True` if the backend is configured to use TLS. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.is_tls() + + @remap_wit_errors(MAPPINGS) + def get_tls_min_version(self) -> int | None: + """Gets the minimum TLS version this backend will use. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.get_tls_min_version() + + @remap_wit_errors(MAPPINGS) + def get_tls_max_version(self) -> int | None: + """Gets the maximum TLS version this backend will use. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.get_tls_max_version() + + @remap_wit_errors(MAPPINGS) + def get_http_keepalive_time(self) -> int: + """Returns the time for this backend to hold onto an idle HTTP keepalive connection + after it was last used before closing it. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.get_http_keepalive_time() + + @remap_wit_errors(MAPPINGS) + def get_tcp_keepalive_enable(self) -> bool: + """Returns `True` if TCP keepalives have been enabled for this backend. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.get_tcp_keepalive_enable() + + @remap_wit_errors(MAPPINGS) + def get_tcp_keepalive_interval(self) -> int: + """Returns the time to wait in between sending each TCP keepalive probe to this backend. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.get_tcp_keepalive_interval() + + @remap_wit_errors(MAPPINGS) + def get_tcp_keepalive_probes(self) -> int: + """Returns the time to wait after the last data was sent before starting to send TCP keepalive + probes to this backend. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.get_tcp_keepalive_probes() + + @remap_wit_errors(MAPPINGS) + def get_tcp_keepalive_time(self) -> int: + """Returns the time to wait after the last data was sent before starting to send TCP keepalive + probes to this backend. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.get_tcp_keepalive_time() + + +@remap_wit_errors(MAPPINGS) +def register_dynamic_backend( + prefix: str, target: str, options: DynamicBackendOptions +) -> Backend: + """Creates a new dynamic backend. + + The arguments are the name of the new backend to use, along with a string describing the + backend host. The latter can be of the form: + + - "" + - "" + - ":" + - ":" + + The name can be whatever you would like, as long as it does not match the name of any of the + static service backends nor match any other dynamic backends built during this service + instance. (Names can overlap between different instances of the same service—they will be + treated as completely separate entities and will not be pooled—but you cannot, for example, + declare a dynamic backend named “dynamic-backend” twice in the same sandbox.) + + Dynamic backends must be enabled for the Compute service. You can determine whether or not + dynamic backends have been allowed for the current service by checking for the + `error.unsupported` error result. This error only arises when attempting to use dynamic + backends with a service that has not had dynamic backends enabled, or dynamic backends have + been administratively prohibited for the node in response to an ongoing incident. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return Backend(_wit.register_dynamic_backend(prefix, target, options._wit_resource)) diff --git a/fastly_compute/_bindings/cache.py b/fastly_compute/_bindings/cache.py new file mode 100644 index 0000000..34185e5 --- /dev/null +++ b/fastly_compute/_bindings/cache.py @@ -0,0 +1,516 @@ +# This file is automatically generated by scripts/generate_bindings. +# Do not edit directly. +"""[Core Cache] API + +[Core Cache]: https://www.fastly.com/documentation/guides/concepts/edge-state/cache/#core-cache +""" + +from __future__ import annotations + +from typing import Self + +from wit_world.imports import cache as _wit +from wit_world.imports.cache import LookupState, ReplaceStrategy + +from fastly_compute._bindings.async_io import Pollable +from fastly_compute._bindings.http_req import Request +from fastly_compute._error_mapping import MAPPINGS, remap_wit_errors +from fastly_compute._resource import FastlyResource + +__all__ = [ + "Entry", + "GetBodyOptions", + "LookupOptions", + "LookupState", + "ReplaceEntry", + "ReplaceOptions", + "ReplaceStrategy", + "WriteOptions", + "await_entry", + "close_entry", + "close_pending_entry", + "close_replace_entry", + "insert", + "replace_insert", +] + + +class LookupOptions: + """Options for cache lookup operations; currently used for both `lookup` and + `transaction-lookup`. + """ + + def __init__( + self, + request_headers: Request | None = None, + always_use_requested_range: bool = False, + ) -> None: + """ + :param request_headers: A full request handle, but used only for its headers May be `None` if the `request_headers` option isn't enabled. + """ + self._wit = _wit.LookupOptions( + request_headers=request_headers._wit_resource + if request_headers is not None + else None, + always_use_requested_range=always_use_requested_range, + extra=None, + ) + + +class WriteOptions: + """Configuration for several functions that write to the cache: + - `insert` + - `transaction-insert` + - `transaction-insert-and-stream-back` + - `transaction-update` + + Some options are only allowed for certain of these hostcalls; see the comments + on the fields. + """ + + def __init__( + self, + max_age_ns: int = 0, + request_headers: Request | None = None, + vary_rule: str | None = None, + initial_age_ns: int | None = None, + stale_while_revalidate_ns: int | None = None, + surrogate_keys: str | None = None, + length: int | None = None, + user_metadata: bytes | None = None, + edge_max_age_ns: int | None = None, + sensitive_data: bool = False, + ) -> None: + """ + :param max_age_ns: this is a required field + :param request_headers: a full request handle, but used only for its headers Only allowed for non-transactional `insert` + :param vary_rule: a list of header names separated by spaces + :param initial_age_ns: The initial age of the object in nanoseconds (default: 0). This age is used to determine the freshness lifetime of the object as well as to prioritize which variant to return if a subsequent lookup matches more than one vary rule + :param surrogate_keys: a list of surrogate keys separated by spaces + """ + self._wit = _wit.WriteOptions( + max_age_ns=max_age_ns, + request_headers=request_headers._wit_resource + if request_headers is not None + else None, + vary_rule=vary_rule, + initial_age_ns=initial_age_ns, + stale_while_revalidate_ns=stale_while_revalidate_ns, + surrogate_keys=surrogate_keys, + length=length, + user_metadata=user_metadata, + edge_max_age_ns=edge_max_age_ns, + sensitive_data=sensitive_data, + extra=None, + ) + + +class GetBodyOptions: + """GetBodyOptions.""" + + def __init__( + self, + from_: int | None = None, + to: int | None = None, + ) -> None: + self._wit = _wit.GetBodyOptions( + from_=from_, + to=to, + extra=None, + ) + + +class ReplaceOptions: + """Options for cache replace operations""" + + def __init__( + self, + request_headers: Request | None = None, + replace_strategy: ReplaceStrategy | None = None, + always_use_requested_range: bool = False, + ) -> None: + """ + :param request_headers: a full request handle, but used only for its headers + """ + self._wit = _wit.ReplaceOptions( + request_headers=request_headers._wit_resource + if request_headers is not None + else None, + replace_strategy=replace_strategy, + always_use_requested_range=always_use_requested_range, + extra=None, + ) + + +class Entry(FastlyResource[_wit.Entry]): + """The outcome of a cache lookup (either bare or as part of a cache transaction)""" + + @classmethod + @remap_wit_errors(MAPPINGS) + def lookup(cls, key: bytes, options: LookupOptions) -> Self: + """Performs a non-request-collapsing cache lookup. + + Returns a result without waiting for any request collapsing that may be ongoing. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return cls(_wit.Entry.lookup(key, options._wit)) + + @classmethod + @remap_wit_errors(MAPPINGS) + def transaction_lookup(cls, key: bytes, options: LookupOptions) -> Self: + """The entrypoint to the request-collapsing cache transaction API. + + This operation always participates in request collapsing and may return stale objects. To + bypass request collapsing, use `entry.lookup` or `insert` instead. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return cls(_wit.Entry.transaction_lookup(key, options._wit)) + + @classmethod + @remap_wit_errors(MAPPINGS) + def transaction_lookup_async(cls, key: bytes, options: LookupOptions) -> Pollable: + """The entrypoint to the request-collapsing cache transaction API, returning instead of waiting + on busy. + + This operation always participates in request collapsing and may return stale objects. To + bypass request collapsing, use `entry.lookup` or `insert` instead. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return Pollable(_wit.Entry.transaction_lookup_async(key, options._wit)) + + @remap_wit_errors(MAPPINGS) + def transaction_insert(self, options: WriteOptions) -> Pollable: + """Inserts an object into the cache with the given metadata. + + Can only be used in if the cache handle state includes the `must_insert_or_update` flag. + + The returned handle is to a streaming body that is used for writing the object into + the cache. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return Pollable(self._wit_resource.transaction_insert(options._wit)) + + @remap_wit_errors(MAPPINGS) + def transaction_insert_and_stream_back( + self, options: WriteOptions + ) -> tuple[Pollable, Self]: + """Inserts an object into the cache with the given metadata, and return a readable stream of the + bytes as they are stored. + + This helps avoid the “slow reader” problem on a teed stream, for example when a program + wishes to store a backend request in the cache while simultaneously streaming to a client + in an HTTP response. + + The returned body handle is to a streaming body that is used for writing the object *into* + the cache. The returned cache handle provides a separate transaction for reading out the + newly cached object to send elsewhere. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + _r = self._wit_resource.transaction_insert_and_stream_back(options._wit) + return (Pollable(_r[0]), Entry(_r[1])) + + @remap_wit_errors(MAPPINGS) + def transaction_update(self, options: WriteOptions) -> None: + """Update the metadata of an object in the cache without changing its data. + + Can only be used in if the cache handle state includes both of the flags: + - `found` + - `must_insert_or_update` + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.transaction_update(options._wit) + + @remap_wit_errors(MAPPINGS) + def get_state(self) -> LookupState: + """Get the state of a cache lookup, waiting for the lookup to complete if necessary. + + Note that FOUND == USABLE, and means "usable" (fresh or stale-while-revalidate). + Some SDKs were released that checked only FOUND to infer "usable"; + we preserve the equivalence for backwards compatibility. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.get_state() + + @remap_wit_errors(MAPPINGS) + def get_user_metadata(self, max_len: int) -> bytes | None: + """Gets the user metadata of the found object, returning `None` if no object + was found. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.get_user_metadata(max_len) + + @remap_wit_errors(MAPPINGS) + def get_body(self, options: GetBodyOptions) -> Pollable: + """Gets a range of the found object body, returning `None` if there + was no found object. + + The returned `body` must be closed before calling this function again on the same + `entry`. + + Note: until the CacheD protocol is adjusted to fully support this functionality, + the body of objects that are past the stale-while-revalidate period will not + be available, even when other metadata is. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return Pollable(self._wit_resource.get_body(options._wit)) + + @remap_wit_errors(MAPPINGS) + def get_length(self) -> int | None: + """Gets the content length of the found object, returning `None` if + there was no found object, or no content length was provided. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.get_length() + + @remap_wit_errors(MAPPINGS) + def get_max_age_ns(self) -> int | None: + """Gets the configured max age of the found object, returning `None` + if there was no found object. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.get_max_age_ns() + + @remap_wit_errors(MAPPINGS) + def get_stale_while_revalidate_ns(self) -> int | None: + """Gets the configured stale-while-revalidate period of the found object, returning `None` + if there was no found object. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.get_stale_while_revalidate_ns() + + @remap_wit_errors(MAPPINGS) + def get_age_ns(self) -> int | None: + """Gets the age of the found object, returning `None` if there + was no found object. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.get_age_ns() + + @remap_wit_errors(MAPPINGS) + def get_hits(self) -> int | None: + """Gets the number of cache hits for the found object, returning `None` + if there was no found object. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.get_hits() + + @remap_wit_errors(MAPPINGS) + def transaction_cancel(self) -> None: + """Cancel an obligation to provide an object to the cache. + + Useful if there is an error before streaming is possible, for example if a backend is + unreachable. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.transaction_cancel() + + +class ReplaceEntry(FastlyResource[_wit.ReplaceEntry]): + """A replace operation.""" + + @classmethod + @remap_wit_errors(MAPPINGS) + def replace(cls, key: bytes, options: ReplaceOptions) -> Self: + """The entrypoint to the replace API. + + This operation always participates in request collapsing and may return stale objects. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return cls(_wit.ReplaceEntry.replace(key, options._wit)) + + @remap_wit_errors(MAPPINGS) + def get_age_ns(self) -> int | None: + """Gets the age of the existing object during replace, returning + `None` if there was no object. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.get_age_ns() + + @remap_wit_errors(MAPPINGS) + def get_body(self, options: GetBodyOptions) -> Pollable | None: + """Gets a range of the existing object body, returning `None` if there + was no existing object. + + The returned `body` must be closed before calling this function + again on the same `replace_entry`. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return Pollable(self._wit_resource.get_body(options._wit)) + + @remap_wit_errors(MAPPINGS) + def get_hits(self) -> int | None: + """Gets the number of cache hits for the existing object during replace, + returning `None` if there was no object. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.get_hits() + + @remap_wit_errors(MAPPINGS) + def get_length(self) -> int | None: + """Gets the content length of the existing object during replace, + returning `None` if there was no object, or no content + length was provided. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.get_length() + + @remap_wit_errors(MAPPINGS) + def get_max_age_ns(self) -> int | None: + """Gets the configured max age of the existing object during replace, + returning `None` if there was no object. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.get_max_age_ns() + + @remap_wit_errors(MAPPINGS) + def get_stale_while_revalidate_ns(self) -> int | None: + """Gets the configured stale-while-revalidate period of the existing + object during replace, returning `None` if there was no + object. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.get_stale_while_revalidate_ns() + + @remap_wit_errors(MAPPINGS) + def get_state(self) -> LookupState | None: + """Gets the lookup state of the existing object during replace, returning + `None` if there was no object. + + Note that FOUND == USABLE, and means "usable" (fresh or stale-while-revalidate). + Some SDKs were released that checked only FOUND to infer "usable"; + we preserve the equivalence for backwards compatibility. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.get_state() + + @remap_wit_errors(MAPPINGS) + def get_user_metadata(self, max_len: int) -> bytes | None: + """Gets the user metadata of the existing object during replace, returning + `None` if there was no object. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.get_user_metadata(max_len) + + +class ExtraLookupOptions(FastlyResource[_wit.ExtraLookupOptions]): + """Extensibility for `lookup-options`""" + + @classmethod + def new(cls) -> Self: + """Construct a new instance with default values.""" + return cls(_wit.ExtraLookupOptions()) + + +class ExtraWriteOptions(FastlyResource[_wit.ExtraWriteOptions]): + """Extensibility for `write-options`""" + + @classmethod + def new(cls) -> Self: + """Construct a new instance with default values.""" + return cls(_wit.ExtraWriteOptions()) + + +class ExtraGetBodyOptions(FastlyResource[_wit.ExtraGetBodyOptions]): + """Extensibility for `get-body-options`""" + + +class ExtraReplaceOptions(FastlyResource[_wit.ExtraReplaceOptions]): + """Extensibility for `replace-options`""" + + @classmethod + def new(cls) -> Self: + """Construct a new instance with default values.""" + return cls(_wit.ExtraReplaceOptions()) + + +@remap_wit_errors(MAPPINGS) +def insert(key: bytes, options: WriteOptions) -> Pollable: + """Performs a non-request-collapsing cache insertion (or update). + + The returned handle is to a streaming body that is used for writing the object into + the cache. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return Pollable(_wit.insert(key, options._wit)) + + +@remap_wit_errors(MAPPINGS) +def await_entry(handle: Pollable) -> Entry: + """Continues the lookup transaction from which the given busy handle was returned, + waiting for the leader transaction if request collapsed, and returns a cache handle. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return Entry(_wit.await_entry(handle._wit_resource)) + + +@remap_wit_errors(MAPPINGS) +def close_pending_entry(handle: Pollable) -> None: + """Closes an interaction with the cache that has not yet finished request collapsing. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return _wit.close_pending_entry(handle._wit_resource) + + +@remap_wit_errors(MAPPINGS) +def close_entry(handle: Entry) -> None: + """Closes an ongoing interaction with the cache. + + If the cache handle state includes the `must_insert_or_update` (and hence no insert or + update has been performed), closing the handle cancels any request collapsing, potentially + choosing a new waiter to perform the insertion/update. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return _wit.close_entry(handle._wit_resource) + + +@remap_wit_errors(MAPPINGS) +def replace_insert(handle: ReplaceEntry, options: WriteOptions) -> Pollable: + """Replace an object in the cache with the given metadata + + The returned handle is to a streaming body that is used for writing the object into + the cache. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return Pollable(_wit.replace_insert(handle._wit_resource, options._wit)) + + +@remap_wit_errors(MAPPINGS) +def close_replace_entry(handle: ReplaceEntry) -> None: + """Closes an ongoing replace interaction with the cache. + + If the replace handle state includes the `must_insert_or_update` (and hence no insert or + update has been performed), closing the handle cancels any request collapsing, potentially + choosing a new waiter to perform the insertion/update. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return _wit.close_replace_entry(handle._wit_resource) diff --git a/fastly_compute/_bindings/compute_runtime.py b/fastly_compute/_bindings/compute_runtime.py new file mode 100644 index 0000000..8322546 --- /dev/null +++ b/fastly_compute/_bindings/compute_runtime.py @@ -0,0 +1,168 @@ +# This file is automatically generated by scripts/generate_bindings. +# Do not edit directly. +"""Features for interacting with the Compute runtime.""" + +from __future__ import annotations + +from wit_world.imports import compute_runtime as _wit + +__all__ = [ + "get_cache_generation", + "get_customer_id", + "get_heap_mib", + "get_hostname", + "get_is_staging", + "get_namespace_id", + "get_pop", + "get_region", + "get_sandbox_id", + "get_service_id", + "get_service_version", + "get_vcpu_ms", +] + + +def get_vcpu_ms() -> int: + """Gets the amount of vCPU time that has passed since this sandbox was started, in + milliseconds. + + This function returns only time spent running on a vCPU, and does not include time spent + performing any I/O operations. However, it is based on clock time passing, and so will include + time spent executing hostcalls, is heavily affected by what core of what CPU is running the + code, and can even be influenced by the state of the CPU. + + As a result, this function *should not be used in benchmarking across runs*. It can be used, + with caution, to compare the runtime of different operations within the same sandbox. + """ + return _wit.get_vcpu_ms() + + +def get_heap_mib() -> int: + """Get a snapshot of the current dynamic memory usage, rounded up to the nearest mebibyte (2^20). + + This includes usage from the Wasm linear memory (heap) and usage from host allocations + made on behalf of this sandbox, e.g. buffered bodies of HTTP responses. + The returned value is just a snapshot- it can change without any explicit action + by the sandbox (for instance, additional response data coming in from an HTTP response.) + It can also change over time / across runs, as the Compute platform's memory usage + changes. Consider the returned value with these uncertainties in mind. + """ + return _wit.get_heap_mib() + + +def get_sandbox_id() -> str: + """A UUID generated by Fastly for each sandbox. + + This is often a useful value to include in log messages, and also to send to upstream + servers as an additional custom HTTP header, allowing for straightforward correlation of + which sandbox processed a request to requests later processed by an origin server. + + By default, each sandbox handles exactly one downstream request, in which case + this sandbox UUID is unique for each request. However, by using + `http-downstream.next-request`, a single sandbox can accept multiple downstream + requests. For a UUID that reliably identifies a request, you may wish to use + `http-downstream.downstream-client-request-id`. + + Equivalent to the "FASTLY_TRACE_ID" environment variable. + """ + return _wit.get_sandbox_id() + + +def get_hostname() -> str: + """The hostname of the Fastly cache server which is executing the current sandbox, for + example, `cache_jfk1034`. + + Equivalent to the "FASTLY_HOSTNAME" environment variable and to [`server.hostname`] in VCL. + + [`server.hostname`]: https://www.fastly.com/documentation/reference/vcl/variables/server/server-hostname/ + """ + return _wit.get_hostname() + + +def get_pop() -> str: + """The three-character identifying code of the [Fastly POP] in which the current service + instance is running. + + Equivalent to the "FASTLY_POP" environment variable and to [`server.datacenter`] in VCL. + + [Fastly POP]: https://www.fastly.com/documentation/guides/concepts/pop/ + [`server.datacenter`]: https://www.fastly.com/documentation/reference/vcl/variables/server/server-datacenter/ + """ + return _wit.get_pop() + + +def get_region() -> str: + """A code representing the general geographic region in which the [Fastly POP] processing the + current Compute sandbox resides. + + Equivalent to the "FASTLY_REGION" environment variable and to [`server.region`] in VCL, and + has the same possible values. + + [`server.region`]: https://www.fastly.com/documentation/reference/vcl/variables/server/server-region/ + [Fastly POP]: https://www.fastly.com/documentation/guides/concepts/pop/ + """ + return _wit.get_region() + + +def get_cache_generation() -> int: + """The current cache generation value for this Fastly service. + + The cache generation value is incremented by [purge-all operations]. + + Equivalent to the "FASTLY_CACHE_GENERATION" environment variable and to + [`req.vcl.generation`] in VCL. + + [purge-all operations]: https://www.fastly.com/documentation/guides/concepts/edge-state/cache/purging/ + [`req.vcl.generation`]: https://www.fastly.com/documentation/reference/vcl/variables/miscellaneous/req-vcl-generation/ + """ + return _wit.get_cache_generation() + + +def get_customer_id() -> str: + """The customer ID of the Fastly customer account to which the currently executing Fastly + service belongs. + + Equivalent to the "FASTLY_CUSTOMER_ID" environment variable and to [`req.customer_id`] in VCL. + + [`req.customer_id`]: https://www.fastly.com/documentation/reference/vcl/variables/miscellaneous/req-customer-id/ + """ + return _wit.get_customer_id() + + +def get_is_staging() -> bool: + """Whether the request is running in the Fastly service's [staging environment]. + + `False` for production or `True` for staging. + + Equivalent to the "FASTLY_IS_STAGING" environment variable and to [`fastly.is_staging`] in VCL. + + [`fastly.is_staging`]: https://www.fastly.com/documentation/reference/vcl/variables/miscellaneous/fastly-is-staging/ + [staging environment]: https://docs.fastly.com/products/staging + """ + return _wit.get_is_staging() + + +def get_service_id() -> str: + """The identifier for the Fastly service that is processing the current request. + + Equivalent to the "FASTLY_SERVICE_ID" environment variable and to [`req.service_id`] in VCL. + + [`req.service_id`]: https://www.fastly.com/documentation/reference/vcl/variables/miscellaneous/req-service-id/ + """ + return _wit.get_service_id() + + +def get_service_version() -> int: + """The version number for the Fastly service that is processing the current request. + + Equivalent to the "FASTLY_SERVICE_VERSION" environment variable and to [`req.vcl.version`] + in VCL. + + [`req.vcl.version`]: https://www.fastly.com/documentation/reference/vcl/variables/miscellaneous/req-vcl-version/ + """ + return _wit.get_service_version() + + +def get_namespace_id() -> str: + """This function is not suitable for general-purpose use.""" + return _wit.get_namespace_id() diff --git a/fastly_compute/_bindings/config_store.py b/fastly_compute/_bindings/config_store.py new file mode 100644 index 0000000..043fe11 --- /dev/null +++ b/fastly_compute/_bindings/config_store.py @@ -0,0 +1,42 @@ +# This file is automatically generated by scripts/generate_bindings. +# Do not edit directly. +"""[Config Store] API. + +[Config Store]: https://www.fastly.com/documentation/guides/concepts/edge-state/dynamic-config/#config-stores +""" + +from __future__ import annotations + +from typing import Self + +from wit_world.imports import config_store as _wit + +from fastly_compute._error_mapping import MAPPINGS, remap_wit_errors +from fastly_compute._resource import FastlyResource + +__all__ = [ + "Store", +] + + +class Store(FastlyResource[_wit.Store]): + """A Config Store.""" + + @classmethod + @remap_wit_errors(MAPPINGS) + def open(cls, name: str) -> Self: + """Attempts to open the named config store. + + Names are case sensitive. + + :raises ~fastly_compute.exceptions.types.open_error.OpenError: + """ + return cls(_wit.Store.open(name)) + + @remap_wit_errors(MAPPINGS) + def get(self, key: str, max_len: int) -> str | None: + """Fetches a value from the config store, returning `None` if it doesn't exist. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.get(key, max_len) diff --git a/fastly_compute/_bindings/device_detection.py b/fastly_compute/_bindings/device_detection.py new file mode 100644 index 0000000..45492c8 --- /dev/null +++ b/fastly_compute/_bindings/device_detection.py @@ -0,0 +1,27 @@ +# This file is automatically generated by scripts/generate_bindings. +# Do not edit directly. +"""Device detection based on the User-Agent header.""" + +from __future__ import annotations + +from wit_world.imports import device_detection as _wit + +from fastly_compute._error_mapping import MAPPINGS, remap_wit_errors + +__all__ = [ + "lookup", +] + + +@remap_wit_errors(MAPPINGS) +def lookup(user_agent: str, max_len: int) -> str | None: + """Looks up the data associated with a particular User-Agent string. + + Returns a list of bytes containing JSON-encoded device data. See [here] for descriptions + of the JSON fields. + + [here]: https://www.fastly.com/documentation/reference/vcl/variables/client-request/client-identified/ + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return _wit.lookup(user_agent, max_len) diff --git a/fastly_compute/_bindings/dictionary.py b/fastly_compute/_bindings/dictionary.py new file mode 100644 index 0000000..e5d0beb --- /dev/null +++ b/fastly_compute/_bindings/dictionary.py @@ -0,0 +1,45 @@ +# This file is automatically generated by scripts/generate_bindings. +# Do not edit directly. +"""[Compute Dictionaries] (deprecated in favor of `config-store`) + +[Compute Dictionaries]: https://www.fastly.com/documentation/guides/concepts/edge-state/dynamic-config/#dictionaries +""" + +from __future__ import annotations + +from typing import Self + +from wit_world.imports import dictionary as _wit + +from fastly_compute._error_mapping import MAPPINGS, remap_wit_errors +from fastly_compute._resource import FastlyResource + +__all__ = [ + "Dictionary", +] + + +class Dictionary(FastlyResource[_wit.Dictionary]): + """A Compute Dictionary.""" + + @classmethod + @remap_wit_errors(MAPPINGS) + def open(cls, name: str) -> Self: + """Opens a dictionary, given its name. + + Names are case sensitive. + + :raises ~fastly_compute.exceptions.types.open_error.OpenError: + """ + return cls(_wit.Dictionary.open(name)) + + @remap_wit_errors(MAPPINGS) + def lookup(self, key: str, max_len: int) -> str | None: + """Tries to look up a value in this dictionary. + + If the lookup is successful, this function returns `s` containing the found + string `s`, or `None` if no entry with the given key was found. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.lookup(key, max_len) diff --git a/fastly_compute/_bindings/erl.py b/fastly_compute/_bindings/erl.py new file mode 100644 index 0000000..8139da6 --- /dev/null +++ b/fastly_compute/_bindings/erl.py @@ -0,0 +1,124 @@ +# This file is automatically generated by scripts/generate_bindings. +# Do not edit directly. +"""[Edge rate limiting] API. + +[Edge rate limiting]: https://docs.fastly.com/products/edge-rate-limiting +""" + +from __future__ import annotations + +from typing import Self + +from wit_world.imports import erl as _wit + +from fastly_compute._error_mapping import MAPPINGS, remap_wit_errors +from fastly_compute._resource import FastlyResource + +__all__ = [ + "PenaltyBox", + "RateCounter", +] + + +class RateCounter(FastlyResource[_wit.RateCounter]): + """A rate counter that can be used with a edge rate limiter or + standalone for counting and rate calculations. + """ + + @classmethod + @remap_wit_errors(MAPPINGS) + def open(cls, name: str) -> Self: + """Opens a `rate_counter` with the given name. + + :raises ~fastly_compute.exceptions.types.open_error.OpenError: + """ + return cls(_wit.RateCounter.open(name)) + + def get_name(self) -> str: + """Returns the name of this rate counter.""" + return self._wit_resource.get_name() + + @remap_wit_errors(MAPPINGS) + def check_rate( + self, + entry: str, + delta: int, + window: int, + limit: int, + penalty_box: PenaltyBox, + ttl: int, + ) -> bool: + """Increments an entry in a rate counter and check if the client has exceeded some average number + of requests per second (RPS) over the window. + + If the client is over the rps limit for the window, add to the penaltybox for ttl. Valid ttl + span is 1m to 1h and TTL value is truncated to the nearest minute. + + Returns `True` if the client is penalized (i.e. should be limited), or `False` if not. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.check_rate( + entry, delta, window, limit, penalty_box._wit_resource, ttl + ) + + @remap_wit_errors(MAPPINGS) + def increment(self, entry: str, delta: int) -> None: + """Increments an entry in the ratecounter by `delta`. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.increment(entry, delta) + + @remap_wit_errors(MAPPINGS) + def lookup_rate(self, entry: str, window: int) -> int: + """Looks up the current rate for entry in the ratecounter for a window. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.lookup_rate(entry, window) + + @remap_wit_errors(MAPPINGS) + def lookup_count(self, entry: str, duration: int) -> int: + """Looks up the current count for entry in the ratecounter for duration. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.lookup_count(entry, duration) + + +class PenaltyBox(FastlyResource[_wit.PenaltyBox]): + """A penaltybox that can be used with the edge rate limiter or + standalone for adding and checking if some entry is in the data set. + """ + + @classmethod + @remap_wit_errors(MAPPINGS) + def open(cls, name: str) -> Self: + """Opens a `penalty_box` identified by the given name. + + :raises ~fastly_compute.exceptions.types.open_error.OpenError: + """ + return cls(_wit.PenaltyBox.open(name)) + + def get_name(self) -> str: + """Returns the name of this penaltybox.""" + return self._wit_resource.get_name() + + @remap_wit_errors(MAPPINGS) + def add(self, entry: str, ttl: int) -> None: + """Adds `entry` to a the penaltybox for the duration of ttl. + + Valid ttl span is 1m to 1h and TTL value is truncated to the nearest minute. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.add(entry, ttl) + + @remap_wit_errors(MAPPINGS) + def has(self, entry: str) -> bool: + """Checks if `entry` is in the penaltybox. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.has(entry) diff --git a/fastly_compute/_bindings/geo.py b/fastly_compute/_bindings/geo.py new file mode 100644 index 0000000..aaf4bc9 --- /dev/null +++ b/fastly_compute/_bindings/geo.py @@ -0,0 +1,32 @@ +# This file is automatically generated by scripts/generate_bindings. +# Do not edit directly. +"""[Geographic data] for IP addresses. + +[Geographic data]: https://www.fastly.com/blog/improve-performance-and-gain-better-end-user-intelligence-geoip-geography-detection +""" + +from __future__ import annotations + +from wit_world.imports import geo as _wit +from wit_world.imports.types import IpAddress + +from fastly_compute._error_mapping import MAPPINGS, remap_wit_errors + +__all__ = [ + "IpAddress", + "lookup", +] + + +@remap_wit_errors(MAPPINGS) +def lookup(ip_addr: IpAddress, max_len: int) -> str: + """Looks up the geographic data associated with a particular IP address. + + Returns a list of bytes containing JSON-encoded geographic data. See [here] for descriptions + of the JSON fields. + + [here]: https://www.fastly.com/documentation/reference/vcl/variables/geolocation/ + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return _wit.lookup(ip_addr, max_len) diff --git a/fastly_compute/_bindings/http_body.py b/fastly_compute/_bindings/http_body.py new file mode 100644 index 0000000..09ae73e --- /dev/null +++ b/fastly_compute/_bindings/http_body.py @@ -0,0 +1,169 @@ +# This file is automatically generated by scripts/generate_bindings. +# Do not edit directly. +"""HTTP bodies.""" + +from __future__ import annotations + +from wit_world.imports import http_body as _wit + +from fastly_compute._bindings.async_io import Pollable +from fastly_compute._error_mapping import MAPPINGS, remap_wit_errors + +__all__ = [ + "append", + "append_trailer", + "close", + "get_known_length", + "get_trailer_names", + "get_trailer_value", + "get_trailer_values", + "new", + "read", + "write", + "write_front", +] + + +@remap_wit_errors(MAPPINGS) +def new() -> Pollable: + """Creates a new empty body that can be used for outgoing requests and responses. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return Pollable(_wit.new()) + + +@remap_wit_errors(MAPPINGS) +def append(dest: Pollable, src: Pollable) -> None: + """Appends the contents of the body `src` to the body `dest`. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return _wit.append(dest._wit_resource, src._wit_resource) + + +@remap_wit_errors(MAPPINGS) +def read(body: Pollable, chunk_size: int) -> bytes: + """Reads from a body. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return _wit.read(body._wit_resource, chunk_size) + + +@remap_wit_errors(MAPPINGS) +def write(body: Pollable, buf: bytes) -> int: + """Writes to a body. + + This function may write fewer bytes than requested; on success, the number of + bytes actually written is returned. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return _wit.write(body._wit_resource, buf) + + +@remap_wit_errors(MAPPINGS) +def write_front(body: Pollable, buf: bytes) -> None: + """Prepends bytes to the front of a body. + + On success, this function always writes all the bytes of `buf`. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return _wit.write_front(body._wit_resource, buf) + + +@remap_wit_errors(MAPPINGS) +def close(body: Pollable) -> None: + """Frees a body. + + This releases resources associated with the body. + + For streaming bodies, this is a *successful* stream termination, which will signal + via framing that the body transfer is complete. + + If a handle is dropped without calling `close`, it's an *unsuccessful* stream + termination. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return _wit.close(body._wit_resource) + + +def get_known_length(body: Pollable) -> int | None: + """Returns a `u64` body length if the length of a body is known, or `None` otherwise. + + If the length is unknown, it is likely due to the body arising from an HTTP/1.1 message with + chunked encoding, an HTTP/2 or later message with no `content_length`, or being a streaming + body. + + Receiving a length from this function does not guarantee that the full number of + bytes can actually be read from the body. For example, when proxying a response from a + backend, this length may reflect the `content_length` promised in the response, but if the + backend connection is closed prematurely, fewer bytes may be delivered before this body + handle can no longer be read. + """ + return _wit.get_known_length(body._wit_resource) + + +@remap_wit_errors(MAPPINGS) +def append_trailer(body: Pollable, name: str, value: bytes) -> None: + """Adds a body trailing header with given value. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return _wit.append_trailer(body._wit_resource, name, value) + + +@remap_wit_errors(MAPPINGS) +def get_trailer_names( + body: Pollable, max_len: int, cursor: int +) -> tuple[str, int | None]: + """Gets the names of the trailers associated with this body. + + The first `cursor` names are skipped. The remaining names are encoded successively with + a NUL byte after each into a list of bytes at most `max_len` long. If any of the remaining + names don't fit, the returned `option` is the index of the first name that didn't fit, + or `None` if all the remaining names fit. If `max_len` is too small to fit any name, an + `error.buffer-len` error is returned, providing a recommended buffer size. + + :raises ~fastly_compute.exceptions.http_body.trailer_error.TrailerError: + """ + return _wit.get_trailer_names(body._wit_resource, max_len, cursor) + + +@remap_wit_errors(MAPPINGS) +def get_trailer_value(body: Pollable, name: str, max_len: int) -> bytes | None: + """Gets the value for the trailer with the given name, or `None` if the trailer is not present. + + If there are multiple values for this header, only one is returned, which may be + any of the values. See `get_trailer_values` if you need to get all of the values. + + This functions returns `v` if the trailer with the given name is present, + and `None` if no trailer with the given name is present. If `max_len` is too + small to fit the value, an `error.buffer-len` error is returned, providing a + recommended buffer size. + + :raises ~fastly_compute.exceptions.http_body.trailer_error.TrailerError: + """ + return _wit.get_trailer_value(body._wit_resource, name, max_len) + + +@remap_wit_errors(MAPPINGS) +def get_trailer_values( + body: Pollable, name: str, max_len: int, cursor: int +) -> tuple[bytes, int | None]: + """Gets multiple values associated with the trailer with the given name. + + As opposed to `get_trailer_value`, this function returns all of the values for this trailer. + + The first `cursor` values are skipped. The remaining values are encoded successively with + a NUL byte after each into a list of bytes at most `max_len` long. If any of the remaining + values don't fit, the returned `option` is the index of the first value that didn't + fit, or `None` if all the remaining values fit. If `max_len` is too small to fit any value, + an `error.buffer-len` error is returned, providing a recommended buffer size. + + :raises ~fastly_compute.exceptions.http_body.trailer_error.TrailerError: + """ + return _wit.get_trailer_values(body._wit_resource, name, max_len, cursor) diff --git a/fastly_compute/_bindings/http_cache.py b/fastly_compute/_bindings/http_cache.py new file mode 100644 index 0000000..d9fb56f --- /dev/null +++ b/fastly_compute/_bindings/http_cache.py @@ -0,0 +1,485 @@ +# This file is automatically generated by scripts/generate_bindings. +# Do not edit directly. +"""[HTTP Cache] API. + +Overall, this should look very familiar to users of the Core Cache API. The primary differences +are: + +- HTTP `request`s and `response`s are used rather than relying on the user to + encode headers, status codes, etc in `user-metadata`. + +- Convenience functions specific to HTTP semantics are provided, such as `is-request-cacheable`, + `get-suggested-backend-request`, `get-suggested-write-options`, and + `transaction-record-not-cacheable`. + +The HTTP-specific behavior of these functions is intended to support applications that match the +normative guidance in [RFC 9111]. For example, `is-request-cacheable` returns `false` for `POST` +requests. However, this answer along with those of many of these functions explicitly provide +*suggestions*; they do not necessarily need to be followed if custom behavior is required, such +as caching `POST` responses when the application author knows that to be safe. + +The starting points for this API are `lookup` (no request collapsing) and `transaction-lookup` +(request collapsing). + +[HTTP Cache]: https://www.fastly.com/documentation/guides/concepts/edge-state/cache/cache-freshness/ +[RFC 9111]: https://www.rfc-editor.org/rfc/rfc9111.html +""" + +from __future__ import annotations + +from typing import Self + +from wit_world.imports import http_cache as _wit +from wit_world.imports.cache import LookupState +from wit_world.imports.http_cache import StorageAction + +from fastly_compute._bindings.async_io import Pollable +from fastly_compute._bindings.backend import Backend +from fastly_compute._bindings.http_req import Request +from fastly_compute._bindings.http_resp import Response +from fastly_compute._error_mapping import MAPPINGS, remap_wit_errors +from fastly_compute._resource import FastlyResource + +__all__ = [ + "Entry", + "LookupOptions", + "LookupState", + "StorageAction", + "SuggestedWriteOptions", + "WriteOptions", + "close_entry", + "get_suggested_cache_key", + "is_request_cacheable", +] + + +class LookupOptions: + """Non-required options for cache lookups.""" + + def __init__( + self, + override_key: bytes | None = None, + backend: Backend | None = None, + ) -> None: + """ + :param override_key: Cache key to use in lieu of the automatically-generated cache key based on the request's properties. The cache key must be exactly 32 bytes long. + :param backend: Backend that will be used for the eventual request. + """ + self._wit = _wit.LookupOptions( + override_key=override_key, + backend=backend._wit_resource if backend is not None else None, + extra=None, + ) + + +class WriteOptions: + """Options for cache insertions and updates.""" + + def __init__( + self, + max_age_ns: int = 0, + vary_rule: str | None = None, + initial_age_ns: int | None = None, + stale_while_revalidate_ns: int | None = None, + stale_if_error_ns: int | None = None, + surrogate_keys: str | None = None, + length: int | None = None, + sensitive_data: bool = False, + ) -> None: + """ + :param max_age_ns: The maximum age of the response before it is considered stale, in nanoseconds. This field is required. + :param vary_rule: A list of header names to use when calculating variants for this response. The format is a string containing header names separated by spaces. + :param initial_age_ns: The initial age of the response in nanoseconds. If this field is not set, the default value is zero. This age is used to determine the freshness lifetime of the response as well as to prioritize which variant to return if a subsequent lookup matches more than one vary rule + :param stale_while_revalidate_ns: The maximum duration after `max_age` during which the response may be delivered stale while being revalidated, in nanoseconds. If this field is not set, the default value is zero. + :param surrogate_keys: A list of surrogate keys that may be used to purge this response. The format is a string containing [valid surrogate keys] separated by spaces. If this field is not set, no surrogate keys will be associated with the response. This means that the response cannot be purged except via a purge-all operation. [valid surrogate keys]: https://www.fastly.com/documentation/reference/http/http-headers/Surrogate-Key/ + :param length: The length of the response body. If this field is not set, the length of the body is treated as unknown. When possible, this field should be set so that other clients waiting to retrieve the body have enough information to synthesize a `content_length` even before the complete body is inserted to the cache. + :param sensitive_data: Enable or disable PCI/HIPAA-compliant non-volatile caching. See the [Fastly PCI-Compliant Caching and Delivery documentation] for details. [Fastly PCI-Compliant Caching and Delivery documentation]: https://docs.fastly.com/products/pci-compliant-caching-and-delivery + """ + self._wit = _wit.WriteOptions( + max_age_ns=max_age_ns, + vary_rule=vary_rule, + initial_age_ns=initial_age_ns, + stale_while_revalidate_ns=stale_while_revalidate_ns, + stale_if_error_ns=stale_if_error_ns, + surrogate_keys=surrogate_keys, + length=length, + sensitive_data=sensitive_data, + extra=None, + ) + + +class Entry(FastlyResource[_wit.Entry]): + """An HTTP Cache transaction.""" + + @classmethod + @remap_wit_errors(MAPPINGS) + def transaction_lookup(cls, req_handle: Request, options: LookupOptions) -> Self: + """Performs a cache lookup based on the given request. + + This operation always participates in request collapsing and may return an obligation to + insert or update responses, and/or stale responses. + + The request is not consumed. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return cls( + _wit.Entry.transaction_lookup(req_handle._wit_resource, options._wit) + ) + + @remap_wit_errors(MAPPINGS) + def transaction_insert( + self, resp_handle: Response, options: WriteOptions + ) -> Pollable: + """Inserts a response into the cache with the given options, returning a streaming body handle + that is ready for writing or appending. + + Can only be used if the cache handle state includes the `must_insert_or_update` flag. + + The response is consumed. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return Pollable( + self._wit_resource.transaction_insert( + resp_handle._wit_resource, options._wit + ) + ) + + @remap_wit_errors(MAPPINGS) + def transaction_insert_and_stream_back( + self, resp_handle: Response, options: WriteOptions + ) -> tuple[Pollable, Self]: + """Inserts a response into the cache with the given options, and return a fresh cache handle + that can be used to retrieve and stream the response while it's being inserted. + + This helps avoid the “slow reader” problem on a teed stream, for example when a program + wishes to store a backend request in the cache while simultaneously streaming to a client + in an HTTP response. + + The response is consumed. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + _r = self._wit_resource.transaction_insert_and_stream_back( + resp_handle._wit_resource, options._wit + ) + return (Pollable(_r[0]), Entry(_r[1])) + + @remap_wit_errors(MAPPINGS) + def transaction_update(self, resp_handle: Response, options: WriteOptions) -> None: + """Updates freshness lifetime, response headers, and caching settings without updating the + response body. + + Can only be used in if the cache handle state includes both of the flags: + - `found` + - `must_insert_or_update` + + The response is consumed. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.transaction_update( + resp_handle._wit_resource, options._wit + ) + + @remap_wit_errors(MAPPINGS) + def transaction_update_and_return_fresh( + self, resp_handle: Response, options: WriteOptions + ) -> Self: + """Updates freshness lifetime, response headers, and caching settings without updating the + response body, and return a fresh cache handle that can be used to retrieve and stream the + stored response. + + Can only be used in if the cache handle state includes both of the flags: + - `found` + - `must_insert_or_update` + + The response is consumed. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return Entry( + self._wit_resource.transaction_update_and_return_fresh( + resp_handle._wit_resource, options._wit + ) + ) + + @remap_wit_errors(MAPPINGS) + def transaction_record_not_cacheable(self, options: WriteOptions) -> None: + """Disables request collapsing and response caching for this cache entry. + + In Varnish terms, this function stores a hit-for-pass object. + + Only the max age and, optionally, the vary rule are read from the `options` + for this function. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.transaction_record_not_cacheable(options._wit) + + @remap_wit_errors(MAPPINGS) + def get_suggested_backend_request(self) -> Request: + """Prepares a suggested request to make to a backend to satisfy the looked-up request. + + If there is a stored, stale response, this suggested request may be for revalidation. If the + looked-up request is ranged, the suggested request will be unranged in order to try caching + the entire response. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return Request(self._wit_resource.get_suggested_backend_request()) + + @remap_wit_errors(MAPPINGS) + def get_suggested_write_options(self, response: Response) -> SuggestedWriteOptions: + """Prepares a suggested set of cache write options for a given request and response pair. + + The response is not consumed. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return SuggestedWriteOptions( + self._wit_resource.get_suggested_write_options(response._wit_resource) + ) + + @remap_wit_errors(MAPPINGS) + def prepare_response_for_storage( + self, response: Response + ) -> tuple[StorageAction, Response]: + """Adjusts a response into the appropriate form for storage and provides a storage action + recommendation. + + For example, if the looked-up request contains conditional headers, this function will + interpret a `304 Not Modified` response for revalidation by updating headers. + + In addition to the updated response, this function returns the recommended storage action. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + _r = self._wit_resource.prepare_response_for_storage(response._wit_resource) + return (_r[0], Response(_r[1])) + + @remap_wit_errors(MAPPINGS) + def get_found_response( + self, transform_for_client: int + ) -> tuple[Response, Pollable] | None: + """Retrieves a stored response from the cache, returning `None` if + there was no response found. + + If `transform_for_client` is set, the response will be adjusted according to the looked-up + request. For example, a response retrieved for a range request may be transformed into a + `206 Partial Content` response with an appropriate `content_range` header. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + _r = self._wit_resource.get_found_response(transform_for_client) + return (Response(_r[0]), Pollable(_r[1])) if _r is not None else None + + @remap_wit_errors(MAPPINGS) + def get_state(self) -> LookupState: + """Gets the state of a cache transaction. + + Primarily useful after performing the lookup to determine what subsequent operations are + possible and whether any insertion or update obligations exist. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.get_state() + + @remap_wit_errors(MAPPINGS) + def get_length(self) -> int | None: + """Gets the length of the found response, returning `None` if there + was no response found or no length was provided. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.get_length() + + @remap_wit_errors(MAPPINGS) + def get_max_age_ns(self) -> int | None: + """Gets the configured max age of the found response in nanoseconds, returning `None` + if there was no response found. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.get_max_age_ns() + + @remap_wit_errors(MAPPINGS) + def get_stale_while_revalidate_ns(self) -> int | None: + """Gets the configured stale-while-revalidate period of the found response in nanoseconds, + returning `None` if there was no response found. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.get_stale_while_revalidate_ns() + + @remap_wit_errors(MAPPINGS) + def get_age_ns(self) -> int | None: + """Gets the age of the found response in nanoseconds, returning `None` + if there was no response found. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.get_age_ns() + + @remap_wit_errors(MAPPINGS) + def get_hits(self) -> int | None: + """Gets the number of cache hits for the found response, returning `None` + if there was no response found. + + This figure only reflects hits for a stored response in a particular cache server + or cluster, not the entire Fastly network. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.get_hits() + + @remap_wit_errors(MAPPINGS) + def get_sensitive_data(self) -> bool | None: + """Gets whether a found response is marked as containing sensitive data, returning `None` + if there was no response found. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.get_sensitive_data() + + @remap_wit_errors(MAPPINGS) + def get_surrogate_keys(self, max_len: int) -> str | None: + """Gets the surrogate keys of the found response, returning `None` if + there was no response found. + + The output is a list of surrogate keys separated by spaces. + + If the full list requires more than `max_len` bytes, an `error.buffer-len` + error is returned containing the required size. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.get_surrogate_keys(max_len) + + @remap_wit_errors(MAPPINGS) + def get_vary_rule(self, max_len: int) -> str | None: + """Gets the vary rule of the found response, returning `None` if there + was no response found. + + The output is a list of header names separated by spaces. + + If the full list requires more than `max_len` bytes, an `error.buffer-len` + error is returned containing the required size. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.get_vary_rule(max_len) + + @remap_wit_errors(MAPPINGS) + def transaction_abandon(self) -> None: + """Abandons an obligation to provide a response to the cache. + + Useful if there is an error before streaming is possible, for example if a backend is + unreachable. + + If there are other requests collapsed on this transaction, one of those other requests will + be awoken and given the obligation to provide a response. If subsequent requests + are unlikely to yield cacheable responses, this may lead to undesired serialization of + requests. Consider using `transaction_record_not_cacheable` to make lookups for this request + bypass the cache. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.transaction_abandon() + + +class ExtraLookupOptions(FastlyResource[_wit.ExtraLookupOptions]): + """Extensibility for `lookup-options`""" + + +class ExtraWriteOptions(FastlyResource[_wit.ExtraWriteOptions]): + """Extensibility for `write-options`""" + + +class SuggestedWriteOptions(FastlyResource[_wit.SuggestedWriteOptions]): + """The methods in this resource return values that correspond to the fields in a + `write-options`. This type is used when a `write-options` value would + be returned, so that it can use `max-len` parameters when returning + dynamically-sized data, and so that it excludes the `extra` field, since borrowed + handles cannot be returned from functions. + """ + + def get_max_age_ns(self) -> int: + """Returns the suggested value for the `write-options.max-age-ns` field.""" + return self._wit_resource.get_max_age_ns() + + @remap_wit_errors(MAPPINGS) + def get_vary_rule(self, max_len: int) -> str: + """Returns the suggested value for the `write-options.vary-rule` field. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.get_vary_rule(max_len) + + def get_initial_age_ns(self) -> int: + """Returns the suggested value for the `write-options.initial-age-ns` field.""" + return self._wit_resource.get_initial_age_ns() + + def get_stale_while_revalidate_ns(self) -> int: + """Returns the suggested value for the `write-options.stale-while-revalidate-ns` field.""" + return self._wit_resource.get_stale_while_revalidate_ns() + + @remap_wit_errors(MAPPINGS) + def get_surrogate_keys(self, max_len: int) -> str: + """Returns the suggested value for the `write-options.surrogate-keys` field. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.get_surrogate_keys(max_len) + + def get_length(self) -> int | None: + """Returns the suggested value for the `write-options.length` field.""" + return self._wit_resource.get_length() + + def get_sensitive_data(self) -> bool: + """Returns the suggested value for the `write-options.sensitive-data` field.""" + return self._wit_resource.get_sensitive_data() + + +@remap_wit_errors(MAPPINGS) +def is_request_cacheable(request: Request) -> bool: + """Determines whether a request is cacheable per conservative [RFC 9111] semantics. + + In particular, this function checks whether the request method is `GET` or `HEAD`, and + considers requests with other methods uncacheable. Applications where it is safe to cache + responses to other methods should consider using their own cacheability check instead of + this function. + + [RFC 9111]: https://www.rfc-editor.org/rfc/rfc9111.html + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return _wit.is_request_cacheable(request._wit_resource) + + +@remap_wit_errors(MAPPINGS) +def get_suggested_cache_key(request: Request, max_len: int) -> bytes: + """Retrieves the default cache key for the request. + + If the full key requires more than `max_len` bytes, an `error.buffer-len` + error is returned containing the required size. + + At the moment, HTTP cache keys must always be 32 bytes. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return _wit.get_suggested_cache_key(request._wit_resource, max_len) + + +@remap_wit_errors(MAPPINGS) +def close_entry(handle: Entry) -> None: + """Closes an ongoing interaction with the cache. + + If the cache handle state includes `must_insert_or_update` (and hence no insert or update + has been performed), closing the handle cancels any request collapsing, potentially choosing + a new waiter to perform the insertion/update. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return _wit.close_entry(handle._wit_resource) diff --git a/fastly_compute/_bindings/http_downstream.py b/fastly_compute/_bindings/http_downstream.py new file mode 100644 index 0000000..2e44086 --- /dev/null +++ b/fastly_compute/_bindings/http_downstream.py @@ -0,0 +1,302 @@ +# This file is automatically generated by scripts/generate_bindings. +# Do not edit directly. +"""HTTP downstream requests and metadata. + +“Downstream” here refers to incoming HTTP requests. +""" + +from __future__ import annotations + +from wit_world.imports import http_downstream as _wit +from wit_world.imports.http_req import ClientCertVerifyResult +from wit_world.imports.types import IpAddress + +from fastly_compute._bindings.async_io import Pollable +from fastly_compute._bindings.http_req import Request +from fastly_compute._error_mapping import MAPPINGS, remap_wit_errors +from fastly_compute._resource import FastlyResource + +__all__ = [ + "ClientCertVerifyResult", + "IpAddress", + "NextRequestOptions", + "await_request", + "downstream_client_ddos_detected", + "downstream_client_h2_fingerprint", + "downstream_client_ip_addr", + "downstream_client_oh_fingerprint", + "downstream_client_request_id", + "downstream_compliance_region", + "downstream_original_header_count", + "downstream_original_header_names", + "downstream_server_ip_addr", + "downstream_tls_cipher_openssl_name", + "downstream_tls_client_cert_verify_result", + "downstream_tls_client_hello", + "downstream_tls_client_servername", + "downstream_tls_ja3_md5", + "downstream_tls_ja4", + "downstream_tls_protocol", + "downstream_tls_raw_client_certificate", + "fastly_key_is_valid", + "next_request", +] + + +class NextRequestOptions: + """Configuration for `next-request`.""" + + def __init__( + self, + timeout_ms: int | None = None, + ) -> None: + self._wit = _wit.NextRequestOptions( + timeout_ms=timeout_ms, + extra=None, + ) + + +class ExtraNextRequestOptions(FastlyResource[_wit.ExtraNextRequestOptions]): + """Extensibility for `next-request-options`""" + + +@remap_wit_errors(MAPPINGS) +def next_request(options: NextRequestOptions) -> Pollable: + """Prepares to accept a new downstream request. + + By default, each sandbox accepts a single downstream request, + passed in as an argument to the `http-incoming.handle` call. The + `next_request` function enables a sandbox to accept additional + downstream requests. `next_request` returns a `pending_request`, which + can be passed to `await_request` to wait for the request to become + available and return the `request`. + + When using this function, be mindful of two considerations: + + - When a single sandbox accepts multiple requests, its state + isn't automatically cleared between requests. Applications are + therefore responsible for preventing sensitive data from leaking from + one request to another. + + - There is no guarantee that a single sandbox will receive all + requests from a given client or from a given location. When it is + necessary to preserve state between multiple requests, store it outside + of the sandbox. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return Pollable(_wit.next_request(options._wit)) + + +@remap_wit_errors(MAPPINGS) +def await_request(pending: Pollable) -> tuple[Request, Pollable] | None: + """Waits until the next request is available, and then returns the resulting + request and body. + + Returns `None` if there are no more requests for this sandbox. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + _r = _wit.await_request(pending._wit_resource) + return (Request(_r[0]), Pollable(_r[1])) if _r is not None else None + + +@remap_wit_errors(MAPPINGS) +def downstream_original_header_names( + ds_request: Request, max_len: int, cursor: int +) -> tuple[str, int | None]: + """Returns the client request's header names exactly as they were originally received. + + This includes both the original header name characters' cases, as well as the original order + of the received headers. + + The first `cursor` names are skipped. The remaining names are encoded successively with + a NUL byte after each into a list of bytes at most `max_len` long. If any of the remaining + names don't fit, the returned `option` is the index of the first name that didn't fit, + or `None` if all the remaining names fit. If `max_len` is too small to fit any name, + an `error.buffer-len` error is returned, providing a recommended buffer size. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return _wit.downstream_original_header_names( + ds_request._wit_resource, max_len, cursor + ) + + +@remap_wit_errors(MAPPINGS) +def downstream_original_header_count(ds_request: Request) -> int: + """Returns the number of headers in the client request as originally received. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return _wit.downstream_original_header_count(ds_request._wit_resource) + + +def downstream_client_ip_addr(ds_request: Request) -> IpAddress | None: + """Returns the IP address of the client making the HTTP request, if known.""" + return _wit.downstream_client_ip_addr(ds_request._wit_resource) + + +def downstream_server_ip_addr(ds_request: Request) -> IpAddress | None: + """Returns the IP address on which this server received the HTTP request, if known.""" + return _wit.downstream_server_ip_addr(ds_request._wit_resource) + + +@remap_wit_errors(MAPPINGS) +def downstream_client_h2_fingerprint(ds_request: Request, max_len: int) -> str: + """Gets the HTTP/2 fingerprint of client request if available. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return _wit.downstream_client_h2_fingerprint(ds_request._wit_resource, max_len) + + +@remap_wit_errors(MAPPINGS) +def downstream_client_request_id(ds_request: Request, max_len: int) -> str: + """Gets the id of the current request if available. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return _wit.downstream_client_request_id(ds_request._wit_resource, max_len) + + +@remap_wit_errors(MAPPINGS) +def downstream_client_oh_fingerprint(ds_request: Request, max_len: int) -> str: + """Gets the fingerprint of client request headers if available. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return _wit.downstream_client_oh_fingerprint(ds_request._wit_resource, max_len) + + +@remap_wit_errors(MAPPINGS) +def downstream_client_ddos_detected(ds_request: Request) -> bool: + """Returns whether the request was tagged as contributing to a DDoS attack. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return _wit.downstream_client_ddos_detected(ds_request._wit_resource) + + +@remap_wit_errors(MAPPINGS) +def downstream_tls_cipher_openssl_name( + ds_request: Request, max_len: int +) -> bytes | None: + """Gets the cipher suite used to secure the downstream client TLS connection. + + The value returned will be consistent with the [OpenSSL name] for the cipher suite. + + Returns `None` if the downstream client connection is not a TLS connection. + + [OpenSSL name]: https://testssl.sh/openssl-iana.mapping.html + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return _wit.downstream_tls_cipher_openssl_name(ds_request._wit_resource, max_len) + + +@remap_wit_errors(MAPPINGS) +def downstream_tls_protocol(ds_request: Request, max_len: int) -> bytes | None: + """Gets the TLS protocol version used to secure the downstream client TLS connection. + + Returns `None` if the downstream client connection is not a TLS connection. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return _wit.downstream_tls_protocol(ds_request._wit_resource, max_len) + + +@remap_wit_errors(MAPPINGS) +def downstream_tls_client_hello(ds_request: Request, max_len: int) -> bytes | None: + """Gets the raw bytes sent by the client in the TLS ClientHello message. + + See [RFC 5246] for details. + + Returns `None` if the downstream client connection is not a TLS connection. + + [RFC 5246]: https://www.rfc-editor.org/rfc/rfc5246#section-7.4.1.2 + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return _wit.downstream_tls_client_hello(ds_request._wit_resource, max_len) + + +@remap_wit_errors(MAPPINGS) +def downstream_tls_raw_client_certificate( + ds_request: Request, max_len: int +) -> bytes | None: + """Gets the raw client certificate used to secure the downstream client mTLS connection. + + The value returned will be based on PEM format. + + Returns `None` if the downstream client connection is not a TLS connection. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return _wit.downstream_tls_raw_client_certificate(ds_request._wit_resource, max_len) + + +@remap_wit_errors(MAPPINGS) +def downstream_tls_client_cert_verify_result( + ds_request: Request, +) -> ClientCertVerifyResult | None: + """Returns the `client_cert_verify_result` from the downstream client mTLS handshake. + + Returns `None` if the downstream client connection is not a TLS connection. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return _wit.downstream_tls_client_cert_verify_result(ds_request._wit_resource) + + +@remap_wit_errors(MAPPINGS) +def downstream_tls_client_servername(ds_request: Request, max_len: int) -> str | None: + """Returns the Server Name Indication from the downstream client TLS handshake. + + Returns `None` if not available. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return _wit.downstream_tls_client_servername(ds_request._wit_resource, max_len) + + +@remap_wit_errors(MAPPINGS) +def downstream_tls_ja3_md5(ds_request: Request) -> bytes | None: + """Gets the JA3 hash of the TLS ClientHello message. + + Returns `None` if the downstream client connection is not a TLS connection. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return _wit.downstream_tls_ja3_md5(ds_request._wit_resource) + + +@remap_wit_errors(MAPPINGS) +def downstream_tls_ja4(ds_request: Request, max_len: int) -> str | None: + """Gets the JA4 hash of the TLS ClientHello message. + + Returns `None` if the downstream client connection is not a TLS connection. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return _wit.downstream_tls_ja4(ds_request._wit_resource, max_len) + + +@remap_wit_errors(MAPPINGS) +def downstream_compliance_region(ds_request: Request, max_len: int) -> str | None: + """Gets the compliance region that the client IP address is in. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return _wit.downstream_compliance_region(ds_request._wit_resource, max_len) + + +@remap_wit_errors(MAPPINGS) +def fastly_key_is_valid(ds_request: Request) -> bool: + """Returns whether or not the original client request arrived with a + Fastly-Key belonging to a user with the rights to purge content on this + service. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return _wit.fastly_key_is_valid(ds_request._wit_resource) diff --git a/fastly_compute/_bindings/http_incoming.py b/fastly_compute/_bindings/http_incoming.py new file mode 100644 index 0000000..e29bf7b --- /dev/null +++ b/fastly_compute/_bindings/http_incoming.py @@ -0,0 +1,37 @@ +# This file is automatically generated by scripts/generate_bindings. +# Do not edit directly. +"""The exported interface. + +The `handle` function serves as the main entrypoint to applications. Unlike the +rest of the interfaces in this package, this `http-incoming` interface is exported by +applications rather than imported, which means that this is a function defined +by the application and called from the outside, rather than a function called +by the application into the outside. +""" + +from __future__ import annotations + +from wit_world.imports import http_incoming as _wit + +from fastly_compute._bindings.async_io import Pollable +from fastly_compute._bindings.http_req import Request +from fastly_compute._error_mapping import MAPPINGS, remap_wit_errors + +__all__ = [ + "handle", +] + + +@remap_wit_errors(MAPPINGS) +def handle(request: Request, body: Pollable) -> None: + """Handle the given request. + + This function is called once per sandbox. When it returns, the + sandbox exits. To opt into receiving multiple requests in a single + sandbox, use `http-downstream.next-request`. + + To send a response for the given `request`, use `send_downstream`, or to + stream the response body after the response has been initiated, use + `send_downstream_streaming`. + """ + return _wit.handle(request._wit_resource, body._wit_resource) diff --git a/fastly_compute/_bindings/http_req.py b/fastly_compute/_bindings/http_req.py new file mode 100644 index 0000000..9ee9830 --- /dev/null +++ b/fastly_compute/_bindings/http_req.py @@ -0,0 +1,408 @@ +# This file is automatically generated by scripts/generate_bindings. +# Do not edit directly. +"""HTTP requests.""" + +from __future__ import annotations + +from typing import Self + +from wit_world.imports import http_req as _wit +from wit_world.imports.http_req import CacheOverride +from wit_world.imports.http_types import ( + ContentEncodings, + FramingHeadersMode, + HttpVersion, +) + +from fastly_compute._bindings.async_io import Pollable +from fastly_compute._bindings.backend import Backend +from fastly_compute._bindings.http_resp import Response +from fastly_compute._error_mapping import MAPPINGS, remap_wit_errors +from fastly_compute._resource import FastlyResource + +__all__ = [ + "CacheOverride", + "ContentEncodings", + "FramingHeadersMode", + "HttpVersion", + "Request", + "await_response", + "close", + "send", + "send_async", + "send_async_streaming", + "send_async_uncached", + "send_async_uncached_streaming", + "send_uncached", + "upgrade_websocket", +] + + +class Request(FastlyResource[_wit.Request]): + """An HTTP request.""" + + @classmethod + @remap_wit_errors(MAPPINGS) + def new(cls) -> Self: + """Creates a new `request` with no method, URL, or headers, and an empty body. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return cls(_wit.Request.new()) + + @remap_wit_errors(MAPPINGS) + def set_cache_override(self, cache_override: CacheOverride) -> None: + """Sets the cache override behavior for this request. + + This setting will override any cache directive headers returned in response to this request. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.set_cache_override(cache_override) + + @remap_wit_errors(MAPPINGS) + def get_header_names(self, max_len: int, cursor: int) -> tuple[str, int | None]: + """Reads the request's header names via a buffer of the provided size. + + The first `cursor` names are skipped. The remaining names are encoded successively with + a NUL byte after each into a list of bytes at most `max_len` long. If any of the remaining + names don't fit, the returned `option` is the index of the first name that didn't fit, + or `None` if all the remaining names fit. If `max_len` is too small to fit any name, + an `error.buffer-len` error is returned, providing a recommended buffer size. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.get_header_names(max_len, cursor) + + @remap_wit_errors(MAPPINGS) + def get_header_value(self, name: str, max_len: int) -> bytes | None: + """Gets the value of a header, or `None` if the header is not present. + + If there are multiple values for the header, only one is returned. See + `get_header_values` if you need to get all of the values. + + If header name requires more than `max_len` bytes, this will return an `error.buffer-len` + containing the required size. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.get_header_value(name, max_len) + + @remap_wit_errors(MAPPINGS) + def get_header_values( + self, name: str, max_len: int, cursor: int + ) -> tuple[bytes, int | None]: + """Gets multiple header values for the given `name` via a buffer of the provided size. + + As opposed to `get_header_value`, this function returns all of the values for this header. + + The first `cursor` values are skipped. The remaining values are encoded successively with + a NUL byte after each into a list of bytes at most `max_len` long. If any of the remaining + values don't fit, the returned `option` is the index of the first value that didn't + fit, or `None` if all the remaining values fit. If `max_len` is too small to fit any value, + an `error.buffer-len` error is returned, providing a recommended buffer size. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.get_header_values(name, max_len, cursor) + + @remap_wit_errors(MAPPINGS) + def set_header_values(self, name: str, values: bytes) -> None: + """Sets the values for the given header name, replacing any headers that previously existed for + that name. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.set_header_values(name, values) + + @remap_wit_errors(MAPPINGS) + def insert_header(self, name: str, value: bytes) -> None: + """Sets a request header to the given value, discarding any previous values for the given + header name. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.insert_header(name, value) + + @remap_wit_errors(MAPPINGS) + def append_header(self, name: str, value: bytes) -> None: + """Adds a request header with given value. + + Unlike `set_header_values`, this does not discard existing values for the same header name. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.append_header(name, value) + + @remap_wit_errors(MAPPINGS) + def remove_header(self, name: str) -> None: + """Removes all request headers of the given name + + Returns `ok` if any headers were successfully removed. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.remove_header(name) + + @remap_wit_errors(MAPPINGS) + def get_method(self, max_len: int) -> str: + """Gets the request method. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.get_method(max_len) + + @remap_wit_errors(MAPPINGS) + def set_method(self, method: str) -> None: + """Sets the request method. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.set_method(method) + + @remap_wit_errors(MAPPINGS) + def get_uri(self, max_len: int) -> str: + """Gets the request URI. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.get_uri(max_len) + + @remap_wit_errors(MAPPINGS) + def set_uri(self, uri: str) -> None: + """Sets the request URI. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.set_uri(uri) + + @remap_wit_errors(MAPPINGS) + def get_version(self) -> HttpVersion: + """Gets the HTTP version of this request. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.get_version() + + @remap_wit_errors(MAPPINGS) + def set_version(self, version: HttpVersion) -> None: + """Sets the HTTP version of this request. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.set_version(version) + + @remap_wit_errors(MAPPINGS) + def set_auto_decompress_response(self, encodings: ContentEncodings) -> None: + """Sets the content encodings to automatically decompress responses to this request. + + If the response to this request is encoded by one of the encodings set by this method, the + response will be presented to the Compute program in decompressed form with the + `Content-Encoding` and `Content-Length` headers removed. + + Not all of the flags defined in `content_encodings` are supported. Currently the only + supported flag is `content-encodings.gzip`. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.set_auto_decompress_response(encodings) + + @remap_wit_errors(MAPPINGS) + def redirect_to_websocket_proxy(self, backend: Backend) -> None: + """Passes the WebSocket directly to a backend. + + This can only be used on services that have the WebSockets feature enabled and on requests + that are valid WebSocket requests. + + The sending completes in the background. Once this method has been called, no other response + can be sent to this request, and the application can exit without affecting the send. + + See the [WebSockets passthrough] documentation for a high-level description of this feature. + + [WebSockets passthrough]: https://www.fastly.com/documentation/guides/concepts/real-time-messaging/websockets-tunnel/ + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.redirect_to_websocket_proxy(backend._wit_resource) + + @remap_wit_errors(MAPPINGS) + def set_framing_headers_mode(self, mode: FramingHeadersMode) -> None: + """Sets how the framing headers `Content-Length` and `Transfer-Encoding` will be determined + when sending this request. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.set_framing_headers_mode(mode) + + @remap_wit_errors(MAPPINGS) + def redirect_to_grip_proxy(self, backend: Backend) -> None: + """redirect_to_grip_proxy. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.redirect_to_grip_proxy(backend._wit_resource) + + +class ExtraCacheOverrideDetails(FastlyResource[_wit.ExtraCacheOverrideDetails]): + """Extensibility for `cache-override-details`""" + + +class ExtraSendErrorDetail(FastlyResource[_wit.ExtraSendErrorDetail]): + """Extensibility for `send-error-detail`""" + + +@remap_wit_errors(MAPPINGS) +def send( + request: Request, body: Pollable, backend: Backend +) -> tuple[Response, Pollable]: + """Retrieves a response for the request, either from cache or by sending it + to the given backend server. + + Returns once the response headers have been received, or an error occurs. + + :raises ~fastly_compute.exceptions.http_req.ErrorWithDetail: + """ + _r = _wit.send(request._wit_resource, body._wit_resource, backend._wit_resource) + return (Response(_r[0]), Pollable(_r[1])) + + +@remap_wit_errors(MAPPINGS) +def send_uncached( + request: Request, body: Pollable, backend: Backend +) -> tuple[Response, Pollable]: + """Sends the request directly to the backend server without performing any + caching or inserting any cache-related headers in the response. + + Returns once the response headers have been received, or an error occurs. + + :raises ~fastly_compute.exceptions.http_req.ErrorWithDetail: + """ + _r = _wit.send_uncached( + request._wit_resource, body._wit_resource, backend._wit_resource + ) + return (Response(_r[0]), Pollable(_r[1])) + + +@remap_wit_errors(MAPPINGS) +def send_async(request: Request, body: Pollable, backend: Backend) -> Pollable: + """Begins sending the request to the given backend server, and returns a + `pending_response` that can yield the backend response or an error. + + This method returns as soon as the request begins sending to the backend, + and transmission of the request body and headers will continue in the + background. + + This method allows for sending more than one request at once and receiving + their responses in arbitrary orders. See `pending_response` for more + details on how to wait on, poll, or select between pending responses. + + This method is also useful for sending requests where the response is + unimportant, but the request may take longer than the Compute program is + able to run, as the request will continue sending even after the program + that initiated it exits. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return Pollable( + _wit.send_async( + request._wit_resource, body._wit_resource, backend._wit_resource + ) + ) + + +@remap_wit_errors(MAPPINGS) +def send_async_uncached(request: Request, body: Pollable, backend: Backend) -> Pollable: + """This is to `send_async` as `send_uncached` is to `send`. + + As with `send_uncached`, this function sends the request directly to the + backend server without performing any caching or inserting any + cache-related headers in the response. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return Pollable( + _wit.send_async_uncached( + request._wit_resource, body._wit_resource, backend._wit_resource + ) + ) + + +@remap_wit_errors(MAPPINGS) +def send_async_streaming( + request: Request, body: Pollable, backend: Backend +) -> Pollable: + """Begins sending the request to the given backend server, and returns a + `pending_response` that can yield the backend response or an error. + + The `body` argument is not consumed, so that it can accept further data to send. + + The backend connection is only closed once `http-body.close` is called. The + `pending_response` will not yield a `response` until the body is finished. + + This method is most useful for programs that do some sort of processing or + inspection of a potentially-large client request body. Streaming allows the + program to operate on small parts of the body rather than having to read it all + into memory at once. + + This method returns as soon as the request begins sending to the backend, + and transmission of the request body and headers will continue in the + background. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return Pollable( + _wit.send_async_streaming( + request._wit_resource, body._wit_resource, backend._wit_resource + ) + ) + + +@remap_wit_errors(MAPPINGS) +def send_async_uncached_streaming( + request: Request, body: Pollable, backend: Backend +) -> Pollable: + """This is to `send_async_streaming` as `send_uncached` is to `send`. + + As with `send_uncached`, this function sends the request directly to the + backend server without performing any caching or inserting any + cache-related headers in the response. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return Pollable( + _wit.send_async_uncached_streaming( + request._wit_resource, body._wit_resource, backend._wit_resource + ) + ) + + +@remap_wit_errors(MAPPINGS) +def await_response(pending: Pollable) -> tuple[Response, Pollable]: + """Waits until the request is completed, and then returns the resulting + response and body. + + :raises ~fastly_compute.exceptions.http_req.ErrorWithDetail: + """ + _r = _wit.await_response(pending._wit_resource) + return (Response(_r[0]), Pollable(_r[1])) + + +@remap_wit_errors(MAPPINGS) +def close(request: Request) -> None: + """Closes the `request`, releasing any associated resources. + + A `request` is automatically consumed when you send a request. You should call `close` + only if you have a `request` you don't intend to use anymore. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return _wit.close(request._wit_resource) + + +@remap_wit_errors(MAPPINGS) +def upgrade_websocket(backend: Backend) -> None: + """upgrade_websocket. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return _wit.upgrade_websocket(backend._wit_resource) diff --git a/fastly_compute/_bindings/http_resp.py b/fastly_compute/_bindings/http_resp.py new file mode 100644 index 0000000..26103e8 --- /dev/null +++ b/fastly_compute/_bindings/http_resp.py @@ -0,0 +1,222 @@ +# This file is automatically generated by scripts/generate_bindings. +# Do not edit directly. +"""HTTP responses.""" + +from __future__ import annotations + +from typing import Self + +from wit_world.imports import http_resp as _wit +from wit_world.imports.http_resp import KeepaliveMode +from wit_world.imports.http_types import FramingHeadersMode, HttpVersion +from wit_world.imports.types import IpAddress + +from fastly_compute._bindings.async_io import Pollable +from fastly_compute._error_mapping import MAPPINGS, remap_wit_errors +from fastly_compute._resource import FastlyResource + +__all__ = [ + "FramingHeadersMode", + "HttpVersion", + "IpAddress", + "KeepaliveMode", + "Response", + "close", + "send_downstream", + "send_downstream_streaming", +] + + +class Response(FastlyResource[_wit.Response]): + """An HTTP response.""" + + @classmethod + @remap_wit_errors(MAPPINGS) + def new(cls) -> Self: + """Create a new `response`. + + The new `response` is created with status code 200 OK, no headers, and an empty body. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return cls(_wit.Response.new()) + + @remap_wit_errors(MAPPINGS) + def get_header_names(self, max_len: int, cursor: int) -> tuple[str, int | None]: + """Read the response's header names via a buffer of the provided size. + + The first `cursor` names are skipped. The remaining names are encoded successively with + a NUL byte after each into a list of bytes at most `max_len` long. If any of the remaining + names don't fit, the returned `option` is the index of the first name that didn't fit, + or `None` if all the remaining names fit. If `max_len` is too small to fit any name, + an `error.buffer-len` error is returned, providing a recommended buffer size. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.get_header_names(max_len, cursor) + + @remap_wit_errors(MAPPINGS) + def get_header_value(self, name: str, max_len: int) -> bytes | None: + """Gets the value of a header, or `None` if the header is not present. + + If there are multiple values for the header, only one is returned. See + `get_header_values` if you need to get all of the values. + + If header name requires more than `max_len` bytes, this will return an `error.buffer-len` + containing the required size. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.get_header_value(name, max_len) + + @remap_wit_errors(MAPPINGS) + def get_header_values( + self, name: str, max_len: int, cursor: int + ) -> tuple[bytes, int | None]: + """Gets multiple header values for the given `name` via a buffer of the provided size. + + As opposed to `get_header_value`, this function returns all of the values for this header. + + The first `cursor` values are skipped. The remaining values are encoded successively with + a NUL byte after each into a list of bytes at most `max_len` long. If any of the remaining + values don't fit, the returned `option` is the index of the first value that didn't + fit, or `None` if all the remaining values fit. If `max_len` is too small to fit any value, + an `error.buffer-len` error is returned, providing a recommended buffer size. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.get_header_values(name, max_len, cursor) + + @remap_wit_errors(MAPPINGS) + def set_header_values(self, name: str, values: bytes) -> None: + """Sets the values for the given header name, replacing any headers that previously existed for + that name. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.set_header_values(name, values) + + @remap_wit_errors(MAPPINGS) + def insert_header(self, name: str, value: bytes) -> None: + """Sets a response header to the given value, discarding any previous values for the given + header name. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.insert_header(name, value) + + @remap_wit_errors(MAPPINGS) + def append_header(self, name: str, value: bytes) -> None: + """Add a response header with given value. + + Unlike `set_header_values`, this does not discard existing values for the same header name. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.append_header(name, value) + + @remap_wit_errors(MAPPINGS) + def remove_header(self, name: str) -> None: + """Remove all response headers of the given name + + Returns `ok` if any headers were successfully removed. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.remove_header(name) + + @remap_wit_errors(MAPPINGS) + def get_version(self) -> HttpVersion: + """Gets the HTTP version of this response. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.get_version() + + @remap_wit_errors(MAPPINGS) + def set_version(self, version: HttpVersion) -> None: + """Sets the HTTP version of this response. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.set_version(version) + + @remap_wit_errors(MAPPINGS) + def get_status(self) -> int: + """Gets the HTTP status code of the response. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.get_status() + + @remap_wit_errors(MAPPINGS) + def set_status(self, status: int) -> None: + """Sets the HTTP status code of the response. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.set_status(status) + + @remap_wit_errors(MAPPINGS) + def set_framing_headers_mode(self, mode: FramingHeadersMode) -> None: + """Sets how the framing headers `Content-Length` and `Transfer-Encoding` will be determined + when sending this response. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.set_framing_headers_mode(mode) + + @remap_wit_errors(MAPPINGS) + def set_http_keepalive_mode(self, mode: KeepaliveMode) -> None: + """Adjust the response's connection reuse mode. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.set_http_keepalive_mode(mode) + + def get_remote_ip_addr(self) -> IpAddress | None: + """Gets the destination IP address used for this response, if known.""" + return self._wit_resource.get_remote_ip_addr() + + def get_remote_port(self) -> int | None: + """Gets the destination port used for this response, if known.""" + return self._wit_resource.get_remote_port() + + +@remap_wit_errors(MAPPINGS) +def send_downstream(response: Response, body: Pollable) -> None: + """Sends a response to the client that made the request passed to `http-incoming.handle`. + + This method returns as soon as the response header begins sending to the client, and + transmission of the response will continue in the background. + + Data for the body must be written before calling this function. To start a response + and write data to it afterwards, use `send_downstream_streaming` instead. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return _wit.send_downstream(response._wit_resource, body._wit_resource) + + +@remap_wit_errors(MAPPINGS) +def send_downstream_streaming(response: Response, body: Pollable) -> None: + """Starts a response to the client that made the request passed to `http-incoming.handle`. + + The body is left open, allowing data to be written after calling this function. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return _wit.send_downstream_streaming(response._wit_resource, body._wit_resource) + + +@remap_wit_errors(MAPPINGS) +def close(response: Response) -> None: + """Closes the `response`, releasing any associated resources. + + A `response` is consumed when you send a response to a client or stream one to a + client. You should call `close` only if you have a `response` you don't intend + to use anymore. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return _wit.close(response._wit_resource) diff --git a/fastly_compute/_bindings/http_types.py b/fastly_compute/_bindings/http_types.py new file mode 100644 index 0000000..c35e039 --- /dev/null +++ b/fastly_compute/_bindings/http_types.py @@ -0,0 +1,5 @@ +# This file is automatically generated by scripts/generate_bindings. +# Do not edit directly. +"""Types used by HTTP interfaces in this package.""" + +from __future__ import annotations diff --git a/fastly_compute/_bindings/image_optimizer.py b/fastly_compute/_bindings/image_optimizer.py new file mode 100644 index 0000000..2f0e551 --- /dev/null +++ b/fastly_compute/_bindings/image_optimizer.py @@ -0,0 +1,64 @@ +# This file is automatically generated by scripts/generate_bindings. +# Do not edit directly. +"""[Image Optimizer] API. + +[Image Optimizer]: https://www.fastly.com/documentation/guides/full-site-delivery/image-optimization/about-fastly-image-optimizer/ +""" + +from __future__ import annotations + +from wit_world.imports import image_optimizer as _wit + +from fastly_compute._bindings.async_io import Pollable +from fastly_compute._bindings.backend import Backend +from fastly_compute._bindings.http_req import Request +from fastly_compute._bindings.http_resp import Response +from fastly_compute._error_mapping import MAPPINGS, remap_wit_errors +from fastly_compute._resource import FastlyResource + +__all__ = [ + "ImageOptimizerTransformOptions", + "transform_image_optimizer_request", +] + + +class ImageOptimizerTransformOptions: + """ImageOptimizerTransformOptions.""" + + def __init__( + self, + sdk_claims_opts: str | None = None, + ) -> None: + """ + :param sdk_claims_opts: Contains any Image Optimizer API parameters that were set as well as the Image Optimizer region the request is meant for. + """ + self._wit = _wit.ImageOptimizerTransformOptions( + sdk_claims_opts=sdk_claims_opts, + extra=None, + ) + + +class ExtraImageOptimizerTransformOptions( + FastlyResource[_wit.ExtraImageOptimizerTransformOptions] +): + """Extensibility for `image-optimizer-transform-options`""" + + +@remap_wit_errors(MAPPINGS) +def transform_image_optimizer_request( + origin_image_request: Request, + origin_image_request_body: Pollable | None, + origin_image_request_backend: Backend, + io_transform_options: ImageOptimizerTransformOptions, +) -> tuple[Response, Pollable]: + """transform_image_optimizer_request. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + _r = _wit.transform_image_optimizer_request( + origin_image_request._wit_resource, + origin_image_request_body, + origin_image_request_backend._wit_resource, + io_transform_options._wit, + ) + return (Response(_r[0]), Pollable(_r[1])) diff --git a/fastly_compute/_bindings/kv_store.py b/fastly_compute/_bindings/kv_store.py new file mode 100644 index 0000000..2df5a3f --- /dev/null +++ b/fastly_compute/_bindings/kv_store.py @@ -0,0 +1,287 @@ +# This file is automatically generated by scripts/generate_bindings. +# Do not edit directly. +"""Interface to Fastly's [Compute KV Store]. + +For a high-level introduction to this feature, see this [blog post]. + +[Compute KV Store]: https://www.fastly.com/documentation/guides/concepts/edge-state/data-stores/#kv-stores +[blog post]: https://www.fastly.com/blog/introducing-the-compute-edge-kv-store-global-persistent-storage-for-compute-functions +""" + +from __future__ import annotations + +from typing import Self + +from wit_world.imports import kv_store as _wit +from wit_world.imports.kv_store import InsertMode, ListMode + +from fastly_compute._bindings.async_io import Pollable +from fastly_compute._error_mapping import MAPPINGS, remap_wit_errors +from fastly_compute._resource import FastlyResource + +__all__ = [ + "Entry", + "InsertMode", + "InsertOptions", + "ListMode", + "ListOptions", + "Store", + "await_delete", + "await_insert", + "await_list", + "await_lookup", +] + + +class InsertOptions: + """Options for configuring the behavior of the `insert` function.""" + + def __init__( + self, + background_fetch: bool = False, + if_generation_match: int | None = None, + metadata: str | None = None, + time_to_live_sec: int | None = None, + mode: InsertMode = InsertMode.OVERWRITE, + ) -> None: + """ + :param background_fetch: If set, allows fetching from the origin to occur in the background, enabling a faster response with stale content. The cache will be updated with fresh content after the request is completed. + :param if_generation_match: Requests for keys will return a “generation” header specific to the version of a key. The generation header is a unique, non-serial 64-bit unsigned integer that can be used for testing against a specific KV store value. + :param metadata: Sets an arbitrary data field which can contain up to 2000B of data. + :param time_to_live_sec: Sets a time for the key to expire. Deletion will take place up to 24 hours after the ttl reaches 0. + :param mode: Select the behavior in the case when the new key matches an existing key. + """ + self._wit = _wit.InsertOptions( + background_fetch=background_fetch, + if_generation_match=if_generation_match, + metadata=metadata, + time_to_live_sec=time_to_live_sec, + mode=mode, + extra=None, + ) + + +class ListOptions: + """Options for `list` and `list-async`.""" + + def __init__( + self, + mode: ListMode = ListMode.STRONG, + cursor: str | None = None, + limit: int | None = None, + prefix: str | None = None, + ) -> None: + """ + :param mode: The level of synchronization to perform. + :param cursor: The item to start the list at. + :param limit: The maximum number of items included the response. + :param prefix: The prefix match for items to include in the resultset. + """ + self._wit = _wit.ListOptions( + mode=mode, + cursor=cursor, + limit=limit, + prefix=prefix, + extra=None, + ) + + +class Store(FastlyResource[_wit.Store]): + """A KV Store.""" + + @classmethod + @remap_wit_errors(MAPPINGS) + def open(cls, name: str) -> Self: + """Opens the KV Store with the given name. + + :raises ~fastly_compute.exceptions.types.open_error.OpenError: + """ + return cls(_wit.Store.open(name)) + + @remap_wit_errors(MAPPINGS) + def lookup(self, key: str) -> Entry | None: + """Looks up a value in the KV Store. + + Returns `v` with the value `v` that was found, `None` if no value was + found, or `err(e)` indicating the error `e` occurred. + + This function waits until the operation completes. + + :raises ~fastly_compute.exceptions.kv_store.kv_error.KvError: + """ + return Entry(self._wit_resource.lookup(key)) + + @remap_wit_errors(MAPPINGS) + def lookup_async(self, key: str) -> Pollable: + """Look up a value in the KV Store asynchronously. + + This function initiates an async lookup of a value in the KV Store. Use + `await_lookup` to finish the lookup. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return Pollable(self._wit_resource.lookup_async(key)) + + @remap_wit_errors(MAPPINGS) + def insert(self, key: str, body: Pollable, options: InsertOptions) -> None: + """Inserts a value into the KV Store. + + If the KV Store already contains a value for this key, the `mode` field + of the `options` argument specifies how the existing value is handled. + + This function waits until the operation completes. + + :raises ~fastly_compute.exceptions.kv_store.kv_error.KvError: + """ + return self._wit_resource.insert(key, body._wit_resource, options._wit) + + @remap_wit_errors(MAPPINGS) + def insert_async( + self, key: str, body: Pollable, options: InsertOptions + ) -> Pollable: + """Insert a value into the KV Store asynchronously. + + If the KV Store already contains a value for this key, the `mode` field + of the `options` argument specifies how the existing value is handled. + + This function initiates an async insert of a value in the KV Store. Use + `await_insert` to finish the lookup. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return Pollable( + self._wit_resource.insert_async(key, body._wit_resource, options._wit) + ) + + @remap_wit_errors(MAPPINGS) + def delete(self, key: str) -> bool: + """Deletes a value in the KV Store. + + Returns `True` if a value was successfully deleted, `False` if no value was + found, or `err(e)` indicating the error `e` occurred. + + This function waits until the operation completes. + + :raises ~fastly_compute.exceptions.kv_store.kv_error.KvError: + """ + return self._wit_resource.delete(key) + + @remap_wit_errors(MAPPINGS) + def delete_async(self, key: str) -> Pollable: + """Delete of a value in the KV Store. + + This function initiates an async delete of a value in the KV Store. Use + `await_delete` to finish the lookup. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return Pollable(self._wit_resource.delete_async(key)) + + @remap_wit_errors(MAPPINGS) + def list(self, options: ListOptions) -> Pollable: + """Lists keys in the KV Store. + + Returns `b` with the body `b` on success, or `err(e)` indicating the error `e` + occurred. + + This function waits until the operation completes. + + :raises ~fastly_compute.exceptions.kv_store.kv_error.KvError: + """ + return Pollable(self._wit_resource.list(options._wit)) + + @remap_wit_errors(MAPPINGS) + def list_async(self, options: ListOptions) -> Pollable: + """List of keys in the KV Store. + + This function initiates an async list value in the KV Store. Use + `await_list` to finish the lookup. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return Pollable(self._wit_resource.list_async(options._wit)) + + +class ExtraKvError(FastlyResource[_wit.ExtraKvError]): + """Extensibility for `kv-error`""" + + +class Entry(FastlyResource[_wit.Entry]): + """A response from a KV Store Lookup operation. + + This type holds the `body`, metadata, and generation of found key. + """ + + def take_body(self) -> Pollable | None: + """Take and return the body from this `entry`, if it has one; otherwise return `None`. + + After calling this method, this entry will no longer have a body. + """ + return Pollable(self._wit_resource.take_body()) + + @remap_wit_errors(MAPPINGS) + def metadata(self, max_len: int) -> str | None: + """Read the metadata of the KV Store item, if present. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.metadata(max_len) + + def generation(self) -> int: + """Read the current generation of the KV Store item.""" + return self._wit_resource.generation() + + +class ExtraInsertOptions(FastlyResource[_wit.ExtraInsertOptions]): + """Extensibility for `insert-options`""" + + +class ExtraListOptions(FastlyResource[_wit.ExtraListOptions]): + """Extensibility for `list-options`""" + + +@remap_wit_errors(MAPPINGS) +def await_lookup(handle: Pollable) -> Entry | None: + """Wait on the async lookup of a value in the KV Store. + + Returns `v` with the value `v` that was found, `None` if no value was + found, or `err(e)` indicating the error `e` occurred. + + :raises ~fastly_compute.exceptions.kv_store.kv_error.KvError: + """ + return Entry(_wit.await_lookup(handle._wit_resource)) + + +@remap_wit_errors(MAPPINGS) +def await_insert(handle: Pollable) -> None: + """Wait on the async insert of a value in the KV Store. + + Returns `ok` if the `insert` succeeded, or an error code on failure. + + :raises ~fastly_compute.exceptions.kv_store.kv_error.KvError: + """ + return _wit.await_insert(handle._wit_resource) + + +@remap_wit_errors(MAPPINGS) +def await_delete(handle: Pollable) -> bool: + """Wait on the async delete of a value in the KV Store. + + Returns `True` if a value was successfully deleted, `False` if no value was + found, or `err(e)` indicating the error `e` occurred. + + :raises ~fastly_compute.exceptions.kv_store.kv_error.KvError: + """ + return _wit.await_delete(handle._wit_resource) + + +@remap_wit_errors(MAPPINGS) +def await_list(handle: Pollable) -> Pollable: + """Wait on the async list of keys in the KV Store. + + Returns `b` with the JSON-encoded body `b` on success, or `err(e)` indicating + the error `e` occurred. + + :raises ~fastly_compute.exceptions.kv_store.kv_error.KvError: + """ + return Pollable(_wit.await_list(handle._wit_resource)) diff --git a/fastly_compute/_bindings/log.py b/fastly_compute/_bindings/log.py new file mode 100644 index 0000000..676bd71 --- /dev/null +++ b/fastly_compute/_bindings/log.py @@ -0,0 +1,49 @@ +# This file is automatically generated by scripts/generate_bindings. +# Do not edit directly. +"""Low-level interface to Fastly's [Real-Time Log Streaming] endpoints. + +[Real-Time Log Streaming]: https://docs.fastly.com/en/guides/about-fastlys-realtime-log-streaming-features +""" + +from __future__ import annotations + +from typing import Self + +from wit_world.imports import log as _wit + +from fastly_compute._error_mapping import MAPPINGS, remap_wit_errors +from fastly_compute._resource import FastlyResource + +__all__ = [ + "Endpoint", +] + + +class Endpoint(FastlyResource[_wit.Endpoint]): + """A logging endpoint.""" + + @classmethod + @remap_wit_errors(MAPPINGS) + def open(cls, name: str) -> Self: + r"""Tries to get an endpoint by name. + + Currently, the conditions on an endpoint name are: + - It must not be empty. + - It must not contain newlines (`\n`) or colons (`:`). + - It must not be `stdout` or `stderr`, which are reserved for debugging. + + Names are case sensitive. Calling `get_endpoint` with a name that doesn't correspond to any + logging endpoint available in your service will still return a usable endpoint, and writes + to that endpoint will succeed. Refer to your service dashboard to diagnose missing log + events. + + :raises ~fastly_compute.exceptions.types.open_error.OpenError: + """ + return cls(_wit.Endpoint.open(name)) + + def write(self, msg: bytes) -> None: + """Writes a data to the given endpoint. + + Each call to `write` with a non-empty message produces a single log event. + """ + return self._wit_resource.write(msg) diff --git a/fastly_compute/_bindings/purge.py b/fastly_compute/_bindings/purge.py new file mode 100644 index 0000000..f97302e --- /dev/null +++ b/fastly_compute/_bindings/purge.py @@ -0,0 +1,69 @@ +# This file is automatically generated by scripts/generate_bindings. +# Do not edit directly. +"""[Cache Purging] API. + +[Cache Purging]: https://www.fastly.com/documentation/guides/concepts/edge-state/cache/purging/ +""" + +from __future__ import annotations + +from wit_world.imports import purge as _wit + +from fastly_compute._error_mapping import MAPPINGS, remap_wit_errors +from fastly_compute._resource import FastlyResource + +__all__ = [ + "PurgeOptions", + "purge_surrogate_key", + "purge_surrogate_key_verbose", +] + + +class PurgeOptions: + """PurgeOptions.""" + + def __init__( + self, + soft_purge: bool = False, + ) -> None: + """ + :param soft_purge: Perform a [soft purge] instead of a hard purge. [soft purge]: https://www.fastly.com/documentation/guides/concepts/edge-state/cache/purging/#soft-vs-hard-purging + """ + self._wit = _wit.PurgeOptions( + soft_purge=soft_purge, + extra=None, + ) + + +class ExtraPurgeOptions(FastlyResource[_wit.ExtraPurgeOptions]): + """Extensibility for `purge-options`""" + + +@remap_wit_errors(MAPPINGS) +def purge_surrogate_key(surrogate_keys: str, purge_options: PurgeOptions) -> None: + """Purge a surrogate key for the current service. + + A surrogate key can be a max of 1024 characters. + A surrogate key must contain only printable ASCII characters (those between `0x21` and `0x7E`, + inclusive). + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return _wit.purge_surrogate_key(surrogate_keys, purge_options._wit) + + +@remap_wit_errors(MAPPINGS) +def purge_surrogate_key_verbose( + surrogate_keys: str, purge_options: PurgeOptions, max_len: int +) -> str: + """Purge a surrogate key for the current service, and return the purge id. + + This is similar to `purge_surrogate_key`, but on success, returns a + [JSON purge response] containing an ASCII alphanumeric string identifying + a purging. + + [JSON purge response]: https://developer.fastly.com/reference/api/purging/#purge-tag + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return _wit.purge_surrogate_key_verbose(surrogate_keys, purge_options._wit, max_len) diff --git a/fastly_compute/_bindings/secret_store.py b/fastly_compute/_bindings/secret_store.py new file mode 100644 index 0000000..e4bea2c --- /dev/null +++ b/fastly_compute/_bindings/secret_store.py @@ -0,0 +1,77 @@ +# This file is automatically generated by scripts/generate_bindings. +# Do not edit directly. +"""[Secret Store] API. + +[Secret Store]: https://www.fastly.com/documentation/reference/api/services/resources/secret-store/ +""" + +from __future__ import annotations + +from typing import Self + +from wit_world.imports import secret_store as _wit + +from fastly_compute._error_mapping import MAPPINGS, remap_wit_errors +from fastly_compute._resource import FastlyResource + +__all__ = [ + "Secret", + "Store", +] + + +class Secret(FastlyResource[_wit.Secret]): + """An individual secret.""" + + @classmethod + @remap_wit_errors(MAPPINGS) + def from_bytes(cls, bytes: bytes) -> Self: + """Creates a new “secret” from the given memory. + + This is *not* the suggested way to create `secret`s; instead, we suggest using `get`. + This secret will *NOT* be shared with other sandboxes. + + This method can be used for data that should be secret, but is being obtained by + some other means than the secret store. New “secrets” created this way use plaintext + only, and live in the sandbox's memory unencrypted for much longer than secrets + generated by `get`. They should thus only be used in situations in which an API requires + a `secret`, but you cannot (for whatever reason) use a `store` to store them. + + As the early note says, this `secret` will be local to the current sandbox, and + will not be shared with other instances of this service. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return cls(_wit.Secret.from_bytes(bytes)) + + @remap_wit_errors(MAPPINGS) + def plaintext(self, max_len: int) -> bytes: + """Returns the plaintext value of this secret. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return self._wit_resource.plaintext(max_len) + + +class Store(FastlyResource[_wit.Store]): + """A Secret Store.""" + + @classmethod + @remap_wit_errors(MAPPINGS) + def open(cls, name: str) -> Self: + """Opens the Secret Store with the given name. + + :raises ~fastly_compute.exceptions.types.open_error.OpenError: + """ + return cls(_wit.Store.open(name)) + + @remap_wit_errors(MAPPINGS) + def get(self, key: str) -> Secret | None: + """Tries to look up a Secret by name in this secret store. + + If successful, this method returns `s` containing the found secret `s` if the + secret is found, or `None` if the secret was not found. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return Secret(self._wit_resource.get(key)) diff --git a/fastly_compute/_bindings/security.py b/fastly_compute/_bindings/security.py new file mode 100644 index 0000000..a30617c --- /dev/null +++ b/fastly_compute/_bindings/security.py @@ -0,0 +1,60 @@ +# This file is automatically generated by scripts/generate_bindings. +# Do not edit directly. +"""[Fastly Next-Gen WAF] API. + +[Fastly Next-Gen WAF]: https://docs.fastly.com/en/ngwaf/ +""" + +from __future__ import annotations + +from wit_world.imports import security as _wit +from wit_world.imports.types import IpAddress + +from fastly_compute._bindings.async_io import Pollable +from fastly_compute._bindings.http_req import Request +from fastly_compute._error_mapping import MAPPINGS, remap_wit_errors +from fastly_compute._resource import FastlyResource + +__all__ = [ + "InspectOptions", + "IpAddress", + "inspect", +] + + +class InspectOptions: + """Configuration for inspecting a `request` using Security.""" + + def __init__( + self, + corp: str | None = None, + workspace: str | None = None, + override_client_ip: IpAddress | None = None, + ) -> None: + self._wit = _wit.InspectOptions( + corp=corp, + workspace=workspace, + override_client_ip=override_client_ip, + extra=None, + ) + + +class ExtraInspectOptions(FastlyResource[_wit.ExtraInspectOptions]): + """Extensibility for `inspect-options`""" + + +@remap_wit_errors(MAPPINGS) +def inspect( + request: Request, body: Pollable, options: InspectOptions, max_len: int +) -> str: + """Inspects request HTTP traffic using the [NGWAF] lookaside service. + + Returns a JSON-encoded string. + + [NGWAF]: https://docs.fastly.com/en/ngwaf/ + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return _wit.inspect( + request._wit_resource, body._wit_resource, options._wit, max_len + ) diff --git a/fastly_compute/_bindings/shielding.py b/fastly_compute/_bindings/shielding.py new file mode 100644 index 0000000..32b0fad --- /dev/null +++ b/fastly_compute/_bindings/shielding.py @@ -0,0 +1,57 @@ +# This file is automatically generated by scripts/generate_bindings. +# Do not edit directly. +"""[Shielding] API. + +[Shielding]: https://www.fastly.com/documentation/guides/concepts/shielding/ +""" + +from __future__ import annotations + +from typing import Self + +from wit_world.imports import shielding as _wit + +from fastly_compute._bindings.backend import Backend +from fastly_compute._error_mapping import MAPPINGS, remap_wit_errors +from fastly_compute._resource import FastlyResource + +__all__ = [ + "ShieldBackendOptions", + "backend_for_shield", + "shield_info", +] + + +class ShieldBackendOptions(FastlyResource[_wit.ShieldBackendOptions]): + """Options for `backend-for-shield`.""" + + @classmethod + def new(cls) -> Self: + """Construct a new instance with default values.""" + return cls(_wit.ShieldBackendOptions()) + + def set_cache_key(self, cache_key: str) -> None: + """set_cache_key.""" + return self._wit_resource.set_cache_key(cache_key) + + def set_first_byte_timeout(self, timeout_ms: int) -> None: + """set_first_byte_timeout.""" + return self._wit_resource.set_first_byte_timeout(timeout_ms) + + +@remap_wit_errors(MAPPINGS) +def shield_info(name: str, max_len: int) -> str: + """shield_info. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return _wit.shield_info(name, max_len) + + +@remap_wit_errors(MAPPINGS) +def backend_for_shield(name: str, options: ShieldBackendOptions | None) -> Backend: + """backend_for_shield. + + :raises ~fastly_compute.exceptions.types.error.Error: + """ + return Backend(_wit.backend_for_shield(name, options)) diff --git a/fastly_compute/_bindings/types.py b/fastly_compute/_bindings/types.py new file mode 100644 index 0000000..fbf00d1 --- /dev/null +++ b/fastly_compute/_bindings/types.py @@ -0,0 +1,5 @@ +# This file is automatically generated by scripts/generate_bindings. +# Do not edit directly. +"""Types used by many interfaces in this package.""" + +from __future__ import annotations diff --git a/fastly_compute/_error_mapping.py b/fastly_compute/_error_mapping.py new file mode 100644 index 0000000..12ebcb5 --- /dev/null +++ b/fastly_compute/_error_mapping.py @@ -0,0 +1,116 @@ +"""Decorator and error mappings for remapping WIT Err values to SDK exceptions. + +This module is part of the generated binding layer and is only imported +when running inside the Wasm sandbox or under Viceroy, where wit_world and +componentize_py_types are available. +""" + +from collections.abc import Callable, Mapping +from enum import Enum +from functools import wraps +from typing import Any + +import wit_world.imports.acl +import wit_world.imports.http_body +import wit_world.imports.http_req +import wit_world.imports.kv_store +import wit_world.imports.types +from componentize_py_types import Err + +import fastly_compute.exceptions.acl.acl_error +import fastly_compute.exceptions.http_body.trailer_error +import fastly_compute.exceptions.http_req +import fastly_compute.exceptions.kv_store.kv_error +import fastly_compute.exceptions.types.error +import fastly_compute.exceptions.types.open_error +from fastly_compute.exceptions import FastlyError, UnexpectedFastlyError + + +def remap_wit_errors( + idiomatic_exceptions: Mapping[Any, type[FastlyError]] | None = None, +) -> Callable: + """Raise more idiomatic exceptions from a function that returns a WIT ``result``. + + A ``result``'s error case is always wrapped in a generic ``Err`` exception by + componentize-py. This decorator converts that to a more descriptive exception + that can be selectively caught. + + :arg idiomatic_exceptions: A map of the types of WIT-level ``Err.value``s to + more informative exception classes. These classes receive the ``Err.value`` + as a constructor argument. If the value's type is not found in the map the + error is wrapped in ``UnexpectedFastlyError``. + + Enum members may also be used as mapping keys. Exceptions raised from those + receive no constructor arguments, since enum member values are generated and + carry no user-meaningful information. + + Goals: Be idiomatic. Be reasonably efficient. Be readable as documentation. + In that order. + """ + # Someday, if we need more flexibility than class-by-class mapping, we can + # take a fallback callable that can do further thinking. Also, only the type + # signature keeps you from passing along an arbitrary callable that can + # emit, say, different exception classes for even and odd ints. + if idiomatic_exceptions is None: + idiomatic_exceptions = {} + + def decorator(func: Callable) -> Callable: + @wraps(func) + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except Err as e: + error_value = e.value + # Look up ordinary instances by class but enum fields by value + # so we can easily give each enum member its own exception class: + if isinstance(error_value, Enum): + key = error_value + exc_args = () + else: + key = type(error_value) + exc_args = (error_value,) + idiomatic_exception = idiomatic_exceptions.get( + key, UnexpectedFastlyError + ) + raise idiomatic_exception(*exc_args) from e + + return wrapper + + return decorator + + +# MAPPINGS is generated by scripts/generate_bindings -- do not edit +MAPPINGS: Mapping[Any, type[FastlyError]] = { + wit_world.imports.acl.AclError.GENERIC_ERROR: fastly_compute.exceptions.acl.acl_error.GenericError, + wit_world.imports.acl.AclError.TOO_MANY_REQUESTS: fastly_compute.exceptions.acl.acl_error.TooManyRequests, + wit_world.imports.http_body.TrailerError_Error: fastly_compute.exceptions.http_body.trailer_error.Error, + wit_world.imports.http_body.TrailerError_NotAvailableYet: fastly_compute.exceptions.http_body.trailer_error.NotAvailableYet, + wit_world.imports.http_req.ErrorWithDetail: fastly_compute.exceptions.http_req.ErrorWithDetail, + wit_world.imports.kv_store.KvError_BadRequest: fastly_compute.exceptions.kv_store.kv_error.BadRequest, + wit_world.imports.kv_store.KvError_Extra: fastly_compute.exceptions.kv_store.kv_error.Extra, + wit_world.imports.kv_store.KvError_GenericError: fastly_compute.exceptions.kv_store.kv_error.GenericError, + wit_world.imports.kv_store.KvError_InternalError: fastly_compute.exceptions.kv_store.kv_error.InternalError, + wit_world.imports.kv_store.KvError_PayloadTooLarge: fastly_compute.exceptions.kv_store.kv_error.PayloadTooLarge, + wit_world.imports.kv_store.KvError_PreconditionFailed: fastly_compute.exceptions.kv_store.kv_error.PreconditionFailed, + wit_world.imports.kv_store.KvError_TooManyRequests: fastly_compute.exceptions.kv_store.kv_error.TooManyRequests, + wit_world.imports.types.Error_AuxiliaryError: fastly_compute.exceptions.types.error.AuxiliaryError, + wit_world.imports.types.Error_BufferLen: fastly_compute.exceptions.types.error.BufferLen, + wit_world.imports.types.Error_CannotRead: fastly_compute.exceptions.types.error.CannotRead, + wit_world.imports.types.Error_GenericError: fastly_compute.exceptions.types.error.GenericError, + wit_world.imports.types.Error_HttpHeadTooLarge: fastly_compute.exceptions.types.error.HttpHeadTooLarge, + wit_world.imports.types.Error_HttpIncomplete: fastly_compute.exceptions.types.error.HttpIncomplete, + wit_world.imports.types.Error_HttpInvalid: fastly_compute.exceptions.types.error.HttpInvalid, + wit_world.imports.types.Error_HttpInvalidStatus: fastly_compute.exceptions.types.error.HttpInvalidStatus, + wit_world.imports.types.Error_HttpUser: fastly_compute.exceptions.types.error.HttpUser, + wit_world.imports.types.Error_InvalidArgument: fastly_compute.exceptions.types.error.InvalidArgument, + wit_world.imports.types.Error_LimitExceeded: fastly_compute.exceptions.types.error.LimitExceeded, + wit_world.imports.types.Error_Unsupported: fastly_compute.exceptions.types.error.Unsupported, + wit_world.imports.types.OpenError.GENERIC_ERROR: fastly_compute.exceptions.types.open_error.GenericError, + wit_world.imports.types.OpenError.INVALID_SYNTAX: fastly_compute.exceptions.types.open_error.InvalidSyntax, + wit_world.imports.types.OpenError.LIMIT_EXCEEDED: fastly_compute.exceptions.types.open_error.LimitExceeded, + wit_world.imports.types.OpenError.NAME_TOO_LONG: fastly_compute.exceptions.types.open_error.NameTooLong, + wit_world.imports.types.OpenError.NOT_FOUND: fastly_compute.exceptions.types.open_error.NotFound, + wit_world.imports.types.OpenError.RESERVED: fastly_compute.exceptions.types.open_error.Reserved, + wit_world.imports.types.OpenError.UNSUPPORTED: fastly_compute.exceptions.types.open_error.Unsupported, + type(None): fastly_compute.exceptions.FastlyError, +} diff --git a/fastly_compute/acl.py b/fastly_compute/acl.py new file mode 100644 index 0000000..e38fb3a --- /dev/null +++ b/fastly_compute/acl.py @@ -0,0 +1,3 @@ +"""ACL API for Fastly Compute.""" + +from fastly_compute._bindings.acl import * # noqa: F401, F403 diff --git a/fastly_compute/async_io.py b/fastly_compute/async_io.py new file mode 100644 index 0000000..2a0352d --- /dev/null +++ b/fastly_compute/async_io.py @@ -0,0 +1,3 @@ +"""Async I/O API for Fastly Compute.""" + +from fastly_compute._bindings.async_io import * # noqa: F401, F403 diff --git a/fastly_compute/backend.py b/fastly_compute/backend.py new file mode 100644 index 0000000..48ab28e --- /dev/null +++ b/fastly_compute/backend.py @@ -0,0 +1,3 @@ +"""Backend API for Fastly Compute.""" + +from fastly_compute._bindings.backend import * # noqa: F401, F403 diff --git a/fastly_compute/cache.py b/fastly_compute/cache.py new file mode 100644 index 0000000..a9c3e26 --- /dev/null +++ b/fastly_compute/cache.py @@ -0,0 +1,3 @@ +"""Core Cache API for Fastly Compute.""" + +from fastly_compute._bindings.cache import * # noqa: F401, F403 diff --git a/fastly_compute/compute_runtime.py b/fastly_compute/compute_runtime.py new file mode 100644 index 0000000..b7ea962 --- /dev/null +++ b/fastly_compute/compute_runtime.py @@ -0,0 +1,3 @@ +"""Compute Runtime API for Fastly Compute.""" + +from fastly_compute._bindings.compute_runtime import * # noqa: F401, F403 diff --git a/fastly_compute/config_store.py b/fastly_compute/config_store.py index f6ee354..a80de18 100644 --- a/fastly_compute/config_store.py +++ b/fastly_compute/config_store.py @@ -11,11 +11,7 @@ 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 - -from ._resource import FastlyResource +from fastly_compute._bindings.config_store import Store as _Store # 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 @@ -23,7 +19,7 @@ _MAX_U32 = 0xFFFFFFFF -class ConfigStore(FastlyResource[wit_config_store.Store]): +class ConfigStore(_Store): """Interface to Fastly Config Store. Config Stores provide read-only access to configuration data that can be @@ -35,23 +31,6 @@ class ConfigStore(FastlyResource[wit_config_store.Store]): api_url = config.get("api_url", "https://api.example.com") """ - @classmethod - def open(cls, name: str) -> Self: - """Open a config store by name. - - :arg name: The name of the config store - :return: ConfigStore instance - :raise ~fastly_compute.exceptions.types.open_error.NotFound: If the config store doesn't exist - :raise ~fastly_compute.exceptions.types.open_error.InvalidSyntax: If the name is invalid - :raise ~fastly_compute.exceptions.types.open_error.NameTooLong: If the name is 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) -> str | None: """Get a configuration value. @@ -65,10 +44,9 @@ def get(self, key: str, default: str | None = None) -> str | None: config = ConfigStore.open("app-config") api_url = config.get("api_url", "https://api.example.com") """ - result = self._wit_resource.get(key, _MAX_U32) + result = super().get(key, _MAX_U32) if result is None: result = default - return result def __contains__(self, key: str) -> bool: diff --git a/fastly_compute/device_detection.py b/fastly_compute/device_detection.py new file mode 100644 index 0000000..af89aaf --- /dev/null +++ b/fastly_compute/device_detection.py @@ -0,0 +1,3 @@ +"""Device Detection API for Fastly Compute.""" + +from fastly_compute._bindings.device_detection import * # noqa: F401, F403 diff --git a/fastly_compute/dictionary.py b/fastly_compute/dictionary.py new file mode 100644 index 0000000..826d057 --- /dev/null +++ b/fastly_compute/dictionary.py @@ -0,0 +1,3 @@ +"""Dictionary API for Fastly Compute.""" + +from fastly_compute._bindings.dictionary import * # noqa: F401, F403 diff --git a/fastly_compute/erl.py b/fastly_compute/erl.py new file mode 100644 index 0000000..eb64a0b --- /dev/null +++ b/fastly_compute/erl.py @@ -0,0 +1,157 @@ +"""Edge Rate Limiting API for Fastly Compute + +This module provides access to Fastly's Edge Rate Limiting (ERL) feature, +which allows you to count requests and enforce rate limits at the edge. + +For more information about Edge Rate Limiting, see the +`Fastly ERL documentation `_. + +Example:: + + from fastly_compute.erl import RateCounter, PenaltyBox + + # Basic rate limiting + with RateCounter.open("api-counter") as counter: + with PenaltyBox.open("api-penalty") as penalty: + is_limited = counter.check_rate( + entry="192.168.1.1", + delta=1, + window=10, + limit=100, + penalty_box=penalty, + ttl=300 + ) + if is_limited: + # Client exceeded rate limit + return Response("Rate limited", status=429) + + # Standalone usage + with RateCounter.open("tracker") as counter: + counter.increment("client-ip", delta=1) + current_rate = counter.lookup_rate("client-ip", window=60) + + with PenaltyBox.open("blocklist") as penalty: + penalty.add("abusive-ip", ttl=600) + if "abusive-ip" in penalty: + return Response("Blocked", status=403) +""" + +from __future__ import annotations + +from fastly_compute._bindings.erl import PenaltyBox as _PenaltyBox +from fastly_compute._bindings.erl import RateCounter as _RateCounter + + +class RateCounter(_RateCounter): + """Interface to Fastly Edge Rate Limiter counter. + + Rate counters track request counts and calculate rates for rate limiting + decisions. + + Example:: + + with RateCounter.open("api-counter") as counter: + counter.increment("192.168.1.1", delta=1) + rate = counter.lookup_rate("192.168.1.1", window=60) + """ + + +class PenaltyBox(_PenaltyBox): + """Interface to Fastly Edge Rate Limiter penalty box. + + Penalty boxes maintain a set of blocked entries (e.g., IP addresses). + + Example:: + + with PenaltyBox.open("blocklist") as penalty: + penalty.add("192.168.1.1", ttl=600) + if "192.168.1.1" in penalty: + return Response("Blocked", status=403) + """ + + def __contains__(self, entry: str) -> bool: + """Check if entry is in the penalty box using the ``in`` operator. + + :param entry: Identifier to check + :return: True if the entry is blocked, False otherwise + :raises ~fastly_compute.exceptions.FastlyError: on invalid inputs or other error conditions. + + Example:: + + with PenaltyBox.open("blocklist") as penalty: + if "192.168.1.1" in penalty: + return Response("Blocked", status=403) + """ + return self.has(entry) + + +class EdgeRateLimiter: + """Convenience wrapper for edge rate limiting. + + Combines a :class:`RateCounter` and :class:`PenaltyBox` into a single + interface for simplified rate limiting operations. + + :param rate_counter: Rate counter to use for counting + :param penalty_box: Penalty box to use for blocking + + Example:: + + counter = RateCounter.open("api-counter") + penalty = PenaltyBox.open("api-penalty") + erl = EdgeRateLimiter(counter, penalty) + + is_limited = erl.check_rate( + entry="192.168.1.1", + delta=1, + window=10, + limit=100, + ttl=300 + ) + """ + + def __init__(self, rate_counter: RateCounter, penalty_box: PenaltyBox): + """Create an EdgeRateLimiter with a rate counter and penalty box. + + :param rate_counter: Rate counter to use for counting + :param penalty_box: Penalty box to use for blocking + """ + self._rate_counter = rate_counter + self._penalty_box = penalty_box + + def check_rate( + self, entry: str, delta: int, window: int, limit: int, ttl: int + ) -> bool: + """Check if entry exceeds rate limit and penalize if necessary. + + Increments the counter for the entry and checks if the average requests + per second (RPS) over the specified window exceeds the limit. If the + limit is exceeded, the entry is added to the penalty box for the + specified time-to-live. + + :param entry: Identifier for the client (e.g., IP address) + :param delta: Amount to increment the counter by + :param window: Time window in seconds for rate calculation + :param limit: Maximum requests per second allowed + :param ttl: Time-to-live in seconds for penalty box entry. The host validates + this parameter and rounds to the nearest minute; consult Fastly + documentation for valid range. + :return: True if the entry is rate limited, False otherwise + :raises ~fastly_compute.exceptions.FastlyError + + Example:: + + counter = RateCounter.open("api-counter") + penalty = PenaltyBox.open("api-penalty") + erl = EdgeRateLimiter(counter, penalty) + + is_limited = erl.check_rate( + entry="192.168.1.1", + delta=1, + window=10, + limit=100, + ttl=300 + ) + """ + return self._rate_counter.check_rate( + entry, delta, window, limit, self._penalty_box, ttl + ) diff --git a/fastly_compute/exceptions/acl/acl_error.py b/fastly_compute/exceptions/acl/acl_error.py index 1d6a73b..8b9c85a 100644 --- a/fastly_compute/exceptions/acl/acl_error.py +++ b/fastly_compute/exceptions/acl/acl_error.py @@ -1,4 +1,4 @@ -# This file is automatically generated by generate_patches. +# This file is automatically generated by scripts/generate_exceptions. # It is not intended for manual editing. """Errors returned on ACL lookup failure.""" diff --git a/fastly_compute/exceptions/http_body/trailer_error.py b/fastly_compute/exceptions/http_body/trailer_error.py index 7fb4227..0c13f7a 100644 --- a/fastly_compute/exceptions/http_body/trailer_error.py +++ b/fastly_compute/exceptions/http_body/trailer_error.py @@ -1,4 +1,4 @@ -# This file is automatically generated by generate_patches. +# This file is automatically generated by scripts/generate_exceptions. # It is not intended for manual editing. """Trailers aren't available until the body has been completely transmitted, so this error type can either indicate that the errors aren't available yet, or that an error occurred. diff --git a/fastly_compute/exceptions/http_req/__init__.py b/fastly_compute/exceptions/http_req/__init__.py index eeab097..c19002d 100644 --- a/fastly_compute/exceptions/http_req/__init__.py +++ b/fastly_compute/exceptions/http_req/__init__.py @@ -1,4 +1,4 @@ -# This file is automatically generated by generate_patches. +# This file is automatically generated by scripts/generate_exceptions. # It is not intended for manual editing. """An `error` code, optionally with extra request error information.""" diff --git a/fastly_compute/exceptions/kv_store/kv_error.py b/fastly_compute/exceptions/kv_store/kv_error.py index 3adf7ff..9535115 100644 --- a/fastly_compute/exceptions/kv_store/kv_error.py +++ b/fastly_compute/exceptions/kv_store/kv_error.py @@ -1,4 +1,4 @@ -# This file is automatically generated by generate_patches. +# This file is automatically generated by scripts/generate_exceptions. # It is not intended for manual editing. """A value indicating the status of a KV store operation.""" diff --git a/fastly_compute/exceptions/types/error.py b/fastly_compute/exceptions/types/error.py index d31ee0b..54fa35c 100644 --- a/fastly_compute/exceptions/types/error.py +++ b/fastly_compute/exceptions/types/error.py @@ -1,4 +1,4 @@ -# This file is automatically generated by generate_patches. +# This file is automatically generated by scripts/generate_exceptions. # It is not intended for manual editing. """A common error type used by many functions in this package. diff --git a/fastly_compute/exceptions/types/open_error.py b/fastly_compute/exceptions/types/open_error.py index 5b8acfa..03d5336 100644 --- a/fastly_compute/exceptions/types/open_error.py +++ b/fastly_compute/exceptions/types/open_error.py @@ -1,4 +1,4 @@ -# This file is automatically generated by generate_patches. +# This file is automatically generated by scripts/generate_exceptions. # It is not intended for manual editing. """An error returned by `open`-like functions.""" diff --git a/fastly_compute/geo.py b/fastly_compute/geo.py new file mode 100644 index 0000000..eaaac47 --- /dev/null +++ b/fastly_compute/geo.py @@ -0,0 +1,3 @@ +"""Geo Lookup API for Fastly Compute.""" + +from fastly_compute._bindings.geo import * # noqa: F401, F403 diff --git a/fastly_compute/http_cache.py b/fastly_compute/http_cache.py new file mode 100644 index 0000000..49b00f4 --- /dev/null +++ b/fastly_compute/http_cache.py @@ -0,0 +1,3 @@ +"""HTTP Cache API for Fastly Compute.""" + +from fastly_compute._bindings.http_cache import * # noqa: F401, F403 diff --git a/fastly_compute/http_downstream.py b/fastly_compute/http_downstream.py new file mode 100644 index 0000000..d0b5646 --- /dev/null +++ b/fastly_compute/http_downstream.py @@ -0,0 +1,3 @@ +"""HTTP Downstream API for Fastly Compute.""" + +from fastly_compute._bindings.http_downstream import * # noqa: F401, F403 diff --git a/fastly_compute/http_req.py b/fastly_compute/http_req.py new file mode 100644 index 0000000..aca5035 --- /dev/null +++ b/fastly_compute/http_req.py @@ -0,0 +1,3 @@ +"""HTTP Request API for Fastly Compute.""" + +from fastly_compute._bindings.http_req import * # noqa: F401, F403 diff --git a/fastly_compute/http_resp.py b/fastly_compute/http_resp.py new file mode 100644 index 0000000..07677f0 --- /dev/null +++ b/fastly_compute/http_resp.py @@ -0,0 +1,3 @@ +"""HTTP Response API for Fastly Compute.""" + +from fastly_compute._bindings.http_resp import * # noqa: F401, F403 diff --git a/fastly_compute/image_optimizer.py b/fastly_compute/image_optimizer.py new file mode 100644 index 0000000..cfd8245 --- /dev/null +++ b/fastly_compute/image_optimizer.py @@ -0,0 +1,3 @@ +"""Image Optimizer API for Fastly Compute.""" + +from fastly_compute._bindings.image_optimizer import * # noqa: F401, F403 diff --git a/fastly_compute/kv_store.py b/fastly_compute/kv_store.py new file mode 100644 index 0000000..2d73179 --- /dev/null +++ b/fastly_compute/kv_store.py @@ -0,0 +1,3 @@ +"""KV Store API for Fastly Compute.""" + +from fastly_compute._bindings.kv_store import * # noqa: F401, F403 diff --git a/fastly_compute/log.py b/fastly_compute/log.py index f6f34f7..481d6c8 100644 --- a/fastly_compute/log.py +++ b/fastly_compute/log.py @@ -24,14 +24,11 @@ import logging from collections.abc import Callable -from typing import Self -from wit_world.imports import log as wit_log +from fastly_compute._bindings.log import Endpoint as _Endpoint -from ._resource import FastlyResource - -class LogEndpoint(FastlyResource[wit_log.Endpoint]): +class LogEndpoint(_Endpoint): """Interface to a Fastly logging endpoint. Logging endpoints send log data to configured Real-Time Log Streaming @@ -44,33 +41,6 @@ class LogEndpoint(FastlyResource[wit_log.Endpoint]): endpoint.write(b"Binary log data") """ - @classmethod - def open(cls, name: str) -> Self: - r"""Open a logging endpoint by name. - - Names are case sensitive. Calling open() with a name that doesn't - correspond to any logging endpoint available in your service will - still return a usable endpoint, and writes to that endpoint will - succeed. Refer to your service dashboard to diagnose missing log events. - - Currently, the conditions on an endpoint name are: - - It must not be empty. - - It must not contain newlines (\n) or colons (:). - - It must not be `stdout` or `stderr`, which are reserved for debugging. - - :arg name: The name of the logging endpoint - :return: LogEndpoint instance - :raise ~fastly_compute.exceptions.types.open_error.NotFound: If the endpoint doesn't exist or can't be created. - :raise ~fastly_compute.exceptions.types.open_error.NameTooLong: If the name is too long. - :raise ~fastly_compute.exceptions.types.open_error.InvalidSyntax: If the name is invalid. - - Example:: - - endpoint = LogEndpoint.open("my_logs") - """ - endpoint = wit_log.Endpoint.open(name) - return cls(endpoint) - def write(self, msg: bytes | str) -> None: """Write data to the logging endpoint. diff --git a/fastly_compute/purge.py b/fastly_compute/purge.py new file mode 100644 index 0000000..40c7548 --- /dev/null +++ b/fastly_compute/purge.py @@ -0,0 +1,3 @@ +"""Purge API for Fastly Compute.""" + +from fastly_compute._bindings.purge import * # noqa: F401, F403 diff --git a/fastly_compute/requests/__init__.py b/fastly_compute/requests/__init__.py index 2498b93..ce1454b 100644 --- a/fastly_compute/requests/__init__.py +++ b/fastly_compute/requests/__init__.py @@ -41,8 +41,8 @@ import urllib.parse from typing import Any, TypedDict, Unpack -from wit_world.imports import http_body, http_req - +from fastly_compute import http_req +from fastly_compute._bindings import http_body from fastly_compute.exceptions.http_req import ErrorWithDetail from fastly_compute.exceptions.types.error import Error from fastly_compute.requests.backend import resolve_backend diff --git a/fastly_compute/requests/backend.py b/fastly_compute/requests/backend.py index b9df09f..d95dcb0 100644 --- a/fastly_compute/requests/backend.py +++ b/fastly_compute/requests/backend.py @@ -10,8 +10,10 @@ from dataclasses import dataclass from typing import TYPE_CHECKING -from wit_world.imports import backend as wit_backend - +from fastly_compute.backend import Backend, DynamicBackendOptions +from fastly_compute.backend import ( + register_dynamic_backend as _register_dynamic_backend_wit, +) from fastly_compute.exceptions.types.error import Error from fastly_compute.exceptions.types.open_error import OpenError @@ -29,7 +31,7 @@ class BackendResolution: """Result of a successful backend resolution.""" url_parsed: urllib.parse.ParseResult - backend: wit_backend.Backend + backend: Backend def resolve_backend( @@ -54,13 +56,13 @@ def resolve_backend( :raise MissingSchema: If URL is missing scheme (subclass of RequestException) """ parsed = urllib.parse.urlparse(url) - backend_obj: wit_backend.Backend + backend_obj: Backend # static backend if fastly_backend is not None: # Check if backend exists by trying to open it try: - backend_obj = wit_backend.Backend.open(fastly_backend) + backend_obj = Backend.open(fastly_backend) except OpenError as e: raise RequestException( f"Static backend '{fastly_backend}' does not exist" @@ -81,7 +83,7 @@ def resolve_backend( ) _dynamic_backends.add(backend_name) else: - backend_obj = wit_backend.Backend.open(backend_name) + backend_obj = Backend.open(backend_name) return BackendResolution(parsed, backend_obj) @@ -90,8 +92,8 @@ def _register_dynamic_backend( backend_name: str, parsed_url: urllib.parse.ParseResult, timeout_config: TimeoutConfig, -) -> wit_backend.Backend: - options = wit_backend.DynamicBackendOptions() +) -> Backend: + options = DynamicBackendOptions.new() # Configure TLS for HTTPS if parsed_url.scheme == "https": @@ -104,9 +106,9 @@ def _register_dynamic_backend( options.first_byte_timeout(timeout_config.first_byte_ms) options.between_bytes_timeout(timeout_config.between_bytes_ms) - # Register the backend try: - return wit_backend.register_dynamic_backend( + # Register the backend + return _register_dynamic_backend_wit( prefix=backend_name, target=parsed_url.netloc, options=options ) except Error as e: diff --git a/fastly_compute/requests/response.py b/fastly_compute/requests/response.py index 9f82874..7037dda 100644 --- a/fastly_compute/requests/response.py +++ b/fastly_compute/requests/response.py @@ -4,7 +4,7 @@ from http import HTTPStatus from typing import Any, override -from wit_world.imports import async_io, http_resp +from fastly_compute import async_io, http_resp from ..utils import create_body_reader from .exceptions import HTTPError diff --git a/fastly_compute/runtime_patching/__init__.py b/fastly_compute/runtime_patching/__init__.py deleted file mode 100644 index 9c163f6..0000000 --- a/fastly_compute/runtime_patching/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Monkeypatches (and supporting machinery) which make WIT behavior more Pythonic""" diff --git a/fastly_compute/runtime_patching/decorators.py b/fastly_compute/runtime_patching/decorators.py deleted file mode 100644 index a59b2c1..0000000 --- a/fastly_compute/runtime_patching/decorators.py +++ /dev/null @@ -1,65 +0,0 @@ -"""Decorators used in runtime patching""" - -from collections.abc import Callable, Mapping -from enum import Enum -from functools import wraps -from typing import Any - -from componentize_py_types import Err - -from fastly_compute.exceptions import FastlyError, UnexpectedFastlyError - - -def remap_wit_errors( - idiomatic_exceptions: Mapping[Any, type[FastlyError]] | None = None, -) -> Callable: - """Raise more idiomatic exceptions from a function that returns a WIT ``result``. - - A ``result``s error case is always wrapped in a generic ``Err`` exception by - componentize-py. Convert that to a more descriptive exception that can be - selectively caught. - - :arg idiomatic_exceptions: A map of the types of WIT-level ``Err.value``s to - more informative exception classes. These classes receive the - ``Err.value`` as a constructor argument. If the value's type is not - found in the map, wrap it in an ``UnexpectedFastlyError``. - - Enum members may also be used as mapping keys. Exceptions raised based - on these receive no constructor arguments, since the values of enum - members are generated and meaningless. - - Goals: Be idiomatic. Be reasonably efficient. Be readable as documentation. - In that order. - - """ - # Someday, if we need more flexibility than class-by-class mapping, we can - # take a fallback callable that can do further thinking. Also, only the type - # signature keeps you from passing along an arbitrary callable that can - # emit, say, different exception classes for even and odd ints. - if idiomatic_exceptions is None: - idiomatic_exceptions = {} - - def decorator(func: Callable) -> Callable: - @wraps(func) - def wrapper(*args, **kwargs): - try: - return func(*args, **kwargs) - except Err as e: - error_value = e.value - - # Look up ordinary instances by class but enum fields by value so - # we can easily give each enum member its own exception class: - if isinstance(error_value, Enum): - key = error_value - exc_args = () - else: - key = type(error_value) - exc_args = (error_value,) - idiomatic_exception = idiomatic_exceptions.get( - key, UnexpectedFastlyError - ) - raise idiomatic_exception(*exc_args) from e - - return wrapper - - return decorator diff --git a/fastly_compute/runtime_patching/patches.py b/fastly_compute/runtime_patching/patches.py deleted file mode 100644 index cce0296..0000000 --- a/fastly_compute/runtime_patching/patches.py +++ /dev/null @@ -1,276 +0,0 @@ -# This file is automatically generated by generate_patches. -# It is not intended for manual editing. -"""Monkeypatches which wrap the routines generated by componentize-py to make -them raise more specific exceptions, not just Err. -""" - -try: - import wit_world.imports.acl - import wit_world.imports.backend - import wit_world.imports.cache - import wit_world.imports.config_store - import wit_world.imports.device_detection - import wit_world.imports.dictionary - import wit_world.imports.erl - import wit_world.imports.geo - import wit_world.imports.http_body - import wit_world.imports.http_cache - import wit_world.imports.http_downstream - import wit_world.imports.http_req - import wit_world.imports.http_resp - import wit_world.imports.image_optimizer - import wit_world.imports.kv_store - import wit_world.imports.log - import wit_world.imports.purge - import wit_world.imports.secret_store - import wit_world.imports.security - import wit_world.imports.shielding - import wit_world.imports.types - - import fastly_compute.exceptions.acl.acl_error - import fastly_compute.exceptions.http_body.trailer_error - import fastly_compute.exceptions.http_req - import fastly_compute.exceptions.kv_store.kv_error - import fastly_compute.exceptions.types.error - import fastly_compute.exceptions.types.open_error - - from .decorators import remap_wit_errors -except ImportError: - # Tolerate that momentary import for the testrunner before Viceroy, and thus - # the wit_world, is around. - def patch(): - """Pretend to patch.""" - print("Faking the run of exception-mapping monkeypatches for test runner.") -else: - MAPPINGS = { - wit_world.imports.acl.AclError.GENERIC_ERROR: fastly_compute.exceptions.acl.acl_error.GenericError, - wit_world.imports.acl.AclError.TOO_MANY_REQUESTS: fastly_compute.exceptions.acl.acl_error.TooManyRequests, - wit_world.imports.http_body.TrailerError_Error: fastly_compute.exceptions.http_body.trailer_error.Error, - wit_world.imports.http_body.TrailerError_NotAvailableYet: fastly_compute.exceptions.http_body.trailer_error.NotAvailableYet, - wit_world.imports.http_req.ErrorWithDetail: fastly_compute.exceptions.http_req.ErrorWithDetail, - wit_world.imports.kv_store.KvError_BadRequest: fastly_compute.exceptions.kv_store.kv_error.BadRequest, - wit_world.imports.kv_store.KvError_Extra: fastly_compute.exceptions.kv_store.kv_error.Extra, - wit_world.imports.kv_store.KvError_GenericError: fastly_compute.exceptions.kv_store.kv_error.GenericError, - wit_world.imports.kv_store.KvError_InternalError: fastly_compute.exceptions.kv_store.kv_error.InternalError, - wit_world.imports.kv_store.KvError_PayloadTooLarge: fastly_compute.exceptions.kv_store.kv_error.PayloadTooLarge, - wit_world.imports.kv_store.KvError_PreconditionFailed: fastly_compute.exceptions.kv_store.kv_error.PreconditionFailed, - wit_world.imports.kv_store.KvError_TooManyRequests: fastly_compute.exceptions.kv_store.kv_error.TooManyRequests, - wit_world.imports.types.Error_AuxiliaryError: fastly_compute.exceptions.types.error.AuxiliaryError, - wit_world.imports.types.Error_BufferLen: fastly_compute.exceptions.types.error.BufferLen, - wit_world.imports.types.Error_CannotRead: fastly_compute.exceptions.types.error.CannotRead, - wit_world.imports.types.Error_GenericError: fastly_compute.exceptions.types.error.GenericError, - wit_world.imports.types.Error_HttpHeadTooLarge: fastly_compute.exceptions.types.error.HttpHeadTooLarge, - wit_world.imports.types.Error_HttpIncomplete: fastly_compute.exceptions.types.error.HttpIncomplete, - wit_world.imports.types.Error_HttpInvalid: fastly_compute.exceptions.types.error.HttpInvalid, - wit_world.imports.types.Error_HttpInvalidStatus: fastly_compute.exceptions.types.error.HttpInvalidStatus, - wit_world.imports.types.Error_HttpUser: fastly_compute.exceptions.types.error.HttpUser, - wit_world.imports.types.Error_InvalidArgument: fastly_compute.exceptions.types.error.InvalidArgument, - wit_world.imports.types.Error_LimitExceeded: fastly_compute.exceptions.types.error.LimitExceeded, - wit_world.imports.types.Error_Unsupported: fastly_compute.exceptions.types.error.Unsupported, - wit_world.imports.types.OpenError.GENERIC_ERROR: fastly_compute.exceptions.types.open_error.GenericError, - wit_world.imports.types.OpenError.INVALID_SYNTAX: fastly_compute.exceptions.types.open_error.InvalidSyntax, - wit_world.imports.types.OpenError.LIMIT_EXCEEDED: fastly_compute.exceptions.types.open_error.LimitExceeded, - wit_world.imports.types.OpenError.NAME_TOO_LONG: fastly_compute.exceptions.types.open_error.NameTooLong, - wit_world.imports.types.OpenError.NOT_FOUND: fastly_compute.exceptions.types.open_error.NotFound, - wit_world.imports.types.OpenError.RESERVED: fastly_compute.exceptions.types.open_error.Reserved, - wit_world.imports.types.OpenError.UNSUPPORTED: fastly_compute.exceptions.types.open_error.Unsupported, - type(None): fastly_compute.exceptions.FastlyError, - } - - did_patch = False - - def patch(): - """Apply patches if they haven't already been applied.""" - global did_patch - if did_patch: - # This test shouldn't be needed, but it avoids double-wrapping the - # routines if somehow patch() did get called twice. - return - did_patch = True - wit_world.imports.log.Endpoint.open = remap_wit_errors(MAPPINGS)(wit_world.imports.log.Endpoint.open) - wit_world.imports.dictionary.Dictionary.open = remap_wit_errors(MAPPINGS)(wit_world.imports.dictionary.Dictionary.open) - wit_world.imports.dictionary.Dictionary.lookup = remap_wit_errors(MAPPINGS)(wit_world.imports.dictionary.Dictionary.lookup) - wit_world.imports.geo.lookup = remap_wit_errors(MAPPINGS)(wit_world.imports.geo.lookup) - wit_world.imports.device_detection.lookup = remap_wit_errors(MAPPINGS)(wit_world.imports.device_detection.lookup) - wit_world.imports.erl.RateCounter.open = remap_wit_errors(MAPPINGS)(wit_world.imports.erl.RateCounter.open) - wit_world.imports.erl.RateCounter.check_rate = remap_wit_errors(MAPPINGS)(wit_world.imports.erl.RateCounter.check_rate) - wit_world.imports.erl.RateCounter.increment = remap_wit_errors(MAPPINGS)(wit_world.imports.erl.RateCounter.increment) - wit_world.imports.erl.RateCounter.lookup_rate = remap_wit_errors(MAPPINGS)(wit_world.imports.erl.RateCounter.lookup_rate) - wit_world.imports.erl.RateCounter.lookup_count = remap_wit_errors(MAPPINGS)(wit_world.imports.erl.RateCounter.lookup_count) - wit_world.imports.erl.PenaltyBox.open = remap_wit_errors(MAPPINGS)(wit_world.imports.erl.PenaltyBox.open) - wit_world.imports.erl.PenaltyBox.add = remap_wit_errors(MAPPINGS)(wit_world.imports.erl.PenaltyBox.add) - wit_world.imports.erl.PenaltyBox.has = remap_wit_errors(MAPPINGS)(wit_world.imports.erl.PenaltyBox.has) - wit_world.imports.secret_store.Secret.from_bytes = remap_wit_errors(MAPPINGS)(wit_world.imports.secret_store.Secret.from_bytes) - wit_world.imports.secret_store.Secret.plaintext = remap_wit_errors(MAPPINGS)(wit_world.imports.secret_store.Secret.plaintext) - wit_world.imports.secret_store.Store.open = remap_wit_errors(MAPPINGS)(wit_world.imports.secret_store.Store.open) - wit_world.imports.secret_store.Store.get = remap_wit_errors(MAPPINGS)(wit_world.imports.secret_store.Store.get) - wit_world.imports.backend.register_dynamic_backend = remap_wit_errors(MAPPINGS)(wit_world.imports.backend.register_dynamic_backend) - wit_world.imports.backend.Backend.open = remap_wit_errors(MAPPINGS)(wit_world.imports.backend.Backend.open) - wit_world.imports.backend.Backend.is_healthy = remap_wit_errors(MAPPINGS)(wit_world.imports.backend.Backend.is_healthy) - wit_world.imports.backend.Backend.is_dynamic = remap_wit_errors(MAPPINGS)(wit_world.imports.backend.Backend.is_dynamic) - wit_world.imports.backend.Backend.get_host = remap_wit_errors(MAPPINGS)(wit_world.imports.backend.Backend.get_host) - wit_world.imports.backend.Backend.get_override_host = remap_wit_errors(MAPPINGS)(wit_world.imports.backend.Backend.get_override_host) - wit_world.imports.backend.Backend.get_port = remap_wit_errors(MAPPINGS)(wit_world.imports.backend.Backend.get_port) - wit_world.imports.backend.Backend.get_connect_timeout_ms = remap_wit_errors(MAPPINGS)(wit_world.imports.backend.Backend.get_connect_timeout_ms) - wit_world.imports.backend.Backend.get_first_byte_timeout_ms = remap_wit_errors(MAPPINGS)(wit_world.imports.backend.Backend.get_first_byte_timeout_ms) - wit_world.imports.backend.Backend.get_between_bytes_timeout_ms = remap_wit_errors(MAPPINGS)(wit_world.imports.backend.Backend.get_between_bytes_timeout_ms) - wit_world.imports.backend.Backend.is_tls = remap_wit_errors(MAPPINGS)(wit_world.imports.backend.Backend.is_tls) - wit_world.imports.backend.Backend.get_tls_min_version = remap_wit_errors(MAPPINGS)(wit_world.imports.backend.Backend.get_tls_min_version) - wit_world.imports.backend.Backend.get_tls_max_version = remap_wit_errors(MAPPINGS)(wit_world.imports.backend.Backend.get_tls_max_version) - wit_world.imports.backend.Backend.get_http_keepalive_time = remap_wit_errors(MAPPINGS)(wit_world.imports.backend.Backend.get_http_keepalive_time) - wit_world.imports.backend.Backend.get_tcp_keepalive_enable = remap_wit_errors(MAPPINGS)(wit_world.imports.backend.Backend.get_tcp_keepalive_enable) - wit_world.imports.backend.Backend.get_tcp_keepalive_interval = remap_wit_errors(MAPPINGS)(wit_world.imports.backend.Backend.get_tcp_keepalive_interval) - wit_world.imports.backend.Backend.get_tcp_keepalive_probes = remap_wit_errors(MAPPINGS)(wit_world.imports.backend.Backend.get_tcp_keepalive_probes) - wit_world.imports.backend.Backend.get_tcp_keepalive_time = remap_wit_errors(MAPPINGS)(wit_world.imports.backend.Backend.get_tcp_keepalive_time) - wit_world.imports.http_body.new = remap_wit_errors(MAPPINGS)(wit_world.imports.http_body.new) - wit_world.imports.http_body.append = remap_wit_errors(MAPPINGS)(wit_world.imports.http_body.append) - wit_world.imports.http_body.read = remap_wit_errors(MAPPINGS)(wit_world.imports.http_body.read) - wit_world.imports.http_body.write = remap_wit_errors(MAPPINGS)(wit_world.imports.http_body.write) - wit_world.imports.http_body.write_front = remap_wit_errors(MAPPINGS)(wit_world.imports.http_body.write_front) - wit_world.imports.http_body.close = remap_wit_errors(MAPPINGS)(wit_world.imports.http_body.close) - wit_world.imports.http_body.append_trailer = remap_wit_errors(MAPPINGS)(wit_world.imports.http_body.append_trailer) - wit_world.imports.http_body.get_trailer_names = remap_wit_errors(MAPPINGS)(wit_world.imports.http_body.get_trailer_names) - wit_world.imports.http_body.get_trailer_value = remap_wit_errors(MAPPINGS)(wit_world.imports.http_body.get_trailer_value) - wit_world.imports.http_body.get_trailer_values = remap_wit_errors(MAPPINGS)(wit_world.imports.http_body.get_trailer_values) - wit_world.imports.http_resp.Response.new = remap_wit_errors(MAPPINGS)(wit_world.imports.http_resp.Response.new) - wit_world.imports.http_resp.Response.get_header_names = remap_wit_errors(MAPPINGS)(wit_world.imports.http_resp.Response.get_header_names) - wit_world.imports.http_resp.Response.get_header_value = remap_wit_errors(MAPPINGS)(wit_world.imports.http_resp.Response.get_header_value) - wit_world.imports.http_resp.Response.get_header_values = remap_wit_errors(MAPPINGS)(wit_world.imports.http_resp.Response.get_header_values) - wit_world.imports.http_resp.Response.set_header_values = remap_wit_errors(MAPPINGS)(wit_world.imports.http_resp.Response.set_header_values) - wit_world.imports.http_resp.Response.insert_header = remap_wit_errors(MAPPINGS)(wit_world.imports.http_resp.Response.insert_header) - wit_world.imports.http_resp.Response.append_header = remap_wit_errors(MAPPINGS)(wit_world.imports.http_resp.Response.append_header) - wit_world.imports.http_resp.Response.remove_header = remap_wit_errors(MAPPINGS)(wit_world.imports.http_resp.Response.remove_header) - wit_world.imports.http_resp.Response.get_version = remap_wit_errors(MAPPINGS)(wit_world.imports.http_resp.Response.get_version) - wit_world.imports.http_resp.Response.set_version = remap_wit_errors(MAPPINGS)(wit_world.imports.http_resp.Response.set_version) - wit_world.imports.http_resp.Response.get_status = remap_wit_errors(MAPPINGS)(wit_world.imports.http_resp.Response.get_status) - wit_world.imports.http_resp.Response.set_status = remap_wit_errors(MAPPINGS)(wit_world.imports.http_resp.Response.set_status) - wit_world.imports.http_resp.Response.set_framing_headers_mode = remap_wit_errors(MAPPINGS)(wit_world.imports.http_resp.Response.set_framing_headers_mode) - wit_world.imports.http_resp.Response.set_http_keepalive_mode = remap_wit_errors(MAPPINGS)(wit_world.imports.http_resp.Response.set_http_keepalive_mode) - wit_world.imports.http_resp.send_downstream = remap_wit_errors(MAPPINGS)(wit_world.imports.http_resp.send_downstream) - wit_world.imports.http_resp.send_downstream_streaming = remap_wit_errors(MAPPINGS)(wit_world.imports.http_resp.send_downstream_streaming) - wit_world.imports.http_resp.close = remap_wit_errors(MAPPINGS)(wit_world.imports.http_resp.close) - wit_world.imports.http_req.Request.new = remap_wit_errors(MAPPINGS)(wit_world.imports.http_req.Request.new) - wit_world.imports.http_req.Request.set_cache_override = remap_wit_errors(MAPPINGS)(wit_world.imports.http_req.Request.set_cache_override) - wit_world.imports.http_req.Request.get_header_names = remap_wit_errors(MAPPINGS)(wit_world.imports.http_req.Request.get_header_names) - wit_world.imports.http_req.Request.get_header_value = remap_wit_errors(MAPPINGS)(wit_world.imports.http_req.Request.get_header_value) - wit_world.imports.http_req.Request.get_header_values = remap_wit_errors(MAPPINGS)(wit_world.imports.http_req.Request.get_header_values) - wit_world.imports.http_req.Request.set_header_values = remap_wit_errors(MAPPINGS)(wit_world.imports.http_req.Request.set_header_values) - wit_world.imports.http_req.Request.insert_header = remap_wit_errors(MAPPINGS)(wit_world.imports.http_req.Request.insert_header) - wit_world.imports.http_req.Request.append_header = remap_wit_errors(MAPPINGS)(wit_world.imports.http_req.Request.append_header) - wit_world.imports.http_req.Request.remove_header = remap_wit_errors(MAPPINGS)(wit_world.imports.http_req.Request.remove_header) - wit_world.imports.http_req.Request.get_method = remap_wit_errors(MAPPINGS)(wit_world.imports.http_req.Request.get_method) - wit_world.imports.http_req.Request.set_method = remap_wit_errors(MAPPINGS)(wit_world.imports.http_req.Request.set_method) - wit_world.imports.http_req.Request.get_uri = remap_wit_errors(MAPPINGS)(wit_world.imports.http_req.Request.get_uri) - wit_world.imports.http_req.Request.set_uri = remap_wit_errors(MAPPINGS)(wit_world.imports.http_req.Request.set_uri) - wit_world.imports.http_req.Request.get_version = remap_wit_errors(MAPPINGS)(wit_world.imports.http_req.Request.get_version) - wit_world.imports.http_req.Request.set_version = remap_wit_errors(MAPPINGS)(wit_world.imports.http_req.Request.set_version) - wit_world.imports.http_req.Request.set_auto_decompress_response = remap_wit_errors(MAPPINGS)(wit_world.imports.http_req.Request.set_auto_decompress_response) - wit_world.imports.http_req.Request.redirect_to_websocket_proxy = remap_wit_errors(MAPPINGS)(wit_world.imports.http_req.Request.redirect_to_websocket_proxy) - wit_world.imports.http_req.Request.set_framing_headers_mode = remap_wit_errors(MAPPINGS)(wit_world.imports.http_req.Request.set_framing_headers_mode) - wit_world.imports.http_req.Request.redirect_to_grip_proxy = remap_wit_errors(MAPPINGS)(wit_world.imports.http_req.Request.redirect_to_grip_proxy) - wit_world.imports.http_req.send = remap_wit_errors(MAPPINGS)(wit_world.imports.http_req.send) - wit_world.imports.http_req.send_uncached = remap_wit_errors(MAPPINGS)(wit_world.imports.http_req.send_uncached) - wit_world.imports.http_req.send_async = remap_wit_errors(MAPPINGS)(wit_world.imports.http_req.send_async) - wit_world.imports.http_req.send_async_uncached = remap_wit_errors(MAPPINGS)(wit_world.imports.http_req.send_async_uncached) - wit_world.imports.http_req.send_async_streaming = remap_wit_errors(MAPPINGS)(wit_world.imports.http_req.send_async_streaming) - wit_world.imports.http_req.send_async_uncached_streaming = remap_wit_errors(MAPPINGS)(wit_world.imports.http_req.send_async_uncached_streaming) - wit_world.imports.http_req.await_response = remap_wit_errors(MAPPINGS)(wit_world.imports.http_req.await_response) - wit_world.imports.http_req.close = remap_wit_errors(MAPPINGS)(wit_world.imports.http_req.close) - wit_world.imports.http_req.upgrade_websocket = remap_wit_errors(MAPPINGS)(wit_world.imports.http_req.upgrade_websocket) - wit_world.imports.http_downstream.next_request = remap_wit_errors(MAPPINGS)(wit_world.imports.http_downstream.next_request) - wit_world.imports.http_downstream.await_request = remap_wit_errors(MAPPINGS)(wit_world.imports.http_downstream.await_request) - wit_world.imports.http_downstream.downstream_original_header_names = remap_wit_errors(MAPPINGS)(wit_world.imports.http_downstream.downstream_original_header_names) - wit_world.imports.http_downstream.downstream_original_header_count = remap_wit_errors(MAPPINGS)(wit_world.imports.http_downstream.downstream_original_header_count) - wit_world.imports.http_downstream.downstream_client_h2_fingerprint = remap_wit_errors(MAPPINGS)(wit_world.imports.http_downstream.downstream_client_h2_fingerprint) - wit_world.imports.http_downstream.downstream_client_request_id = remap_wit_errors(MAPPINGS)(wit_world.imports.http_downstream.downstream_client_request_id) - wit_world.imports.http_downstream.downstream_client_oh_fingerprint = remap_wit_errors(MAPPINGS)(wit_world.imports.http_downstream.downstream_client_oh_fingerprint) - wit_world.imports.http_downstream.downstream_client_ddos_detected = remap_wit_errors(MAPPINGS)(wit_world.imports.http_downstream.downstream_client_ddos_detected) - wit_world.imports.http_downstream.downstream_tls_cipher_openssl_name = remap_wit_errors(MAPPINGS)(wit_world.imports.http_downstream.downstream_tls_cipher_openssl_name) - wit_world.imports.http_downstream.downstream_tls_protocol = remap_wit_errors(MAPPINGS)(wit_world.imports.http_downstream.downstream_tls_protocol) - wit_world.imports.http_downstream.downstream_tls_client_hello = remap_wit_errors(MAPPINGS)(wit_world.imports.http_downstream.downstream_tls_client_hello) - wit_world.imports.http_downstream.downstream_tls_raw_client_certificate = remap_wit_errors(MAPPINGS)(wit_world.imports.http_downstream.downstream_tls_raw_client_certificate) - wit_world.imports.http_downstream.downstream_tls_client_cert_verify_result = remap_wit_errors(MAPPINGS)(wit_world.imports.http_downstream.downstream_tls_client_cert_verify_result) - wit_world.imports.http_downstream.downstream_tls_client_servername = remap_wit_errors(MAPPINGS)(wit_world.imports.http_downstream.downstream_tls_client_servername) - wit_world.imports.http_downstream.downstream_tls_ja3_md5 = remap_wit_errors(MAPPINGS)(wit_world.imports.http_downstream.downstream_tls_ja3_md5) - wit_world.imports.http_downstream.downstream_tls_ja4 = remap_wit_errors(MAPPINGS)(wit_world.imports.http_downstream.downstream_tls_ja4) - wit_world.imports.http_downstream.downstream_compliance_region = remap_wit_errors(MAPPINGS)(wit_world.imports.http_downstream.downstream_compliance_region) - wit_world.imports.http_downstream.fastly_key_is_valid = remap_wit_errors(MAPPINGS)(wit_world.imports.http_downstream.fastly_key_is_valid) - wit_world.imports.security.inspect = remap_wit_errors(MAPPINGS)(wit_world.imports.security.inspect) - wit_world.imports.kv_store.Store.open = remap_wit_errors(MAPPINGS)(wit_world.imports.kv_store.Store.open) - wit_world.imports.kv_store.Store.lookup = remap_wit_errors(MAPPINGS)(wit_world.imports.kv_store.Store.lookup) - wit_world.imports.kv_store.Store.lookup_async = remap_wit_errors(MAPPINGS)(wit_world.imports.kv_store.Store.lookup_async) - wit_world.imports.kv_store.Store.insert = remap_wit_errors(MAPPINGS)(wit_world.imports.kv_store.Store.insert) - wit_world.imports.kv_store.Store.insert_async = remap_wit_errors(MAPPINGS)(wit_world.imports.kv_store.Store.insert_async) - wit_world.imports.kv_store.Store.delete = remap_wit_errors(MAPPINGS)(wit_world.imports.kv_store.Store.delete) - wit_world.imports.kv_store.Store.delete_async = remap_wit_errors(MAPPINGS)(wit_world.imports.kv_store.Store.delete_async) - wit_world.imports.kv_store.Store.list = remap_wit_errors(MAPPINGS)(wit_world.imports.kv_store.Store.list) - wit_world.imports.kv_store.Store.list_async = remap_wit_errors(MAPPINGS)(wit_world.imports.kv_store.Store.list_async) - wit_world.imports.kv_store.await_lookup = remap_wit_errors(MAPPINGS)(wit_world.imports.kv_store.await_lookup) - wit_world.imports.kv_store.await_insert = remap_wit_errors(MAPPINGS)(wit_world.imports.kv_store.await_insert) - wit_world.imports.kv_store.await_delete = remap_wit_errors(MAPPINGS)(wit_world.imports.kv_store.await_delete) - wit_world.imports.kv_store.await_list = remap_wit_errors(MAPPINGS)(wit_world.imports.kv_store.await_list) - wit_world.imports.kv_store.Entry.metadata = remap_wit_errors(MAPPINGS)(wit_world.imports.kv_store.Entry.metadata) - wit_world.imports.acl.Acl.open = remap_wit_errors(MAPPINGS)(wit_world.imports.acl.Acl.open) - wit_world.imports.acl.Acl.lookup = remap_wit_errors(MAPPINGS)(wit_world.imports.acl.Acl.lookup) - wit_world.imports.purge.purge_surrogate_key = remap_wit_errors(MAPPINGS)(wit_world.imports.purge.purge_surrogate_key) - wit_world.imports.purge.purge_surrogate_key_verbose = remap_wit_errors(MAPPINGS)(wit_world.imports.purge.purge_surrogate_key_verbose) - wit_world.imports.cache.Entry.lookup = remap_wit_errors(MAPPINGS)(wit_world.imports.cache.Entry.lookup) - wit_world.imports.cache.Entry.transaction_lookup = remap_wit_errors(MAPPINGS)(wit_world.imports.cache.Entry.transaction_lookup) - wit_world.imports.cache.Entry.transaction_lookup_async = remap_wit_errors(MAPPINGS)(wit_world.imports.cache.Entry.transaction_lookup_async) - wit_world.imports.cache.Entry.transaction_insert = remap_wit_errors(MAPPINGS)(wit_world.imports.cache.Entry.transaction_insert) - wit_world.imports.cache.Entry.transaction_insert_and_stream_back = remap_wit_errors(MAPPINGS)(wit_world.imports.cache.Entry.transaction_insert_and_stream_back) - wit_world.imports.cache.Entry.transaction_update = remap_wit_errors(MAPPINGS)(wit_world.imports.cache.Entry.transaction_update) - wit_world.imports.cache.Entry.get_state = remap_wit_errors(MAPPINGS)(wit_world.imports.cache.Entry.get_state) - wit_world.imports.cache.Entry.get_user_metadata = remap_wit_errors(MAPPINGS)(wit_world.imports.cache.Entry.get_user_metadata) - wit_world.imports.cache.Entry.get_body = remap_wit_errors(MAPPINGS)(wit_world.imports.cache.Entry.get_body) - wit_world.imports.cache.Entry.get_length = remap_wit_errors(MAPPINGS)(wit_world.imports.cache.Entry.get_length) - wit_world.imports.cache.Entry.get_max_age_ns = remap_wit_errors(MAPPINGS)(wit_world.imports.cache.Entry.get_max_age_ns) - wit_world.imports.cache.Entry.get_stale_while_revalidate_ns = remap_wit_errors(MAPPINGS)(wit_world.imports.cache.Entry.get_stale_while_revalidate_ns) - wit_world.imports.cache.Entry.get_age_ns = remap_wit_errors(MAPPINGS)(wit_world.imports.cache.Entry.get_age_ns) - wit_world.imports.cache.Entry.get_hits = remap_wit_errors(MAPPINGS)(wit_world.imports.cache.Entry.get_hits) - wit_world.imports.cache.Entry.transaction_cancel = remap_wit_errors(MAPPINGS)(wit_world.imports.cache.Entry.transaction_cancel) - wit_world.imports.cache.ReplaceEntry.replace = remap_wit_errors(MAPPINGS)(wit_world.imports.cache.ReplaceEntry.replace) - wit_world.imports.cache.ReplaceEntry.get_age_ns = remap_wit_errors(MAPPINGS)(wit_world.imports.cache.ReplaceEntry.get_age_ns) - wit_world.imports.cache.ReplaceEntry.get_body = remap_wit_errors(MAPPINGS)(wit_world.imports.cache.ReplaceEntry.get_body) - wit_world.imports.cache.ReplaceEntry.get_hits = remap_wit_errors(MAPPINGS)(wit_world.imports.cache.ReplaceEntry.get_hits) - wit_world.imports.cache.ReplaceEntry.get_length = remap_wit_errors(MAPPINGS)(wit_world.imports.cache.ReplaceEntry.get_length) - wit_world.imports.cache.ReplaceEntry.get_max_age_ns = remap_wit_errors(MAPPINGS)(wit_world.imports.cache.ReplaceEntry.get_max_age_ns) - wit_world.imports.cache.ReplaceEntry.get_stale_while_revalidate_ns = remap_wit_errors(MAPPINGS)(wit_world.imports.cache.ReplaceEntry.get_stale_while_revalidate_ns) - wit_world.imports.cache.ReplaceEntry.get_state = remap_wit_errors(MAPPINGS)(wit_world.imports.cache.ReplaceEntry.get_state) - wit_world.imports.cache.ReplaceEntry.get_user_metadata = remap_wit_errors(MAPPINGS)(wit_world.imports.cache.ReplaceEntry.get_user_metadata) - wit_world.imports.cache.insert = remap_wit_errors(MAPPINGS)(wit_world.imports.cache.insert) - wit_world.imports.cache.await_entry = remap_wit_errors(MAPPINGS)(wit_world.imports.cache.await_entry) - wit_world.imports.cache.close_pending_entry = remap_wit_errors(MAPPINGS)(wit_world.imports.cache.close_pending_entry) - wit_world.imports.cache.close_entry = remap_wit_errors(MAPPINGS)(wit_world.imports.cache.close_entry) - wit_world.imports.cache.replace_insert = remap_wit_errors(MAPPINGS)(wit_world.imports.cache.replace_insert) - wit_world.imports.cache.close_replace_entry = remap_wit_errors(MAPPINGS)(wit_world.imports.cache.close_replace_entry) - wit_world.imports.http_cache.Entry.transaction_lookup = remap_wit_errors(MAPPINGS)(wit_world.imports.http_cache.Entry.transaction_lookup) - wit_world.imports.http_cache.Entry.transaction_insert = remap_wit_errors(MAPPINGS)(wit_world.imports.http_cache.Entry.transaction_insert) - wit_world.imports.http_cache.Entry.transaction_insert_and_stream_back = remap_wit_errors(MAPPINGS)(wit_world.imports.http_cache.Entry.transaction_insert_and_stream_back) - wit_world.imports.http_cache.Entry.transaction_update = remap_wit_errors(MAPPINGS)(wit_world.imports.http_cache.Entry.transaction_update) - wit_world.imports.http_cache.Entry.transaction_update_and_return_fresh = remap_wit_errors(MAPPINGS)(wit_world.imports.http_cache.Entry.transaction_update_and_return_fresh) - wit_world.imports.http_cache.Entry.transaction_record_not_cacheable = remap_wit_errors(MAPPINGS)(wit_world.imports.http_cache.Entry.transaction_record_not_cacheable) - wit_world.imports.http_cache.Entry.get_suggested_backend_request = remap_wit_errors(MAPPINGS)(wit_world.imports.http_cache.Entry.get_suggested_backend_request) - wit_world.imports.http_cache.Entry.get_suggested_write_options = remap_wit_errors(MAPPINGS)(wit_world.imports.http_cache.Entry.get_suggested_write_options) - wit_world.imports.http_cache.Entry.prepare_response_for_storage = remap_wit_errors(MAPPINGS)(wit_world.imports.http_cache.Entry.prepare_response_for_storage) - wit_world.imports.http_cache.Entry.get_found_response = remap_wit_errors(MAPPINGS)(wit_world.imports.http_cache.Entry.get_found_response) - wit_world.imports.http_cache.Entry.get_state = remap_wit_errors(MAPPINGS)(wit_world.imports.http_cache.Entry.get_state) - wit_world.imports.http_cache.Entry.get_length = remap_wit_errors(MAPPINGS)(wit_world.imports.http_cache.Entry.get_length) - wit_world.imports.http_cache.Entry.get_max_age_ns = remap_wit_errors(MAPPINGS)(wit_world.imports.http_cache.Entry.get_max_age_ns) - wit_world.imports.http_cache.Entry.get_stale_while_revalidate_ns = remap_wit_errors(MAPPINGS)(wit_world.imports.http_cache.Entry.get_stale_while_revalidate_ns) - wit_world.imports.http_cache.Entry.get_age_ns = remap_wit_errors(MAPPINGS)(wit_world.imports.http_cache.Entry.get_age_ns) - wit_world.imports.http_cache.Entry.get_hits = remap_wit_errors(MAPPINGS)(wit_world.imports.http_cache.Entry.get_hits) - wit_world.imports.http_cache.Entry.get_sensitive_data = remap_wit_errors(MAPPINGS)(wit_world.imports.http_cache.Entry.get_sensitive_data) - wit_world.imports.http_cache.Entry.get_surrogate_keys = remap_wit_errors(MAPPINGS)(wit_world.imports.http_cache.Entry.get_surrogate_keys) - wit_world.imports.http_cache.Entry.get_vary_rule = remap_wit_errors(MAPPINGS)(wit_world.imports.http_cache.Entry.get_vary_rule) - wit_world.imports.http_cache.Entry.transaction_abandon = remap_wit_errors(MAPPINGS)(wit_world.imports.http_cache.Entry.transaction_abandon) - wit_world.imports.http_cache.is_request_cacheable = remap_wit_errors(MAPPINGS)(wit_world.imports.http_cache.is_request_cacheable) - wit_world.imports.http_cache.get_suggested_cache_key = remap_wit_errors(MAPPINGS)(wit_world.imports.http_cache.get_suggested_cache_key) - wit_world.imports.http_cache.close_entry = remap_wit_errors(MAPPINGS)(wit_world.imports.http_cache.close_entry) - wit_world.imports.http_cache.SuggestedWriteOptions.get_vary_rule = remap_wit_errors(MAPPINGS)(wit_world.imports.http_cache.SuggestedWriteOptions.get_vary_rule) - wit_world.imports.http_cache.SuggestedWriteOptions.get_surrogate_keys = remap_wit_errors(MAPPINGS)(wit_world.imports.http_cache.SuggestedWriteOptions.get_surrogate_keys) - wit_world.imports.config_store.Store.open = remap_wit_errors(MAPPINGS)(wit_world.imports.config_store.Store.open) - wit_world.imports.config_store.Store.get = remap_wit_errors(MAPPINGS)(wit_world.imports.config_store.Store.get) - wit_world.imports.shielding.shield_info = remap_wit_errors(MAPPINGS)(wit_world.imports.shielding.shield_info) - wit_world.imports.shielding.backend_for_shield = remap_wit_errors(MAPPINGS)(wit_world.imports.shielding.backend_for_shield) - wit_world.imports.image_optimizer.transform_image_optimizer_request = remap_wit_errors(MAPPINGS)(wit_world.imports.image_optimizer.transform_image_optimizer_request) diff --git a/fastly_compute/secret_store.py b/fastly_compute/secret_store.py new file mode 100644 index 0000000..2411beb --- /dev/null +++ b/fastly_compute/secret_store.py @@ -0,0 +1,3 @@ +"""Secret Store API for Fastly Compute.""" + +from fastly_compute._bindings.secret_store import * # noqa: F401, F403 diff --git a/fastly_compute/security.py b/fastly_compute/security.py new file mode 100644 index 0000000..d3fb22b --- /dev/null +++ b/fastly_compute/security.py @@ -0,0 +1,3 @@ +"""Security (NGWAF) API for Fastly Compute.""" + +from fastly_compute._bindings.security import * # noqa: F401, F403 diff --git a/fastly_compute/shielding.py b/fastly_compute/shielding.py new file mode 100644 index 0000000..12f7102 --- /dev/null +++ b/fastly_compute/shielding.py @@ -0,0 +1,3 @@ +"""Shielding API for Fastly Compute.""" + +from fastly_compute._bindings.shielding import * # noqa: F401, F403 diff --git a/fastly_compute/tests/test_erl.py b/fastly_compute/tests/test_erl.py new file mode 100644 index 0000000..e970f8c --- /dev/null +++ b/fastly_compute/tests/test_erl.py @@ -0,0 +1,128 @@ +"""Integration tests for Edge Rate Limiting functionality.""" + +import pytest + +from fastly_compute.erl import EdgeRateLimiter, PenaltyBox, RateCounter +from fastly_compute.exceptions.types.open_error import NotFound +from fastly_compute.testing import AutoViceroyTestBase, on_viceroy + + +class TestRateCounter(AutoViceroyTestBase): + """Rate counter integration tests.""" + + VICEROY_CONFIG = { + "local_server": { + "rate_counters": {"test-counter": {}}, + "penalty_boxes": {"test-penalty": {}}, + } + } + + @on_viceroy + def rate_counter_increment(cls, counter_name, entry, delta): + """Increment a counter and return None (no error).""" + with RateCounter.open(counter_name) as counter: + counter.increment(entry, delta) + + @on_viceroy + def rate_counter_lookup_rate(cls, counter_name, entry, window): + """Lookup rate for an entry.""" + with RateCounter.open(counter_name) as counter: + return counter.lookup_rate(entry, window) + + @on_viceroy + def rate_counter_lookup_count(cls, counter_name, entry, duration): + """Lookup count for an entry.""" + with RateCounter.open(counter_name) as counter: + return counter.lookup_count(entry, duration) + + @on_viceroy + def rate_counter_check_rate( + cls, counter_name, penalty_name, entry, delta, window, limit, ttl + ): + """Check rate with penalty box.""" + with RateCounter.open(counter_name) as counter: + with PenaltyBox.open(penalty_name) as penalty: + return counter.check_rate(entry, delta, window, limit, penalty, ttl) + + @on_viceroy + def penalty_box_add(cls, penalty_name, entry, ttl): + """Add entry to penalty box.""" + with PenaltyBox.open(penalty_name) as penalty: + penalty.add(entry, ttl) + return None + + @on_viceroy + def penalty_box_contains(cls, penalty_name, entry): + """Check if entry is in penalty box using __contains__.""" + with PenaltyBox.open(penalty_name) as penalty: + return entry in penalty + + @on_viceroy + def edge_rate_limiter_check_rate( + cls, counter_name, penalty_name, entry, delta, window, limit, ttl + ): + """Check rate using EdgeRateLimiter convenience wrapper.""" + counter = RateCounter.open(counter_name) + penalty = PenaltyBox.open(penalty_name) + erl = EdgeRateLimiter(counter, penalty) + return erl.check_rate(entry, delta, window, limit, ttl) + + @pytest.mark.xfail( + reason="Viceroy's ERL implementation does not validate resource existence" + ) + def test_open_nonexistent_counter(self): + """Test opening a non-existent rate counter raises error.""" + with pytest.raises(NotFound): + self.rate_counter_increment("nonexistent", "192.168.1.1", 1) + + def test_increment(self): + """Test incrementing a counter.""" + result = self.rate_counter_increment("test-counter", "192.168.1.1", 1) + assert result is None # No error + + def test_lookup_rate(self): + """Test looking up rate.""" + # Viceroy returns 0, but we verify the API works + rate = self.rate_counter_lookup_rate("test-counter", "192.168.1.1", 60) + assert rate == 0 # Viceroy stub returns 0 + + def test_lookup_count(self): + """Test looking up count.""" + # Viceroy returns 0, but we verify the API works + count = self.rate_counter_lookup_count("test-counter", "192.168.1.1", 30) + assert count == 0 # Viceroy stub returns 0 + + def test_check_rate(self): + """Test checking rate with penalty box.""" + # Viceroy returns False, but we verify the API works + is_limited = self.rate_counter_check_rate( + "test-counter", "test-penalty", "192.168.1.1", 1, 10, 100, 300 + ) + assert is_limited is False # Viceroy stub returns False + + @pytest.mark.xfail( + reason="Viceroy's ERL implementation does not validate resource existence" + ) + def test_open_nonexistent_penalty_box(self): + """Test opening a non-existent penalty box raises error.""" + with pytest.raises(NotFound): + self.penalty_box_add("nonexistent", "192.168.1.1", 600) + + def test_pb_add(self): + """Test adding entry to penalty box.""" + result = self.penalty_box_add("test-penalty", "192.168.1.1", 600) + assert result is None # No error + + def test_pb_contains(self): + """Test checking if entry is in penalty box using 'in' operator.""" + # Viceroy returns False, but we verify the API works + is_blocked = self.penalty_box_contains("test-penalty", "192.168.1.1") + assert is_blocked is False # Viceroy stub always returns False + + def test_edge_rate_limiter_check_rate(self): + """Test EdgeRateLimiter convenience wrapper.""" + # Viceroy returns False, but we verify the API works + is_limited = self.edge_rate_limiter_check_rate( + "test-counter", "test-penalty", "192.168.1.1", 1, 10, 100, 300 + ) + assert is_limited is False # Viceroy stub always returns False diff --git a/fastly_compute/tests/test_exception_remapping.py b/fastly_compute/tests/test_exception_remapping.py index 72386d1..1848d4a 100644 --- a/fastly_compute/tests/test_exception_remapping.py +++ b/fastly_compute/tests/test_exception_remapping.py @@ -6,11 +6,11 @@ from pytest import raises from wit_world.imports.types import Error_BufferLen, OpenError +from fastly_compute._error_mapping import remap_wit_errors 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 diff --git a/fastly_compute/utils.py b/fastly_compute/utils.py index 63f98a0..b060d5f 100644 --- a/fastly_compute/utils.py +++ b/fastly_compute/utils.py @@ -2,7 +2,8 @@ from io import BufferedReader, RawIOBase -from wit_world.imports import async_io, http_body +from fastly_compute import async_io +from fastly_compute._bindings import http_body class _RawBodyReader(RawIOBase): diff --git a/fastly_compute/wsgi.py b/fastly_compute/wsgi.py index d68eb03..a65f9c3 100644 --- a/fastly_compute/wsgi.py +++ b/fastly_compute/wsgi.py @@ -15,16 +15,19 @@ from urllib.parse import urlparse from wsgiref.types import InputStream +import wit_world.imports.async_io as _wit_async_io +import wit_world.imports.http_req as _wit_http_req from wit_world.exports import HttpIncoming -from wit_world.imports import async_io, http_body, http_req, http_resp -from wit_world.imports.http_downstream import ( + +from fastly_compute import async_io, http_req, http_resp +from fastly_compute._bindings import http_body +from fastly_compute.exceptions.types.error import CannotRead +from fastly_compute.http_downstream import ( NextRequestOptions, await_request, next_request, ) -from wit_world.imports.http_resp import send_downstream - -from fastly_compute.exceptions.types.error import CannotRead +from fastly_compute.http_resp import send_downstream from fastly_compute.utils import create_body_reader @@ -203,12 +206,18 @@ def __call__(self): """ return self - def handle(self, request: http_req.Request, body: async_io.Pollable) -> None: + def handle( + self, request: _wit_http_req.Request, body: _wit_async_io.Pollable + ) -> None: """Handle incoming HTTP requests by serving them through the WSGI app.""" - with request: # Ensure dropping of request resource before trying to get another one. This dodges a crash. + # Wrap the raw WIT export-boundary resources into _bindings wrapper + # types before passing them to the rest of the SDK. + wrapped_request = http_req.Request(request) + wrapped_body = async_io.Pollable(body) + with wrapped_request: # Ensure dropping of request resource before trying to get another one. This dodges a crash. serve_wsgi_request( - request, - create_body_reader(body), + wrapped_request, + create_body_reader(wrapped_body), self.wsgi_app, handle_errors=self.handle_errors, ) @@ -216,7 +225,7 @@ def handle(self, request: http_req.Request, body: async_io.Pollable) -> None: if not self.reuse_sandboxes_for_ms: return - options = NextRequestOptions(timeout_ms=self.reuse_sandboxes_for_ms, extra=None) + options = NextRequestOptions(timeout_ms=self.reuse_sandboxes_for_ms) while True: pending_request = next_request(options) try: diff --git a/pyproject.toml b/pyproject.toml index 8c57099..4deec19 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,7 +66,6 @@ quote-style = "double" indent-style = "space" skip-magic-trailing-comma = false line-ending = "auto" -exclude = ["fastly_compute/runtime_patching/patches.py"] [tool.ruff.lint.pydocstyle] convention = "google" @@ -77,6 +76,8 @@ convention = "google" "examples/*" = ["D"] # What can one say about __main__? "__main__.py" = ["D100"] +# Generated bindings: D is suppressed because docstrings come verbatim from WIT. +"fastly_compute/_bindings/*" = ["D"] [build-system] requires = ["maturin>=1.0,<2.0"] @@ -114,4 +115,5 @@ project-excludes = [ # CLI wrapper imports from native extension that doesn't exist until built "fastly_compute/fastly_compute_py.py", "fastly_compute/testing/stubs", + "fastly_compute/_bindings", ] diff --git a/scripts/generate_bindings/__init__.py b/scripts/generate_bindings/__init__.py new file mode 100644 index 0000000..9dfe98a --- /dev/null +++ b/scripts/generate_bindings/__init__.py @@ -0,0 +1,8 @@ +"""Generates fastly_compute/_bindings/*.py from the Fastly Compute WIT. + +Each generated module wraps the corresponding wit_world.imports.* module, +applying remap_wit_errors at definition time so there is no runtime +monkeypatching required. + +This runs at SDK build time; customers don't run it. +""" diff --git a/scripts/generate_bindings/__main__.py b/scripts/generate_bindings/__main__.py new file mode 100644 index 0000000..2e59883 --- /dev/null +++ b/scripts/generate_bindings/__main__.py @@ -0,0 +1,6 @@ +"""Entry point: generate fastly_compute/_bindings/*.py from WIT.""" + +from .generation import generate + +if __name__ == "__main__": + generate() diff --git a/scripts/generate_bindings/generation.py b/scripts/generate_bindings/generation.py new file mode 100644 index 0000000..a6d3011 --- /dev/null +++ b/scripts/generate_bindings/generation.py @@ -0,0 +1,357 @@ +"""Generate fastly_compute/_bindings/*.py from the Fastly Compute WIT.""" + +import json +from dataclasses import dataclass +from pathlib import Path +from subprocess import check_output, run + +from jinja2 import Environment, FileSystemLoader + +from scripts.wit.types import ( + Enum, + Flags, + Handle, + Option, + Record, + Resource, + Result, + TupleType, + Type, + Variant, +) +from scripts.wit.utils import lower_snake, upper_camel +from scripts.wit.wit import Interface, Wit + +WIT_DIR = "wit" +BINDINGS_DIR = Path("fastly_compute/_bindings") +TEMPLATES_DIR = Path(__file__).parent / "templates" + + +@dataclass +class ExtraImport: + """A single import line to emit at the top of a generated module.""" + + module: str # e.g. "wit_world.imports.http_types" + name: str # e.g. "HttpVersion" + + +def _all_type_refs(interface: Interface) -> list[Type]: + """Return every non-self type referenced in params or ok-return positions. + + Error types from result arms are excluded — those are handled by the + exception hierarchy and MAPPINGS, not by generated imports. + + Record field types are included recursively so that any enum/variant/resource + types that appear only as record fields still get their imports generated. + """ + seen: set[int | str] = set() + result: list[Type] = [] + + def _collect(raw_type_id): + if raw_type_id is None: + return + t = Type.from_id(raw_type_id, interface._wit) + # Unwrap handle to the resource it points to + if isinstance(t, Handle): + t = t._resource_type() + # Unwrap result: collect ok type only, skip error arm + elif isinstance(t, Result): + _collect(t._me["kind"]["result"].get("ok")) + return + elif isinstance(t, Option): + _collect(t._me["kind"]["option"]) + return + elif isinstance(t, TupleType): + for sub_id in t._me["kind"]["tuple"]["types"]: + _collect(sub_id) + return + if t._id in seen: + return + seen.add(t._id) + result.append(t) + # Recurse into record fields so field-level type deps get imports generated. + if isinstance(t, Record): + for field in t.fields(): + _collect(field._me["type"]) + + for func in interface.functions(): + for raw_p in func._me.get("params", []): + if raw_p["name"] == "self": + continue + _collect(raw_p["type"]) + _collect(func._me.get("result")) + + return result + + +def _extra_imports_for_interface(interface: Interface) -> list[ExtraImport]: + """Compute import statements needed for types defined in other interfaces.""" + # Find this interface's own index so we can detect foreign types. + own_index: int | None = None + for i, iface_json in enumerate(interface._wit["interfaces"]): + if iface_json is interface._me: + own_index = i + break + + imports: list[ExtraImport] = [] + seen_names: set[str] = set() + + for t in _all_type_refs(interface): + owner = t._me.get("owner") + if owner is None: + continue + owner_idx = owner.get("interface") + if owner_idx is None or owner_idx == own_index: + continue + owner_iface = interface._wit["interfaces"][owner_idx] + owner_module = lower_snake(owner_iface["name"]) + type_name = upper_camel(t._me["name"]) + if type_name in seen_names: + continue + seen_names.add(type_name) + + if isinstance(t, Resource): + imports.append( + ExtraImport( + module=f"fastly_compute._bindings.{owner_module}", + name=type_name, + ) + ) + elif isinstance(t, Enum | Variant | Flags): + imports.append( + ExtraImport( + module=f"wit_world.imports.{owner_module}", + name=type_name, + ) + ) + + return sorted(imports, key=lambda i: (i.module, i.name)) + + +def _reexports_for_interface(interface: Interface) -> list[str]: + """Names to re-export verbatim from wit_world for this interface. + + Covers enum/flags/variant types owned by this interface that appear in + any param or return position (not just params). Error types that appear + only as result error arms are excluded. + """ + # Find this interface's own index. + own_index: int | None = None + for i, iface_json in enumerate(interface._wit["interfaces"]): + if iface_json is interface._me: + own_index = i + break + + # Collect all type IDs visible from params and return types + # (via _all_type_refs which already unwraps handles/results/options/tuples). + referenced_ids: set[int | str] = {t._id for t in _all_type_refs(interface)} + + names = [] + for type_name, type_id in interface._me.get("types", {}).items(): + t = Type.from_id(type_id, interface._wit) + if t._id not in referenced_ids: + continue + owner = t._me.get("owner") + if owner is None: + continue + if owner.get("interface") != own_index: + continue + if isinstance(t, Enum | Flags | Variant): + names.append(upper_camel(type_name)) + return sorted(names) + + +def _records_for_interface(interface: Interface) -> list[Record]: + """Return own-interface Record types used in function signatures. + + These need generated Python wrapper classes with documented __init__ + signatures so callers don't have to construct raw WIT dataclasses. + """ + own_index: int | None = None + for i, iface_json in enumerate(interface._wit["interfaces"]): + if iface_json is interface._me: + own_index = i + break + + referenced_ids: set[int | str] = {t._id for t in _all_type_refs(interface)} + + records = [] + seen_ids: set[int | str] = set() + for type_id in interface._me.get("types", {}).values(): + t = Type.from_id(type_id, interface._wit) + if not isinstance(t, Record): + continue + if t._id in seen_ids: + continue + if t._id not in referenced_ids: + continue + owner = t._me.get("owner", {}) + if owner.get("interface") != own_index: + continue + seen_ids.add(t._id) + records.append(t) + return records + + +def _public_names( + records: list, + resources_and_methods: list, + freestanding: list, + reexports: list[str], + extra_imports: list[ExtraImport], +) -> list[str]: + """Compute the sorted __all__ list for a generated binding module. + + Includes: non-Extra records, non-Extra resources, freestanding functions, + own-interface enum/flags/variant re-exports, and cross-module enum/variant + imports from wit_world (they have no public module of their own yet). + + Excludes: Extra* extensibility types, infrastructure names + (MAPPINGS, remap_wit_errors, FastlyResource, _wit alias), and foreign + resource types imported from other _bindings modules (their canonical + home is their own public module, e.g. Pollable belongs in async_io). + """ + names: list[str] = [] + for record in records: + name = record.wit_class_name() + if not name.startswith("Extra"): + names.append(name) + for resource, _ in resources_and_methods: + name = resource.bindings_class_name() + if not name.startswith("Extra"): + names.append(name) + for func in freestanding: + names.append(func.py_name()) + names.extend(reexports) + for imp in extra_imports: + # Foreign resources imported from other _bindings modules are excluded — + # they belong in their own public module (e.g. Pollable → async_io). + # Enums/variants from wit_world are included since they have no public + # module of their own. + if imp.module.startswith("fastly_compute._bindings"): + continue + if not imp.name.startswith("Extra"): + names.append(imp.name) + return sorted(set(names)) + + +def generate_binding_module(interface: Interface, env: Environment) -> str: + """Render the binding module for a single WIT interface.""" + resources_and_methods = [ + (r, interface.methods_for_resource(r)) for r in interface.resources() + ] + freestanding = interface.freestanding_functions() + records = _records_for_interface(interface) + module_docstring = interface.docstring(indent=0) or "" + reexports = _reexports_for_interface(interface) + extra_imports = _extra_imports_for_interface(interface) + public_names = _public_names( + records, resources_and_methods, freestanding, reexports, extra_imports + ) + + needs_resource = bool(resources_and_methods) + needs_decorator = any( + f.raises_errors() for _, methods in resources_and_methods for f in methods + ) or any(f.raises_errors() for f in freestanding) + needs_self = any( + f.return_annotation(r) == "Self" + for r, methods in resources_and_methods + for f in methods + if f.kind() == "static" + ) or any( + f.kind() == "constructor" + for _, methods in resources_and_methods + for f in methods + ) + + template = env.get_template("bindings_module.py.jinja") + return template.render( + interface=interface, + module_docstring=module_docstring, + resources_and_methods=resources_and_methods, + freestanding=freestanding, + records=records, + reexports=reexports, + extra_imports=extra_imports, + public_names=public_names, + needs_resource=needs_resource, + needs_decorator=needs_decorator, + needs_self=needs_self, + ) + + +def _format(code: str, filename: str) -> str: + """Run ruff import-sort and format on code in a single in-process pass. + + Uses --stdin-filename so ruff applies the correct per-file config. + """ + # Fix import order first (isort equivalent). + result = run( + [ + "uv", + "run", + "--extra", + "dev", + "ruff", + "check", + "--fix", + "--exit-zero", + "--select", + "I", + "--stdin-filename", + filename, + "-", + ], + input=code, + capture_output=True, + text=True, + ) + code = result.stdout or code + # Then format. + result = run( + [ + "uv", + "run", + "--extra", + "dev", + "ruff", + "format", + "--stdin-filename", + filename, + "-", + ], + input=code, + capture_output=True, + text=True, + ) + return result.stdout or code + + +def generate() -> None: + """Generate all _bindings modules for the Fastly Compute package.""" + wit_text = check_output(["wasm-tools", "component", "wit", WIT_DIR, "--json"]) + wit_json = json.loads(wit_text) + wit = Wit(wit_json) + + env = Environment( + loader=FileSystemLoader(str(TEMPLATES_DIR)), + autoescape=False, + trim_blocks=True, + lstrip_blocks=True, + ) + + BINDINGS_DIR.mkdir(parents=True, exist_ok=True) + + (BINDINGS_DIR / "__init__.py").write_text( + "# This package is automatically generated by scripts/generate_bindings.\n" + "# Do not edit directly.\n" + ) + + pkg = wit.fastly_compute_package() + for interface in pkg.interfaces(): + module_name = interface.py_module() + dest = BINDINGS_DIR / f"{module_name}.py" + code = generate_binding_module(interface, env) + code = _format(code, str(dest)) + dest.write_text(code) + print(f" wrote {dest}") diff --git a/scripts/generate_bindings/templates/bindings_module.py.jinja b/scripts/generate_bindings/templates/bindings_module.py.jinja new file mode 100644 index 0000000..ca1e966 --- /dev/null +++ b/scripts/generate_bindings/templates/bindings_module.py.jinja @@ -0,0 +1,153 @@ +# This file is automatically generated by scripts/generate_bindings. +# Do not edit directly. +{{ module_docstring }} + +from __future__ import annotations +{% if needs_self %} +from typing import Self +{% endif %} +{% if needs_resource or freestanding %} + +from wit_world.imports import {{ interface.py_module() }} as _wit +{% endif %} +{% for imp in extra_imports %} +{% if imp.module.startswith('wit_world') %} +from {{ imp.module }} import {{ imp.name }} +{% endif %} +{% endfor %} +{% for reexport in reexports %} +from wit_world.imports.{{ interface.py_module() }} import {{ reexport }} +{% endfor %} +{% for imp in extra_imports %} +{% if not imp.module.startswith('wit_world') %} +from {{ imp.module }} import {{ imp.name }} +{% endif %} +{% endfor %} +{% if needs_resource %} +from fastly_compute._resource import FastlyResource +{% endif %} +{% if needs_decorator %} +from fastly_compute._error_mapping import MAPPINGS, remap_wit_errors +{% endif %} + +{% if public_names %} +__all__ = [ +{% for name in public_names %} + "{{ name }}", +{% endfor %} +] +{% endif %} + +{# + wit_args: comma-separated argument list for a raw WIT call, unwrapping + resource handles (._wit_resource) and record wrappers (._wit) as needed. +#} +{% macro wit_args(params) %}{% for p in params %}{% if p.needs_unwrap() %}{{ p.name() }}._wit_resource{% elif p.needs_record_unwrap() %}{{ p.name() }}._wit{% else %}{{ p.name() }}{% endif %}{% if not loop.last %}, {% endif %}{% endfor %}{% endmacro %} + +{# + return_body: emit the return statement for a function, wrapping resource + handles or tuple elements as required. Emits with no leading indent; + callers use | indent() to place it correctly. +#} +{% macro return_body(func, call_expr) %} +{% if func.returns_tuple_with_handles() %} +{% if func.tuple_is_optional() %} +_r = {{ call_expr }} +return ({% for part in func.tuple_return_parts() %}{{ part }}{% if not loop.last %}, {% endif %}{% endfor %}) if _r is not None else None +{% else %} +_r = {{ call_expr }} +return ({{ func.tuple_return_parts() | join(', ') }}) +{% endif %} +{% elif func.returns_resource_handle() %} +return {{ func.return_wrap_expression(call_expr) }} +{% else %} +return {{ call_expr }} +{% endif %} +{% endmacro %} + +{% for record in records %} +class {{ record.wit_class_name() }}: + {{ record.docstring(indent=4) or '"""' + record.wit_class_name() + '."""' }} + + def __init__( + self, + {% for field in record.fields() %} + {% if not field.is_extra() %} + {{ field.name() }}: {{ field.annotation() }} = {{ field.default() }}, + {% endif %} + {% endfor %} + ) -> None: + {% set ns = namespace(has_param_docs=false) %} + {% for field in record.fields() %} + {% if not field.is_extra() and field.docs() %} + {% set ns.has_param_docs = true %} + {% endif %} + {% endfor %} + {% if ns.has_param_docs %} + """ + {% for field in record.fields() %} + {% if not field.is_extra() and field.param_doc() %} + :param {{ field.name() }}: {{ field.param_doc() }} + {% endif %} + {% endfor %} + """ + {% endif %} + self._wit = _wit.{{ record.wit_class_name() }}( + {% for field in record.fields() %} + {% if field.is_extra() %} + {{ field.name() }}=None, + {% elif field.needs_unwrap() %} + {{ field.name() }}={{ field.name() }}._wit_resource if {{ field.name() }} is not None else None, + {% elif field.needs_record_unwrap() %} + {{ field.name() }}={{ field.name() }}._wit if {{ field.name() }} is not None else None, + {% else %} + {{ field.name() }}={{ field.name() }}, + {% endif %} + {% endfor %} + ) + +{% endfor %} + +{% for resource, methods in resources_and_methods %} +class {{ resource.bindings_class_name() }}(FastlyResource[_wit.{{ resource.bindings_class_name() }}]): + {{ resource.docstring(indent=4) or '"""' + resource.bindings_class_name() + '."""' }} + + {% for func in methods %} + {% if func.kind() == 'constructor' %} + @classmethod + def {{ func.py_name() }}(cls) -> Self: + {{ func.docstring_with_raises(indent=8, fallback='"""Construct a new instance with default values."""') }} + return cls(_wit.{{ resource.bindings_class_name() }}()) + {% elif func.kind() == 'static' %} + @classmethod + {% if func.raises_errors() %} + @remap_wit_errors(MAPPINGS) + {% endif %} + def {{ func.py_name() }}(cls{% for p in func.params() %}, {{ p.name() }}: {{ p.annotation(resource) }}{% endfor %}) -> {{ func.return_annotation(resource) }}: + {{ func.docstring_with_raises(indent=8) }} + {% if func.return_annotation(resource) == 'Self' %} + return cls(_wit.{{ resource.bindings_class_name() }}.{{ func.py_name() }}({{ wit_args(func.params()) }})) + {% else %} + {{ return_body(func, '_wit.' + resource.bindings_class_name() + '.' + func.py_name() + '(' + wit_args(func.params()) + ')') | trim | indent(8) }} + {% endif %} + {% elif func.kind() == 'method' %} + {% if func.raises_errors() %} + @remap_wit_errors(MAPPINGS) + {% endif %} + def {{ func.py_name() }}(self{% for p in func.params() %}, {{ p.name() }}: {{ p.annotation(resource) }}{% endfor %}) -> {{ func.return_annotation(resource) }}: + {{ func.docstring_with_raises(indent=8) }} + {{ return_body(func, 'self._wit_resource.' + func.py_name() + '(' + wit_args(func.params()) + ')') | trim | indent(8) }} + {% endif %} + + {% endfor %} +{% endfor %} + +{% for func in freestanding %} +{% if func.raises_errors() %} +@remap_wit_errors(MAPPINGS) +{% endif %} +def {{ func.py_name() }}({% for p in func.params() %}{{ p.name() }}: {{ p.annotation() }}{% if not loop.last %}, {% endif %}{% endfor %}) -> {{ func.return_annotation() }}: + {{ func.docstring_with_raises(indent=4) }} + {{ return_body(func, '_wit.' + func.py_name() + '(' + wit_args(func.params()) + ')') | trim | indent(4) }} + +{% endfor %} diff --git a/scripts/generate_exceptions/__init__.py b/scripts/generate_exceptions/__init__.py new file mode 100644 index 0000000..57d5b6b --- /dev/null +++ b/scripts/generate_exceptions/__init__.py @@ -0,0 +1,12 @@ +"""Generator for the fastly_compute/exceptions/ hierarchy. + +Reads WIT via wasm-tools and generates Python exception classes for every +result error type in the Fastly Compute API, inheriting names and docstrings +from the WIT. + +Generation is informed by the WIT JSON rather than componentize-py's generated +stubs — the JSON approach is more reliable since stubs lose type relationship +information (e.g. which errors each function can actually return). + +This runs at SDK-build time; customers don't run it. +""" diff --git a/scripts/generate_exceptions/__main__.py b/scripts/generate_exceptions/__main__.py new file mode 100644 index 0000000..d2de5bf --- /dev/null +++ b/scripts/generate_exceptions/__main__.py @@ -0,0 +1,6 @@ +"""Entry point: generate fastly_compute/exceptions/ from WIT.""" + +from .generation import generate + +if __name__ == "__main__": + generate() diff --git a/scripts/generate_patches/generation.py b/scripts/generate_exceptions/generation.py old mode 100755 new mode 100644 similarity index 61% rename from scripts/generate_patches/generation.py rename to scripts/generate_exceptions/generation.py index 8cbbc1f..1cd7254 --- a/scripts/generate_patches/generation.py +++ b/scripts/generate_exceptions/generation.py @@ -1,5 +1,4 @@ -"""Top level of the code-generation that makes exception-raising more idiomatic -in Fastly SDK routines +"""Generate fastly_compute/exceptions/ from WIT result error types. Handles high-level logic and writing to the filesystem. """ @@ -14,14 +13,17 @@ from jinja2 import Environment, PackageLoader, Template, TemplateNotFound -from .wit import Function, NullType, Type, Wit +from scripts.wit.types import NullType, Type +from scripts.wit.wit import Wit WIT_DIR = "wit" FASTLY_COMPUTE = Path(__file__).parent.parent.parent / "fastly_compute" jinja_env = Environment( - loader=PackageLoader("scripts.generate_patches"), autoescape=False + loader=PackageLoader("scripts.generate_exceptions"), + autoescape=False, + keep_trailing_newline=True, ) @@ -56,7 +58,7 @@ def generate_exceptions(error_types: Iterable[Type]): # Create package's __init__.py if not already there: if package not in package_docstrings: - package_docstrings[package] = error_type.interface().docstring(indent=0) + package_docstrings[package] = error_type.interface_docstring(indent=0) # Common superclass for exceptions based on the enum or variant's # members. Or the raised exception itself for records. @@ -93,63 +95,6 @@ def generate_exceptions(error_types: Iterable[Type]): ) -def generate_patches( - error_types: Iterable[Type], functions_to_patch: Iterable[Function] -): - """Generate code which makes componentize-py-generated routines raise more - specific, idiomatically shaped exceptions. - - Map componentize-py's Err values to specific exceptions. Generate - monkeypatches that wrap componentize-py's generated Python routines to raise - them. - """ - # Collect info: - mappings = set() - wit_imports = set() - fastly_imports = set() - for error_type in error_types: - # Get where it is found in wit_world. Use shallow imports to avoid collisions. - wit_imports.add(error_type.wit_module_path()) - fastly_imports.add(error_type.py_module_path()) - if error_type.has_cases(): - # We need only add the cases; it doesn't make sense in WIT to return - # the Enum or Variant itself in a result. - for case in error_type.cases(): - mappings.add( - ( - case.wit_path(), - error_type.py_module_path(), - case.py_exception_name(), - ) - ) - else: - mappings.add( - ( - error_type.wit_path(), - error_type.py_module_path(), - error_type.py_exception_name(), - ) - ) - - # Collect import paths for the functions themselves: - for func in functions_to_patch: - wit_imports.add(func.wit_module_path()) - - # TODO: Maybe automatically improve the docstring of each method to list the - # exceptions it raises. - - write_templated_file( - FASTLY_COMPUTE / "runtime_patching" / "patches.py", - { - "fastly_imports": sorted(fastly_imports), - "wit_imports": sorted(wit_imports), - "mappings": sorted(mappings), - "functions_to_patch": functions_to_patch, - }, - jinja_env.get_template("patches.py.jinja"), - ) - - def join_named_chunks( chunks: dict[str, str], sep: str, omit: list[str] | None = None ) -> str: @@ -191,44 +136,33 @@ def write_templated_file( def generate(): - """Generate idiomatic exceptions and monkeypatches to get WIT functions to - raise them. - - Currently, this handles only ``result`` error types that are variants, - enums, records, or the unit type. It doesn't handle options or primitives, - but it would be straightfoward to expand as necessary. The only interesting - decision to make when expanding is what kind of exception to raise: for - enums, variants, and records, we generate an exception class corresponding - to each case and raise that. But you can't raise a plain int. Maybe raise a - generic FastlyError? We throw a NotImplementedError during generation if we - do encounter something unsupported. + """Generate idiomatic exception classes from WIT result error types. + + Currently handles ``result`` error types that are variants, enums, records, + or the unit type. Primitives and options are not yet supported but would be + straightforward to add. """ wit_text = check_output(["wasm-tools", "component", "wit", WIT_DIR, "--json"]) wit_json = json.loads(wit_text) wit = Wit(wit_json) - # A dict preserves order, for comprehensibility and determinism of generated code: - exceptions_to_generate: dict[Type, bool] = {} - functions_to_patch = [] - # Hunt through our whole fastly-compute package to find the result error # types we return. Each inspires the generation of one exception class (in # the case of records) or more (in the case of variants or enums). + # A dict preserves order, for comprehensibility and determinism. + exceptions_to_generate: dict[Type, bool] = {} + for interface in wit.fastly_compute_package().interfaces(): for function in interface.functions(): if error_type := function.error_type_of_returned_result(): if not isinstance(error_type, NullType): # Null errors (result) are handled by a static - # entry mapping it to FastlyError. + # entry mapping them to FastlyError. # We don't need to go any deeper than the top-level type of - # the result's error. That represents the whole universe of - # Err values the componentize-py-generated bits may raise. - # Those values are what we will promote to exceptions. + # the result's error arm. That represents the whole universe + # of Err values the componentize-py-generated code may raise, + # and those values are what we promote to exceptions. exceptions_to_generate[error_type] = True - # Resource methods are shoved in here too but are - # identifiable: - functions_to_patch.append(function) generate_exceptions(exceptions_to_generate.keys()) - generate_patches(exceptions_to_generate.keys(), functions_to_patch) diff --git a/scripts/generate_patches/templates/default_exception.py.jinja b/scripts/generate_exceptions/templates/default_exception.py.jinja similarity index 75% rename from scripts/generate_patches/templates/default_exception.py.jinja rename to scripts/generate_exceptions/templates/default_exception.py.jinja index fa021d4..3531823 100644 --- a/scripts/generate_patches/templates/default_exception.py.jinja +++ b/scripts/generate_exceptions/templates/default_exception.py.jinja @@ -1,4 +1,4 @@ -# This file is automatically generated by generate_patches. +# This file is automatically generated by scripts/generate_exceptions. # It is not intended for manual editing. {{ module_docstring }} diff --git a/scripts/generate_patches/templates/exception_init_module.py.jinja b/scripts/generate_exceptions/templates/exception_init_module.py.jinja similarity index 95% rename from scripts/generate_patches/templates/exception_init_module.py.jinja rename to scripts/generate_exceptions/templates/exception_init_module.py.jinja index 62eec96..f2c4bda 100644 --- a/scripts/generate_patches/templates/exception_init_module.py.jinja +++ b/scripts/generate_exceptions/templates/exception_init_module.py.jinja @@ -1,2 +1 @@ {{ module_docstring }} - diff --git a/scripts/generate_patches/templates/exceptions/types/error.py.jinja b/scripts/generate_exceptions/templates/exceptions/types/error.py.jinja similarity index 100% rename from scripts/generate_patches/templates/exceptions/types/error.py.jinja rename to scripts/generate_exceptions/templates/exceptions/types/error.py.jinja diff --git a/scripts/generate_patches/__init__.py b/scripts/generate_patches/__init__.py deleted file mode 100755 index 52849ea..0000000 --- a/scripts/generate_patches/__init__.py +++ /dev/null @@ -1,25 +0,0 @@ -"""A generator of Python code which improves upon the ergonomics and -idiomaticness of the WIT-derived code generated by componentize-py - -This runs at SDK-build time; customers don't run it. - -Generation is informed by the WIT. We considered informing it from -componentize-py's generated Python, but that turned out to be infeasibly lossy. -For example, for exception mapping, componentize-py's dataclasses and enum -members (which go into the .value properties of Err exceptions and correspond to -the more specific exceptions we'd like to raise) had nothing relating them to -methods except a brittle "Raises" docstring. And that docstring began to fail -with more complex error types, e.g. ones which are option<>s: -`wit_world.types.Err(wit_world.imports.Optional[Any])` is what comes out of -`result<_, option>`. - -On the surface, one downside of our chosen method is that we assume a stable -relationship of the WIT to componentize-py's generated stubs. But that must -remain stable, or callers would break. A true though slight downside is that -there are quite a few reimplemented bits of knowledge: WIT-interface-to-module -mappings, case conventions, and so on. Finally, there's the error-proneness of -JSON-struct-chasing and the need for wasm-tools. However, none of this is needed -beyond SDK build time, DRY code in the WIT processing should break spectacularly -if at all, and tests that touch any of the customer API should quickly reveal -such breakage. -""" diff --git a/scripts/generate_patches/__main__.py b/scripts/generate_patches/__main__.py deleted file mode 100755 index 8a9a637..0000000 --- a/scripts/generate_patches/__main__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .generation import generate - -generate() diff --git a/scripts/generate_patches/templates/patches.py.jinja b/scripts/generate_patches/templates/patches.py.jinja deleted file mode 100644 index f205c5b..0000000 --- a/scripts/generate_patches/templates/patches.py.jinja +++ /dev/null @@ -1,45 +0,0 @@ -# This file is automatically generated by generate_patches. -# It is not intended for manual editing. -"""Monkeypatches which wrap the routines generated by componentize-py to make -them raise more specific exceptions, not just Err. -""" - -try: - {%- for import_ in wit_imports %} - import {{ import_ }} - {%- endfor %} -{# Skip a line here. #} - {%- for import_ in fastly_imports %} - import {{ import_ }} - {%- endfor %} - - from .decorators import remap_wit_errors -except ImportError: - # Tolerate that momentary import for the testrunner before Viceroy, and thus - # the wit_world, is around. - def patch(): - """Pretend to patch.""" - print("Faking the run of exception-mapping monkeypatches for test runner.") -else: - MAPPINGS = { - {% for wit_path, py_module_path, py_exception_name in mappings -%} - {{wit_path}}: {{py_module_path}}.{{py_exception_name}}, - {% endfor -%} - type(None): fastly_compute.exceptions.FastlyError, - } - - did_patch = False - - def patch(): - """Apply patches if they haven't already been applied.""" - global did_patch - if did_patch: - # This test shouldn't be needed, but it avoids double-wrapping the - # routines if somehow patch() did get called twice. - return - did_patch = True - - {%- for func in functions_to_patch %} - {{ func.wit_path() }} = remap_wit_errors(MAPPINGS)({{ func.wit_path() }}) - {%- endfor %} - diff --git a/scripts/generate_patches/wit.py b/scripts/generate_patches/wit.py deleted file mode 100644 index 4a4bb31..0000000 --- a/scripts/generate_patches/wit.py +++ /dev/null @@ -1,383 +0,0 @@ -"""Abstraction over a WIT file - -Provides affordances for walking among WIT constructs and translating drawing -correspondences between them, componentize-py-generated Python code, and -Fastly's own slightly higher level generated code. -""" -# We override many methods and don't want to clutter the module repeating -# identical docstrings or tagging each with @override. -# ruff: noqa D102 - -import re -from collections.abc import Iterable -import textwrap -from types import NoneType -from typing import Any, Self - -from .utils import lower_snake, only, shouty_snake, upper_camel - - -class DocsHaver: - """A WIT item which has documentation - - Abstract. - """ - - def __init__(self, me: Any): - """Construct. - - :arg me: The JSON representing the WIT-file entity I am - """ - self._me: dict[str, Any] = me - - def docs(self) -> str: - """Return the documentation of the type, "" if omitted. - - Strip leading and trailing whitespace. - """ - return self._me.get("docs", {}).get("contents", "").strip() - - def docstring(self, indent=4) -> str: - """Return a one-level-indented, triple-quoted version of the docs - suitable for use as a docstring in a top-level construct. - """ - docs = self.docs() - if docs: - if docs.count("\n") > 0: # multi-line - docs += "\n" - docs += '"""' - - return '"""' + textwrap.indent(docs, " " * indent).lstrip() - return "" - - -class Thing(DocsHaver): - """Any kind of thing represented in WIT: type, function, etc. - - Abstract. - """ - - def __repr__(self): - return f"<{self.__class__.__name__} {self.name()}>" - - def name(self) -> str: - """Return the name of this type, in usual WIT kebab case.""" - return self._me["name"] - - def wit_module_path(self) -> str: - """Return the full dotted path to the Python module in which I am defined.""" - return "wit_world.imports." + lower_snake(self.interface().name()) - - def wit_path(self): - """Return the dotted path to my definition in wit_world. - - This is used as the key fed to ``remap_wit_errors()`` for a type, - among other things. - """ - return self.wit_module_path() + "." + upper_camel(self.name()) - - def py_exception_name(self) -> str: - """Return my name, fashioned as a suitable name for an exception.""" - return upper_camel(self.name()) - - def interface(self) -> "Interface": - """Return the interface where this type is defined.""" - raise NotImplementedError - - -class Type(Thing): - """A WIT type: primitive, stock, or user-defined. - - In practice, many types, like variants and the unit type, are represented by - more-specific subclasses, leaving this one to stand in for ones we haven't - needed to specialize for yet. - """ - - @classmethod - def from_id( - cls, type_id: int | str | None, wit_json: dict[str, list[dict]] - ) -> Self: - """Construct a type of the given index under the WIT's "types" key. - - If that type is an alias (which is the case when referencing a type from - a different interface), chase it down to its ultimate resolution. - Construct an Enum, Variant, or other more specific class if there is one. - - :arg type_id: The (non-negative) array index of the type in the WIT's type array - :arg wit_json: The entire JSON-decided WIT file - """ - while True: - if isinstance(type_id, str): - # It's a primitive. - return cls(type_id, {"name": type_id}, wit_json) - elif isinstance(type_id, NoneType): - return NullType(wit_json) - - # It's an int. Chase that down, including following type aliases. - current_type = wit_json["types"][type_id] - next_type = current_type["kind"].get("type") - if next_type is None: - kind = only(current_type["kind"].keys()) - class_ = KINDS_TO_CLASSES.get(kind, cls) - return class_(type_id, current_type, wit_json) - else: - # It's a pointer to a different tyoe. - type_id = next_type - - def __init__(self, id: int | str, type_: dict, wit_json: dict[str, list[dict]]): - """Private constructor. Use from_id() instead.""" - super().__init__(type_) - self._id: int | str = id - self._wit = wit_json - - def __hash__(self): - """Let us put Types into dicts and constrain them unique. - - Type instances compare and hash based on their IDs: their positions in - the WIT's type array. Only non-alias type IDs occur in instances. - """ - return hash(self._id) - - def __eq__(self, other): - return self._id == other._id - - def __ne__(self, other): - return not (self == other) - - def has_cases(self) -> bool: - return False - - def cases(self) -> Iterable["Case"]: - """Return the cases of an type if it has them, else an empty iterable.""" - return [] - - def interface(self) -> "Interface": - return Interface( - self._wit["interfaces"][self._me["owner"]["interface"]], self._wit - ) - - def py_package(self) -> str: - """Return the innermost, undotted package in which this type resides.""" - return lower_snake(self.interface().name()) - - def py_module(self) -> str: - """Return the name of the file (minus ".py") in which the exception - corresponding to this type resides. - """ - raise NotImplementedError( - "Only variants, enums, and records are currently handled as " - "``result`` error types. Looks like it's time to support others!" - ) - - def py_module_path(self) -> str: - """Return the dotted import path of the module holding the exception - corresponding to this type. - """ - raise NotImplementedError( - "Only variants, enums, and records are currently handled as " - "``result`` error types. Looks like it's time to support others!" - ) - - -class Result(Type): - """A WIT ``result``""" - - def error_type(self) -> Type: - """Return the type of my error case.""" - return self.from_id(self._me["kind"]["result"]["err"], self._wit) - - -class NullType(Type): - def __init__(self, wit_json): - super().__init__("null", {"name": "null"}, wit_json) - - -class Record(Type): - """A WIT ``record`` type""" - - def py_module(self) -> str: - return "__init__" - - def py_module_path(self) -> str: - return f"fastly_compute.exceptions.{self.py_package()}" - - -class CaseHaver(Type): - """Abstract WIT type that has cases""" - - _case_class: type - - def has_cases(self) -> bool: - return True - - def cases(self): - return ( - self._case_class(c, self) for c in self._me["kind"][self._case_key]["cases"] - ) - - def py_module(self) -> str: - return lower_snake(self.name()) - - def py_module_path(self) -> str: - return f"fastly_compute.exceptions.{self.py_package()}.{self.py_module()}" - - -class Case(Thing): - """Abstract arm of a WIT type that has alternative manifestations.""" - - def __init__(self, case_json: dict[str, Any], haver: CaseHaver): - super().__init__(case_json) - self._haver = haver - - -class EnumCase(Case): - """An arm of a WIT ``enum``""" - - def wit_path(self) -> str: - return self._haver.wit_path() + "." + shouty_snake(self.name()) - - -class VariantCase(Case): - """An arm of a WIT ``variant``""" - - def wit_path(self) -> str: - return ( - self._haver.wit_module_path() - + "." - + upper_camel(self._haver.name()) - + "_" - + upper_camel(self.name()) - ) - - -class Enum(CaseHaver): - _case_key = "enum" - _case_class = EnumCase - - -class Variant(CaseHaver): - _case_key = "variant" - _case_class = VariantCase - - -KINDS_TO_CLASSES = { - "enum": Enum, - "variant": Variant, - "record": Record, - "result": Result, -} - - -METHOD_RE = re.compile(r"\[(static|method|constructor)\]([a-z0-9%-]+)\.([a-z0-9%-]+)") -FREESTANDING_FUNCTION_RE = re.compile(r"[a-z0-9%-]+") - - -class Function(Thing): - """A function or resource method in a WIT""" - - def __init__( - self, - function_json: dict[str, Any], - interface_json: dict[str, Any], - wit_json: dict[str, list[dict]], - ): - super().__init__(function_json) - self._interface = interface_json - self._wit = wit_json - - def interface(self) -> "Interface": - """Return the interface to which I belong.""" - return Interface(self._interface, self._wit) - - def wit_path(self) -> str: - """Return the dotted path to my definition in wit_world. - - This is used as the key fed to ``remap_wit_errors()`` for this type, - among other things. - """ - name = self._me["name"] - if match := METHOD_RE.match(name): - return ( - self.wit_module_path() - + "." - + upper_camel(match.group(2)) - + "." - + lower_snake(match.group(3)) - ) - elif FREESTANDING_FUNCTION_RE.match(name): - return self.wit_module_path() + "." + lower_snake(name) - else: - raise NotImplementedError( - f'A new and exciting kind of function needs to be recognized. Its name field is "{name}".' - ) - - def error_type_of_returned_result(self) -> Type | None: - """If this Function returns a single ``result`` type, return the type of its error case. - - Otherwise, return None. - """ - return_type = Type.from_id(self._me.get("result"), self._wit) - if isinstance(return_type, Result): - return return_type.error_type() - - -class Interface(DocsHaver): - """A WIT interface""" - - def __init__(self, interface_json: dict[str, Any], wit_json: dict[str, list[dict]]): - self._me = interface_json - self._wit = wit_json - - def name(self) -> str: - return self._me["name"] - - def functions(self) -> Iterable[Function]: - """Return the functions and methods defined in this interface.""" - for function in self._me["functions"].values(): - yield Function(function, self._me, self._wit) - - -class Package: - """A WIT package""" - - def __init__(self, package_json: dict, wit_json: dict[str, list[dict]]): - self._package = package_json - self._wit = wit_json - - def interfaces(self) -> Iterable[Interface]: - """Return the interfaces defined in this package.""" - for interface_num in self._package["interfaces"].values(): - yield Interface(self._wit["interfaces"][interface_num], self._wit) - - -class Wit: - """A WIT file - - This provides an abstraction layer atop the output of ``wasm-tools component - wit --json``. It begins a tree of classes which work their way steadily - narrower into the WIT: package, then interface, then function or type. They - are instantiated lazily, for the most part, retaining unprocessed bits of - JSON for later instantiation. - """ - - def __init__(self, wit_json: dict[str, list[dict]]): - """Construct. - - :arg wit_json: The loaded JSON out of ``wasm-tools component wit wit/ --json`` - """ - self._packages: dict[str, Package] = { - p["name"]: Package(p, wit_json) for p in wit_json["packages"] - } - - def package(self, name: str) -> Package: - """Return a package of the given name, e.g. - "fastly:compute@0.0.0-prerelease.0", or raise KeyError. - """ - return self._packages[name] - - def fastly_compute_package(self) -> Package: - """Return the package representing the Fastly Compute API.""" - package_name = only( - [p for p in self._packages.keys() if p.startswith("fastly:compute@")] - ) - return self.package(package_name) diff --git a/scripts/wit/__init__.py b/scripts/wit/__init__.py new file mode 100644 index 0000000..588d8c5 --- /dev/null +++ b/scripts/wit/__init__.py @@ -0,0 +1 @@ +"""WIT file abstraction layer for Fastly Compute SDK code generation.""" diff --git a/scripts/wit/base.py b/scripts/wit/base.py new file mode 100644 index 0000000..ff3c5c1 --- /dev/null +++ b/scripts/wit/base.py @@ -0,0 +1,126 @@ +"""Base classes shared across the WIT abstraction layer. + +DocsHaver and Thing are the documentation and identity base classes used by +both the type system (types.py) and the structural traversal layer (wit.py). +Kept separate to avoid circular imports between those two modules. +""" + +# ruff: noqa D102 + +import re +import textwrap +from typing import Any + +from .utils import lower_snake, upper_camel + + +class DocsHaver: + """A WIT item which has documentation + + Abstract. + """ + + def __init__(self, me: Any): + """Construct. + + :arg me: The JSON representing the WIT-file entity I am + """ + self._me: dict[str, Any] = me + + def docs(self) -> str: + """Return the documentation of the type, "" if omitted. + + Strip leading and trailing whitespace. + """ + return self._me.get("docs", {}).get("contents", "").strip() + + def docs_for_python(self) -> str: + """Return docs() with high-confidence WIT-isms rewritten to Python idioms. + + Transformations applied: + - ``ok(some(X))`` → ``X`` (unwrapped option inside ok) + - ``ok(none)`` → ``None`` (absent option inside ok) + - ``ok(X)`` → ``X`` (plain ok value) + - ``none`` → ``None`` + - ``true`` → ``True`` + - ``false`` → ``False`` + - kebab-case identifiers inside backticks → snake_case + e.g. ``kv-store`` → ``kv_store`` + + ``err(...)`` phrases are left untouched — they don't have a clean + mechanical replacement and the ``:raises`` block already covers the + intent. + """ + text = self.docs() + if not text: + return text + + # ok(some(X)) → X (must come before ok(X) so the longer form matches first) + text = re.sub(r"`ok\(some\(([^)]+)\)\)`", r"`\1`", text) + # ok(none) → None + text = re.sub(r"`ok\(none\)`", "`None`", text) + # ok(X) → X (plain ok wrapping a value) + text = re.sub(r"`ok\(([^)]+)\)`", r"`\1`", text) + # standalone `none` → `None` + text = re.sub(r"`none`", "`None`", text) + # `true` / `false` → `True` / `False` + text = re.sub(r"`true`", "`True`", text) + text = re.sub(r"`false`", "`False`", text) + # kebab-case identifiers inside backticks → snake_case + # Only matches pure kebab identifiers (letters, digits, hyphens). + text = re.sub( + r"`([a-z][a-z0-9]*(?:-[a-z0-9]+)+)`", + lambda m: "`" + m.group(1).replace("-", "_") + "`", + text, + ) + return text + + def docstring(self, indent=4) -> str: + """Return a one-level-indented, triple-quoted version of the docs + suitable for use as a docstring in a top-level construct. + """ + docs = self.docs() + if docs: + if docs.count("\n") > 0: # multi-line + docs += "\n" + docs += '"""' + + # Use a raw string if the doc contains backslashes so that ruff + # doesn't flag it with D301. + prefix = 'r"""' if "\\" in docs else '"""' + return prefix + textwrap.indent(docs, " " * indent).lstrip() + return "" + + +class Thing(DocsHaver): + """Any kind of thing represented in WIT: type, function, etc. + + Abstract. + """ + + def __repr__(self): + return f"<{self.__class__.__name__} {self.name()}>" + + def name(self) -> str: + """Return the name of this type, in usual WIT kebab case.""" + return self._me["name"] + + def interface_name(self) -> str: + """Return the name of the interface where this thing is defined.""" + raise NotImplementedError + + def wit_module_path(self) -> str: + """Return the full dotted path to the Python module in which I am defined.""" + return "wit_world.imports." + lower_snake(self.interface_name()) + + def wit_path(self): + """Return the dotted path to my definition in wit_world. + + This is used as the key fed to ``remap_wit_errors()`` for a type, + among other things. + """ + return self.wit_module_path() + "." + upper_camel(self.name()) + + def py_exception_name(self) -> str: + """Return my name, fashioned as a suitable name for an exception.""" + return upper_camel(self.name()) diff --git a/scripts/wit/types.py b/scripts/wit/types.py new file mode 100644 index 0000000..b51fac4 --- /dev/null +++ b/scripts/wit/types.py @@ -0,0 +1,489 @@ +"""WIT type system classes. + +Covers the full hierarchy of types that appear in a WIT file: primitives, +resources, handles, options, lists, tuples, flags, records, enums, variants, +and the case types that populate them. + +Maps directly onto entries in the ``types`` array of the WIT JSON produced by +``wasm-tools component wit --json``. +""" + +# ruff: noqa D102 + +import re +from collections.abc import Iterable +from types import NoneType +from typing import Any, Self + +from .base import DocsHaver, Thing +from .utils import lower_snake, only, shouty_snake, upper_camel + + +# WIT primitive type -> Python annotation string +_PRIMITIVES: dict[str, str] = { + "string": "str", + "bool": "bool", + "u8": "int", + "u16": "int", + "u32": "int", + "u64": "int", + "s8": "int", + "s16": "int", + "s32": "int", + "s64": "int", + "f32": "float", + "f64": "float", + "char": "str", + "bytes": "bytes", +} + + +class Type(Thing): + """A WIT type: primitive, stock, or user-defined. + + In practice, many types, like variants and the unit type, are represented by + more-specific subclasses, leaving this one to stand in for ones we haven't + needed to specialize for yet. + """ + + @classmethod + def from_id( + cls, type_id: int | str | None, wit_json: dict[str, list[dict]] + ) -> "Type": + """Construct a type of the given index under the WIT's "types" key. + + If that type is an alias (which is the case when referencing a type from + a different interface), chase it down to its ultimate resolution. + Construct an Enum, Variant, or other more specific class if there is one. + + :arg type_id: The (non-negative) array index of the type in the WIT's type array + :arg wit_json: The entire JSON-decided WIT file + """ + while True: + if isinstance(type_id, str): + # It's a primitive — always a base Type regardless of calling class. + return Type(type_id, {"name": type_id}, wit_json) + elif isinstance(type_id, NoneType): + return NullType(wit_json) + + # It's an int. Chase that down, including following type aliases. + current_type = wit_json["types"][type_id] + kind_val = current_type["kind"] + # Resources have kind = "resource" (a bare string, not a dict). + if isinstance(kind_val, str): + class_ = KINDS_TO_CLASSES.get(kind_val, Type) + return class_(type_id, current_type, wit_json) + next_type = kind_val.get("type") + if next_type is None: + kind = only(kind_val.keys()) + class_ = KINDS_TO_CLASSES.get(kind, Type) + return class_(type_id, current_type, wit_json) + else: + # It's a pointer to a different type. + type_id = next_type + + def __init__(self, id: int | str, type_: dict, wit_json: dict[str, list[dict]]): + """Private constructor. Use from_id() instead.""" + super().__init__(type_) + self._id: int | str = id + self._wit = wit_json + + def __hash__(self): + """Let us put Types into dicts and constrain them unique. + + Type instances compare and hash based on their IDs: their positions in + the WIT's type array. Only non-alias type IDs occur in instances. + """ + return hash(self._id) + + def __eq__(self, other): + return self._id == other._id + + def __ne__(self, other): + return not (self == other) + + def interface_name(self) -> str: + """Return the name of the interface that owns this type.""" + iface_index = self._me["owner"]["interface"] + return self._wit["interfaces"][iface_index]["name"] + + def interface_docstring(self, indent: int = 0) -> str: + """Return the docstring of the interface that owns this type.""" + iface_index = self._me["owner"]["interface"] + iface_json = self._wit["interfaces"][iface_index] + contents = iface_json.get("docs", {}).get("contents", "").strip() + if not contents: + return "" + # Reuse DocsHaver.docstring() logic inline — we don't have a DocsHaver + # for the interface here, just its raw JSON. + import textwrap + + if contents.count("\n") > 0: + contents += "\n" + contents += '"""' + prefix = 'r"""' if "\\" in contents else '"""' + return prefix + textwrap.indent(contents, " " * indent).lstrip() + + def has_cases(self) -> bool: + return False + + def cases(self) -> Iterable["Case"]: + """Return the cases of an type if it has them, else an empty iterable.""" + return [] + + def py_package(self) -> str: + """Return the innermost, undotted package in which this type resides.""" + return lower_snake(self.interface_name()) + + def py_module(self) -> str: + """Return the name of the file (minus ".py") in which the exception + corresponding to this type resides. + """ + raise NotImplementedError( + "Only variants, enums, and records are currently handled as " + "``result`` error types. Looks like it's time to support others!" + ) + + def py_module_path(self) -> str: + """Return the dotted import path of the module holding the exception + corresponding to this type. + """ + raise NotImplementedError( + "Only variants, enums, and records are currently handled as " + "``result`` error types. Looks like it's time to support others!" + ) + + def py_annotation(self, self_resource: "Resource | None" = None) -> str: + """Return the Python type annotation string for this type. + + :arg self_resource: When resolving a handle to a resource that is the + same resource as the method being generated, use ``Self`` instead of + the class name. Pass the owning resource to enable this. + """ + # Primitive types (id is a string like "string", "u32", etc.) + if isinstance(self._id, str): + return _PRIMITIVES.get(self._id, self._id) + # Named types with no special handling fall back to their upper-camel name + name = self._me.get("name") + if name: + return upper_camel(name) + return "Any" + + +class Result(Type): + """A WIT ``result``""" + + def error_type(self) -> Type: + """Return the type of my error case.""" + return self.from_id(self._me["kind"]["result"]["err"], self._wit) + + def ok_type(self) -> "Type": + """Return the type of the ok case, or NullType if unit.""" + return self.from_id(self._me["kind"]["result"].get("ok"), self._wit) + + def py_annotation(self, self_resource=None) -> str: + return self.ok_type().py_annotation(self_resource) + + +class NullType(Type): + def __init__(self, wit_json): + super().__init__("null", {"name": "null"}, wit_json) + + def interface_name(self) -> str: + return "" + + def interface_docstring(self, indent: int = 0) -> str: + return "" + + def py_annotation(self, self_resource=None) -> str: + return "None" + + +class Resource(Type): + """A WIT ``resource`` type.""" + + def py_annotation(self, self_resource=None) -> str: + return upper_camel(self._me["name"]) + + def bindings_class_name(self) -> str: + """The Python class name for this resource in the generated bindings.""" + return upper_camel(self._me["name"]) + + def bindings_module_path(self) -> str: + """The dotted import path of the generated bindings module for this resource's interface.""" + return "fastly_compute._bindings." + lower_snake(self.interface_name()) + + +class Handle(Type): + """A WIT ``handle`` (own or borrow).""" + + def _resource_type(self) -> Resource: + """Resolve the resource this handle points to.""" + handle_kind = self._me["kind"]["handle"] + resource_id = handle_kind.get("own") or handle_kind.get("borrow") + t = Type.from_id(resource_id, self._wit) + assert isinstance(t, Resource), f"Handle points to non-resource type: {t}" + return t + + def is_own(self) -> bool: + return "own" in self._me["kind"]["handle"] + + def py_annotation(self, self_resource=None) -> str: + resource = self._resource_type() + if self_resource is not None and resource == self_resource: + return "Self" + return resource.py_annotation() + + +class Option(Type): + """A WIT ``option``.""" + + def inner_type(self) -> Type: + return Type.from_id(self._me["kind"]["option"], self._wit) + + def py_annotation(self, self_resource=None) -> str: + inner = self.inner_type().py_annotation(self_resource) + return f"{inner} | None" + + +class ListType(Type): + """A WIT ``list``.""" + + def inner_type(self) -> Type: + return Type.from_id(self._me["kind"]["list"], self._wit) + + def py_annotation(self, self_resource=None) -> str: + # list is the WIT encoding of a byte buffer; map it to bytes + # rather than list[int] since that is what callers actually pass. + if self._me["kind"]["list"] == "u8": + return "bytes" + inner = self.inner_type().py_annotation(self_resource) + return f"list[{inner}]" + + +class TupleType(Type): + """A WIT ``tuple``.""" + + def py_annotation(self, self_resource=None) -> str: + items = self._me["kind"]["tuple"]["types"] + parts = [Type.from_id(t, self._wit).py_annotation(self_resource) for t in items] + return f"tuple[{', '.join(parts)}]" + + +class Flags(Type): + """A WIT ``flags`` type -- re-exported from wit_world.""" + + def py_annotation(self, self_resource=None) -> str: + return upper_camel(self._me["name"]) + + +class Record(Type): + """A WIT ``record`` type""" + + def py_module(self) -> str: + return "__init__" + + def py_module_path(self) -> str: + return f"fastly_compute.exceptions.{self.py_package()}" + + def py_annotation(self, self_resource=None) -> str: + return upper_camel(self._me["name"]) + + def fields(self) -> list["RecordField"]: + """Return the fields of this record.""" + return [RecordField(f, self._wit) for f in self._me["kind"]["record"]["fields"]] + + def wit_class_name(self) -> str: + """The componentize-py class name for this record (e.g. InsertOptions).""" + return upper_camel(self._me["name"]) + + +class RecordField(DocsHaver): + """A field of a WIT record.""" + + def __init__(self, field_json: dict, wit_json: dict): + super().__init__(field_json) + self._wit = wit_json + + def name(self) -> str: + """Python snake_case field name, avoiding reserved keywords.""" + n = lower_snake(self._me["name"]) + # Append underscore to avoid clashing with Python reserved words + if n in ( + "from", + "import", + "class", + "def", + "return", + "pass", + "in", + "is", + "not", + "and", + "or", + "if", + "else", + "for", + "while", + "with", + "as", + "try", + "except", + "finally", + "raise", + "del", + "global", + "nonlocal", + "lambda", + "yield", + "assert", + "break", + "continue", + "type", + ): + return n + "_" + return n + + def wit_name(self) -> str: + """Raw WIT kebab-case field name.""" + return self._me["name"] + + def type_(self) -> "Type": + return Type.from_id(self._me["type"], self._wit) + + def is_optional(self) -> bool: + return isinstance(self.type_(), Option) + + def is_extra(self) -> bool: + """True if this is an extensibility handle field (should be hidden).""" + return self._me["name"].startswith("extra") + + def annotation(self) -> str: + return self.type_().py_annotation() + + def needs_unwrap(self) -> bool: + """True if this field holds a resource handle (unwrap via ._wit_resource).""" + t = self.type_() + if isinstance(t, Option): + return isinstance(t.inner_type(), Handle) + return isinstance(t, Handle) + + def needs_record_unwrap(self) -> bool: + """True if this field holds a record wrapper (unwrap via ._wit).""" + t = self.type_() + if isinstance(t, Option): + return isinstance(t.inner_type(), Record) + return isinstance(t, Record) + + def param_doc(self) -> str: + """Return docs_for_python() normalized for use as a single-line :param entry. + + Collapses newlines and runs of whitespace to a single space so that the + full field description fits on one line without breaking Sphinx parsing. + """ + text = self.docs_for_python() + if not text: + return "" + return re.sub(r"\s+", " ", text).strip() + + def default(self) -> str: + """Python literal for a sensible default value for this field.""" + t = self.type_() + if isinstance(t, Option): + return "None" + if isinstance(t, NullType): + return "None" + # Primitive defaults + ann = t.py_annotation() + if ann == "bool": + return "False" + if ann == "int": + return "0" + if ann == "float": + return "0.0" + if ann == "str": + return '""' + # Enum: use the first case + if isinstance(t, (Enum, Variant)): + cases = list(t.cases()) + if cases: + return f"{upper_camel(t.name())}.{shouty_snake(cases[0].name())}" + return "None" + + +class CaseHaver(Type): + """Abstract WIT type that has cases""" + + _case_class: type + + def has_cases(self) -> bool: + return True + + def cases(self): + return ( + self._case_class(c, self) for c in self._me["kind"][self._case_key]["cases"] + ) + + def py_module(self) -> str: + return lower_snake(self.name()) + + def py_module_path(self) -> str: + return f"fastly_compute.exceptions.{self.py_package()}.{self.py_module()}" + + def py_annotation(self, self_resource=None) -> str: + return upper_camel(self._me["name"]) + + +class Case(Thing): + """Abstract arm of a WIT type that has alternative manifestations.""" + + def __init__(self, case_json: dict[str, Any], haver: CaseHaver): + super().__init__(case_json) + self._haver = haver + + def interface_name(self) -> str: + return self._haver.interface_name() + + +class EnumCase(Case): + """An arm of a WIT ``enum``""" + + def wit_path(self) -> str: + return self._haver.wit_path() + "." + shouty_snake(self.name()) + + +class VariantCase(Case): + """An arm of a WIT ``variant``""" + + def wit_path(self) -> str: + return ( + self._haver.wit_module_path() + + "." + + upper_camel(self._haver.name()) + + "_" + + upper_camel(self.name()) + ) + + +class Enum(CaseHaver): + _case_key = "enum" + _case_class = EnumCase + + +class Variant(CaseHaver): + _case_key = "variant" + _case_class = VariantCase + + +KINDS_TO_CLASSES = { + "enum": Enum, + "variant": Variant, + "record": Record, + "result": Result, + "resource": Resource, + "handle": Handle, + "option": Option, + "list": ListType, + "tuple": TupleType, + "flags": Flags, +} diff --git a/scripts/generate_patches/utils.py b/scripts/wit/utils.py similarity index 92% rename from scripts/generate_patches/utils.py rename to scripts/wit/utils.py index d1c1a92..325774c 100644 --- a/scripts/generate_patches/utils.py +++ b/scripts/wit/utils.py @@ -1,4 +1,4 @@ -"""Little helpers used in patch generation""" +"""Little helpers used in WIT-based code generation.""" def only(iterable): diff --git a/scripts/wit/wit.py b/scripts/wit/wit.py new file mode 100644 index 0000000..46b3f39 --- /dev/null +++ b/scripts/wit/wit.py @@ -0,0 +1,457 @@ +"""WIT structural traversal: Function, Param, Interface, Package, Wit. + +This module covers the parts of a WIT file that describe structure and +behaviour — functions, their parameters, and the interfaces and packages +that group them. The type system (Type and all its subclasses) lives in +types.py. Base classes (DocsHaver, Thing) live in base.py. +""" + +# ruff: noqa D102 + +import re +import textwrap +from collections.abc import Iterable +from typing import Any + +from .base import DocsHaver, Thing +from .types import ( + CaseHaver, + Handle, + Option, + Record, + Resource, + Result, + TupleType, + Type, +) +from .utils import lower_snake, only, upper_camel + +METHOD_RE = re.compile(r"\[(static|method|constructor)\]([a-z0-9%-]+)\.([a-z0-9%-]+)") +CONSTRUCTOR_RE = re.compile(r"\[constructor\]([a-z0-9%-]+)") +FREESTANDING_FUNCTION_RE = re.compile(r"[a-z0-9%-]+") + + +class Function(Thing): + """A function or resource method in a WIT""" + + def __init__( + self, + function_json: dict[str, Any], + interface_json: dict[str, Any], + wit_json: dict[str, list[dict]], + ): + super().__init__(function_json) + self._interface = interface_json + self._wit = wit_json + + def interface(self) -> "Interface": + """Return the interface to which I belong.""" + return Interface(self._interface, self._wit) + + def interface_name(self) -> str: + return self._interface["name"] + + def wit_path(self) -> str: + """Return the dotted path to my definition in wit_world.""" + name = self._me["name"] + if match := METHOD_RE.match(name): + return ( + self.wit_module_path() + + "." + + upper_camel(match.group(2)) + + "." + + lower_snake(match.group(3)) + ) + elif FREESTANDING_FUNCTION_RE.match(name): + return self.wit_module_path() + "." + lower_snake(name) + else: + raise NotImplementedError( + f'A new and exciting kind of function needs to be recognized. Its name field is "{name}".' + ) + + def kind(self) -> str: + """Return 'static', 'method', 'constructor', or 'freestanding'.""" + name = self._me["name"] + if match := METHOD_RE.match(name): + return match.group(1) + if CONSTRUCTOR_RE.match(name): + return "constructor" + return "freestanding" + + def resource_name(self) -> str | None: + """Return the WIT kebab-case resource name this method belongs to, or None.""" + name = self._me["name"] + if match := METHOD_RE.match(name): + return match.group(2) + if match := CONSTRUCTOR_RE.match(name): + return match.group(1) + return None + + def py_name(self) -> str: + """Return the Python method/function name (snake_case). + + Constructors are named ``new`` since they take no arguments and + return an owned instance of the resource. + """ + name = self._me["name"] + if match := METHOD_RE.match(name): + return lower_snake(match.group(3)) + if CONSTRUCTOR_RE.match(name): + return "new" + return lower_snake(name) + + def params(self) -> list["Param"]: + """Return parameters, excluding the implicit 'self' borrow handle.""" + result = [] + for p in self._me.get("params", []): + if p["name"] == "self": + continue + result.append(Param(p, self._wit)) + return result + + def return_annotation(self, self_resource: "Resource | None" = None) -> str: + """Return the Python return type annotation string. + + For result the ok type T is returned; errors are handled by the decorator. + """ + return Type.from_id(self._me.get("result"), self._wit).py_annotation( + self_resource + ) + + def returns_resource_handle(self) -> bool: + """Return True if the ok return type is an owned resource handle. + + Freestanding functions (and non-Self static methods) that return a + resource handle need to wrap the raw WIT return value in the binding + class, e.g. ``return ClassName(_wit.fn(...))``. + """ + ret_id = self._me.get("result") + if ret_id is None: + return False + t = Type.from_id(ret_id, self._wit) + if isinstance(t, Result): + ok_id = t._me["kind"]["result"].get("ok") + if ok_id is None: + return False + t = Type.from_id(ok_id, self._wit) + if isinstance(t, Option): + t = Type.from_id(t._me["kind"]["option"], self._wit) + return isinstance(t, Handle) and t.is_own() + + def return_wrap_expression(self, call_expr: str) -> str: + """Return a Python expression that wraps ``call_expr`` for owned handles. + + Returns ``ClassName(call_expr)`` for owned handle returns, or + ``call_expr`` unchanged for everything else. For tuple returns + use ``tuple_return_parts()`` instead. + + :arg call_expr: The raw WIT call expression + """ + ret_id = self._me.get("result") + if ret_id is None: + return call_expr + t = Type.from_id(ret_id, self._wit) + if isinstance(t, Result): + ok_id = t._me["kind"]["result"].get("ok") + if ok_id is None: + return call_expr + t = Type.from_id(ok_id, self._wit) + if isinstance(t, Option): + t = Type.from_id(t._me["kind"]["option"], self._wit) + if isinstance(t, Handle) and t.is_own(): + cls_name = t._resource_type().py_annotation() + return f"{cls_name}({call_expr})" + return call_expr + + def _unwrap_to_tuple(self) -> "TupleType | None": + """Chase result/option wrappers to a TupleType, or return None.""" + ret_id = self._me.get("result") + if ret_id is None: + return None + t = Type.from_id(ret_id, self._wit) + if isinstance(t, Result): + ok_id = t._me["kind"]["result"].get("ok") + if ok_id is None: + return None + t = Type.from_id(ok_id, self._wit) + if isinstance(t, Option): + t = Type.from_id(t._me["kind"]["option"], self._wit) + if isinstance(t, TupleType): + return t + return None + + def tuple_is_optional(self) -> bool: + """Return True if the tuple return is wrapped in option<...>.""" + ret_id = self._me.get("result") + if ret_id is None: + return False + t = Type.from_id(ret_id, self._wit) + if isinstance(t, Result): + ok_id = t._me["kind"]["result"].get("ok") + if ok_id is None: + return False + t = Type.from_id(ok_id, self._wit) + return isinstance(t, Option) + + def returns_tuple_with_handles(self) -> bool: + """Return True if the ok return type is a tuple containing owned handles.""" + tup = self._unwrap_to_tuple() + if tup is None: + return False + for item_id in tup._me["kind"]["tuple"]["types"]: + item_t = Type.from_id(item_id, self._wit) + if isinstance(item_t, Handle) and item_t.is_own(): + return True + return False + + def tuple_return_parts(self) -> list[str]: + """For tuple-returning functions, return a list of wrap expressions per element. + + Each element is either ``'_r[i]'`` (no wrap) or ``'ClassName(_r[i])'`` (wrap). + The caller assigns the raw WIT return to ``_r`` and returns a tuple of these. + """ + tup = self._unwrap_to_tuple() + assert tup is not None + parts = [] + for i, item_id in enumerate(tup._me["kind"]["tuple"]["types"]): + item_t = Type.from_id(item_id, self._wit) + if isinstance(item_t, Handle) and item_t.is_own(): + cls_name = item_t._resource_type().py_annotation() + parts.append(f"{cls_name}(_r[{i}])") + else: + parts.append(f"_r[{i}]") + return parts + + def raises_errors(self) -> bool: + """Return True if this function returns a result type.""" + return self.error_type_of_returned_result() is not None + + def error_type_of_returned_result(self) -> Type | None: + """If this Function returns a result type, return the type of its error case.""" + return_type = Type.from_id(self._me.get("result"), self._wit) + if isinstance(return_type, Result): + return return_type.error_type() + + def raises_python(self) -> list[tuple[str, str, str]]: + """Return a list of (short_name, qualified_name, description) for each Python + exception this function can raise, derived from its WIT result error type. + + short_name: the bare class name, e.g. ``KvError`` + qualified_name: dotted import path, e.g. ``fastly_compute.exceptions.kv_store.kv_error.KvError`` + description: the type's docstring (may be empty) + + Emits the parent exception class for the error type rather than each + individual case — the parent is the right type to catch, and the module + it lives in documents the specific subclasses. + """ + error_type = self.error_type_of_returned_result() + if error_type is None: + return [] + + entries: list[tuple[str, str, str]] = [] + + if isinstance(error_type, CaseHaver): + short = upper_camel(error_type.name()) + qualified = error_type.py_module_path() + "." + short + desc = error_type.docstring(indent=0) or "" + entries.append((short, qualified, desc)) + elif isinstance(error_type, Record): + short = error_type.wit_class_name() + qualified = error_type.py_module_path() + "." + short + desc = error_type.docstring(indent=0) or "" + entries.append((short, qualified, desc)) + + return entries + + def docstring_with_raises( + self, indent: int = 4, fallback: str | None = None + ) -> str: + """Return a complete triple-quoted docstring, appending :raises lines. + + If the function has no WIT docs and no raises entries, returns the + fallback string (defaulting to the function name followed by a period). + """ + raises = self.raises_python() + raw_docs = self.docs_for_python() + pad = " " * indent + + if not raw_docs and not raises: + return fallback or f'"""{self.py_name()}."""' + + body_parts: list[str] = [] + if raw_docs: + # textwrap.indent adds `pad` to every non-blank line; lstrip() + # removes the leading pad from the very first line so it sits + # flush against the opening `"""`. rstrip() removes any trailing + # newline so there is exactly one blank line before :raises. + body_parts.append(textwrap.indent(raw_docs.rstrip(), pad).lstrip()) + elif raises: + # No WIT docs but we have raises — use the function name as a + # minimal summary so the :raises lines don't land flush against """. + body_parts.append(f"{self.py_name()}.") + if raises: + if body_parts: + body_parts.append("") # blank line — no trailing whitespace + for _short, qualified, _desc in raises: + body_parts.append(f":raises ~{qualified}:") + # Join lines with newline + pad so continuation lines are indented. + # Blank lines must emit only a bare newline (no pad) to satisfy W293. + out: list[str] = [] + for i, part in enumerate(body_parts): + if i == 0: + out.append(part) + elif part == "": + out.append("\n") + else: + out.append("\n" + pad + part) + body = "".join(out) + + use_raw = "\\" in body + prefix = 'r"""' if use_raw else '"""' + if "\n" in body: + return f'{prefix}{body}\n{pad}"""' + return f'{prefix}{body}"""' + + +class Param: + """A parameter of a WIT function.""" + + def __init__(self, param_json: dict[str, Any], wit_json: dict[str, list[dict]]): + self._me = param_json + self._wit = wit_json + + def name(self) -> str: + """Return the parameter name as a Python identifier (snake_case).""" + return lower_snake(self._me["name"]) + + def wit_name(self) -> str: + """Return the raw WIT parameter name.""" + return self._me["name"] + + def type_(self) -> Type: + """Return the resolved Type for this parameter.""" + return Type.from_id(self._me["type"], self._wit) + + def annotation(self, self_resource: "Resource | None" = None) -> str: + """Return the Python type annotation string.""" + return self.type_().py_annotation(self_resource) + + def needs_unwrap(self) -> bool: + """Return True if this param is a resource handle and must be unwrapped. + + When calling through to the underlying WIT binding, any parameter that + is a generated FastlyResource wrapper must be unwrapped via + ``._wit_resource`` before being passed to the raw WIT method. + """ + return isinstance(self.type_(), Handle) + + def needs_record_unwrap(self) -> bool: + """Return True if this param is a record wrapper and must be unwrapped. + + Record wrapper objects expose a ``._wit`` attribute containing the + underlying WIT record that the raw WIT method expects. + """ + return isinstance(self.type_(), Record) + + +class Interface(DocsHaver): + """A WIT interface""" + + def __init__(self, interface_json: dict[str, Any], wit_json: dict[str, list[dict]]): + self._me = interface_json + self._wit = wit_json + + def name(self) -> str: + return self._me["name"] + + def interface_name(self) -> str: + return self._me["name"] + + def py_module(self) -> str: + """Return the snake_case Python module name for this interface.""" + return lower_snake(self.name()) + + def functions(self) -> Iterable[Function]: + """Return all functions and methods defined in this interface.""" + for function in self._me["functions"].values(): + yield Function(function, self._me, self._wit) + + def resources(self) -> list[Resource]: + """Return resource types defined directly in this interface (not imported aliases).""" + result = [] + for type_id in self._me.get("types", {}).values(): + t = Type.from_id(type_id, self._wit) + if not isinstance(t, Resource): + continue + # Only include resources whose canonical owner is this interface. + owner = t._me.get("owner") + if owner is None: + continue + owner_iface_index = owner.get("interface") + # Find this interface's index in the global interfaces array. + for i, iface in enumerate(self._wit["interfaces"]): + if iface is self._me: + if i == owner_iface_index: + result.append(t) + break + return result + + def methods_for_resource(self, resource: Resource) -> list[Function]: + """Return constructors, static methods, and instance methods for a resource.""" + resource_wit_name = resource.name() + return [ + f + for f in self.functions() + if f.resource_name() == resource_wit_name + and f.kind() in ("constructor", "static", "method") + ] + + def freestanding_functions(self) -> list[Function]: + """Return functions that are not methods or constructors of any resource.""" + return [f for f in self.functions() if f.kind() == "freestanding"] + + +class Package: + """A WIT package""" + + def __init__(self, package_json: dict, wit_json: dict[str, list[dict]]): + self._package = package_json + self._wit = wit_json + + def interfaces(self) -> Iterable[Interface]: + """Return the interfaces defined in this package.""" + for interface_num in self._package["interfaces"].values(): + yield Interface(self._wit["interfaces"][interface_num], self._wit) + + +class Wit: + """A WIT file + + This provides an abstraction layer atop the output of ``wasm-tools component + wit --json``. It begins a tree of classes which work their way steadily + narrower into the WIT: package, then interface, then function or type. They + are instantiated lazily, for the most part, retaining unprocessed bits of + JSON for later instantiation. + """ + + def __init__(self, wit_json: dict[str, list[dict]]): + """Construct. + + :arg wit_json: The loaded JSON out of ``wasm-tools component wit wit/ --json`` + """ + self._packages: dict[str, Package] = { + p["name"]: Package(p, wit_json) for p in wit_json["packages"] + } + + def package(self, name: str) -> Package: + """Return a package of the given name, e.g. + "fastly:compute@0.0.0-prerelease.0", or raise KeyError. + """ + return self._packages[name] + + def fastly_compute_package(self) -> Package: + """Return the package representing the Fastly Compute API.""" + package_name = only( + [p for p in self._packages.keys() if p.startswith("fastly:compute@")] + ) + return self.package(package_name)