From bf47c6506eeb35d22b3c43239527d4b60f5042eb Mon Sep 17 00:00:00 2001 From: Jukka Lehtosalo Date: Wed, 7 Aug 2019 20:35:28 +0300 Subject: [PATCH] Add beginnings of error code support (#7267) This PR adds a foundation for error codes and implements a few error codes. It also adds support for `# type: ignore[code1, ...]` which ignores only specific error codes on a line. Only a few errors include interesting error codes at this point. I'll add support for more error codes in additional PRs. Most errors will implicitly fall back to a `misc` error code. Error codes are only shown if `--show-error-codes` is used. The error codes look like this in mypy output: ``` t.py:3: error: "str" has no attribute "trim" [attr-defined] ``` Error codes are intended to be short but human-readable. The name of an error code refers to the check that produces this error. In the above example we generate a "no attribute" error when we check whether an attribute is defined. Work towards #7239. --- mypy/build.py | 4 +- mypy/errorcodes.py | 50 ++++++++++++ mypy/errors.py | 91 ++++++++++++++------- mypy/fastparse.py | 116 ++++++++++++++------------- mypy/fastparse2.py | 79 +++++++++--------- mypy/main.py | 3 + mypy/messages.py | 43 +++++++--- mypy/newsemanal/semanal.py | 23 +++--- mypy/nodes.py | 10 ++- mypy/options.py | 1 + mypy/test/testcheck.py | 4 + mypy/treetransform.py | 4 +- test-data/unit/check-errorcodes.test | 112 ++++++++++++++++++++++++++ 13 files changed, 390 insertions(+), 150 deletions(-) create mode 100644 mypy/errorcodes.py create mode 100644 test-data/unit/check-errorcodes.test diff --git a/mypy/build.py b/mypy/build.py index 0cdba0e37986..6770b62c71c5 100644 --- a/mypy/build.py +++ b/mypy/build.py @@ -199,7 +199,9 @@ def _build(sources: List[BuildSource], reports = Reports(data_dir, options.report_dirs) source_set = BuildSourceSet(sources) - errors = Errors(options.show_error_context, options.show_column_numbers) + errors = Errors(options.show_error_context, + options.show_column_numbers, + options.show_error_codes) plugin, snapshot = load_plugins(options, errors, stdout) # Construct a build manager object to hold state during the build. diff --git a/mypy/errorcodes.py b/mypy/errorcodes.py new file mode 100644 index 000000000000..adbb815252a5 --- /dev/null +++ b/mypy/errorcodes.py @@ -0,0 +1,50 @@ +"""Classification of possible errors mypy can detect. + +These can be used for filtering specific errors. +""" + +from typing import List +from typing_extensions import Final + + +# All created error codes are implicitly stored in this list. +all_error_codes = [] # type: List[ErrorCode] + + +class ErrorCode: + def __init__(self, code: str, description: str, category: str) -> None: + self.code = code + self.description = description + self.category = category + + def __str__(self) -> str: + return ''.format(self.code) + + +ATTR_DEFINED = ErrorCode( + 'attr-defined', "Check that attribute exists", 'General') # type: Final +NAME_DEFINED = ErrorCode( + 'name-defined', "Check that name is defined", 'General') # type: Final +CALL_ARG = ErrorCode( + 'call-arg', "Check number, names and kinds of arguments in calls", 'General') # type: Final +ARG_TYPE = ErrorCode( + 'arg-type', "Check argument types in calls", 'General') # type: Final +VALID_TYPE = ErrorCode( + 'valid-type', "Check that type (annotation) is valid", 'General') # type: Final +MISSING_ANN = ErrorCode( + 'var-annotated', "Require variable annotation if type can't be inferred", + 'General') # type: Final +OVERRIDE = ErrorCode( + 'override', "Check that method override is compatible with base class", + 'General') # type: Final +RETURN_VALUE = ErrorCode( + 'return-value', "Check that return value is compatible with signature", + 'General') # type: Final +ASSIGNMENT = ErrorCode( + 'assignment', "Check that assigned value is compatible with target", 'General') # type: Final + +SYNTAX = ErrorCode( + 'syntax', "Report syntax errors", 'General') # type: Final + +MISC = ErrorCode( + 'misc', "Miscenallenous other checks", 'General') # type: Final diff --git a/mypy/errors.py b/mypy/errors.py index 11ae673ad93b..2329483ab5cb 100644 --- a/mypy/errors.py +++ b/mypy/errors.py @@ -9,6 +9,8 @@ from mypy.scope import Scope from mypy.options import Options from mypy.version import __version__ as mypy_version +from mypy.errorcodes import ErrorCode +from mypy import errorcodes as codes T = TypeVar('T') allowed_duplicates = ['@overload', 'Got:', 'Expected:'] # type: Final @@ -45,6 +47,9 @@ class ErrorInfo: # The error message. message = '' + # The error code. + code = None # type: Optional[ErrorCode] + # If True, we should halt build after the file that generated this error. blocker = False @@ -68,6 +73,7 @@ def __init__(self, column: int, severity: str, message: str, + code: Optional[ErrorCode], blocker: bool, only_once: bool, origin: Optional[Tuple[str, int, int]] = None, @@ -81,12 +87,23 @@ def __init__(self, self.column = column self.severity = severity self.message = message + self.code = code self.blocker = blocker self.only_once = only_once self.origin = origin or (file, line, line) self.target = target +# Type used internally to represent errors: +# (path, line, column, severity, message, code) +ErrorTuple = Tuple[Optional[str], + int, + int, + str, + str, + Optional[ErrorCode]] + + class Errors: """Container for compile errors. @@ -111,8 +128,9 @@ class Errors: # Path to current file. file = '' # type: str - # Ignore errors on these lines of each file. - ignored_lines = None # type: Dict[str, Set[int]] + # Ignore some errors on these lines of each file + # (path -> line -> error-codes) + ignored_lines = None # type: Dict[str, Dict[int, List[str]]] # Lines on which an error was actually ignored. used_ignored_lines = None # type: Dict[str, Set[int]] @@ -135,10 +153,13 @@ class Errors: target_module = None # type: Optional[str] scope = None # type: Optional[Scope] - def __init__(self, show_error_context: bool = False, - show_column_numbers: bool = False) -> None: + def __init__(self, + show_error_context: bool = False, + show_column_numbers: bool = False, + show_error_codes: bool = False) -> None: self.show_error_context = show_error_context self.show_column_numbers = show_column_numbers + self.show_error_codes = show_error_codes self.initialize() def initialize(self) -> None: @@ -197,7 +218,7 @@ def set_file(self, file: str, self.scope = scope def set_file_ignored_lines(self, file: str, - ignored_lines: Set[int], + ignored_lines: Dict[int, List[str]], ignore_all: bool = False) -> None: self.ignored_lines[file] = ignored_lines if ignore_all: @@ -226,6 +247,8 @@ def report(self, line: int, column: Optional[int], message: str, + code: Optional[ErrorCode] = None, + *, blocker: bool = False, severity: str = 'error', file: Optional[str] = None, @@ -237,7 +260,9 @@ def report(self, Args: line: line number of error + column: column number of error message: message to report + code: error code (defaults to 'misc' for 'error' severity) blocker: if True, don't continue analysis after this error severity: 'error' or 'note' file: if non-None, override current file as context @@ -267,8 +292,11 @@ def report(self, if end_line is None: end_line = origin_line + if severity == 'error' and code is None: + code = codes.MISC + info = ErrorInfo(self.import_context(), file, self.current_module(), type, - function, line, column, severity, message, + function, line, column, severity, message, code, blocker, only_once, origin=(self.file, origin_line, end_line), target=self.current_target()) @@ -293,7 +321,7 @@ def add_error_info(self, info: ErrorInfo) -> None: # Check each line in this context for "type: ignore" comments. # line == end_line for most nodes, so we only loop once. for scope_line in range(line, end_line + 1): - if scope_line in self.ignored_lines[file]: + if self.is_ignored_error(scope_line, info, self.ignored_lines[file]): # Annotation requests us to ignore all errors on this line. self.used_ignored_lines[file].add(scope_line) return @@ -305,6 +333,16 @@ def add_error_info(self, info: ErrorInfo) -> None: self.only_once_messages.add(info.message) self._add_error_info(file, info) + def is_ignored_error(self, line: int, info: ErrorInfo, ignores: Dict[int, List[str]]) -> bool: + if line not in ignores: + return False + elif not ignores[line]: + # Empty list means that we ignore all errors + return True + elif info.code: + return info.code.code in ignores[line] + return False + def clear_errors_in_targets(self, path: str, targets: Set[str]) -> None: """Remove errors in specific fine-grained targets within a file.""" if path in self.error_info_map: @@ -319,11 +357,11 @@ def clear_errors_in_targets(self, path: str, targets: Set[str]) -> None: def generate_unused_ignore_errors(self, file: str) -> None: ignored_lines = self.ignored_lines[file] if not self.is_typeshed_file(file) and file not in self.ignored_files: - for line in ignored_lines - self.used_ignored_lines[file]: + for line in set(ignored_lines) - self.used_ignored_lines[file]: # Don't use report since add_error_info will ignore the error! info = ErrorInfo(self.import_context(), file, self.current_module(), None, None, line, -1, 'error', "unused 'type: ignore' comment", - False, False) + None, False, False) self._add_error_info(file, info) def is_typeshed_file(self, file: str) -> bool: @@ -373,7 +411,7 @@ def format_messages(self, error_info: List[ErrorInfo]) -> List[str]: a = [] # type: List[str] errors = self.render_messages(self.sort_messages(error_info)) errors = self.remove_duplicates(errors) - for file, line, column, severity, message in errors: + for file, line, column, severity, message, code in errors: s = '' if file is not None: if self.show_column_numbers and line >= 0 and column >= 0: @@ -385,6 +423,8 @@ def format_messages(self, error_info: List[ErrorInfo]) -> List[str]: s = '{}: {}: {}'.format(srcloc, severity, message) else: s = message + if self.show_error_codes and code: + s = '{} [{}]'.format(s, code.code) a.append(s) return a @@ -420,18 +460,16 @@ def targets(self) -> Set[str]: for info in errs if info.target) - def render_messages(self, errors: List[ErrorInfo]) -> List[Tuple[Optional[str], int, int, - str, str]]: + def render_messages(self, + errors: List[ErrorInfo]) -> List[ErrorTuple]: """Translate the messages into a sequence of tuples. - Each tuple is of form (path, line, col, severity, message). + Each tuple is of form (path, line, col, severity, message, code). The rendered sequence includes information about error contexts. The path item may be None. If the line item is negative, the line number is not defined for the tuple. """ - result = [] # type: List[Tuple[Optional[str], int, int, str, str]] - # (path, line, column, severity, message) - + result = [] # type: List[ErrorTuple] prev_import_context = [] # type: List[Tuple[str, int]] prev_function_or_member = None # type: Optional[str] prev_type = None # type: Optional[str] @@ -455,7 +493,7 @@ def render_messages(self, errors: List[ErrorInfo]) -> List[Tuple[Optional[str], # Remove prefix to ignore from path (if present) to # simplify path. path = remove_path_prefix(path, self.ignore_prefix) - result.append((None, -1, -1, 'note', fmt.format(path, line))) + result.append((None, -1, -1, 'note', fmt.format(path, line), None)) i -= 1 file = self.simplify_path(e.file) @@ -467,27 +505,27 @@ def render_messages(self, errors: List[ErrorInfo]) -> List[Tuple[Optional[str], e.type != prev_type): if e.function_or_member is None: if e.type is None: - result.append((file, -1, -1, 'note', 'At top level:')) + result.append((file, -1, -1, 'note', 'At top level:', None)) else: result.append((file, -1, -1, 'note', 'In class "{}":'.format( - e.type))) + e.type), None)) else: if e.type is None: result.append((file, -1, -1, 'note', 'In function "{}":'.format( - e.function_or_member))) + e.function_or_member), None)) else: result.append((file, -1, -1, 'note', 'In member "{}" of class "{}":'.format( - e.function_or_member, e.type))) + e.function_or_member, e.type), None)) elif e.type != prev_type: if e.type is None: - result.append((file, -1, -1, 'note', 'At top level:')) + result.append((file, -1, -1, 'note', 'At top level:', None)) else: result.append((file, -1, -1, 'note', - 'In class "{}":'.format(e.type))) + 'In class "{}":'.format(e.type), None)) - result.append((file, e.line, e.column, e.severity, e.message)) + result.append((file, e.line, e.column, e.severity, e.message, e.code)) prev_import_context = e.import_ctx prev_function_or_member = e.function_or_member @@ -518,10 +556,9 @@ def sort_messages(self, errors: List[ErrorInfo]) -> List[ErrorInfo]: result.extend(a) return result - def remove_duplicates(self, errors: List[Tuple[Optional[str], int, int, str, str]] - ) -> List[Tuple[Optional[str], int, int, str, str]]: + def remove_duplicates(self, errors: List[ErrorTuple]) -> List[ErrorTuple]: """Remove duplicates from a sorted error list.""" - res = [] # type: List[Tuple[Optional[str], int, int, str, str]] + res = [] # type: List[ErrorTuple] i = 0 while i < len(errors): dup = False diff --git a/mypy/fastparse.py b/mypy/fastparse.py index 76e849726008..cf6e374c41a6 100644 --- a/mypy/fastparse.py +++ b/mypy/fastparse.py @@ -3,7 +3,7 @@ import typing # for typing.Type, which conflicts with types.Type from typing import ( - Tuple, Union, TypeVar, Callable, Sequence, Optional, Any, Dict, cast, List, overload, Set + Tuple, Union, TypeVar, Callable, Sequence, Optional, Any, Dict, cast, List, overload ) from typing_extensions import Final, Literal @@ -33,7 +33,7 @@ TypeOfAny, Instance, RawExpressionType, ) from mypy import defaults -from mypy import message_registry +from mypy import message_registry, errorcodes as codes from mypy.errors import Errors from mypy.options import Options from mypy.reachability import mark_block_unreachable @@ -124,7 +124,7 @@ def ast3_parse(source: Union[str, bytes], filename: str, mode: str, TYPE_COMMENT_SYNTAX_ERROR = 'syntax error in type comment' # type: Final -TYPE_IGNORE_PATTERN = re.compile(r'[^#]*#\s*type:\s*ignore\s*($|#)') +TYPE_IGNORE_PATTERN = re.compile(r'[^#]*#\s*type:\s*ignore\s*(\[[^[#]*\]\s*)?($|#)') # Older versions of typing don't allow using overload outside stubs, @@ -173,8 +173,8 @@ def parse(source: Union[str, bytes], tree.path = fnam tree.is_stub = is_stub_file except SyntaxError as e: - errors.report(e.lineno, e.offset, e.msg, blocker=True) - tree = MypyFile([], [], False, set()) + errors.report(e.lineno, e.offset, e.msg, blocker=True, code=codes.SYNTAX) + tree = MypyFile([], [], False, {}) if raise_on_error and errors.is_errors(): errors.raise_error() @@ -182,12 +182,26 @@ def parse(source: Union[str, bytes], return tree +def parse_type_ignore_tag(tag: Optional[str]) -> List[str]: + # TODO: Implement proper parsing and error checking + if not tag: + return [] + m = re.match(r'\[([^#]*)\]', tag) + if m is None: + return [] + return [code.strip() for code in m.group(1).split(',')] + + def parse_type_comment(type_comment: str, line: int, column: int, errors: Optional[Errors], assume_str_is_unicode: bool = True, - ) -> Tuple[bool, Optional[Type]]: + ) -> Tuple[Optional[List[str]], Optional[Type]]: + """Parse type portion of a type comment (+ optional type ignore). + + Return (ignore info, parsed type). + """ try: typ = ast3_parse(type_comment, '', 'eval') except SyntaxError as e: @@ -195,17 +209,23 @@ def parse_type_comment(type_comment: str, stripped_type = type_comment.split("#", 2)[0].strip() err_msg = "{} '{}'".format(TYPE_COMMENT_SYNTAX_ERROR, stripped_type) errors.report(line, e.offset, err_msg, blocker=True) - return False, None + return None, None else: raise else: - extra_ignore = TYPE_IGNORE_PATTERN.match(type_comment) is not None + extra_ignore = TYPE_IGNORE_PATTERN.match(type_comment) + if extra_ignore: + # Typeshed has a non-optional return type for group! + tag = cast(Any, extra_ignore).group(1) # type: Optional[str] + ignored = parse_type_ignore_tag(tag) # type: Optional[List[str]] + else: + ignored = None assert isinstance(typ, ast3_Expression) converted = TypeConverter(errors, line=line, override_column=column, assume_str_is_unicode=assume_str_is_unicode).visit(typ.body) - return extra_ignore, converted + return ignored, converted def parse_type_string(expr_string: str, expr_fallback_name: str, @@ -261,7 +281,7 @@ def __init__(self, self.is_stub = is_stub self.errors = errors - self.type_ignores = set() # type: Set[int] + self.type_ignores = {} # type: Dict[int, List[str]] # Cache of visit_X methods keyed by type of visited object self.visitor_cache = {} # type: Dict[type, Callable[[Optional[AST]], Any]] @@ -269,9 +289,13 @@ def __init__(self, def note(self, msg: str, line: int, column: int) -> None: self.errors.report(line, column, msg, severity='note') - def fail(self, msg: str, line: int, column: int, blocker: bool = True) -> None: + def fail(self, + msg: str, + line: int, + column: int, + blocker: bool = True) -> None: if blocker or not self.options.ignore_errors: - self.errors.report(line, column, msg, blocker=blocker) + self.errors.report(line, column, msg, blocker=blocker, code=codes.SYNTAX) def visit(self, node: Optional[AST]) -> Any: if node is None: @@ -322,6 +346,21 @@ def translate_stmt_list(self, return res + def translate_type_comment(self, + n: Union[ast3.stmt, ast3.arg], + type_comment: Optional[str]) -> Optional[Type]: + if type_comment is None: + return None + else: + lineno = n.lineno + extra_ignore, typ = parse_type_comment(type_comment, + lineno, + n.col_offset, + self.errors) + if extra_ignore is not None: + self.type_ignores[lineno] = extra_ignore + return typ + op_map = { ast3.Add: '+', ast3.Sub: '-', @@ -424,7 +463,8 @@ def translate_module_id(self, id: str) -> str: return id def visit_Module(self, mod: ast3.Module) -> MypyFile: - self.type_ignores = {ti.lineno for ti in mod.type_ignores} + self.type_ignores = {ti.lineno: parse_type_ignore_tag(ti.tag) # type: ignore + for ti in mod.type_ignores} body = self.fix_function_overloads(self.translate_stmt_list(mod.body, ismodule=True)) return MypyFile(body, self.imports, @@ -639,12 +679,8 @@ def make_argument(self, arg: ast3.arg, default: Optional[ast3.expr], kind: int, arg_type = None if annotation is not None: arg_type = TypeConverter(self.errors, line=arg.lineno).visit(annotation) - elif type_comment is not None: - extra_ignore, arg_type = parse_type_comment(type_comment, arg.lineno, - arg.col_offset, self.errors) - if extra_ignore: - self.type_ignores.add(arg.lineno) - + else: + arg_type = self.translate_type_comment(arg, type_comment) return Argument(Var(arg.arg), arg_type, self.visit(default), kind) def fail_arg(self, msg: str, arg: ast3.arg) -> None: @@ -698,13 +734,7 @@ def visit_Delete(self, n: ast3.Delete) -> DelStmt: def visit_Assign(self, n: ast3.Assign) -> AssignmentStmt: lvalues = self.translate_expr_list(n.targets) rvalue = self.visit(n.value) - if n.type_comment is not None: - extra_ignore, typ = parse_type_comment(n.type_comment, n.lineno, n.col_offset, - self.errors) - if extra_ignore: - self.type_ignores.add(n.lineno) - else: - typ = None + typ = self.translate_type_comment(n, n.type_comment) s = AssignmentStmt(lvalues, rvalue, type=typ, new_syntax=False) return self.set_line(s, n) @@ -733,13 +763,7 @@ def visit_NamedExpr(self, n: NamedExpr) -> None: # For(expr target, expr iter, stmt* body, stmt* orelse, string? type_comment) def visit_For(self, n: ast3.For) -> ForStmt: - if n.type_comment is not None: - extra_ignore, target_type = parse_type_comment(n.type_comment, n.lineno, n.col_offset, - self.errors) - if extra_ignore: - self.type_ignores.add(n.lineno) - else: - target_type = None + target_type = self.translate_type_comment(n, n.type_comment) node = ForStmt(self.visit(n.target), self.visit(n.iter), self.as_required_block(n.body, n.lineno), @@ -749,13 +773,7 @@ def visit_For(self, n: ast3.For) -> ForStmt: # AsyncFor(expr target, expr iter, stmt* body, stmt* orelse, string? type_comment) def visit_AsyncFor(self, n: ast3.AsyncFor) -> ForStmt: - if n.type_comment is not None: - extra_ignore, target_type = parse_type_comment(n.type_comment, n.lineno, n.col_offset, - self.errors) - if extra_ignore: - self.type_ignores.add(n.lineno) - else: - target_type = None + target_type = self.translate_type_comment(n, n.type_comment) node = ForStmt(self.visit(n.target), self.visit(n.iter), self.as_required_block(n.body, n.lineno), @@ -781,13 +799,7 @@ def visit_If(self, n: ast3.If) -> IfStmt: # With(withitem* items, stmt* body, string? type_comment) def visit_With(self, n: ast3.With) -> WithStmt: - if n.type_comment is not None: - extra_ignore, target_type = parse_type_comment(n.type_comment, n.lineno, - n.col_offset, self.errors) - if extra_ignore: - self.type_ignores.add(n.lineno) - else: - target_type = None + target_type = self.translate_type_comment(n, n.type_comment) node = WithStmt([self.visit(i.context_expr) for i in n.items], [self.visit(i.optional_vars) for i in n.items], self.as_required_block(n.body, n.lineno), @@ -796,13 +808,7 @@ def visit_With(self, n: ast3.With) -> WithStmt: # AsyncWith(withitem* items, stmt* body, string? type_comment) def visit_AsyncWith(self, n: ast3.AsyncWith) -> WithStmt: - if n.type_comment is not None: - extra_ignore, target_type = parse_type_comment(n.type_comment, n.lineno, - n.col_offset, self.errors) - if extra_ignore: - self.type_ignores.add(n.lineno) - else: - target_type = None + target_type = self.translate_type_comment(n, n.type_comment) s = WithStmt([self.visit(i.context_expr) for i in n.items], [self.visit(i.optional_vars) for i in n.items], self.as_required_block(n.body, n.lineno), @@ -1289,7 +1295,7 @@ def parent(self) -> Optional[AST]: def fail(self, msg: str, line: int, column: int) -> None: if self.errors: - self.errors.report(line, column, msg, blocker=True) + self.errors.report(line, column, msg, blocker=True, code=codes.SYNTAX) def note(self, msg: str, line: int, column: int) -> None: if self.errors: diff --git a/mypy/fastparse2.py b/mypy/fastparse2.py index 3fefbf9661f1..1545fbde10e6 100644 --- a/mypy/fastparse2.py +++ b/mypy/fastparse2.py @@ -17,7 +17,7 @@ import sys import typing # for typing.Type, which conflicts with types.Type -from typing import Tuple, Union, TypeVar, Callable, Sequence, Optional, Any, Dict, cast, List, Set +from typing import Tuple, Union, TypeVar, Callable, Sequence, Optional, Any, Dict, cast, List from typing_extensions import Final, Literal from mypy.sharedparse import ( @@ -41,9 +41,12 @@ from mypy.types import ( Type, CallableType, AnyType, UnboundType, EllipsisType, TypeOfAny, Instance, ) -from mypy import message_registry +from mypy import message_registry, errorcodes as codes from mypy.errors import Errors -from mypy.fastparse import TypeConverter, parse_type_comment, bytes_to_human_readable_repr +from mypy.fastparse import ( + TypeConverter, parse_type_comment, bytes_to_human_readable_repr, parse_type_ignore_tag, + TYPE_IGNORE_PATTERN +) from mypy.options import Options from mypy.reachability import mark_block_unreachable @@ -111,8 +114,8 @@ def parse(source: Union[str, bytes], tree.path = fnam tree.is_stub = is_stub_file except SyntaxError as e: - errors.report(e.lineno, e.offset, e.msg, blocker=True) - tree = MypyFile([], [], False, set()) + errors.report(e.lineno, e.offset, e.msg, blocker=True, code=codes.SYNTAX) + tree = MypyFile([], [], False, {}) if raise_on_error and errors.is_errors(): errors.raise_error() @@ -162,11 +165,11 @@ def __init__(self, # Cache of visit_X methods keyed by type of visited object self.visitor_cache = {} # type: Dict[type, Callable[[Optional[AST]], Any]] - self.type_ignores = set() # type: Set[int] + self.type_ignores = {} # type: Dict[int, List[str]] def fail(self, msg: str, line: int, column: int, blocker: bool = True) -> None: if blocker or not self.options.ignore_errors: - self.errors.report(line, column, msg, blocker=blocker) + self.errors.report(line, column, msg, blocker=blocker, code=codes.SYNTAX) def visit(self, node: Optional[AST]) -> Any: # same as in typed_ast stub if node is None: @@ -216,6 +219,20 @@ def translate_stmt_list(self, res.append(node) return res + def translate_type_comment(self, n: ast27.stmt, type_comment: Optional[str]) -> Optional[Type]: + if type_comment is None: + return None + else: + lineno = n.lineno + extra_ignore, typ = parse_type_comment(type_comment, + lineno, + n.col_offset, + self.errors, + assume_str_is_unicode=self.unicode_literals) + if extra_ignore is not None: + self.type_ignores[lineno] = extra_ignore + return typ + op_map = { ast27.Add: '+', ast27.Sub: '-', @@ -319,7 +336,8 @@ def translate_module_id(self, id: str) -> str: return id def visit_Module(self, mod: ast27.Module) -> MypyFile: - self.type_ignores = {ti.lineno for ti in mod.type_ignores} + self.type_ignores = {ti.lineno: parse_type_ignore_tag(ti.tag) # type: ignore + for ti in mod.type_ignores} body = self.fix_function_overloads(self.translate_stmt_list(mod.body)) return MypyFile(body, self.imports, @@ -516,12 +534,20 @@ def convert_arg(self, index: int, arg: ast27.expr, line: int, raise RuntimeError("'{}' is not a valid argument.".format(ast27.dump(arg))) return Var(v) - def get_type(self, i: int, type_comments: Sequence[Optional[str]], + def get_type(self, + i: int, + type_comments: Sequence[Optional[str]], converter: TypeConverter) -> Optional[Type]: if i < len(type_comments): comment = type_comments[i] if comment is not None: - return converter.visit_raw_str(comment) + typ = converter.visit_raw_str(comment) + extra_ignore = TYPE_IGNORE_PATTERN.match(comment) + if extra_ignore: + tag = cast(Any, extra_ignore).group(1) # type: Optional[str] + ignored = parse_type_ignore_tag(tag) + self.type_ignores[converter.line] = ignored + return typ return None def stringify_name(self, n: AST) -> str: @@ -569,16 +595,7 @@ def visit_Delete(self, n: ast27.Delete) -> DelStmt: # Assign(expr* targets, expr value, string? type_comment) def visit_Assign(self, n: ast27.Assign) -> AssignmentStmt: - typ = None - if n.type_comment: - extra_ignore, typ = parse_type_comment(n.type_comment, - n.lineno, - n.col_offset, - self.errors, - assume_str_is_unicode=self.unicode_literals) - if extra_ignore: - self.type_ignores.add(n.lineno) - + typ = self.translate_type_comment(n, n.type_comment) stmt = AssignmentStmt(self.translate_expr_list(n.targets), self.visit(n.value), type=typ) @@ -593,16 +610,7 @@ def visit_AugAssign(self, n: ast27.AugAssign) -> OperatorAssignmentStmt: # For(expr target, expr iter, stmt* body, stmt* orelse, string? type_comment) def visit_For(self, n: ast27.For) -> ForStmt: - if n.type_comment is not None: - extra_ignore, typ = parse_type_comment(n.type_comment, - n.lineno, - n.col_offset, - self.errors, - assume_str_is_unicode=self.unicode_literals) - if extra_ignore: - self.type_ignores.add(n.lineno) - else: - typ = None + typ = self.translate_type_comment(n, n.type_comment) stmt = ForStmt(self.visit(n.target), self.visit(n.iter), self.as_required_block(n.body, n.lineno), @@ -626,16 +634,7 @@ def visit_If(self, n: ast27.If) -> IfStmt: # With(withitem* items, stmt* body, string? type_comment) def visit_With(self, n: ast27.With) -> WithStmt: - if n.type_comment is not None: - extra_ignore, typ = parse_type_comment(n.type_comment, - n.lineno, - n.col_offset, - self.errors, - assume_str_is_unicode=self.unicode_literals) - if extra_ignore: - self.type_ignores.add(n.lineno) - else: - typ = None + typ = self.translate_type_comment(n, n.type_comment) stmt = WithStmt([self.visit(n.context_expr)], [self.visit(n.optional_vars)], self.as_required_block(n.body, n.lineno), diff --git a/mypy/main.py b/mypy/main.py index 188e8c2fcb9f..63c3c6e449b1 100644 --- a/mypy/main.py +++ b/mypy/main.py @@ -568,6 +568,9 @@ def add_invertible_flag(flag: str, add_invertible_flag('--show-column-numbers', default=False, help="Show column numbers in error messages", group=error_group) + add_invertible_flag('--show-error-codes', default=False, + help="Show error codes in error messages", + group=error_group) strict_help = "Strict mode; enables the following flags: {}".format( ", ".join(strict_flag_names)) diff --git a/mypy/messages.py b/mypy/messages.py index c77b6b802165..85c65662d233 100644 --- a/mypy/messages.py +++ b/mypy/messages.py @@ -32,7 +32,9 @@ CallExpr ) from mypy.util import unmangle -from mypy import message_registry +from mypy.errorcodes import ErrorCode +from mypy import message_registry, errorcodes as codes + ARG_CONSTRUCTOR_NAMES = { ARG_POS: "Arg", @@ -104,8 +106,14 @@ def enable_errors(self) -> None: def is_errors(self) -> bool: return self.errors.is_errors() - def report(self, msg: str, context: Optional[Context], severity: str, - file: Optional[str] = None, origin: Optional[Context] = None, + def report(self, + msg: str, + context: Optional[Context], + severity: str, + *, + code: Optional[ErrorCode] = None, + file: Optional[str] = None, + origin: Optional[Context] = None, offset: int = 0) -> None: """Report an error or note (unless disabled).""" if origin is not None: @@ -119,12 +127,18 @@ def report(self, msg: str, context: Optional[Context], severity: str, context.get_column() if context else -1, msg, severity=severity, file=file, offset=offset, origin_line=origin.get_line() if origin else None, - end_line=end_line) - - def fail(self, msg: str, context: Optional[Context], file: Optional[str] = None, + end_line=end_line, + code=code) + + def fail(self, + msg: str, + context: Optional[Context], + *, + code: Optional[ErrorCode] = None, + file: Optional[str] = None, origin: Optional[Context] = None) -> None: """Report an error message (unless disabled).""" - self.report(msg, context, 'error', file=file, origin=origin) + self.report(msg, context, 'error', code=code, file=file, origin=origin) def note(self, msg: str, context: Context, file: Optional[str] = None, origin: Optional[Context] = None, offset: int = 0) -> None: @@ -220,13 +234,18 @@ def has_no_attr(self, original_type: Type, typ: Type, member: str, context: Cont # TODO: Handle differences in division between Python 2 and 3 more cleanly matches = [] if matches: - self.fail('{} has no attribute "{}"; maybe {}?{}'.format( - format_type(original_type), member, pretty_or(matches), extra), - context) + self.fail( + '{} has no attribute "{}"; maybe {}?{}'.format( + format_type(original_type), member, pretty_or(matches), extra), + context, + code=codes.ATTR_DEFINED) failed = True if not failed: - self.fail('{} has no attribute "{}"{}'.format(format_type(original_type), - member, extra), context) + self.fail( + '{} has no attribute "{}"{}'.format( + format_type(original_type), member, extra), + context, + code=codes.ATTR_DEFINED) elif isinstance(original_type, UnionType): # The checker passes "object" in lieu of "None" for attribute # checks, so we manually convert it back. diff --git a/mypy/newsemanal/semanal.py b/mypy/newsemanal/semanal.py index 708561aa100d..78aeedd4863f 100644 --- a/mypy/newsemanal/semanal.py +++ b/mypy/newsemanal/semanal.py @@ -81,7 +81,8 @@ from mypy.visitor import NodeVisitor from mypy.errors import Errors, report_internal_error from mypy.messages import best_matches, MessageBuilder, pretty_or -from mypy import message_registry +from mypy.errorcodes import ErrorCode +from mypy import message_registry, errorcodes as codes from mypy.types import ( FunctionLike, UnboundType, TypeVarDef, TupleType, UnionType, StarType, function_type, CallableType, Overloaded, Instance, Type, AnyType, LiteralType, LiteralValue, @@ -3163,12 +3164,12 @@ def visit_for_stmt(self, s: ForStmt) -> None: def visit_break_stmt(self, s: BreakStmt) -> None: self.statement = s if self.loop_depth == 0: - self.fail("'break' outside loop", s, True, blocker=True) + self.fail("'break' outside loop", s, serious=True, blocker=True) def visit_continue_stmt(self, s: ContinueStmt) -> None: self.statement = s if self.loop_depth == 0: - self.fail("'continue' outside loop", s, True, blocker=True) + self.fail("'continue' outside loop", s, serious=True, blocker=True) def visit_if_stmt(self, s: IfStmt) -> None: self.statement = s @@ -3358,10 +3359,10 @@ def visit_star_expr(self, expr: StarExpr) -> None: def visit_yield_from_expr(self, e: YieldFromExpr) -> None: if not self.is_func_scope(): # not sure - self.fail("'yield from' outside function", e, True, blocker=True) + self.fail("'yield from' outside function", e, serious=True, blocker=True) else: if self.function_stack[-1].is_coroutine: - self.fail("'yield from' in async function", e, True, blocker=True) + self.fail("'yield from' in async function", e, serious=True, blocker=True) else: self.function_stack[-1].is_generator = True if e.expr: @@ -3738,11 +3739,11 @@ def visit__promote_expr(self, expr: PromoteExpr) -> None: def visit_yield_expr(self, expr: YieldExpr) -> None: if not self.is_func_scope(): - self.fail("'yield' outside function", expr, True, blocker=True) + self.fail("'yield' outside function", expr, serious=True, blocker=True) else: if self.function_stack[-1].is_coroutine: if self.options.python_version < (3, 6): - self.fail("'yield' in async function", expr, True, blocker=True) + self.fail("'yield' in async function", expr, serious=True, blocker=True) else: self.function_stack[-1].is_generator = True self.function_stack[-1].is_async_generator = True @@ -4446,7 +4447,7 @@ def name_not_defined(self, name: str, ctx: Context, namespace: Optional[str] = N self.record_incomplete_ref() return message = "Name '{}' is not defined".format(name) - self.fail(message, ctx) + self.fail(message, ctx, code=codes.NAME_DEFINED) if 'builtins.{}'.format(name) in SUGGESTED_TEST_FIXTURES: # The user probably has a missing definition in a test fixture. Let's verify. @@ -4520,7 +4521,9 @@ def is_local_name(self, name: str) -> bool: def fail(self, msg: str, ctx: Context, - serious: bool = False, *, + serious: bool = False, + *, + code: Optional[ErrorCode] = None, blocker: bool = False) -> None: if (not serious and not self.options.check_untyped_defs and @@ -4529,7 +4532,7 @@ def fail(self, return # In case it's a bug and we don't really have context assert ctx is not None, msg - self.errors.report(ctx.get_line(), ctx.get_column(), msg, blocker=blocker) + self.errors.report(ctx.get_line(), ctx.get_column(), msg, blocker=blocker, code=code) def fail_blocker(self, msg: str, ctx: Context) -> None: self.fail(msg, ctx, blocker=True) diff --git a/mypy/nodes.py b/mypy/nodes.py index a42fae436b59..0934ff520f3e 100644 --- a/mypy/nodes.py +++ b/mypy/nodes.py @@ -243,8 +243,10 @@ class MypyFile(SymbolNode): names = None # type: SymbolTable # All import nodes within the file (also ones within functions etc.) imports = None # type: List[ImportBase] - # Lines to ignore when checking - ignored_lines = None # type: Set[int] + # Lines on which to ignore certain errors when checking. + # If the value is empty, ignore all errors; otherwise, the list contains all + # error codes to ignore. + ignored_lines = None # type: Dict[int, List[str]] # Is this file represented by a stub file (.pyi)? is_stub = False # Is this loaded from the cache and thus missing the actual body of the file? @@ -260,7 +262,7 @@ def __init__(self, defs: List[Statement], imports: List['ImportBase'], is_bom: bool = False, - ignored_lines: Optional[Set[int]] = None) -> None: + ignored_lines: Optional[Dict[int, List[str]]] = None) -> None: super().__init__() self.defs = defs self.line = 1 # Dummy line number @@ -271,7 +273,7 @@ def __init__(self, if ignored_lines: self.ignored_lines = ignored_lines else: - self.ignored_lines = set() + self.ignored_lines = {} def local_definitions(self) -> Iterator[Definition]: """Return all definitions within the module (including nested). diff --git a/mypy/options.py b/mypy/options.py index 2c43605d49aa..ce04d9c6e106 100644 --- a/mypy/options.py +++ b/mypy/options.py @@ -248,6 +248,7 @@ def __init__(self) -> None: # -- experimental options -- self.shadow_file = None # type: Optional[List[List[str]]] self.show_column_numbers = False # type: bool + self.show_error_codes = False self.dump_graph = False self.dump_deps = False self.logical_deps = False diff --git a/mypy/test/testcheck.py b/mypy/test/testcheck.py index f3ed3448d8b1..0445a12178b4 100644 --- a/mypy/test/testcheck.py +++ b/mypy/test/testcheck.py @@ -86,6 +86,7 @@ 'check-newsemanal.test', 'check-inline-config.test', 'check-reports.test', + 'check-errorcodes.test', 'check-annotated.test', ] @@ -167,6 +168,9 @@ def run_case_once(self, testcase: DataDrivenTestCase, options.new_semantic_analyzer = True if 'columns' in testcase.file: options.show_column_numbers = True + if 'errorcodes' in testcase.file: + options.show_error_codes = True + options.new_semantic_analyzer = True if incremental_step and options.incremental: # Don't overwrite # flags: --no-incremental in incremental test cases diff --git a/mypy/treetransform.py b/mypy/treetransform.py index d84eb681cb0e..2ab1e8789330 100644 --- a/mypy/treetransform.py +++ b/mypy/treetransform.py @@ -59,8 +59,10 @@ def __init__(self) -> None: def visit_mypy_file(self, node: MypyFile) -> MypyFile: # NOTE: The 'names' and 'imports' instance variables will be empty! + ignored_lines = {line: codes[:] + for line, codes in node.ignored_lines.items()} new = MypyFile(self.statements(node.defs), [], node.is_bom, - ignored_lines=set(node.ignored_lines)) + ignored_lines=ignored_lines) new._fullname = node._fullname new.path = node.path new.names = SymbolTable() diff --git a/test-data/unit/check-errorcodes.test b/test-data/unit/check-errorcodes.test new file mode 100644 index 000000000000..0893808345bb --- /dev/null +++ b/test-data/unit/check-errorcodes.test @@ -0,0 +1,112 @@ +-- Tests for error codes +-- +-- These implicitly use --show-error-codes. + +[case testErrorCodeNoAttribute] +import m +m.x # E: Module has no attribute "x" [attr-defined] +'x'.foobar # E: "str" has no attribute "foobar" [attr-defined] +[file m.py] +[builtins fixtures/module.pyi] + +[case testErrorCodeUndefinedName] +x # E: Name 'x' is not defined [name-defined] +def f() -> None: + y # E: Name 'y' is not defined [name-defined] +[file m.py] +[builtins fixtures/module.pyi] + +[case testErrorCodeUnclassifiedError] +class A: + def __init__(self) -> int: \ + # E: The return type of "__init__" must be None [misc] + pass + +[case testErrorCodeNoteHasNoCode] +reveal_type(1) # N: Revealed type is 'builtins.int' + +[case testErrorCodeSyntaxError] +1 '' # E: invalid syntax [syntax] + +[case testErrorCodeSyntaxError2] +def f(): # E: Type signature has too many arguments [syntax] + # type: (int) -> None + 1 + +[case testErrorCodeSyntaxError_python2] +1 '' # E: invalid syntax [syntax] + +[case testErrorCodeSyntaxError2_python2] +def f(): # E: Type signature has too many arguments [syntax] + # type: (int) -> None + 1 + +[case testErrorCodeIgnore1] +'x'.foobar # type: ignore[attr-defined] +'x'.foobar # type: ignore[xyz] # E: "str" has no attribute "foobar" [attr-defined] +'x'.foobar # type: ignore + +[case testErrorCodeIgnore2] +a = 'x'.foobar # type: int # type: ignore[attr-defined] +b = 'x'.foobar # type: int # type: ignore[xyz] # E: "str" has no attribute "foobar" [attr-defined] +c = 'x'.foobar # type: int # type: ignore + +[case testErrorCodeIgnore1_python2] +'x'.foobar # type: ignore[attr-defined] +'x'.foobar # type: ignore[xyz] # E: "str" has no attribute "foobar" [attr-defined] +'x'.foobar # type: ignore + +[case testErrorCodeIgnore2_python2] +a = 'x'.foobar # type: int # type: ignore[attr-defined] +b = 'x'.foobar # type: int # type: ignore[xyz] # E: "str" has no attribute "foobar" [attr-defined] +c = 'x'.foobar # type: int # type: ignore + +[case testErrorCodeIgnoreMultiple1] +a = 'x'.foobar(b) # type: ignore[name-defined, attr-defined] +a = 'x'.foobar(b) # type: ignore[name-defined, xyz] # E: "str" has no attribute "foobar" [attr-defined] +a = 'x'.foobar(b) # type: ignore[xyz, w, attr-defined] # E: Name 'b' is not defined [name-defined] + +[case testErrorCodeIgnoreMultiple2] +a = 'x'.foobar(b) # type: int # type: ignore[name-defined, attr-defined] +b = 'x'.foobar(b) # type: int # type: ignore[name-defined, xyz] # E: "str" has no attribute "foobar" [attr-defined] + +[case testErrorCodeIgnoreMultiple1_python2] +a = 'x'.foobar(b) # type: ignore[name-defined, attr-defined] +a = 'x'.foobar(b) # type: ignore[name-defined, xyz] # E: "str" has no attribute "foobar" [attr-defined] +a = 'x'.foobar(b) # type: ignore[xyz, w, attr-defined] # E: Name 'b' is not defined [name-defined] + +[case testErrorCodeIgnoreMultiple2_python2] +a = 'x'.foobar(b) # type: int # type: ignore[name-defined, attr-defined] +b = 'x'.foobar(b) # type: int # type: ignore[name-defined, xyz] # E: "str" has no attribute "foobar" [attr-defined] + +[case testErrorCodeIgnoreAfterArgComment] +def f(x # type: xyz # type: ignore[name-defined] # Comment + ): + # type () -> None + pass + +def g(x # type: xyz # type: ignore # Comment + ): + # type () -> None + pass + +def h(x # type: xyz # type: ignore[foo] # E: Name 'xyz' is not defined [name-defined] + ): + # type () -> None + pass + +[case testErrorCodeIgnoreAfterArgComment_python2] +def f(x # type: xyz # type: ignore[name-defined] # Comment + ): + # type () -> None + pass + +def g(x # type: xyz # type: ignore # Comment + ): + # type () -> None + pass + +def h(x # type: xyz # type: ignore[foo] # E: Name 'xyz' is not defined [name-defined] + ): + # type () -> None + pass