httpxgen 0.1.0__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- httpxgen-0.2.0/IMPROVEMENT_SPEC.md +483 -0
- {httpxgen-0.1.0 → httpxgen-0.2.0}/PKG-INFO +175 -55
- {httpxgen-0.1.0 → httpxgen-0.2.0}/README.md +174 -54
- {httpxgen-0.1.0 → httpxgen-0.2.0}/httpxgen/cli.py +16 -0
- {httpxgen-0.1.0 → httpxgen-0.2.0}/httpxgen/generator/build.py +37 -4
- {httpxgen-0.1.0 → httpxgen-0.2.0}/httpxgen/generator/client.py +206 -41
- {httpxgen-0.1.0 → httpxgen-0.2.0}/httpxgen/generator/operations.py +96 -25
- {httpxgen-0.1.0 → httpxgen-0.2.0}/httpxgen/generator/package.py +59 -3
- httpxgen-0.2.0/httpxgen/generator/templates/binary_operation.jinja2 +51 -0
- httpxgen-0.2.0/httpxgen/generator/templates/client.jinja2 +29 -0
- httpxgen-0.2.0/httpxgen/generator/templates/enum.jinja2 +2 -0
- httpxgen-0.2.0/httpxgen/generator/templates/exceptions.jinja2 +74 -0
- httpxgen-0.2.0/httpxgen/generator/templates/generated_header.jinja2 +3 -0
- httpxgen-0.2.0/httpxgen/generator/templates/http_methods.jinja2 +12 -0
- httpxgen-0.2.0/httpxgen/generator/templates/model.jinja2 +9 -0
- httpxgen-0.2.0/httpxgen/generator/templates/models.jinja2 +6 -0
- httpxgen-0.2.0/httpxgen/generator/templates/operation.jinja2 +33 -0
- httpxgen-0.2.0/httpxgen/generator/templates/package_init.jinja2 +5 -0
- httpxgen-0.2.0/httpxgen/generator/templates/serialization.jinja2 +130 -0
- httpxgen-0.2.0/httpxgen/generator/templates/shared_init.jinja2 +51 -0
- httpxgen-0.2.0/httpxgen/generator/templates/transport.jinja2 +112 -0
- httpxgen-0.2.0/httpxgen/generator/templates.py +42 -0
- {httpxgen-0.1.0 → httpxgen-0.2.0}/httpxgen/output.py +21 -2
- {httpxgen-0.1.0 → httpxgen-0.2.0}/preview/__init__.py +10 -1
- httpxgen-0.2.0/preview/invoices/client.py +264 -0
- httpxgen-0.2.0/preview/payments/client.py +646 -0
- httpxgen-0.2.0/preview/shared/__init__.py +56 -0
- httpxgen-0.2.0/preview/shared/exceptions.py +79 -0
- {httpxgen-0.1.0 → httpxgen-0.2.0}/preview/shared/serialization.py +1 -1
- httpxgen-0.2.0/preview/shared/transport.py +109 -0
- {httpxgen-0.1.0 → httpxgen-0.2.0}/pyproject.toml +2 -1
- {httpxgen-0.1.0 → httpxgen-0.2.0}/tests/generator/test_build.py +58 -5
- {httpxgen-0.1.0 → httpxgen-0.2.0}/tests/generator/test_client.py +5 -3
- httpxgen-0.2.0/tests/generator/test_improvements.py +340 -0
- {httpxgen-0.1.0 → httpxgen-0.2.0}/tests/generator/test_operations.py +1 -1
- {httpxgen-0.1.0 → httpxgen-0.2.0}/tests/generator/test_package.py +2 -2
- {httpxgen-0.1.0 → httpxgen-0.2.0}/tests/generator/test_runtime.py +1 -3
- {httpxgen-0.1.0 → httpxgen-0.2.0}/tests/test_cli.py +25 -1
- {httpxgen-0.1.0 → httpxgen-0.2.0}/tests/test_output.py +2 -1
- httpxgen-0.1.0/httpxgen/generator/templates.py +0 -332
- httpxgen-0.1.0/preview/invoices/client.py +0 -184
- httpxgen-0.1.0/preview/payments/client.py +0 -432
- httpxgen-0.1.0/preview/shared/__init__.py +0 -28
- httpxgen-0.1.0/preview/shared/exceptions.py +0 -20
- {httpxgen-0.1.0 → httpxgen-0.2.0}/.github/workflows/ci.yml +0 -0
- {httpxgen-0.1.0 → httpxgen-0.2.0}/.gitignore +0 -0
- {httpxgen-0.1.0 → httpxgen-0.2.0}/.python-version +0 -0
- {httpxgen-0.1.0 → httpxgen-0.2.0}/CLAUDE.md +0 -0
- {httpxgen-0.1.0 → httpxgen-0.2.0}/LICENSE +0 -0
- {httpxgen-0.1.0 → httpxgen-0.2.0}/VISION.md +0 -0
- {httpxgen-0.1.0 → httpxgen-0.2.0}/httpxgen/__init__.py +0 -0
- {httpxgen-0.1.0 → httpxgen-0.2.0}/httpxgen/generator/__init__.py +0 -0
- {httpxgen-0.1.0 → httpxgen-0.2.0}/httpxgen/generator/errors.py +0 -0
- {httpxgen-0.1.0 → httpxgen-0.2.0}/httpxgen/generator/models.py +0 -0
- {httpxgen-0.1.0 → httpxgen-0.2.0}/httpxgen/generator/naming.py +0 -0
- {httpxgen-0.1.0 → httpxgen-0.2.0}/httpxgen/generator/normalize.py +0 -0
- {httpxgen-0.1.0 → httpxgen-0.2.0}/httpxgen/generator/schema.py +0 -0
- {httpxgen-0.1.0 → httpxgen-0.2.0}/httpxgen/io.py +0 -0
- {httpxgen-0.1.0 → httpxgen-0.2.0}/httpxgen/loading.py +0 -0
- {httpxgen-0.1.0 → httpxgen-0.2.0}/httpxgen/openapi.py +0 -0
- {httpxgen-0.1.0 → httpxgen-0.2.0}/httpxgen/selection.py +0 -0
- {httpxgen-0.1.0 → httpxgen-0.2.0}/preview/invoices/__init__.py +0 -0
- {httpxgen-0.1.0 → httpxgen-0.2.0}/preview/invoices/models.py +0 -0
- {httpxgen-0.1.0 → httpxgen-0.2.0}/preview/payments/__init__.py +0 -0
- {httpxgen-0.1.0 → httpxgen-0.2.0}/preview/payments/models.py +0 -0
- {httpxgen-0.1.0 → httpxgen-0.2.0}/preview/py.typed +0 -0
- {httpxgen-0.1.0 → httpxgen-0.2.0}/preview/shared/http_methods.py +0 -0
- {httpxgen-0.1.0 → httpxgen-0.2.0}/preview/shared/models.py +0 -0
- {httpxgen-0.1.0 → httpxgen-0.2.0}/scripts/generate-preview.sh +0 -0
- {httpxgen-0.1.0 → httpxgen-0.2.0}/specs/api.yml +0 -0
- {httpxgen-0.1.0 → httpxgen-0.2.0}/tests/conftest.py +0 -0
- {httpxgen-0.1.0 → httpxgen-0.2.0}/tests/generator/test_models.py +0 -0
- {httpxgen-0.1.0 → httpxgen-0.2.0}/tests/generator/test_naming.py +0 -0
- {httpxgen-0.1.0 → httpxgen-0.2.0}/tests/generator/test_normalize.py +0 -0
- {httpxgen-0.1.0 → httpxgen-0.2.0}/tests/generator/test_schema.py +0 -0
- {httpxgen-0.1.0 → httpxgen-0.2.0}/tests/test_loading.py +0 -0
- {httpxgen-0.1.0 → httpxgen-0.2.0}/tests/test_openapi.py +0 -0
- {httpxgen-0.1.0 → httpxgen-0.2.0}/tests/test_selection.py +0 -0
- {httpxgen-0.1.0 → httpxgen-0.2.0}/uv.lock +0 -0
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
# Cleaner generated client APIs
|
|
2
|
+
|
|
3
|
+
## Context
|
|
4
|
+
|
|
5
|
+
The generated client is currently too closely coupled to `httpx` internals and
|
|
6
|
+
does not provide enough application-level abstractions for errors and large
|
|
7
|
+
binary transfers. The concrete integration in `browser-provisioner` exposes
|
|
8
|
+
three recurring problems:
|
|
9
|
+
|
|
10
|
+
1. The generated client requires an `httpx.AsyncClient`, while the application
|
|
11
|
+
uses an API-compatible `httpx2.AsyncClient`. Consumers therefore need
|
|
12
|
+
`cast(Any, ...)` at every construction site.
|
|
13
|
+
2. Only non-success HTTP responses are represented as `ApiError`. Transport
|
|
14
|
+
failures and response validation failures escape as unrelated `HTTPError`,
|
|
15
|
+
`ValidationError`, or `ValueError` instances. Consumers must repeat broad
|
|
16
|
+
exception tuples around almost every generated operation.
|
|
17
|
+
3. Binary responses are always materialized through `response.content`. Large
|
|
18
|
+
recordings and downloads cannot be transferred incrementally, so consumers
|
|
19
|
+
bypass the generated operation and rebuild its URL, timeout, status handling,
|
|
20
|
+
and streaming behavior manually.
|
|
21
|
+
|
|
22
|
+
The multipart implementation has related limitations:
|
|
23
|
+
|
|
24
|
+
- It recognizes only properties whose own schema has `format: binary`.
|
|
25
|
+
Standard multi-file fields expressed as `array` with binary `items` are not
|
|
26
|
+
recognized as files.
|
|
27
|
+
- It builds `files` as `dict[str, bytes]`. A dictionary cannot represent
|
|
28
|
+
repeated multipart field names and the `bytes` restriction requires the
|
|
29
|
+
complete file to be resident in memory.
|
|
30
|
+
- Multipart bodies are passed through a Pydantic model and `model_dump()`.
|
|
31
|
+
This representation is appropriate for structured data but not for open file
|
|
32
|
+
handles or streaming content.
|
|
33
|
+
- Existing coverage exercises only one small in-memory `bytes` upload, so it
|
|
34
|
+
does not validate multi-file encoding or memory-safe file inputs.
|
|
35
|
+
|
|
36
|
+
## Goals
|
|
37
|
+
|
|
38
|
+
- Generated clients accept compatible asynchronous HTTP transports without
|
|
39
|
+
casts in consumer code.
|
|
40
|
+
- Consumers can catch one generated base exception for all failures originating
|
|
41
|
+
from a client operation.
|
|
42
|
+
- Documented API errors retain their parsed, typed error model.
|
|
43
|
+
- Binary responses can be processed incrementally without duplicating generated
|
|
44
|
+
request and error logic.
|
|
45
|
+
- Binary and multipart requests can use file-like or streaming inputs rather
|
|
46
|
+
than requiring complete in-memory `bytes` values.
|
|
47
|
+
- Multipart arrays are encoded as repeated parts with the correct wire name.
|
|
48
|
+
- Ordinary JSON, form, text, and small binary operations remain simple.
|
|
49
|
+
- Generated client class names describe the API rather than its installation
|
|
50
|
+
path or the fact that its source was generated.
|
|
51
|
+
|
|
52
|
+
## Non-goals
|
|
53
|
+
|
|
54
|
+
- Implementing a generic runtime framework behind generated clients.
|
|
55
|
+
- Adding automatic retries, pagination, or resumable upload protocols.
|
|
56
|
+
- Hiding the generated request construction or OpenAPI response branches.
|
|
57
|
+
- Requiring one concrete HTTP implementation such as `httpx` or `httpx2`.
|
|
58
|
+
|
|
59
|
+
## 1. Client naming and package identity
|
|
60
|
+
|
|
61
|
+
Package location and public client identity are separate concepts and must not
|
|
62
|
+
be derived from the same unprocessed value.
|
|
63
|
+
|
|
64
|
+
The current single-client implementation constructs the class name from the
|
|
65
|
+
complete `package_name`:
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
client_name = f"{class_name(package_name)}Client"
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
In `browser-provisioner`, the import package is `generated.browser_worker`.
|
|
72
|
+
Normalizing that full dotted path therefore produces the unnecessarily verbose
|
|
73
|
+
class name `GeneratedBrowserWorkerClient`. The `Generated` prefix is an
|
|
74
|
+
installation detail, not part of the API domain. A similar package path such as
|
|
75
|
+
`generated.backend_worker` would incorrectly produce
|
|
76
|
+
`GeneratedBackendWorkerClient`.
|
|
77
|
+
|
|
78
|
+
This is a generator design defect rather than an OpenAPI modeling error. The
|
|
79
|
+
consumer should not have to rename its package or add a handwritten alias to get
|
|
80
|
+
a clean public API.
|
|
81
|
+
|
|
82
|
+
### Required naming precedence
|
|
83
|
+
|
|
84
|
+
For a single generated client, resolve its name in this order:
|
|
85
|
+
|
|
86
|
+
1. An explicit `client_name` option supplied through the Python API or
|
|
87
|
+
`--client-name` through the CLI.
|
|
88
|
+
2. Otherwise, the final segment of `package_name`, not the complete import path.
|
|
89
|
+
3. If `package_name` was omitted, the final segment of the output directory as
|
|
90
|
+
it is today.
|
|
91
|
+
|
|
92
|
+
Examples:
|
|
93
|
+
|
|
94
|
+
| Package/import path | Explicit name | Generated class |
|
|
95
|
+
| --- | --- | --- |
|
|
96
|
+
| `generated.browser_worker` | omitted | `BrowserWorkerClient` |
|
|
97
|
+
| `company.payments` | omitted | `PaymentsClient` |
|
|
98
|
+
| `generated.api` | `ProvisionerClient` | `ProvisionerClient` |
|
|
99
|
+
| `ordinary_api` | omitted | `OrdinaryApiClient` |
|
|
100
|
+
|
|
101
|
+
The generator must not heuristically strip words such as `api`, `backend`,
|
|
102
|
+
`service`, or `worker`: they may be meaningful domain terms. Callers can remove
|
|
103
|
+
or replace them explicitly with `--client-name`. Only module-path prefixes are
|
|
104
|
+
discarded by the default rule.
|
|
105
|
+
|
|
106
|
+
The explicit name is the complete class name and is used verbatim after
|
|
107
|
+
validation. The generator must not append a `Client` suffix automatically.
|
|
108
|
+
Invalid Python identifiers, keyword names, and collisions with generated models
|
|
109
|
+
or support types must fail generation with an actionable error.
|
|
110
|
+
|
|
111
|
+
For workspaces split by multiple tags, the existing tag-derived client names
|
|
112
|
+
remain the appropriate default because each tag identifies a distinct API
|
|
113
|
+
surface. A single global `--client-name` must either be rejected for multi-tag
|
|
114
|
+
generation or replaced later by a separately designed per-tag naming option; it
|
|
115
|
+
must not silently rename only one generated client.
|
|
116
|
+
|
|
117
|
+
With this change, the intended `browser-provisioner` usage becomes:
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
from generated.browser_worker import BrowserWorkerClient
|
|
121
|
+
|
|
122
|
+
client = BrowserWorkerClient(http, browser_worker_url)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
There should be no `Generated` prefix in a public class merely because the
|
|
126
|
+
package lives below a `generated` namespace.
|
|
127
|
+
|
|
128
|
+
## 2. Transport abstraction
|
|
129
|
+
|
|
130
|
+
Replace the concrete constructor annotation with a generated structural
|
|
131
|
+
protocol containing only the operations the client actually needs. At minimum,
|
|
132
|
+
the protocol must cover `request()`, `stream()`, and `aclose()` where applicable.
|
|
133
|
+
|
|
134
|
+
Conceptually:
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
class AsyncHttpClient(Protocol):
|
|
138
|
+
async def request(self, method: str, url: str, **kwargs: Any) -> HttpResponse: ...
|
|
139
|
+
|
|
140
|
+
def stream(
|
|
141
|
+
self, method: str, url: str, **kwargs: Any
|
|
142
|
+
) -> AsyncContextManager[HttpResponse]: ...
|
|
143
|
+
|
|
144
|
+
async def aclose(self) -> None: ...
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
The response should likewise be described structurally using only the members
|
|
148
|
+
needed by generated code, such as `status_code`, `headers`, `text`, `content`,
|
|
149
|
+
`json()`, and `aiter_bytes()`.
|
|
150
|
+
|
|
151
|
+
The generated constructor then accepts `AsyncHttpClient` rather than
|
|
152
|
+
`httpx.AsyncClient`:
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
client = BrowserWorkerClient(
|
|
156
|
+
http_client,
|
|
157
|
+
browser_worker_url,
|
|
158
|
+
timeout=30.0,
|
|
159
|
+
)
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Both `httpx.AsyncClient` and `httpx2.AsyncClient` must satisfy the protocol
|
|
163
|
+
without `Any` casts or adapters.
|
|
164
|
+
|
|
165
|
+
### Transport ownership
|
|
166
|
+
|
|
167
|
+
The lifecycle contract must be explicit. Because the transport is injected by
|
|
168
|
+
the caller, it should be treated as borrowed by default. Closing a short-lived
|
|
169
|
+
generated wrapper must not unexpectedly close a shared application transport.
|
|
170
|
+
If owned transports are supported later, ownership should be an explicit
|
|
171
|
+
constructor option or separate factory.
|
|
172
|
+
|
|
173
|
+
## 3. Unified client errors
|
|
174
|
+
|
|
175
|
+
Generate a common exception hierarchy:
|
|
176
|
+
|
|
177
|
+
```python
|
|
178
|
+
class ClientError(Exception):
|
|
179
|
+
"""Base class for failures produced by a generated client operation."""
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class TransportError(ClientError):
|
|
183
|
+
cause: Exception
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class ResponseValidationError(ClientError):
|
|
187
|
+
status_code: int
|
|
188
|
+
response: HttpResponse
|
|
189
|
+
cause: Exception
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class ApiError(ClientError):
|
|
193
|
+
status_code: int
|
|
194
|
+
detail: object | None
|
|
195
|
+
response: HttpResponse
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
The generated operation must translate failures at its boundary:
|
|
199
|
+
|
|
200
|
+
- Exceptions raised while executing the HTTP request become `TransportError`.
|
|
201
|
+
- Malformed JSON, incompatible response bodies, and invalid response headers
|
|
202
|
+
become `ResponseValidationError`.
|
|
203
|
+
- Documented non-success responses and unexpected status codes remain
|
|
204
|
+
`ApiError`.
|
|
205
|
+
- Existing `ClientError` instances must be re-raised unchanged.
|
|
206
|
+
- Cancellation and other `BaseException` subclasses must not be wrapped.
|
|
207
|
+
|
|
208
|
+
This allows ordinary consumer code to catch one type:
|
|
209
|
+
|
|
210
|
+
```python
|
|
211
|
+
try:
|
|
212
|
+
state = await client.capture_browser_state()
|
|
213
|
+
except ClientError as error:
|
|
214
|
+
raise BrowserStateTransferException("Could not capture state") from error
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Specific documented failures remain distinguishable through their parsed model:
|
|
218
|
+
|
|
219
|
+
```python
|
|
220
|
+
try:
|
|
221
|
+
return await client.download_file(download_id)
|
|
222
|
+
except ApiError as error:
|
|
223
|
+
if isinstance(error.detail, DownloadNotFoundError):
|
|
224
|
+
raise DownloadNotFoundException() from error
|
|
225
|
+
raise
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
`ApiError` should expose convenience properties such as `code` and `message`
|
|
229
|
+
when its parsed detail contains those fields. Consumer code should not need
|
|
230
|
+
`getattr(error.parsed_body, "code", None)`.
|
|
231
|
+
|
|
232
|
+
For compatibility, the current `parsed_body` and raw `body` attributes may be
|
|
233
|
+
kept as deprecated aliases during a transition. New generated examples and
|
|
234
|
+
documentation should use `detail` and an explicitly named `raw_body` property.
|
|
235
|
+
|
|
236
|
+
## 4. Streaming binary responses
|
|
237
|
+
|
|
238
|
+
For every operation with a successful binary response, generate both:
|
|
239
|
+
|
|
240
|
+
- the existing convenient method returning `bytes`; and
|
|
241
|
+
- a streaming context-manager variant.
|
|
242
|
+
|
|
243
|
+
Example surface:
|
|
244
|
+
|
|
245
|
+
```python
|
|
246
|
+
content = await client.download_recording(recording_id)
|
|
247
|
+
|
|
248
|
+
async with client.stream_download_recording(recording_id) as file:
|
|
249
|
+
await storage.put(
|
|
250
|
+
StoredObject(
|
|
251
|
+
content=file.iter_bytes(chunk_size=64 * 1024),
|
|
252
|
+
content_type=file.media_type,
|
|
253
|
+
)
|
|
254
|
+
)
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
The yielded object should provide at least:
|
|
258
|
+
|
|
259
|
+
```python
|
|
260
|
+
class BinaryStream:
|
|
261
|
+
media_type: str | None
|
|
262
|
+
content_length: int | None
|
|
263
|
+
filename: str | None
|
|
264
|
+
headers: Mapping[str, str]
|
|
265
|
+
|
|
266
|
+
def iter_bytes(self, chunk_size: int | None = None) -> AsyncIterator[bytes]: ...
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
The stream context manager owns only the individual response lifecycle. Leaving
|
|
270
|
+
the context must close the response, not the injected shared HTTP client.
|
|
271
|
+
|
|
272
|
+
Status matching, content-type validation, parsing of error responses, and
|
|
273
|
+
exception translation must be shared between the buffered and streaming paths.
|
|
274
|
+
The buffered convenience method should be implemented on top of the streaming
|
|
275
|
+
path where practical, so the protocol logic cannot drift between the two.
|
|
276
|
+
|
|
277
|
+
For a success response offering multiple binary media types, `media_type` must
|
|
278
|
+
report the normalized actual response content type. An unsupported content type
|
|
279
|
+
must produce `ResponseValidationError`, not an untyped `ValueError`.
|
|
280
|
+
|
|
281
|
+
## 5. Large binary request bodies
|
|
282
|
+
|
|
283
|
+
An `application/octet-stream` request must not be limited to `bytes`. Its
|
|
284
|
+
generated input type should support incremental request bodies:
|
|
285
|
+
|
|
286
|
+
```python
|
|
287
|
+
BinaryContent = bytes | Iterable[bytes] | AsyncIterable[bytes]
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
The body must be passed directly as request content without Pydantic
|
|
291
|
+
serialization or eager concatenation.
|
|
292
|
+
|
|
293
|
+
## 6. Multipart and multi-file requests
|
|
294
|
+
|
|
295
|
+
### File-part abstraction
|
|
296
|
+
|
|
297
|
+
Generate or export a small transport-neutral file-part type:
|
|
298
|
+
|
|
299
|
+
```python
|
|
300
|
+
@dataclass(frozen=True)
|
|
301
|
+
class UploadFile:
|
|
302
|
+
content: bytes | BinaryIO
|
|
303
|
+
filename: str | None = None
|
|
304
|
+
content_type: str | None = None
|
|
305
|
+
headers: Mapping[str, str] | None = None
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
The generator converts this type to the tuple representation expected by the
|
|
309
|
+
selected HTTP transport. A plain `bytes` or binary file handle may be accepted
|
|
310
|
+
as shorthand where no metadata is needed.
|
|
311
|
+
|
|
312
|
+
Multipart file handles must never be read eagerly by generated serialization
|
|
313
|
+
code.
|
|
314
|
+
|
|
315
|
+
### Generated method shape
|
|
316
|
+
|
|
317
|
+
Multipart fields should be represented directly in the operation signature
|
|
318
|
+
rather than being hidden inside a Pydantic body model that requires `bytes`.
|
|
319
|
+
For example, this OpenAPI shape:
|
|
320
|
+
|
|
321
|
+
```yaml
|
|
322
|
+
type: object
|
|
323
|
+
required: [documents, category]
|
|
324
|
+
properties:
|
|
325
|
+
documents:
|
|
326
|
+
type: array
|
|
327
|
+
items:
|
|
328
|
+
type: string
|
|
329
|
+
format: binary
|
|
330
|
+
category:
|
|
331
|
+
type: string
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
should produce an API resembling:
|
|
335
|
+
|
|
336
|
+
```python
|
|
337
|
+
await client.upload_documents(
|
|
338
|
+
documents=[
|
|
339
|
+
UploadFile(open("a.pdf", "rb"), filename="a.pdf"),
|
|
340
|
+
UploadFile(open("b.pdf", "rb"), filename="b.pdf"),
|
|
341
|
+
],
|
|
342
|
+
category="contracts",
|
|
343
|
+
)
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
Required multipart fields must remain required parameters. Optional fields must
|
|
347
|
+
use `None` and be omitted from the encoded request when absent.
|
|
348
|
+
|
|
349
|
+
If flattening fields would collide with path, query, header, or generated
|
|
350
|
+
control parameters such as `timeout`, generation must fail with a useful error
|
|
351
|
+
or apply a deterministic, documented name disambiguation rule.
|
|
352
|
+
|
|
353
|
+
### Multipart encoding
|
|
354
|
+
|
|
355
|
+
The generated code must use a sequence of pairs rather than a dictionary:
|
|
356
|
+
|
|
357
|
+
```python
|
|
358
|
+
files: list[tuple[str, FilePart]] = []
|
|
359
|
+
files.extend(("documents", document) for document in documents)
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
This preserves repeated wire names required by multi-file fields. Scalar form
|
|
363
|
+
data should likewise preserve repeated values where the OpenAPI schema permits
|
|
364
|
+
arrays.
|
|
365
|
+
|
|
366
|
+
Multipart boundaries remain the responsibility of the HTTP transport. Generated
|
|
367
|
+
code must not set a bare `Content-Type: multipart/form-data` header because doing
|
|
368
|
+
so would omit the transport-generated boundary.
|
|
369
|
+
|
|
370
|
+
## 7. Expected browser-provisioner result
|
|
371
|
+
|
|
372
|
+
After regeneration, `browser-provisioner` should no longer need:
|
|
373
|
+
|
|
374
|
+
- the verbose `GeneratedBrowserWorkerClient` name;
|
|
375
|
+
- `cast(Any, self._http)` when constructing generated clients;
|
|
376
|
+
- repeated `(ApiError, HTTPError, ValidationError, ValueError)` exception tuples;
|
|
377
|
+
- `getattr(error.parsed_body, "code", None)`;
|
|
378
|
+
- hand-built URLs and direct `_http.stream()` calls for generated binary
|
|
379
|
+
endpoints; or
|
|
380
|
+
- duplicated status and content-type handling around recording downloads.
|
|
381
|
+
|
|
382
|
+
The application integration should reduce to domain translation around the
|
|
383
|
+
generated client:
|
|
384
|
+
|
|
385
|
+
```python
|
|
386
|
+
try:
|
|
387
|
+
client = self._client(browser)
|
|
388
|
+
state = await client.capture_browser_state()
|
|
389
|
+
except ClientError as error:
|
|
390
|
+
raise _as_transfer_failure("read browser state from", error) from error
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
and:
|
|
394
|
+
|
|
395
|
+
```python
|
|
396
|
+
client = self._client(browser, transfer=True)
|
|
397
|
+
async with client.stream_download_recording(recording_id) as recording:
|
|
398
|
+
await self._storage.put(
|
|
399
|
+
StoredObject(
|
|
400
|
+
key=key,
|
|
401
|
+
content=recording.iter_bytes(chunk_size=64 * 1024),
|
|
402
|
+
content_type=recording.media_type or "application/octet-stream",
|
|
403
|
+
)
|
|
404
|
+
)
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
## 8. Tests and acceptance criteria
|
|
408
|
+
|
|
409
|
+
### Naming
|
|
410
|
+
|
|
411
|
+
- `package_name="generated.browser_worker"` generates
|
|
412
|
+
`BrowserWorkerClient` by default.
|
|
413
|
+
- `package_name="company.payments"` generates `PaymentsClient` by default.
|
|
414
|
+
- `client_name="ProvisionerClient"` overrides the inferred name exactly.
|
|
415
|
+
- An explicit name without a `Client` suffix is accepted verbatim; explicit
|
|
416
|
+
values are never rewritten beyond validation.
|
|
417
|
+
- Invalid and colliding explicit names fail before files are written.
|
|
418
|
+
- Supplying a single client name during multi-tag generation fails with a clear
|
|
419
|
+
explanation.
|
|
420
|
+
- Package imports continue to use the complete dotted `package_name`; changing
|
|
421
|
+
class-name inference must not alter module paths.
|
|
422
|
+
|
|
423
|
+
### Transport and errors
|
|
424
|
+
|
|
425
|
+
- An `httpx.AsyncClient` satisfies the generated transport protocol.
|
|
426
|
+
- An `httpx2.AsyncClient` satisfies the same protocol without a cast.
|
|
427
|
+
- Request failures are raised as `TransportError` with the original exception as
|
|
428
|
+
`__cause__`.
|
|
429
|
+
- Invalid JSON, invalid typed bodies, invalid response headers, and unsupported
|
|
430
|
+
response content types are raised as `ResponseValidationError`.
|
|
431
|
+
- Documented non-success responses produce `ApiError` containing the parsed
|
|
432
|
+
typed detail.
|
|
433
|
+
- Unexpected status codes produce `ApiError` with `detail is None` and preserve
|
|
434
|
+
the response.
|
|
435
|
+
- All generated operation failures can be caught as `ClientError`.
|
|
436
|
+
|
|
437
|
+
### Streaming downloads
|
|
438
|
+
|
|
439
|
+
- A binary response can be consumed chunk by chunk without accessing
|
|
440
|
+
`response.content`.
|
|
441
|
+
- The response remains open while inside the stream context and closes on exit,
|
|
442
|
+
including exceptional exits.
|
|
443
|
+
- Error responses received by a streaming operation are parsed through the same
|
|
444
|
+
error path as buffered operations.
|
|
445
|
+
- The buffered binary method still returns `bytes` and uses the same response
|
|
446
|
+
validation rules.
|
|
447
|
+
- Multiple declared binary media types report the actual normalized media type.
|
|
448
|
+
|
|
449
|
+
### Multipart uploads
|
|
450
|
+
|
|
451
|
+
- A single `bytes` file remains supported.
|
|
452
|
+
- An open binary file can be uploaded without being read eagerly by generated
|
|
453
|
+
code.
|
|
454
|
+
- An array of binary files is encoded as repeated parts with the same wire name.
|
|
455
|
+
- Optional file fields are omitted when `None`.
|
|
456
|
+
- Filenames, per-part content types, and per-part headers are preserved.
|
|
457
|
+
- Multipart boundaries are generated by the HTTP transport.
|
|
458
|
+
- A test uses a file-like object that fails if the generator attempts an eager
|
|
459
|
+
full read, preventing regressions to in-memory buffering.
|
|
460
|
+
|
|
461
|
+
## 9. Implementation order
|
|
462
|
+
|
|
463
|
+
1. Separate `client_name` from `package_name`, add the CLI/Python override, and
|
|
464
|
+
switch default inference to the final package segment.
|
|
465
|
+
2. Introduce the transport and response protocols and remove the concrete
|
|
466
|
+
`httpx.AsyncClient` annotation.
|
|
467
|
+
3. Add `ClientError`, `TransportError`, and `ResponseValidationError`; update
|
|
468
|
+
generated request and parsing boundaries.
|
|
469
|
+
4. Generate streaming variants for successful binary responses and implement
|
|
470
|
+
buffered binary methods through the shared response path.
|
|
471
|
+
5. Extend request-body analysis to distinguish single binary fields from arrays
|
|
472
|
+
of binary fields.
|
|
473
|
+
6. Introduce `UploadFile`, flatten multipart operation parameters, and encode
|
|
474
|
+
files as repeated pairs.
|
|
475
|
+
7. Regenerate the `browser-provisioner` client as `BrowserWorkerClient` and
|
|
476
|
+
remove its casts, broad exception tuples, and manual recording download
|
|
477
|
+
transport code.
|
|
478
|
+
8. Update the README and remove streaming from the list of general limitations.
|
|
479
|
+
|
|
480
|
+
The first three steps address the concrete readability and large-recording
|
|
481
|
+
problems in `browser-provisioner`. Multipart changes should follow as a separate,
|
|
482
|
+
focused change because they alter generated method signatures and therefore
|
|
483
|
+
need an explicit migration note.
|