Skip to content

chore: add handling for None values - #624

Merged
cooperlees merged 3 commits into
PyCQA:mainfrom
phalberg:chore/satisfy_mypy
Nov 8, 2026
Merged

cooperlees merged 3 commits into
PyCQA:mainfrom
phalberg:chore/satisfy_mypy

Conversation

@phalberg

@phalberg phalberg commented Nov 7, 2026

Copy link
Copy Markdown
Contributor

Desc

I get the following complaint from mypy when running strict across the bugbear.py

python3 -m mypy --strict bugbear.py 
bugbear.py:624: error: Argument 1 to "visit" has incompatible type "expr | None"; expected "AST"  [arg-type]
Found 1 error in 1 file (checked 1 source file)

After this fix it is no longer present.
This type annotation seems to have been updated to include None.

Comment thread bugbear.py Resolved
@cooperlees

cooperlees commented Nov 7, 2026 •

Copy link
Copy Markdown
Collaborator

Thanks for this. Some research behind the change and a suggested tweak:

This is a real 3.15 bug, not just a typing nit

PEP 798 (unpacking in comprehensions) lands in CPython 3.15. {**d for d in ds} parses as a DictComp whose value is None:

$ python3.15 -c "import ast; print(ast.dump(ast.parse('{**d for d in ds}').body[0].value))"
DictComp(key=Name(id='d'), generators=[...])   # value=None

typeshed matches this: DictComp.value: expr | None only under sys.version_info >= (3, 15). key is still expr on every version.

On main, this crashes flake8 on 3.15.2 if the comprehension sits in a module (or enclosing class) body before a libcst visitor, because B906's _b906_scope_bindings calls visit(None):

import libcst as cst

x = {**d for d in ds}


class V(cst.CSTVisitor):
    def visit_Call(self, node):
        pass
AttributeError: 'NoneType' object has no attribute '_fields'

Why CI didn't catch it

  1. mypy only runs on 3.13. [gh-actions] in tox.ini maps 3.13: py313,mypy, so mypy checks against the 3.13 stubs, where value is plain expr. mypy --strict --python-version 3.15 bugbear.py reports this error. --python-version 3.13 and 3.14 don't.
  2. No PEP 798 test input. The 3.15 test job passes because no eval file has {**x for ...} before a libcst visitor.
  3. The existing guard is on the wrong field. if node.key is not None (from d7b7d8c, the B906 libcst change) can't fire, since key is never None. It looks like it was meant to guard value.

Suggested change

The diff here nests visit(node.value) inside if node.key is not None:. That works today only because key is never None, but it reads as if value depends on key. I'd suggest keeping them flat and dropping the dead check:

        if isinstance(node, ast.DictComp):
            visit(node.key)
            # PEP 798 (3.15+): ``{**d for d in ds}`` has ``value=None``.
            if node.value is not None:
                visit(node.value)
        else:
            visit(node.elt)

I checked this locally: mypy --strict is clean with --python-version 3.13 and 3.15, the repro above no longer crashes on 3.15.2, and the test suite passes on 3.15.

Better follow-ups (could be in this PR or a separate one)

  • Add a regression test, e.g. tests/eval_files/b906_py315.py, using the existing _pyXYZ version gating (as b035_py315.py does), with the repro above.
  • Run mypy against the newest supported Python as well. For example, map 3.15: py315,mypy in [gh-actions], or add --python-version 3.15 alongside 3.13 in the mypy env. Stubs gated on sys.version_info only show up when the newest version is checked.

@phalberg

phalberg commented Nov 7, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for this. Some research behind the change and a suggested tweak:

This is a real 3.15 bug, not just a typing nit

PEP 798 (unpacking in comprehensions) lands in CPython 3.15. {**d for d in ds} parses as a DictComp whose value is None:

$ python3.15 -c "import ast; print(ast.dump(ast.parse('{**d for d in ds}').body[0].value))"
DictComp(key=Name(id='d'), generators=[...])   # value=None

typeshed matches this: DictComp.value: expr | None only under sys.version_info >= (3, 15). key is still expr on every version.

On main, this crashes flake8 on 3.15.2 if the comprehension sits in a module (or enclosing class) body before a libcst visitor, because B906's _b906_scope_bindings calls visit(None):

import libcst as cst

x = {**d for d in ds}


class V(cst.CSTVisitor):
    def visit_Call(self, node):
        pass
AttributeError: 'NoneType' object has no attribute '_fields'

Why CI didn't catch it

1. **mypy only runs on 3.13.** `[gh-actions]` in `tox.ini` maps `3.13: py313,mypy`, so mypy checks against the 3.13 stubs, where `value` is plain `expr`. `mypy --strict --python-version 3.15 bugbear.py` reports this error. `--python-version 3.13` and `3.14` don't.

2. **No PEP 798 test input.** The 3.15 test job passes because no eval file has `{**x for ...}` before a libcst visitor.

3. **The existing guard is on the wrong field.** `if node.key is not None` (from [d7b7d8c](https://github.com/PyCQA/flake8-bugbear/commit/d7b7d8c0ad18ce798335425d49cbaa20ffe4c2b8), the B906 libcst change) can't fire, since `key` is never `None`. It looks like it was meant to guard `value`.

Suggested change

The diff here nests visit(node.value) inside if node.key is not None:. That works today only because key is never None, but it reads as if value depends on key. I'd suggest keeping them flat and dropping the dead check:

        if isinstance(node, ast.DictComp):
            visit(node.key)
            # PEP 798 (3.15+): ``{**d for d in ds}`` has ``value=None``.
            if node.value is not None:
                visit(node.value)
        else:
            visit(node.elt)

I checked this locally: mypy --strict is clean with --python-version 3.13 and 3.15, the repro above no longer crashes on 3.15.2, and the test suite passes on 3.15.

Better follow-ups (could be in this PR or a separate one)

* **Add a regression test**, e.g. `tests/eval_files/b906_py315.py`, using the existing `_pyXYZ` version gating (as `b035_py315.py` does), with the repro above.

* **Run mypy against the newest supported Python as well.** For example, map `3.15: py315,mypy` in `[gh-actions]`, or add `--python-version 3.15` alongside 3.13 in the `mypy` env. Stubs gated on `sys.version_info` only show up when the newest version is checked.

Thanks for the reply and analysis here.
I'd suggest we make the changes then in this PR for the fix.
I think that works then, even though it does not make big changes.

Then i can add a follow-up PR to let mypy run on 3.15 -- that should be fine.
Perhaps we can make a test case for this, i can add a issue for that :)

@phalberg

phalberg commented Nov 7, 2026

Copy link
Copy Markdown
Contributor Author

Applied the recommended draft! That should suffice for now :)

@cooperlees
cooperlees merged commit 8edf276 into PyCQA:main Nov 8, 2026
6 checks passed
@phalberg
phalberg deleted the chore/satisfy_mypy branch November 8, 2026 17:43
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.

2 participants