httpxgen 0.1.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.
Files changed (58) hide show
  1. httpxgen-0.1.0/.github/workflows/ci.yml +64 -0
  2. httpxgen-0.1.0/.gitignore +39 -0
  3. httpxgen-0.1.0/.python-version +1 -0
  4. httpxgen-0.1.0/CLAUDE.md +6 -0
  5. httpxgen-0.1.0/LICENSE +21 -0
  6. httpxgen-0.1.0/PKG-INFO +445 -0
  7. httpxgen-0.1.0/README.md +433 -0
  8. httpxgen-0.1.0/VISION.md +47 -0
  9. httpxgen-0.1.0/httpxgen/__init__.py +12 -0
  10. httpxgen-0.1.0/httpxgen/cli.py +78 -0
  11. httpxgen-0.1.0/httpxgen/generator/__init__.py +4 -0
  12. httpxgen-0.1.0/httpxgen/generator/build.py +162 -0
  13. httpxgen-0.1.0/httpxgen/generator/client.py +729 -0
  14. httpxgen-0.1.0/httpxgen/generator/errors.py +2 -0
  15. httpxgen-0.1.0/httpxgen/generator/models.py +493 -0
  16. httpxgen-0.1.0/httpxgen/generator/naming.py +40 -0
  17. httpxgen-0.1.0/httpxgen/generator/normalize.py +665 -0
  18. httpxgen-0.1.0/httpxgen/generator/operations.py +599 -0
  19. httpxgen-0.1.0/httpxgen/generator/package.py +202 -0
  20. httpxgen-0.1.0/httpxgen/generator/schema.py +142 -0
  21. httpxgen-0.1.0/httpxgen/generator/templates.py +332 -0
  22. httpxgen-0.1.0/httpxgen/io.py +5 -0
  23. httpxgen-0.1.0/httpxgen/loading.py +31 -0
  24. httpxgen-0.1.0/httpxgen/openapi.py +117 -0
  25. httpxgen-0.1.0/httpxgen/output.py +66 -0
  26. httpxgen-0.1.0/httpxgen/selection.py +162 -0
  27. httpxgen-0.1.0/preview/__init__.py +84 -0
  28. httpxgen-0.1.0/preview/invoices/__init__.py +22 -0
  29. httpxgen-0.1.0/preview/invoices/client.py +184 -0
  30. httpxgen-0.1.0/preview/invoices/models.py +61 -0
  31. httpxgen-0.1.0/preview/payments/__init__.py +62 -0
  32. httpxgen-0.1.0/preview/payments/client.py +432 -0
  33. httpxgen-0.1.0/preview/payments/models.py +232 -0
  34. httpxgen-0.1.0/preview/py.typed +0 -0
  35. httpxgen-0.1.0/preview/shared/__init__.py +28 -0
  36. httpxgen-0.1.0/preview/shared/exceptions.py +20 -0
  37. httpxgen-0.1.0/preview/shared/http_methods.py +17 -0
  38. httpxgen-0.1.0/preview/shared/models.py +41 -0
  39. httpxgen-0.1.0/preview/shared/serialization.py +137 -0
  40. httpxgen-0.1.0/pyproject.toml +65 -0
  41. httpxgen-0.1.0/scripts/generate-preview.sh +10 -0
  42. httpxgen-0.1.0/specs/api.yml +753 -0
  43. httpxgen-0.1.0/tests/conftest.py +25 -0
  44. httpxgen-0.1.0/tests/generator/test_build.py +352 -0
  45. httpxgen-0.1.0/tests/generator/test_client.py +265 -0
  46. httpxgen-0.1.0/tests/generator/test_models.py +190 -0
  47. httpxgen-0.1.0/tests/generator/test_naming.py +38 -0
  48. httpxgen-0.1.0/tests/generator/test_normalize.py +427 -0
  49. httpxgen-0.1.0/tests/generator/test_operations.py +406 -0
  50. httpxgen-0.1.0/tests/generator/test_package.py +62 -0
  51. httpxgen-0.1.0/tests/generator/test_runtime.py +600 -0
  52. httpxgen-0.1.0/tests/generator/test_schema.py +92 -0
  53. httpxgen-0.1.0/tests/test_cli.py +51 -0
  54. httpxgen-0.1.0/tests/test_loading.py +39 -0
  55. httpxgen-0.1.0/tests/test_openapi.py +31 -0
  56. httpxgen-0.1.0/tests/test_output.py +98 -0
  57. httpxgen-0.1.0/tests/test_selection.py +136 -0
  58. httpxgen-0.1.0/uv.lock +546 -0
@@ -0,0 +1,64 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ lint:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+
14
+ - name: Install uv
15
+ uses: astral-sh/setup-uv@v4
16
+ with:
17
+ python-version: "3.14"
18
+
19
+ - name: Install dependencies
20
+ run: uv sync --all-groups
21
+
22
+ - name: Ruff lint
23
+ run: uv run ruff check --output-format=github .
24
+
25
+ - name: Ruff format
26
+ run: uv run ruff format --check .
27
+
28
+ preview:
29
+ runs-on: ubuntu-latest
30
+ steps:
31
+ - uses: actions/checkout@v4
32
+
33
+ - name: Install uv
34
+ uses: astral-sh/setup-uv@v4
35
+ with:
36
+ python-version: "3.14"
37
+
38
+ - name: Install dependencies
39
+ run: uv sync --all-groups
40
+
41
+ - name: Regenerate the preview client
42
+ run: bash scripts/generate-preview.sh
43
+
44
+ - name: Fail if the checked-in preview is stale
45
+ run: git diff --exit-code -- preview
46
+
47
+ test:
48
+ runs-on: ubuntu-latest
49
+ strategy:
50
+ matrix:
51
+ python-version: ["3.12", "3.13", "3.14"]
52
+ steps:
53
+ - uses: actions/checkout@v4
54
+
55
+ - name: Install uv
56
+ uses: astral-sh/setup-uv@v4
57
+ with:
58
+ python-version: ${{ matrix.python-version }}
59
+
60
+ - name: Install dependencies
61
+ run: uv sync --all-groups
62
+
63
+ - name: Run tests
64
+ run: uv run pytest
@@ -0,0 +1,39 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ build/
8
+ dist/
9
+ *.egg-info/
10
+ .eggs/
11
+
12
+ # Virtual environments
13
+ .venv/
14
+ venv/
15
+ env/
16
+
17
+ # uv
18
+ .uv/
19
+
20
+ # Testing / coverage
21
+ .tmp/
22
+ .pytest_cache/
23
+ .coverage
24
+ .coverage.*
25
+ coverage.xml
26
+ htmlcov/
27
+ .tox/
28
+ .nox/
29
+
30
+ # Type checkers
31
+ .mypy_cache/
32
+ .pyright/
33
+ .ruff_cache/
34
+
35
+ # Editors / OS
36
+ .vscode/
37
+ .idea/
38
+ .DS_Store
39
+ Thumbs.db
@@ -0,0 +1 @@
1
+ 3.14
@@ -0,0 +1,6 @@
1
+ # httpxgen
2
+
3
+ ## Conventions
4
+
5
+ - In `__init__.py` files, use relative imports (e.g. `from .generator import ...`). Everywhere else, use absolute imports (`from httpxgen.generator import ...`).
6
+ - No blanket module-level docstrings/comments at the top of files (e.g. `"""Renders a typed async httpx client..."""`).
httpxgen-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 mathisarends
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,445 @@
1
+ Metadata-Version: 2.5
2
+ Name: httpxgen
3
+ Version: 0.1.0
4
+ Summary: Generate a typed async httpx client from an OpenAPI document.
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.12
8
+ Requires-Dist: jinja2>=3.1
9
+ Requires-Dist: pydantic>=2.0
10
+ Requires-Dist: pyyaml>=6.0
11
+ Description-Content-Type: text/markdown
12
+
13
+ # httpxgen
14
+
15
+ Generate a **typed async [httpx](https://www.python-httpx.org/) client** from an OpenAPI document — no runtime layer, no reflection, no magic.
16
+
17
+ httpxgen emits plain Python you could have written by hand: `async def` methods with real parameter names, [Pydantic](https://docs.pydantic.dev/) models for every schema, discriminated unions for `oneOf`, `StrEnum` for enums, and `UUID` / `datetime` where the spec says so. Check the output into your repo, read it, click through it in your editor.
18
+
19
+ ```
20
+ openapi.json ──▶ httpxgen ──▶ payments/
21
+ ├── __init__.py
22
+ ├── client.py # the public client class
23
+ ├── models.py
24
+ ├── exceptions.py # ApiError
25
+ ├── http_methods.py # HttpMethods
26
+ ├── serialization.py # parameter and auth helpers
27
+ └── py.typed
28
+ ```
29
+
30
+ Split a large document along its tags and the support modules are generated once,
31
+ beside the clients that share them:
32
+
33
+ ```
34
+ openapi.json ──▶ httpxgen ──▶ api/
35
+ ├── __init__.py # clients, ApiError, all models
36
+ ├── shared/ # generated once
37
+ │ ├── exceptions.py
38
+ │ ├── http_methods.py
39
+ │ ├── models.py # only models used by both tags
40
+ │ └── serialization.py
41
+ ├── payments/
42
+ │ ├── client.py
43
+ │ └── models.py # models only payments uses
44
+ ├── invoices/
45
+ │ ├── client.py
46
+ │ └── models.py
47
+ └── py.typed
48
+ ```
49
+
50
+ ## Install
51
+
52
+ With [uv](https://docs.astral.sh/uv/):
53
+
54
+ ```sh
55
+ uv tool install httpxgen # standalone CLI
56
+ uv add --dev httpxgen # dev dependency of your project
57
+ ```
58
+
59
+ With pip:
60
+
61
+ ```sh
62
+ pip install httpxgen
63
+ ```
64
+
65
+ Or run it without installing anything:
66
+
67
+ ```sh
68
+ uvx httpxgen openapi.json src/payments
69
+ ```
70
+
71
+ httpxgen is only needed at build time — the generated package depends on `httpx` and `pydantic` alone.
72
+
73
+ ## Quick start
74
+
75
+ ```sh
76
+ httpxgen openapi.yaml src/payments --package-name payments
77
+ ```
78
+
79
+ ```
80
+ Generated 6 file(s) in package src/payments.
81
+ ```
82
+
83
+ ```python
84
+ import asyncio
85
+
86
+ import httpx
87
+
88
+ from payments import ApiError, CardPaymentMethod, CreateChargeRequest, Money, PaymentsClient
89
+
90
+
91
+ async def main() -> None:
92
+ async with PaymentsClient(
93
+ httpx.AsyncClient(),
94
+ "https://payments.example.com/api",
95
+ credentials={"bearerAuth": "your-token"},
96
+ ) as client:
97
+ page = await client.list_charges(status="succeeded", page_size=50)
98
+ for charge in page.items:
99
+ print(charge.id, charge.amount.amount_cents, charge.status)
100
+
101
+ try:
102
+ charge = await client.create_charge(
103
+ CreateChargeRequest(
104
+ amount=Money(amount_cents=4200, currency="EUR"),
105
+ payment_method=CardPaymentMethod(...),
106
+ )
107
+ )
108
+ except ApiError as error:
109
+ print(error.status_code, error.body)
110
+ if error.parsed_body is not None:
111
+ print(error.parsed_body)
112
+
113
+
114
+ asyncio.run(main())
115
+ ```
116
+
117
+ ## What the generated code looks like
118
+
119
+ ### From this spec
120
+
121
+ ```yaml
122
+ /charges:
123
+ get:
124
+ operationId: listCharges
125
+ parameters:
126
+ - name: status
127
+ in: query
128
+ schema: { $ref: "#/components/schemas/ChargeStatus" }
129
+ - name: cursor
130
+ in: query
131
+ schema: { type: string }
132
+ - name: page_size
133
+ in: query
134
+ schema: { type: integer, default: 25, minimum: 1, maximum: 200 }
135
+ responses:
136
+ "200":
137
+ content:
138
+ application/json:
139
+ schema: { $ref: "#/components/schemas/ChargePage" }
140
+ ```
141
+
142
+ ### You get this client
143
+
144
+ `operationId` becomes an idiomatic snake_case method, optional query parameters are only sent when set, and the response is validated into a model:
145
+
146
+ `client.py` holds nothing but the imports and the client class — the serialization
147
+ helpers live in `serialization.py`, the error type in `exceptions.py`:
148
+
149
+ ```python
150
+ class ListChargesParams(BaseModel):
151
+ status: ChargeStatus | None = None
152
+ cursor: str | None = None
153
+ page_size: int = Field(25, ge=1, le=200)
154
+
155
+
156
+ class PaymentsClient:
157
+ async def list_charges(
158
+ self,
159
+ status: ChargeStatus | None = None,
160
+ cursor: str | None = None,
161
+ page_size: int = 25,
162
+ *,
163
+ timeout: float | None = None,
164
+ ) -> ChargePage:
165
+ path = "/charges"
166
+
167
+ params = ListChargesParams(
168
+ status=status,
169
+ cursor=cursor,
170
+ page_size=page_size,
171
+ )
172
+ query: list[tuple[str, str]] = []
173
+ if params.status is not None:
174
+ query.extend(serialize_query("status", params.status))
175
+ if params.cursor is not None:
176
+ query.extend(serialize_query("cursor", params.cursor))
177
+ query.extend(serialize_query("page_size", params.page_size))
178
+
179
+ headers = dict(self._headers)
180
+ headers.setdefault("Accept", "application/json")
181
+
182
+ apply_security(self._credentials, [("bearerAuth",)], headers, query, {})
183
+
184
+ response = await self._client.request(
185
+ method=HttpMethods.GET,
186
+ url=f"{self._base_url}{path}",
187
+ params=query,
188
+ headers=headers,
189
+ timeout=self._timeout if timeout is None else timeout,
190
+ )
191
+
192
+ if response.status_code == 200:
193
+ return ChargePage.model_validate(response.json())
194
+
195
+ raise ApiError(response.status_code, response.text, response=response)
196
+ ```
197
+
198
+ Path parameters carry their spec format — `format: uuid` becomes `UUID`, not `str`:
199
+
200
+ ```python
201
+ async def get_customer(
202
+ self,
203
+ customer_id: UUID,
204
+ *,
205
+ timeout: float | None = None,
206
+ ) -> Customer:
207
+ path = "/customers/{customerId}"
208
+ path = path.replace("{customerId}", serialize_path("customerId", customer_id))
209
+
210
+ headers = dict(self._headers)
211
+ headers.setdefault("Accept", "application/json")
212
+
213
+ response = await self._client.request(
214
+ method=HttpMethods.GET,
215
+ url=f"{self._base_url}{path}",
216
+ headers=headers,
217
+ timeout=self._timeout if timeout is None else timeout,
218
+ )
219
+
220
+ if response.status_code == 200:
221
+ return Customer.model_validate(response.json())
222
+ if response.status_code == 404:
223
+ parsed_body = ApiErrorModel.model_validate(response.json())
224
+ raise ApiError(response.status_code, response.text, parsed_body, response)
225
+
226
+ raise ApiError(response.status_code, response.text, response=response)
227
+ ```
228
+
229
+ Request bodies are a single typed `body` argument, serialized by alias and without `None` noise:
230
+
231
+ ```python
232
+ async def create_charge(
233
+ self,
234
+ body: CreateChargeRequest,
235
+ *,
236
+ timeout: float | None = None,
237
+ ) -> Charge:
238
+ ...
239
+ json_body = TypeAdapter(CreateChargeRequest).dump_python(
240
+ body, mode="json", by_alias=True, exclude_none=True
241
+ )
242
+ ...
243
+ ```
244
+
245
+ The same direct shape is used for other ordinary body encodings:
246
+ `application/x-www-form-urlencoded` is passed as `data=`, multipart object
247
+ fields are separated into `data=` and `files=`, and binary payloads use
248
+ `content=`. Multipart boundaries remain under `httpx`'s control.
249
+
250
+ The client is an async context manager, so `httpx` connections are closed for you:
251
+
252
+ ```python
253
+ http_client = httpx.AsyncClient()
254
+ async with PaymentsClient(
255
+ http_client,
256
+ "https://payments.example.com/api",
257
+ credentials={"bearerAuth": "your-token"},
258
+ ) as client:
259
+ ...
260
+ ```
261
+
262
+ ### And these models
263
+
264
+ `allOf` becomes inheritance, `oneOf` + `discriminator` becomes a Pydantic discriminated union, string enums become `StrEnum`, and `minimum` / `maxLength` survive as `Field(...)` constraints:
265
+
266
+ ```python
267
+ class ChargeStatus(StrEnum):
268
+ PENDING = "pending"
269
+ SUCCEEDED = "succeeded"
270
+ FAILED = "failed"
271
+ REFUNDED = "refunded"
272
+
273
+
274
+ class Money(BaseModel):
275
+ amount_cents: int
276
+ currency: str = Field(min_length=3, max_length=3)
277
+
278
+
279
+ class CardPaymentMethod(BaseModel):
280
+ type: Literal[PaymentMethodType.CARD]
281
+ card_number: str
282
+ exp_month: int = Field(ge=1, le=12)
283
+ exp_year: int
284
+ billing_address: Address | None = None
285
+
286
+
287
+ PaymentMethod = Annotated[
288
+ CardPaymentMethod | BankTransferPaymentMethod,
289
+ Field(discriminator="type"),
290
+ ]
291
+
292
+
293
+ class BaseEntity(BaseModel):
294
+ id: str
295
+ created_at: datetime
296
+
297
+
298
+ class Charge(BaseEntity): # allOf: BaseEntity + own properties
299
+ amount: Money
300
+ status: ChargeStatus
301
+ payment_method: PaymentMethod # discriminated at parse time
302
+ metadata: dict[str, str] | None = None
303
+ ```
304
+
305
+ Everything is re-exported from the package root, so consumers import from one place:
306
+
307
+ ```python
308
+ from payments import ApiError, Charge, ChargeStatus, Money, PaymentsClient
309
+ ```
310
+
311
+ ## CLI
312
+
313
+ ```
314
+ httpxgen OPENAPI OUTPUT [--package-name NAME] [--tag TAG] [--schema-tag TAG] [--check]
315
+ ```
316
+
317
+ | Argument | Meaning |
318
+ | --- | --- |
319
+ | `OPENAPI` | OpenAPI JSON or YAML file |
320
+ | `OUTPUT` | target package directory, or the root holding one package per tag when several `--tag` are given (created if missing) |
321
+ | `--package-name` | import name and client class prefix; defaults to the output directory name |
322
+ | `--tag TAG` | generate only operations carrying this tag; repeat it for one package per tag |
323
+ | `--schema-tag TAG` | keep schemas referenced by this tag without generating its operations; repeatable |
324
+ | `--check` | write nothing; exit non-zero when the checked-in output is stale |
325
+
326
+ Carve a focused client out of a large spec:
327
+
328
+ ```sh
329
+ httpxgen openapi.json src/billing \
330
+ --package-name billing \
331
+ --tag charges \
332
+ --schema-tag webhooks
333
+ ```
334
+
335
+ Repeat `--tag` and you get one client package per tag. `ApiError`, `HttpMethods`,
336
+ and the serialization helpers are generated once in `shared/`, and every model lands in
337
+ the package that uses it — `shared/models.py` holds only what more than one tag
338
+ references. Generated modules import each other absolutely, so the output reads
339
+ the same wherever you open it:
340
+
341
+ ```sh
342
+ httpxgen specs/api.yml src/api --package-name api --tag payments --tag invoices
343
+ ```
344
+
345
+ ```python
346
+ # src/api/invoices/client.py
347
+ from api.invoices.models import CreateInvoiceRequest, Invoice, InvoicePage
348
+ from api.shared import ApiError, HttpMethods, apply_security, serialize_path
349
+ from api.shared.models import ApiErrorModel
350
+ ```
351
+
352
+ ```python
353
+ from api import ApiError, InvoicesClient, Money, PaymentsClient
354
+ ```
355
+
356
+ Every managed file starts with a `# Generated by httpxgen. DO NOT EDIT.` header. Files without it are never overwritten — httpxgen aborts instead.
357
+
358
+ ## Use it from a shell script
359
+
360
+ Generated code is checked in, so a tiny script is usually all the automation you need.
361
+
362
+ `scripts/generate-client.sh`:
363
+
364
+ ```sh
365
+ #!/usr/bin/env sh
366
+ set -eu
367
+
368
+ SPEC_URL="https://payments.example.com/api/openapi.json"
369
+ OUT="src/payments"
370
+
371
+ curl -fsSL "$SPEC_URL" -o openapi.json
372
+ uvx httpxgen openapi.json "$OUT" --package-name payments
373
+
374
+ echo "client regenerated in $OUT"
375
+ ```
376
+
377
+ ```sh
378
+ chmod +x scripts/generate-client.sh
379
+ ./scripts/generate-client.sh
380
+ ```
381
+
382
+ Use the `--check` variant in CI so a drifting spec fails the build instead of surprising you at runtime:
383
+
384
+ ```yaml
385
+ # .github/workflows/client.yml
386
+ - name: Verify generated client is current
387
+ run: |
388
+ curl -fsSL "$SPEC_URL" -o openapi.json
389
+ uvx httpxgen openapi.json src/payments --package-name payments --check
390
+ ```
391
+
392
+ ```
393
+ Generated HTTP client is current.
394
+ ```
395
+
396
+ Or wire it into a `Makefile`:
397
+
398
+ ```make
399
+ .PHONY: client client-check
400
+
401
+ client:
402
+ uvx httpxgen openapi.json src/payments --package-name payments
403
+
404
+ client-check:
405
+ uvx httpxgen openapi.json src/payments --package-name payments --check
406
+ ```
407
+
408
+ ## Scope
409
+
410
+ httpxgen targets ordinary OpenAPI 3.0 and 3.1 client specifications, not every
411
+ JSON Schema feature. It supports JSON/YAML input, local component references,
412
+ path/query/header/cookie serialization, JSON/form/multipart/binary request
413
+ bodies, JSON/text/binary responses, numeric/default/status-range responses,
414
+ typed error bodies, common
415
+ security schemes, directional request/response models, inline and recursive
416
+ Pydantic models, enums, nullable values, discriminated unions, and practical
417
+ `allOf` inheritance.
418
+
419
+ Unsupported constructs fail generation where possible. Important remaining
420
+ limitations are external references, streaming, callbacks/webhooks, automatic
421
+ pagination, and a synchronous client.
422
+
423
+ See [`MISSING_IMPL.md`](MISSING_IMPL.md) for the prioritized checklist and the
424
+ test requirements for each future step.
425
+
426
+ ## Development
427
+
428
+ ```sh
429
+ uv sync --all-groups
430
+ uv run pytest
431
+ uv run ruff check . # lint
432
+ uv run ruff format . # format
433
+ ```
434
+
435
+ `preview/` is generated output, checked in so the effect of a change is visible
436
+ in review. Regenerate it whenever generated code changes — CI fails if it is
437
+ stale:
438
+
439
+ ```sh
440
+ ./scripts/generate-preview.sh
441
+ ```
442
+
443
+ ## License
444
+
445
+ MIT