Skip to content

fix: ignore an empty object default instead of dropping the schema - #1449

Open
alexander-wenzel-dev wants to merge 1 commit into
openapi-generators:mainfrom
alexander-wenzel-dev:fix/freeform-object-default
Open

alexander-wenzel-dev wants to merge 1 commit into
openapi-generators:mainfrom
alexander-wenzel-dev:fix/freeform-object-default

Conversation

@alexander-wenzel-dev

Copy link
Copy Markdown

fix: support default {} on freeform object schemas

Summary

A freeform object schema (type: object, additionalProperties: true,
no declared properties) inside an anyOf could not carry a
default: {}. The parser rejected the default, which silently dropped
the enclosing schema and every endpoint that referenced it. This PR
accepts the empty-dict default and generates a working empty-container
initializer.

Problem

A schema declared as type: object with additionalProperties: true
and no declared properties (often appearing inside an anyOf with
null) could not carry a default: {}. ModelProperty.convert_value
rejected the default with ModelProperty cannot have a default value,
which propagated up as a warning and silently dropped the enclosing
schema and every endpoint that referenced it.

Minimal reproduction

components:
  schemas:
    ModelWithFreeformDefault:
      type: object
      properties:
        extras:
          anyOf:
            - type: object
              additionalProperties: true
            - type: "null"
          default: {}

Before this PR: ModelWithFreeformDefault is missing from the
generated client; the only signal is a warning on stderr.

Fix

Three coordinated changes in model_property.py and union.py:

  1. ModelProperty.convert_value accepts {} on a freeform model
    and emits a ClassName() constructor call. When
    required_properties has not yet been populated, the check falls
    back to the raw schema (data.properties, data.allOf,
    data.anyOf, data.oneOf) so a referenced schema still gets the
    correct decision before its properties have been processed.

  2. Import promotion in ModelProperty. A default value forces the
    generated client to import the inner model at runtime rather than
    only under TYPE_CHECKING, otherwise the default initializer would
    NameError at class-definition time. get_imports and
    get_lazy_imports are updated so inner-property imports are
    promoted when the property has a non-None default.

  3. Import promotion in UnionProperty. The same promotion applies
    to unions that carry a default — every inner property's lazy
    imports become runtime imports so the union's default expression
    resolves at class-definition time.

Tests

  • Functional test:
    end_to_end_tests/functional_tests/generated_code_execution/test_freeform_object_defaults.py
    — uses the inline spec above, asserts the model is instantiable
    and that the default extras is the empty container.
  • Unit tests in
    tests/test_parser/test_properties/test_model_property.py:
    • test_get_imports_with_default and test_get_lazy_imports_with_default
      cover the import-promotion branches.
    • test_convert_value and test_convert_value_unprocessed cover
      convert_value's new accept/reject decisions, including the
      pre-processing fallback path.

Local verification

pdm install
pdm run ruff format . --check
pdm run ruff check .
pdm mypy --show-error-codes
pdm test_with_coverage

All green on Python 3.14. 100% coverage on both changed files
(model_property.py, union.py) when measured across the full
upstream suite. Golden-record snapshot tests pass without
regeneration — the import-promotion paths are only reachable when a
model has a non-None default, which previously errored, so no
existing snapshot can depend on the old behaviour.

Risk

  • ModelProperty.convert_value changes from a @classmethod to an
    instance method.
    A grep across openapi_python_client/, tests/,
    and end_to_end_tests/ finds zero ModelProperty.convert_value(...)
    class-level call sites; every caller uses prop.convert_value(...)
    via the PropertyProtocol. The signature change is invisible to
    existing call sites.
  • Acceptance is conservative: only {} is accepted, and only on
    a model that is genuinely freeform (no properties, allOf,
    anyOf, or oneOf). Any other default value still returns
    PropertyError with the original message, so models with required
    fields can't slip through with an empty default.
  • Snapshot impact: confirmed none. Snapshot tests pass without
    pdm regen.

Related

Companion PR: #1448 — a second independent fix for the
title-collision case surfaced from the same downstream spec. The two
PRs share no code paths and can land in either order.

alexander-wenzel-dev added a commit to alexander-wenzel-dev/mealie-mcp that referenced this pull request Jun 6, 2026
Adds a [tool.uv.sources] override resolving openapi-python-client from
alexander-wenzel-dev/openapi-python-client at branch
pin/mealie-mcp-combined. The branch carries two unmerged generator
fixes (upstream PRs openapi-generators/openapi-python-client#1448 and
openapi-generators/openapi-python-client#1449) that unblock the
Recipe-Input / Recipe-Output models and the PUT/PATCH recipe endpoints
in the generated client.

This commit only changes resolution; the generated client tree is
untouched and gets regenerated in a separate PR. Removal plan is
inline in pyproject.toml.
@alexander-wenzel-dev
alexander-wenzel-dev force-pushed the fix/freeform-object-default branch from de81a94 to a7d33d6 Compare July 19, 2026 07:44
@alexander-wenzel-dev
alexander-wenzel-dev force-pushed the fix/freeform-object-default branch from a7d33d6 to fb00a27 Compare August 22, 2026 08:49
A property whose type is a model schema could not carry `default: {}`.
`ModelProperty.convert_value` rejected it with `ModelProperty cannot have a
default value`, or with `Value {} is not valid, only None is allowed` when the
model sat in a union. That error propagated as a warning, removed the enclosing
schema and every endpoint that referenced it, and still exited 0.

An empty object carries no information for a model default, so it is now
ignored and the property generates with no default. This is how an inline
`default: {}` on a property and a bare `$ref` with a sibling `default` already
behave. A non-empty default is still rejected.

Tests: a functional test in `end_to_end_tests/functional_tests` covers the
empty object default on a union member and on a model referenced through an
`allOf`. Unit tests cover `ModelProperty.convert_value`.
@alexander-wenzel-dev alexander-wenzel-dev changed the title fix: support default {} on freeform object schemas fix: ignore an empty object default instead of dropping the schema Sep 19, 2026
@alexander-wenzel-dev

Copy link
Copy Markdown
Author

Rebased onto main at v0.29.1, and the approach is reduced.

The earlier version of this PR accepted default: {} on a freeform model and generated a ClassName() constructor call as the default. Rendering a default there forces the model's import from lazy to runtime, because a class body evaluates the default while the class is being defined. It also produces a default that is a single shared instance, since attrs does not turn a model instance into a factory, so mutating it reaches every instance of that class and every later call that relies on the default.

This version does less. An empty object carries no information for a model default, so convert_value ignores it and the property generates with no default. The enclosing schema and every endpoint that referenced it stop being dropped, which was the actual problem. Nothing new is rendered, so no import handling changes.

It also lines up with what the generator already does elsewhere. An inline default: {} on a property, and a bare $ref with a sibling default, both generate = UNSET on main today. The model case was the only one that raised instead. A non-empty default is still rejected.

If you would rather see the default honoured, that needs field(factory=...) on the model attribute and a separate answer for endpoint signatures, which cannot take a factory. I am happy to follow up with it.

On Python 3.14 with the locked ruff, ruff format --check, ruff check, mypy and the full test suite pass, and coverage stays at 100%.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant