[analyzer] SATest: Move from csv to json project maps

Summary:
JSON format is a bit more verbose and easier to reason about
and extend.  For this reason, before extending SATestBuild
functionality it is better to refactor the part of how we
configure the whole system.

Differential Revision: https://reviews.llvm.org/D81563
This commit is contained in:
Valeriy Savchenko
2020-06-16 13:30:00 +03:00
parent 4a606e0a8c
commit fb4b565212
4 changed files with 160 additions and 120 deletions
+94
View File
@@ -0,0 +1,94 @@
import json
import os
from typing import Any, Dict, List, NamedTuple, Optional
JSON = Dict[str, Any]
DEFAULT_MAP_FILE = "projects.json"
class ProjectInfo(NamedTuple):
"""
Information about a project to analyze.
"""
name: str
mode: int
enabled: bool = True
class ProjectMap:
"""
Project map stores info about all the "registered" projects.
"""
def __init__(self, path: Optional[str] = None, should_exist: bool = True):
"""
:param path: optional path to a project JSON file, when None defaults
to DEFAULT_MAP_FILE.
:param should_exist: flag to tell if it's an exceptional situation when
the project file doesn't exist, creates an empty
project list instead if we are not expecting it to
exist.
"""
if path is None:
path = os.path.join(os.path.abspath(os.curdir), DEFAULT_MAP_FILE)
if not os.path.exists(path):
if should_exist:
raise ValueError(
f"Cannot find the project map file {path}"
f"\nRunning script for the wrong directory?\n")
else:
self._create_empty(path)
self.path = path
self._load_projects()
def save(self):
"""
Save project map back to its original file.
"""
self._save(self.projects, self.path)
def _load_projects(self):
with open(self.path) as raw_data:
raw_projects = json.load(raw_data)
if not isinstance(raw_projects, list):
raise ValueError(
"Project map should be a list of JSON objects")
self.projects = self._parse(raw_projects)
@staticmethod
def _parse(raw_projects: List[JSON]) -> List[ProjectInfo]:
return [ProjectMap._parse_project(raw_project)
for raw_project in raw_projects]
@staticmethod
def _parse_project(raw_project: JSON) -> ProjectInfo:
try:
name: str = raw_project["name"]
build_mode: int = raw_project["mode"]
enabled: bool = raw_project.get("enabled", True)
return ProjectInfo(name, build_mode, enabled)
except KeyError as e:
raise ValueError(
f"Project info is required to have a '{e.args[0]}' field")
@staticmethod
def _create_empty(path: str):
ProjectMap._save([], path)
@staticmethod
def _save(projects: List[ProjectInfo], path: str):
with open(path, "w") as output:
json.dump(ProjectMap._convert_infos_to_dicts(projects),
output, indent=2)
@staticmethod
def _convert_infos_to_dicts(projects: List[ProjectInfo]) -> List[JSON]:
return [project._asdict() for project in projects]
+15 -29
View File
@@ -43,13 +43,11 @@ the Repository Directory.
> changes_for_analyzer.patch
"""
import SATestBuild
from ProjectMap import ProjectMap, ProjectInfo
import csv
import os
import sys
from typing import IO
def add_new_project(name: str, build_mode: int):
"""
@@ -57,9 +55,10 @@ def add_new_project(name: str, build_mode: int):
:param name: is a short string used to identify a project.
"""
project_info = SATestBuild.ProjectInfo(name, build_mode,
is_reference_build=True)
tester = SATestBuild.ProjectTester(project_info)
project_info = ProjectInfo(name, build_mode)
test_info = SATestBuild.TestInfo(project_info,
is_reference_build=True)
tester = SATestBuild.ProjectTester(test_info)
project_dir = tester.get_project_dir()
if not os.path.exists(project_dir):
@@ -70,33 +69,20 @@ def add_new_project(name: str, build_mode: int):
tester.test()
# Add the project name to the project map.
project_map_path = SATestBuild.get_project_map_path(should_exist=False)
project_map = ProjectMap(should_exist=False)
if os.path.exists(project_map_path):
file_mode = "r+"
if is_existing_project(project_map, name):
print(f"Warning: Project with name '{name}' already exists.",
file=sys.stdout)
print("Reference output has been regenerated.", file=sys.stdout)
else:
print("Warning: Creating the project map file!")
file_mode = "w+"
with open(project_map_path, file_mode) as map_file:
if is_existing_project(map_file, name):
print(f"Warning: Project with name '{name}' already exists.",
file=sys.stdout)
print("Reference output has been regenerated.", file=sys.stdout)
else:
map_writer = csv.writer(map_file)
map_writer.writerow((name, build_mode))
print(f"The project map is updated: {project_map_path}")
project_map.projects.append(project_info)
project_map.save()
def is_existing_project(map_file: IO, project_name: str) -> bool:
map_reader = csv.reader(map_file)
for raw_info in map_reader:
if project_name == raw_info[0]:
return True
return False
def is_existing_project(project_map: ProjectMap, project_name: str) -> bool:
return any(existing_project.name == project_name
for existing_project in project_map.projects)
# TODO: Use argparse
+42 -84
View File
@@ -44,9 +44,9 @@ variable. It should contain a comma separated list.
"""
import CmpRuns
import SATestUtils
from ProjectMap import ProjectInfo, ProjectMap
import argparse
import csv
import glob
import logging
import math
@@ -59,9 +59,11 @@ import threading
import time
from queue import Queue
# mypy has problems finding InvalidFileException in the module
# and this is we can shush that false positive
from plistlib import InvalidFileException # type:ignore
from subprocess import CalledProcessError, check_call
from typing import (cast, Dict, Iterable, IO, List, NamedTuple, Optional,
Tuple, TYPE_CHECKING)
from typing import Dict, IO, List, NamedTuple, Optional, TYPE_CHECKING
###############################################################################
@@ -105,9 +107,6 @@ CLANG = cc_candidate
# Number of jobs.
MAX_JOBS = int(math.ceil(multiprocessing.cpu_count() * 0.75))
# Project map stores info about all the "registered" projects.
PROJECT_MAP_FILE = "projectMap.csv"
# Names of the project specific scripts.
# The script that downloads the project.
DOWNLOAD_SCRIPT = "download_project.sh"
@@ -187,18 +186,6 @@ class StreamToLogger:
###############################################################################
def get_project_map_path(should_exist: bool = True) -> str:
project_map_path = os.path.join(os.path.abspath(os.curdir),
PROJECT_MAP_FILE)
if should_exist and not os.path.exists(project_map_path):
stderr(f"Error: Cannot find the project map file {project_map_path}"
f"\nRunning script for the wrong directory?\n")
sys.exit(1)
return project_map_path
def run_cleanup_script(directory: str, build_log_file: IO):
"""
Run pre-processing script if any.
@@ -268,12 +255,11 @@ def apply_patch(directory: str, build_log_file: IO):
sys.exit(1)
class ProjectInfo(NamedTuple):
class TestInfo(NamedTuple):
"""
Information about a project and settings for its analysis.
"""
name: str
build_mode: int
project: ProjectInfo
override_compiler: bool = False
extra_analyzer_config: str = ""
is_reference_build: bool = False
@@ -287,9 +273,9 @@ class ProjectInfo(NamedTuple):
# It is a common workaround for this situation:
# https://mypy.readthedocs.io/en/stable/common_issues.html#using-classes-that-are-generic-in-stubs-but-not-at-runtime
if TYPE_CHECKING:
ProjectQueue = Queue[ProjectInfo] # this is only processed by mypy
TestQueue = Queue[TestInfo] # this is only processed by mypy
else:
ProjectQueue = Queue # this will be executed at runtime
TestQueue = Queue # this will be executed at runtime
class RegressionTester:
@@ -306,25 +292,24 @@ class RegressionTester:
self.strictness = strictness
def test_all(self) -> bool:
projects_to_test: List[ProjectInfo] = []
projects_to_test: List[TestInfo] = []
with open(get_project_map_path(), "r") as map_file:
validate_project_file(map_file)
project_map = ProjectMap()
# Test the projects.
for proj_name, proj_build_mode in get_projects(map_file):
projects_to_test.append(
ProjectInfo(proj_name, int(proj_build_mode),
self.override_compiler,
self.extra_analyzer_config,
self.regenerate, self.strictness))
# Test the projects.
for project in project_map.projects:
projects_to_test.append(
TestInfo(project,
self.override_compiler,
self.extra_analyzer_config,
self.regenerate, self.strictness))
if self.jobs <= 1:
return self._single_threaded_test_all(projects_to_test)
else:
return self._multi_threaded_test_all(projects_to_test)
def _single_threaded_test_all(self,
projects_to_test: List[ProjectInfo]) -> bool:
projects_to_test: List[TestInfo]) -> bool:
"""
Run all projects.
:return: whether tests have passed.
@@ -336,7 +321,7 @@ class RegressionTester:
return success
def _multi_threaded_test_all(self,
projects_to_test: List[ProjectInfo]) -> bool:
projects_to_test: List[TestInfo]) -> bool:
"""
Run each project in a separate thread.
@@ -345,7 +330,7 @@ class RegressionTester:
:return: whether tests have passed.
"""
tasks_queue = ProjectQueue()
tasks_queue = TestQueue()
for project_info in projects_to_test:
tasks_queue.put(project_info)
@@ -370,13 +355,12 @@ class ProjectTester:
"""
A component aggregating testing for one project.
"""
def __init__(self, project_info: ProjectInfo):
self.project_name = project_info.name
self.build_mode = project_info.build_mode
self.override_compiler = project_info.override_compiler
self.extra_analyzer_config = project_info.extra_analyzer_config
self.is_reference_build = project_info.is_reference_build
self.strictness = project_info.strictness
def __init__(self, test_info: TestInfo):
self.project = test_info.project
self.override_compiler = test_info.override_compiler
self.extra_analyzer_config = test_info.extra_analyzer_config
self.is_reference_build = test_info.is_reference_build
self.strictness = test_info.strictness
def test(self) -> bool:
"""
@@ -384,7 +368,11 @@ class ProjectTester:
:return tests_passed: Whether tests have passed according
to the :param strictness: criteria.
"""
stdout(f" \n\n--- Building project {self.project_name}\n")
if not self.project.enabled:
stdout(f" \n\n--- Skipping disabled project {self.project.name}\n")
return True
stdout(f" \n\n--- Building project {self.project.name}\n")
start_time = time.time()
@@ -405,13 +393,13 @@ class ProjectTester:
else:
passed = run_cmp_results(project_dir, self.strictness)
stdout(f"Completed tests for project {self.project_name} "
stdout(f"Completed tests for project {self.project.name} "
f"(time: {time.time() - start_time:.2f}).\n")
return passed
def get_project_dir(self) -> str:
return os.path.join(os.path.abspath(os.curdir), self.project_name)
return os.path.join(os.path.abspath(os.curdir), self.project.name)
def get_output_dir(self) -> str:
if self.is_reference_build:
@@ -441,7 +429,7 @@ class ProjectTester:
# Build and analyze the project.
with open(build_log_path, "w+") as build_log_file:
if self.build_mode == 1:
if self.project.mode == 1:
download_and_patch(directory, build_log_file)
run_cleanup_script(directory, build_log_file)
self.scan_build(directory, output_dir, build_log_file)
@@ -451,7 +439,7 @@ class ProjectTester:
if self.is_reference_build:
run_cleanup_script(directory, build_log_file)
normalize_reference_results(directory, output_dir,
self.build_mode)
self.project.mode)
stdout(f"Build complete (time: {time.time() - time_start:.2f}). "
f"See the log for more details: {build_log_path}\n")
@@ -549,7 +537,7 @@ class ProjectTester:
prefix += " -Xclang -analyzer-config "
prefix += f"-Xclang {self.generate_config()} "
if self.build_mode == 2:
if self.project.mode == 2:
prefix += "-std=c++11 "
plist_path = os.path.join(directory, output_dir, "date")
@@ -601,7 +589,7 @@ class ProjectTester:
class TestProjectThread(threading.Thread):
def __init__(self, tasks_queue: ProjectQueue,
def __init__(self, tasks_queue: TestQueue,
results_differ: threading.Event,
failure_flag: threading.Event):
"""
@@ -621,13 +609,13 @@ class TestProjectThread(threading.Thread):
def run(self):
while not self.tasks_queue.empty():
try:
project_info = self.tasks_queue.get()
test_info = self.tasks_queue.get()
Logger = logging.getLogger(project_info.name)
Logger = logging.getLogger(test_info.project.name)
LOCAL.stdout = StreamToLogger(Logger, logging.INFO)
LOCAL.stderr = StreamToLogger(Logger, logging.ERROR)
tester = ProjectTester(project_info)
tester = ProjectTester(test_info)
if not tester.test():
self.results_differ.set()
@@ -841,7 +829,7 @@ def clean_up_empty_plists(output_dir: str):
os.remove(plist)
continue
except plistlib.InvalidFileException as e:
except InvalidFileException as e:
stderr(f"Error parsing plist file {plist}: {str(e)}")
continue
@@ -856,36 +844,6 @@ def clean_up_empty_folders(output_dir: str):
os.removedirs(subdir)
def get_projects(map_file: IO) -> Iterable[Tuple[str, str]]:
"""
Iterate over all projects defined in the project file handler `map_file`
from the start.
"""
map_file.seek(0)
# TODO: csv format is not very readable, change it to JSON
for project_info in csv.reader(map_file):
if SATestUtils.is_comment_csv_line(project_info):
continue
# suppress mypy error
yield cast(Tuple[str, str], project_info)
def validate_project_file(map_file: IO):
"""
Validate project file.
"""
for project_info in get_projects(map_file):
if len(project_info) != 2:
stderr("Error: Rows in the project map file "
"should have 2 entries.")
raise Exception()
if project_info[1] not in ('0', '1', '2'):
stderr("Error: Second entry in the project map file should be 0"
" (single file), 1 (project), or 2(single file c++11).")
raise Exception()
if __name__ == "__main__":
# Parse command line arguments.
parser = argparse.ArgumentParser(
+9 -7
View File
@@ -4,6 +4,7 @@
Update reference results for static analyzer.
"""
import SATestBuild
from ProjectMap import ProjectInfo, ProjectMap
import os
import shutil
@@ -14,9 +15,9 @@ from subprocess import check_call
Verbose = 0
def update_reference_results(project_name: str, build_mode: int):
project_info = SATestBuild.ProjectInfo(project_name, build_mode)
tester = SATestBuild.ProjectTester(project_info)
def update_reference_results(project: ProjectInfo):
test_info = SATestBuild.TestInfo(project)
tester = SATestBuild.ProjectTester(test_info)
project_dir = tester.get_project_dir()
tester.is_reference_build = True
@@ -54,7 +55,7 @@ def update_reference_results(project_name: str, build_mode: int):
SATestBuild.run_cleanup_script(project_dir, build_log_file)
SATestBuild.normalize_reference_results(
project_dir, ref_results_path, build_mode)
project_dir, ref_results_path, project.mode)
# Clean up the generated difference results.
SATestBuild.cleanup_reference_results(ref_results_path)
@@ -62,6 +63,7 @@ def update_reference_results(project_name: str, build_mode: int):
run_cmd(f"git add '{ref_results_path}'")
# TODO: use argparse
def main(argv):
if len(argv) == 2 and argv[1] in ("-h", "--help"):
print("Update static analyzer reference results based "
@@ -70,9 +72,9 @@ def main(argv):
file=sys.stderr)
sys.exit(1)
with open(SATestBuild.get_project_map_path(), "r") as f:
for project_name, build_mode in SATestBuild.get_projects(f):
update_reference_results(project_name, int(build_mode))
project_map = ProjectMap()
for project in project_map.projects:
update_reference_results(project)
if __name__ == '__main__':