| # Copyright 2026 The Chromium Authors |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| import contextlib |
| import os |
| import pathlib |
| import re |
| import platform |
| import sys |
| |
| import shutil |
| import subprocess |
| import tempfile |
| import itertools |
| from typing import Dict, List, Generator |
| |
| _THIS_DIR = pathlib.Path(__file__).resolve().parent |
| _SRC_ROOT = _THIS_DIR.parent.parent.parent |
| |
| # This file lists all preexisting errors that were in the code when this |
| # check was added. Long term we want to clean that list. |
| # TODO(crbug.com/484881364): Fix this list. |
| _IGNORE_LIST_PATH = _SRC_ROOT.joinpath( |
| 'tools/metrics/python_support/mypy_ignore_list.txt' |
| ) |
| |
| # Specs of venv paths are applied in order of the list |
| _VPYTHON_SPEC_FILES: List[pathlib.Path] = [ |
| _SRC_ROOT.joinpath('tools/metrics/python_support/vpython.toml') |
| ] |
| |
| # In order to resolve imports coming from the setup_modules.py and its tooling |
| # stubs directory is created and symlinked to files using their "fake" paths, |
| # that way mypy can resolve them. |
| # The keys in the mapping are paths relative to stubs directory to create |
| # the values are the paths relative to src/ to symlink. |
| _STUB_MAPPINGS: Dict[str, str] = { |
| 'chromium_src': '.', |
| 'setup_modules_lib.py': 'tools/metrics/python_support/setup_modules_lib.py', |
| 'setup_modules.py': 'tools/metrics/setup_modules.py', |
| 'typ': 'third_party/catapult/third_party/typ', |
| } |
| |
| # List of regex patterns for errors to ignore. |
| _IGNORED_ERRORS: List[str] = [ |
| # Ignore issues in chromium_src/tools/metrics as they are duplicates |
| # of what we already have through analyzing "local" files. |
| r'^.*chromium_mypy_stubs_.*chromium_src.tools.metrics.*$', |
| # Ignore all third party errors. |
| # TODO(crbug.com/484881364): Treat them as warning instead. |
| r'^.*chromium_mypy_stubs_.*chromium_src.third_party.*$', |
| # TODO(crbug.com/484881364): Fix this. |
| r'^.*chromium_src.components.autofill.core.browser.data_model', |
| ] |
| |
| _IGNORED_ERRORS_PATTERNS = [re.compile(p) for p in _IGNORED_ERRORS] |
| |
| |
| def _convert_to_windows_ignore_line_if_needed(ignore_file_entry: str) -> str: |
| """Converts the line from mypy_ignore_list.txt fixing paths on Windows.""" |
| if platform.system() != 'Windows': |
| return ignore_file_entry |
| |
| # Replace slashes with backslashes in the first part of each line which |
| # is a path. |
| parts = ignore_file_entry.split(':', 1) |
| parts[0] = parts[0].replace('/', '\\') |
| return ':'.join(parts) |
| |
| |
| def _create_symlink(target: pathlib.Path, link_name: pathlib.Path) -> None: |
| """Creates a symlink at link_name pointing to target.""" |
| if platform.system() == 'Windows': |
| # Windows requires mklink to create junction or hard link to not require |
| # admin rights. |
| absolute_target = str(target.resolve()) |
| absolute_link = str(link_name.resolve()) |
| if target.is_dir(): |
| mklink = subprocess.run( |
| ['cmd', '/c', 'mklink', '/j', absolute_link, absolute_target] |
| ) |
| else: |
| mklink = subprocess.run( |
| ['cmd', '/c', 'mklink', '/H', absolute_link, absolute_target] |
| ) |
| _ = mklink.returncode # wait for it to finish |
| else: |
| os.symlink(target, link_name) |
| |
| |
| @contextlib.contextmanager |
| def _setup_stubs() -> Generator[pathlib.Path, None, None]: |
| """Setups a stub directories required for proper analysis of tools/metrics |
| |
| It is a context manager that creates a temporary directory and populates it |
| with symlinks (or copies) based on _STUB_MAPPINGS. |
| |
| Yields: |
| pathlib.Path: The absolute path to the temporary stub directory. |
| """ |
| temp_dir = tempfile.mkdtemp(prefix='chromium_mypy_stubs_') |
| temp_path = pathlib.Path(temp_dir) |
| |
| try: |
| for stub_name, rel_src_path in _STUB_MAPPINGS.items(): |
| src_target = _SRC_ROOT.joinpath(rel_src_path) |
| stub_link = temp_path.joinpath(stub_name) |
| |
| if not src_target.exists(): |
| continue |
| |
| stub_link.parent.mkdir(parents=True, exist_ok=True) |
| _create_symlink(src_target, stub_link) |
| |
| yield temp_path |
| |
| finally: |
| shutil.rmtree(temp_dir, ignore_errors=True) |
| |
| |
| def run_mypy(stub_dir: pathlib.Path, dir_to_check: str) -> List[str]: |
| """Runs mypy using vpython3 with tools/metrics specific config. |
| |
| Args: |
| stub_dir: The directory containing the stubs/symlinks. |
| dir_to_check: Directory that mypy will analyse |
| |
| Returns: |
| List[str]: A list of error lines returned by mypy. |
| """ |
| env = os.environ.copy() |
| env['MYPYPATH'] = str(stub_dir.resolve()) |
| |
| spec_flags = [['-vpython-spec', f'{str(p)}'] for p in _VPYTHON_SPEC_FILES] |
| |
| cmd = [ |
| 'vpython3', |
| *itertools.chain(*spec_flags), |
| '-m', |
| 'mypy', |
| '--explicit-package-bases', |
| '--no-error-summary', # Easier to parse without the summary |
| '--hide-error-context', # Cleaner line-by-line filtering |
| '--', |
| dir_to_check, |
| ] |
| |
| result = subprocess.run( |
| cmd, |
| env=env, |
| cwd=_SRC_ROOT, |
| capture_output=True, |
| shell=sys.platform == 'win32', |
| text=True, |
| ) |
| |
| output = result.stdout + result.stderr |
| return output.splitlines() |
| |
| |
| def run_mypy_and_filter_irrelevant(dir_to_check: str) -> List[str]: |
| """Runs mypy and reports finding after filtering exceptions. |
| |
| Sets up stubs, runs mypy, and filters the output based on _IGNORED_ERRORS |
| and _IGNORE_LIST_PATH content. |
| |
| Returns: |
| List[str]: The filtered list of error messages. |
| """ |
| with open(_IGNORE_LIST_PATH, 'r') as f: |
| ignored_errors = { |
| _convert_to_windows_ignore_line_if_needed(error.strip()) |
| for error in f.readlines() |
| } |
| |
| with _setup_stubs() as stub_dir: |
| raw_errors = run_mypy(stub_dir, dir_to_check) |
| |
| ignored_errors_cnt = 0 |
| ignored_patterns = 0 |
| empty_lines = 0 |
| |
| filtered_errors = [] |
| for line in raw_errors: |
| line = line.strip() |
| if not line: |
| empty_lines += 1 |
| continue |
| |
| if any(pattern.search(line) for pattern in _IGNORED_ERRORS_PATTERNS): |
| ignored_patterns += 1 |
| continue |
| |
| if line in ignored_errors: |
| ignored_errors_cnt += 1 |
| continue |
| |
| filtered_errors.append(line) |
| |
| assert empty_lines == 0 |
| print( |
| f'MyPy found {len(filtered_errors)} issues + {ignored_patterns}' |
| f' ignored by pattern + {ignored_errors_cnt} ignored by list' |
| ) |
| return filtered_errors |