[python] Add generateScalarAnyOfAsUnion option

SP
16h ago
+309-5

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 as PetStatus.AVAILABLE, "adopted" reads as the string, and both write back unchanged.

  • New testGenerateScalarAnyOfAsUnion and testScalarAnyOfWrapperByDefault with spec 3_0/python/scalar-anyof-union.yaml. PythonClientCodegenTest: 113 passed, and the other org.openapitools.codegen.python tests 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 ValidationError for a string over max_length and for a number;
    • ApiClient.deserialize for an alias and a List of one;
    • query parameter and request body serialization.

    Decoding and deserialize also pass with compatibleWithPythonLegacy and with lazyImports.

  • ./bin/generate-samples.sh ./bin/configs/*.yaml changes no samples, since the option is off by default. docs/generators/python.md is 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:
    ./mvnw clean package || exit
    ./bin/generate-samples.sh ./bin/configs/*.yaml || exit
    ./bin/utils/export_docs_generators.sh || exit
    
    Commit all changed files.
  • 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_instance or OpenPetStatus(...) must use the value directly.