mirror of
https://github.com/trezor/trezor-firmware.git
synced 2024-12-25 07:48:10 +00:00
feat(tools): make pyright_tool more user-friendly
[no changelog] * passed in directory respects absolute and relative paths instead of working "from repository root" * we don't require the enableTypeIgnoreComments to be set so both `pyright` and `pyright_tool` can work with the same config at the same time * use click's magic functionality to deal with missing / unreadable / unwriteable files * read the error results via a pipe, do not write to filesystem unless requested * simplified logic regarding "test mode"/"dev mode" * renamed `--log` to more typical `--verbose` * use pathlib more extensively
This commit is contained in:
parent
b365788d88
commit
61718aff49
@ -116,7 +116,7 @@ mypy: ## deprecated; use "make typecheck"
|
||||
typecheck: pyright
|
||||
|
||||
pyright:
|
||||
python ./../tools/pyright_tool.py --dir core
|
||||
python ../tools/pyright_tool.py
|
||||
|
||||
clippy:
|
||||
cd embed/rust ; cargo clippy
|
||||
|
@ -10,6 +10,5 @@
|
||||
"stubPath": "mocks/generated",
|
||||
"typeCheckingMode": "basic",
|
||||
"pythonVersion": "3.10",
|
||||
"enableTypeIgnoreComments": false,
|
||||
"reportMissingModuleSource": false
|
||||
}
|
||||
|
@ -67,4 +67,4 @@ test:
|
||||
pytest tests
|
||||
|
||||
pyright:
|
||||
python ./../tools/pyright_tool.py --dir python
|
||||
python ./../tools/pyright_tool.py
|
||||
|
@ -6,7 +6,6 @@
|
||||
],
|
||||
"pythonVersion": "3.6",
|
||||
"typeCheckingMode": "basic",
|
||||
"enableTypeIgnoreComments": false,
|
||||
"reportMissingImports": false,
|
||||
"reportUntypedFunctionDecorator": true,
|
||||
"reportUntypedClassDecorator": true,
|
||||
|
@ -26,7 +26,6 @@ Usage:
|
||||
|
||||
Running the script:
|
||||
- see all script argument by calling `python pyright_tool.py --help`
|
||||
- only directories with existing `pyrightconfig.json` can be tested - see `--dir` flag
|
||||
|
||||
Simplified program flow (as it happens in PyrightTool.run()):
|
||||
- extract and validate pyright config data from pyrightconfig.json
|
||||
@ -39,15 +38,17 @@ Simplified program flow (as it happens in PyrightTool.run()):
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Final, TypedDict, TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Any, Final, Iterator, TypedDict
|
||||
|
||||
import click
|
||||
|
||||
if TYPE_CHECKING:
|
||||
LineIgnores = list[LineIgnore]
|
||||
@ -122,50 +123,6 @@ class PyrightOffIgnore:
|
||||
already_used: bool = False
|
||||
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"--dev", action="store_true", help="Creating the error file and not deleting it"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--test",
|
||||
action="store_true",
|
||||
help="Reusing existing error file and not deleting it",
|
||||
)
|
||||
parser.add_argument("--log", action="store_true", help="Log details")
|
||||
parser.add_argument(
|
||||
"--dir",
|
||||
help="Directory which to test, relative to the repository root. When empty, taking the directory of this file.",
|
||||
default="",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.dev:
|
||||
should_generate_error_file = True
|
||||
should_delete_error_file = False
|
||||
print("Running in dev mode, creating the file and not deleting it")
|
||||
elif args.test:
|
||||
should_generate_error_file = False
|
||||
should_delete_error_file = False
|
||||
print("Running in test mode, will reuse existing error file")
|
||||
else:
|
||||
should_generate_error_file = True
|
||||
should_delete_error_file = True
|
||||
|
||||
SHOULD_GENERATE_ERROR_FILE = should_generate_error_file
|
||||
SHOULD_DELETE_ERROR_FILE = should_delete_error_file
|
||||
SHOULD_LOG = args.log
|
||||
|
||||
if args.dir:
|
||||
# Need to change the os directory to find all the files correctly
|
||||
# Repository root + the wanted directory.
|
||||
HERE = Path(__file__).resolve().parent.parent / args.dir
|
||||
if not HERE.is_dir():
|
||||
raise RuntimeError(f"Could not find directory {args.dir} under {HERE}")
|
||||
os.chdir(HERE)
|
||||
else:
|
||||
# Directory of this file
|
||||
HERE = Path(__file__).resolve().parent
|
||||
|
||||
# TODO: move into a JSON or other config file
|
||||
# Files need to have a relative location to the directory being tested
|
||||
# Example (when checking `python` directory):
|
||||
@ -200,25 +157,33 @@ class PyrightTool:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
pyright_config_file: str | Path,
|
||||
workdir: Path,
|
||||
pyright_config_file: io.TextIOWrapper,
|
||||
*,
|
||||
file_specific_ignores: FileSpecificIgnores | None = None,
|
||||
aliases: dict[str, str] | None = None,
|
||||
error_file: str | Path = "temp_error_file.json",
|
||||
should_generate_error_file: bool = True,
|
||||
should_delete_error_file: bool = True,
|
||||
should_log: bool = False,
|
||||
input_file: io.TextIOWrapper | None = None,
|
||||
error_file: io.TextIOWrapper | None = None,
|
||||
verbose: bool = False,
|
||||
) -> None:
|
||||
self.pyright_config_file = pyright_config_file
|
||||
# validate arguments
|
||||
if not pyright_config_file.readable():
|
||||
raise RuntimeError("pyright config file is not readable")
|
||||
if input_file is not None and not input_file.readable():
|
||||
raise RuntimeError("input file is not readable")
|
||||
if error_file is not None and not error_file.writable():
|
||||
raise RuntimeError("error file is not writable")
|
||||
|
||||
# save config
|
||||
self.workdir = workdir.resolve()
|
||||
self.pyright_config_data = self.load_config(pyright_config_file)
|
||||
self.file_specific_ignores = file_specific_ignores or {}
|
||||
self.aliases = aliases or {}
|
||||
self.input_file = input_file
|
||||
self.error_file = error_file
|
||||
self.should_generate_error_file = should_generate_error_file
|
||||
self.should_delete_error_file = should_delete_error_file
|
||||
self.should_log = should_log
|
||||
self.verbose = verbose
|
||||
|
||||
self.count_of_ignored_errors = 0
|
||||
|
||||
self.check_input_correctness()
|
||||
|
||||
def check_input_correctness(self) -> None:
|
||||
@ -247,8 +212,6 @@ class PyrightTool:
|
||||
|
||||
def run(self) -> None:
|
||||
"""Main function, putting together all logic and evaluating result."""
|
||||
self.pyright_config_data = self.get_and_validate_pyright_config_data()
|
||||
|
||||
self.original_pyright_results = self.get_original_pyright_results()
|
||||
|
||||
self.all_files_to_check = self.get_all_files_to_check()
|
||||
@ -293,76 +256,72 @@ class PyrightTool:
|
||||
print("And we have unused ignores or inconsistencies!")
|
||||
sys.exit(1)
|
||||
|
||||
def get_and_validate_pyright_config_data(self) -> dict[str, Any]:
|
||||
"""Verify that pyrightconfig exists and has correct data."""
|
||||
if not os.path.isfile(self.pyright_config_file):
|
||||
raise RuntimeError(
|
||||
f"Pyright config file under {self.pyright_config_file} does not exist! "
|
||||
"Tool relies on its existence, please create it."
|
||||
)
|
||||
|
||||
def load_config(self, config: io.TextIOWrapper) -> dict[str, Any]:
|
||||
"""Load pyright config and validate any errors."""
|
||||
try:
|
||||
config_data = json.loads(open(self.pyright_config_file, "r").read())
|
||||
return json.load(config)
|
||||
except json.decoder.JSONDecodeError as err:
|
||||
raise RuntimeError(
|
||||
f"Pyright config under {self.pyright_config_file} does not contain valid JSON! Err: {err}"
|
||||
) from None
|
||||
f"Pyright config does not contain valid JSON! Err: {err}"
|
||||
) from err
|
||||
|
||||
# enableTypeIgnoreComments MUST be set to False, otherwise the "type: ignore"s
|
||||
# will affect the original pyright result - and we need it to grab all the errors
|
||||
# so we can handle them on our own
|
||||
if (
|
||||
"enableTypeIgnoreComments" not in config_data
|
||||
or config_data["enableTypeIgnoreComments"]
|
||||
):
|
||||
raise RuntimeError(
|
||||
f"Please set '\"enableTypeIgnoreComments\": true' in {self.pyright_config_file}. "
|
||||
"Otherwise the tool will not work as expected."
|
||||
def get_pyright_output(self) -> str:
|
||||
"""Run pyright and return its output."""
|
||||
# generate config with enableTypeIgnoreComments: false
|
||||
config_data = self.pyright_config_data.copy()
|
||||
config_data["enableTypeIgnoreComments"] = False
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".json", dir=self.workdir) as tmp:
|
||||
json.dump(config_data, tmp)
|
||||
tmp.flush()
|
||||
|
||||
cmd = (
|
||||
"pyright",
|
||||
"--outputjson",
|
||||
"--project",
|
||||
str(Path(tmp.name).resolve()),
|
||||
)
|
||||
|
||||
return config_data
|
||||
# run pyright with generated config
|
||||
result = subprocess.run(cmd, stdout=subprocess.PIPE, text=True)
|
||||
|
||||
def get_original_pyright_results(self) -> PyrightResults:
|
||||
"""Extract all information from pyright.
|
||||
|
||||
`pyright --outputjson` will return all the results in
|
||||
nice JSON format with `generalDiagnostics` array storing
|
||||
all the errors - schema described in PyrightResults
|
||||
"""
|
||||
if self.should_generate_error_file:
|
||||
cmd = f"pyright -p {self.pyright_config_file} --outputjson > {self.error_file}"
|
||||
exit_code = subprocess.call(cmd, shell=True)
|
||||
# Checking if there was no non-type-checking error when running the above command
|
||||
# Exit code 0 = all fine, no type-checking issues in pyright
|
||||
# Exit code 1 = pyright has found some type-checking issues (expected)
|
||||
# All other exit codes mean something non-type-related got wrong (or pyright was not found)
|
||||
# https://github.com/microsoft/pyright/blob/main/docs/command-line.md#pyright-exit-codes
|
||||
if exit_code not in (0, 1):
|
||||
if result.returncode not in (0, 1):
|
||||
raise RuntimeError(
|
||||
f"Running '{cmd}' produced a non-expected exit code (see output above)."
|
||||
f"Running '{' '.join(cmd)}' produced a non-expected exit code (see output above)."
|
||||
)
|
||||
|
||||
if not os.path.isfile(self.error_file):
|
||||
if not result.stdout:
|
||||
raise RuntimeError(
|
||||
f"Pyright error file under {self.error_file} was not generated by running '{cmd}'."
|
||||
f"Running '{' '.join(cmd)}' produced no data (see output above)."
|
||||
)
|
||||
|
||||
return result.stdout
|
||||
|
||||
def get_original_pyright_results(self) -> PyrightResults:
|
||||
"""Extract pyright results data in a structured format.
|
||||
|
||||
That means either running `pyright --outputjson`, or loading the provided JSON
|
||||
file created by an earlier run.
|
||||
"""
|
||||
if self.input_file is not None:
|
||||
pyright_result_str = self.input_file.read()
|
||||
else:
|
||||
pyright_result_str = self.get_pyright_output()
|
||||
|
||||
if self.error_file is not None:
|
||||
self.error_file.write(pyright_result_str)
|
||||
|
||||
try:
|
||||
pyright_results: PyrightResults = json.loads(
|
||||
open(self.error_file, "r").read()
|
||||
)
|
||||
except FileNotFoundError:
|
||||
raise RuntimeError(
|
||||
f"Error file under {self.error_file} does not exist!"
|
||||
) from None
|
||||
pyright_results: PyrightResults = json.loads(pyright_result_str)
|
||||
except json.decoder.JSONDecodeError as err:
|
||||
raise RuntimeError(
|
||||
f"Error file under {self.error_file} does not contain valid JSON! Err: {err}"
|
||||
f"Input error file does not contain valid JSON! Err: {err}"
|
||||
) from None
|
||||
|
||||
if self.should_delete_error_file:
|
||||
os.remove(self.error_file)
|
||||
|
||||
return pyright_results
|
||||
|
||||
def get_all_real_errors(self) -> list[Error]:
|
||||
@ -412,38 +371,25 @@ class PyrightTool:
|
||||
|
||||
def get_all_files_to_check(self) -> set[str]:
|
||||
"""Get all files to be analyzed by pyright, based on its config."""
|
||||
all_files: set[str] = set()
|
||||
all_files: set[Path] = set()
|
||||
|
||||
if "include" in self.pyright_config_data:
|
||||
for dir_or_file in self.pyright_config_data["include"]:
|
||||
for file in self.get_all_py_files_recursively(dir_or_file):
|
||||
all_files.add(file)
|
||||
def _all_files(entry: str) -> Iterator[Path]:
|
||||
file_or_dir = Path(self.workdir / entry)
|
||||
if file_or_dir.is_file():
|
||||
yield file_or_dir
|
||||
else:
|
||||
# "include" is missing, we should analyze all files in current dir
|
||||
for file in self.get_all_py_files_recursively("."):
|
||||
all_files.add(file)
|
||||
yield from file_or_dir.glob("**/*.py")
|
||||
|
||||
if "exclude" in self.pyright_config_data:
|
||||
for dir_or_file in self.pyright_config_data["exclude"]:
|
||||
for file in self.get_all_py_files_recursively(dir_or_file):
|
||||
if file in all_files:
|
||||
all_files.remove(file)
|
||||
# include all relevant files.
|
||||
# use either the entries in `include`, or the current directory
|
||||
for entry in self.pyright_config_data.get("include", ("",)):
|
||||
all_files.update(_all_files(entry))
|
||||
|
||||
return all_files
|
||||
# exclude specified files
|
||||
for entry in self.pyright_config_data.get("exclude", ()):
|
||||
all_files -= set(_all_files(entry))
|
||||
|
||||
@staticmethod
|
||||
def get_all_py_files_recursively(dir_or_file: str) -> set[str]:
|
||||
"""Return all python files in certain directory (or the file itself)."""
|
||||
if os.path.isfile(dir_or_file):
|
||||
return set(str(HERE / dir_or_file))
|
||||
|
||||
all_files: set[str] = set()
|
||||
for root, _, files in os.walk(dir_or_file):
|
||||
for file in files:
|
||||
if file.endswith(".py"):
|
||||
all_files.add(str(HERE / os.path.join(root, file)))
|
||||
|
||||
return all_files
|
||||
return {str(f) for f in all_files}
|
||||
|
||||
def get_all_pyright_ignores(self) -> FileIgnores:
|
||||
"""Get ignore information from all the files to be analyzed."""
|
||||
@ -618,7 +564,7 @@ class PyrightTool:
|
||||
|
||||
def log_ignore(self, error: Error, reason: str) -> None:
|
||||
"""Print the action of ignoring certain error into the console."""
|
||||
if self.should_log:
|
||||
if self.verbose:
|
||||
err = self.get_human_readable_error_string(error)
|
||||
print(f"\nError ignored. Reason: {reason}.\nErr: {err}")
|
||||
|
||||
@ -634,16 +580,53 @@ class PyrightTool:
|
||||
return f"{file}:{line + 1}: - error: {message} ({rule})\n"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
@click.command()
|
||||
@click.argument(
|
||||
"workdir", type=click.Path(exists=True, file_okay=False, dir_okay=True), default="."
|
||||
)
|
||||
@click.option(
|
||||
"--config",
|
||||
type=click.File("r"),
|
||||
help="Pyright configuration file. Defaults to pyrightconfig.json in the selected (or current) directory.",
|
||||
)
|
||||
@click.option(
|
||||
"-o",
|
||||
"--output",
|
||||
"output_file",
|
||||
type=click.File("w"),
|
||||
help="Save pyright JSON output to file",
|
||||
)
|
||||
@click.option(
|
||||
"-i",
|
||||
"--input",
|
||||
"input_file",
|
||||
type=click.File("r"),
|
||||
help="Use input file instead of running pyright",
|
||||
)
|
||||
@click.option("-v", "--verbose", is_flag=True, help="Print verbose output")
|
||||
def main(config, input_file, output_file, verbose, workdir):
|
||||
workdir = Path(workdir)
|
||||
if config is None:
|
||||
config_path = workdir / "pyrightconfig.json"
|
||||
try:
|
||||
config = open(config_path)
|
||||
except Exception:
|
||||
raise click.ClickException(f"Failed to load {config_path}")
|
||||
|
||||
try:
|
||||
tool = PyrightTool(
|
||||
pyright_config_file=HERE / "pyrightconfig.json",
|
||||
file_specific_ignores={
|
||||
str(HERE / k): v for k, v in FILE_SPECIFIC_IGNORES.items()
|
||||
},
|
||||
workdir=workdir,
|
||||
pyright_config_file=config,
|
||||
file_specific_ignores=FILE_SPECIFIC_IGNORES,
|
||||
aliases=ALIASES,
|
||||
error_file="errors_for_pyright_temp.json",
|
||||
should_generate_error_file=SHOULD_GENERATE_ERROR_FILE,
|
||||
should_delete_error_file=SHOULD_DELETE_ERROR_FILE,
|
||||
should_log=SHOULD_LOG,
|
||||
input_file=input_file,
|
||||
error_file=output_file,
|
||||
verbose=verbose,
|
||||
)
|
||||
tool.run()
|
||||
except Exception as e:
|
||||
raise click.ClickException(str(e)) from e
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
Loading…
Reference in New Issue
Block a user