Skip to content

Python codegen: missing from ... import <parent> when a submodule references only top-level parent types #158

@gogoout

Description

@gogoout

Problem

When a reflectapi namespace contains a sub-namespace whose generated Python
file only references top-level types from the parent namespace (not
deeper paths like parent.input.X / parent.output.X), the codegen emits
parent.SomeType field annotations without emitting the matching
from ... import parent line. The submodule has no binding for parent
in its scope, so static analysers (ruff, pyright) flag the line as F821
"Undefined name", and pydantic.model_rebuild() requires the forward
references to be resolved via the global type registry.

The sibling pattern — a submodule that references at least one nested name
like parent.input.Xdoes get the import. Two sibling submodules under
the same parent end up with different import scaffolding for the same
parent reference.

Repro

Reflect schema (excerpts from a real reflectapi.json):

{
  "kind": "struct",
  "name": "offer_rules::categories::GetResponse",
  "fields": {
    "named": [
      {
        "name": "category",
        "type": { "name": "offer_rules::InsurerCategory" },
        "required": true
      }
    ]
  }
}
{
  "kind": "struct",
  "name": "offer_rules::InsurerCategory",
  "description": "API-facing shape for a single insurer category .",
  "fields": { "named": [ /* id, name, description, display_order */ ] }
}
{
  "kind": "struct",
  "name": "offer_rules::configs::InsertRequest",
  "fields": {
    "named": [
      { "name": "name",      "type": { "name": "std::string::String" }, "required": true },
      { "name": "form_data", "type": { "name": "offer_rules::input::FormData" }, "required": true }
    ]
  }
}

So the two sibling submodules look like this in the schema:

Submodule Parent refs
offer_rules::categories::* top-level only — offer_rules::InsurerCategory, offer_rules::InsurerCategorySummary
offer_rules::configs::* top-level and nested — offer_rules::WorkProviderOfferRuleConfig, offer_rules::input::FormData, offer_rules::output::FormData

Actual (generated Python)

offer_rules/categories/__init__.pyno from ... import offer_rules anywhere in the file:

"""
DO NOT MODIFY THIS FILE MANUALLY
This file was generated by reflectapi-cli
"""

from __future__ import annotations

# Standard library imports
import warnings
from collections.abc import AsyncIterator, Iterator
# ... typing/pydantic/reflectapi_runtime imports ...

from ..._types import (
    ...
)

# (no `from ... import offer_rules` line anywhere)

class OfferRulesCategoriesGetResponse(BaseModel):
    model_config = ConfigDict(
        extra="ignore", populate_by_name=True, protected_namespaces=(), defer_build=True
    )

    category: offer_rules.InsurerCategory   # ← F821: Undefined name `offer_rules`


class OfferRulesCategoriesInsertResponse(BaseModel):
    model_config = ConfigDict(
        extra="ignore", populate_by_name=True, protected_namespaces=(), defer_build=True
    )

    category: offer_rules.InsurerCategory   # ← F821


class OfferRulesCategoriesListResponse(BaseModel):
    model_config = ConfigDict(
        extra="ignore", populate_by_name=True, protected_namespaces=(), defer_build=True
    )

    categories: list[offer_rules.InsurerCategorySummary]   # ← F821


class OfferRulesCategoriesUpdateResponse(BaseModel):
    model_config = ConfigDict(
        extra="ignore", populate_by_name=True, protected_namespaces=(), defer_build=True
    )

    category: offer_rules.InsurerCategory   # ← F821

core_server_client/offer_rules/configs/__init__.py — emitter did add the import (because of the nested offer_rules.input.FormData reference):

from __future__ import annotations

# ... same standard imports ...

import sys
from ... import offer_rules

offer_rules.configs = sys.modules[__name__]

# ... rest of the file uses `offer_rules.WorkProviderOfferRuleConfig`,
# `offer_rules.input.FormData`, `offer_rules.output.FormData` without F821.

Ruff output from a fresh codegen run:

F821 Undefined name `offer_rules`
   --> core_server_client/offer_rules/categories/__init__.py:287:15
    |
285 |     )
286 |
287 |     category: offer_rules.InsurerCategory
    |               ^^^^^^^^^^^

…repeated for every line that references offer_rules.<TopLevel>.

Expected

The emitter should add from ... import <parent> (and the matching
<parent>.<this_module> = sys.modules[__name__] registration line)
whenever the generated module references any symbol qualified with
the parent namespace — not only when the reference goes through a
sub-namespace.

For the example above, categories/__init__.py should start the same
way configs/__init__.py does:

import sys
from ... import offer_rules

offer_rules.categories = sys.modules[__name__]

Then category: offer_rules.InsurerCategory resolves both statically
and at model_rebuild() time.

Why this matters

  • Ruff/pyright flag every occurrence. With from __future__ import annotations and defer_build=True the code happens to still work at
    runtime (forward refs are resolved by the package's shared rebuild),
    but the static-analysis noise is non-trivial: a single PR that adds
    one new endpoint multiplies the F821 count linearly with the number
    of distinct response classes carrying parent-typed fields.
  • The behaviour is inconsistent: two siblings under the same parent get
    different import scaffolding for the same parent.X reference pattern,
    depending solely on whether one of them happens to use
    parent.input.X somewhere.

Suggested fix

In the Python emitter, when walking a submodule's generated type
references, treat any parent::* reference (top-level or nested) as a
trigger to emit:

import sys
from <relative_path> import <parent>

<parent>.<this_module_name> = sys.modules[__name__]

Equivalently: drop the "only sub-namespace counts" heuristic from the
import-emission pass.

Repro environment

  • reflectapi = "0.17.4" (workspace dep). The same pattern is
    reproducible in any schema with a namespace whose only cross-namespace
    references are top-level siblings.

Metadata

Metadata

Assignees

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