httpxgen 0.2.0__tar.gz → 0.4.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 (89) hide show
  1. {httpxgen-0.2.0 → httpxgen-0.4.0}/.github/workflows/ci.yml +7 -3
  2. httpxgen-0.4.0/.github/workflows/release.yml +135 -0
  3. {httpxgen-0.2.0 → httpxgen-0.4.0}/PKG-INFO +187 -16
  4. {httpxgen-0.2.0 → httpxgen-0.4.0}/README.md +186 -15
  5. {httpxgen-0.2.0 → httpxgen-0.4.0}/VISION.md +1 -1
  6. httpxgen-0.4.0/changelog.md +324 -0
  7. {httpxgen-0.2.0 → httpxgen-0.4.0}/httpxgen/cli.py +94 -94
  8. {httpxgen-0.2.0 → httpxgen-0.4.0}/httpxgen/generator/__init__.py +4 -4
  9. {httpxgen-0.2.0 → httpxgen-0.4.0}/httpxgen/generator/build.py +46 -24
  10. {httpxgen-0.2.0 → httpxgen-0.4.0}/httpxgen/generator/client.py +1057 -894
  11. {httpxgen-0.2.0 → httpxgen-0.4.0}/httpxgen/generator/models.py +630 -493
  12. {httpxgen-0.2.0 → httpxgen-0.4.0}/httpxgen/generator/naming.py +57 -0
  13. {httpxgen-0.2.0 → httpxgen-0.4.0}/httpxgen/generator/normalize.py +160 -104
  14. {httpxgen-0.2.0 → httpxgen-0.4.0}/httpxgen/generator/operations.py +863 -579
  15. {httpxgen-0.2.0 → httpxgen-0.4.0}/httpxgen/generator/package.py +29 -5
  16. {httpxgen-0.2.0 → httpxgen-0.4.0}/httpxgen/generator/schema.py +108 -24
  17. httpxgen-0.4.0/httpxgen/generator/templates/binary_operation.jinja2 +51 -0
  18. {httpxgen-0.2.0 → httpxgen-0.4.0}/httpxgen/generator/templates/client.jinja2 +1 -1
  19. httpxgen-0.2.0/httpxgen/generator/templates/exceptions.jinja2 → httpxgen-0.4.0/httpxgen/generator/templates/errors.jinja2 +8 -3
  20. httpxgen-0.4.0/httpxgen/generator/templates/models.jinja2 +37 -0
  21. httpxgen-0.4.0/httpxgen/generator/templates/serialization.jinja2 +329 -0
  22. {httpxgen-0.2.0 → httpxgen-0.4.0}/httpxgen/generator/templates/shared_init.jinja2 +18 -3
  23. httpxgen-0.4.0/httpxgen/generator/templates/transport.jinja2 +135 -0
  24. {httpxgen-0.2.0 → httpxgen-0.4.0}/httpxgen/generator/templates.py +3 -4
  25. {httpxgen-0.2.0 → httpxgen-0.4.0}/httpxgen/openapi.py +19 -2
  26. {httpxgen-0.2.0 → httpxgen-0.4.0}/httpxgen/output.py +110 -85
  27. httpxgen-0.4.0/httpxgen/references.py +162 -0
  28. httpxgen-0.4.0/httpxgen/selection.py +196 -0
  29. {httpxgen-0.2.0 → httpxgen-0.4.0}/preview/__init__.py +93 -93
  30. {httpxgen-0.2.0 → httpxgen-0.4.0}/preview/invoices/__init__.py +22 -22
  31. {httpxgen-0.2.0 → httpxgen-0.4.0}/preview/invoices/client.py +270 -264
  32. {httpxgen-0.2.0 → httpxgen-0.4.0}/preview/invoices/models.py +59 -61
  33. {httpxgen-0.2.0 → httpxgen-0.4.0}/preview/payments/__init__.py +62 -62
  34. {httpxgen-0.2.0 → httpxgen-0.4.0}/preview/payments/client.py +675 -646
  35. {httpxgen-0.2.0 → httpxgen-0.4.0}/preview/payments/models.py +244 -232
  36. {httpxgen-0.2.0 → httpxgen-0.4.0}/preview/shared/__init__.py +71 -56
  37. httpxgen-0.2.0/preview/shared/exceptions.py → httpxgen-0.4.0/preview/shared/errors.py +84 -79
  38. {httpxgen-0.2.0 → httpxgen-0.4.0}/preview/shared/models.py +72 -41
  39. httpxgen-0.4.0/preview/shared/serialization.py +336 -0
  40. httpxgen-0.4.0/preview/shared/transport.py +141 -0
  41. {httpxgen-0.2.0 → httpxgen-0.4.0}/pyproject.toml +66 -66
  42. {httpxgen-0.2.0 → httpxgen-0.4.0}/specs/api.yml +753 -753
  43. {httpxgen-0.2.0 → httpxgen-0.4.0}/tests/conftest.py +25 -25
  44. {httpxgen-0.2.0 → httpxgen-0.4.0}/tests/generator/test_build.py +399 -377
  45. {httpxgen-0.2.0 → httpxgen-0.4.0}/tests/generator/test_client.py +265 -267
  46. httpxgen-0.4.0/tests/generator/test_cookies.py +161 -0
  47. {httpxgen-0.2.0 → httpxgen-0.4.0}/tests/generator/test_improvements.py +26 -15
  48. {httpxgen-0.2.0 → httpxgen-0.4.0}/tests/generator/test_models.py +203 -190
  49. {httpxgen-0.2.0 → httpxgen-0.4.0}/tests/generator/test_normalize.py +425 -427
  50. {httpxgen-0.2.0 → httpxgen-0.4.0}/tests/generator/test_package.py +61 -61
  51. {httpxgen-0.2.0 → httpxgen-0.4.0}/tests/generator/test_runtime.py +597 -597
  52. {httpxgen-0.2.0 → httpxgen-0.4.0}/tests/generator/test_schema.py +2 -0
  53. httpxgen-0.4.0/tests/generator/test_tickets.py +2634 -0
  54. httpxgen-0.4.0/tests/generator/test_typing.py +116 -0
  55. {httpxgen-0.2.0 → httpxgen-0.4.0}/tests/test_cli.py +75 -75
  56. {httpxgen-0.2.0 → httpxgen-0.4.0}/tests/test_openapi.py +38 -31
  57. httpxgen-0.4.0/tests/test_openapi_32.py +286 -0
  58. httpxgen-0.4.0/tests/test_output.py +171 -0
  59. {httpxgen-0.2.0 → httpxgen-0.4.0}/tests/test_selection.py +136 -136
  60. {httpxgen-0.2.0 → httpxgen-0.4.0}/uv.lock +1 -1
  61. httpxgen-0.2.0/httpxgen/generator/templates/binary_operation.jinja2 +0 -51
  62. httpxgen-0.2.0/httpxgen/generator/templates/http_methods.jinja2 +0 -12
  63. httpxgen-0.2.0/httpxgen/generator/templates/models.jinja2 +0 -6
  64. httpxgen-0.2.0/httpxgen/generator/templates/serialization.jinja2 +0 -130
  65. httpxgen-0.2.0/httpxgen/generator/templates/transport.jinja2 +0 -112
  66. httpxgen-0.2.0/httpxgen/selection.py +0 -162
  67. httpxgen-0.2.0/preview/shared/http_methods.py +0 -17
  68. httpxgen-0.2.0/preview/shared/serialization.py +0 -137
  69. httpxgen-0.2.0/preview/shared/transport.py +0 -109
  70. httpxgen-0.2.0/tests/test_output.py +0 -99
  71. {httpxgen-0.2.0 → httpxgen-0.4.0}/.gitignore +0 -0
  72. {httpxgen-0.2.0 → httpxgen-0.4.0}/.python-version +0 -0
  73. {httpxgen-0.2.0 → httpxgen-0.4.0}/CLAUDE.md +0 -0
  74. {httpxgen-0.2.0 → httpxgen-0.4.0}/IMPROVEMENT_SPEC.md +0 -0
  75. {httpxgen-0.2.0 → httpxgen-0.4.0}/LICENSE +0 -0
  76. {httpxgen-0.2.0 → httpxgen-0.4.0}/httpxgen/__init__.py +0 -0
  77. {httpxgen-0.2.0 → httpxgen-0.4.0}/httpxgen/generator/errors.py +0 -0
  78. {httpxgen-0.2.0 → httpxgen-0.4.0}/httpxgen/generator/templates/enum.jinja2 +0 -0
  79. {httpxgen-0.2.0 → httpxgen-0.4.0}/httpxgen/generator/templates/generated_header.jinja2 +0 -0
  80. {httpxgen-0.2.0 → httpxgen-0.4.0}/httpxgen/generator/templates/model.jinja2 +0 -0
  81. {httpxgen-0.2.0 → httpxgen-0.4.0}/httpxgen/generator/templates/operation.jinja2 +0 -0
  82. {httpxgen-0.2.0 → httpxgen-0.4.0}/httpxgen/generator/templates/package_init.jinja2 +0 -0
  83. {httpxgen-0.2.0 → httpxgen-0.4.0}/httpxgen/io.py +0 -0
  84. {httpxgen-0.2.0 → httpxgen-0.4.0}/httpxgen/loading.py +0 -0
  85. {httpxgen-0.2.0 → httpxgen-0.4.0}/preview/py.typed +0 -0
  86. {httpxgen-0.2.0 → httpxgen-0.4.0}/scripts/generate-preview.sh +0 -0
  87. {httpxgen-0.2.0 → httpxgen-0.4.0}/tests/generator/test_naming.py +0 -0
  88. {httpxgen-0.2.0 → httpxgen-0.4.0}/tests/generator/test_operations.py +0 -0
  89. {httpxgen-0.2.0 → httpxgen-0.4.0}/tests/test_loading.py +0 -0
@@ -17,7 +17,7 @@ jobs:
17
17
  python-version: "3.14"
18
18
 
19
19
  - name: Install dependencies
20
- run: uv sync --all-groups
20
+ run: uv sync --locked --all-groups
21
21
 
22
22
  - name: Ruff lint
23
23
  run: uv run ruff check --output-format=github .
@@ -36,7 +36,7 @@ jobs:
36
36
  python-version: "3.14"
37
37
 
38
38
  - name: Install dependencies
39
- run: uv sync --all-groups
39
+ run: uv sync --locked --all-groups
40
40
 
41
41
  - name: Regenerate the preview client
42
42
  run: bash scripts/generate-preview.sh
@@ -58,7 +58,11 @@ jobs:
58
58
  python-version: ${{ matrix.python-version }}
59
59
 
60
60
  - name: Install dependencies
61
- run: uv sync --all-groups
61
+ run: uv sync --locked --all-groups
62
62
 
63
63
  - name: Run tests
64
64
  run: uv run pytest
65
+
66
+ - name: Type-check generated protocols
67
+ if: matrix.python-version == '3.14'
68
+ run: uv run --frozen --with mypy==2.4.0 --with httpx2==2.13.1 pytest --no-cov tests/generator/test_typing.py
@@ -0,0 +1,135 @@
1
+ name: Release
2
+
3
+ on:
4
+ pull_request:
5
+ release:
6
+ types: [published]
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ concurrency:
12
+ group: release-${{ github.event.release.tag_name || github.head_ref || github.ref }}
13
+ cancel-in-progress: false
14
+
15
+ jobs:
16
+ build:
17
+ name: Build and verify distributions
18
+ runs-on: ubuntu-latest
19
+ steps:
20
+ - uses: actions/checkout@v4
21
+ with:
22
+ fetch-depth: 0
23
+ persist-credentials: false
24
+
25
+ - name: Install uv
26
+ uses: astral-sh/setup-uv@v4
27
+ with:
28
+ python-version: "3.14"
29
+
30
+ - name: Validate release tag and source
31
+ if: github.event_name == 'release'
32
+ env:
33
+ RELEASE_TAG: ${{ github.event.release.tag_name }}
34
+ run: |
35
+ python - <<'PY'
36
+ import os
37
+ import tomllib
38
+ from pathlib import Path
39
+
40
+ version = tomllib.loads(Path("pyproject.toml").read_text())["project"]["version"]
41
+ expected = f"v{version}"
42
+ if os.environ["RELEASE_TAG"] != expected:
43
+ raise SystemExit(f"Release tag must be {expected}")
44
+ PY
45
+ git merge-base --is-ancestor HEAD origin/main
46
+
47
+ - name: Install locked dependencies
48
+ run: uv sync --locked --all-groups
49
+
50
+ - name: Lint and format
51
+ run: |
52
+ uv run ruff check --output-format=github .
53
+ uv run ruff format --check .
54
+
55
+ - name: Run tests
56
+ run: uv run pytest
57
+
58
+ - name: Type-check generated protocols
59
+ run: uv run --frozen --with mypy==2.4.0 --with httpx2==2.13.1 pytest --no-cov tests/generator/test_typing.py
60
+
61
+ - name: Verify checked-in preview
62
+ run: |
63
+ bash scripts/generate-preview.sh
64
+ git diff --exit-code -- preview
65
+
66
+ - name: Build wheel and source distribution
67
+ run: uv build --no-sources
68
+
69
+ - name: Check distribution metadata
70
+ run: uvx --from twine twine check --strict dist/*
71
+
72
+ - name: Smoke-test the installed wheel outside the checkout
73
+ run: |
74
+ wheel="$(realpath dist/*.whl)"
75
+ cd "$RUNNER_TEMP"
76
+ uv run --isolated --no-project --with "$wheel" httpxgen --help
77
+ uv run --isolated --no-project --with "$wheel" httpxgen \
78
+ "$GITHUB_WORKSPACE/specs/api.yml" "$RUNNER_TEMP/release_smoke" \
79
+ --package-name release_smoke --tag payments --tag invoices
80
+ uv run --isolated --no-project --with "$wheel" httpxgen \
81
+ "$GITHUB_WORKSPACE/specs/api.yml" "$RUNNER_TEMP/release_smoke" \
82
+ --package-name release_smoke --tag payments --tag invoices --check
83
+
84
+ - name: Store verified distributions
85
+ uses: actions/upload-artifact@v4
86
+ with:
87
+ name: distributions
88
+ path: |
89
+ dist/*.whl
90
+ dist/*.tar.gz
91
+ if-no-files-found: error
92
+
93
+ publish:
94
+ name: Publish to PyPI
95
+ if: github.event_name == 'release'
96
+ needs: build
97
+ runs-on: ubuntu-latest
98
+ environment:
99
+ name: pypi
100
+ url: https://pypi.org/project/httpxgen/
101
+ permissions:
102
+ id-token: write
103
+ steps:
104
+ - name: Download verified distributions
105
+ uses: actions/download-artifact@v4
106
+ with:
107
+ name: distributions
108
+ path: dist
109
+
110
+ - name: Publish with Trusted Publishing
111
+ uses: pypa/gh-action-pypi-publish@release/v1
112
+
113
+ release-assets:
114
+ name: Attach published distributions to GitHub Release
115
+ if: github.event_name == 'release'
116
+ needs: publish
117
+ runs-on: ubuntu-latest
118
+ permissions:
119
+ contents: write
120
+ steps:
121
+ - name: Download published distributions
122
+ uses: actions/download-artifact@v4
123
+ with:
124
+ name: distributions
125
+ path: dist
126
+
127
+ - name: Calculate checksums
128
+ working-directory: dist
129
+ run: sha256sum -- *.whl *.tar.gz > SHA256SUMS
130
+
131
+ - name: Upload release assets
132
+ env:
133
+ GH_TOKEN: ${{ github.token }}
134
+ RELEASE_TAG: ${{ github.event.release.tag_name }}
135
+ run: gh release upload "$RELEASE_TAG" dist/* --repo "$GITHUB_REPOSITORY" --clobber
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: httpxgen
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: Generate a typed async httpx client from an OpenAPI document.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -38,6 +38,7 @@ the client, check it into your repository, review it, debug it, and ship it.
38
38
  - [Automation and CI](#automation-and-ci)
39
39
  - [Supported scope](#supported-scope)
40
40
  - [Development](#development)
41
+ - [Releasing](#releasing)
41
42
  - [License](#license)
42
43
 
43
44
  ## Why httpxgen?
@@ -64,11 +65,10 @@ A regular specification becomes a self-contained Python package:
64
65
  openapi.json ──▶ httpxgen ──▶ payments/
65
66
  ├── __init__.py
66
67
  ├── client.py # the public client class
67
- ├── models.py
68
- ├── exceptions.py # generated client errors
69
- ├── http_methods.py # HttpMethods
68
+ ├── models.py # API models, HttpMethods, HttpResponse
69
+ ├── errors.py # generated client errors
70
70
  ├── serialization.py # parameter and auth helpers
71
- ├── transport.py # structural protocols and file types
71
+ ├── transport.py # HTTP client protocol and file types
72
72
  └── py.typed
73
73
  ```
74
74
 
@@ -79,9 +79,8 @@ models and support modules are generated once beside them:
79
79
  openapi.json ──▶ httpxgen ──▶ api/
80
80
  ├── __init__.py # clients, ApiError, all models
81
81
  ├── shared/ # generated once
82
- │ ├── exceptions.py
83
- │ ├── http_methods.py
84
- │ ├── models.py # only models used by both tags
82
+ │ ├── errors.py
83
+ │ ├── models.py # shared models, HttpMethods, HttpResponse
85
84
  │ ├── serialization.py
86
85
  │ └── transport.py
87
86
  ├── payments/
@@ -93,6 +92,10 @@ openapi.json ──▶ httpxgen ──▶ api/
93
92
  └── py.typed
94
93
  ```
95
94
 
95
+ `HttpMethods` and `HttpResponse` live in `models.py` (`shared/models.py` in a
96
+ workspace). Generated error classes live in `errors.py`. These types are also
97
+ available from the package root.
98
+
96
99
  ## Install
97
100
 
98
101
  Requires Python 3.12 or newer.
@@ -200,7 +203,7 @@ imports stay short and stable.
200
203
  `operationId` becomes an idiomatic snake_case method, optional query parameters are only sent when set, and the response is validated into a model:
201
204
 
202
205
  `client.py` holds nothing but the imports and the client class — the serialization
203
- helpers live in `serialization.py`, the error type in `exceptions.py`:
206
+ helpers live in `serialization.py`, the error type in `errors.py`:
204
207
 
205
208
  ```python
206
209
  class ListChargesParams(BaseModel):
@@ -291,7 +294,23 @@ Path parameters carry their spec format — `format: uuid` becomes `UUID`, not `
291
294
  raise ApiError(response.status_code, response.text, response=response)
292
295
  ```
293
296
 
294
- Request bodies are a single typed `body` argument, serialized by alias and without `None` noise:
297
+ Request bodies are a single typed `body` argument, serialized by alias.
298
+ JSON inputs are validated using the declared Pydantic schema before the HTTP
299
+ transport runs, including mappings and model instances changed after creation.
300
+ Invalid inputs raise `RequestValidationError`; Pydantic's normal accepted
301
+ conversions apply before serialization.
302
+ Unsupported runtime `content_type` choices also raise `RequestValidationError`
303
+ before sending a request, with the declared alternatives listed.
304
+ Explicitly supplied nullable fields are sent as JSON null; unset nullable fields
305
+ are omitted, including inside nested models:
306
+
307
+ For `application/merge-patch+json`, all unset model fields are omitted, including
308
+ schema defaults. Explicit values equal to a default are still sent. Ordinary
309
+ JSON create requests continue to include non-null defaults.
310
+
311
+ Omit an optional JSON body by leaving out `body`. Passing `body=None` sends the
312
+ literal JSON payload `null` when the schema permits null, with the selected JSON
313
+ Content-Type. Omission uses a separate generated sentinel.
295
314
 
296
315
  ```python
297
316
  async def create_charge(
@@ -301,9 +320,7 @@ Request bodies are a single typed `body` argument, serialized by alias and witho
301
320
  timeout: float | None = None,
302
321
  ) -> Charge:
303
322
  ...
304
- json_body = TypeAdapter(CreateChargeRequest).dump_python(
305
- body, mode="json", by_alias=True, exclude_none=True
306
- )
323
+ json_body = serialize_json_body(body, CreateChargeRequest)
307
324
  ...
308
325
  ```
309
326
 
@@ -311,10 +328,19 @@ The same direct shape is used for JSON, form, text, and small binary bodies.
311
328
  An `application/octet-stream` body accepts `bytes`, `Iterable[bytes]`, or
312
329
  `AsyncIterable[bytes]` and is passed directly as `content=`.
313
330
 
331
+ Synchronous binary iterables are adapted lazily to async chunks. Exceptions
332
+ raised while consuming an upload iterable are reported as `TransportError`.
333
+
314
334
  Multipart fields are flattened into method arguments. Binary arrays become
315
- repeated parts with the same wire name, while `UploadFile` carries optional
335
+ repeated parts with the same wire name. Parameter and multipart argument names
336
+ that collide with generated locals (such as `path`, `headers`, or `files`) gain
337
+ an `_arg` suffix; their wire names are preserved. `UploadFile` carries optional
316
338
  per-part metadata without reading an open file eagerly:
317
339
 
340
+ Bytes and default `UploadFile` inputs use the filename `upload`; bare handles
341
+ use their basename when available. Set `UploadFile(filename=None, ...)`
342
+ explicitly to send a filename-less part.
343
+
318
344
  ```python
319
345
  await client.upload_documents(
320
346
  documents=[
@@ -332,6 +358,16 @@ call sites to pass fields directly.
332
358
  Successful binary responses also get a streaming context-manager variant. The
333
359
  buffered method remains available for small responses:
334
360
 
361
+ For endpoints with mixed success representations, buffered methods return the
362
+ declared bytes/model/text/None union. Streaming variants yield `BinaryStream`
363
+ for binary content and the parsed value for JSON, text, or empty successes.
364
+
365
+ Binary successes with declared headers return a typed result whose `body` is
366
+ bytes and whose fields contain validated headers. Their streaming variants
367
+ yield a corresponding `...Result...Stream` model with `body: BinaryStream` and
368
+ the same header fields, validated before iteration. Endpoints without declared
369
+ headers retain the direct bytes/`BinaryStream` API.
370
+
335
371
  ```python
336
372
  content = await client.download_recording(recording_id)
337
373
 
@@ -343,6 +379,7 @@ print(recording.media_type, recording.content_length, recording.filename)
343
379
  ```
344
380
 
345
381
  The streaming context owns the individual response only. The generated wrapper
382
+ closes entered responses even when validation or reading is cancelled, and
346
383
  treats its injected HTTP client as borrowed, so closing the wrapper does not
347
384
  close a shared application transport:
348
385
 
@@ -359,10 +396,14 @@ await http_client.aclose()
359
396
  ```
360
397
 
361
398
  All operation failures derive from `ClientError`. `TransportError` retains its
362
- original `cause`, `ResponseValidationError` retains both the response and cause,
399
+ original `cause`, including failures during binary iteration.
400
+ `ResponseValidationError` retains both the response and cause,
363
401
  and `ApiError` exposes a parsed `detail` plus convenient `code` and `message`
364
402
  properties. `body` and `parsed_body` remain compatibility aliases for
365
403
  `raw_body` and `detail`.
404
+ When an error declares headers, `detail` retains its typed result wrapper and
405
+ header fields; `code`/`message` read its typed payload. Arbitrary user payloads
406
+ with a property named `body` are not unwrapped.
366
407
 
367
408
  ### Generated models
368
409
 
@@ -530,7 +571,58 @@ client-check:
530
571
 
531
572
  ## Supported scope
532
573
 
533
- httpxgen targets ordinary OpenAPI 3.0 and 3.1 client specifications, not every
574
+ HTTP header names merge case-insensitively. Configured values override generated
575
+ defaults; operation header parameters override configured values; applied
576
+ security credentials take precedence over both. Each logical header is sent once.
577
+
578
+ Recursive array and `anyOf` aliases use lazy Python 3.12+ type aliases, including
579
+ mutual recursion. Recursive nondiscriminated `oneOf` and reference-only aliases are rejected with
580
+ an actionable error; ordinary aliases and recursive object models stay readable.
581
+
582
+ Exact `oneOf` membership uses JSON types without string/number/boolean coercion.
583
+ Formatted date/UUID strings remain valid, and mathematically integral JSON
584
+ numbers count as integers. Zero or multiple matching branches are rejected.
585
+
586
+ Query names beginning with `_` or `model_` receive a public `field`/`field_`
587
+ prefix in generated signatures and query models. Their wire names and
588
+ validation constraints remain intact.
589
+
590
+ Leading-underscore schema properties also receive a `field` prefix (for example,
591
+ `_id` becomes `field_id`) and keep an explicit wire alias. They are validated
592
+ ordinary model fields, including when additional properties are forbidden.
593
+
594
+ Operation names that collide with generated lifecycle methods or streaming
595
+ companions fail generation before writing files; rename the conflicting
596
+ `operationId` in the specification.
597
+
598
+ Local Path Item references are resolved before schema normalization and tag
599
+ selection. Authored siblings replace the corresponding inherited fields;
600
+ omitted siblings preserve inherited parameters and operations. Cycles fail
601
+ generation with a specific error.
602
+
603
+ References to nullable object components accept null in response bodies, request
604
+ arguments, and nested fields while keeping the object model's required fields
605
+ and constraints. OpenAPI 3.0 `nullable` requires an explicit type.
606
+
607
+ URL-encoded forms honor supported per-field style/explode encodings and JSON
608
+ contentType. Object values use JSON by default; explicit form styles can flatten
609
+ simple objects. Unsupported encoding options (including form allowReserved)
610
+ fail generation.
611
+
612
+ Multipart object fields and object-array items are JSON parts with aliases and
613
+ the declared `encoding.contentType` (JSON/+json), or application/json by default.
614
+ File parts also honor `encoding.contentType`. Unsupported multipart encoding
615
+ options fail generation explicitly.
616
+
617
+ Dot-valued path identifiers (`.` and `..`) are percent-encoded to preserve the
618
+ route boundary instead of allowing URL dot-segment normalization.
619
+
620
+ Path `simple`, `label`, and `matrix` styles support scalar, flat array, and flat
621
+ object values with their style-specific explode delimiters. Data is escaped
622
+ before joining it, including literal commas, label dots, slashes, and percent
623
+ signs. `allowReserved` applies only to query parameters.
624
+
625
+ httpxgen targets ordinary OpenAPI 3.0, 3.1 and 3.2 client specifications, not every
534
626
  JSON Schema feature. It supports JSON/YAML input, local component references,
535
627
  path/query/header/cookie serialization, JSON/form/multipart/binary request
536
628
  bodies, JSON/text/binary responses, numeric/default/status-range responses,
@@ -539,12 +631,56 @@ security schemes, directional request/response models, inline and recursive
539
631
  Pydantic models, enums, nullable values, discriminated unions, and practical
540
632
  `allOf` inheritance.
541
633
 
634
+ `allOf` supports object-model inheritance. Scalar/array intersections and
635
+ nullable or union bases fail generation with an unsupported-composition error.
636
+
637
+ Required object keys without declared property schemas retain presence checks
638
+ and their additional-property validation, including inherited and directional
639
+ models. Their values remain unconstrained when additional properties are allowed.
640
+
641
+ OpenAPI 3.2 documents are accepted with the same feature set as 3.1, plus
642
+ reusable `components.mediaTypes` references. Streaming media types described
643
+ through `itemSchema` (for example Server-Sent Events or JSON Lines), `QUERY`
644
+ operations, `additionalOperations`, and `querystring` parameters are not
645
+ generated and fail with an explicit error. Mixed REST/streaming documents can
646
+ still produce a REST client: select the REST operations with `--tag` and keep
647
+ the event models with `--schema-tag`. Model selection follows `itemSchema` and
648
+ `contentSchema`, so a payload such as
649
+ `itemSchema.properties.data.contentSchema: {$ref: ...}` keeps its referenced
650
+ models, including nested and recursive references:
651
+
652
+ ```sh
653
+ httpxgen openapi.json src/api --tag rest --schema-tag sse
654
+ ```
655
+
542
656
  Unsupported constructs fail generation where possible. Important remaining
543
657
  limitations are external references, callbacks/webhooks, automatic
544
658
  pagination, and a synchronous client.
545
659
 
546
660
  ## Development
547
661
 
662
+ String `format: byte` values use standard Base64 in JSON (OpenAPI 3.0 and the
663
+ legacy format in 3.1). Python bytes hold decoded data; Base64 strings are decoded
664
+ with strict alphabet/padding validation. Invalid response strings raise
665
+ `ResponseValidationError`. This does not change `format: binary`, raw binary
666
+ bodies, or multipart file uploads.
667
+
668
+ Parameter `content` supports one JSON/+json representation for query, header,
669
+ and path positions. Values are validated and serialized as one compact JSON
670
+ value with aliases and formatted fields. Required nullable parameters send JSON
671
+ null; optional `None` omits the parameter. JSON cookie content and combinations
672
+ with schema/style/explode/allowReserved are rejected during generation.
673
+
674
+ Query `allowReserved: true` preserves `:/?@!$'()*,;` and valid percent-encoded
675
+ triples in values. It escapes `[]#&=+`, stray percent signs, and other disallowed
676
+ characters to preserve query boundaries and form decoding. Parameter names,
677
+ ordinary parameters, and query API keys retain normal escaping.
678
+
679
+ Regeneration removes obsolete modules carrying the generator header inside the
680
+ output package; `--check` reports them without changing files. Handwritten files
681
+ and unmanaged `__init__.py`/`py.typed` files are preserved. Linked output paths
682
+ are rejected instead of writing outside the package.
683
+
548
684
  ```sh
549
685
  uv sync --all-groups
550
686
  uv run pytest
@@ -552,6 +688,14 @@ uv run ruff check . # lint
552
688
  uv run ruff format . # format
553
689
  ```
554
690
 
691
+ CI also checks generated transport/operation typing with Mypy against HTTPX and
692
+ HTTPX2. Run that check locally with:
693
+
694
+ ```sh
695
+ uv run --frozen --with mypy==2.4.0 --with httpx2==2.13.1 \
696
+ pytest --no-cov tests/generator/test_typing.py
697
+ ```
698
+
555
699
  `preview/` is generated output, checked in so the effect of a change is visible
556
700
  in review. Regenerate it whenever generated code changes — CI fails if it is
557
701
  stale:
@@ -560,6 +704,33 @@ stale:
560
704
  ./scripts/generate-preview.sh
561
705
  ```
562
706
 
707
+ ## Releasing
708
+
709
+ Publishing a GitHub Release runs `.github/workflows/release.yml`, which builds
710
+ and verifies the wheel and source distribution, publishes them to PyPI through
711
+ Trusted Publishing, and attaches the same files plus `SHA256SUMS` to the release.
712
+ Pull requests run the build and verification steps as a publishing dry run.
713
+
714
+ 1. Update `project.version` in `pyproject.toml`, run `uv lock` to synchronize
715
+ `uv.lock`, and finalize the version's changelog and migration notes.
716
+ 2. Merge the release preparation PR into `main` and wait for green CI.
717
+ 3. Create a GitHub Release with tag `v<project.version>` targeting that merged
718
+ commit. A draft does not publish anything; choose **Publish release** to
719
+ start the workflow.
720
+ 4. Wait for the **Release** workflow to finish and verify the new version on
721
+ [PyPI](https://pypi.org/project/httpxgen/).
722
+
723
+ The workflow rejects tags that do not match `project.version` or point to a
724
+ commit outside `main`. The PyPI Trusted Publisher must name repository
725
+ `mathisarends/httpxgen`, workflow `release.yml`, and environment `pypi`.
726
+ The publishing job uses that GitHub environment and `id-token: write`; no PyPI
727
+ API token is required. Any configured environment approval must be completed
728
+ before the publishing job can run.
729
+
730
+ The tagged commit must contain the release workflow. For the first automated
731
+ release, merge this workflow before creating the tag. If reusing an existing
732
+ draft, update its target commit and release notes before publishing it.
733
+
563
734
  ## License
564
735
 
565
736
  MIT