ha-core/script/lint_and_test.py

243 lines
6.8 KiB
Python
Raw Normal View History

2018-03-09 21:27:39 +01:00
#!/usr/bin/env python3
2023-02-03 23:08:48 +01:00
"""Quickly check if branch is up to PR standards.
2018-03-09 21:27:39 +01:00
2022-11-23 14:40:37 +01:00
This is NOT a full CI/linting replacement, only a quick check during development.
2018-03-09 21:27:39 +01:00
"""
import asyncio
from collections import namedtuple
from contextlib import suppress
import itertools
import os
2018-03-09 21:27:39 +01:00
import re
import shlex
import sys
2018-03-09 21:27:39 +01:00
try:
from colorlog.escape_codes import escape_codes
except ImportError:
escape_codes = None
RE_ASCII = re.compile(r"\033\[[^m]*m")
2019-07-31 21:25:30 +02:00
Error = namedtuple("Error", ["file", "line", "col", "msg", "skip"])
2018-03-09 21:27:39 +01:00
2019-07-31 21:25:30 +02:00
PASS = "green"
FAIL = "bold_red"
2018-03-09 21:27:39 +01:00
def printc(the_color, *args):
"""Color print helper."""
2019-07-31 21:25:30 +02:00
msg = " ".join(args)
2018-03-09 21:27:39 +01:00
if not escape_codes:
print(msg)
return
try:
2019-07-31 21:25:30 +02:00
print(escape_codes[the_color] + msg + escape_codes["reset"])
2021-04-01 20:34:01 +02:00
except KeyError as err:
2018-03-09 21:27:39 +01:00
print(msg)
2021-04-01 20:34:01 +02:00
raise ValueError(f"Invalid color {the_color}") from err
2018-03-09 21:27:39 +01:00
def validate_requirements_ok():
"""Validate requirements, returns True of ok."""
# pylint: disable-next=import-error,import-outside-toplevel
2018-03-09 21:27:39 +01:00
from gen_requirements_all import main as req_main
2019-07-31 21:25:30 +02:00
2018-03-09 21:27:39 +01:00
return req_main(True) == 0
async def read_stream(stream, display):
"""Read from stream line by line until EOF, display, and capture lines."""
output = []
while True:
line = await stream.readline()
if not line:
break
output.append(line)
display(line.decode()) # assume it doesn't block
2019-07-31 21:25:30 +02:00
return b"".join(output)
2018-03-09 21:27:39 +01:00
async def async_exec(*args, display=False):
"""Execute, return code & log."""
argsp = []
for arg in args:
if os.path.isfile(arg):
argsp.append(f"\\\n {shlex.quote(arg)}")
2018-03-09 21:27:39 +01:00
else:
argsp.append(shlex.quote(arg))
2019-07-31 21:25:30 +02:00
printc("cyan", *argsp)
2018-03-09 21:27:39 +01:00
try:
2019-07-31 21:25:30 +02:00
kwargs = {
"stdout": asyncio.subprocess.PIPE,
"stderr": asyncio.subprocess.STDOUT,
}
2018-03-09 21:27:39 +01:00
if display:
2019-07-31 21:25:30 +02:00
kwargs["stderr"] = asyncio.subprocess.PIPE
2018-03-09 21:27:39 +01:00
proc = await asyncio.create_subprocess_exec(*args, **kwargs)
except FileNotFoundError as err:
printc(FAIL, f"Could not execute {args[0]}. Did you install test requirements?")
2018-03-09 21:27:39 +01:00
raise err
if not display:
# Readin stdout into log
stdout, _ = await proc.communicate()
else:
# read child's stdout/stderr concurrently (capture and display)
stdout, _ = await asyncio.gather(
read_stream(proc.stdout, sys.stdout.write),
2019-07-31 21:25:30 +02:00
read_stream(proc.stderr, sys.stderr.write),
)
2018-03-09 21:27:39 +01:00
exit_code = await proc.wait()
2019-07-31 21:25:30 +02:00
stdout = stdout.decode("utf-8")
2018-03-09 21:27:39 +01:00
return exit_code, stdout
async def git():
"""Exec git."""
2019-07-31 21:25:30 +02:00
if len(sys.argv) > 2 and sys.argv[1] == "--":
2018-03-09 21:27:39 +01:00
return sys.argv[2:]
2019-07-31 21:25:30 +02:00
_, log = await async_exec("git", "merge-base", "upstream/dev", "HEAD")
merge_base = log.splitlines()[0]
2019-07-31 21:25:30 +02:00
_, log = await async_exec("git", "diff", merge_base, "--name-only")
2018-03-09 21:27:39 +01:00
return log.splitlines()
async def pylint(files):
"""Exec pylint."""
2019-07-31 21:25:30 +02:00
_, log = await async_exec("pylint", "-f", "parseable", "--persistent=n", *files)
2018-03-09 21:27:39 +01:00
res = []
for line in log.splitlines():
2019-07-31 21:25:30 +02:00
line = line.split(":")
2018-03-09 21:27:39 +01:00
if len(line) < 3:
continue
2019-07-31 21:25:30 +02:00
_fn = line[0].replace("\\", "/")
res.append(Error(_fn, line[1], "", line[2].strip(), _fn.startswith("tests/")))
2018-03-09 21:27:39 +01:00
return res
2023-03-17 13:30:06 +01:00
async def ruff(files):
"""Exec ruff."""
_, log = await async_exec("pre-commit", "run", "ruff", "--files", *files)
2018-03-09 21:27:39 +01:00
res = []
for line in log.splitlines():
2019-07-31 21:25:30 +02:00
line = line.split(":")
2018-03-09 21:27:39 +01:00
if len(line) < 4:
continue
2019-07-31 21:25:30 +02:00
_fn = line[0].replace("\\", "/")
res.append(Error(_fn, line[1], line[2], line[3].strip(), False))
2018-03-09 21:27:39 +01:00
return res
async def lint(files):
"""Perform lint."""
files = [file for file in files if os.path.isfile(file)]
res = sorted(
itertools.chain(
*await asyncio.gather(
pylint(files),
ruff(files),
)
),
key=lambda item: item.file,
)
2018-03-09 21:27:39 +01:00
if res:
print("Lint errors:")
2018-03-09 21:27:39 +01:00
else:
printc(PASS, "Lint passed")
2018-03-09 21:27:39 +01:00
lint_ok = True
for err in res:
err_msg = f"{err.file} {err.line}:{err.col} {err.msg}"
2018-03-09 21:27:39 +01:00
# tests/* does not have to pass lint
if err.skip:
2018-03-09 21:27:39 +01:00
print(err_msg)
else:
printc(FAIL, err_msg)
lint_ok = False
return lint_ok
async def main():
"""Run the main loop."""
2018-03-09 21:27:39 +01:00
# Ensure we are in the homeassistant root
os.chdir(os.path.dirname(os.path.dirname(os.path.realpath(__file__))))
files = await git()
if not files:
2019-07-31 21:25:30 +02:00
print(
"No changed files found. Please ensure you have added your "
"changes with git add & git commit"
)
2018-03-09 21:27:39 +01:00
return
pyfile = re.compile(r".+\.py$")
pyfiles = [file for file in files if pyfile.match(file)]
print("=============================")
2019-07-31 21:25:30 +02:00
printc("bold", "CHANGED FILES:\n", "\n ".join(pyfiles))
2018-03-09 21:27:39 +01:00
print("=============================")
2019-07-31 21:25:30 +02:00
skip_lint = len(sys.argv) > 1 and sys.argv[1] == "--skiplint"
2018-03-09 21:27:39 +01:00
if skip_lint:
printc(FAIL, "LINT DISABLED")
elif not await lint(pyfiles):
printc(FAIL, "Please fix your lint issues before continuing")
return
test_files = set()
gen_req = False
for fname in pyfiles:
2019-07-31 21:25:30 +02:00
if fname.startswith("homeassistant/components/"):
2018-03-09 21:27:39 +01:00
gen_req = True # requirements script for components
# Find test files...
2019-07-31 21:25:30 +02:00
if fname.startswith("tests/"):
if "/test_" in fname and os.path.isfile(fname):
# All test helpers should be excluded
2018-03-09 21:27:39 +01:00
test_files.add(fname)
else:
2019-07-31 21:25:30 +02:00
parts = fname.split("/")
parts[0] = "tests"
if parts[-1] == "__init__.py":
parts[-1] = "test_init.py"
elif parts[-1] == "__main__.py":
parts[-1] = "test_main.py"
2018-03-09 21:27:39 +01:00
else:
parts[-1] = f"test_{parts[-1]}"
2019-07-31 21:25:30 +02:00
fname = "/".join(parts)
2018-03-09 21:27:39 +01:00
if os.path.isfile(fname):
test_files.add(fname)
if gen_req:
print("=============================")
if validate_requirements_ok():
printc(PASS, "script/gen_requirements.py passed")
else:
printc(FAIL, "Please run script/gen_requirements.py")
return
print("=============================")
if not test_files:
2022-11-23 14:40:37 +01:00
print("No test files identified")
2018-03-09 21:27:39 +01:00
return
code, _ = await async_exec(
2019-07-31 21:25:30 +02:00
"pytest", "-vv", "--force-sugar", "--", *test_files, display=True
)
2018-03-09 21:27:39 +01:00
print("=============================")
if code == 0:
2022-11-23 14:40:37 +01:00
printc(PASS, "Yay! This will most likely pass CI")
2018-03-09 21:27:39 +01:00
else:
printc(FAIL, "Tests not passing")
if skip_lint:
printc(FAIL, "LINT DISABLED")
2019-07-31 21:25:30 +02:00
if __name__ == "__main__":
with suppress(FileNotFoundError, KeyboardInterrupt):
2021-04-01 20:34:01 +02:00
asyncio.run(main())