openapi-generators / openapi-generators/openapi-python-client

Multi-body endpoints with required requestBody emit `| Unset` without importing `Unset`

Open
#1,451 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Python
Stars
2k
Forks
293
Avg merge
34m
Merged PRs (30d)
1

Description

Describe the bug

When an endpoint has a required requestBody with multiple content types, the generator appends | Unset = UNSET to the type annotation - even though the body is required.

Furthermore, Unset is not added to the imports, so the generated file references an undefined name - this causes ruff to report multiple instances of F821 Undefined name 'Unset'.

To Reproduce

Create openapi.yaml with the following spec:

openapi: 3.0.3
info:
  title: Unset Bug Demo
  version: 1.0.0
paths:
  /items/{id}:
    put:
      operationId: updateItem
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true # <-- must be true
        content:
          application/json: # <-- must have multiple content types
            schema:
              $ref: '#/components/schemas/Item'
          multipart/form-data: # <-- must have multiple content types
            schema:
              $ref: '#/components/schemas/Item'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Item'
components:
  schemas:
    Item:
      type: object
      properties:
        name:
          type: string

Generate the client and lint:

# Tested on CachyOS with uv 0.11.21 and the following:
uvx --python 3.14 openapi-python-client==0.29.0 generate --path openapi.yaml --output-path out
uvx ruff==0.15.17 check out
Generated code snippet

In out/unset_bug_demo_client/api/default/update_item.py:

from ...types import UNSET, Response  # <-- Unset is not imported

def _get_kwargs(
    id: str,
    *,
    body: Item | Item | Unset = UNSET,  # <-- F821: Undefined name 'Unset' (also note how Item is duplicated)
) -> dict[str, Any]:
    ...

Potential root cause (unverified)

The following was generated by an LLM and has not been verified!

I'm not really familiar with the codebase, but I might take a deeper look in the coming weeks if I find time and turn this into a proper fix/PR...

1. The template always appends | Unset — even for required bodies

In templates/endpoint_macros.py.jinja, the multi-body branch of the arguments macro uses an uninitialized variable > body_required:

{% elif endpoint.bodies | length > 1 %}
body:
    {%- for body in endpoint.bodies -%}{% set body_required = body_required and body.prop.required %}
    {{ body.prop.get_type_string(no_optional=True) }} {% if not loop.last %} | {% endif %}
    {%- endfor -%}{% if not body_required %} | Unset = UNSET{% endif %}
,
{% endif %}

body_required is referenced before it is ever initialized. On the first loop iteration body_required is Jinja Undefined, and Undefined and body.prop.required evaluates to Undefined (falsy). So it stays falsy for the whole loop, and after the loop {% if not body_required %} is always True. Result: | Unset = UNSET is appended for every multi-body endpoint, regardless of whether the bodies are required. (It should have been seeded to True > before the loop so the and chain actually reflects "all bodies required".)

2. The Unset import is never added for required bodies

Each member is rendered with get_type_string(no_optional=True), and the import set is driven by get_imports():

# parser/properties/protocol.py
def get_imports(self, *, prefix: str) -> set[str]:
    ...
    imports = set()
    if not self.required:
        imports.add(f"from {prefix}types import UNSET, Unset")
    return imports

Unset/UNSET are only imported when the property is not required. Endpoint imports are collected from exactly this > method:

# parser/openapi.py
result.bodies.append(body)
result.relative_imports.update(body.prop.get_imports(prefix=models_relative_prefix))

And the module's static imports include only UNSET, never Unset:

# templates/endpoint_module.py.jinja
from ...types import Response, UNSET

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Reproduce the generated client from the provided openapi.yaml, then inspect templates/endpoint_macros.py.jinja, parser/properties/protocol.py, parser/openapi.py, and templates/endpoint_module.py.jinja. Trace how required multi-body arguments and imports are collected. Done means the generated required request body has no unnecessary Unset default, no duplicated type, and ruff reports no undefined Unset name.

Written by the indexing model from the issue text.

Assessment

Tech stack
python
Domain
api, tooling
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
72/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.