Skip to content

Replace Runtime Monkeypatching with Generated WIT Binding Wrappers#84

Open
posborne wants to merge 18 commits into
mainfrom
posborne/codegen-with-type-unwrapping-records
Open

Replace Runtime Monkeypatching with Generated WIT Binding Wrappers#84
posborne wants to merge 18 commits into
mainfrom
posborne/codegen-with-type-unwrapping-records

Conversation

@posborne
Copy link
Copy Markdown
Member

@posborne posborne commented Apr 9, 2026

Disclaimer: this is a large change, but a lot of the noise is from restructuring bits from monkeypatching contributing to the line noise. There is still a lot of new code; I think this is a very compelling path forward but am open to trying to make the change smaller -- that being said, reviewing the generated _bindings layer should provide a solid surface to look at and work backwards from.

Background

Previously the SDK addressed this with runtime monkeypatching — at startup, a generated
patch() function wrapped each wit_world function with a decorator that caught Err values and re-raised them as typed Python exceptions. This approach worked, and its simplicity is worth acknowledging: the WIT bindings were used directly, a small runtime layer handled error translation, and there was little generated code to reason about.

This PR replaces that approach with generated binding wrappers: a code generation pipeline that produces a fastly_compute/_bindings/ layer at SDK build time, wrapping each WIT resource and function with typed, documented Python classes.

In addition to strictly making the transition to codegen, I also tried to address some of the main missing pieces in order to be able to directly re-export the generated wrappers by doing some docs modifications (adding :raises and rewriting witicisms), doing type mapping between wrapper types and wit types (_wit_resource) where required, and adding in functionality that was missing around Record types using the builder pattern so we have something to work against there which includes docs and (hopefully) reasonable defaults.


Why move away from monkeypatching?

  • No IDE support. Users worked with raw wit_world types. Autocomplete and hover docs showed
    WIT internals, not SDK types. :raises documentation was impossible to generate.

  • Documentation required a separate effort. The monkeypatching approach gave Sphinx nothing
    useful to introspect — generating API docs would have required a separate codegen pass or
    external stub files regardless. This PR instead treats the WIT definitions as the source of
    truth and surfaces as much as possible directly into the Python layer: docstrings, :raises
    entries, :param docs on record fields, and WIT prose rewritten for Python idioms. As the
    WIT definitions gain richer documentation, the generated Python layer improves automatically
    with no manual wrapping needed.

  • Runtime fragility. patch() had to be called before any SDK call; the ordering dependency
    was implicit and not enforced by the type system.

  • Static analysis blind spots. Type checkers had no visibility into the exception behaviour
    of patched functions, since patching happened at runtime via attribute mutation.

  • Scaling maintenance. Every new WIT interface required manually updating the patch list.

  • Ease of Reasoning. While tracing through a fastly_compute API call still involves an
    intermediate layer, I find the more direct delegation to be easy to understand without
    having to untangle (for both humans and machines) what is going on at import and runtime.


Delegation vs. inheritance

The natural question when wrapping a type is whether the wrapper should subclass the WIT
type or hold a reference to it (delegation via FastlyResource[T]).

Subclassing was considered — componentize-py types are subclassable in Python — but the wrapper
and the WIT type are not in a true is-a relationship:

  • Method signatures diverge. Store.insert() in WIT accepts a raw
    wit_world.imports.async_io.Pollable; our wrapper accepts _bindings.Pollable. These are
    unrelated types (_bindings.Pollable does not inherit from wit_world.imports.async_io.Pollable),
    so overriding to accept the wrapper type violates the Liskov Substitution Principle and produces
    bad-override errors from every type checker. The same applies to return types —
    Store.lookup() in WIT returns wit_world.imports.kv_store.Entry; our wrapper returns
    _bindings.Entry.

  • WIT type constructors are opaque. componentize-py's __init__ signatures are (*args, **kwargs);
    they are not designed to be constructed by user code, making super().__init__() calls unsafe.

Delegation keeps the WIT type as a private implementation detail. The cost is one additional
layer in stack traces and unwrapping boilerplate at call sites (param._wit_resource), which the
generator handles automatically.


What the generator produces

For each WIT interface, scripts/generate_bindings/ produces a fastly_compute/_bindings/*.py
module containing:

  • Resource wrapper classesFastlyResource[T] subclasses with methods delegating to the
    underlying WIT resource. Methods whose WIT signature returns result<_, E> are decorated with
    @remap_wit_errors(MAPPINGS) at class definition time, not patched at runtime.

  • Record wrapper classes — typed __init__ signatures with default values and :param
    documentation drawn directly from WIT field docs (e.g. WriteOptions, InsertOptions).

  • Enum/variant/flags re-exports — a single import surface, no wit_world imports needed.

  • __all__ declarations — explicit public names, excluding Extra* extensibility types
    (WIT ABI forward-compatibility handles, always None in practice). As needed, we can
    tailor this in codegen or at the final shim (which largly re-exports with * now).

  • Docstrings with :raises entries — each method that can raise gets a
    :raises ~fully.qualified.ParentExceptionClass: entry, pointing to the catchable parent
    rather than listing every variant case individually. We will probably want to bolster
    the error case documentation in WIT and in our exception hierarchy to aid users further.

  • WIT-to-Python prose rewrites — WIT idioms rewritten before embedding in docstrings:
    `ok(some(v))``v`, `none``None`, `true`/`false`
    `True`/`False`, kebab-case identifiers → snake_case. None of this was possible
    under monkeypatching, where docstrings were never touched. This is limited to a few
    regex patterns today but can be expanded as we see fit.


Public API surface

Thin public modules (fastly_compute/kv_store.py, fastly_compute/backend.py, etc.) re-export
from _bindings via import *, with __all__ doing the filtering:

  • Users import from fastly_compute.kv_store etc., never from _bindings directly.
  • __init__.py remains empty — modules are only loaded when explicitly imported, avoiding
    unnecessary memory in the compiled Wasm binary snapshot (componentize-py snapshots all
    top-level imports).
  • Interfaces with hand-written wrappers (config_store, erl, log, requests, wsgi)
    are unchanged at the user-facing level.
  • http_body and http_incoming are intentionally excluded for now — raw handle plumbing
    and the framework entry point, not intended for direct use.

Code generation restructuring

scripts/generate_patches/ has been renamed and split into scripts/wit/ (shared WIT
abstraction layer, used by both generators) and scripts/generate_exceptions/ (exception
hierarchy generation, formerly the only active part of generate_patches). The dead
generate_patches() function and patches.py.jinja template — which generated the now-deleted
runtime_patching/patches.py — have been removed along with all their supporting code.


Tradeoffs

Improvements:

  • Full IDE support: typed SDK types, docstrings, and :raises entries everywhere
  • Exception remapping is static and verifiable at import time
  • Adding a new WIT interface is a make invocation
  • WIT doc idioms are rewritten to Python before surfacing to users
  • Record option types are properly wrapped with typed, documented constructors
  • Hopefully let's us get features from WIT for free in more cases that Just Work

Ongoing costs:

  • Stack traces through _bindings are one layer deeper than before, though we were headed toward adding
    some kind of wrapper layer manually in many cases.
  • Generated code volume is larger; files must not be hand-edited (enforced by the # automatically generated header and make regeneration)
  • The FastlyResource[T] delegation pattern adds indirection that subclassing would avoid —
    but as described above, clean subclassing is not achievable given how componentize-py generates
    its types

posborne added 18 commits April 7, 2026 17:07
This is a pretty direct wrapping of the WIT with the addition
of The composite `EdgeRateLimiter` which matches what is provided
by the Rust SDK with `ERL`.

Viceroy mostly has stubs for this functionality at this point in
time, so the tests are not particularly substantive but do try
to at least put some tracers through the hostcall boundary.  There
are several xfail tests which could be made to fail properly
with viceory changes to do more faithfully match parts of the
production impl.
Lints for python 3.14 disallow string quoting types; importing
annotations from __future__ defers type avaluations to avoid this
problem and make the linter happy.
For now, I simplified the exception documentation to reference base
exceptions as some of the detailed sub-exceptions were not accurate
or complete.  I think documenting more generally when subclasses of
the base exception may occur generally will likely be more sustainable
unless an exception is on a path that is likely to need to be frequently
handled by guest code.
Introduce scripts/generate_bindings/ which produces fastly_compute/_bindings/*.py
for all Fastly Compute WIT interfaces. Each generated module wraps its
wit_world counterpart with remap_wit_errors applied at definition time,
corrected :raises: docstrings referencing the SDK exception hierarchy, and
proper Python type annotations including resource handle and tuple wrapping.

Delete runtime_patching/ and migrate all SDK code (config_store, erl, log,
wsgi, requests) off direct wit_world imports onto the generated bindings layer.
WIT list<u8> is always a byte buffer; bytes is the correct Python type
for callers to pass and type checkers to verify. This fixes annotations
throughout http_body, http_req, http_resp, cache, secret_store, log, and
related interfaces, and removes the now-unnecessary bad-override suppress
on LogEndpoint.write.
_all_type_refs did not recurse into Record fields, so enum, variant, and
resource types that appeared only as record field types were invisible to
_reexports_for_interface and _extra_imports_for_interface. Fix by walking
record fields after adding a Record to the result set.

Affected: InsertMode/ListMode (kv_store), ReplaceStrategy/Request (cache),
Backend (http_cache), IpAddress (security). Removes the F821 suppression
from pyproject.toml per-file-ignores for _bindings/.
Generate __all__ in each _bindings module to define the public contract:
own-interface records, resources, freestanding functions, and
enum/flags/variant re-exports, excluding Extra* extensibility types and
infrastructure names (MAPPINGS, remap_wit_errors, FastlyResource).
Foreign resources imported from other _bindings modules (e.g. Pollable)
are also excluded so each type has a single canonical import location.

Add thin public fastly_compute/{module}.py files that re-export via
'import *', letting __all__ do the filtering. Skipped: http_body (raw
handle plumbing), http_incoming (framework entry point), http_types and
types (empty placeholders). Existing hand-written modules (config_store,
erl, log, wsgi, requests) are unchanged.
…ed bindings

Generate :raises entries in method docstrings derived from the WIT result
error type. Each decorated method now documents the parent exception class
for its error type (e.g. KvError, OpenError, Error) rather than listing
every individual case, keeping docstrings concise while pointing readers
to the right catchable type. UnexpectedFastlyError is omitted per-method
since it applies universally and is documented on remap_wit_errors itself.
Methods with no WIT docs get a minimal name-based summary so :raises lines
are never orphaned against the opening quotes.

Also add DocsHaver.docs_for_python() which rewrites high-confidence WIT
idioms to Python equivalents before embedding in docstrings: ok(some(X))
-> X, ok(none)/none -> None, true/false -> True/False, and kebab-case
identifiers in backticks -> snake_case. docstring_with_raises() uses this
instead of docs() so all generated method docstrings get the rewrites.

Fix Makefile to include scripts/generate_patches/*.py as a dependency of
the _bindings/__init__.py target so changes to wit.py correctly trigger
regeneration.
This cleans up some of the odd bits we get as a result of trying to
format the code properly in all cases with Jinja --  it's easier to
just fix many of these details up in post.
Change WsgiHttpIncoming.handle() to declare raw WIT types for its
parameters (_wit_http_req.Request, _wit_async_io.Pollable) rather than
_bindings wrapper types, since componentize-py passes raw resources at
the export boundary. This accurately reflects what actually arrives and
eliminates the bad-override suppression.

Use distinct variable names (wrapped_request, wrapped_body) for the
_bindings-wrapped values so the type checker can track the type
transition without casts. Removes all three inline pyrefly: ignore
comments from the file.
RecordField now extends DocsHaver, giving it access to docs(),
docs_for_python(), and docstring(). A new param_doc() method normalises
the field docs to a single line (collapsing whitespace) for use in
:param entries.

The bindings template emits an __init__ docstring with :param lines for
every non-extra field that carries WIT docs, and omits the docstring
entirely when no fields have docs. WIT-to-Python prose rewrites from
docs_for_python() are applied (none/true/false capitalisation,
ok()/some() unwrapping, kebab-case to snake_case).
We now no longer do monkeypatching and some of the existing
code was no longer called or named in a misleading fashion.
For internal code paths like the requests facade, dogfood our
own top-level api surface (which may just be a re-export)
for building this second layer of functionality.
The monolithic wit/__init__.py had become quite large; take
a stab at separating with a few changes to break circular
dependencies between wit/types around interface names.
@posborne posborne requested a review from erikrose April 9, 2026 05:34
@posborne
Copy link
Copy Markdown
Member Author

One missing piece here that I think we can likely generate in a reasonable fashion is for enumerations like IpAddress (used in a few places). Right now the generated code references the WIT type directly but it lacks the affordances I think we probably should provide.

IpAddress is a case where we may want to have the sandwich layer do a mapping between python native types (https://docs.python.org/3/library/ipaddress.html) and the WIT type. With that, I think we have pretty reasonable code for a few additional interfaces like security and geo.

@erikrose
Copy link
Copy Markdown
Member

Just want to let you know this is on my radar, but it will have to wait for a nice chunk of uninterrupted thought.

Copy link
Copy Markdown
Member

@erikrose erikrose left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here are a few things to start with. This is a huge dump to trawl through. Next time, can we meet beforehand and hash out philosophy rather than attempting design-by-6000-line-surprise?

) -> wit_backend.Backend:
options = wit_backend.DynamicBackendOptions()
) -> Backend:
options = DynamicBackendOptions.new()
Copy link
Copy Markdown
Member

@erikrose erikrose Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I were a Python SDK customer, I'd rather have idiomatic Python than consistency with other languages I'm not using. I imagine mechanical translation is straightforward given our consistency of naming.

Comment thread fastly_compute/erl.py
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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, yes, I had started to play with some of these ideas based on the ERL branch but didn't fully address a few comments.

Comment thread fastly_compute/erl.py Outdated
@@ -63,10 +63,7 @@ def open(cls, name: str) -> Self:
"""Open a rate counter by name.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think documenting more generally when subclasses of
the base exception may occur generally will likely be more sustainable
unless an exception is on a path that is likely to need to be frequently
handled by guest code.

Sorry, couldn't make sense of this. Can you elucidate?

My initial position is that open-error's cases have intuitive-enough applicability to ERL's things that we can generally say "raises OpenError" and be done with it. Maybe that's what you meant.

I'd think twice before doing that with plain old error, as its has cases that aren't as clear.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, that reads odd. What I was trying to express is, I think, the same basically. Pointing readers at OpenError is probably good enough and we could consider adding whatever text makes sense on OpenError or its subclasses to understand how to handle things generally rather than trying to call out each individual error inline.

For cases that might reasonably need to be able to be handled on a given API call, having the WIT docs describe that case (e.g. If the name is greater than 200 characters, then we'll return this-error or whatever) would be ideal and we can do our pass that info along, possibly with a mapping of error->exception (though even if we don't, I think most readers will probably be able to follow).

Comment thread fastly_compute/erl.py Outdated
:return: True if the entry is rate limited, False otherwise
:raises ~fastly_compute.exceptions.types.error.InvalidArgument: If parameters are invalid
:raises ~fastly_compute.exceptions.types.error.GenericError: If an unexpected error occurs
:raises ~fastly_compute.exceptions.FastlyError
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We will have to be somewhat general here because the WIT comments don't nail down which cases can be raised from each routine, but we can at least claim to raise Error (class Error(FastlyError)), from which the full range of cases can be discovered.

@posborne
Copy link
Copy Markdown
Member Author

Here are a few things to start with. This is a huge dump to trawl through. Next time, can we meet beforehand and hash out philosophy rather than attempting design-by-6000-line-surprise?

Happy to discuss; this work was in large part my attempt to see what I could achieve in a day to flesh out several ideas. Given that somewhat limited time investment, I'm not completely tied to things here, but this was the easiest way for me to explore several possible features in a way that can be discussed.

The ideas related to codegen vs. monkeypatching had been discussed several times previously. Much of the code here is generated mechanically.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants