Skip to content

Bug: Python codegen drops flattened tagged-union fields #123

@MattGson

Description

@MattGson

Summary

The Python generator appears to lose a flattened tagged-union field when generating offers_search::Offer.

In the ReflectAPI schema, offers_search::Offer has:

  • id
  • payload: offers_search::OfferKind
  • payload is marked "flattened": true

The generated Python client emits OffersSearchOffer with only id, while generating OffersSearchOfferKind* separately but never composing them back into OffersSearchOffer.

This causes real API payload fields like type, business, and nested payload to be silently dropped because the generated Python models use extra="ignore".

Minimal ReflectAPI snippet

From reflectapi.json:

{
  "name": "offer_recommendation::RepairerRecommendations",
  "fields": {
    "named": [
      {
        "name": "offers",
        "type": {
          "name": "std::vec::Vec",
          "arguments": [
            {
              "name": "offers_search::Offer"
            }
          ]
        },
        "required": true
      }
    ]
  }
}
{
  "name": "offers_search::Offer",
  "fields": {
    "named": [
      {
        "name": "id",
        "type": {
          "name": "std::string::String"
        },
        "required": true
      },
      {
        "name": "payload",
        "type": {
          "name": "offers_search::OfferKind"
        },
        "required": true,
        "flattened": true
      }
    ]
  }
}

Generated Python output

Generated Python currently produces:

class OffersSearchOffer(BaseModel):
    model_config = ConfigDict(extra="ignore", populate_by_name=True)
    id: str


class OffersSearchOfferKindSingle(BaseModel):
    model_config = ConfigDict(extra="ignore", populate_by_name=True)

    type: Literal["single"] = "single"
    business: SupplySearchBusiness
    payload: OffersSearchSellableKind


class OffersSearchOfferKindGroup(BaseModel):
    model_config = ConfigDict(extra="ignore", populate_by_name=True)

    type: Literal["group"] = "group"
    status: SupplySearchGroupOfferStatus
    offers: list[OffersSearchSellableOffer]


class OffersSearchOfferKindRequest(BaseModel):
    model_config = ConfigDict(extra="ignore", populate_by_name=True)

    type: Literal["request"] = "request"
    offer: OffersSearchSellableOffer
    note: str | None = None
    subject: OfferRequestSubjectV2 | None = None
    is_override: bool | None = None


class OffersSearchOfferKind(RootModel):
    root: Annotated[
        Union[
            OffersSearchOfferKindSingle,
            OffersSearchOfferKindGroup,
            OffersSearchOfferKindRequest,
        ],
        Field(discriminator="type"),
    ]

The problem is that OffersSearchOffer never includes the flattened OfferKind.

Expected Python shape

Roughly something like:

class OffersSearchOffer(BaseModel):
    model_config = ConfigDict(extra="ignore", populate_by_name=True)
    id: str
    payload: OffersSearchOfferKind = Field(flattened=True)

Or any equivalent generated representation that preserves the flattened tagged union when validating and dumping JSON.

Evidence from other generators

The same schema is represented correctly in other generated clients.

TypeScript:

export type Offer = {
  id: string;
} & NullToEmptyObject<offers_search.OfferKind>;

Rust:

pub struct Offer {
    pub id: String,
    #[serde(flatten)]
    pub payload: super::offers_search::OfferKind,
}

Impact

A real response object like:

{
  "id": "99742bbe-b189-4731-8553-7ce63e60860e",
  "type": "single",
  "business": {
    "name": "Brown Auto Parts"
  },
  "payload": {
    "kind": "product"
  }
}

validates into the generated Python OffersSearchOffer as effectively:

{
  "id": "99742bbe-b189-4731-8553-7ce63e60860e"
}

because the flattened fields are dropped.

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