Skip to content

Functions with global inside should not have their function bodies be stripped #22240

Open

Description

@A5rocks

Bug Report

See title

To Reproduce

# a.py
import b

reveal_type(b.x)
# b.py
x = None

def f() -> None:
    global x
    x = 1

Expected Behavior

$ uv run mypy --follow-imports=silent  a.py
a.py:3: note: Revealed type is "int | None"
Success: no issues found in 1 source file

(remove the --follow-imports=silent to get this output)

Actual Behavior

$ uv run mypy --follow-imports=silent a.py
a.py:3: note: Revealed type is "None"
Success: no issues found in 1 source file

Your Environment

  • Mypy version used: 2.4.0
  • Mypy command-line flags: --follow-imports=silent
  • Mypy configuration options from mypy.ini (and other config files): N/A
  • Python version used: 3.13.13

cc @JukkaL

Activity

  1. Madin-H23 commented on Oct 21, 2026

    @Madin-H23

    I looked into this and found the root cause. The fix for the default (native) parser is in mypyc/ast_serialize#98.

    Root cause. When a module is only analyzed for its types (e.g. it is imported with --follow-imports=silent), function bodies are skipped if they have no "externally visible effects". There are two implementations of that check, and both miss global/nonlocal declarations:

    • native parser (the default): mypyc/ast_serialize, src/func_effect_visitor.rs, has_externally_visible_effect() — it keeps bodies that assign to an attribute of the first parameter (self.x = 1) or that contain a yield (the inferred return type depends on it), but a global/nonlocal declaration is not considered;
    • old Python parser (--no-native-parser): mypy/fastparse.py, strip_function_bodies — same gap.

    A function that declares a global can assign to it, so skipping the body changes the inferred type of a variable outside the function:

    # b.py
    x = None
    
    def f() -> None:
        global x
        x = 1
    # a.py
    import b
    
    reveal_type(b.x)

    With --follow-imports=silent this reveals None; without the flag it reveals int | None. The same happens with nonlocal and with a global declaration nested in an if block or in a nested function.

    Fix. Treat a global/nonlocal declaration as an externally visible effect. Nested functions and classes have to be searched as well, since a declaration in a nested scope also refers to a variable outside the current function — and the nested body would be dropped together with the enclosing one.

    The linked PR adds this to the native parser, together with unit tests. The old Python parser needs the same change in mypy/fastparse.py; I have a verified patch for that as well (roughly 10 lines, plus a regression test in test-data/unit/check-flags.test) and can send it as a separate PR if that is useful.

    I can also add the end-to-end test to test-data/unit/native-parser.test once a new ast_serialize release is available.

    🤖 This analysis and the patches were drafted with AI assistance.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions