Skip to content

[BUG][PYTHON] Nullable response type emits a _response_types_map string __deserialize cannot parse — AttributeError on every successful response #24712

Description

@brttfnll

Bug Report Checklist

  • Have you provided a full/minimal spec to reproduce the issue?
  • Have you validated the input using an OpenAPI validator (example)?
  • Have you tested with the latest master to confirm the issue still exists?
  • Have you searched for related issues/PRs?
  • What's the actual output vs expected output?
  • [Optional] Sponsorship to speed up the bug fix or feature request
Description

The Python generator writes each operation's _response_types_map from {{dataType}} — the Python type annotation for the response. But ApiClient.__deserialize consumes those strings with a three-rule grammar that is not Python:

if klass.startswith('List['):  m = re.match(r'List\[(.*)]', klass)           # recurse on group 1
if klass.startswith('Dict['):  m = re.match(r'Dict\[([^,]*), (.*)]', klass)  # recurse on group 2
if klass in self.NATIVE_TYPES_MAPPING: ...                                   # int/str/object/UUID/…
else: klass = getattr(models, klass)                                         # anything else

Optional[...] matches none of those three rules. So when a response type is nullable, the generator emits a string its own runtime cannot parse: the Dict[ rule matches, recursion reaches Optional[object], and that falls through to getattr(models, "Optional[object]").

Every successful response from the endpoint raises AttributeError, before any user code runs.

The same text is simultaneously correct and incorrect, which is the clearest way to see the defect. From one generated file:

    ) -> Dict[str, Optional[object]]:            # line 53 — correct as an annotation
            '200': "Dict[str, Optional[object]]",  # line 87 — invalid as a protocol string

Emission site is modules/openapi-generator/src/main/resources/python/partial_api.mustache:

_response_types_map: Dict[str, Optional[str]] = {
    {{#responses}}
    {{^isWildcard}}
    '{{code}}': {{#dataType}}"{{.}}"{{/dataType}}{{^dataType}}None{{/dataType}},

Actual output: AttributeError: module 'repro_api.models' has no attribute 'Optional[object]' on every 200.
Expected output: the response deserialized, i.e. {'a': 1} for a body of {"a": 1}.

openapi-generator version

Reproduced identically on 7.24.0, 7.23.0, and master (7.25.0-SNAPSHOT, build 7.25.0-20260814.055009-235).

Not a regression — all three produce byte-identical output for the specs below. This appears to be long-standing.

OpenAPI declaration file content or url

Minimal, OpenAPI 3.0.3. The trigger is additionalProperties.nullable:

{
  "openapi": "3.0.3",
  "info": { "title": "Repro", "version": "1.0" },
  "paths": {
    "/settings": {
      "get": {
        "operationId": "get_settings",
        "responses": {
          "200": {
            "description": "A map whose values are nullable.",
            "content": {
              "application/json": {
                "schema": { "type": "object", "additionalProperties": { "nullable": true } }
              }
            }
          }
        }
      }
    }
  }
}

Removing "nullable": true emits the correct "Dict[str, object]" and works, which isolates nullability as the cause.

The OpenAPI 3.1 spelling of the same thing, "additionalProperties": { "anyOf": [{}, { "type": "null" }] }, emits "Dict[str, Optional[AnyOf]]" — same root cause, and AnyOf is not a model either. That is the shape FastAPI emits for dict[str, Any | None], so it is reachable without hand-writing a spec.

Generation Details
openapi-generator generate -g python -i repro.json -o out \
  -c <(echo '{"library":"httpx","packageName":"repro_api"}')

Also reproduces with the default urllib3 library — __deserialize lives in api_client.mustache, which is shared by all library variants.

Steps to reproduce
  1. Generate from the spec above.

  2. Observe repro_api/api/default_api.py:

    _response_types_map: Dict[str, Optional[str]] = {
        '200': "Dict[str, Optional[object]]",
    }
  3. Call the deserializer with that string, exactly as call_api does for any 200:

    from repro_api.api_client import ApiClient
    c = ApiClient()
    deserialize = getattr(c, '_ApiClient__deserialize')
    deserialize({"a": 1}, "Dict[str, Optional[object]]")
    AttributeError: module 'repro_api.models' has no attribute 'Optional[object]'
    

    For contrast, in the same interpreter:

    'Dict[str, object]'            -> {'a': 1}
    'Dict[str, Optional[object]]'  -> AttributeError: ... has no attribute 'Optional[object]'
    'Dict[str, object | None]'     -> AttributeError: ... has no attribute 'object | None'
    

    The third line matters for anyone tempted to fix this by modernising the string to PEP 604 — the grammar has no union syntax at all, so the wrapper has to be removed, not translated.

Related issues/PRs

Searched and found nothing reporting this. Adjacent but different — all concern model type generation or an empty map rather than _response_types_map carrying an annotation the deserializer cannot parse: #16967, #18774, #20373, #5421, #8227.

Originally filed on the npm wrapper repo by mistake: OpenAPITools/openapi-generator-cli#1274.

Suggest a fix

Two candidates. The second is smaller and also repairs already-generated clients on regeneration.

  1. Stop using dataType as a protocol string. partial_api.mustache needs the deserializer's type language, not the annotation language — either a separate codegen property for it, or strip the nullable wrapper at that site. This addresses the cause, but any future annotation feature can reopen the same gap.

  2. Teach __deserialize the wrapper. Unwrap Optional[...] (and, for robustness, a trailing | None) before the List[/Dict[ dispatch in api_client.mustache. Dropping nullability loses nothing at runtime: __deserialize already returns None for None data as its first action, and the method annotation still tells the truth to consumers and type checkers.

Note that unwrapping needs to be bracket-matched rather than regex-substituted. A pattern like Optional\[([^\[\]]*)\] only matches a wrapper whose contents hold no brackets, so List[Optional[Dict[str, Optional[int]]]] stalls after the innermost pass and leaves an Optional behind.

Happy to open a PR for whichever direction maintainers prefer.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions