xref: /aosp_15_r20/external/toolchain-utils/toolchain_utils_githooks/check-presubmit.py (revision 760c253c1ed00ce9abd48f8546f08516e57485fe)
1*760c253cSXin Li#!/usr/bin/env python3
2*760c253cSXin Li#
3*760c253cSXin Li# Copyright 2019 The ChromiumOS Authors
4*760c253cSXin Li# Use of this source code is governed by a BSD-style license that can be
5*760c253cSXin Li# found in the LICENSE file.
6*760c253cSXin Li
7*760c253cSXin Li"""Runs presubmit checks against a bundle of files."""
8*760c253cSXin Li
9*760c253cSXin Liimport argparse
10*760c253cSXin Liimport dataclasses
11*760c253cSXin Liimport datetime
12*760c253cSXin Liimport multiprocessing
13*760c253cSXin Liimport multiprocessing.pool
14*760c253cSXin Liimport os
15*760c253cSXin Lifrom pathlib import Path
16*760c253cSXin Liimport re
17*760c253cSXin Liimport shlex
18*760c253cSXin Liimport shutil
19*760c253cSXin Liimport subprocess
20*760c253cSXin Liimport sys
21*760c253cSXin Liimport textwrap
22*760c253cSXin Liimport threading
23*760c253cSXin Liimport traceback
24*760c253cSXin Lifrom typing import (
25*760c253cSXin Li    Dict,
26*760c253cSXin Li    Iterable,
27*760c253cSXin Li    List,
28*760c253cSXin Li    NamedTuple,
29*760c253cSXin Li    Optional,
30*760c253cSXin Li    Sequence,
31*760c253cSXin Li    Tuple,
32*760c253cSXin Li    Union,
33*760c253cSXin Li)
34*760c253cSXin Li
35*760c253cSXin Li
36*760c253cSXin Li# This was originally had many packages in it (notably scipy)
37*760c253cSXin Li# but due to changes in how scipy is built, we can no longer install
38*760c253cSXin Li# it in the chroot. See b/284489250
39*760c253cSXin Li#
40*760c253cSXin Li# For type checking Python code, we also need mypy. This isn't
41*760c253cSXin Li# listed here because (1) only very few files are actually type checked,
42*760c253cSXin Li# so we don't pull the dependency in unless needed, and (2) mypy
43*760c253cSXin Li# may be installed through other means than pip.
44*760c253cSXin LiPIP_DEPENDENCIES = ("numpy",)
45*760c253cSXin Li
46*760c253cSXin Li
47*760c253cSXin Li# Each checker represents an independent check that's done on our sources.
48*760c253cSXin Li#
49*760c253cSXin Li# They should:
50*760c253cSXin Li#  - never write to stdout/stderr or read from stdin directly
51*760c253cSXin Li#  - return either a CheckResult, or a list of [(subcheck_name, CheckResult)]
52*760c253cSXin Li#  - ideally use thread_pool to check things concurrently
53*760c253cSXin Li#    - though it's important to note that these *also* live on the threadpool
54*760c253cSXin Li#      we've provided. It's the caller's responsibility to guarantee that at
55*760c253cSXin Li#      least ${number_of_concurrently_running_checkers}+1 threads are present
56*760c253cSXin Li#      in the pool. In order words, blocking on results from the provided
57*760c253cSXin Li#      threadpool is OK.
58*760c253cSXin LiCheckResult = NamedTuple(
59*760c253cSXin Li    "CheckResult",
60*760c253cSXin Li    (
61*760c253cSXin Li        ("ok", bool),
62*760c253cSXin Li        ("output", str),
63*760c253cSXin Li        ("autofix_commands", List[List[str]]),
64*760c253cSXin Li    ),
65*760c253cSXin Li)
66*760c253cSXin Li
67*760c253cSXin Li
68*760c253cSXin LiCommand = Sequence[Union[str, os.PathLike]]
69*760c253cSXin LiCheckResults = Union[List[Tuple[str, CheckResult]], CheckResult]
70*760c253cSXin Li
71*760c253cSXin Li
72*760c253cSXin Li# The files and directories on which we run the mypy typechecker. The paths are
73*760c253cSXin Li# relative to the root of the toolchain-utils repository.
74*760c253cSXin LiMYPY_CHECKED_PATHS = (
75*760c253cSXin Li    "afdo_tools/update_kernel_afdo.py",
76*760c253cSXin Li    "check_portable_toolchains.py",
77*760c253cSXin Li    "cros_utils/bugs.py",
78*760c253cSXin Li    "cros_utils/bugs_test.py",
79*760c253cSXin Li    "cros_utils/tiny_render.py",
80*760c253cSXin Li    "llvm_tools",
81*760c253cSXin Li    "pgo_tools",
82*760c253cSXin Li    "pgo_tools_rust/pgo_rust.py",
83*760c253cSXin Li    "rust_tools",
84*760c253cSXin Li    "toolchain_utils_githooks/check-presubmit.py",
85*760c253cSXin Li)
86*760c253cSXin Li
87*760c253cSXin Li
88*760c253cSXin Lidef run_command_unchecked(
89*760c253cSXin Li    command: Command,
90*760c253cSXin Li    cwd: Optional[str] = None,
91*760c253cSXin Li    env: Optional[Dict[str, str]] = None,
92*760c253cSXin Li) -> Tuple[int, str]:
93*760c253cSXin Li    """Runs a command in the given dir, returning its exit code and stdio."""
94*760c253cSXin Li    p = subprocess.run(
95*760c253cSXin Li        command,
96*760c253cSXin Li        check=False,
97*760c253cSXin Li        cwd=cwd,
98*760c253cSXin Li        stdin=subprocess.DEVNULL,
99*760c253cSXin Li        stdout=subprocess.PIPE,
100*760c253cSXin Li        stderr=subprocess.STDOUT,
101*760c253cSXin Li        env=env,
102*760c253cSXin Li        encoding="utf-8",
103*760c253cSXin Li        errors="replace",
104*760c253cSXin Li    )
105*760c253cSXin Li    return p.returncode, p.stdout
106*760c253cSXin Li
107*760c253cSXin Li
108*760c253cSXin Lidef has_executable_on_path(exe: str) -> bool:
109*760c253cSXin Li    """Returns whether we have `exe` somewhere on our $PATH"""
110*760c253cSXin Li    return shutil.which(exe) is not None
111*760c253cSXin Li
112*760c253cSXin Li
113*760c253cSXin Lidef remove_deleted_files(files: Iterable[str]) -> List[str]:
114*760c253cSXin Li    return [f for f in files if os.path.exists(f)]
115*760c253cSXin Li
116*760c253cSXin Li
117*760c253cSXin Lidef is_file_executable(file_path: str) -> bool:
118*760c253cSXin Li    return os.access(file_path, os.X_OK)
119*760c253cSXin Li
120*760c253cSXin Li
121*760c253cSXin Li# As noted in our docs, some of our Python code depends on modules that sit in
122*760c253cSXin Li# toolchain-utils/. Add that to PYTHONPATH to ensure that things like `cros
123*760c253cSXin Li# lint` are kept happy.
124*760c253cSXin Lidef env_with_pythonpath(toolchain_utils_root: str) -> Dict[str, str]:
125*760c253cSXin Li    env = dict(os.environ)
126*760c253cSXin Li    if "PYTHONPATH" in env:
127*760c253cSXin Li        env["PYTHONPATH"] += ":" + toolchain_utils_root
128*760c253cSXin Li    else:
129*760c253cSXin Li        env["PYTHONPATH"] = toolchain_utils_root
130*760c253cSXin Li    return env
131*760c253cSXin Li
132*760c253cSXin Li
133*760c253cSXin Li@dataclasses.dataclass(frozen=True)
134*760c253cSXin Liclass MyPyInvocation:
135*760c253cSXin Li    """An invocation of mypy."""
136*760c253cSXin Li
137*760c253cSXin Li    command: List[str]
138*760c253cSXin Li    # Entries to add to PYTHONPATH, formatted for direct use in the PYTHONPATH
139*760c253cSXin Li    # env var.
140*760c253cSXin Li    pythonpath_additions: str
141*760c253cSXin Li
142*760c253cSXin Li
143*760c253cSXin Lidef get_mypy() -> Optional[MyPyInvocation]:
144*760c253cSXin Li    """Finds the mypy executable and returns a command to invoke it.
145*760c253cSXin Li
146*760c253cSXin Li    If mypy cannot be found and we're inside the chroot, this
147*760c253cSXin Li    function installs mypy and returns a command to invoke it.
148*760c253cSXin Li
149*760c253cSXin Li    If mypy cannot be found and we're outside the chroot, this
150*760c253cSXin Li    returns None.
151*760c253cSXin Li
152*760c253cSXin Li    Returns:
153*760c253cSXin Li        An optional tuple containing:
154*760c253cSXin Li            - the command to invoke mypy, and
155*760c253cSXin Li            - any environment variables to set when invoking mypy
156*760c253cSXin Li    """
157*760c253cSXin Li    if has_executable_on_path("mypy"):
158*760c253cSXin Li        return MyPyInvocation(command=["mypy"], pythonpath_additions="")
159*760c253cSXin Li    pip = get_pip()
160*760c253cSXin Li    if not pip:
161*760c253cSXin Li        assert not is_in_chroot()
162*760c253cSXin Li        return None
163*760c253cSXin Li
164*760c253cSXin Li    def get_from_pip() -> Optional[MyPyInvocation]:
165*760c253cSXin Li        rc, output = run_command_unchecked(pip + ["show", "mypy"])
166*760c253cSXin Li        if rc:
167*760c253cSXin Li            return None
168*760c253cSXin Li
169*760c253cSXin Li        m = re.search(r"^Location: (.*)", output, re.MULTILINE)
170*760c253cSXin Li        if not m:
171*760c253cSXin Li            return None
172*760c253cSXin Li
173*760c253cSXin Li        pythonpath = m.group(1)
174*760c253cSXin Li        return MyPyInvocation(
175*760c253cSXin Li            command=[
176*760c253cSXin Li                "python3",
177*760c253cSXin Li                "-m",
178*760c253cSXin Li                "mypy",
179*760c253cSXin Li            ],
180*760c253cSXin Li            pythonpath_additions=pythonpath,
181*760c253cSXin Li        )
182*760c253cSXin Li
183*760c253cSXin Li    from_pip = get_from_pip()
184*760c253cSXin Li    if from_pip:
185*760c253cSXin Li        return from_pip
186*760c253cSXin Li
187*760c253cSXin Li    if is_in_chroot():
188*760c253cSXin Li        assert pip is not None
189*760c253cSXin Li        subprocess.check_call(pip + ["install", "--user", "mypy"])
190*760c253cSXin Li        return get_from_pip()
191*760c253cSXin Li    return None
192*760c253cSXin Li
193*760c253cSXin Li
194*760c253cSXin Lidef get_pip() -> Optional[List[str]]:
195*760c253cSXin Li    """Finds pip and returns a command to invoke it.
196*760c253cSXin Li
197*760c253cSXin Li    If pip cannot be found, this function attempts to install
198*760c253cSXin Li    pip and returns a command to invoke it.
199*760c253cSXin Li
200*760c253cSXin Li    If pip cannot be found, this function returns None.
201*760c253cSXin Li    """
202*760c253cSXin Li    have_pip = can_import_py_module("pip")
203*760c253cSXin Li    if not have_pip:
204*760c253cSXin Li        print("Autoinstalling `pip`...")
205*760c253cSXin Li        subprocess.check_call(["python", "-m", "ensurepip"])
206*760c253cSXin Li        have_pip = can_import_py_module("pip")
207*760c253cSXin Li
208*760c253cSXin Li    if have_pip:
209*760c253cSXin Li        return ["python", "-m", "pip"]
210*760c253cSXin Li    return None
211*760c253cSXin Li
212*760c253cSXin Li
213*760c253cSXin Lidef get_check_result_or_catch(
214*760c253cSXin Li    task: multiprocessing.pool.ApplyResult,
215*760c253cSXin Li) -> CheckResult:
216*760c253cSXin Li    """Returns the result of task(); if that raises, returns a CheckResult.
217*760c253cSXin Li
218*760c253cSXin Li    The task is expected to return a CheckResult on get().
219*760c253cSXin Li    """
220*760c253cSXin Li    try:
221*760c253cSXin Li        return task.get()
222*760c253cSXin Li    except Exception:
223*760c253cSXin Li        return CheckResult(
224*760c253cSXin Li            ok=False,
225*760c253cSXin Li            output="Check exited with an unexpected exception:\n%s"
226*760c253cSXin Li            % traceback.format_exc(),
227*760c253cSXin Li            autofix_commands=[],
228*760c253cSXin Li        )
229*760c253cSXin Li
230*760c253cSXin Li
231*760c253cSXin Lidef check_isort(
232*760c253cSXin Li    toolchain_utils_root: str, python_files: Iterable[str]
233*760c253cSXin Li) -> CheckResult:
234*760c253cSXin Li    """Subchecker of check_py_format. Checks python file formats with isort"""
235*760c253cSXin Li    chromite = Path("/mnt/host/source/chromite")
236*760c253cSXin Li    isort = chromite / "scripts" / "isort"
237*760c253cSXin Li    config_file = chromite / ".isort.cfg"
238*760c253cSXin Li
239*760c253cSXin Li    if not (isort.exists() and config_file.exists()):
240*760c253cSXin Li        return CheckResult(
241*760c253cSXin Li            ok=True,
242*760c253cSXin Li            output="isort not found; skipping",
243*760c253cSXin Li            autofix_commands=[],
244*760c253cSXin Li        )
245*760c253cSXin Li
246*760c253cSXin Li    config_file_flag = f"--settings-file={config_file}"
247*760c253cSXin Li    command = [str(isort), "-c", config_file_flag] + list(python_files)
248*760c253cSXin Li    exit_code, stdout_and_stderr = run_command_unchecked(
249*760c253cSXin Li        command, cwd=toolchain_utils_root
250*760c253cSXin Li    )
251*760c253cSXin Li
252*760c253cSXin Li    # isort fails when files have broken formatting.
253*760c253cSXin Li    if not exit_code:
254*760c253cSXin Li        return CheckResult(
255*760c253cSXin Li            ok=True,
256*760c253cSXin Li            output="",
257*760c253cSXin Li            autofix_commands=[],
258*760c253cSXin Li        )
259*760c253cSXin Li
260*760c253cSXin Li    bad_files = []
261*760c253cSXin Li    bad_file_re = re.compile(
262*760c253cSXin Li        r"^ERROR: (.*) Imports are incorrectly sorted and/or formatted\.$"
263*760c253cSXin Li    )
264*760c253cSXin Li    for line in stdout_and_stderr.splitlines():
265*760c253cSXin Li        m = bad_file_re.match(line)
266*760c253cSXin Li        if m:
267*760c253cSXin Li            (file_name,) = m.groups()
268*760c253cSXin Li            bad_files.append(file_name.strip())
269*760c253cSXin Li
270*760c253cSXin Li    if not bad_files:
271*760c253cSXin Li        return CheckResult(
272*760c253cSXin Li            ok=False,
273*760c253cSXin Li            output=f"`{shlex.join(command)}` failed; stdout/stderr:\n"
274*760c253cSXin Li            f"{stdout_and_stderr}",
275*760c253cSXin Li            autofix_commands=[],
276*760c253cSXin Li        )
277*760c253cSXin Li
278*760c253cSXin Li    autofix = [str(isort), config_file_flag] + bad_files
279*760c253cSXin Li    return CheckResult(
280*760c253cSXin Li        ok=False,
281*760c253cSXin Li        output="The following file(s) have formatting errors: %s" % bad_files,
282*760c253cSXin Li        autofix_commands=[autofix],
283*760c253cSXin Li    )
284*760c253cSXin Li
285*760c253cSXin Li
286*760c253cSXin Lidef check_black(
287*760c253cSXin Li    toolchain_utils_root: str, black: Path, python_files: Iterable[str]
288*760c253cSXin Li) -> CheckResult:
289*760c253cSXin Li    """Subchecker of check_py_format. Checks python file formats with black"""
290*760c253cSXin Li    # Folks have been bitten by accidentally using multiple formatter
291*760c253cSXin Li    # versions in the past. This is an issue, since newer versions of
292*760c253cSXin Li    # black may format things differently. Make the version obvious.
293*760c253cSXin Li    command: Command = [black, "--version"]
294*760c253cSXin Li    exit_code, stdout_and_stderr = run_command_unchecked(
295*760c253cSXin Li        command, cwd=toolchain_utils_root
296*760c253cSXin Li    )
297*760c253cSXin Li    if exit_code:
298*760c253cSXin Li        return CheckResult(
299*760c253cSXin Li            ok=False,
300*760c253cSXin Li            output="Failed getting black version; "
301*760c253cSXin Li            f"stdstreams: {stdout_and_stderr}",
302*760c253cSXin Li            autofix_commands=[],
303*760c253cSXin Li        )
304*760c253cSXin Li
305*760c253cSXin Li    black_version = stdout_and_stderr.strip()
306*760c253cSXin Li    black_invocation: List[str] = [str(black), "--line-length=80"]
307*760c253cSXin Li    command = black_invocation + ["--check"] + list(python_files)
308*760c253cSXin Li    exit_code, stdout_and_stderr = run_command_unchecked(
309*760c253cSXin Li        command, cwd=toolchain_utils_root
310*760c253cSXin Li    )
311*760c253cSXin Li    # black fails when files are poorly formatted.
312*760c253cSXin Li    if exit_code == 0:
313*760c253cSXin Li        return CheckResult(
314*760c253cSXin Li            ok=True,
315*760c253cSXin Li            output=f"Using {black_version!r}, no issues were found.",
316*760c253cSXin Li            autofix_commands=[],
317*760c253cSXin Li        )
318*760c253cSXin Li
319*760c253cSXin Li    # Output format looks something like:
320*760c253cSXin Li    # f'{complaints}\nOh no!{emojis}\n{summary}'
321*760c253cSXin Li    # Whittle it down to complaints.
322*760c253cSXin Li    complaints = stdout_and_stderr.split("\nOh no!", 1)
323*760c253cSXin Li    if len(complaints) != 2:
324*760c253cSXin Li        return CheckResult(
325*760c253cSXin Li            ok=False,
326*760c253cSXin Li            output=f"Unparseable `black` output:\n{stdout_and_stderr}",
327*760c253cSXin Li            autofix_commands=[],
328*760c253cSXin Li        )
329*760c253cSXin Li
330*760c253cSXin Li    bad_files = []
331*760c253cSXin Li    errors = []
332*760c253cSXin Li    refmt_prefix = "would reformat "
333*760c253cSXin Li    for line in complaints[0].strip().splitlines():
334*760c253cSXin Li        line = line.strip()
335*760c253cSXin Li        if line.startswith("error:"):
336*760c253cSXin Li            errors.append(line)
337*760c253cSXin Li            continue
338*760c253cSXin Li
339*760c253cSXin Li        if not line.startswith(refmt_prefix):
340*760c253cSXin Li            return CheckResult(
341*760c253cSXin Li                ok=False,
342*760c253cSXin Li                output=f"Unparseable `black` output:\n{stdout_and_stderr}",
343*760c253cSXin Li                autofix_commands=[],
344*760c253cSXin Li            )
345*760c253cSXin Li
346*760c253cSXin Li        bad_files.append(line[len(refmt_prefix) :].strip())
347*760c253cSXin Li
348*760c253cSXin Li    # If black had internal errors that it could handle, print them out and exit
349*760c253cSXin Li    # without an autofix.
350*760c253cSXin Li    if errors:
351*760c253cSXin Li        err_str = "\n".join(errors)
352*760c253cSXin Li        return CheckResult(
353*760c253cSXin Li            ok=False,
354*760c253cSXin Li            output=f"Using {black_version!r} had the following errors:\n"
355*760c253cSXin Li            f"{err_str}",
356*760c253cSXin Li            autofix_commands=[],
357*760c253cSXin Li        )
358*760c253cSXin Li
359*760c253cSXin Li    autofix = black_invocation + bad_files
360*760c253cSXin Li    return CheckResult(
361*760c253cSXin Li        ok=False,
362*760c253cSXin Li        output=f"Using {black_version!r}, these file(s) have formatting "
363*760c253cSXin Li        f"errors: {bad_files}",
364*760c253cSXin Li        autofix_commands=[autofix],
365*760c253cSXin Li    )
366*760c253cSXin Li
367*760c253cSXin Li
368*760c253cSXin Lidef check_mypy(
369*760c253cSXin Li    toolchain_utils_root: str,
370*760c253cSXin Li    mypy: MyPyInvocation,
371*760c253cSXin Li    files: Iterable[str],
372*760c253cSXin Li) -> CheckResult:
373*760c253cSXin Li    """Checks type annotations using mypy."""
374*760c253cSXin Li    fixed_env = env_with_pythonpath(toolchain_utils_root)
375*760c253cSXin Li    if mypy.pythonpath_additions:
376*760c253cSXin Li        new_pythonpath = (
377*760c253cSXin Li            f"{mypy.pythonpath_additions}:{fixed_env['PYTHONPATH']}"
378*760c253cSXin Li        )
379*760c253cSXin Li        fixed_env["PYTHONPATH"] = new_pythonpath
380*760c253cSXin Li
381*760c253cSXin Li    # Show the version number, mainly for troubleshooting purposes.
382*760c253cSXin Li    cmd = mypy.command + ["--version"]
383*760c253cSXin Li    exit_code, output = run_command_unchecked(
384*760c253cSXin Li        cmd, cwd=toolchain_utils_root, env=fixed_env
385*760c253cSXin Li    )
386*760c253cSXin Li    if exit_code:
387*760c253cSXin Li        return CheckResult(
388*760c253cSXin Li            ok=False,
389*760c253cSXin Li            output=f"Failed getting mypy version; stdstreams: {output}",
390*760c253cSXin Li            autofix_commands=[],
391*760c253cSXin Li        )
392*760c253cSXin Li    # Prefix output with the version information.
393*760c253cSXin Li    prefix = f"Using {output.strip()}, "
394*760c253cSXin Li
395*760c253cSXin Li    cmd = mypy.command + ["--follow-imports=silent"] + list(files)
396*760c253cSXin Li    exit_code, output = run_command_unchecked(
397*760c253cSXin Li        cmd, cwd=toolchain_utils_root, env=fixed_env
398*760c253cSXin Li    )
399*760c253cSXin Li    if exit_code == 0:
400*760c253cSXin Li        return CheckResult(
401*760c253cSXin Li            ok=True,
402*760c253cSXin Li            output=f"{output}{prefix}checks passed",
403*760c253cSXin Li            autofix_commands=[],
404*760c253cSXin Li        )
405*760c253cSXin Li    else:
406*760c253cSXin Li        return CheckResult(
407*760c253cSXin Li            ok=False,
408*760c253cSXin Li            output=f"{output}{prefix}type errors were found",
409*760c253cSXin Li            autofix_commands=[],
410*760c253cSXin Li        )
411*760c253cSXin Li
412*760c253cSXin Li
413*760c253cSXin Lidef check_python_file_headers(python_files: Iterable[str]) -> CheckResult:
414*760c253cSXin Li    """Subchecker of check_py_format. Checks python #!s"""
415*760c253cSXin Li    add_hashbang = []
416*760c253cSXin Li    remove_hashbang = []
417*760c253cSXin Li
418*760c253cSXin Li    for python_file in python_files:
419*760c253cSXin Li        needs_hashbang = is_file_executable(python_file)
420*760c253cSXin Li        with open(python_file, encoding="utf-8") as f:
421*760c253cSXin Li            has_hashbang = f.read(2) == "#!"
422*760c253cSXin Li            if needs_hashbang == has_hashbang:
423*760c253cSXin Li                continue
424*760c253cSXin Li
425*760c253cSXin Li            if needs_hashbang:
426*760c253cSXin Li                add_hashbang.append(python_file)
427*760c253cSXin Li            else:
428*760c253cSXin Li                remove_hashbang.append(python_file)
429*760c253cSXin Li
430*760c253cSXin Li    autofix = []
431*760c253cSXin Li    output = []
432*760c253cSXin Li    if add_hashbang:
433*760c253cSXin Li        output.append(
434*760c253cSXin Li            "The following files have no #!, but need one: %s" % add_hashbang
435*760c253cSXin Li        )
436*760c253cSXin Li        autofix.append(["sed", "-i", "1i#!/usr/bin/env python3"] + add_hashbang)
437*760c253cSXin Li
438*760c253cSXin Li    if remove_hashbang:
439*760c253cSXin Li        output.append(
440*760c253cSXin Li            "The following files have a #!, but shouldn't: %s" % remove_hashbang
441*760c253cSXin Li        )
442*760c253cSXin Li        autofix.append(["sed", "-i", "1d"] + remove_hashbang)
443*760c253cSXin Li
444*760c253cSXin Li    if not output:
445*760c253cSXin Li        return CheckResult(
446*760c253cSXin Li            ok=True,
447*760c253cSXin Li            output="",
448*760c253cSXin Li            autofix_commands=[],
449*760c253cSXin Li        )
450*760c253cSXin Li    return CheckResult(
451*760c253cSXin Li        ok=False,
452*760c253cSXin Li        output="\n".join(output),
453*760c253cSXin Li        autofix_commands=autofix,
454*760c253cSXin Li    )
455*760c253cSXin Li
456*760c253cSXin Li
457*760c253cSXin Lidef check_py_format(
458*760c253cSXin Li    toolchain_utils_root: str,
459*760c253cSXin Li    thread_pool: multiprocessing.pool.ThreadPool,
460*760c253cSXin Li    files: Iterable[str],
461*760c253cSXin Li) -> CheckResults:
462*760c253cSXin Li    """Runs black on files to check for style bugs. Also checks for #!s."""
463*760c253cSXin Li    black = "black"
464*760c253cSXin Li    if not has_executable_on_path(black):
465*760c253cSXin Li        return CheckResult(
466*760c253cSXin Li            ok=False,
467*760c253cSXin Li            output="black isn't available on your $PATH. Please either "
468*760c253cSXin Li            "enter a chroot, or place depot_tools on your $PATH.",
469*760c253cSXin Li            autofix_commands=[],
470*760c253cSXin Li        )
471*760c253cSXin Li
472*760c253cSXin Li    python_files = [f for f in remove_deleted_files(files) if f.endswith(".py")]
473*760c253cSXin Li    if not python_files:
474*760c253cSXin Li        return CheckResult(
475*760c253cSXin Li            ok=True,
476*760c253cSXin Li            output="no python files to check",
477*760c253cSXin Li            autofix_commands=[],
478*760c253cSXin Li        )
479*760c253cSXin Li
480*760c253cSXin Li    tasks = [
481*760c253cSXin Li        (
482*760c253cSXin Li            "check_black",
483*760c253cSXin Li            thread_pool.apply_async(
484*760c253cSXin Li                check_black, (toolchain_utils_root, black, python_files)
485*760c253cSXin Li            ),
486*760c253cSXin Li        ),
487*760c253cSXin Li        (
488*760c253cSXin Li            "check_isort",
489*760c253cSXin Li            thread_pool.apply_async(
490*760c253cSXin Li                check_isort, (toolchain_utils_root, python_files)
491*760c253cSXin Li            ),
492*760c253cSXin Li        ),
493*760c253cSXin Li        (
494*760c253cSXin Li            "check_file_headers",
495*760c253cSXin Li            thread_pool.apply_async(check_python_file_headers, (python_files,)),
496*760c253cSXin Li        ),
497*760c253cSXin Li    ]
498*760c253cSXin Li    return [(name, get_check_result_or_catch(task)) for name, task in tasks]
499*760c253cSXin Li
500*760c253cSXin Li
501*760c253cSXin Lidef file_is_relative_to(file: Path, potential_parent: Path) -> bool:
502*760c253cSXin Li    """file.is_relative_to(potential_parent), but for Python < 3.9."""
503*760c253cSXin Li    try:
504*760c253cSXin Li        file.relative_to(potential_parent)
505*760c253cSXin Li        return True
506*760c253cSXin Li    except ValueError:
507*760c253cSXin Li        return False
508*760c253cSXin Li
509*760c253cSXin Li
510*760c253cSXin Lidef is_file_in_any_of(file: Path, files_and_dirs: List[Path]) -> bool:
511*760c253cSXin Li    """Returns whether `files_and_dirs` encompasses `file`.
512*760c253cSXin Li
513*760c253cSXin Li    `files_and_dirs` is considered to encompass `file` if `files_and_dirs`
514*760c253cSXin Li    contains `file` directly, or if it contains a directory that is a parent of
515*760c253cSXin Li    `file`.
516*760c253cSXin Li
517*760c253cSXin Li    Args:
518*760c253cSXin Li        file: a path to check
519*760c253cSXin Li        files_and_dirs: a list of directories to check
520*760c253cSXin Li    """
521*760c253cSXin Li    # This could technically be made sublinear, but it's running at most a few
522*760c253cSXin Li    # dozen times on a `files_and_dirs` that's currently < 10 elems.
523*760c253cSXin Li    return any(
524*760c253cSXin Li        file == x or file_is_relative_to(file, x) for x in files_and_dirs
525*760c253cSXin Li    )
526*760c253cSXin Li
527*760c253cSXin Li
528*760c253cSXin Lidef check_py_types(
529*760c253cSXin Li    toolchain_utils_root: str,
530*760c253cSXin Li    thread_pool: multiprocessing.pool.ThreadPool,
531*760c253cSXin Li    files: Iterable[str],
532*760c253cSXin Li) -> CheckResults:
533*760c253cSXin Li    """Runs static type checking for files in MYPY_CHECKED_FILES."""
534*760c253cSXin Li    path_root = Path(toolchain_utils_root)
535*760c253cSXin Li    check_locations = [path_root / x for x in MYPY_CHECKED_PATHS]
536*760c253cSXin Li    to_check = [
537*760c253cSXin Li        x
538*760c253cSXin Li        for x in files
539*760c253cSXin Li        if x.endswith(".py") and is_file_in_any_of(Path(x), check_locations)
540*760c253cSXin Li    ]
541*760c253cSXin Li
542*760c253cSXin Li    if not to_check:
543*760c253cSXin Li        return CheckResult(
544*760c253cSXin Li            ok=True,
545*760c253cSXin Li            output="no python files to typecheck",
546*760c253cSXin Li            autofix_commands=[],
547*760c253cSXin Li        )
548*760c253cSXin Li
549*760c253cSXin Li    mypy = get_mypy()
550*760c253cSXin Li    if not mypy:
551*760c253cSXin Li        return CheckResult(
552*760c253cSXin Li            ok=False,
553*760c253cSXin Li            output="mypy not found. Please either enter a chroot "
554*760c253cSXin Li            "or install mypy",
555*760c253cSXin Li            autofix_commands=[],
556*760c253cSXin Li        )
557*760c253cSXin Li
558*760c253cSXin Li    tasks = [
559*760c253cSXin Li        (
560*760c253cSXin Li            "check_mypy",
561*760c253cSXin Li            thread_pool.apply_async(
562*760c253cSXin Li                check_mypy, (toolchain_utils_root, mypy, to_check)
563*760c253cSXin Li            ),
564*760c253cSXin Li        ),
565*760c253cSXin Li    ]
566*760c253cSXin Li    return [(name, get_check_result_or_catch(task)) for name, task in tasks]
567*760c253cSXin Li
568*760c253cSXin Li
569*760c253cSXin Lidef find_chromeos_root_directory() -> Optional[str]:
570*760c253cSXin Li    return os.getenv("CHROMEOS_ROOT_DIRECTORY")
571*760c253cSXin Li
572*760c253cSXin Li
573*760c253cSXin Lidef check_cros_lint(
574*760c253cSXin Li    toolchain_utils_root: str,
575*760c253cSXin Li    thread_pool: multiprocessing.pool.ThreadPool,
576*760c253cSXin Li    files: Iterable[str],
577*760c253cSXin Li) -> CheckResults:
578*760c253cSXin Li    """Runs `cros lint`"""
579*760c253cSXin Li
580*760c253cSXin Li    fixed_env = env_with_pythonpath(toolchain_utils_root)
581*760c253cSXin Li
582*760c253cSXin Li    # We have to support users who don't have a chroot. So we either run `cros
583*760c253cSXin Li    # lint` (if it's been made available to us), or we try a mix of
584*760c253cSXin Li    # pylint+golint.
585*760c253cSXin Li    def try_run_cros_lint(cros_binary: str) -> Optional[CheckResult]:
586*760c253cSXin Li        exit_code, output = run_command_unchecked(
587*760c253cSXin Li            [cros_binary, "lint", "--"] + list(files),
588*760c253cSXin Li            toolchain_utils_root,
589*760c253cSXin Li            env=fixed_env,
590*760c253cSXin Li        )
591*760c253cSXin Li
592*760c253cSXin Li        # This is returned specifically if cros couldn't find the ChromeOS tree
593*760c253cSXin Li        # root.
594*760c253cSXin Li        if exit_code == 127:
595*760c253cSXin Li            return None
596*760c253cSXin Li
597*760c253cSXin Li        return CheckResult(
598*760c253cSXin Li            ok=exit_code == 0,
599*760c253cSXin Li            output=output,
600*760c253cSXin Li            autofix_commands=[],
601*760c253cSXin Li        )
602*760c253cSXin Li
603*760c253cSXin Li    cros_lint = try_run_cros_lint("cros")
604*760c253cSXin Li    if cros_lint is not None:
605*760c253cSXin Li        return cros_lint
606*760c253cSXin Li
607*760c253cSXin Li    cros_root = find_chromeos_root_directory()
608*760c253cSXin Li    if cros_root:
609*760c253cSXin Li        cros_lint = try_run_cros_lint(
610*760c253cSXin Li            os.path.join(cros_root, "chromite/bin/cros")
611*760c253cSXin Li        )
612*760c253cSXin Li        if cros_lint is not None:
613*760c253cSXin Li            return cros_lint
614*760c253cSXin Li
615*760c253cSXin Li    tasks = []
616*760c253cSXin Li
617*760c253cSXin Li    def check_result_from_command(command: List[str]) -> CheckResult:
618*760c253cSXin Li        exit_code, output = run_command_unchecked(
619*760c253cSXin Li            command, toolchain_utils_root, env=fixed_env
620*760c253cSXin Li        )
621*760c253cSXin Li        return CheckResult(
622*760c253cSXin Li            ok=exit_code == 0,
623*760c253cSXin Li            output=output,
624*760c253cSXin Li            autofix_commands=[],
625*760c253cSXin Li        )
626*760c253cSXin Li
627*760c253cSXin Li    python_files = [f for f in remove_deleted_files(files) if f.endswith(".py")]
628*760c253cSXin Li    if python_files:
629*760c253cSXin Li
630*760c253cSXin Li        def run_pylint() -> CheckResult:
631*760c253cSXin Li            # pylint is required. Fail hard if it DNE.
632*760c253cSXin Li            return check_result_from_command(["pylint"] + python_files)
633*760c253cSXin Li
634*760c253cSXin Li        tasks.append(("pylint", thread_pool.apply_async(run_pylint)))
635*760c253cSXin Li
636*760c253cSXin Li    go_files = [f for f in remove_deleted_files(files) if f.endswith(".go")]
637*760c253cSXin Li    if go_files:
638*760c253cSXin Li
639*760c253cSXin Li        def run_golint() -> CheckResult:
640*760c253cSXin Li            if has_executable_on_path("golint"):
641*760c253cSXin Li                return check_result_from_command(
642*760c253cSXin Li                    ["golint", "-set_exit_status"] + go_files
643*760c253cSXin Li                )
644*760c253cSXin Li
645*760c253cSXin Li            complaint = (
646*760c253cSXin Li                "WARNING: go linting disabled. golint is not on your $PATH.\n"
647*760c253cSXin Li                "Please either enter a chroot, or install go locally. "
648*760c253cSXin Li                "Continuing."
649*760c253cSXin Li            )
650*760c253cSXin Li            return CheckResult(
651*760c253cSXin Li                ok=True,
652*760c253cSXin Li                output=complaint,
653*760c253cSXin Li                autofix_commands=[],
654*760c253cSXin Li            )
655*760c253cSXin Li
656*760c253cSXin Li        tasks.append(("golint", thread_pool.apply_async(run_golint)))
657*760c253cSXin Li
658*760c253cSXin Li    complaint = (
659*760c253cSXin Li        "WARNING: No ChromeOS checkout detected, and no viable CrOS tree\n"
660*760c253cSXin Li        "found; falling back to linting only python and go. If you have a\n"
661*760c253cSXin Li        "ChromeOS checkout, please either develop from inside of the source\n"
662*760c253cSXin Li        "tree, or set $CHROMEOS_ROOT_DIRECTORY to the root of it."
663*760c253cSXin Li    )
664*760c253cSXin Li
665*760c253cSXin Li    results = [(name, get_check_result_or_catch(task)) for name, task in tasks]
666*760c253cSXin Li    if not results:
667*760c253cSXin Li        return CheckResult(
668*760c253cSXin Li            ok=True,
669*760c253cSXin Li            output=complaint,
670*760c253cSXin Li            autofix_commands=[],
671*760c253cSXin Li        )
672*760c253cSXin Li
673*760c253cSXin Li    # We need to complain _somewhere_.
674*760c253cSXin Li    name, angry_result = results[0]
675*760c253cSXin Li    angry_complaint = (complaint + "\n\n" + angry_result.output).strip()
676*760c253cSXin Li    results[0] = (name, angry_result._replace(output=angry_complaint))
677*760c253cSXin Li    return results
678*760c253cSXin Li
679*760c253cSXin Li
680*760c253cSXin Lidef check_go_format(toolchain_utils_root, _thread_pool, files):
681*760c253cSXin Li    """Runs gofmt on files to check for style bugs."""
682*760c253cSXin Li    gofmt = "gofmt"
683*760c253cSXin Li    if not has_executable_on_path(gofmt):
684*760c253cSXin Li        return CheckResult(
685*760c253cSXin Li            ok=False,
686*760c253cSXin Li            output="gofmt isn't available on your $PATH. Please either "
687*760c253cSXin Li            "enter a chroot, or place your go bin/ directory on your $PATH.",
688*760c253cSXin Li            autofix_commands=[],
689*760c253cSXin Li        )
690*760c253cSXin Li
691*760c253cSXin Li    go_files = [f for f in remove_deleted_files(files) if f.endswith(".go")]
692*760c253cSXin Li    if not go_files:
693*760c253cSXin Li        return CheckResult(
694*760c253cSXin Li            ok=True,
695*760c253cSXin Li            output="no go files to check",
696*760c253cSXin Li            autofix_commands=[],
697*760c253cSXin Li        )
698*760c253cSXin Li
699*760c253cSXin Li    command = [gofmt, "-l"] + go_files
700*760c253cSXin Li    exit_code, output = run_command_unchecked(command, cwd=toolchain_utils_root)
701*760c253cSXin Li
702*760c253cSXin Li    if exit_code:
703*760c253cSXin Li        return CheckResult(
704*760c253cSXin Li            ok=False,
705*760c253cSXin Li            output=f"{shlex.join(command)} failed; stdout/stderr:\n{output}",
706*760c253cSXin Li            autofix_commands=[],
707*760c253cSXin Li        )
708*760c253cSXin Li
709*760c253cSXin Li    output = output.strip()
710*760c253cSXin Li    if not output:
711*760c253cSXin Li        return CheckResult(
712*760c253cSXin Li            ok=True,
713*760c253cSXin Li            output="",
714*760c253cSXin Li            autofix_commands=[],
715*760c253cSXin Li        )
716*760c253cSXin Li
717*760c253cSXin Li    broken_files = [x.strip() for x in output.splitlines()]
718*760c253cSXin Li    autofix = [gofmt, "-w"] + broken_files
719*760c253cSXin Li    return CheckResult(
720*760c253cSXin Li        ok=False,
721*760c253cSXin Li        output="The following Go files have incorrect "
722*760c253cSXin Li        "formatting: %s" % broken_files,
723*760c253cSXin Li        autofix_commands=[autofix],
724*760c253cSXin Li    )
725*760c253cSXin Li
726*760c253cSXin Li
727*760c253cSXin Lidef check_no_compiler_wrapper_changes(
728*760c253cSXin Li    toolchain_utils_root: str,
729*760c253cSXin Li    _thread_pool: multiprocessing.pool.ThreadPool,
730*760c253cSXin Li    files: List[str],
731*760c253cSXin Li) -> CheckResult:
732*760c253cSXin Li    compiler_wrapper_prefix = (
733*760c253cSXin Li        os.path.join(toolchain_utils_root, "compiler_wrapper") + "/"
734*760c253cSXin Li    )
735*760c253cSXin Li    if not any(x.startswith(compiler_wrapper_prefix) for x in files):
736*760c253cSXin Li        return CheckResult(
737*760c253cSXin Li            ok=True,
738*760c253cSXin Li            output="no compiler_wrapper changes detected",
739*760c253cSXin Li            autofix_commands=[],
740*760c253cSXin Li        )
741*760c253cSXin Li
742*760c253cSXin Li    return CheckResult(
743*760c253cSXin Li        ok=False,
744*760c253cSXin Li        autofix_commands=[],
745*760c253cSXin Li        output=textwrap.dedent(
746*760c253cSXin Li            """\
747*760c253cSXin Li            Compiler wrapper changes should be made in chromiumos-overlay.
748*760c253cSXin Li            If you're a CrOS toolchain maintainer, please make the change
749*760c253cSXin Li            directly there now. If you're contributing as part of a downstream
750*760c253cSXin Li            (e.g., the Android toolchain team), feel free to bypass this check
751*760c253cSXin Li            and note to your reviewer that you received this message. They can
752*760c253cSXin Li            review your CL and commit to the right plate for you. Thanks!
753*760c253cSXin Li            """
754*760c253cSXin Li        ).strip(),
755*760c253cSXin Li    )
756*760c253cSXin Li
757*760c253cSXin Li
758*760c253cSXin Lidef check_tests(
759*760c253cSXin Li    toolchain_utils_root: str,
760*760c253cSXin Li    _thread_pool: multiprocessing.pool.ThreadPool,
761*760c253cSXin Li    files: List[str],
762*760c253cSXin Li) -> CheckResult:
763*760c253cSXin Li    """Runs tests."""
764*760c253cSXin Li    exit_code, stdout_and_stderr = run_command_unchecked(
765*760c253cSXin Li        [os.path.join(toolchain_utils_root, "run_tests_for.py"), "--"] + files,
766*760c253cSXin Li        toolchain_utils_root,
767*760c253cSXin Li    )
768*760c253cSXin Li    return CheckResult(
769*760c253cSXin Li        ok=exit_code == 0,
770*760c253cSXin Li        output=stdout_and_stderr,
771*760c253cSXin Li        autofix_commands=[],
772*760c253cSXin Li    )
773*760c253cSXin Li
774*760c253cSXin Li
775*760c253cSXin Lidef detect_toolchain_utils_root() -> str:
776*760c253cSXin Li    return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
777*760c253cSXin Li
778*760c253cSXin Li
779*760c253cSXin Lidef process_check_result(
780*760c253cSXin Li    check_name: str,
781*760c253cSXin Li    check_results: CheckResults,
782*760c253cSXin Li    start_time: datetime.datetime,
783*760c253cSXin Li) -> Tuple[bool, List[List[str]]]:
784*760c253cSXin Li    """Prints human-readable output for the given check_results."""
785*760c253cSXin Li    indent = "  "
786*760c253cSXin Li
787*760c253cSXin Li    def indent_block(text: str) -> str:
788*760c253cSXin Li        return indent + text.replace("\n", "\n" + indent)
789*760c253cSXin Li
790*760c253cSXin Li    if isinstance(check_results, CheckResult):
791*760c253cSXin Li        ok, output, autofix_commands = check_results
792*760c253cSXin Li        if not ok and autofix_commands:
793*760c253cSXin Li            recommendation = (
794*760c253cSXin Li                "Recommended command(s) to fix this: "
795*760c253cSXin Li                f"{[shlex.join(x) for x in autofix_commands]}"
796*760c253cSXin Li            )
797*760c253cSXin Li            if output:
798*760c253cSXin Li                output += "\n" + recommendation
799*760c253cSXin Li            else:
800*760c253cSXin Li                output = recommendation
801*760c253cSXin Li    else:
802*760c253cSXin Li        output_pieces = []
803*760c253cSXin Li        autofix_commands = []
804*760c253cSXin Li        for subname, (ok, output, autofix) in check_results:
805*760c253cSXin Li            status = "succeeded" if ok else "failed"
806*760c253cSXin Li            message = ["*** %s.%s %s" % (check_name, subname, status)]
807*760c253cSXin Li            if output:
808*760c253cSXin Li                message.append(indent_block(output))
809*760c253cSXin Li            if not ok and autofix:
810*760c253cSXin Li                message.append(
811*760c253cSXin Li                    indent_block(
812*760c253cSXin Li                        "Recommended command(s) to fix this: "
813*760c253cSXin Li                        f"{[shlex.join(x) for x in autofix]}"
814*760c253cSXin Li                    )
815*760c253cSXin Li                )
816*760c253cSXin Li
817*760c253cSXin Li            output_pieces.append("\n".join(message))
818*760c253cSXin Li            autofix_commands += autofix
819*760c253cSXin Li
820*760c253cSXin Li        ok = all(x.ok for _, x in check_results)
821*760c253cSXin Li        output = "\n\n".join(output_pieces)
822*760c253cSXin Li
823*760c253cSXin Li    time_taken = datetime.datetime.now() - start_time
824*760c253cSXin Li    if ok:
825*760c253cSXin Li        print("*** %s succeeded after %s" % (check_name, time_taken))
826*760c253cSXin Li    else:
827*760c253cSXin Li        print("*** %s failed after %s" % (check_name, time_taken))
828*760c253cSXin Li
829*760c253cSXin Li    if output:
830*760c253cSXin Li        print(indent_block(output))
831*760c253cSXin Li
832*760c253cSXin Li    print()
833*760c253cSXin Li    return ok, autofix_commands
834*760c253cSXin Li
835*760c253cSXin Li
836*760c253cSXin Lidef try_autofix(
837*760c253cSXin Li    all_autofix_commands: List[List[str]], toolchain_utils_root: str
838*760c253cSXin Li) -> None:
839*760c253cSXin Li    """Tries to run all given autofix commands, if appropriate."""
840*760c253cSXin Li    if not all_autofix_commands:
841*760c253cSXin Li        return
842*760c253cSXin Li
843*760c253cSXin Li    exit_code, output = run_command_unchecked(
844*760c253cSXin Li        ["git", "status", "--porcelain"], cwd=toolchain_utils_root
845*760c253cSXin Li    )
846*760c253cSXin Li    if exit_code != 0:
847*760c253cSXin Li        print("Autofix aborted: couldn't get toolchain-utils git status.")
848*760c253cSXin Li        return
849*760c253cSXin Li
850*760c253cSXin Li    if output.strip():
851*760c253cSXin Li        # A clean repo makes checking/undoing autofix commands trivial. A dirty
852*760c253cSXin Li        # one... less so. :)
853*760c253cSXin Li        print("Git repo seems dirty; skipping autofix.")
854*760c253cSXin Li        return
855*760c253cSXin Li
856*760c253cSXin Li    anything_succeeded = False
857*760c253cSXin Li    for command in all_autofix_commands:
858*760c253cSXin Li        exit_code, output = run_command_unchecked(
859*760c253cSXin Li            command, cwd=toolchain_utils_root
860*760c253cSXin Li        )
861*760c253cSXin Li
862*760c253cSXin Li        if exit_code:
863*760c253cSXin Li            print(
864*760c253cSXin Li                f"*** Autofix command `{shlex.join(command)}` exited with "
865*760c253cSXin Li                f"code {exit_code}; stdout/stderr:"
866*760c253cSXin Li            )
867*760c253cSXin Li            print(output)
868*760c253cSXin Li        else:
869*760c253cSXin Li            print(f"*** Autofix `{shlex.join(command)}` succeeded")
870*760c253cSXin Li            anything_succeeded = True
871*760c253cSXin Li
872*760c253cSXin Li    if anything_succeeded:
873*760c253cSXin Li        print(
874*760c253cSXin Li            "NOTE: Autofixes have been applied. Please check your tree, since "
875*760c253cSXin Li            "some lints may now be fixed"
876*760c253cSXin Li        )
877*760c253cSXin Li
878*760c253cSXin Li
879*760c253cSXin Lidef find_repo_root(base_dir: str) -> Optional[str]:
880*760c253cSXin Li    current = base_dir
881*760c253cSXin Li    while current != "/":
882*760c253cSXin Li        if os.path.isdir(os.path.join(current, ".repo")):
883*760c253cSXin Li            return current
884*760c253cSXin Li        current = os.path.dirname(current)
885*760c253cSXin Li    return None
886*760c253cSXin Li
887*760c253cSXin Li
888*760c253cSXin Lidef is_in_chroot() -> bool:
889*760c253cSXin Li    return os.path.exists("/etc/cros_chroot_version")
890*760c253cSXin Li
891*760c253cSXin Li
892*760c253cSXin Lidef maybe_reexec_inside_chroot(
893*760c253cSXin Li    autofix: bool, install_deps_only: bool, files: List[str]
894*760c253cSXin Li) -> None:
895*760c253cSXin Li    if is_in_chroot():
896*760c253cSXin Li        return
897*760c253cSXin Li
898*760c253cSXin Li    enter_chroot = True
899*760c253cSXin Li    chdir_to = None
900*760c253cSXin Li    toolchain_utils = detect_toolchain_utils_root()
901*760c253cSXin Li    if find_repo_root(toolchain_utils) is None:
902*760c253cSXin Li        chromeos_root_dir = find_chromeos_root_directory()
903*760c253cSXin Li        if chromeos_root_dir is None:
904*760c253cSXin Li            print(
905*760c253cSXin Li                "Standalone toolchain-utils checkout detected; cannot enter "
906*760c253cSXin Li                "chroot."
907*760c253cSXin Li            )
908*760c253cSXin Li            enter_chroot = False
909*760c253cSXin Li        else:
910*760c253cSXin Li            chdir_to = chromeos_root_dir
911*760c253cSXin Li
912*760c253cSXin Li    if not has_executable_on_path("cros_sdk"):
913*760c253cSXin Li        print("No `cros_sdk` detected on $PATH; cannot enter chroot.")
914*760c253cSXin Li        enter_chroot = False
915*760c253cSXin Li
916*760c253cSXin Li    if not enter_chroot:
917*760c253cSXin Li        print(
918*760c253cSXin Li            "Giving up on entering the chroot; be warned that some presubmits "
919*760c253cSXin Li            "may be broken."
920*760c253cSXin Li        )
921*760c253cSXin Li        return
922*760c253cSXin Li
923*760c253cSXin Li    # We'll be changing ${PWD}, so make everything relative to toolchain-utils,
924*760c253cSXin Li    # which resides at a well-known place inside of the chroot.
925*760c253cSXin Li    chroot_toolchain_utils = "/mnt/host/source/src/third_party/toolchain-utils"
926*760c253cSXin Li
927*760c253cSXin Li    def rebase_path(path: str) -> str:
928*760c253cSXin Li        return os.path.join(
929*760c253cSXin Li            chroot_toolchain_utils, os.path.relpath(path, toolchain_utils)
930*760c253cSXin Li        )
931*760c253cSXin Li
932*760c253cSXin Li    args = [
933*760c253cSXin Li        "cros_sdk",
934*760c253cSXin Li        "--enter",
935*760c253cSXin Li        "--",
936*760c253cSXin Li        rebase_path(__file__),
937*760c253cSXin Li    ]
938*760c253cSXin Li
939*760c253cSXin Li    if not autofix:
940*760c253cSXin Li        args.append("--no_autofix")
941*760c253cSXin Li    if install_deps_only:
942*760c253cSXin Li        args.append("--install_deps_only")
943*760c253cSXin Li    args.extend(rebase_path(x) for x in files)
944*760c253cSXin Li
945*760c253cSXin Li    if chdir_to is None:
946*760c253cSXin Li        print("Attempting to enter the chroot...")
947*760c253cSXin Li    else:
948*760c253cSXin Li        print(f"Attempting to enter the chroot for tree at {chdir_to}...")
949*760c253cSXin Li        os.chdir(chdir_to)
950*760c253cSXin Li    os.execvp(args[0], args)
951*760c253cSXin Li
952*760c253cSXin Li
953*760c253cSXin Lidef can_import_py_module(module: str) -> bool:
954*760c253cSXin Li    """Returns true if `import {module}` works."""
955*760c253cSXin Li    exit_code = subprocess.call(
956*760c253cSXin Li        ["python3", "-c", f"import {module}"],
957*760c253cSXin Li        stdout=subprocess.DEVNULL,
958*760c253cSXin Li        stderr=subprocess.DEVNULL,
959*760c253cSXin Li    )
960*760c253cSXin Li    return exit_code == 0
961*760c253cSXin Li
962*760c253cSXin Li
963*760c253cSXin Lidef ensure_pip_deps_installed() -> None:
964*760c253cSXin Li    if not PIP_DEPENDENCIES:
965*760c253cSXin Li        # No need to install pip if we don't have any deps.
966*760c253cSXin Li        return
967*760c253cSXin Li
968*760c253cSXin Li    pip = get_pip()
969*760c253cSXin Li    assert pip, "pip not found and could not be installed"
970*760c253cSXin Li
971*760c253cSXin Li    for package in PIP_DEPENDENCIES:
972*760c253cSXin Li        subprocess.check_call(pip + ["install", "--user", package])
973*760c253cSXin Li
974*760c253cSXin Li
975*760c253cSXin Lidef main(argv: List[str]) -> int:
976*760c253cSXin Li    parser = argparse.ArgumentParser(description=__doc__)
977*760c253cSXin Li    parser.add_argument(
978*760c253cSXin Li        "--no_autofix",
979*760c253cSXin Li        dest="autofix",
980*760c253cSXin Li        action="store_false",
981*760c253cSXin Li        help="Don't run any autofix commands.",
982*760c253cSXin Li    )
983*760c253cSXin Li    parser.add_argument(
984*760c253cSXin Li        "--no_enter_chroot",
985*760c253cSXin Li        dest="enter_chroot",
986*760c253cSXin Li        action="store_false",
987*760c253cSXin Li        help="Prevent auto-entering the chroot if we're not already in it.",
988*760c253cSXin Li    )
989*760c253cSXin Li    parser.add_argument(
990*760c253cSXin Li        "--install_deps_only",
991*760c253cSXin Li        action="store_true",
992*760c253cSXin Li        help="""
993*760c253cSXin Li        Only install dependencies that would be required if presubmits were
994*760c253cSXin Li        being run, and quit. This skips all actual checking.
995*760c253cSXin Li        """,
996*760c253cSXin Li    )
997*760c253cSXin Li    parser.add_argument("files", nargs="*")
998*760c253cSXin Li    opts = parser.parse_args(argv)
999*760c253cSXin Li
1000*760c253cSXin Li    files = opts.files
1001*760c253cSXin Li    install_deps_only = opts.install_deps_only
1002*760c253cSXin Li    if not files and not install_deps_only:
1003*760c253cSXin Li        return 0
1004*760c253cSXin Li
1005*760c253cSXin Li    if opts.enter_chroot:
1006*760c253cSXin Li        maybe_reexec_inside_chroot(opts.autofix, install_deps_only, files)
1007*760c253cSXin Li
1008*760c253cSXin Li    # If you ask for --no_enter_chroot, you're on your own for installing these
1009*760c253cSXin Li    # things.
1010*760c253cSXin Li    if is_in_chroot():
1011*760c253cSXin Li        ensure_pip_deps_installed()
1012*760c253cSXin Li        if install_deps_only:
1013*760c253cSXin Li            print(
1014*760c253cSXin Li                "Dependency installation complete & --install_deps_only "
1015*760c253cSXin Li                "passed. Quit."
1016*760c253cSXin Li            )
1017*760c253cSXin Li            return 0
1018*760c253cSXin Li    elif install_deps_only:
1019*760c253cSXin Li        parser.error(
1020*760c253cSXin Li            "--install_deps_only is meaningless if the chroot isn't entered"
1021*760c253cSXin Li        )
1022*760c253cSXin Li
1023*760c253cSXin Li    files = [os.path.abspath(f) for f in files]
1024*760c253cSXin Li
1025*760c253cSXin Li    # Note that we extract .__name__s from these, so please name them in a
1026*760c253cSXin Li    # user-friendly way.
1027*760c253cSXin Li    checks = (
1028*760c253cSXin Li        check_cros_lint,
1029*760c253cSXin Li        check_py_format,
1030*760c253cSXin Li        check_py_types,
1031*760c253cSXin Li        check_go_format,
1032*760c253cSXin Li        check_tests,
1033*760c253cSXin Li        check_no_compiler_wrapper_changes,
1034*760c253cSXin Li    )
1035*760c253cSXin Li
1036*760c253cSXin Li    toolchain_utils_root = detect_toolchain_utils_root()
1037*760c253cSXin Li
1038*760c253cSXin Li    # NOTE: As mentioned above, checks can block on threads they spawn in this
1039*760c253cSXin Li    # pool, so we need at least len(checks)+1 threads to avoid deadlock. Use *2
1040*760c253cSXin Li    # so all checks can make progress at a decent rate.
1041*760c253cSXin Li    num_threads = max(multiprocessing.cpu_count(), len(checks) * 2)
1042*760c253cSXin Li    start_time = datetime.datetime.now()
1043*760c253cSXin Li
1044*760c253cSXin Li    # For our single print statement...
1045*760c253cSXin Li    spawn_print_lock = threading.RLock()
1046*760c253cSXin Li
1047*760c253cSXin Li    def run_check(check_fn):
1048*760c253cSXin Li        name = check_fn.__name__
1049*760c253cSXin Li        with spawn_print_lock:
1050*760c253cSXin Li            print("*** Spawning %s" % name)
1051*760c253cSXin Li        return name, check_fn(toolchain_utils_root, pool, files)
1052*760c253cSXin Li
1053*760c253cSXin Li    with multiprocessing.pool.ThreadPool(num_threads) as pool:
1054*760c253cSXin Li        all_checks_ok = True
1055*760c253cSXin Li        all_autofix_commands = []
1056*760c253cSXin Li        for check_name, result in pool.imap_unordered(run_check, checks):
1057*760c253cSXin Li            ok, autofix_commands = process_check_result(
1058*760c253cSXin Li                check_name, result, start_time
1059*760c253cSXin Li            )
1060*760c253cSXin Li            all_checks_ok = ok and all_checks_ok
1061*760c253cSXin Li            all_autofix_commands += autofix_commands
1062*760c253cSXin Li
1063*760c253cSXin Li    # Run these after everything settles, so:
1064*760c253cSXin Li    # - we don't collide with checkers that are running concurrently
1065*760c253cSXin Li    # - we clearly print out everything that went wrong ahead of time, in case
1066*760c253cSXin Li    #   any of these fail
1067*760c253cSXin Li    if opts.autofix:
1068*760c253cSXin Li        try_autofix(all_autofix_commands, toolchain_utils_root)
1069*760c253cSXin Li
1070*760c253cSXin Li    if not all_checks_ok:
1071*760c253cSXin Li        return 1
1072*760c253cSXin Li    return 0
1073*760c253cSXin Li
1074*760c253cSXin Li
1075*760c253cSXin Liif __name__ == "__main__":
1076*760c253cSXin Li    sys.exit(main(sys.argv[1:]))
1077