Skip to content

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

Description

@Henri-J-Norden

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

Activity

  1. rolandgeider commented on Aug 11, 2026

    @rolandgeider

    we're running into the same problem as well

  2. MrSampson commented on Sep 25, 2026

    @MrSampson

    We're hitting the same underlying defect, but triggered by an optional multi-content-type request body rather than a required one — worth flagging since PR #1478 (as currently written) only fixes the body_required uninitialized-variable bug in endpoint_macros.py.jinja, which controls whether | Unset = UNSET gets appended to the annotation. It doesn't touch the import-generation side, so an endpoint where | Unset is correctly appended (because the body actually is optional) still emits the same NameError today, and looks like it still will after #1478 merges.

    Minimal repro (differs from the original only in requestBody.required: false):

    openapi: 3.0.3
    info:
      title: Unset Import Bug (optional body)
      version: 1.0.0
    paths:
      /items/{id}:
        put:
          operationId: updateItem
          parameters:
            - name: id
              in: path
              required: true
              schema:
                type: string
          requestBody:
            required: false  # <-- optional, not required
            content:
              application/json:
                schema:
                  $ref: '#/components/schemas/Item'
              multipart/form-data:
                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

    Generated update_item.py (confirmed on 0.29.0 and 0.29.1, the latest release as of this comment):

    from ...types import UNSET, Response
    
    def _get_kwargs(
        *,
        body: Item | Item | Unset = UNSET,
    ) -> dict[str, Any]:
        ...

    Unset is referenced in the annotation but never imported — same symptom as the original report. ruff --select F821 flags it, and it raises NameError on import under Python versions with eager annotation evaluation (e.g. 3.12; PEP 649's lazy annotations on 3.14+ mask it until the annotation is first accessed).

    Since #1478 is scoped to the required-body path, would it make sense to also cover the import side (the static from ...types import Response, UNSET line, or wherever Unset's import gets decided for the multi-body branch) in that same fix/test suite? Happy to open a separate issue if that's cleaner for tracking — just wanted to flag the gap here first since it's the same class of bug.

  3. sebix commented on Sep 27, 2026

    @sebix

    We stumbled upon this bug too and, as a workaround, ended up removing all multipart/form-data blocks that are identical to application/json blocks:

    https://github.com/DINA-community/Matching-Agent/blob/1070b3b00a15e1503a4283d98ea8dcc1493d8afc/plugins/asset_source/netbox/assets/dedupe_multipart_bodies.py

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

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