Skip to content

Commit

Permalink
Merge pull request #3388 from csordasmarton/local_diff_workflow
Browse files Browse the repository at this point in the history
[cli] Local diff workflow support
  • Loading branch information
bruntib authored Aug 17, 2021
2 parents bcf8743 + 51b2780 commit 8172976
Show file tree
Hide file tree
Showing 11 changed files with 726 additions and 160 deletions.
82 changes: 63 additions & 19 deletions analyzer/codechecker_analyzer/cmd/parse.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
from codechecker_analyzer import analyzer_context, suppress_handler

from codechecker_common import arg, logger, plist_parser, util, cmd_config
from codechecker_common.output import json as out_json, twodim, \
from codechecker_common.output import baseline, json as out_json, twodim, \
codeclimate, gerrit
from codechecker_common.skiplist_handler import SkipListHandler
from codechecker_common.source_code_comment_handler import \
Expand All @@ -37,7 +37,7 @@

LOG = logger.get_logger('system')

EXPORT_TYPES = ['html', 'json', 'codeclimate', 'gerrit']
EXPORT_TYPES = ['html', 'json', 'codeclimate', 'gerrit', 'baseline']

_data_files_dir_path = analyzer_context.get_context().data_files_dir_path
_severity_map_file = os.path.join(_data_files_dir_path, 'config',
Expand Down Expand Up @@ -457,12 +457,18 @@ def add_arguments_to_parser(parser):
"For more information see:\n"
"https://github.com/codeclimate/platform/"
"blob/master/spec/analyzers/SPEC.md"
"#data-types")
"#data-types\n"
"'baseline' output can be used to integrate "
"CodeChecker into your local workflow "
"without using a CodeChecker server. For "
"more information see our usage guide.")

output_opts.add_argument('-o', '--output',
dest="output_path",
default=argparse.SUPPRESS,
help="Store the output in the given folder.")
help="Store the output in the given file/folder. "
"Note: baseline files must have extension "
"'.baseline'.")

parser.add_argument('--suppress',
type=str,
Expand Down Expand Up @@ -639,6 +645,9 @@ def _parse_convert_reports(
report.trim_path_prefixes(trim_path_prefixes)

number_of_reports = len(all_reports)
if out_format == "baseline":
return (baseline.convert(all_reports), number_of_reports)

if out_format == "codeclimate":
return (codeclimate.convert(all_reports, severity_map),
number_of_reports)
Expand All @@ -655,7 +664,7 @@ def _generate_json_output(
severity_map: Dict,
input_dirs: List[str],
output_type: str,
output_path: Optional[str],
output_file_path: Optional[str],
trim_path_prefixes: Optional[List[str]],
skip_handler: Callable[[str], bool]
) -> int:
Expand All @@ -675,7 +684,7 @@ def _generate_json_output(
result of analyzing.
output_type : str
Specifies the type of output. It can be gerrit, json, codeclimate.
output_path : Optional[str]
output_file_path : Optional[str]
Path of the output file. If it contains file name then generated output
will be written into.
trim_path_prefixes : Optional[List[str]]
Expand All @@ -692,13 +701,7 @@ def _generate_json_output(
skip_handler)
output_text = json.dumps(reports)

if output_path:
output_path = os.path.abspath(output_path)

if not os.path.exists(output_path):
os.mkdir(output_path)

output_file_path = os.path.join(output_path, 'reports.json')
if output_file_path:
with open(output_file_path, mode='w', encoding='utf-8',
errors="ignore") as output_f:
output_f.write(output_text)
Expand Down Expand Up @@ -789,13 +792,54 @@ def main(args):
trim_path_prefixes = args.trim_path_prefix if \
'trim_path_prefix' in args else None

output_path = None
output_dir_path = None
output_file_path = None
if 'output_path' in args:
output_path = os.path.abspath(args.output_path)

if export == 'html':
output_dir_path = output_path
else:
if os.path.exists(output_path) and os.path.isdir(output_path):
# For backward compatibility reason we handle the use case
# when directory is provided to this command.
LOG.error("Please provide a file path instead of a directory "
"for '%s' export type!", export)
sys.exit(1)

if export == 'baseline' and not baseline.check(output_path):
LOG.error("Baseline files must have '.baseline' extensions.")
sys.exit(1)

output_file_path = output_path
output_dir_path = os.path.dirname(output_file_path)

if not os.path.exists(output_dir_path):
os.makedirs(output_dir_path)

def get_output_file_path(default_file_name) -> Optional[str]:
""" Return an output file path. """
if output_file_path:
return output_file_path

if output_dir_path:
return os.path.join(output_dir_path, default_file_name)

if export:
if export == 'baseline':
report_hashes, number_of_reports = _parse_convert_reports(
args.input, export, context.severity_map, trim_path_prefixes,
skip_handler)

output_path = get_output_file_path("reports.baseline")
if output_path:
baseline.write(output_path, report_hashes)

sys.exit(2 if number_of_reports else 0)

# The HTML part will be handled separately below.
if export != 'html':
output_path = get_output_file_path("reports.json")
sys.exit(_generate_json_output(
context.severity_map, args.input, export, output_path,
trim_path_prefixes, skip_handler))
Expand Down Expand Up @@ -864,7 +908,7 @@ def skip_html_report_data_handler(report_hash, source_file, report_line,

LOG.info("Generating html output files:")
PlistToHtml.parse(input_path,
output_path,
output_dir_path,
context.path_plist_to_html_dist,
skip_html_report_data_handler,
html_builder,
Expand Down Expand Up @@ -927,14 +971,14 @@ def skip_html_report_data_handler(report_hash, source_file, report_line,

# Create index.html and statistics.html for the generated html files.
if html_builder:
html_builder.create_index_html(output_path)
html_builder.create_statistics_html(output_path)
html_builder.create_index_html(output_dir_path)
html_builder.create_statistics_html(output_dir_path)

print('\nTo view statistics in a browser run:\n> firefox {0}'.format(
os.path.join(output_path, 'statistics.html')))
os.path.join(output_dir_path, 'statistics.html')))

print('\nTo view the results in a browser run:\n> firefox {0}'.format(
os.path.join(args.output_path, 'index.html')))
os.path.join(output_dir_path, 'index.html')))
else:
print("\n----==== Summary ====----")
if file_stats:
Expand Down
103 changes: 100 additions & 3 deletions analyzer/tests/functional/analyze_and_parse/test_analyze_and_parse.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
from libtest import project
from libtest.codechecker import call_command

from codechecker_common.output import baseline


class AnalyzeParseTestCaseMeta(type):
def __new__(mcs, name, bases, test_dict):
Expand Down Expand Up @@ -412,16 +414,16 @@ def test_codeclimate_export(self):
""" Test exporting codeclimate output. """
test_project_notes = os.path.join(self.test_workspaces['NORMAL'],
"test_files", "notes")
output_path = self.test_workspaces['OUTPUT']
output_file_path = os.path.join(
self.test_workspaces['OUTPUT'], 'reports.json')
extract_cmd = ['CodeChecker', 'parse', "--export", "codeclimate",
test_project_notes, "--output", output_path,
test_project_notes, "--output", output_file_path,
'--trim-path-prefix', test_project_notes]

out, _, result = call_command(extract_cmd, cwd=self.test_dir,
env=self.env)
self.assertEqual(result, 2, "Parsing not found any issue.")
result_from_stdout = json.loads(out)
output_file_path = os.path.join(output_path, "reports.json")
with open(output_file_path, 'r', encoding='utf-8', errors='ignore') \
as handle:
result_from_file = json.load(handle)
Expand Down Expand Up @@ -544,3 +546,98 @@ def test_html_export_exit_code(self):
out, _, result = call_command(extract_cmd, cwd=self.test_dir,
env=self.env)
self.assertEqual(result, 0, "Parsing should not found any issue.")

def test_baseline_output(self):
""" Test parse baseline output. """
output_path = self.test_workspaces['OUTPUT']
out_file_path = os.path.join(output_path, "reports.baseline")

# Analyze the first project.
test_project_notes = os.path.join(
self.test_workspaces['NORMAL'], "test_files", "notes")

extract_cmd = ['CodeChecker', 'parse',
"-e", "baseline",
"-o", out_file_path,
test_project_notes,
'--trim-path-prefix', test_project_notes]

_, _, result = call_command(
extract_cmd, cwd=self.test_dir, env=self.env)
self.assertEqual(result, 2, "Parsing not found any issue.")

report_hashes = baseline.get_report_hashes([out_file_path])
self.assertEqual(
report_hashes, {'3d15184f38c5fa57e479b744fe3f5035'})

# Analyze the second project and see whether the baseline file is
# merged.
test_project_macros = os.path.join(
self.test_workspaces['NORMAL'], "test_files", "macros")

extract_cmd = ['CodeChecker', 'parse',
"-e", "baseline",
"-o", out_file_path,
test_project_macros,
'--trim-path-prefix', test_project_macros]

_, _, result = call_command(
extract_cmd, cwd=self.test_dir, env=self.env)
self.assertEqual(result, 2, "Parsing not found any issue.")

report_hashes = baseline.get_report_hashes([out_file_path])
self.assertSetEqual(report_hashes, {
'3d15184f38c5fa57e479b744fe3f5035',
'f8fbc46cc5afbb056d92bd3d3d702781'})

def test_invalid_baseline_file_extension(self):
""" Test invalid baseline file extension for parse. """
output_path = self.test_workspaces['OUTPUT']
out_file_path = os.path.join(output_path, "cc_reports.invalid")

# Analyze the first project.
test_project_notes = os.path.join(
self.test_workspaces['NORMAL'], "test_files", "notes")

# Try to create baseline file with invalid extension.
parse_cmd = [
"CodeChecker", "parse", "-e", "baseline", "-o", out_file_path,
test_project_notes]

out, _, result = call_command(
parse_cmd, cwd=self.test_dir, env=self.env)
self.assertEqual(result, 1)
self.assertIn("Baseline files must have '.baseline' extensions", out)

# Try to create baseline file in a directory which exists.
os.makedirs(output_path)
parse_cmd = [
"CodeChecker", "parse", "-e", "baseline", "-o", output_path,
test_project_notes]

out, _, result = call_command(
parse_cmd, cwd=self.test_dir, env=self.env)
self.assertEqual(result, 1)
self.assertIn("Please provide a file path instead of a directory", out)

def test_custom_baseline_file(self):
""" Test parse baseline custom output file. """
output_path = self.test_workspaces['OUTPUT']
out_file_path = os.path.join(output_path, "cc_reports.baseline")

# Analyze the first project.
test_project_notes = os.path.join(
self.test_workspaces['NORMAL'], "test_files", "notes")

extract_cmd = ['CodeChecker', 'parse',
"-e", "baseline",
"-o", out_file_path,
test_project_notes]

_, _, result = call_command(
extract_cmd, cwd=self.test_dir, env=self.env)
self.assertEqual(result, 2, "Parsing not found any issue.")

report_hashes = baseline.get_report_hashes([out_file_path])
self.assertEqual(
report_hashes, {'3d15184f38c5fa57e479b744fe3f5035'})
79 changes: 79 additions & 0 deletions codechecker_common/output/baseline.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# -------------------------------------------------------------------------
#
# Part of the CodeChecker project, under the Apache License v2.0 with
# LLVM Exceptions. See LICENSE for license information.
# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
#
# -------------------------------------------------------------------------
""" CodeChecker baseline output helpers. """

from io import TextIOWrapper
from typing import Iterable, List, Set

from codechecker_common import logger
from codechecker_common.report import Report


LOG = logger.get_logger('system')


def __get_report_hashes(f: TextIOWrapper) -> List[str]:
""" Get report hashes from the given file. """
return [h.strip() for h in f.readlines() if h]


def check(file_path: str) -> bool:
""" True if the given file path is a baseline file. """
return file_path.endswith('.baseline')


def get_report_hashes(
baseline_file_paths: Iterable[str]
) -> Set[str]:
""" Get uniqued hashes from baseline files. """
report_hashes = set()
for file_path in baseline_file_paths:
with open(file_path, mode='r', encoding='utf-8', errors="ignore") as f:
report_hashes.update(__get_report_hashes(f))

return report_hashes


def convert(reports: Iterable[Report]) -> List[str]:
""" Convert the given reports to CodeChecker baseline format.
Returns a list of sorted unique report hashes.
"""
return sorted(set(r.report_hash for r in reports))


def write(file_path: str, report_hashes: Iterable[str]):
""" Create a new baseline file or extend an existing one with the given
report hashes in the given output directory. It will remove the duplicates
and also sort the report hashes before writing it to a file.
"""
with open(file_path, mode='a+', encoding='utf-8', errors="ignore") as f:
f.seek(0)
old_report_hashes = __get_report_hashes(f)
new_report_hashes = set(report_hashes) - set(old_report_hashes)

if not new_report_hashes:
LOG.info("Baseline file (%s) is up-to-date.", file_path)
return

if old_report_hashes:
LOG.info("Merging existing baseline file: %s", file_path)
else:
LOG.info("Creating new baseline file: %s", file_path)

LOG.info("Total number of old report hashes: %d",
len(old_report_hashes))
LOG.info("Total number of new report hashes: %d",
len(new_report_hashes))

LOG.debug("New report hashes: %s", sorted(new_report_hashes))

f.seek(0)
f.truncate()
f.write("\n".join(sorted(
set([*old_report_hashes, *report_hashes]))))
Loading

0 comments on commit 8172976

Please sign in to comment.