formatted handlers.py

This commit is contained in:
Karma Riuk
2025-03-27 09:42:37 +01:00
parent 2e04ed49a3
commit e8cf0b4e37

View File

@ -5,11 +5,12 @@ from typing import Iterable, Optional, Tuple, Iterator
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from javalang.tree import PackageDeclaration from javalang.tree import PackageDeclaration
REPORT_SIZE_THRESHOLD = 400 # less than 400 bytes (charcaters), we don't care about it REPORT_SIZE_THRESHOLD = 400 # less than 400 bytes (charcaters), we don't care about it
USER_ID = os.getuid() # for container user USER_ID = os.getuid() # for container user
GROUP_ID = os.getgid() GROUP_ID = os.getgid()
class BuildHandler(ABC): class BuildHandler(ABC):
def __init__(self, repo_path: str, build_file: str, updates: dict) -> None: def __init__(self, repo_path: str, build_file: str, updates: dict) -> None:
@ -25,18 +26,17 @@ class BuildHandler(ABC):
def __enter__(self): def __enter__(self):
self.container = self.client.containers.run( self.container = self.client.containers.run(
image=self.container_name(), image=self.container_name(),
command="tail -f /dev/null", # to keep the container alive command="tail -f /dev/null", # to keep the container alive
volumes={os.path.abspath(self.path): {"bind": "/repo", "mode": "rw"}}, volumes={os.path.abspath(self.path): {"bind": "/repo", "mode": "rw"}},
user=f"{USER_ID}:{GROUP_ID}", user=f"{USER_ID}:{GROUP_ID}",
detach=True, detach=True,
tty=True tty=True,
) )
def __exit__(self, *args): def __exit__(self, *args):
self.container.kill() self.container.kill()
self.container.remove() self.container.remove()
def check_for_tests(self) -> None: def check_for_tests(self) -> None:
with open(os.path.join(self.path, self.build_file), "r") as f: with open(os.path.join(self.path, self.build_file), "r") as f:
content = f.read() content = f.read()
@ -65,7 +65,7 @@ class BuildHandler(ABC):
def compile_repo(self) -> None: def compile_repo(self) -> None:
def timeout_handler(signum, frame): def timeout_handler(signum, frame):
raise TimeoutError("Tests exceeded time limit") raise TimeoutError("Tests exceeded time limit")
signal.signal(signal.SIGALRM, timeout_handler) signal.signal(signal.SIGALRM, timeout_handler)
signal.alarm(3600) # Set timeout to 1 hour (3600 seconds) signal.alarm(3600) # Set timeout to 1 hour (3600 seconds)
@ -83,7 +83,7 @@ class BuildHandler(ABC):
def test_repo(self) -> None: def test_repo(self) -> None:
def timeout_handler(signum, frame): def timeout_handler(signum, frame):
raise TimeoutError("Tests exceeded time limit") raise TimeoutError("Tests exceeded time limit")
signal.signal(signal.SIGALRM, timeout_handler) signal.signal(signal.SIGALRM, timeout_handler)
signal.alarm(3600) # Set timeout to 1 hour (3600 seconds) signal.alarm(3600) # Set timeout to 1 hour (3600 seconds)
@ -93,7 +93,7 @@ class BuildHandler(ABC):
output = clean_output(exec_result.output) output = clean_output(exec_result.output)
if exec_result.exit_code != 0: if exec_result.exit_code != 0:
raise FailedToTestError(output) raise FailedToTestError(output)
self.extract_test_numbers(output) self.extract_test_numbers(output)
except TimeoutError: except TimeoutError:
@ -123,13 +123,17 @@ class BuildHandler(ABC):
candidates.append({"report_file": coverage_report_path, "fqc": fully_qualified_class}) candidates.append({"report_file": coverage_report_path, "fqc": fully_qualified_class})
# if coverage_report_path[:len(src_dir)] != src_dir: # if coverage_report_path[:len(src_dir)] != src_dir:
# continue # continue
coverage = get_coverage_for_file(coverage_report_path, fully_qualified_class, os.path.basename(filename)) coverage = get_coverage_for_file(
coverage_report_path, fully_qualified_class, os.path.basename(filename)
)
if coverage != -1: if coverage != -1:
found_at_least_one = True found_at_least_one = True
yield coverage_report_path, coverage yield coverage_report_path, coverage
if not found_at_least_one: if not found_at_least_one:
raise FileNotCovered(f"File '{filename}' didn't have any coverage in any of the jacoco reports: {candidates}") raise FileNotCovered(
f"File '{filename}' didn't have any coverage in any of the jacoco reports: {candidates}"
)
def _extract_fully_qualified_class(self, filepath: str) -> str: def _extract_fully_qualified_class(self, filepath: str) -> str:
if not filepath.endswith('.java'): if not filepath.endswith('.java'):
@ -142,22 +146,25 @@ class BuildHandler(ABC):
try: try:
parsed_tree = javalang.parse.parse(f.read()) parsed_tree = javalang.parse.parse(f.read())
except javalang.parser.JavaSyntaxError as e: except javalang.parser.JavaSyntaxError as e:
raise NotJavaFileError(f"File '{filepath}' has a syntax error and could not be parsed by javalang, raised error: '{e}'") raise NotJavaFileError(
f"File '{filepath}' has a syntax error and could not be parsed by javalang, raised error: '{e}'"
)
package_name = None package_name = None
for _, node in parsed_tree.filter(PackageDeclaration): for _, node in parsed_tree.filter(PackageDeclaration):
package_name = node.name # type: ignore package_name = node.name # type: ignore
break # Stop after finding the first package declaration break # Stop after finding the first package declaration
if package_name is None: if package_name is None:
raise NoPackageFoundError(f"File '{filepath}' did not have a packaged name recognized by javalang") raise NoPackageFoundError(
f"File '{filepath}' did not have a packaged name recognized by javalang"
)
fully_qualified_class = package_name.replace('.', '/') fully_qualified_class = package_name.replace('.', '/')
# src_dir = filepath[:filepath.index(fully_qualified_class)] # src_dir = filepath[:filepath.index(fully_qualified_class)]
fully_qualified_class += "/" + os.path.basename(filepath)[:-5] # -5 to remove '.java' fully_qualified_class += "/" + os.path.basename(filepath)[:-5] # -5 to remove '.java'
return fully_qualified_class return fully_qualified_class
def clean_repo(self) -> None: def clean_repo(self) -> None:
self.container.exec_run(self.clean_cmd()) self.container.exec_run(self.clean_cmd())
@ -193,6 +200,7 @@ class BuildHandler(ABC):
def container_name(self) -> str: def container_name(self) -> str:
pass pass
class MavenHandler(BuildHandler): class MavenHandler(BuildHandler):
def __init__(self, repo_path: str, build_file: str, updates: dict) -> None: def __init__(self, repo_path: str, build_file: str, updates: dict) -> None:
super().__init__(repo_path, build_file, updates) super().__init__(repo_path, build_file, updates)
@ -212,7 +220,7 @@ class MavenHandler(BuildHandler):
def clean_cmd(self) -> str: def clean_cmd(self) -> str:
return f"{self.base_cmd} clean" return f"{self.base_cmd} clean"
def generate_coverage_report_cmd(self): def generate_coverage_report_cmd(self):
return f"{self.base_cmd} jacoco:report-aggregate" return f"{self.base_cmd} jacoco:report-aggregate"
@ -239,13 +247,13 @@ class MavenHandler(BuildHandler):
self.updates["n_tests_failed"] += failures self.updates["n_tests_failed"] += failures
self.updates["n_tests_errors"] += errors self.updates["n_tests_errors"] += errors
self.updates["n_tests_skipped"] += skipped self.updates["n_tests_skipped"] += skipped
self.updates["n_tests_passed"] += (tests_run - (failures + errors)) # Calculate passed tests self.updates["n_tests_passed"] += tests_run - (failures + errors) # Calculate passed tests
def get_jacoco_report_paths(self) -> Iterable[str]: def get_jacoco_report_paths(self) -> Iterable[str]:
found_at_least_one = False found_at_least_one = False
for root, _, files in os.walk(os.path.join(self.path)): for root, _, files in os.walk(os.path.join(self.path)):
if "target/site" not in root: if "target/site" not in root:
continue # to avoid any misleading jacoco.xml randomly lying around continue # to avoid any misleading jacoco.xml randomly lying around
for file in files: for file in files:
if file == "jacoco.xml": if file == "jacoco.xml":
found_at_least_one = True found_at_least_one = True
@ -253,6 +261,7 @@ class MavenHandler(BuildHandler):
if not found_at_least_one: if not found_at_least_one:
raise NoCoverageReportFound(f"Couldn't find any 'jacoco.xml' in {self.path}") raise NoCoverageReportFound(f"Couldn't find any 'jacoco.xml' in {self.path}")
class GradleHandler(BuildHandler): class GradleHandler(BuildHandler):
def __init__(self, repo_path: str, build_file: str, updates: dict) -> None: def __init__(self, repo_path: str, build_file: str, updates: dict) -> None:
super().__init__(repo_path, build_file, updates) super().__init__(repo_path, build_file, updates)
@ -269,7 +278,7 @@ class GradleHandler(BuildHandler):
def clean_cmd(self) -> str: def clean_cmd(self) -> str:
return f"{self.base_cmd} clean" return f"{self.base_cmd} clean"
def generate_coverage_report_cmd(self) -> str: def generate_coverage_report_cmd(self) -> str:
return f"{self.base_cmd} jacocoTestReport" return f"{self.base_cmd} jacocoTestReport"
@ -290,7 +299,7 @@ class GradleHandler(BuildHandler):
# Load the HTML file # Load the HTML file
with open(test_results_path, "r") as file: with open(test_results_path, "r") as file:
soup = BeautifulSoup(file, "html.parser") soup = BeautifulSoup(file, "html.parser")
# test_div = soup.select_one("div", class_="infoBox", id="tests") # test_div = soup.select_one("div", class_="infoBox", id="tests")
test_div = soup.select_one("div.infoBox#tests") test_div = soup.select_one("div.infoBox#tests")
if test_div is None: if test_div is None:
@ -302,7 +311,7 @@ class GradleHandler(BuildHandler):
raise NoTestResultsToExtractError("No test results found (not div.counter for tests)") raise NoTestResultsToExtractError("No test results found (not div.counter for tests)")
self.updates["n_tests"] = int(counter_div.text.strip()) self.updates["n_tests"] = int(counter_div.text.strip())
# failures_div = soup.find("div", class_="infoBox", id="failures") # failures_div = soup.find("div", class_="infoBox", id="failures")
failures_div = soup.select_one("div.infoBox#failures") failures_div = soup.select_one("div.infoBox#failures")
if failures_div is None: if failures_div is None:
@ -314,7 +323,7 @@ class GradleHandler(BuildHandler):
raise NoTestResultsToExtractError("No test results found (not div.counter for failures)") raise NoTestResultsToExtractError("No test results found (not div.counter for failures)")
self.updates["n_tests_failed"] = int(counter_div.text.strip()) self.updates["n_tests_failed"] = int(counter_div.text.strip())
# Calculate passed tests # Calculate passed tests
self.updates["n_tests_passed"] = self.updates["n_tests"] - self.updates["n_tests_failed"] self.updates["n_tests_passed"] = self.updates["n_tests"] - self.updates["n_tests_failed"]
@ -328,44 +337,59 @@ class GradleHandler(BuildHandler):
found_at_least_one = True found_at_least_one = True
yield os.path.join(root, file) yield os.path.join(root, file)
if not found_at_least_one: if not found_at_least_one:
raise NoCoverageReportFound(f"Couldn't find any 'index.html' inside any 'reports/jacoco' in {self.path}") raise NoCoverageReportFound(
f"Couldn't find any 'index.html' inside any 'reports/jacoco' in {self.path}"
)
class HandlerException(Exception, ABC): class HandlerException(Exception, ABC):
reason_for_failure = "Generic handler expection (this shouldn't appear)" reason_for_failure = "Generic handler expection (this shouldn't appear)"
class NoTestsFoundError(HandlerException): class NoTestsFoundError(HandlerException):
reason_for_failure = "No tests found" reason_for_failure = "No tests found"
class FailedToCompileError(HandlerException): class FailedToCompileError(HandlerException):
reason_for_failure = "Failed to compile" reason_for_failure = "Failed to compile"
class FailedToTestError(HandlerException): class FailedToTestError(HandlerException):
reason_for_failure = "Failed to test" reason_for_failure = "Failed to test"
class NoTestResultsToExtractError(HandlerException): class NoTestResultsToExtractError(HandlerException):
reason_for_failure = "Failed to extract test results" reason_for_failure = "Failed to extract test results"
class CantExecJacoco(HandlerException): class CantExecJacoco(HandlerException):
reason_for_failure = "Couldn't execute jacoco" reason_for_failure = "Couldn't execute jacoco"
class NoCoverageReportFound(HandlerException): class NoCoverageReportFound(HandlerException):
reason_for_failure = "No coverage report was found" reason_for_failure = "No coverage report was found"
class FileNotCovered(HandlerException): class FileNotCovered(HandlerException):
reason_for_failure = "Commented file from the PR wasn't not covered" reason_for_failure = "Commented file from the PR wasn't not covered"
class GradleAggregateReportNotFound(HandlerException): class GradleAggregateReportNotFound(HandlerException):
reason_for_failure = "Couldn't find the aggregate report (with gradle it's messy)" reason_for_failure = "Couldn't find the aggregate report (with gradle it's messy)"
class NotJavaFileError(HandlerException): class NotJavaFileError(HandlerException):
reason_for_failure = "File that was checked for coverage was not java file" reason_for_failure = "File that was checked for coverage was not java file"
class NoPackageFoundError(HandlerException): class NoPackageFoundError(HandlerException):
reason_for_failure = "Java file did not contain a valid package name" reason_for_failure = "Java file did not contain a valid package name"
class FileNotFoundInRepoError(HandlerException): class FileNotFoundInRepoError(HandlerException):
reason_for_failure = "Commented file not found in repo (likely renamed or deleted)" reason_for_failure = "Commented file not found in repo (likely renamed or deleted)"
def merge_download_lines(lines: list) -> list: def merge_download_lines(lines: list) -> list:
""" """
Merges lines that are part of the same download block in Maven output. Merges lines that are part of the same download block in Maven output.
@ -388,6 +412,7 @@ def merge_download_lines(lines: list) -> list:
downloading_block = False downloading_block = False
return cleaned_lines return cleaned_lines
def merge_unapproved_licences(lines: list) -> list: def merge_unapproved_licences(lines: list) -> list:
""" """
Merges lines that are part of the same unapproved licences block in Maven output. Merges lines that are part of the same unapproved licences block in Maven output.
@ -412,6 +437,7 @@ def merge_unapproved_licences(lines: list) -> list:
cleaned_lines.append(line) cleaned_lines.append(line)
return cleaned_lines return cleaned_lines
def clean_output(output: bytes) -> str: def clean_output(output: bytes) -> str:
output_lines = output.decode().split("\n") output_lines = output.decode().split("\n")
@ -420,6 +446,7 @@ def clean_output(output: bytes) -> str:
return "\n".join(cleaned_lines) return "\n".join(cleaned_lines)
def get_coverage_for_file(xml_file: str, target_fully_qualified_class: str, basename: str) -> float: def get_coverage_for_file(xml_file: str, target_fully_qualified_class: str, basename: str) -> float:
# Parse the XML file # Parse the XML file
tree = ET.parse(xml_file) tree = ET.parse(xml_file)
@ -428,7 +455,10 @@ def get_coverage_for_file(xml_file: str, target_fully_qualified_class: str, base
# Find coverage for the target file # Find coverage for the target file
for package in root.findall(".//package"): for package in root.findall(".//package"):
for class_ in package.findall("class"): for class_ in package.findall("class"):
if class_.get("sourcefilename") == basename and class_.get("name") == target_fully_qualified_class: if (
class_.get("sourcefilename") == basename
and class_.get("name") == target_fully_qualified_class
):
# Extract line coverage data # Extract line coverage data
line_counter = class_.find("counter[@type='LINE']") line_counter = class_.find("counter[@type='LINE']")
if line_counter is not None: if line_counter is not None:
@ -443,6 +473,7 @@ def get_coverage_for_file(xml_file: str, target_fully_qualified_class: str, base
return coverage return coverage
return -1 return -1
def get_build_handler(root: str, repo: str, updates: dict, verbose: bool = False) -> Optional[BuildHandler]: def get_build_handler(root: str, repo: str, updates: dict, verbose: bool = False) -> Optional[BuildHandler]:
""" """
Get the path to the build file of a repository. The build file is either a Get the path to the build file of a repository. The build file is either a
@ -466,7 +497,8 @@ def get_build_handler(root: str, repo: str, updates: dict, verbose: bool = False
to_keep = ["pom.xml", "build.gradle"] to_keep = ["pom.xml", "build.gradle"]
for entry in os.scandir(path): for entry in os.scandir(path):
if entry.is_file() and entry.name in to_keep: if entry.is_file() and entry.name in to_keep:
if verbose: print(f"Found {entry.name} in {repo} root, so keeping it and returning") if verbose:
print(f"Found {entry.name} in {repo} root, so keeping it and returning")
updates["depth_of_build_file"] = 0 updates["depth_of_build_file"] = 0
if entry.name == "build.gradle": if entry.name == "build.gradle":
updates["build_system"] = "gradle" updates["build_system"] = "gradle"
@ -474,13 +506,14 @@ def get_build_handler(root: str, repo: str, updates: dict, verbose: bool = False
else: else:
updates["build_system"] = "maven" updates["build_system"] = "maven"
return MavenHandler(path, entry.name, updates) return MavenHandler(path, entry.name, updates)
# List files in the immediate subdirectories # List files in the immediate subdirectories
for entry in os.scandir(path): for entry in os.scandir(path):
if entry.is_dir(): if entry.is_dir():
for sub_entry in os.scandir(entry.path): for sub_entry in os.scandir(entry.path):
if sub_entry.is_file() and sub_entry.name in to_keep: if sub_entry.is_file() and sub_entry.name in to_keep:
if verbose: print(f"Found {sub_entry.name} in {repo} first level, so keeping it and returning") if verbose:
print(f"Found {sub_entry.name} in {repo} first level, so keeping it and returning")
updates["depth_of_build_file"] = 1 updates["depth_of_build_file"] = 1
if entry.name == "build.gradle": if entry.name == "build.gradle":
updates["build_system"] = "gradle" updates["build_system"] = "gradle"