Skip to content

feat(python): add forward-compatible model serialization - #133

Draft
ThomasRooney wants to merge 22 commits into
mainfrom
thomas/python-preserve-unknown-fields
Draft

ThomasRooney wants to merge 22 commits into
mainfrom
thomas/python-preserve-unknown-fields

Conversation

@ThomasRooney

@ThomasRooney ThomasRooney commented Oct 22, 2026 •

Copy link
Copy Markdown
Member

Why

Python SDKs need undeclared fields to survive nested JSON request and response serialization, and model helpers must serialize future enum values without a downstream base-model patch.

What changed

  • Add python.preserveUnknownFields (default false) to retain undeclared fields in generated models and error bodies. Explicitly typed additional properties still validate; the option deliberately permits extras on closed schemas.
  • Add python.modelSerializationMethods (default false) for to_dict and to_json helpers, including field aliases and Pydantic filtering options. Reserve the helper names only when enabled.
  • Give unrecognized string and integer enums explicit JSON serializers without warnings, while retaining the original objects in Python-mode dumps. This fix also applies when the new options are disabled.
  • Preserve nested JSON nulls without changing top-level omission, required-value validation, or optional JSON query parameters.
  • Add four echo-test variants covering every combination of the two options and dictionary/model inputs, sync/async requests, recursion, arrays, maps, aliases, nulls, unions, composed schemas, empty models, error responses, helper-name collisions, and separate input/output models. All four variants run in CI.
  • Align the generator test job with the existing patched PHP setup used by PHP jobs so dependency security checks can run on a supported patch release.

Configuration schema: speakeasy-api/sdk-gen-config#136

Testing

  • 121 echo/runtime cases pass in all four combinations of the two options, including warning-free enum serialization and Python-mode object identity.
  • The enabled suite also passes on Pydantic 2.11.2, the minimum supported version.
  • Generated SDKs pass Pyright, mypy, Pylint and Python compilation.
  • Focused Python generator snapshots, template type-check, template/configuration unit tests pass.
  • Review SDK regeneration passes integrity validation; its only runtime source change is the base-model enum serializers and formatting.
  • git diff --check passes.

Public-safety check

  • This change contains no credentials, customer documents, private repository URLs, private filesystem paths, or unredacted private logs.
  • Title, body, comments, and commit messages name no customers or customer-derived identifiers, private paths or trackers, or workflow provenance, and are understandable without private context.
  • Generated fixtures and review SDK changes are public-safe.
  • I reviewed git diff --check.

@speakeasy-generator-bot

speakeasy-generator-bot Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Generator Snapshot Testing

Selector(s) Result Summary
snapshot-python ❌ Snapshot run failed

Snapshot results: https://github.com/speakeasy-api/openapi-generation-snapshots/issues/128#issuecomment-6357571860

Snapshot results are summarized above. Additional run context is linked for maintainers with access.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed

Shadow auto-approve: would not auto-approve because issues were found.
Tip: Get a Slack DM when cubic finishes reviewing your PRs. Connect Slack

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread templates/templates/pythonv2/tests/unknown-fields/test_unknown_fields.py.stmpl Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 28 files

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Shadow auto-approve: would auto-approve. Adds an opt-in Python generator flag (default false) so opted-in models retain undeclared fields through validation and serialization; default output is unchanged and new echo-test variants/CI cover the enabled and disabled paths.

View guided diff | Turn on auto-fix | Re-trigger cubic

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 4 files (changes from recent commits).

Shadow auto-approve: would not auto-approve because issues were found.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread templates/templates/pythonv2/tests/unknown-fields/test_unknown_fields.py.stmpl Outdated
@ThomasRooney
ThomasRooney marked this pull request as ready for review October 22, 2026 11:49
@ThomasRooney
ThomasRooney requested a review from a team as a code owner October 22, 2026 11:49

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0 issues found across 3 files (changes from recent commits).

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Shadow auto-approve: would not auto-approve. Auto-approval blocked by 2 unresolved issues from previous reviews.

View guided diff | Turn on auto-fix | Re-trigger cubic

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0 issues found across 1 file (changes from recent commits).

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Shadow auto-approve: would not auto-approve. Auto-approval blocked by 2 unresolved issues from previous reviews.

View guided diff | Turn on auto-fix | Re-trigger cubic

@ThomasRooney ThomasRooney changed the title feat(python): preserve unknown model fields feat(python): add forward-compatible model serialization Oct 22, 2026

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0 issues found across 14 files (changes from recent commits).

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Shadow auto-approve: would not auto-approve. Auto-approval blocked by 2 unresolved issues from previous reviews.

View guided diff | Turn on auto-fix | Re-trigger cubic

Exercise all four model serialization configurations in CI.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0 issues found across 14 files (changes from recent commits).

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Shadow auto-approve: would not auto-approve. Auto-approval blocked by 2 unresolved issues from previous reviews.

View guided diff | Turn on auto-fix | Re-trigger cubic

@2ynn
2ynn self-requested a review October 22, 2026 17:27
Comment thread Makefile Outdated
@2ynn
2ynn marked this pull request as draft October 22, 2026 22:53
@2ynn

2ynn commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

@ThomasRooney The commits I pushed solve the following issues:

With both options off

  • Open-enum serializers decline values of other types. In a union of an open enum with a number or boolean, 1.5 was sent as 1 and true as 1. This affected every SDK with such a union.

modelSerializationMethods

  • to_dict/to_json keep consts and discriminators, so their output validates again.
  • Only model fields are renamed to avoid to_dict/to_json. SDK accessors (a toJson group became sdk.to_json_), constructor arguments and server variables keep their names.
  • use_api_names=False returns Python names.
  • Fields excluded by exclude_unset, include, exclude or a null default stay out of the output instead of coming back as None.

preserveUnknownFields

  • Unset ignores extras, so invalid values on optional nullable fields still validate to UNSET.
  • The serializer reads each field from the key the dump used (also with only modelSerializationMethods). In API-name dumps, an unknown field named like a Python attribute is no longer dropped or taken as the declared value. Excluded required fields are not written as None. exclude_none helpers omit nulls, while request payloads still send explicit nulls.
  • Generated tests compare declared response fields exactly and check only the undeclared fields present in the expected value.
  • The option description states that undeclared top-level request fields need flattenRequests: false.

Tests and CI

  • The four dedicated unknown-fields variants, their CI jobs and spec are replaced by a shared fragment in the existing variants: primary (both off), secondary (preserve), tertiary (methods), quaternary (both). The echo operation is skipped on javav2 and the helper group on ruby, where existing naming issues break generation.
  • echoUnknownFields is skipped in tertiary TypeScript only, because that configuration gives inline success and error bodies the same name.
  • The PHP CI step already on main is dropped.

Each commit stands alone, so any of them can be dropped.

Known limitations

  • When a payload contains both a field's API name and an undeclared key equal to its Python name (displayName and display_name), the wire and API-name dumps (model_dump(by_alias=True), to_dict()) keep both keys. Python-name dumps, including the default model_dump() and to_dict(use_api_names=False), show the undeclared value in place of the declared one. Plain Pydantic does the same with extra="allow" and populate_by_name=True.
  • Additional properties assigned under a key the model already emits replace the declared value in the output.
  • Flattened operation arguments accept only declared fields.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 37 files (changes from recent commits).

Shadow auto-approve: would not auto-approve because issues were found.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread templates/templates/pythonv2/model.py.stmpl
2ynn added 2 commits October 23, 2026 00:18
Const fields, including union discriminators, are populated by their
default, so the exclude_unset default of to_dict and to_json dropped
them. A discriminated union value then failed re-validation, and a
const beside optional fields was emitted as None.

When modelSerializationMethods is enabled, models with const fields now
mark them as set after initialisation, so the helpers emit the same
consts as the request payload.

Optional consts whose value is null are not marked, so they stay out of
request payloads and helper output unless the caller sets them.
Enabling modelSerializationMethods appended a trailing underscore to
every name matching a helper, not only to model fields. A toJson
operation group became sdk.to_json_ in code and usage snippets, and
globals or server variables named like a helper were renamed in SDK
constructor arguments.

Sub-SDK classes and the SDK constructor have no helper methods, so only
model fields, including those of the generated globals model, keep the
trailing underscore.

Usage snippets name globals with the same constructor argument names.
2ynn added 12 commits October 23, 2026 00:18
The generated model serializer always wrote declared fields under their
API names, so to_dict(use_api_names=False) and to_json(use_api_names=False)
returned API names for any model with optional or nullable fields.

When modelSerializationMethods is enabled, the serializer now writes
field names when by_alias is explicitly False. model_dump() without
by_alias, by_alias=True and request serialization are unchanged.
main installs the same patched PHP in the test-generator job, so this
branch no longer needs its own copy of the step.
With preserveUnknownFields, Unset inherited extra="allow", so an
invalid dict on an OptionalNullable field validated to Unset(...)
carrying that dict and no longer compared equal to UNSET.
The generated model serializer looked a field up under its API name,
fell back to its Python name, and removed both keys. With
preserveUnknownFields or modelSerializationMethods enabled this:

- dropped an unknown field named like a declared field's Python name,
  because serialized.pop(n) ran as the eager default of
  serialized.pop(k);
- took that unknown field as the declared field's value, written under
  the API name, when exclude_none or exclude_defaults had dropped the
  declared field;
- wrote None for required fields Pydantic excluded because they held
  their default, so to_dict(exclude_defaults=True) emitted
  {"kind": None} for consts;
- returned None for nullable fields from to_dict(exclude_none=True) and
  to_json(exclude_none=True), because the serializer puts explicit nulls
  back after Pydantic drops them for request serialization.

When either option is enabled, the serializer now receives the
serialization info and reads each field only from the key the dump
used, by_alias deciding between the API and Python name. With
modelSerializationMethods, fields dropped by exclude_defaults stay out,
and to_dict and to_json dump with a serialization context so fields
dropped by exclude_none stay out too. Request payloads still send
explicit nulls.
With preserveUnknownFields, response models keep fields the server
returns but the spec does not declare, and model equality includes
them. Generated tests compared whole response models with ==, so they
failed whenever the server sent such fields.

Compare declared fields exactly and undeclared fields only where the
expected value has them. Additional properties of models that declare
additionalProperties are still compared exactly. The helper ships in
tests/unknown_fields.py, generated whenever tests are generated with
preserveUnknownFields.
Run the unknown-field cases from a shared uber fragment instead of four
dedicated variants with their own spec:

- secondary enables preserveUnknownFields and keeps whole request
  bodies, so top-level unknown fields can be sent and echoed.
- tertiary enables modelSerializationMethods.
- quaternary enables both with flattened requests, covering to_dict and
  to_json on responses that carry unknown fields.

Every other variant keeps both options off. The four unknown-fields
variants, their CI jobs and the separate spec are removed.

Generated models holding unrecognized enum values are also dumped with
warnings raised as errors, not only a bare TypeAdapter.

The echo operation is excluded from javav2, whose generated union
members repeat the same interface when the parent schema has input and
output variants.

The discriminator enums of the fragment are named schemas, so Go does
not give the NumberChoice kind the type name Kind used by an existing
enum.
Share the unknown-field cases between variants, each paired with the
fields left when unknown fields are dropped:

- primary (both options off) checks unknown fields are dropped from
  models and request bodies.
- secondary (preserveUnknownFields) echoes them as before.
- tertiary (modelSerializationMethods) checks to_dict and to_json drop
  unknown fields, keep consts, reserve helper names only on models and
  honour exclude_none.
- quaternary (both) round trips every case through to_dict and to_json,
  compares them with literal expected values instead of model_dump with
  the same options, sends flattened requests asynchronously, and covers
  unknown fields named like an excluded field and additional properties
  in the assertion helper.

The serialization-helper group is excluded from ruby, where its
accessor would shadow Object#to_json.
The model serializer put back every field the dump had excluded. As a
result, to_dict() returned unset nullable fields whose default is null,
although it leaves every other unset defaulted field out, and dumps with
exclude_unset, include or exclude wrote None for the fields they
excluded.

Only request serialization needs fields put back: it dumps with
exclude_none and still sends explicit nulls on nullable fields. When
modelSerializationMethods is enabled, excluded fields now stay out of
every dump except exclude_none dumps outside to_dict and to_json.
The JSON serializer of UnrecognizedStr and UnrecognizedInt accepted any
value. While serializing a union, Pydantic can try it for values that
validated as another member, so an open enum followed by a number or
boolean branch sent 1.5 as 1 and true as 1. This applied to every SDK
with such a union, with or without the new model options.

Each serializer now declines values that are not its own type, so the
union serializes them with the matching member. Unrecognized enum values
are still returned as UnrecognizedStr and UnrecognizedInt in Python mode
and as plain strings and integers in JSON.

The test fragment declares its enums as named schemas. Inline, the
integer enum generated a type named One in other targets, replacing the
existing One model.
Flattened operation arguments only accept declared fields, so undeclared
top-level request fields need flattenRequests: false. Nested models
passed as arguments keep their undeclared fields. State this in the
option description.

Note at the model serializer that Pydantic writes extras after declared
fields, so an extra under a field's emitted key replaces that field's
value. The template comment is not emitted into generated code.
The tertiary TypeScript configuration combines flat responses, no index
modules and the legacy naming scheme. With that combination, an
operation whose success and error bodies are both inline gives both the
same generated name, and generation fails with a duplicate import.

Remove echoUnknownFields only from the tertiary TypeScript spec, so the
other TypeScript variants and every other target keep the operation.
Delete this overlay once the generator names the two bodies apart.
@2ynn
2ynn force-pushed the thomas/python-preserve-unknown-fields branch from deff206 to c904549 Compare October 23, 2026 04:31

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0 issues found across 4 files (changes from recent commits).

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Shadow auto-approve: would require human review. Adds opt-in Python serialization/unknown-field preservation with tests and an open-enum serializer fix; requires human review because the dismissal of the model_post_init collision claim relies on a reserved-keyword list not visible in the diff.

View guided diff | Turn on auto-fix | Re-trigger cubic

@2ynn

2ynn commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

@ThomasRooney I split the work into a stack of four PRs for easier review:

  1. fix(python): serialize unrecognized open enum values #145: unrecognized enum serialization
  2. fix(python): honour include/exclude filters in model dumps #149: honour include/exclude filters in model dumps
  3. feat(python): add preserveUnknownFields option #146: preserveUnknownFields
  4. feat(python): add modelSerializationMethods option #147: modelSerializationMethods

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

default python Trigger (106) snapshot tests for python

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants