Skip to content

Commit 205d35a

Browse files
committed
Add codegen tool and exception hierarchy
- Added tools/codegen.py to generate safe WIT bindings. - Added fastly_compute/exceptions.py to define the exception hierarchy. - Updated Makefile to include codegen target.
1 parent 7f1994c commit 205d35a

3 files changed

Lines changed: 358 additions & 1 deletion

File tree

Makefile

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,11 @@ build-all: all
7575

7676
# Clean build artifacts
7777
clean:
78-
rm -rf $(BUILD_DIR) $(STUBS_DIR)
78+
rm -rf $(BUILD_DIR) $(STUBS_DIR) fastly_compute/wit
79+
80+
# Code Generation
81+
codegen: $(STUBS_DIR)
82+
uv run python3 tools/codegen.py
7983

8084
# Development tools
8185
lint: | $(STUBS_DIR)

fastly_compute/exceptions.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
from typing import Any
2+
3+
4+
class FastlyError(Exception):
5+
"""Base class for all Fastly Compute exceptions."""
6+
7+
def __init__(self, message: str, wit_error: Any | None = None):
8+
super().__init__(message)
9+
self.wit_error = wit_error
10+
11+
12+
class ResourceError(FastlyError):
13+
"""Resource open/access errors."""
14+
15+
pass
16+
17+
18+
class ResourceOpenError(ResourceError):
19+
"""Error opening a resource."""
20+
21+
pass
22+
23+
24+
class ResourceNotFound(ResourceError):
25+
"""Resource not found."""
26+
27+
pass
28+
29+
30+
class ResourceLimitExceeded(ResourceError):
31+
"""Quotas or limits exceeded."""
32+
33+
pass
34+
35+
36+
class BackendError(FastlyError):
37+
"""Backend communication errors."""
38+
39+
pass
40+
41+
42+
class BadRequestError(FastlyError):
43+
"""Bad Request."""
44+
45+
pass
46+
47+
48+
class RateLimitExceeded(FastlyError):
49+
"""Rate limit exceeded."""
50+
51+
pass
52+
53+
54+
# KV Store Specific
55+
class KVStoreError(FastlyError):
56+
pass
57+
58+
59+
class KVKeyFound(KVStoreError):
60+
pass
61+
62+
63+
class KVPreconditionFailed(KVStoreError):
64+
pass
65+
66+
67+
class KVPayloadTooLarge(KVStoreError):
68+
pass
69+
70+
71+
# ACL Specific
72+
class ACLError(FastlyError):
73+
pass

tools/codegen.py

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
#!/usr/bin/env python3
2+
import json
3+
import os
4+
import re
5+
import subprocess
6+
import sys
7+
from pathlib import Path
8+
from typing import Any
9+
10+
# Configuration
11+
WIT_DIR = Path("wit")
12+
OUTPUT_DIR = Path("fastly_compute/wit")
13+
RAW_BINDINGS_PACKAGE = "wit_world" # For now, use existing location
14+
15+
# Mapping from WIT Error Enum Names to Base Exception Classes
16+
# and specific variants to specific exceptions.
17+
# Format: "wit-enum-name": ("BaseException", {"variant": "SpecificException"})
18+
ERROR_MAPPING = {
19+
"kv-error": (
20+
"KVStoreError",
21+
{
22+
"bad-request": "BadRequestError",
23+
"precondition-failed": "KVPreconditionFailed",
24+
"payload-too-large": "KVPayloadTooLarge",
25+
"too-many-requests": "RateLimitExceeded",
26+
"internal-error": "BackendError",
27+
},
28+
),
29+
"acl-error": (
30+
"ACLError",
31+
{
32+
"too-many-requests": "RateLimitExceeded",
33+
},
34+
),
35+
"open-error": (
36+
"ResourceOpenError",
37+
{
38+
"name-empty": "ValueError",
39+
"name-too-long": "ValueError",
40+
"name-contains-invalid-char": "ValueError",
41+
"file-not-found": "ResourceNotFound", # Assuming implicit
42+
},
43+
),
44+
"error": ("FastlyError", {}),
45+
"error-with-detail": ("FastlyError", {}), # Generic error with string
46+
}
47+
48+
49+
def to_snake_case(name: str) -> str:
50+
return name.replace("-", "_")
51+
52+
53+
def to_camel_case(name: str) -> str:
54+
parts = name.split("-")
55+
return "".join(p.capitalize() for p in parts)
56+
57+
58+
def run_wasm_tools(wit_dir: Path) -> dict[str, Any]:
59+
"""Run wasm-tools to get JSON representation of WIT."""
60+
try:
61+
result = subprocess.run(
62+
["wasm-tools", "component", "wit", str(wit_dir), "--json"],
63+
capture_output=True,
64+
text=True,
65+
check=True,
66+
)
67+
return json.loads(result.stdout)
68+
except subprocess.CalledProcessError as e:
69+
print(f"Error running wasm-tools: {e.stderr}", file=sys.stderr)
70+
sys.exit(1)
71+
except FileNotFoundError:
72+
print("wasm-tools not found in PATH", file=sys.stderr)
73+
sys.exit(1)
74+
75+
76+
def resolve_type(types: list[dict], type_id: int) -> dict:
77+
if type_id < len(types):
78+
return types[type_id]
79+
return {}
80+
81+
82+
def find_error_type(types: list[dict], type_id: Any) -> str | None:
83+
"""Trace a type ID to find if it's a Result with an Error Enum."""
84+
if isinstance(type_id, int):
85+
t = resolve_type(types, type_id)
86+
kind = t.get("kind", {})
87+
if "result" in kind:
88+
result = kind["result"]
89+
err_id = result.get("err")
90+
if err_id is not None:
91+
err_type = resolve_type(types, err_id)
92+
# Check if it has a name (Enum or Resource)
93+
return err_type.get("name")
94+
elif "type" in kind:
95+
# Alias
96+
return find_error_type(types, kind["type"])
97+
return None
98+
99+
100+
def generate_utils_module(output_dir: Path) -> None:
101+
content = """
102+
from functools import wraps
103+
from typing import Any, Callable, Dict, Type, Optional
104+
from fastly_compute.witraw.imports.types import Err
105+
from fastly_compute.exceptions import FastlyError
106+
107+
def map_wit_error(mapping: Dict[str, Type[FastlyError]], default: Type[FastlyError] = FastlyError):
108+
def decorator(func: Callable) -> Callable:
109+
@wraps(func)
110+
def wrapper(*args, **kwargs):
111+
try:
112+
return func(*args, **kwargs)
113+
except Err as e:
114+
err_val = e.value
115+
variant = str(err_val)
116+
exc_cls = mapping.get(variant)
117+
if exc_cls:
118+
raise exc_cls(variant) from e
119+
raise default(f"Generic error: {err_val}") from e
120+
return wrapper
121+
return decorator
122+
"""
123+
with open(output_dir / "utils.py", "w") as f:
124+
f.write(content.strip())
125+
126+
127+
def generate_module(interface: dict, types: list[dict], module_name: str) -> str:
128+
functions = interface.get("functions", {})
129+
if not functions:
130+
return ""
131+
132+
lines = []
133+
lines.append("# Generated by tools/codegen.py. DO NOT EDIT.")
134+
lines.append(f"from fastly_compute.witraw.imports import {module_name} as _raw")
135+
lines.append("from fastly_compute.wit.utils import map_wit_error")
136+
lines.append("from fastly_compute.exceptions import *")
137+
lines.append("")
138+
139+
# Determine error mapping for this module
140+
# We scan all functions to find the common error enum(s)
141+
# Ideally, an interface uses one main error type (e.g. kv-error)
142+
# If multiple, we might need multiple maps or a merged one.
143+
144+
# Collect all error types used in this module
145+
error_types = set()
146+
for func_def in functions.values():
147+
res = func_def.get("result")
148+
err_name = find_error_type(types, res)
149+
if err_name:
150+
error_types.add(err_name)
151+
152+
# Generate Mappings
153+
for err_name in error_types:
154+
if err_name in ERROR_MAPPING:
155+
base_exc, variants = ERROR_MAPPING[err_name]
156+
# Generate dict definition
157+
lines.append(f"_{to_snake_case(err_name).upper()}_MAP = {{")
158+
for var, exc in variants.items():
159+
lines.append(f" '{var}': {exc},")
160+
lines.append("}")
161+
lines.append("")
162+
163+
# Group functions by resource (same as before)
164+
resources = {}
165+
freestanding = []
166+
167+
for func_key, func_def in functions.items():
168+
name = func_def["name"]
169+
if "." in name and not name.startswith(
170+
"["
171+
): # Handle [method] prefix removal logic
172+
pass # Logic handles below
173+
174+
# Robust parsing of "[kind]resource.method"
175+
clean_name = re.sub(r"\[.*?\]", "", name)
176+
if "." in clean_name:
177+
res_name, method_name = clean_name.split(".", 1)
178+
if res_name not in resources:
179+
resources[res_name] = []
180+
resources[res_name].append((method_name, func_def))
181+
else:
182+
freestanding.append((name, func_def))
183+
184+
# Helper to get decorator string
185+
def get_decorator(func_def):
186+
res = func_def.get("result")
187+
err_name = find_error_type(types, res)
188+
if err_name and err_name in ERROR_MAPPING:
189+
map_name = f"_{to_snake_case(err_name).upper()}_MAP"
190+
base_exc = ERROR_MAPPING[err_name][0]
191+
return f"@map_wit_error({map_name}, default={base_exc})"
192+
return None
193+
194+
# Generate Freestanding
195+
for name, func_def in freestanding:
196+
py_name = to_snake_case(name)
197+
deco = get_decorator(func_def)
198+
if deco:
199+
lines.append(deco)
200+
lines.append(f"def {py_name}(*args, **kwargs):")
201+
lines.append(f" return _raw.{to_snake_case(name)}(*args, **kwargs)")
202+
lines.append("")
203+
204+
# Generate Classes
205+
for res_name, methods in resources.items():
206+
class_name = to_camel_case(res_name)
207+
lines.append(f"class {class_name}:")
208+
lines.append(" def __init__(self, handle):")
209+
lines.append(" self._handle = handle")
210+
lines.append("")
211+
212+
for name, func_def in methods:
213+
py_name = to_snake_case(name)
214+
deco = get_decorator(func_def)
215+
if deco:
216+
lines.append(f" {deco}")
217+
218+
# Check static vs method
219+
is_static = "[static]" in func_def["name"]
220+
221+
if is_static:
222+
# Static method
223+
lines.append(" @classmethod")
224+
lines.append(f" def {py_name}(cls, *args, **kwargs):")
225+
lines.append(
226+
f" return _raw.{class_name}.{to_snake_case(name)}(*args, **kwargs)"
227+
)
228+
else:
229+
# Instance method
230+
lines.append(f" def {py_name}(self, *args, **kwargs):")
231+
# Pass self._handle as first arg if raw expects it?
232+
# Raw bindings for methods usually take 'self' handle as first arg.
233+
lines.append(
234+
f" return _raw.{class_name}.{to_snake_case(name)}(self._handle, *args, **kwargs)"
235+
)
236+
lines.append("")
237+
238+
return "\n".join(lines)
239+
240+
241+
def main():
242+
# Ensure output dir
243+
if not OUTPUT_DIR.exists():
244+
os.makedirs(OUTPUT_DIR)
245+
246+
imports_dir = OUTPUT_DIR / "imports"
247+
if not imports_dir.exists():
248+
os.makedirs(imports_dir)
249+
250+
(OUTPUT_DIR / "__init__.py").touch()
251+
(imports_dir / "__init__.py").touch()
252+
253+
# Generate utils
254+
generate_utils_module(OUTPUT_DIR)
255+
256+
print(f"Parsing WIT from {WIT_DIR}...")
257+
data = run_wasm_tools(WIT_DIR)
258+
259+
types = data.get("types", [])
260+
interfaces = data.get("interfaces", [])
261+
262+
for interface in interfaces:
263+
name = interface.get("name")
264+
if not name:
265+
continue
266+
267+
print(f"Generating wrapper for {name}...")
268+
py_mod_name = to_snake_case(name)
269+
270+
content = generate_module(interface, types, py_mod_name)
271+
272+
if content:
273+
with open(imports_dir / f"{py_mod_name}.py", "w") as f:
274+
f.write(content)
275+
276+
print("Done.")
277+
278+
279+
if __name__ == "__main__":
280+
main()

0 commit comments

Comments
 (0)