diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 486b0cb..08f54ea 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -13,7 +13,10 @@ jobs: include: # linux - os: ubuntu-latest - python: pypy-3.11 + python: pypy-3.9 + toxenv: py + - os: ubuntu-latest + python: 3.9 toxenv: py - os: ubuntu-latest python: '3.10' @@ -27,12 +30,9 @@ jobs: - os: ubuntu-latest python: '3.13' toxenv: py - - os: ubuntu-latest - python: '3.14' - toxenv: py # windows - os: windows-latest - python: '3.10' + python: 3.9 toxenv: py # misc - os: ubuntu-latest @@ -46,8 +46,8 @@ jobs: toxenv: dogfood runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 with: python-version: ${{ matrix.python }} - run: python -mpip install --upgrade setuptools pip tox virtualenv diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f75e5ee..9df4a79 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,10 +1,6 @@ repos: -- repo: https://github.com/asottile/add-trailing-comma - rev: v4.0.0 - hooks: - - id: add-trailing-comma - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v6.0.0 + rev: v4.5.0 hooks: - id: check-yaml - id: debug-statements @@ -12,33 +8,34 @@ repos: - id: trailing-whitespace exclude: ^tests/fixtures/ - repo: https://github.com/asottile/setup-cfg-fmt - rev: v3.2.0 + rev: v2.5.0 hooks: - id: setup-cfg-fmt - repo: https://github.com/asottile/reorder-python-imports - rev: v3.16.0 + rev: v3.14.0 hooks: - id: reorder-python-imports args: [ --application-directories, '.:src', - --py310-plus, + --py39-plus, --add-import, 'from __future__ import annotations', ] - repo: https://github.com/asottile/pyupgrade - rev: v3.21.2 + rev: v3.19.1 hooks: - id: pyupgrade - args: [--py310-plus] -- repo: https://github.com/hhatto/autopep8 - rev: v2.3.2 + args: [--py39-plus] +- repo: https://github.com/psf/black + rev: 23.12.1 hooks: - - id: autopep8 + - id: black + args: [--line-length=79] - repo: https://github.com/PyCQA/flake8 - rev: 7.3.0 + rev: 7.0.0 hooks: - id: flake8 - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.19.1 + rev: v1.15.0 hooks: - id: mypy exclude: ^(docs/|example-plugin/) diff --git a/bin/gen-pycodestyle-plugin b/bin/gen-pycodestyle-plugin index 7fc504a..c93fbfe 100755 --- a/bin/gen-pycodestyle-plugin +++ b/bin/gen-pycodestyle-plugin @@ -3,9 +3,9 @@ from __future__ import annotations import inspect import os.path -from collections.abc import Callable from collections.abc import Generator from typing import Any +from typing import Callable from typing import NamedTuple import pycodestyle diff --git a/setup.cfg b/setup.cfg index c0b8137..a6b5a5e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -16,6 +16,7 @@ classifiers = Environment :: Console Framework :: Flake8 Intended Audience :: Developers + License :: OSI Approved :: MIT License Programming Language :: Python Programming Language :: Python :: 3 Programming Language :: Python :: 3 :: Only @@ -30,7 +31,7 @@ install_requires = mccabe>=0.7.0,<0.8.0 pycodestyle>=2.14.0,<2.15.0 pyflakes>=3.4.0,<3.5.0 -python_requires = >=3.10 +python_requires = >=3.9 package_dir = =src diff --git a/src/flake8/__init__.py b/src/flake8/__init__.py index 0dea638..db29166 100644 --- a/src/flake8/__init__.py +++ b/src/flake8/__init__.py @@ -66,5 +66,5 @@ def configure_logging( LOG.addHandler(handler) LOG.setLevel(log_level) LOG.debug( - "Added a %s logging handler to logger root at %s", filename, __name__, + "Added a %s logging handler to logger root at %s", filename, __name__ ) diff --git a/src/flake8/api/legacy.py b/src/flake8/api/legacy.py index 4d5c91d..446df29 100644 --- a/src/flake8/api/legacy.py +++ b/src/flake8/api/legacy.py @@ -135,7 +135,7 @@ class StyleGuide: stdin_display_name=self.options.stdin_display_name, filename_patterns=self.options.filename, exclude=self.options.exclude, - ), + ) ) return not paths @@ -153,7 +153,7 @@ class StyleGuide: if not issubclass(reporter, formatter.BaseFormatter): raise ValueError( "Report should be subclass of " - "flake8.formatter.BaseFormatter.", + "flake8.formatter.BaseFormatter." ) self._application.formatter = reporter(self.options) self._application.guide = None diff --git a/src/flake8/checker.py b/src/flake8/checker.py index c6a24eb..84d45aa 100644 --- a/src/flake8/checker.py +++ b/src/flake8/checker.py @@ -45,39 +45,40 @@ SERIAL_RETRY_ERRNOS = { # noise in diffs. } -_mp: tuple[Checkers, argparse.Namespace] | None = None +_mp_plugins: Checkers +_mp_options: argparse.Namespace @contextlib.contextmanager def _mp_prefork( - plugins: Checkers, options: argparse.Namespace, + plugins: Checkers, options: argparse.Namespace ) -> Generator[None]: # we can save significant startup work w/ `fork` multiprocessing - global _mp - _mp = plugins, options + global _mp_plugins, _mp_options + _mp_plugins, _mp_options = plugins, options try: yield finally: - _mp = None + del _mp_plugins, _mp_options def _mp_init(argv: Sequence[str]) -> None: - global _mp + global _mp_plugins, _mp_options # Ensure correct signaling of ^C using multiprocessing.Pool. signal.signal(signal.SIGINT, signal.SIG_IGN) - # for `fork` this'll already be set - if _mp is None: + try: + # for `fork` this'll already be set + _mp_plugins, _mp_options # noqa: B018 + except NameError: plugins, options = parse_args(argv) - _mp = plugins.checkers, options + _mp_plugins, _mp_options = plugins.checkers, options def _mp_run(filename: str) -> tuple[str, Results, dict[str, int]]: - assert _mp is not None, _mp - plugins, options = _mp return FileChecker( - filename=filename, plugins=plugins, options=options, + filename=filename, plugins=_mp_plugins, options=_mp_options ).run_checks() @@ -137,7 +138,7 @@ class Manager: if utils.is_using_stdin(self.options.filenames): LOG.warning( "The --jobs option is not compatible with supplying " - "input using - . Ignoring --jobs arguments.", + "input using - . Ignoring --jobs arguments." ) return 0 @@ -252,7 +253,7 @@ class Manager: stdin_display_name=self.options.stdin_display_name, filename_patterns=self.options.filename, exclude=self.exclude, - ), + ) ) self.jobs = min(len(self.filenames), self.jobs) @@ -332,11 +333,11 @@ class FileChecker: assert self.processor is not None, self.filename try: params = self.processor.keyword_arguments_for( - plugin.parameters, arguments, + plugin.parameters, arguments ) except AttributeError as ae: raise exceptions.PluginRequestedUnknownParameters( - plugin_name=plugin.display_name, exception=ae, + plugin_name=plugin.display_name, exception=ae ) try: return plugin.obj(**arguments, **params) @@ -372,6 +373,43 @@ class FileChecker: token = () row, column = (1, 0) + if ( + column > 0 + and token + and isinstance(exception, SyntaxError) + and len(token) == 4 # Python 3.9 or earlier + ): + # NOTE(sigmavirus24): SyntaxErrors report 1-indexed column + # numbers. We need to decrement the column number by 1 at + # least. + column_offset = 1 + row_offset = 0 + # See also: https://github.com/pycqa/flake8/issues/169, + # https://github.com/PyCQA/flake8/issues/1372 + # On Python 3.9 and earlier, token will be a 4-item tuple with the + # last item being the string. Starting with 3.10, they added to + # the tuple so now instead of it ending with the code that failed + # to parse, it ends with the end of the section of code that + # failed to parse. Luckily the absolute position in the tuple is + # stable across versions so we can use that here + physical_line = token[3] + + # NOTE(sigmavirus24): Not all "tokens" have a string as the last + # argument. In this event, let's skip trying to find the correct + # column and row values. + if physical_line is not None: + # NOTE(sigmavirus24): SyntaxErrors also don't exactly have a + # "physical" line so much as what was accumulated by the point + # tokenizing failed. + # See also: https://github.com/pycqa/flake8/issues/169 + lines = physical_line.rstrip("\n").split("\n") + row_offset = len(lines) - 1 + logical_line = lines[0] + logical_line_length = len(logical_line) + if column > logical_line_length: + column = logical_line_length + row -= row_offset + column -= column_offset return row, column def run_ast_checks(self) -> None: @@ -511,7 +549,7 @@ class FileChecker: self.run_logical_checks() def check_physical_eol( - self, token: tokenize.TokenInfo, prev_physical: str, + self, token: tokenize.TokenInfo, prev_physical: str ) -> None: """Run physical checks if and only if it is at the end of the line.""" assert self.processor is not None @@ -561,7 +599,7 @@ def _try_initialize_processpool( def find_offset( - offset: int, mapping: processor._LogicalMapping, + offset: int, mapping: processor._LogicalMapping ) -> tuple[int, int]: """Find the offset tuple for a single offset.""" if isinstance(offset, tuple): diff --git a/src/flake8/discover_files.py b/src/flake8/discover_files.py index 40b6e5c..da28ba5 100644 --- a/src/flake8/discover_files.py +++ b/src/flake8/discover_files.py @@ -3,9 +3,9 @@ from __future__ import annotations import logging import os.path -from collections.abc import Callable from collections.abc import Generator from collections.abc import Sequence +from typing import Callable from flake8 import utils diff --git a/src/flake8/formatting/base.py b/src/flake8/formatting/base.py index bbbfdff..d986d65 100644 --- a/src/flake8/formatting/base.py +++ b/src/flake8/formatting/base.py @@ -110,7 +110,7 @@ class BaseFormatter: The formatted error string. """ raise NotImplementedError( - "Subclass of BaseFormatter did not implement" " format.", + "Subclass of BaseFormatter did not implement" " format." ) def show_statistics(self, statistics: Statistics) -> None: diff --git a/src/flake8/main/application.py b/src/flake8/main/application.py index 165a6ef..4704cbd 100644 --- a/src/flake8/main/application.py +++ b/src/flake8/main/application.py @@ -76,7 +76,7 @@ class Application: assert self.formatter is not None assert self.options is not None self.guide = style_guide.StyleGuideManager( - self.options, self.formatter, + self.options, self.formatter ) def make_file_checker_manager(self, argv: Sequence[str]) -> None: diff --git a/src/flake8/main/debug.py b/src/flake8/main/debug.py index 73ca74b..c3a8b0b 100644 --- a/src/flake8/main/debug.py +++ b/src/flake8/main/debug.py @@ -14,7 +14,7 @@ def information(version: str, plugins: Plugins) -> dict[str, Any]: (loaded.plugin.package, loaded.plugin.version) for loaded in plugins.all_plugins() if loaded.plugin.package not in {"flake8", "local"} - }, + } ) return { "version": version, diff --git a/src/flake8/main/options.py b/src/flake8/main/options.py index e8cbe09..9d57321 100644 --- a/src/flake8/main/options.py +++ b/src/flake8/main/options.py @@ -32,7 +32,7 @@ def stage1_arg_parser() -> argparse.ArgumentParser: ) parser.add_argument( - "--output-file", default=None, help="Redirect report to a file.", + "--output-file", default=None, help="Redirect report to a file." ) # Config file options diff --git a/src/flake8/options/config.py b/src/flake8/options/config.py index fddee55..b51949c 100644 --- a/src/flake8/options/config.py +++ b/src/flake8/options/config.py @@ -78,7 +78,7 @@ def load_config( if config is not None: if not cfg.read(config, encoding="UTF-8"): raise exceptions.ExecutionError( - f"The specified config file does not exist: {config}", + f"The specified config file does not exist: {config}" ) cfg_dir = os.path.dirname(config) else: @@ -89,7 +89,7 @@ def load_config( for filename in extra: if not cfg.read(filename, encoding="UTF-8"): raise exceptions.ExecutionError( - f"The specified config file does not exist: {filename}", + f"The specified config file does not exist: {filename}" ) return cfg, cfg_dir @@ -131,7 +131,7 @@ def parse_config( raise ValueError( f"Error code {error_code!r} " f"supplied to {option_name!r} option " - f"does not match {VALID_CODE_PREFIX.pattern!r}", + f"does not match {VALID_CODE_PREFIX.pattern!r}" ) assert option.config_name is not None diff --git a/src/flake8/options/manager.py b/src/flake8/options/manager.py index ae40794..cb195fe 100644 --- a/src/flake8/options/manager.py +++ b/src/flake8/options/manager.py @@ -5,9 +5,9 @@ import argparse import enum import functools import logging -from collections.abc import Callable from collections.abc import Sequence from typing import Any +from typing import Callable from flake8 import utils from flake8.plugins.finder import Plugins @@ -165,7 +165,7 @@ class Option: if long_option_name is _ARG.NO: raise ValueError( "When specifying parse_from_config=True, " - "a long_option_name must also be specified.", + "a long_option_name must also be specified." ) self.config_name = long_option_name[2:].replace("-", "_") diff --git a/src/flake8/plugins/finder.py b/src/flake8/plugins/finder.py index 4da3402..88b66a0 100644 --- a/src/flake8/plugins/finder.py +++ b/src/flake8/plugins/finder.py @@ -83,8 +83,8 @@ class Plugins(NamedTuple): f"{loaded.plugin.package}: {loaded.plugin.version}" for loaded in self.all_plugins() if loaded.plugin.package not in {"flake8", "local"} - }, - ), + } + ) ) @@ -167,7 +167,7 @@ def _flake8_plugins( # ideally pycodestyle's plugin entrypoints would exactly represent # the codes they produce... yield Plugin( - pycodestyle_meta["name"], pycodestyle_meta["version"], ep, + pycodestyle_meta["name"], pycodestyle_meta["version"], ep ) else: yield Plugin(name, version, ep) @@ -240,7 +240,7 @@ def _check_required_plugins( f"required plugins were not installed!\n" f"- installed: {', '.join(sorted(plugin_names))}\n" f"- expected: {', '.join(sorted(expected_names))}\n" - f"- missing: {', '.join(sorted(missing_plugins))}", + f"- missing: {', '.join(sorted(missing_plugins))}" ) @@ -338,7 +338,7 @@ def _classify_plugins( if not VALID_CODE_PREFIX.match(loaded.entry_name): raise ExecutionError( f"plugin code for `{loaded.display_name}` does not match " - f"{VALID_CODE_PREFIX.pattern}", + f"{VALID_CODE_PREFIX.pattern}" ) return Plugins( diff --git a/src/flake8/plugins/pyflakes.py b/src/flake8/plugins/pyflakes.py index 9844025..66d8c1c 100644 --- a/src/flake8/plugins/pyflakes.py +++ b/src/flake8/plugins/pyflakes.py @@ -72,7 +72,7 @@ class FlakesChecker(pyflakes.checker.Checker): def __init__(self, tree: ast.AST, filename: str) -> None: """Initialize the PyFlakes plugin with an AST tree and filename.""" super().__init__( - tree, filename=filename, withDoctest=self.with_doctest, + tree, filename=filename, withDoctest=self.with_doctest ) @classmethod diff --git a/src/flake8/processor.py b/src/flake8/processor.py index b1742ca..ccb4c57 100644 --- a/src/flake8/processor.py +++ b/src/flake8/processor.py @@ -21,7 +21,7 @@ LOG = logging.getLogger(__name__) NEWLINE = frozenset([tokenize.NL, tokenize.NEWLINE]) SKIP_TOKENS = frozenset( - [tokenize.NL, tokenize.NEWLINE, tokenize.INDENT, tokenize.DEDENT], + [tokenize.NL, tokenize.NEWLINE, tokenize.INDENT, tokenize.DEDENT] ) _LogicalMapping = list[tuple[int, tuple[int, int]]] @@ -173,7 +173,7 @@ class FileProcessor: """Update the checker_state attribute for the plugin.""" if "checker_state" in plugin.parameters: self.checker_state = self._checker_states.setdefault( - plugin.entry_name, {}, + plugin.entry_name, {} ) def next_logical_line(self) -> None: @@ -280,7 +280,7 @@ class FileProcessor: def _noqa_line_range(self, min_line: int, max_line: int) -> dict[int, str]: line_range = range(min_line, max_line + 1) - joined = "".join(self.lines[min_line - 1: max_line]) + joined = "".join(self.lines[min_line - 1 : max_line]) return dict.fromkeys(line_range, joined) @functools.cached_property @@ -367,7 +367,7 @@ class FileProcessor: elif any(defaults.NOQA_FILE.search(line) for line in self.lines): LOG.warning( "Detected `flake8: noqa` on line with code. To ignore an " - "error on a line use `noqa` instead.", + "error on a line use `noqa` instead." ) return False else: @@ -388,7 +388,7 @@ class FileProcessor: def is_eol_token(token: tokenize.TokenInfo) -> bool: """Check if the token is an end-of-line token.""" - return token[0] in NEWLINE or token[4][token[3][1]:].lstrip() == "\\\n" + return token[0] in NEWLINE or token[4][token[3][1] :].lstrip() == "\\\n" def is_multiline_string(token: tokenize.TokenInfo) -> bool: diff --git a/src/flake8/statistics.py b/src/flake8/statistics.py index b30e4c7..5a22254 100644 --- a/src/flake8/statistics.py +++ b/src/flake8/statistics.py @@ -35,7 +35,7 @@ class Statistics: self._store[key].increment() def statistics_for( - self, prefix: str, filename: str | None = None, + self, prefix: str, filename: str | None = None ) -> Generator[Statistic]: """Generate statistics for the prefix and filename. @@ -108,7 +108,7 @@ class Statistic: """ def __init__( - self, error_code: str, filename: str, message: str, count: int, + self, error_code: str, filename: str, message: str, count: int ) -> None: """Initialize our Statistic.""" self.error_code = error_code diff --git a/src/flake8/style_guide.py b/src/flake8/style_guide.py index d675df7..f72e6d8 100644 --- a/src/flake8/style_guide.py +++ b/src/flake8/style_guide.py @@ -218,7 +218,7 @@ class StyleGuideManager: self.decider = decider or DecisionEngine(options) self.style_guides: list[StyleGuide] = [] self.default_style_guide = StyleGuide( - options, formatter, self.stats, decider=decider, + options, formatter, self.stats, decider=decider ) self.style_guides = [ self.default_style_guide, @@ -228,7 +228,7 @@ class StyleGuideManager: self.style_guide_for = functools.cache(self._style_guide_for) def populate_style_guides_with( - self, options: argparse.Namespace, + self, options: argparse.Namespace ) -> Generator[StyleGuide]: """Generate style guides from the per-file-ignores option. @@ -240,7 +240,7 @@ class StyleGuideManager: per_file = utils.parse_files_to_codes_mapping(options.per_file_ignores) for filename, violations in per_file: yield self.default_style_guide.copy( - filename=filename, extend_ignore_with=violations, + filename=filename, extend_ignore_with=violations ) def _style_guide_for(self, filename: str) -> StyleGuide: @@ -288,7 +288,7 @@ class StyleGuideManager: """ guide = self.style_guide_for(filename) return guide.handle_error( - code, filename, line_number, column_number, text, physical_line, + code, filename, line_number, column_number, text, physical_line ) @@ -330,7 +330,7 @@ class StyleGuide: options.extend_ignore = options.extend_ignore or [] options.extend_ignore.extend(extend_ignore_with or []) return StyleGuide( - options, self.formatter, self.stats, filename=filename, + options, self.formatter, self.stats, filename=filename ) @contextlib.contextmanager diff --git a/src/flake8/utils.py b/src/flake8/utils.py index e5c086e..67db33f 100644 --- a/src/flake8/utils.py +++ b/src/flake8/utils.py @@ -23,7 +23,7 @@ NORMALIZE_PACKAGE_NAME_RE = re.compile(r"[-_.]+") def parse_comma_separated_list( - value: str, regexp: Pattern[str] = COMMA_SEPARATED_LIST_RE, + value: str, regexp: Pattern[str] = COMMA_SEPARATED_LIST_RE ) -> list[str]: """Parse a comma-separated list. @@ -115,7 +115,7 @@ def parse_files_to_codes_mapping( # noqa: C901 f"Expected `per-file-ignores` to be a mapping from file exclude " f"patterns to ignore codes.\n\n" f"Configured `per-file-ignores` setting:\n\n" - f"{textwrap.indent(value.strip(), ' ')}", + f"{textwrap.indent(value.strip(), ' ')}" ) for token in _tokenize_files_to_codes_mapping(value): @@ -150,7 +150,7 @@ def parse_files_to_codes_mapping( # noqa: C901 def normalize_paths( - paths: Sequence[str], parent: str = os.curdir, + paths: Sequence[str], parent: str = os.curdir ) -> list[str]: """Normalize a list of paths relative to a parent directory. diff --git a/src/flake8/violation.py b/src/flake8/violation.py index 8535178..ae1631a 100644 --- a/src/flake8/violation.py +++ b/src/flake8/violation.py @@ -64,6 +64,6 @@ class Violation(NamedTuple): return True LOG.debug( - "%r is not ignored inline with ``# noqa: %s``", self, codes_str, + "%r is not ignored inline with ``# noqa: %s``", self, codes_str ) return False diff --git a/tests/integration/test_checker.py b/tests/integration/test_checker.py index f7f07af..a585f5a 100644 --- a/tests/integration/test_checker.py +++ b/tests/integration/test_checker.py @@ -2,6 +2,7 @@ from __future__ import annotations import importlib.metadata +import sys from unittest import mock import pytest @@ -96,7 +97,7 @@ def mock_file_checker_with_plugin(plugin_target): # Prevent it from reading lines from stdin or somewhere else with mock.patch( - "flake8.processor.FileProcessor.read_lines", return_value=["Line 1"], + "flake8.processor.FileProcessor.read_lines", return_value=["Line 1"] ): file_checker = checker.FileChecker( filename="-", @@ -321,10 +322,17 @@ def test_handling_syntaxerrors_across_pythons(): We need to handle that correctly to avoid crashing. https://github.com/PyCQA/flake8/issues/1372 """ - err = SyntaxError( - "invalid syntax", ("", 2, 1, "bad python:\n", 2, 11), - ) - expected = (2, 1) + if sys.version_info < (3, 10): # pragma: no cover (<3.10) + # Python 3.9 or older + err = SyntaxError( + "invalid syntax", ("", 2, 5, "bad python:\n") + ) + expected = (2, 4) + else: # pragma: no cover (3.10+) + err = SyntaxError( + "invalid syntax", ("", 2, 1, "bad python:\n", 2, 11) + ) + expected = (2, 1) file_checker = checker.FileChecker( filename="-", plugins=finder.Checkers([], [], []), diff --git a/tests/integration/test_main.py b/tests/integration/test_main.py index 0ca5b63..68b93cb 100644 --- a/tests/integration/test_main.py +++ b/tests/integration/test_main.py @@ -168,8 +168,10 @@ def test_tokenization_error_but_not_syntax_error(tmpdir, capsys): tmpdir.join("t.py").write("b'foo' \\\n") assert cli.main(["t.py"]) == 1 - if sys.implementation.name == "pypy": # pragma: no cover (pypy) - expected = "t.py:1:9: E999 SyntaxError: unexpected end of file (EOF) in multi-line statement\n" # noqa: E501 + if hasattr(sys, "pypy_version_info"): # pragma: no cover (pypy) + expected = "t.py:2:1: E999 SyntaxError: end of file (EOF) in multi-line statement\n" # noqa: E501 + elif sys.version_info < (3, 10): # pragma: no cover (cp38+) + expected = "t.py:1:8: E999 SyntaxError: unexpected EOF while parsing\n" else: # pragma: no cover (cp310+) expected = "t.py:1:10: E999 SyntaxError: unexpected EOF while parsing\n" # noqa: E501 @@ -184,8 +186,10 @@ def test_tokenization_error_is_a_syntax_error(tmpdir, capsys): tmpdir.join("t.py").write("if True:\n pass\n pass\n") assert cli.main(["t.py"]) == 1 - if sys.implementation.name == "pypy": # pragma: no cover (pypy) - expected = "t.py:3:3: E999 IndentationError: unindent does not match any outer indentation level\n" # noqa: E501 + if hasattr(sys, "pypy_version_info"): # pragma: no cover (pypy) + expected = "t.py:3:2: E999 IndentationError: unindent does not match any outer indentation level\n" # noqa: E501 + elif sys.version_info < (3, 10): # pragma: no cover (