Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
14 changes: 7 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ jobs:
- uses: actions/checkout@v6
- uses: taiki-e/install-action@just
- uses: dtolnay/rust-toolchain@stable
if: contains(matrix.generator, 'rust-') || matrix.generator == 'typescript-fetch-vp'
if: contains(matrix.generator, 'rust-') || contains(matrix.generator, 'python-') || matrix.generator == 'typescript-fetch-vp'
- uses: Swatinem/rust-cache@v2
if: contains(matrix.generator, 'rust-')
with:
Expand All @@ -168,8 +168,12 @@ jobs:
if: matrix.generator == 'typescript-fetch-vp'
with:
key: typescript-client-tests
- uses: Swatinem/rust-cache@v2
if: contains(matrix.generator, 'python-')
with:
key: golden-${{ matrix.generator }}
- uses: actions/setup-node@v6
if: contains(matrix.generator, 'typescript-fetch')
if: contains(matrix.generator, 'typescript-fetch') || contains(matrix.generator, 'python-')
with:
node-version: 'lts/*'
- if: contains(matrix.generator, 'typescript-fetch')
Expand All @@ -183,11 +187,7 @@ jobs:
- uses: actions/setup-python@v6
if: contains(matrix.generator, 'python-')
with:
python-version: '3.12'
- uses: actions/setup-node@v6
if: contains(matrix.generator, 'python-')
with:
node-version: '20'
python-version: '3.x'
- uses: actions/setup-java@v5
if: contains(matrix.generator, 'java-') || contains(matrix.generator, 'kotlin-')
with:
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ examples/*/generated/
examples/*/target/
tests/generated-clients/rust/*/generated/
tests/generated-clients/typescript/generated/
tests/generated-clients/python/generated/
tests/generated-clients/jvm/*/generated/
tests/generated-clients/jvm/**/.gradle/
tests/generated-clients/jvm/**/build/
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Convert Python httpx/requests untagged-union members in models, nested containers,
aliases, JSON/multipart requests, responses, and lazy error bodies. Share enum-
aware selection across both transports with `firstMatch`, `strict`, and dynamic
`raw` policies, preserving nested strict errors and productive recursion. Keep
shared helpers import-safe and restore tagged wire wrappers from typed payload
members. Encode required nullable union request bodies as JSON `null` instead
of omitting them.
- Convert camelCase TypeScript untagged-union members in both JSON directions,
including mixed client/search tools and containing requests. Add configurable
`firstMatch`, `strict`, and wire-preserving `raw` selection policies instead
Expand Down
3 changes: 2 additions & 1 deletion Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ mod golden-typescript-clients 'just/golden-typescript-clients.just'
mod golden-go 'just/golden-go.just'
mod golden-rust 'just/golden-rust.just'
mod golden-python 'just/golden-python.just'
mod golden-python-clients 'just/golden-python-clients.just'
mod golden-java 'just/golden-java.just'
mod golden-kotlin 'just/golden-kotlin.just'
mod golden-jvm 'just/golden-jvm.just'
Expand Down Expand Up @@ -154,7 +155,7 @@ golden-check:
# Update .golden files for all generators
golden-update: golden-typescript::update golden-go::update golden-rust::update golden-python::update golden-java::update golden-kotlin::update

# Compile-check all goldens
# Validate all goldens and their integrated consumer contracts
golden-build-all: golden-typescript::build golden-go::build golden-rust::build golden-python::build golden-java::build golden-kotlin::build

# ---------- Publish ----------
Expand Down
1 change: 1 addition & 0 deletions docs/src/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
- [Response Headers](response_headers.md)
- [Rust Generator Configuration](rust_config.md)
- [TypeScript Generator Configuration](ts_config.md)
- [Python Generator Configuration](python_config.md)
- [Architecture](architecture.md)
- [Adding a Generator](adding_a_generator.md)
- [IR Reference](ir_reference.md)
Expand Down
26 changes: 24 additions & 2 deletions docs/src/golden_testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,27 @@ round-trips, not only wrapper identity or discriminator substrings.
There are no JVM build subprocesses or global directory locks in the Rust test
suite. The independent JVM Client Tests CI matrix and `just ci` run these checks.

## Native Python Union Consumers

`just golden-python-clients::test httpx` and
`just golden-python-clients::test requests` generate fresh clients for
`firstMatch`, `strict`, and `raw`, run strict pyright on the unchanged files, then
run ordinary `unittest` consumers. The same contracts cover dataclasses, nested
containers, aliases, finite values, recursion, JSON/multipart transport encoding,
tagged payloads, nulls, and lazy error decoding. In-process HTTP transports avoid
network IO without bypassing each library's request encoder. Native tests do not
launch generation, type checkers, or other runners.

The same `just golden-python::build-httpx` / `build-requests` entrypoints run all
golden checks, fresh public-declaration checks and native consumers, both locally
and in CI. `golden-build-all` and `just ci` inherit this complete validation; CI
does not append a Python-specific test step. Dependencies are installed once in
a temporary environment created with the active Python (locally 3.15.0, CI `3.x`),
and the same checker and interpreter are used throughout the backend's checks.
The generated SDK's minimum and explicit pyright target remain 3.12, a static contract,
not a claim that the native tests execute on the minimum runtime. See the
[Python configuration](python_config.md) for selection-policy semantics.

## Validation Contracts and Toolchains

- Missing fixture files fail their parser tests. Nested fixture paths are relative
Expand All @@ -190,7 +211,8 @@ suite. The independent JVM Client Tests CI matrix and `just ci` run these checks
- Rust golden builds retain their cache under `target/golden-build/rust-<backend>`
or the explicitly configured `CARGO_TARGET_DIR`. CI caches the same directory.
- Python prepares dependencies and resolves pyright once per backend, sharing
that environment across its fixtures. `PYTHON` selects the install interpreter;
that environment across goldens, declaration checks and runtime consumers.
`PYTHON` selects the interpreter used to create the temporary environment;
the active `pyright` is used when available, otherwise npm resolves it once.
- Development and ordinary CI validation select current Rust `stable` (locally
validated with Rust 1.99.0). MSRV checks explicitly select Rust 1.90.0; they do
Expand All @@ -211,7 +233,7 @@ normally; no test harness launches a compiler, build tool, or validation script.
to just/CI, which directly run the golden builds and ordinary consumer projects.

`just ci` includes generator tests and coverage, all nine generators' native
golden builds, and TypeScript/Rust/JVM consumer tests. Golden comparison is already included
golden builds, and TypeScript/Python/Rust/JVM consumer tests. Golden comparison is already included
in `test-all`, so the full recipe does not repeat it as another standalone step.

## Extra File Detection
Expand Down
80 changes: 80 additions & 0 deletions docs/src/python_config.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Python Generator Configuration

Both `python-httpx` and `python-requests` accept the same options. Generated
clients require Python 3.12 or newer.

```toml
[generators.python-httpx]
package_name = "my_client"
untagged_union_strategy = "firstMatch"

[generators.python-requests]
package_name = "my_client"
untagged_union_strategy = "strict"
```

The equivalent CLI override is:

```sh
openapi-nexus generate --input openapi.yaml --output client \
--generators python-httpx \
--generator-config python-httpx.untagged_union_strategy=strict
```

## `package_name`

Overrides the generated import package and project name. The default is derived
from the API title.

## `untagged_union_strategy`

Controls conversion of untagged `oneOf` and `anyOf` values, not general schema
validation. Both keywords use this policy; `strict` does not impose `oneOf`
cardinality on every `anyOf` elsewhere in a specification.

| Value | Member selection | No match | Public representation |
| ------------ | --------------------------------------------- | --------------------------------- | --------------------------------- |
| `firstMatch` | First concrete match, in schema order | Preserve the original value | Existing dataclass/primitive union |
| `strict` | Reject multiple concrete matches | Raise `ValueError` | Existing dataclass/primitive union |
| `raw` | Do not select or convert the union subtree | Preserve the original value | Dynamic `object` |

The default is `firstMatch`. Concrete alternatives take precedence over opaque
`Any`/empty-object fallbacks, even when the fallback appears first. Strict counts
original concrete members before calling any converter; an opaque fallback does
not create false ambiguity. Selected nested unions retain their own policy and
errors rather than silently trying another member. Nullable wrappers permit
null without inventing an extra authored member.

Matching uses required fields, present optional fields, JSON kinds, and retained
finite enum/literal constraints. Booleans are not numbers despite Python's
`True == 1`. Arrays, dictionary values, aliases, and productive recursion retain
the same conversion boundaries. Formats and numeric/string bounds do not serve
as member-selection validators.

For tagged members inside an untagged union, outbound matching follows the
existing public payload representation. The selected tagged converter restores
its wire wrapper only after selection; strict ambiguity counting never calls a
converter to probe whether a member matches.

## Conversion entrypoints

Existing dataclass constructors and `to_dict` / `from_dict` methods remain public.
For a named untagged union `Choice`, its module also provides
`choice_from_dict(value)` and `choice_to_dict(value)`. These helpers accept or
return arbitrary JSON values, not only dictionaries; their names align with the
existing Python tagged-union convention. They are not added to package barrels.

Model fields, JSON HTTP payloads, success responses, and lazy error bodies use
the same conversion plan. Direct typed members can therefore be used in union
fields without manual conversion before `json.dumps`. Required union request
bodies containing `None` are encoded as JSON `null`, not an absent HTTP body.
Optional request bodies retain the existing convention that `None` means omit.

In `raw` mode, supply JSON-compatible values yourself; generated dataclasses
inside a raw subtree are deliberately not auto-serialized. Ordinary containing
objects still use their dataclass conversion. Default unmatched values are kept
as an escape hatch, so annotations describe the normal matching path rather than
proving validation. Choose `strict` to reject unmatched inputs.

Dictionary/additional-property model preservation and multi-member `allOf`
composition are separate concerns and are not redesigned by this option.
19 changes: 19 additions & 0 deletions just/golden-python-clients.just
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Native Python consumers of freshly generated, unmodified output.

set working-directory := '..'

@prepare backend:
#!/usr/bin/env bash
set -euo pipefail
case '{{backend}}' in httpx|requests) ;; *) echo 'Expected httpx or requests' >&2; exit 2 ;; esac
for policy in default strict raw; do
strategy="$policy"
[ "$policy" != default ] || strategy=firstMatch
cargo run --quiet --bin openapi-nexus -- generate --config tests/generated-clients/config.toml --input tests/fixtures/valid/python-untagged-union.yaml --output "tests/generated-clients/python/generated/{{backend}}/$policy" --generators 'python-{{backend}}' --generator-config "python-{{backend}}.package_name=union_$policy" --generator-config "python-{{backend}}.untagged_union_strategy=$strategy"
done

@build backend: (prepare backend)
"${PYRIGHT:-pyright}" --pythonpath "$(command -v "${PYTHON:-python3}")" --project tests/generated-clients/python/pyrightconfig.{{backend}}.json tests/generated-clients/python/generated/{{backend}}/default/union_default tests/generated-clients/python/generated/{{backend}}/strict/union_strict tests/generated-clients/python/generated/{{backend}}/raw/union_raw tests/generated-clients/python/typing_contracts.py

@test backend: (build backend)
NEXUS_PYTHON_BACKEND={{backend}} PYTHONPATH="tests/generated-clients/python/generated/{{backend}}/default:tests/generated-clients/python/generated/{{backend}}/strict:tests/generated-clients/python/generated/{{backend}}/raw" "${PYTHON:-python3}" -m unittest discover -v -s tests/generated-clients/python/tests
6 changes: 3 additions & 3 deletions just/golden-python.just
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,13 @@ update: update-httpx update-requests
@update-requests:
UPDATE_GOLDEN=1 cargo test --test golden_tests_python_requests -- --nocapture

# Type-check all Python goldens
# Validate all Python goldens, public declarations and native consumers
build: build-httpx build-requests

# Type-check Python httpx goldens with pyright
# Validate Python httpx goldens and fresh native consumers
@build-httpx:
./scripts/golden-build-python.sh httpx

# Type-check Python requests goldens with pyright
# Validate Python requests goldens and fresh native consumers
@build-requests:
./scripts/golden-build-python.sh requests
14 changes: 10 additions & 4 deletions scripts/golden-build-python.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ golden="$root/tests/golden/python/python-$backend"
source "$root/scripts/golden-build-common.sh"
golden_require_projects "$golden" pyproject.toml.golden

# One dependency installation and checker resolution per backend, not per fixture.
# One active-interpreter environment for goldens and fresh native consumers.
deps=$(mktemp -d)
tmp=""; log=""
cleanup() {
Expand All @@ -19,7 +19,9 @@ cleanup() {
[ -z "$log" ] || rm -f "$log"
}
trap cleanup EXIT
"${PYTHON:-python3}" -m pip install "$backend" --quiet --target "$deps"
"${PYTHON:-python3}" -m venv "$deps"
python="$deps/bin/python"
"$python" -m pip install "$backend" --quiet
if command -v pyright >/dev/null 2>&1; then
checker=$(command -v pyright)
else
Expand Down Expand Up @@ -47,13 +49,12 @@ for test_dir in "$golden"/*/; do
{
"pythonVersion": "3.12",
"typeCheckingMode": "strict",
"extraPaths": ["$deps"],
"reportUnnecessaryIsInstance": "none"
}
PYRIGHT

log=$(mktemp)
if (cd "$tmp" && "$checker") >"$log" 2>&1; then
if (cd "$tmp" && "$checker" --pythonpath "$python") >"$log" 2>&1; then
echo "ok"
passed=$((passed + 1))
else
Expand All @@ -75,3 +76,8 @@ if [ "$failed" -gt 0 ]; then
for n in "${fails[@]}"; do echo " - $n"; done
exit 1
fi

# The public golden-build entrypoint owns the complete backend contract. CI and
# `just ci` must not append a second, Python-specific validation path.
export PYTHON="$python" PYRIGHT="$checker"
(cd "$root" && just golden-python-clients::test "$backend")
Loading
Loading