Skip to content

Commit 975785c

Browse files
committed
feat(api): generate Projects REST bindings
Implements step 2 of #683. This adds the first public resource backed by generated REST bindings: - `BraintrustClient.projects`: synchronous create, list, get, update, and delete methods - `braintrust.api.types`: public `CreateProject`, `PatchProject`, `Project`, and `ProjectList` types Generated code is organized by OpenAPI tag: braintrust/api/_generated/ ├── models/ │ ├── projects.py │ └── shared.py ├── operations/ │ └── projects.py └── resources/ └── projects.py Models referenced by one tag live with that tag; cross-tag and unowned models live in `models/shared.py`. Operation metadata remains separate from the thin, typed resource functions that execute it. The generated bindings reuse the existing resource transport, authentication, routing, retry policies, and error handling. The adapter serializes generated path, query, header, and JSON body parameters and decodes JSON, text, and empty responses. Projects is enabled declaratively through `endpoint_generator.generated_tags`, with naming and retry overrides kept in `openapi/config.json`. Generation remains offline and deterministic, and codegen drift checks replace the generated tree atomically so stale files cannot survive a layout change. Coverage includes exact wire behavior, additive response fields, operation policies, typing, generated package contents, inline-schema conflicts, and a cassette-backed end-to-end Projects flow.
1 parent c67d80d commit 975785c

51 files changed

Lines changed: 8821 additions & 6832 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

openapi/README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,12 @@ make generate-api-client
1313
make check-api-client-codegen
1414
```
1515

16-
The check regenerates into a temporary directory and does not modify the worktree.
16+
The check regenerates into a temporary directory and does not modify the worktree. Endpoint bindings
17+
are rolled out explicitly through `endpoint_generator.generated_tags`; adding a tag expands the
18+
committed operation registry and resource bindings. Generated models and operations are split by
19+
OpenAPI tag under `models/` and `operations/`; models referenced by multiple tags live in
20+
`models/shared.py`. Ergonomic naming and runtime-policy overrides remain declarative in the same
21+
configuration.
1722

1823
To fetch the configured upstream commit explicitly:
1924

openapi/config.json

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,41 @@
2121
"--use-generic-container-types",
2222
"--use-field-description",
2323
"--strict-nullable",
24-
"--parent-scoped-naming",
24+
"--naming-strategy=primary-first",
25+
"--treat-dot-as-module",
2526
"--no-use-closed-typed-dict",
2627
"--disable-future-imports",
2728
"--formatters=ruff-format"
2829
]
2930
},
3031
"endpoint_generator": {
3132
"schema_version": 1,
33+
"generated_tags": [
34+
"Projects"
35+
],
36+
"naming_overrides": {
37+
"postProject": {
38+
"method_name": "create"
39+
},
40+
"getProject": {
41+
"method_name": "list",
42+
"response_type": "ProjectList"
43+
},
44+
"getProjectId": {
45+
"method_name": "get"
46+
},
47+
"patchProjectId": {
48+
"method_name": "update"
49+
},
50+
"deleteProjectId": {
51+
"method_name": "delete"
52+
}
53+
},
54+
"runtime_policy_overrides": {
55+
"postProject": {
56+
"retry_mode": "IDEMPOTENT_WRITE"
57+
}
58+
},
3259
"skip_tags": {
3360
"Proxy": {
3461
"reason": "Proxy endpoints stream provider-specific payloads and remain on the specialized proxy path.",

py/scripts/openapi_codegen.py

Lines changed: 584 additions & 19 deletions
Large diffs are not rendered by default.

py/src/braintrust/api/_adapter.py

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
"""Runtime adapter for generated REST operation bindings."""
2+
3+
from collections.abc import Mapping, Sequence
4+
from dataclasses import dataclass, replace
5+
from typing import Any
6+
from urllib.parse import quote
7+
8+
from ._routing import RequestTarget
9+
from ._service import ResourceAPI
10+
from .errors import BraintrustResponseError
11+
from .policies import RetryMode, RetryPolicy
12+
13+
14+
@dataclass(frozen=True)
15+
class Parameter:
16+
"""Serialization metadata for one generated operation parameter."""
17+
18+
argument_name: str
19+
name: str
20+
location: str
21+
type_name: str
22+
required: bool
23+
style: str
24+
explode: bool
25+
26+
27+
@dataclass(frozen=True)
28+
class Operation:
29+
"""Resolved wire and runtime-policy metadata for a generated operation."""
30+
31+
operation_id: str
32+
method: str
33+
path: str
34+
tag: str
35+
parameters: tuple[Parameter, ...]
36+
request_body_type: str | None
37+
request_body_required: bool
38+
response_type: str | None
39+
success_statuses: tuple[int, ...]
40+
response_media_type: str | None
41+
target: RequestTarget
42+
retry_mode: RetryMode
43+
timeout: float | None = None
44+
max_elapsed_time: float | None = None
45+
46+
47+
class GeneratedOperationAdapter(ResourceAPI):
48+
"""Execute generated operation metadata through the shared resource transport."""
49+
50+
def execute(
51+
self,
52+
operation: Operation,
53+
*,
54+
path_parameters: Mapping[str, Any] | None = None,
55+
query_parameters: Mapping[str, Any] | None = None,
56+
header_parameters: Mapping[str, Any] | None = None,
57+
body: Any = None,
58+
) -> Any:
59+
path_values = path_parameters or {}
60+
query_values = query_parameters or {}
61+
header_values = header_parameters or {}
62+
path = operation.path
63+
query_parts: list[str] = []
64+
headers: dict[str, str] = {}
65+
66+
for parameter in operation.parameters:
67+
values = {
68+
"path": path_values,
69+
"query": query_values,
70+
"header": header_values,
71+
}.get(parameter.location)
72+
if values is None:
73+
raise ValueError(f"Unsupported generated parameter location: {parameter.location!r}")
74+
value = values.get(parameter.argument_name)
75+
if value is None:
76+
if parameter.required:
77+
raise TypeError(f"Missing required parameter: {parameter.argument_name}")
78+
continue
79+
if parameter.location == "path":
80+
encoded = _encode_path_parameter(value, parameter)
81+
path = path.replace("{" + parameter.name + "}", encoded)
82+
elif parameter.location == "query":
83+
query_parts.extend(_encode_query_parameter(value, parameter))
84+
else:
85+
headers[parameter.name] = _scalar_string(value)
86+
87+
if query_parts:
88+
path += ("&" if "?" in path else "?") + "&".join(query_parts)
89+
90+
request_kwargs: dict[str, Any] = {"retry_mode": operation.retry_mode}
91+
if operation.timeout is not None or operation.max_elapsed_time is not None:
92+
policy = RetryPolicy.for_mode(operation.retry_mode)
93+
replacements = {}
94+
if operation.timeout is not None:
95+
replacements["timeout"] = operation.timeout
96+
if operation.max_elapsed_time is not None:
97+
replacements["max_elapsed_time"] = operation.max_elapsed_time
98+
request_kwargs["retry_policy"] = replace(policy, **replacements)
99+
if headers:
100+
request_kwargs["headers"] = headers
101+
if operation.request_body_required and body is None:
102+
raise TypeError(f"Missing required request body for {operation.operation_id}")
103+
if operation.request_body_type is not None and body is not None:
104+
request_kwargs["json"] = body
105+
106+
response = self._request(operation.target, operation.method, path, **request_kwargs)
107+
if response.status_code not in operation.success_statuses:
108+
expected_statuses = ", ".join(str(status) for status in operation.success_statuses)
109+
raise BraintrustResponseError(
110+
method=operation.method,
111+
url=response.url,
112+
status_code=response.status_code,
113+
response_body=response.text,
114+
response_headers=response.headers,
115+
attempts=getattr(response, "_braintrust_attempts", 1),
116+
message=(
117+
f"{operation.method} {response.url} returned unexpected HTTP status {response.status_code}; "
118+
f"expected one of: {expected_statuses}"
119+
),
120+
)
121+
if operation.response_type is None:
122+
return None
123+
124+
media_type = operation.response_media_type
125+
if media_type is None:
126+
media_type = response.headers.get("Content-Type", "").partition(";")[0].strip().lower() or None
127+
if media_type == "application/json":
128+
return self._transport.decode_json_response(response, method=operation.method, url=response.url)
129+
if media_type == "text/plain":
130+
return response.text
131+
raise ValueError(f"Unsupported generated response media type: {media_type!r}")
132+
133+
134+
def _encode_path_parameter(value: Any, parameter: Parameter) -> str:
135+
if parameter.style != "simple":
136+
raise ValueError(f"Unsupported path parameter style: {parameter.style!r}")
137+
if _is_array(value):
138+
value = ",".join(_scalar_string(item) for item in value)
139+
return quote(_scalar_string(value), safe="")
140+
141+
142+
def _encode_query_parameter(value: Any, parameter: Parameter) -> list[str]:
143+
if parameter.style != "form":
144+
raise ValueError(f"Unsupported query parameter style: {parameter.style!r}")
145+
encoded_name = quote(parameter.name, safe="")
146+
if _is_array(value):
147+
encoded_values = [quote(_scalar_string(item), safe="") for item in value]
148+
if parameter.explode:
149+
return [f"{encoded_name}={item}" for item in encoded_values]
150+
return [f"{encoded_name}={','.join(encoded_values)}"]
151+
encoded_value = quote(_scalar_string(value), safe="")
152+
return [f"{encoded_name}={encoded_value}"]
153+
154+
155+
def _is_array(value: Any) -> bool:
156+
return isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray))
157+
158+
159+
def _scalar_string(value: Any) -> str:
160+
if isinstance(value, bool):
161+
return "true" if value else "false"
162+
return str(value)

0 commit comments

Comments
 (0)