mypy icon indicating copy to clipboard operation
mypy copied to clipboard

1.16 regression: `--warn-unused-ignore` now complains in ignored-but-correct places

Open asottile-sentry opened this issue 6 months ago • 18 comments

this appears to have changed behaviour in mypy 1.15 -- I personally prefer the behaviour in 1.15 but would understand if it needed to change. I don't see it mentioned in the changelog however

Bug Report

we're working on incrementally typing a large codebase -- often this means fixing a particular callsite or file at a time (sometimes by intentionally ignoring errors unfortunately)

it seems that in a module with a particular error code (temporarily) disabled, --warn-unused-ignores will complain about a "correct" type ignore (I say "correct" here as it will complain when the file is no longer blocklisted) -- will provide a small example below

To Reproduce

# t.py
def f() -> None:
    x: int = 'hi'  # type: ignore[assignment]
[mypy]
warn_unused_ignores = true
[mypy-t]
disable_error_code = assignment

Expected Behavior

$ mypy --version
mypy 1.15.0 (compiled: yes)
$ mypy t.py
Success: no issues found in 1 source file

Actual Behavior

$ mypy --version
mypy 1.16.0 (compiled: yes)
$ mypy t.py
t.py:2: error: Unused "type: ignore" comment  [unused-ignore]
Found 1 error in 1 file (checked 1 source file)

Your Environment

  • Mypy version used: 1.16.0 (regression from 1.15.0)
  • Mypy command-line flags: (see above)
  • Mypy configuration options from mypy.ini (and other config files): (see above)
  • Python version used: 3.13.1

asottile-sentry avatar Jun 04 '25 17:06 asottile-sentry

this seems intentional via #11059 and #18849 (bisected to 6b686615dd9fba32af3395d5eeefe2812997c7be)

I'd still argue this is worse than before for my use case and I'm not quite sure what the motivation was in the original issue -- cc @Akuli @brianschubert

asottile-sentry avatar Jun 04 '25 18:06 asottile-sentry

actually I do see it in the notes now -- I was searching for the wrong thing (warn-unused-ignore)

asottile-sentry avatar Jun 04 '25 18:06 asottile-sentry

Yeah, this was an intentional change. The ignore is unused in the sense it isn't necessary under the current configuration and removing it would not result in a diagnostic being emitted. This is generally a useful behavior, since it lets you detect # type: ignore comments that become no longer relevant after a change in configuration.

As a workaround, you can try disabling the unused-ignore error code on a per-file or line-by-line basis. For example, mypy will accept the following regardless of whether the assignment error code is enabled or not:

def f() -> None:
    x: int = 'hi'  # type: ignore[assignment,unused-ignore]

I'll note that the previous behavior was buggy in that only some errors could be both ignored and disabled without setting off --warn-unused-ignores. This has to do with how disabled error codes are internally handled in mypy, where in some cases mypy will check whether an error code is enabled before doing some analysis, while in other cases mypy will unconditionally do some analysis and then latter suppress errors if the associated error code is disabled. Previously, checks of the former type would set off --warn-unused-ignores when the error code was disabled while checks of the latter type would not. Now, both behave in the same way.

Also FWIW, the current behavior is consistent with how other static analysis tools I've used work. For example, ruff's RUF100 rule handles unused # noqa comments in much the same way. In ruff, if you silence an error with # noqa and later disable the rule that was associated with that error, you'll get a new error suggesting to remove the now defunct # noqa comment:

ruff unused noqa behavior
$ cat t1.py
def f() -> None:
    x: int = 'hi'

$ ruff check --select=F,RUF t1.py
t1.py:2:5: F841 Local variable `x` is assigned to but never used
  |
1 | def f() -> None:
2 |     x: int = 'hi'
  |     ^ F841
  |
  = help: Remove assignment to unused variable `x`

# Add '# noqa' comment
$ cat t2.py
def f() -> None:
    x: int = 'hi'  # noqa: F841

# Linting now passes
$ ruff check --select=F,RUF t2.py
All checks passed!

# Now disable the rule with '--ignore=F841'
# Ruff now warns you about the unused '# noqa' comment
$ ruff check --select=F,RUF --ignore=F841 t2.py
t2.py:2:20: RUF100 [*] Unused `noqa` directive (non-enabled: `F841`)
  |
1 | def f() -> None:
2 |     x: int = 'hi'  # noqa: F841
  |                    ^^^^^^^^^^^^ RUF100
  |
  = help: Remove unused `noqa` directive

Found 1 error.
[*] 1 fixable with the `--fix` option.

brianschubert avatar Jun 04 '25 21:06 brianschubert

tagging with unused-ignore doesn't really help though -- since now it could live there ~forever even if the actual thing gets fixed

I don't think ruff's behaviour is particularly relevant -- incremental application isn't a common approach there.

asottile-sentry avatar Jun 04 '25 21:06 asottile-sentry

@asottile-sentry how does adding warn_unused_ignores = false to [mypy-t] along with the disable_error_code strike you as a solution (to your particular problem)?

wyattscarpenter avatar Jun 07 '25 21:06 wyattscarpenter

again that defeats the whole point for me

asottile-sentry avatar Jun 09 '25 13:06 asottile-sentry

But then the type-ignores won't live forever, only until you remove the pair of configurations from your configuration file.

wyattscarpenter avatar Jun 09 '25 16:06 wyattscarpenter

it won't but it lets other completely wrong type ignores creep in / stick around

asottile-sentry avatar Jun 09 '25 16:06 asottile-sentry

I see, that's true.

wyattscarpenter avatar Jun 09 '25 17:06 wyattscarpenter

Besides the obvious solutions (the one I just mentioned, the one brianschubert mentioned, changing the behavior of warn-unused-ignores back, introducing a new configuration warn_unused_ignores_except, & just removing the type ignores), which all have their drawbacks, you could also check out basedmypy's baselining feature https://kotlinisland.github.io/basedmypy/baseline. Basedmypy is a fork of mypy and has slightly different behavior sometimes (and different things enabled by default), but there's a slight chance that it's exactly what you need.

wyattscarpenter avatar Jun 09 '25 17:06 wyattscarpenter

yeah I'm never using anything by that author or with "based" in the name so that's not really a fix to mypy's behaviour change

asottile-sentry avatar Jun 09 '25 17:06 asottile-sentry

What if mypy didn't warn about any unused ignores with error codes that are suppressed in that file? It feels worse than previous status quo, but it's also consistent (in that checks that try to save work if disabled also have the right behavior).

(I really don't like that behavior though. But no matter what there's a nonlocal thing affecting the file which is very confusing so I think adding a bit more isn't too bad?)

A5rocks avatar Jun 10 '25 21:06 A5rocks

On the other hand making file-level disabling of error codes different than project-level disabling of error codes feels disappointing. Do you get warned that an ignore is useless if you disable an error code for your whole project?

A5rocks avatar Jun 10 '25 21:06 A5rocks

I still don't see why the original behaviour had to change. I feel like it was pretty good: an error was suppressed on this line so the ignore is valid, it just also happened to be suppressed globally due to reduced strictness.

asottile-sentry avatar Jun 11 '25 14:06 asottile-sentry

The main issue with the previous behavior was that it was inconsistent and also not very predictable. It only worked for some errors (not necessarily even for all errors with the same error code), and whether it worked for a particular error could also change unexpectedly when other parts of the configuration changed or between mypy releases.

For example, was this ok under the previous behavior?

# flags: --warn-unused-ignores --disable-error-code no-untyped-def
def foo(x) -> None: pass  # type: ignore[no-untyped-def]

Whether this emitted an unused-ignore error or not depended on the values of stict, disallow_untyped_defs, and disallow_incomplete_defs, even though regardless of those settings a no-untyped-def would not be emitted if the type: ignore were removed.

brianschubert avatar Jun 12 '25 21:06 brianschubert

in my mind it would be better to address those inconsistencies rather than what was done which makes incremental adoption much more painful

asottile-sentry avatar Jun 12 '25 21:06 asottile-sentry

for instance (guessing how things work) by having mypy always produce the error for untyped / incomplete defs and then use the 1.15 type-ignore behaviour. should be more consistent but without having the undesirable behaviour change

asottile-sentry avatar Jun 12 '25 21:06 asottile-sentry

You can find actual instances of this by searching the mypy codebase for is_error_code_enabled. For instance, the first result when I try that locally only actually creates a visitor and uses it if we're checking whether things are possibly defined/want to know if assignment A happens before use B. That visitor seemingly reanalyzes a scope to know specifically when assignments happened! We only want to enable that potentially costly thing if the error code is actually going to be seen. (and we obviously can't know if a statement could trigger that error cheaply, or else we would do that...)

So IMO it's simply impossible to know whether an ignore is actually unused without regard to file configuration without perf downsides (running the file twice? running the file once and filtering out certain error codes? I guess maybe the second would work and it would only be a minor perf change. Probably. There's probably issues due to flags vs error codes, where some flag might increase the number of e.g. misc errors?)

A5rocks avatar Jun 12 '25 22:06 A5rocks