Skip to content

Generated client emits two distinct IfConflictOn* classes with the same name in different modules; consumers silently get the wrong one and pydantic rejects with a misleading error #161

@MattGson

Description

@MattGson

Summary

For each IfConflictOn* type, the Python codegen can emit two completely separate class objects under the same name in different modules:

# core_server_client/_types.py
class IfConflictOnUpdate(BaseModel, Generic[C]):
    not_exists: C | None = None
    matches: EditionMatchCondition | None = None

# core_server_client/nomatches/__init__.py
class NomatchesIfConflictOnUpdate(BaseModel, Generic[C]):  # ← different class object
    ...
IfConflictOnUpdate = NomatchesIfConflictOnUpdate  # rebinds the name in nomatches
_types.IfConflictOnUpdate is not nomatches.IfConflictOnUpdatethey're two unrelated classes that happen to share a name.

Each generated wrapper picks one of the two via its module's imports. A consumer who imports from core_server_client import nomatches and writes nomatches.IfConflictOnUpdateX can silently get the wrong class, and pydantic rejects the value at runtime with an error that names both sides identically, making the cause impossible to spot.

Versions
reflectapi-runtime 0.17.4
pydantic 2.12.5
Python 3.14

Reproducer

from core_server_client import nomatches
from core_server_client import _types
from core_server_client._types import (
    Identity,
    IfConflictOnUpdate as IfConflictOnUpdateFromTypes,
    IfNotExistsAbort,
)
from core_server_client import job as core_job

# Same name, totally different classes:
assert _types.IfConflictOnUpdate is not nomatches.IfConflictOnUpdate
print(_types.IfConflictOnUpdate)
# <class 'core_server_client._types.IfConflictOnUpdate'>
print(nomatches.IfConflictOnUpdate)
# <class 'core_server_client.nomatches.NomatchesIfConflictOnUpdate'>

identity = core_job.JobIdentity(
    core_job.JobIdentityIdVariant(field_0="00000000-0000-0000-0000-000000000000")
)

# CONSUMER A — what most people would write after seeing `from … import nomatches`:
nomatches.NomatchesUpdateOrElseIdentityDataJobIdentityJobUpdateRequestDataIfNotExistsAbort(
    if_=nomatches.IfConflictOnUpdate[IfNotExistsAbort](
        not_exists=IfNotExistsAbort.ABORT
    ),
    identity=identity,
)
# pydantic_core._pydantic_core.ValidationError:
#   Input should be a valid dictionary or instance of IfConflictOnUpdate[IfNotExistsAbort]
#   [type=model_type,
#    input_type=NomatchesIfConflictOnUpdate[IfNotExistsAbort]]
#
# (Same name, two different classes — the wrapper field was annotated
# against `_types.IfConflictOnUpdate` because that's what its module
# imports, not the rebound `nomatches.IfConflictOnUpdate`.)

# CONSUMER B — works, but only if you happen to know to import from `_types`:
nomatches.NomatchesUpdateOrElseIdentityDataJobIdentityJobUpdateRequestDataIfNotExistsAbort(
    if_=IfConflictOnUpdateFromTypes[IfNotExistsAbort](
        not_exists=IfNotExistsAbort.ABORT
    ),
    identity=identity,
)  # OK

Why this is hard to diagnose

The error message names the same type on both sides: Input should be a valid dictionary or instance of IfConflictOnUpdate[X] vs input_type=NomatchesIfConflictOnUpdate[X]. Nothing in the message hints that there are two IfConflictOnUpdate classes living in different modules.

Static type checkers (pyright / mypy) see no problem — they treat the two as compatible because both are subclasses of BaseModel with the same init signature.

IDE auto-complete prefers nomatches.IfConflictOnUpdate (it's right there in the obviously-relevant nomatches module) over core_server_client._types.IfConflictOnUpdate (an underscore-prefixed module that consumers are conditioned to ignore).
The mapping isn't uniform. Some IfConflictOn* types exist only in nomatches with no _types counterpart (e.g. IfConflictOnInsertMany). So a workaround like "always use _types.IfConflictOn*" doesn't generalise — consumers have to inspect each wrapper's imports to know which class its field actually expects.

The duplication is invisible from the public surface. Reading the user-facing nomatches.py only shows one IfConflictOnUpdate. The second class lurking in _types.py is what the wrapper's field actually resolves to.
Root cause

The codegen emits the same logical type as a fresh class definition in every module that references it, instead of defining it once and re-exporting / importing it everywhere else. When two modules then both call their local class IfConflictOnUpdate, the names collide on the public surface even though they're unrelated objects internally.

Suggested fix

Define each IfConflictOn* (and any other shared scaffolding type) exactly once in one canonical module, and have every other module reach for it via from import rather than re-emitting a parallel definition. With one class per logical type, the is identity is preserved everywhere, pydantic validation works regardless of which import path the consumer reaches for, and the IDE-autocomplete trap goes away.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions