Added support for per-line suppression of diagnostics using # pyright: ignore comment. This also supports rule-specific suppression using a list of diagnostic rules, as in # pyright: ignore [reportGeneralTypeIssues].

This commit is contained in:
Eric Traut 2022-03-11 09:47:41 -07:00
parent 915928f572
commit 4cbd352d39
10 changed files with 232 additions and 29 deletions

View File

@ -36,3 +36,10 @@ Diagnostic levels are also supported.
# pyright: reportPrivateUsage=warning, reportOptionalCall=error
```
## Line-level Diagnostic Suppression
PEP 484 defines a special comment `# type: ignore` that can be used at the end of a line to suppress all diagnostics emitted by a type checker on that line. Pyright supports this mechanism.
Pyright also supports a `# pyright: ignore` comment at the end of a line to suppress all Pyright diagnostics on that line. This can be useful if you use multiple type checkers on your source base and want to limit suppression of diagnostics to Pyright only.
The `# pyright: ignore` comment accepts an optional list of comma-delimited diagnostic rule names surrounded by square brackets. If such a list is present, only diagnostics within those diagnostic rule categories are suppressed on that line. For example, `# pyright: ignore [reportPrivateUsage, reportGeneralTypeIssues]` would suppress diagnostics related to those two categories but no others.

View File

@ -50,7 +50,7 @@ The following settings control pyrights diagnostic output (warnings or errors
**strictParameterNoneValue** [boolean]: PEP 484 indicates that when a function parameter is assigned a default value of None, its type should implicitly be Optional even if the explicit type is not. When enabled, this rule requires that parameter type annotations use Optional explicitly in this case. The default value for this setting is 'true'.
**enableTypeIgnoreComments** [boolean]: PEP 484 defines support for "# type: ignore" comments. This switch enables or disables support for these comments. The default value for this setting is 'true'.
**enableTypeIgnoreComments** [boolean]: PEP 484 defines support for "# type: ignore" comments. This switch enables or disables support for these comments. The default value for this setting is 'true'. This does not affect "# pyright: ignore" comments.
**reportGeneralTypeIssues** [boolean or string, optional]: Generate or suppress diagnostics for general type inconsistencies, unsupported operations, argument/parameter mismatches, etc. This covers all of the basic type-checking rules not covered by other rules. It does not include syntax errors. The default value for this setting is 'error'.
@ -164,7 +164,7 @@ The following settings control pyrights diagnostic output (warnings or errors
**reportUnusedCoroutine** [boolean or string, optional]: Generate or suppress diagnostics for call statements whose return value is not used in any way and is a Coroutine. This identifies a common error where an `await` keyword is mistakenly omitted. The default value for this setting is 'error'.
**reportUnnecessaryTypeIgnoreComment** [boolean or string, optional]: Generate or suppress diagnostics for a '# type: ignore' comment that would have no effect if removed. The default value for this setting is 'none'.
**reportUnnecessaryTypeIgnoreComment** [boolean or string, optional]: Generate or suppress diagnostics for a '# type: ignore' or '# pyright: ignore' comment that would have no effect if removed. The default value for this setting is 'none'.
**reportMatchNotExhaustive** [boolean or string, optional]: Generate or suppress diagnostics for a 'match' statement that does not provide cases that exhaustively match against all potential types of the target expression. The default value for this setting is 'none'.

View File

@ -46,6 +46,7 @@ import { SignatureHelpProvider, SignatureHelpResults } from '../languageService/
import { Localizer } from '../localization/localize';
import { ModuleNode, NameNode } from '../parser/parseNodes';
import { ModuleImport, ParseOptions, Parser, ParseResults } from '../parser/parser';
import { IgnoreComment } from '../parser/tokenizer';
import { Token } from '../parser/tokenizerTypes';
import { AnalyzerFileInfo, ImportLookup } from './analyzerFileInfo';
import * as AnalyzerNodeInfo from './analyzerNodeInfo';
@ -146,8 +147,9 @@ export class SourceFile {
private _parseDiagnostics: Diagnostic[] = [];
private _bindDiagnostics: Diagnostic[] = [];
private _checkerDiagnostics: Diagnostic[] = [];
private _typeIgnoreLines = new Map<number, TextRange>();
private _typeIgnoreAll: TextRange | undefined;
private _typeIgnoreLines = new Map<number, IgnoreComment>();
private _typeIgnoreAll: IgnoreComment | undefined;
private _pyrightIgnoreLines = new Map<number, IgnoreComment>();
// Settings that control which diagnostics should be output.
private _diagnosticRuleSet = getBasicDiagnosticRuleSet();
@ -259,6 +261,7 @@ export class SourceFile {
let diagList = [...this._parseDiagnostics, ...this._bindDiagnostics, ...this._checkerDiagnostics];
const prefilteredDiagList = diagList;
const typeIgnoreLinesClone = new Map(this._typeIgnoreLines);
const pyrightIgnoreLinesClone = new Map(this._pyrightIgnoreLines);
// Filter the diagnostics based on "type: ignore" lines.
if (this._diagnosticRuleSet.enableTypeIgnoreComments) {
@ -278,6 +281,55 @@ export class SourceFile {
}
}
// Filter the diagnostics based on "pyright: ignore" lines.
if (this._pyrightIgnoreLines.size > 0) {
diagList = diagList.filter((d) => {
if (d.category !== DiagnosticCategory.UnusedCode && d.category !== DiagnosticCategory.Deprecated) {
for (let line = d.range.start.line; line <= d.range.end.line; line++) {
const pyrightIgnoreComment = this._pyrightIgnoreLines.get(line);
if (pyrightIgnoreComment) {
if (!pyrightIgnoreComment.rulesList) {
pyrightIgnoreLinesClone.delete(line);
return false;
}
const diagRule = d.getRule();
if (!diagRule) {
// If there's no diagnostic rule, it won't match
// against a rules list.
return true;
}
// Did we find this rule in the list?
if (pyrightIgnoreComment.rulesList.find((rule) => rule.text === diagRule)) {
// Update the pyrightIgnoreLinesClone to remove this rule.
const oldClone = pyrightIgnoreLinesClone.get(line);
if (oldClone?.rulesList) {
const filteredRulesList = oldClone.rulesList.filter(
(rule) => rule.text !== diagRule
);
if (filteredRulesList.length === 0) {
pyrightIgnoreLinesClone.delete(line);
} else {
pyrightIgnoreLinesClone.set(line, {
range: oldClone.range,
rulesList: filteredRulesList,
});
}
}
return false;
}
return true;
}
}
}
return true;
});
}
const unnecessaryTypeIgnoreDiags: Diagnostic[] = [];
if (this._diagnosticRuleSet.reportUnnecessaryTypeIgnoreComment !== 'none') {
@ -296,29 +348,63 @@ export class SourceFile {
diagCategory,
Localizer.Diagnostic.unnecessaryTypeIgnore(),
convertOffsetsToRange(
this._typeIgnoreAll.start,
this._typeIgnoreAll.start + this._typeIgnoreAll.length,
this._typeIgnoreAll.range.start,
this._typeIgnoreAll.range.start + this._typeIgnoreAll.range.length,
this._parseResults!.tokenizerOutput.lines!
)
)
);
}
typeIgnoreLinesClone.forEach((textRange) => {
typeIgnoreLinesClone.forEach((ignoreComment) => {
if (this._parseResults?.tokenizerOutput.lines) {
unnecessaryTypeIgnoreDiags.push(
new Diagnostic(
diagCategory,
Localizer.Diagnostic.unnecessaryTypeIgnore(),
convertOffsetsToRange(
textRange.start,
textRange.start + textRange.length,
ignoreComment.range.start,
ignoreComment.range.start + ignoreComment.range.length,
this._parseResults!.tokenizerOutput.lines!
)
)
);
}
});
pyrightIgnoreLinesClone.forEach((ignoreComment) => {
if (this._parseResults?.tokenizerOutput.lines) {
if (!ignoreComment.rulesList) {
unnecessaryTypeIgnoreDiags.push(
new Diagnostic(
diagCategory,
Localizer.Diagnostic.unnecessaryPyrightIgnore(),
convertOffsetsToRange(
ignoreComment.range.start,
ignoreComment.range.start + ignoreComment.range.length,
this._parseResults!.tokenizerOutput.lines!
)
)
);
} else {
ignoreComment.rulesList.forEach((unusedRule) => {
unnecessaryTypeIgnoreDiags.push(
new Diagnostic(
diagCategory,
Localizer.Diagnostic.unnecessaryPyrightIgnoreRule().format({
name: unusedRule.text,
}),
convertOffsetsToRange(
unusedRule.range.start,
unusedRule.range.start + unusedRule.range.length,
this._parseResults!.tokenizerOutput.lines!
)
)
);
});
}
}
});
}
if (this._diagnosticRuleSet.reportImportCycles !== 'none' && this._circularDependencies.length > 0) {
@ -659,6 +745,7 @@ export class SourceFile {
this._parseResults = parseResults;
this._typeIgnoreLines = this._parseResults.tokenizerOutput.typeIgnoreLines;
this._typeIgnoreAll = this._parseResults.tokenizerOutput.typeIgnoreAll;
this._pyrightIgnoreLines = this._parseResults.tokenizerOutput.pyrightIgnoreLines;
// Resolve imports.
timingStats.resolveImportsTime.timeOperation(() => {
@ -704,7 +791,8 @@ export class SourceFile {
tokens: new TextRangeCollection<Token>([]),
lines: new TextRangeCollection<TextRange>([]),
typeIgnoreAll: undefined,
typeIgnoreLines: new Map<number, TextRange>(),
typeIgnoreLines: new Map<number, IgnoreComment>(),
pyrightIgnoreLines: new Map<number, IgnoreComment>(),
predominantEndOfLineSequence: '\n',
predominantTabSequence: ' ',
predominantSingleQuoteCharacter: "'",

View File

@ -278,8 +278,8 @@ export interface DiagnosticRuleSet {
// and is not used in any way.
reportUnusedCoroutine: DiagnosticLevel;
// Report cases where the removal of a "# type: ignore" comment would
// have no effect.
// Report cases where the removal of a "# type: ignore" or "# pyright: ignore"
// comment would have no effect.
reportUnnecessaryTypeIgnoreComment: DiagnosticLevel;
// Report cases where the a "match" statement is not exhaustive in

View File

@ -885,6 +885,9 @@ export namespace Localizer {
new ParameterizedString<{ testType: string; classType: string }>(
getRawString('Diagnostic.unnecessaryIsSubclassAlways')
);
export const unnecessaryPyrightIgnore = () => getRawString('Diagnostic.unnecessaryPyrightIgnore');
export const unnecessaryPyrightIgnoreRule = () =>
new ParameterizedString<{ name: string }>(getRawString('Diagnostic.unnecessaryPyrightIgnoreRule'));
export const unnecessaryTypeIgnore = () => getRawString('Diagnostic.unnecessaryTypeIgnore');
export const unpackArgCount = () => getRawString('Diagnostic.unpackArgCount');
export const unpackedArgInTypeArgument = () => getRawString('Diagnostic.unpackedArgInTypeArgument');

View File

@ -444,7 +444,9 @@
"unnecessaryCast": "Unnecessary \"cast\" call; type is already \"{type}\"",
"unnecessaryIsInstanceAlways": "Unnecessary isinstance call; \"{testType}\" is always an instance of \"{classType}\"",
"unnecessaryIsSubclassAlways": "Unnecessary issubclass call; \"{testType}\" is always a subclass of \"{classType}\"",
"unnecessaryTypeIgnore": "Unnecessary '# type ignore' comment",
"unnecessaryPyrightIgnore": "Unnecessary \"# pyright: ignore\" comment",
"unnecessaryPyrightIgnoreRule": "Unnecessary \"# pyright: ignore\" rule: \"{name}\"",
"unnecessaryTypeIgnore": "Unnecessary \"# type: ignore\" comment",
"unpackArgCount": "Expected a single type argument after \"Unpack\"",
"unpackedArgInTypeArgument": "Unpacked arguments cannot be used in type argument lists",
"unpackedArgWithVariadicParam": "Unpacked argument cannot be used for TupleTypeVar parameter",

View File

@ -146,10 +146,13 @@ export interface TokenizerOutput {
lines: TextRangeCollection<TextRange>;
// Map of all line numbers that end in a "type: ignore" comment.
typeIgnoreLines: Map<number, TextRange>;
typeIgnoreLines: Map<number, IgnoreComment>;
// Map of all line numbers that end in a "pyright: ignore" comment.
pyrightIgnoreLines: Map<number, IgnoreComment>;
// Program starts with a "type: ignore" comment.
typeIgnoreAll: TextRange | undefined;
typeIgnoreAll: IgnoreComment | undefined;
// Line-end sequence ('/n', '/r', or '/r/n').
predominantEndOfLineSequence: string;
@ -174,6 +177,16 @@ interface IndentInfo {
isTabPresent: boolean;
}
export interface IgnoreCommentRule {
text: string;
range: TextRange;
}
export interface IgnoreComment {
range: TextRange;
rulesList: IgnoreCommentRule[] | undefined;
}
export class Tokenizer {
private _cs = new CharacterStream('');
private _tokens: Token[] = [];
@ -181,8 +194,9 @@ export class Tokenizer {
private _parenDepth = 0;
private _lineRanges: TextRange[] = [];
private _indentAmounts: IndentInfo[] = [];
private _typeIgnoreAll: TextRange | undefined;
private _typeIgnoreLines = new Map<number, TextRange>();
private _typeIgnoreAll: IgnoreComment | undefined;
private _typeIgnoreLines = new Map<number, IgnoreComment>();
private _pyrightIgnoreLines = new Map<number, IgnoreComment>();
private _comments: Comment[] | undefined;
// Total times CR, CR/LF, and LF are used to terminate
@ -300,6 +314,7 @@ export class Tokenizer {
lines: new TextRangeCollection(this._lineRanges),
typeIgnoreLines: this._typeIgnoreLines,
typeIgnoreAll: this._typeIgnoreAll,
pyrightIgnoreLines: this._pyrightIgnoreLines,
predominantEndOfLineSequence,
predominantTabSequence,
predominantSingleQuoteCharacter: this._singleQuoteCount >= this._doubleQuoteCount ? "'" : '"',
@ -1071,27 +1086,62 @@ export class Tokenizer {
const value = this._cs.getText().substr(start, length);
const comment = Comment.create(start, length, value);
// We include "[" in the regular expression because mypy supports
// ignore comments of the form ignore[errorCode, ...]. We'll treat
// these as regular ignore statements (as though no errorCodes were
// included).
const regexMatch = value.match(/^\s*type:\s*ignore(\s|\[|$)/);
if (regexMatch) {
const textRange: TextRange = { start, length: regexMatch[0].length };
if (regexMatch[0].endsWith('[')) {
textRange.length--;
}
const typeIgnoreRegexMatch = value.match(/^\s*type:\s*ignore(\s*\[([\s*\w-,]*)\]|\s|$)/);
if (typeIgnoreRegexMatch) {
const textRange: TextRange = { start, length: typeIgnoreRegexMatch[0].length };
const ignoreComment: IgnoreComment = {
range: textRange,
rulesList: this._getIgnoreCommentRulesList(start, typeIgnoreRegexMatch),
};
if (this._tokens.findIndex((t) => t.type !== TokenType.NewLine && t && t.type !== TokenType.Indent) < 0) {
this._typeIgnoreAll = textRange;
this._typeIgnoreAll = ignoreComment;
} else {
this._typeIgnoreLines.set(this._lineRanges.length, textRange);
this._typeIgnoreLines.set(this._lineRanges.length, ignoreComment);
}
}
const pyrightIgnoreRegexMatch = value.match(/^\s*pyright:\s*ignore(\s*\[([\s*\w-,]*)\]|\s|$)/);
if (pyrightIgnoreRegexMatch) {
const textRange: TextRange = { start, length: pyrightIgnoreRegexMatch[0].length };
const ignoreComment: IgnoreComment = {
range: textRange,
rulesList: this._getIgnoreCommentRulesList(start, pyrightIgnoreRegexMatch),
};
this._pyrightIgnoreLines.set(this._lineRanges.length, ignoreComment);
}
this._addComments(comment);
}
// Extracts the individual rules within a "type: ignore [x, y, z]" comment.
private _getIgnoreCommentRulesList(start: number, match: RegExpMatchArray): IgnoreCommentRule[] | undefined {
if (match.length < 3 || match[2] === undefined) {
return undefined;
}
const splitElements = match[2].split(',');
const commentRules: IgnoreCommentRule[] = [];
let currentOffset = start + match[0].indexOf('[') + 1;
for (const element of splitElements) {
const frontTrimmed = element.trimStart();
currentOffset += element.length - frontTrimmed.length;
const endTrimmed = frontTrimmed.trimEnd();
if (endTrimmed.length > 0) {
commentRules.push({
range: { start: currentOffset, length: endTrimmed.length },
text: endTrimmed,
});
}
currentOffset += frontTrimmed.length + 1;
}
return commentRules;
}
private _addComments(comment: Comment) {
if (this._comments) {
this._comments.push(comment);

View File

@ -305,6 +305,24 @@ test('TypeIgnore5', () => {
TestUtils.validateResults(analysisResults, 0, 1);
});
test('PyrightIgnore1', () => {
const configOptions = new ConfigOptions('.');
const analysisResults = TestUtils.typeAnalyzeSampleFiles(['pyrightIgnore1.py'], configOptions);
TestUtils.validateResults(analysisResults, 1);
});
test('PyrightIgnore2', () => {
const configOptions = new ConfigOptions('.');
let analysisResults = TestUtils.typeAnalyzeSampleFiles(['pyrightIgnore2.py'], configOptions);
TestUtils.validateResults(analysisResults, 2);
configOptions.diagnosticRuleSet.reportUnnecessaryTypeIgnoreComment = 'warning';
analysisResults = TestUtils.typeAnalyzeSampleFiles(['pyrightIgnore2.py'], configOptions);
TestUtils.validateResults(analysisResults, 2, 3);
});
test('DuplicateImports1', () => {
const configOptions = new ConfigOptions('.');

View File

@ -0,0 +1,11 @@
# This sample tests the # pyright: ignore comment.
from typing import Optional
def foo(self, x: Optional[int]) -> str:
# This should suppress the error
x + "hi" # pyright: ignore - test
# This should not suppress the error because the rule doesn't match.
return 3 # pyright: ignore [foo]

View File

@ -0,0 +1,24 @@
# This sample tests the use of a # pyright: ignore comment in conjunction
# with the reportUnnecessaryTypeIgnoreComment mechanism.
from typing import Optional
def foo(self, x: Optional[int]) -> str:
# This should suppress the error
x + "hi" # pyright: ignore - test
# This is unnecessary
x + x # pyright: ignore
# This will not suppress the error
# These are both unnecessary
x + x # pyright: ignore [foo, bar]
# This will not suppress the error
x + x # pyright: ignore []
# One of these is unnecessary
x + "hi" # pyright: ignore [reportGeneralTypeIssues, foo]
return 3 # pyright: ignore [reportGeneralTypeIssues]