[python] Add generateScalarAnyOfAsUnion option
Summary
An anyOf of enums and plain values is generated as a wrapper model, so SDK code wraps every value it sends and unwraps every value it reads through actual_instance. The new opt-in option generateScalarAnyOfAsUnion generates such a schema as a Union type alias instead, so properties hold the value itself.
For example, a pet status that accepts the known values and any status the server adds later:
PetStatus:
type: string
enum: [available, pending, sold]
OpenPetStatus:
anyOf:
- $ref: '#/components/schemas/PetStatus'
- type: string
Pet:
type: object
required: [status]
properties:
status:
$ref: '#/components/schemas/OpenPetStatus'
# default (unchanged)
pet = Pet(status=OpenPetStatus(PetStatus.AVAILABLE))
adopted = Pet(status=OpenPetStatus("adopted"))
status = pet.status.actual_instance
if isinstance(status, PetStatus):
... # a known status
else:
... # any other string
# generateScalarAnyOfAsUnion=true
pet = Pet(status=PetStatus.AVAILABLE)
adopted = Pet(status="adopted")
if isinstance(pet.status, PetStatus):
... # a known status
else:
... # any other string
Lists and dicts of the type hold plain values the same way.
The generated model changes from a class to an alias:
# default (unchanged)
class OpenPetStatus(BaseModel):
anyof_schema_1_validator: Optional[PetStatus] = None
anyof_schema_2_validator: Optional[StrictStr] = None
actual_instance: Any = None
...
# generateScalarAnyOfAsUnion=true
OpenPetStatus = Annotated[Union[PetStatus, StrictStr], Field(union_mode="left_to_right")]
union_mode="left_to_right" makes a value take the first member it validates against, in the order the anyOf lists them, so "available" becomes PetStatus.AVAILABLE. pydantic's default smart mode would keep it a str, because the input is already a str. Member constraints carry over, for example Annotated[str, Field(strict=True, max_length=32)].
A schema qualifies when every member is an enum model or a string, number, integer, boolean, date, date-time or UUID, and the schema has no properties, discriminator, oneOf or parent. Any other anyOf keeps the wrapper, because an object can match several members at once.
AbstractPythonCodegen.postProcessAllModels (all of this only with the option)
mark qualifying anyOf models x-py-scalar-union
postProcessModelsMap
alias: members typed without Optional, no BaseModel/ConfigDict imports
mark properties, list/dict items and wrapper members that hold an alias as primitive
-> from_dict()/to_dict() pass the value through
model_anyof_scalar.mustache the alias
api_client.mustache deserialize a response typed as an alias with TypeAdapter
model_doc.mustache alias page lists its members; property rows still link to it
Evidence
-
Both snippets above run against clients generated from the schema above, with and without the option, and pass
pyright --strict."available"reads asPetStatus.AVAILABLE,"adopted"reads as the string, and both write back unchanged. -
New
testGenerateScalarAnyOfAsUnionandtestScalarAnyOfWrapperByDefaultwith spec3_0/python/scalar-anyof-union.yaml.PythonClientCodegenTest: 113 passed, and the otherorg.openapitools.codegen.pythontests pass. -
With the option, a client generated from that spec (pydantic 2.14) passes these runtime checks:
- decode and round trip, including list, dict, nullable and wrapper-member values;
- a
ValidationErrorfor a string overmax_lengthand for a number; ApiClient.deserializefor an alias and aListof one;- query parameter and request body serialization.
Decoding and
deserializealso pass withcompatibleWithPythonLegacyand withlazyImports. -
./bin/generate-samples.sh ./bin/configs/*.yamlchanges no samples, since the option is off by default.docs/generators/python.mdis regenerated.
Merge Danger
Door: two-way. With the option off, output is unchanged.
Blast Radius: opt-in python.
Turning the option on is a breaking change for that client: code that builds or unwraps the wrapper (OpenPetStatus(...), .actual_instance, OpenPetStatus.from_json) has to use the value directly, as in the snippet above.
PR checklist
- Read the contribution guidelines.
- Run the following to build the project and update samples:
Commit all changed files../mvnw clean package || exit ./bin/generate-samples.sh ./bin/configs/*.yaml || exit ./bin/utils/export_docs_generators.sh || exit - If your PR is targeting a particular programming language, @mention the technical committee members, so they are more likely to review the pull request.
@cbornet @tomplus @krjakbrjak @fa0311: review requested for the new Python client option.
Summary by cubic
Adds generateScalarAnyOfAsUnion, an opt-in option that generates an anyOf schema whose members are all enums or scalar types (strings, numbers, booleans, dates, UUIDs) as a Union type alias instead of a wrapper model. With the option on, properties hold plain values directly, e.g. pet.status = PetStatus.AVAILABLE, and list/dict items and response deserialization pass values through. With the option off (default), output is unchanged.
- Migration: Enabling the option is a breaking change: code that wraps or unwraps values via
actual_instanceorOpenPetStatus(...)must use the value directly.