Skip to content

Break cyclic import between mypy.types and mypy.expandtype#21264

Closed
rennf93 wants to merge 1 commit intopython:masterfrom
rennf93:fix/compiled-circular-import-types-expandtype
Closed

Break cyclic import between mypy.types and mypy.expandtype#21264
rennf93 wants to merge 1 commit intopython:masterfrom
rennf93:fix/compiled-circular-import-types-expandtype

Conversation

@rennf93
Copy link
Copy Markdown

@rennf93 rennf93 commented Apr 18, 2026

Summary

InstantiateAliasVisitor lives at the bottom of mypy/types.py and is defined via a module-level back-import from mypy.expandtype import ExpandTypeVisitor. The current in-source comment already acknowledges this as "unfortunate":

# This cyclic import is unfortunate, but to avoid it we would need to move away all uses
# of get_proper_type() from types.py. Majority of them have been removed, but few remaining
# are quite tricky to get rid of, but ultimately we want to do it at some point.
from mypy.expandtype import ExpandTypeVisitor

The back-import is load-order-dependent. When an external consumer — notably any mypy plugin that does from mypy.expandtype import … at module scope — imports mypy.expandtype before mypy.types has finished initialising, execution of mypy/expandtype.py line 8 re-enters mypy/types.py, which then runs the from mypy.expandtype import ExpandTypeVisitor line at module scope — but ExpandTypeVisitor has not been bound on the mypy.expandtype module object yet.

Symptoms:

  • Pure-Python mypy raises explicitly:

    ImportError: cannot import name 'ExpandTypeVisitor' from partially initialized module 'mypy.expandtype'
    
  • Compiled mypy (mypyc build) silently swallows the AttributeError during plugin registration. The plugin never loads and the user gets no diagnostic — downstream decorators registered by that plugin (@field_validator, @model_validator, etc.) are reported as untyped-decorator.

pydantic.mypy is the most common reproducer in the wild: from mypy.expandtype import expand_type, expand_type_by_instance at the top of the plugin reliably triggers this against compiled mypy ≥ 1.17 (and presumably earlier — I verified 1.17, 1.18, 1.19, 1.20 all exhibit the silent-swallow behaviour on mypyc builds).

How I hit this

Found while hardening guard-core, a security engine library whose SecurityConfig is a pydantic v2 BaseModel with ~6 @field_validator / @model_validator decorated methods. Strict mypy config (disallow_untyped_decorators = true) with the officially recommended plugins = ["pydantic.mypy"] was in place and had been green on a prior mypy version. After a routine dependency refresh picked up compiled mypy 1.20.1, CI started reporting:

guard_core/models.py:498: error: Untyped decorator makes function "validate_ip_lists" untyped  [untyped-decorator]
guard_core/models.py:517: error: Untyped decorator makes function "validate_trusted_proxies" untyped  [untyped-decorator]
guard_core/models.py:536: error: Untyped decorator makes function "validate_proxy_depth" untyped  [untyped-decorator]
guard_core/models.py:543: error: Untyped decorator makes function "validate_cloud_providers" untyped  [untyped-decorator]
guard_core/models.py:551: error: Untyped decorator makes function "validate_geo_ip_handler_exists" untyped  [untyped-decorator]
guard_core/models.py:570: error: Untyped decorator makes function "validate_agent_config" untyped  [untyped-decorator]

mypy -v showed no plugin load log entries and no error about plugin registration. The @field_validator decorator was being seen as untyped, which is the exact behaviour pydantic's mypy plugin is supposed to prevent.

Minimal repro

# repro.py
from pydantic import BaseModel, field_validator


class Example(BaseModel):
    value: int

    @field_validator("value")
    @classmethod
    def validate_value(cls, v: int) -> int:
        return v
# pyproject.toml
[tool.mypy]
python_version = "3.10"
disallow_untyped_decorators = true
plugins = ["pydantic.mypy"]
pip install "mypy==1.20.1" "pydantic==2.13.2"
mypy repro.py
# repro.py:7: error: Untyped decorator makes function "validate_value" untyped  [untyped-decorator]

Isolating the import path confirms the cycle is the root cause:

python -c "import pydantic.mypy"
# ImportError: cannot import name 'ExpandTypeVisitor' from partially initialized module 'mypy.expandtype'

python -c "import mypy.types; import pydantic.mypy; print('OK')"
# OK   ← pre-loading mypy.types breaks the cycle

Fix

  • Move InstantiateAliasVisitor to mypy/expandtype.py, right alongside its base class ExpandTypeVisitor. It keeps the same class body and uses the existing mypy.type_visitor import already present in expandtype.py.
  • Replace the module-level back-import in mypy/types.py with a function-scoped lazy import at the single usage site — TypeAliasType._partial_expansion (the only place InstantiateAliasVisitor is constructed). This removes the cycle entirely.

No behavioural change: class body is byte-identical, single call site is semantically preserved.

Verification

  • python -c "import pydantic.mypy" now succeeds on both compiled and pure-Python builds.
  • pydantic.mypy plugin registers correctly when invoked via plugins = ["pydantic.mypy"]; decorator typing for pydantic v2 BaseModel validators is restored in downstream projects.
  • Full mypy/test/testtypes.py: 118 passed, 2 skipped (unchanged from baseline).
  • Full mypy/test/testcheck.py: 7833 passed, 33 skipped, 7 xfailed, 0 failed (unchanged from baseline).
  • mypy_self_check.ini self-check: 34 errors identical to baseline (zero delta — the remaining 34 are pre-existing in test tooling, unrelated to this change).

Test plan

  • python -m pytest mypy/test/testtypes.py
  • python -m pytest mypy/test/testcheck.py
  • python -m mypy --config-file mypy_self_check.ini -p mypy (diff vs baseline)
  • python -c "import pydantic.mypy" (compiled and pure-Python builds)
  • End-to-end: mypy invocation with plugins = ["pydantic.mypy"] on a pydantic v2 BaseModel now correctly recognises @field_validator as typed

`InstantiateAliasVisitor` was defined at the bottom of `mypy.types` and relied on a module-level back-import `from mypy.expandtype import ExpandTypeVisitor`. Current comment acknowledges this as "unfortunate".

The back-import is load-order-dependent. When an external consumer (e.g. a mypy plugin such as `pydantic.mypy`) imports `mypy.expandtype` before `mypy.types` has finished loading, execution of `mypy/expandtype.py` line 8 re-enters `mypy/types.py`, which at module level then does `from mypy.expandtype import ExpandTypeVisitor` — but `ExpandTypeVisitor` has not been bound on `mypy.expandtype` yet.

Under pure-Python mypy this raises ImportError: cannot import name 'ExpandTypeVisitor' from partially initialized module 'mypy.expandtype'

Under compiled mypy (mypyc builds) the `AttributeError` is swallowed silently during plugin registration, so the plugin never loads and downstream decorators (`@field_validator`, `@model_validator`, etc.) are reported as `untyped-decorator` with no diagnostic hint.

Fix: move `InstantiateAliasVisitor` to `mypy.expandtype` beside its base class, and replace the module-level back-import in `mypy.types` with a function-scoped lazy import at the single usage site (`TypeAliasType._partial_expansion`). No behavioural change — class body is identical and the one call site is semantically preserved.

Verified: full `mypy/test/testcheck.py` suite (7833 tests) passes unchanged; `python -c "import pydantic.mypy"` succeeds on both compiled and pure-Python builds; plugin registers correctly when mypy runs.
@github-actions
Copy link
Copy Markdown
Contributor

According to mypy_primer, this change doesn't affect type check results on a corpus of open source code. ✅

Copy link
Copy Markdown
Member

@ilevkivskyi ilevkivskyi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could please clarify a bit more why exactly this is needed? Do you want to say that pydantic mypy plugin doesn't work at all? This is highly unlikely, as this is one of the most popular mypy plugins.

Comment thread mypy/types.py
):
mapping[tvar.id] = sub

from mypy.expandtype import InstantiateAliasVisitor
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function-level imports are slow in mypyc, and this is very hot code.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it's a hard no, I think there's a couple alternatives:

  1. Module-level cached binding in mypy/types.py:
    _InstantiateAliasVisitor: "type[InstantiateAliasVisitor] | None" = None
# ... inside TypeAliasType._partial_expansion:
global _InstantiateAliasVisitor

if _InstantiateAliasVisitor is None:
    from mypy.expandtype import InstantiateAliasVisitor as _V
    _InstantiateAliasVisitor = _V
visitor = _InstantiateAliasVisitor(...)

First call pays the import; rest are a module-global load. Preserves the no-cycle property because the import still happens lazily.

  1. Move the module-level back-import to the end of mypy/types.py (after all class definitions) and rely on InstantiateAliasVisitor being defined early in expandtype.py. This only works if InstantiateAliasVisitor's body and its dependencies are bound above the from mypy.types import ... line in expandtype.py (currently line 8) — otherwise the cycle reappears with a different name. I'd need to check the class's dependency chain carefully; if it's doable it matches the pre-existing structural pattern and hot-path cost goes to zero.

I can benchmark all three (current PR, 1, 2) on a realistic mypy self-check run and post numbers. If 1 is within noise I'll switch to 1); if 2) is doable I'll switch to 2). Any preferences up-front?

@rennf93
Copy link
Copy Markdown
Author

rennf93 commented Apr 19, 2026

Hey @ilevkivskyi . Thanks for pointing that out.

My bad, I see how "pydantic's mypy plugin doesn't work" reads as too strong. It's not the plugin that's broken, it's the plugin's loading that fails silently under a specific combination of conditions:

  1. Compiled mypy (the default mypyc-built wheels on PyPI, not pure-Python source installs).
  2. A plugin that imports mypy.expandtype at module scope. pydantic.mypy does from mypy.expandtype import expand_type, expand_type_by_instance at the top of the file.
  3. The plugin loading before mypy.types has finished initializing.

When all three happen together, the cycle the in-source comment already flags (# This cyclic import is unfortunate) fires. On pure-Python mypy it raises a clean ImportError: cannot import name 'ExpandTypeVisitor' from partially initialized module 'mypy.expandtype' (in the PR body). On compiled mypy the resulting AttributeError is swallowed at mypy's plugin-registration boundary and the user gets zero diagnostic, downstream they see every @field_validator reported as untyped-decorator.

Reproducer (no plugin config, just the import):

python -c "import pydantic.mypy"
# ImportError: cannot import name 'ExpandTypeVisitor' from partially initialized module 'mypy.expandtype'
python -c "import mypy.types; import pydantic.mypy; print('OK')"
# OK  --- pre-loading mypy.types lets expandtype complete its own body before re-entry
Reproduces on mypy 1.17 / 1.18 / 1.19 / 1.20 (compiled builds), pydantic 2.13.2.

The plugin's popularity is actually why: most users don't trigger it because their plugin chain doesn't top-level-import mypy.expandtype. Pydantic does, and plugins = ["pydantic.mypy"] is the officially documented integration. I hit it on a routine dependency bump from mypy 1.19 → 1.20.1 and spent a day tracing why a stable CI suddenly had untyped decorators... the trail led to pydantic/pydantic#10094 and a handful of forums with the same symptom and no root cause pinned on the mypy side.

I can rephrase the PR summary to something like "pydantic.mypy silently fails to register on compiled mypy builds when loaded via the documented plugins = [...] entrypoint, because the in-source-acknowledged cycle in mypy/types.py is load-order-dependent." Should I do it?

@JelleZijlstra
Copy link
Copy Markdown
Member

That doesn't make much sense. Lots of people use pydantic.mypy and evidently don't run into this issue.

@rennf93
Copy link
Copy Markdown
Author

rennf93 commented Apr 19, 2026

Hi @JelleZijlstra

This is exactly the repro. A fresh venv, two pinned pip installs, one python -c:

python -m venv /tmp/repro && source /tmp/repro/bin/activate
pip install "mypy==1.20.1" "pydantic==2.13.2"
python -c "import pydantic.mypy"

I get ImportError: cannot import name 'ExpandTypeVisitor' from partially initialized module 'mypy.expandtype' (most likely due to a circular import).

If that command succeeds on your machine, my analysis is wrong and I'd like to know what's different about your environment. If it fails: we agree the cycle is live, and the discussion is just about whether this is the right fix.

We've been hitting this in guard-core's CI since mypy 1.17 (compiled-wheel installs from PyPI on standard Ubuntu / macOS runners, reproducing across Python 3.10-3.14). I can post the CI logs if useful, but the three-line repro above should be sufficient.

@ilevkivskyi
Copy link
Copy Markdown
Member

This is not a repro. Mypy imports plugins after a lot of other things are already imported (including mypy.types). And if it cannot import a plugin it gives a (blocking) error.

@rennf93
Copy link
Copy Markdown
Author

rennf93 commented Apr 19, 2026

@ilevkivskyi @JelleZijlstra

Empirically, in this repo (guard-core, public CI logs available on request):

Config:

pyproject.toml

plugins = ["pydantic.mypy"]

Versions:
mypy 1.20.1 (compiled wheel from PyPI)
pydantic 2.13.2
pydantic.mypy plugin shipped at .venv/lib/python3.10/site-packages/pydantic/mypy.py

What happens when I remove the # type: ignore[untyped-decorator] pragmas from our @field_validator / @model_validator decorators and run mypy:

$ uv run mypy --show-traceback -v guard_core/models.py | grep -iE "plugin pydantic|untyped|error"

LOG:  Skipping /Users/.../pydantic/__init__.py (pydantic)
LOG:  Metadata fresh for pydantic_core.core_schema: ...
LOG:  Metadata fresh for pydantic_core: ...
LOG:  Skipping /Users/.../pydantic/json_schema.py (pydantic.json_schema)

guard_core/models.py:652: error: Untyped decorator makes function "validate_ip_lists" untyped  [untyped-decorator]
guard_core/models.py:671: error: Untyped decorator makes function "validate_trusted_proxies" untyped  [untyped-decorator]
guard_core/models.py:690: error: Untyped decorator makes function "validate_proxy_depth" untyped  [untyped-decorator]
guard_core/models.py:697: error: Untyped decorator makes function "validate_cloud_providers" untyped  [untyped-decorator]
guard_core/models.py:705: error: Untyped decorator makes function "validate_geo_ip_handler_exists" untyped  [untyped-decorator]
guard_core/models.py:724: error: Untyped decorator makes function "validate_agent_config" untyped  [untyped-decorator]

LOG:  Build finished in 0.049 seconds with 0 modules, and 0 errors

Found 6 errors in 1 file (checked 1 source file)

Observations that contradict the two claims:

  1. "mypy imports plugins after mypy.types is already imported." Mypy produced 6 untyped-decorator errors on code that pydantic.mypy is documented to handle — @field_validator and @model_validator. If the plugin had loaded, these wouldn't be errors. -v output shows zero "Loading plugin" / "Plugin loaded" / anything referencing pydantic.mypy. The plugin did not register, yet mypy proceeded.

  2. "if it cannot import a plugin it gives a (blocking) error." There is no blocking error anywhere. No error about plugin load failure, no traceback, no warning. Mypy reported Build finished … with 0 errors for the plugin-load phase and then emitted the untyped-decorator errors. That's the silent-swallow behaviour the PR is about.

And the cycle itself, in isolation:

python -c "import pydantic.mypy"
AttributeError: module 'mypy.expandtype' has no attribute 'ExpandTypeVisitor'

(Compiled mypy surfaces AttributeError from the from ... import ... at mypy/types.py module-body time; pure-Python mypy turns the same situation into ImportError. Either way the plugin module's top-level fails to finish executing.)

I can put the full mypy -v output, versions, and pyproject.toml into a gist if useful. I'd also run any alternate repro command you want if there's a specific way you invoke mypy that wouldn't hit this, tell me and I'll run it here.

@JelleZijlstra
Copy link
Copy Markdown
Member

I suspect you (or your AI?) misdiagnosed the issue and there's really something else going on causing the errors you're seeing.

@ilevkivskyi
Copy link
Copy Markdown
Member

I am going to close this. If you think there is a bug, please just open an issue (with a detailed description of your actual problem, not what whatever you think is the cause), and we will try to diagnose it ourselves.

@rennf93
Copy link
Copy Markdown
Author

rennf93 commented Apr 20, 2026

Filing an issue as requested.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants