[ty] Support type:ignore[ty:code] suppressions (#24096)

This commit is contained in:
Micha Reiser
2026-03-24 08:33:41 +00:00
committed by GitHub
parent 523fcf92b0
commit 84ff94b42e
13 changed files with 215 additions and 66 deletions
+1 -1
View File
@@ -989,7 +989,7 @@ fn datetype(criterion: &mut Criterion) {
max_dep_date: "2025-07-04",
python_version: PythonVersion::PY313,
},
4,
10,
);
bench_project(&benchmark, criterion);
+1 -1
View File
@@ -109,7 +109,7 @@ static ALTAIR: Benchmark = Benchmark::new(
max_dep_date: "2025-06-17",
python_version: PythonVersion::PY312,
},
860,
897,
);
static COLOUR_SCIENCE: Benchmark = Benchmark::new(
+2 -2
View File
@@ -742,11 +742,11 @@ Added in <a href="https://github.com/astral-sh/ty/releases/tag/0.0.1-alpha.1">0.
**What it does**
Checks for `ty: ignore[code]` where `code` isn't a known lint rule.
Checks for `ty: ignore[code]` or `type: ignore[ty:code]` comments where `code` isn't a known lint rule.
**Why is this bad?**
A `ty: ignore[code]` directive with a `code` that doesn't match
A `ty: ignore[code]` or a `type:ignore[ty:code] directive with a `code` that doesn't match
any known rule will not suppress any type errors, and is probably a mistake.
**Examples**
+69
View File
@@ -161,6 +161,75 @@ mod tests {
");
}
#[test]
fn add_code_existing_type_ignore() {
let test = CodeActionTest::with_source(
r#"
b = <START>a<END> / 0 # type:ignore[ty:division-by-zero]
"#,
);
assert_snapshot!(test.code_actions(&UNRESOLVED_REFERENCE), @"
info[code-action]: Ignore 'unresolved-reference' for this line
--> main.py:2:5
|
2 | b = a / 0 # type:ignore[ty:division-by-zero]
| ^
|
1 |
- b = a / 0 # type:ignore[ty:division-by-zero]
2 + b = a / 0 # type:ignore[ty:division-by-zero, ty:unresolved-reference]
");
}
#[test]
fn add_code_existing_type_ignore_without_any_ty_code() {
let test = CodeActionTest::with_source(
r#"
b = <START>a<END> / 0 # type:ignore[mypy-code]
"#,
);
assert_snapshot!(test.code_actions(&UNRESOLVED_REFERENCE), @"
info[code-action]: Ignore 'unresolved-reference' for this line
--> main.py:2:5
|
2 | b = a / 0 # type:ignore[mypy-code]
| ^
|
1 |
- b = a / 0 # type:ignore[mypy-code]
2 + b = a / 0 # type:ignore[mypy-code] # ty:ignore[unresolved-reference]
");
}
#[test]
fn add_ignore_existing_file_level_ignore() {
let test = CodeActionTest::with_source(
r#"
# ty:ignore[division-by-zero]
b = <START>a<END> / 0
"#,
);
assert_snapshot!(test.code_actions(&UNRESOLVED_REFERENCE), @"
info[code-action]: Ignore 'unresolved-reference' for this line
--> main.py:4:5
|
2 | # ty:ignore[division-by-zero]
3 |
4 | b = a / 0
| ^
|
1 |
2 | # ty:ignore[division-by-zero]
3 |
- b = a / 0
4 + b = a / 0 # ty:ignore[unresolved-reference]
");
}
#[test]
fn add_code_existing_ignore_trailing_comma() {
let test = CodeActionTest::with_source(
@@ -507,7 +507,7 @@ the class "header":
class A: ...
class B( # type: ignore[duplicate-base]
class B( # type: ignore[ty:duplicate-base]
A,
A,
): ...
@@ -515,7 +515,7 @@ class B( # type: ignore[duplicate-base]
class C(
A,
A
): # type: ignore[duplicate-base]
): # type: ignore[ty:duplicate-base]
x: int
# fmt: on
@@ -532,7 +532,7 @@ exception at runtime, not a sub-expression in the class's bases list.
class D(
A,
# error: [unused-type-ignore-comment]
A, # type: ignore[duplicate-base]
A, # type: ignore[ty:duplicate-base]
): ...
# error: [duplicate-base]
@@ -541,7 +541,7 @@ class E(
A
):
# error: [unused-type-ignore-comment]
x: int # type: ignore[duplicate-base]
x: int # type: ignore[ty:duplicate-base]
# fmt: on
```
@@ -66,7 +66,7 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/mro.md
51 |
52 | class A: ...
53 |
54 | class B( # type: ignore[duplicate-base]
54 | class B( # type: ignore[ty:duplicate-base]
55 | A,
56 | A,
57 | ): ...
@@ -74,7 +74,7 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/mro.md
59 | class C(
60 | A,
61 | A
62 | ): # type: ignore[duplicate-base]
62 | ): # type: ignore[ty:duplicate-base]
63 | x: int
64 |
65 | # fmt: on
@@ -84,7 +84,7 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/mro.md
69 | class D(
70 | A,
71 | # error: [unused-type-ignore-comment]
72 | A, # type: ignore[duplicate-base]
72 | A, # type: ignore[ty:duplicate-base]
73 | ): ...
74 |
75 | # error: [duplicate-base]
@@ -93,7 +93,7 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/mro.md
78 | A
79 | ):
80 | # error: [unused-type-ignore-comment]
81 | x: int # type: ignore[duplicate-base]
81 | x: int # type: ignore[ty:duplicate-base]
82 |
83 | # fmt: on
```
@@ -281,7 +281,7 @@ error[duplicate-base]: Duplicate base class `A`
| _______^
70 | | A,
71 | | # error: [unused-type-ignore-comment]
72 | | A, # type: ignore[duplicate-base]
72 | | A, # type: ignore[ty:duplicate-base]
73 | | ): ...
| |_^
74 |
@@ -295,7 +295,7 @@ info: The definition of class `D` will raise `TypeError` at runtime
70 | A,
| - Class `A` first included in bases list here
71 | # error: [unused-type-ignore-comment]
72 | A, # type: ignore[duplicate-base]
72 | A, # type: ignore[ty:duplicate-base]
| ^ Class `A` later repeated here
73 | ): ...
|
@@ -304,20 +304,20 @@ info: rule `duplicate-base` is enabled by default
```
```
warning[unused-type-ignore-comment]: Unused blanket `type: ignore` directive
warning[unused-type-ignore-comment]: Unused `type: ignore` directive
--> src/mdtest_snippet.py:72:9
|
70 | A,
71 | # error: [unused-type-ignore-comment]
72 | A, # type: ignore[duplicate-base]
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
72 | A, # type: ignore[ty:duplicate-base]
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
73 | ): ...
|
help: Remove the unused suppression comment
69 | class D(
70 | A,
71 | # error: [unused-type-ignore-comment]
- A, # type: ignore[duplicate-base]
- A, # type: ignore[ty:duplicate-base]
72 + A,
73 | ): ...
74 |
@@ -337,7 +337,7 @@ error[duplicate-base]: Duplicate base class `A`
79 | | ):
| |_^
80 | # error: [unused-type-ignore-comment]
81 | x: int # type: ignore[duplicate-base]
81 | x: int # type: ignore[ty:duplicate-base]
|
info: The definition of class `E` will raise `TypeError` at runtime
--> src/mdtest_snippet.py:77:5
@@ -356,13 +356,13 @@ info: rule `duplicate-base` is enabled by default
```
```
warning[unused-type-ignore-comment]: Unused blanket `type: ignore` directive
warning[unused-type-ignore-comment]: Unused `type: ignore` directive
--> src/mdtest_snippet.py:81:13
|
79 | ):
80 | # error: [unused-type-ignore-comment]
81 | x: int # type: ignore[duplicate-base]
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
81 | x: int # type: ignore[ty:duplicate-base]
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
82 |
83 | # fmt: on
|
@@ -370,7 +370,7 @@ help: Remove the unused suppression comment
78 | A
79 | ):
80 | # error: [unused-type-ignore-comment]
- x: int # type: ignore[duplicate-base]
- x: int # type: ignore[ty:duplicate-base]
81 + x: int
82 |
83 | # fmt: on
@@ -0,0 +1,35 @@
---
source: crates/ty_test/src/lib.rs
expression: snapshot
---
---
mdtest name: type_ignore.md - Suppressing errors with `type: ignore` - Unused ignore comment mixed with mypy comments
mdtest path: crates/ty_python_semantic/resources/mdtest/suppressions/type_ignore.md
---
# Python source files
## mdtest_snippet.py
```
1 | # error: [unused-type-ignore-comment] "Unused `type: ignore` directive: 'division-by-zero'"
2 | a = 10 / 2 # type: ignore[mypy-code, ty:division-by-zero]
```
# Diagnostics
```
warning[unused-type-ignore-comment]: Unused `type: ignore` directive: 'division-by-zero'
--> src/mdtest_snippet.py:2:39
|
1 | # error: [unused-type-ignore-comment] "Unused `type: ignore` directive: 'division-by-zero'"
2 | a = 10 / 2 # type: ignore[mypy-code, ty:division-by-zero]
| ^^^^^^^^^^^^^^^^^^^
|
help: Remove the unused suppression code
1 | # error: [unused-type-ignore-comment] "Unused `type: ignore` directive: 'division-by-zero'"
- a = 10 / 2 # type: ignore[mypy-code, ty:division-by-zero]
2 + a = 10 / 2 # type: ignore[mypy-code]
```
@@ -167,15 +167,13 @@ a = 4 / 0 # ty: ignore[]
## File-level suppression comments
File level suppression comments are currently intentionally unsupported because we've yet to decide
if they should use a different syntax that also supports enabling rules or changing the rule's
severity: `ty: possibly-undefined-reference=error`
File level suppression comments suppress all errors in a file with a given code.
```py
# error: [unused-ignore-comment]
# ty: ignore[division-by-zero]
a = 4 / 0 # error: [division-by-zero]
a = 4 / 0
b = a + c # error: [unresolved-reference]
```
## Unknown rule
@@ -132,11 +132,19 @@ a = f"""
## Codes
Mypy supports `type: ignore[code]`. ty doesn't understand mypy's rule names. Therefore, ignore the
codes and suppress all errors.
Similar to mypy support `type: ignore[codes]` comments. But unlike mypy, ty only respects codes
starting with `ty:` to avoid ambiguity with suppression comments from mypy and other type checkers.
```py
a = test # type: ignore[name-defined]
a = test # type: ignore[name-defined, ty:unresolved-reference]
```
## Unknown codes starting with `ty`
```py
# error: [unresolved-reference]
# error: [ignore-comment-unknown-rule]
a = test # type: ignore[ty:name-defined]
```
## Nested comments
@@ -190,6 +198,15 @@ a = 10 / 0
b = a / 0
```
## File level suppression with code
```py
# type: ignore[ty:division-by-zero]
a = 10 / 0
b = a + c # error: [unresolved-reference]
```
## File level suppression with leading shebang
```py
@@ -242,3 +259,26 @@ ty doesn't report invalid `type: ignore` comments:
```py
a = 10 + 4 # type: ignoreee
```
## Unused ignore comment mixed with mypy comments
<!-- snapshot-diagnostics -->
```py
# error: [unused-type-ignore-comment] "Unused `type: ignore` directive: 'division-by-zero'"
a = 10 / 2 # type: ignore[mypy-code, ty:division-by-zero]
```
## Unused ignore comment
```py
# error: [unused-type-ignore-comment] "Unused `type: ignore` directive"
a = 10 / 2 # type: ignore[ty:division-by-zero]
```
## Unknown ignore code
```py
# error: [ignore-comment-unknown-rule] "Unknown rule `division-by`. Did you mean"
a = 10 / 2 # type: ignore[ty:division-by]
```
+23 -27
View File
@@ -83,10 +83,10 @@ declare_lint! {
declare_lint! {
/// ## What it does
/// Checks for `ty: ignore[code]` where `code` isn't a known lint rule.
/// Checks for `ty: ignore[code]` or `type: ignore[ty:code]` comments where `code` isn't a known lint rule.
///
/// ## Why is this bad?
/// A `ty: ignore[code]` directive with a `code` that doesn't match
/// A `ty: ignore[code]` or a `type:ignore[ty:code] directive with a `code` that doesn't match
/// any known rule will not suppress any type errors, and is probably a mistake.
///
/// ## Examples
@@ -204,7 +204,7 @@ pub(crate) fn check_suppressions(
context.diagnostics.into_inner().into_diagnostics()
}
/// Checks for `ty: ignore` comments that reference unknown rules.
/// Checks for `ty: ignore` and `type: ignore[ty:<code>]` comments that reference unknown rules.
fn check_unknown_rule(context: &mut CheckSuppressionsContext) {
if context.is_lint_disabled(&IGNORE_COMMENT_UNKNOWN_RULE) {
return;
@@ -339,8 +339,6 @@ pub(crate) struct Suppressions {
///
/// The suppressions are sorted by [`Suppression::comment_range`] and the [`Suppression::suppressed_range`]
/// spans the entire file.
///
/// For now, this is limited to `type: ignore` comments.
file: SmallVec<[Suppression; 1]>,
/// Suppressions that apply to a specific line (or lines).
@@ -531,7 +529,7 @@ struct SuppressionsBuilder<'a> {
lint_registry: &'a LintRegistry,
source: &'a str,
/// `type: ignore` comments at the top of the file before any non-trivia code apply to the entire file.
/// Ignore comments at the top of the file before any non-trivia code apply to the entire file.
/// This boolean tracks if there has been any non trivia token.
seen_non_trivia_token: bool,
@@ -574,13 +572,13 @@ impl<'a> SuppressionsBuilder<'a> {
#[expect(clippy::needless_pass_by_value)]
fn add_comment(&mut self, comment: SuppressionComment, line_range: TextRange) {
// `type: ignore` comments at the start of the file apply to the entire range.
// ignore comments at the start of the file apply to the entire range.
// > A # type: ignore comment on a line by itself at the top of a file, before any docstrings,
// > imports, or other executable code, silences all errors in the file.
// > Blank lines and other comments, such as shebang lines and coding cookies,
// > may precede the # type: ignore comment.
// > https://typing.python.org/en/latest/spec/directives.html#type-ignore-comments
let is_file_suppression = comment.kind().is_type_ignore() && !self.seen_non_trivia_token;
let is_file_suppression = !self.seen_non_trivia_token;
let suppressed_range = if is_file_suppression {
TextRange::new(0.into(), self.source.text_len())
@@ -588,7 +586,7 @@ impl<'a> SuppressionsBuilder<'a> {
line_range
};
let mut push_type_ignore_suppression = |suppression: Suppression| {
let mut push_ignore_suppression = |suppression: Suppression| {
if is_file_suppression {
self.file.push(suppression);
} else {
@@ -599,7 +597,7 @@ impl<'a> SuppressionsBuilder<'a> {
match comment.codes() {
// `type: ignore`
None => {
push_type_ignore_suppression(Suppression {
push_ignore_suppression(Suppression {
target: SuppressionTarget::All,
kind: comment.kind(),
comment_range: comment.range(),
@@ -608,22 +606,9 @@ impl<'a> SuppressionsBuilder<'a> {
});
}
// `type: ignore[..]`
// The suppression applies to all lints if it is a `type: ignore`
// comment. `type: ignore` apply to all lints for better mypy compatibility.
Some(_) if comment.kind().is_type_ignore() => {
push_type_ignore_suppression(Suppression {
target: SuppressionTarget::All,
kind: comment.kind(),
comment_range: comment.range(),
range: comment.range(),
suppressed_range,
});
}
// `ty: ignore[]`
// `ty: ignore[]` or `type: ignore[]`
Some([]) => {
self.line.push(Suppression {
push_ignore_suppression(Suppression {
target: SuppressionTarget::Empty,
kind: comment.kind(),
range: comment.range(),
@@ -632,14 +617,25 @@ impl<'a> SuppressionsBuilder<'a> {
});
}
// `ty: ignore[a, b]`
// `ty: ignore[a, b]` or `type: ignore[a, b]`
Some(codes) => {
for &code_range in codes {
let code = &self.source[code_range];
// For `type:ignore`, ignore codes that don't start with `ty:`.
let code = if comment.kind().is_type_ignore() {
if let Some(prefix) = code.strip_prefix("ty:") {
prefix
} else {
continue;
}
} else {
code
};
match self.lint_registry.get(code) {
Ok(lint) => {
self.line.push(Suppression {
push_ignore_suppression(Suppression {
target: SuppressionTarget::Lint(lint),
kind: comment.kind(),
range: code_range,
@@ -14,7 +14,7 @@ use smallvec::SmallVec;
use crate::Db;
use crate::lint::LintId;
use crate::suppression::{SuppressionTarget, Suppressions, suppressions};
use crate::suppression::{SuppressionKind, SuppressionTarget, Suppressions, suppressions};
/// Creates fixes to suppress all violations in `ids_with_range`.
///
@@ -183,7 +183,10 @@ fn append_to_existing_or_add_end_of_line_suppression(
up_to_line_end.trim_end_matches(|c| !matches!(c, '\n' | '\r') && c.is_whitespace());
let trailing_whitespace_len = up_to_line_end.text_len() - up_to_first_content.text_len();
let insertion = format!(" # ty:ignore[{codes}]", codes = Codes(codes));
let insertion = format!(
" # ty:ignore[{codes}]",
codes = Codes(SuppressionKind::Ty, codes)
);
Fix::safe_edit(if trailing_whitespace_len == TextSize::ZERO {
Edit::insertion(insertion, line_end)
@@ -218,9 +221,9 @@ fn add_to_existing_suppression(
let up_to_last_code = before_closing_paren.trim_end();
let insertion = if up_to_last_code.ends_with(',') {
format!(" {codes}", codes = Codes(codes))
format!(" {codes}", codes = Codes(existing.kind, codes))
} else {
format!(", {codes}", codes = Codes(codes))
format!(", {codes}", codes = Codes(existing.kind, codes))
};
let relative_offset_from_end = comment_text.text_len() - up_to_last_code.text_len();
@@ -231,10 +234,18 @@ fn add_to_existing_suppression(
)))
}
struct Codes<'a>(&'a [LintName]);
struct Codes<'a>(SuppressionKind, &'a [LintName]);
impl std::fmt::Display for Codes<'_> {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.join(", ").entries(self.0).finish()
let mut joiner = f.join(", ");
let namespace = if self.0.is_type_ignore() { "ty:" } else { "" };
for item in self.1 {
joiner.entry(&format_args!("{namespace}{item}"));
}
joiner.finish()
}
}
+1 -1
View File
@@ -23,4 +23,4 @@ def snake_case(name: str) -> str:
def get_indent(line: str) -> str:
return re.match(r"^\s*", line).group() # type: ignore[union-attr]
return re.match(r"^\s*", line).group() # type: ignore[union-attr, ty:unresolved-attribute]
+1 -1
View File
@@ -596,7 +596,7 @@
},
"ignore-comment-unknown-rule": {
"title": "detects `ty: ignore` comments that reference unknown rules",
"description": "## What it does\nChecks for `ty: ignore[code]` where `code` isn't a known lint rule.\n\n## Why is this bad?\nA `ty: ignore[code]` directive with a `code` that doesn't match\nany known rule will not suppress any type errors, and is probably a mistake.\n\n## Examples\n```py\na = 20 / 0 # ty: ignore[division-by-zer]\n```\n\nUse instead:\n\n```py\na = 20 / 0 # ty: ignore[division-by-zero]\n```",
"description": "## What it does\nChecks for `ty: ignore[code]` or `type: ignore[ty:code]` comments where `code` isn't a known lint rule.\n\n## Why is this bad?\nA `ty: ignore[code]` or a `type:ignore[ty:code] directive with a `code` that doesn't match\nany known rule will not suppress any type errors, and is probably a mistake.\n\n## Examples\n```py\na = 20 / 0 # ty: ignore[division-by-zer]\n```\n\nUse instead:\n\n```py\na = 20 / 0 # ty: ignore[division-by-zero]\n```",
"default": "warn",
"oneOf": [
{