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.X — does 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__.py — no 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.
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 emitsparent.SomeTypefield annotations without emitting the matchingfrom ... import parentline. The submodule has no binding forparentin its scope, so static analysers (ruff, pyright) flag the line as F821
"Undefined name", and
pydantic.model_rebuild()requires the forwardreferences to be resolved via the global type registry.
The sibling pattern — a submodule that references at least one nested name
like
parent.input.X— does get the import. Two sibling submodules underthe 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:
offer_rules::categories::*offer_rules::InsurerCategory,offer_rules::InsurerCategorySummaryoffer_rules::configs::*offer_rules::WorkProviderOfferRuleConfig,offer_rules::input::FormData,offer_rules::output::FormDataActual (generated Python)
offer_rules/categories/__init__.py— nofrom ... import offer_rulesanywhere in the file:core_server_client/offer_rules/configs/__init__.py— emitter did add the import (because of the nestedoffer_rules.input.FormDatareference):Ruff output from a fresh codegen run:
…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__.pyshould start the sameway
configs/__init__.pydoes:Then
category: offer_rules.InsurerCategoryresolves both staticallyand at
model_rebuild()time.Why this matters
from __future__ import annotationsanddefer_build=Truethe code happens to still work atruntime (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.
different import scaffolding for the same
parent.Xreference pattern,depending solely on whether one of them happens to use
parent.input.Xsomewhere.Suggested fix
In the Python emitter, when walking a submodule's generated type
references, treat any
parent::*reference (top-level or nested) as atrigger to emit:
Equivalently: drop the "only sub-namespace counts" heuristic from the
import-emission pass.
Repro environment
reflectapi = "0.17.4"(workspace dep). The same pattern isreproducible in any schema with a namespace whose only cross-namespace
references are top-level siblings.