From 87fa1e0b911135e63cb85fa6ca6c4b6968a02541 Mon Sep 17 00:00:00 2001 From: devdanzin <74280297+devdanzin@users.noreply.github.com> Date: Fri, 5 Jul 2024 10:52:23 -0300 Subject: [PATCH 1/2] Support regions (functions, classes) in JSON reports. --- coverage/cmdline.py | 12 ++++ coverage/config.py | 4 ++ coverage/control.py | 4 ++ coverage/jsonreport.py | 52 +++++++++++++- tests/test_cmdline.py | 13 +++- tests/test_json.py | 154 +++++++++++++++++++++++++++++++++++++++-- 6 files changed, 231 insertions(+), 8 deletions(-) diff --git a/coverage/cmdline.py b/coverage/cmdline.py index 9f9c06559..d75430143 100644 --- a/coverage/cmdline.py +++ b/coverage/cmdline.py @@ -46,6 +46,10 @@ class Opts: "", "--branch", action="store_true", help="Measure branch coverage in addition to statement coverage.", ) + classes = optparse.make_option( + "", "--classes", action="store_true", metavar="CLASSES", + help="Report coverage for individual classes.", + ) concurrency = optparse.make_option( "", "--concurrency", action="store", metavar="LIBS", help=( @@ -101,6 +105,10 @@ class Opts: "", "--format", action="store", metavar="FORMAT", help="Output format, either text (default), markdown, or total.", ) + functions = optparse.make_option( + "", "--functions", action="store_true", metavar="FUNCTIONS", + help="Report coverage for individual functions and methods.", + ) help = optparse.make_option( "-h", "--help", action="store_true", help="Get help on this command.", @@ -459,9 +467,11 @@ def get_prog_name(self) -> str: "json": CmdOptionParser( "json", [ + Opts.classes, Opts.contexts, Opts.datafle_input, Opts.fail_under, + Opts.functions, Opts.ignore_errors, Opts.include, Opts.omit, @@ -737,6 +747,8 @@ def command_line(self, argv: list[str]) -> int: outfile=options.outfile, pretty_print=options.pretty_print, show_contexts=options.show_contexts, + json_classes=options.classes, + json_functions=options.functions, **report_args, ) elif options.action == "lcov": diff --git a/coverage/config.py b/coverage/config.py index 5468ca490..7def0f35d 100644 --- a/coverage/config.py +++ b/coverage/config.py @@ -244,6 +244,8 @@ def __init__(self) -> None: self.xml_package_depth = 99 # Defaults for [json] + self.json_classes = False + self.json_functions = False self.json_output = "coverage.json" self.json_pretty_print = False self.json_show_contexts = False @@ -422,6 +424,8 @@ def copy(self) -> CoverageConfig: ("xml_package_depth", "xml:package_depth", "int"), # [json] + ("json_classes", "json:classes", "boolean"), + ("json_functions", "json:functions", "boolean"), ("json_output", "json:output"), ("json_pretty_print", "json:pretty_print", "boolean"), ("json_show_contexts", "json:show_contexts", "boolean"), diff --git a/coverage/control.py b/coverage/control.py index 583fed946..b9cca3349 100644 --- a/coverage/control.py +++ b/coverage/control.py @@ -1215,6 +1215,8 @@ def json_report( contexts: list[str] | None = None, pretty_print: bool | None = None, show_contexts: bool | None = None, + json_classes: bool | None = None, + json_functions: bool | None = None, ) -> float: """Generate a JSON report of coverage results. @@ -1240,6 +1242,8 @@ def json_report( report_contexts=contexts, json_pretty_print=pretty_print, json_show_contexts=show_contexts, + json_classes=json_classes, + json_functions=json_functions, ): return render_report(self.config.json_output, JsonReporter(self), morfs, self._message) diff --git a/coverage/jsonreport.py b/coverage/jsonreport.py index 9e515c202..f9b89ecbf 100644 --- a/coverage/jsonreport.py +++ b/coverage/jsonreport.py @@ -19,6 +19,7 @@ if TYPE_CHECKING: from coverage import Coverage from coverage.data import CoverageData + from coverage.plugin import FileReporter # "Version 1" had no format number at all. @@ -60,6 +61,7 @@ def report(self, morfs: Iterable[TMorf] | None, outfile: IO[str]) -> float: measured_files[file_reporter.relative_filename()] = self.report_one_file( coverage_data, analysis, + file_reporter, ) self.report_data["files"] = measured_files @@ -89,7 +91,9 @@ def report(self, morfs: Iterable[TMorf] | None, outfile: IO[str]) -> float: return self.total.n_statements and self.total.pc_covered - def report_one_file(self, coverage_data: CoverageData, analysis: Analysis) -> dict[str, Any]: + def report_one_file( + self, coverage_data: CoverageData, analysis: Analysis, file_reporter: FileReporter + ) -> dict[str, Any]: """Extract the relevant report data for a single file.""" nums = analysis.numbers self.total += nums @@ -101,7 +105,7 @@ def report_one_file(self, coverage_data: CoverageData, analysis: Analysis) -> di "missing_lines": nums.n_missing, "excluded_lines": nums.n_excluded, } - reported_file = { + reported_file: dict[str, Any] = { "executed_lines": sorted(analysis.executed), "summary": summary, "missing_lines": sorted(analysis.missing), @@ -122,6 +126,50 @@ def report_one_file(self, coverage_data: CoverageData, analysis: Analysis) -> di reported_file["missing_branches"] = list( _convert_branch_arcs(analysis.missing_branch_arcs()), ) + report_on = {"class": self.config.json_classes, "function": self.config.json_functions} + if not any(report_on.values()): + return reported_file + + for region in file_reporter.code_regions(): + if not report_on[region.kind]: + continue + elif region.kind not in reported_file: + reported_file[region.kind] = {} + num_lines = len(file_reporter.source().splitlines()) + outside_lines = set(range(1, num_lines + 1)) + outside_lines -= region.lines + narrowed_analysis = analysis.narrow(region.lines) + narrowed_nums = narrowed_analysis.numbers + narrowed_summary = { + "covered_lines": narrowed_nums.n_executed, + "num_statements": narrowed_nums.n_statements, + "percent_covered": narrowed_nums.pc_covered, + "percent_covered_display": narrowed_nums.pc_covered_str, + "missing_lines": narrowed_nums.n_missing, + "excluded_lines": narrowed_nums.n_excluded, + } + reported_file[region.kind][region.name] = { + "executed_lines": sorted(narrowed_analysis.executed), + "summary": narrowed_summary, + "missing_lines": sorted(narrowed_analysis.missing), + "excluded_lines": sorted(narrowed_analysis.excluded), + } + if self.config.json_show_contexts: + contexts = coverage_data.contexts_by_lineno(narrowed_analysis.filename) + reported_file[region.kind][region.name]["contexts"] = contexts + if coverage_data.has_arcs(): + narrowed_summary.update({ + "num_branches": narrowed_nums.n_branches, + "num_partial_branches": narrowed_nums.n_partial_branches, + "covered_branches": narrowed_nums.n_executed_branches, + "missing_branches": narrowed_nums.n_missing_branches, + }) + reported_file[region.kind][region.name]["executed_branches"] = list( + _convert_branch_arcs(narrowed_analysis.executed_branch_arcs()), + ) + reported_file[region.kind][region.name]["missing_branches"] = list( + _convert_branch_arcs(narrowed_analysis.missing_branch_arcs()), + ) return reported_file diff --git a/tests/test_cmdline.py b/tests/test_cmdline.py index f832fcc94..842a1d178 100644 --- a/tests/test_cmdline.py +++ b/tests/test_cmdline.py @@ -58,7 +58,8 @@ class BaseCmdLineTest(CoverageTest): ) _defaults.Coverage().json_report( ignore_errors=None, include=None, omit=None, morfs=[], outfile=None, - contexts=None, pretty_print=None, show_contexts=None, + contexts=None, pretty_print=None, show_contexts=None, json_classes=None, + json_functions=None, ) _defaults.Coverage().lcov_report( ignore_errors=None, include=None, omit=None, morfs=[], outfile=None, @@ -501,6 +502,16 @@ def test_json(self) -> None: cov.load() cov.json_report() """) + self.cmd_executes("json --functions", """\ + cov = Coverage() + cov.load() + cov.json_report(json_functions=True) + """) + self.cmd_executes("json --classes", """\ + cov = Coverage() + cov.load() + cov.json_report(json_classes=True) + """) def test_lcov(self) -> None: # coverage lcov [-i] [--omit DIR,...] [FILE1 FILE2 ...] diff --git a/tests/test_json.py b/tests/test_json.py index c5ef71cb8..e445691d1 100644 --- a/tests/test_json.py +++ b/tests/test_json.py @@ -25,10 +25,10 @@ def _assert_expected_json_report( self, cov: Coverage, expected_result: dict[str, Any], + **kwargs: Any, ) -> None: """ - Helper that handles common ceremonies so tests can clearly show the - consequences of setting various arguments. + Helper that creates an example file for most tests. """ self.make_file("a.py", """\ a = {'b': 1} @@ -41,9 +41,49 @@ def _assert_expected_json_report( if not a: b = 4 """) - a = self.start_import_stop(cov, "a") - output_path = os.path.join(self.temp_dir, "a.json") - cov.json_report(a, outfile=output_path) + self._compare_json_reports(cov, expected_result, "a", **kwargs) + + def _assert_expected_json_report_with_regions( + self, + cov: Coverage, + expected_result: dict[str, Any], + **kwargs: Any, + ) -> None: + """ + Helper that creates an example file for regions tests. + """ + self.make_file("b.py", """\ + a = {'b': 1} + + def c(): + return 1 + + class C: + pass + + class D: + def e(self): + return 2 + def f(self): + return 3 + """) + self._compare_json_reports(cov, expected_result, "b", **kwargs) + + def _compare_json_reports( + self, + cov: Coverage, + expected_result: dict[str, Any], + mod_name: str, + **kwargs: Any, + ) -> None: + """ + Helper that handles common ceremonies, comparing JSON reports that + it creates to expected results, so tests can clearly show the + consequences of setting various arguments. + """ + mod = self.start_import_stop(cov, mod_name) + output_path = os.path.join(self.temp_dir, f"{mod_name}.json") + cov.json_report(mod, outfile=output_path, **kwargs) with open(output_path) as result_file: parsed_result = json.load(result_file) self.assert_recent_datetime( @@ -140,6 +180,110 @@ def test_simple_line_coverage(self) -> None: } self._assert_expected_json_report(cov, expected_result) + def test_regions_coverage(self) -> None: + cov = coverage.Coverage() + expected_result = { + "meta": { + "branch_coverage": False, + "show_contexts": False + }, + "files": { + "b.py": { + "executed_lines": [1, 3, 6, 7, 9, 10, 12], + "summary": { + "covered_lines": 7, + "num_statements": 10, + "percent_covered": 70.0, + "percent_covered_display": "70", + "missing_lines": 3, + "excluded_lines": 0 + }, + "missing_lines": [4, 11, 13], + "excluded_lines": [], + "function": { + "c": { + "executed_lines": [], + "summary": { + "covered_lines": 0, + "num_statements": 1, + "percent_covered": 0.0, + "percent_covered_display": "0", + "missing_lines": 1, + "excluded_lines": 0 + }, + "missing_lines": [4], + "excluded_lines": [] + }, + "D.e": { + "executed_lines": [], + "summary": { + "covered_lines": 0, + "num_statements": 1, + "percent_covered": 0.0, + "percent_covered_display": "0", + "missing_lines": 1, + "excluded_lines": 0 + }, + "missing_lines": [11], + "excluded_lines": [] + }, + "D.f": { + "executed_lines": [], + "summary": { + "covered_lines": 0, + "num_statements": 1, + "percent_covered": 0.0, + "percent_covered_display": "0", + "missing_lines": 1, + "excluded_lines": 0 + }, + "missing_lines": [13], + "excluded_lines": [] + } + }, + "class": { + "C": { + "executed_lines": [], + "summary": { + "covered_lines": 0, + "num_statements": 0, + "percent_covered": 100.0, + "percent_covered_display": "100", + "missing_lines": 0, + "excluded_lines": 0 + }, + "missing_lines": [], + "excluded_lines": [] + }, + "D": { + "executed_lines": [], + "summary": { + "covered_lines": 0, + "num_statements": 2, + "percent_covered": 0.0, + "percent_covered_display": "0", + "missing_lines": 2, + "excluded_lines": 0 + }, + "missing_lines": [11, 13], + "excluded_lines": [] + } + } + } + }, + "totals": { + "covered_lines": 7, + "num_statements": 10, + "percent_covered": 70.0, + "percent_covered_display": "70", + "missing_lines": 3, + "excluded_lines": 0 + } + } + self._assert_expected_json_report_with_regions( + cov, expected_result, json_classes=True, json_functions=True + ) + def run_context_test(self, relative_files: bool) -> None: """A helper for two tests below.""" self.make_file("config", f"""\ From 1c01114f6825f6465351f4fd7f2f719e1b72f72d Mon Sep 17 00:00:00 2001 From: devdanzin <74280297+devdanzin@users.noreply.github.com> Date: Mon, 8 Jul 2024 06:40:44 -0300 Subject: [PATCH 2/2] Always add region information to JSON report. --- coverage/cmdline.py | 12 ------------ coverage/config.py | 4 ---- coverage/control.py | 4 ---- coverage/jsonreport.py | 7 +------ tests/test_cmdline.py | 13 +------------ tests/test_json.py | 13 ++++--------- 6 files changed, 6 insertions(+), 47 deletions(-) diff --git a/coverage/cmdline.py b/coverage/cmdline.py index d75430143..9f9c06559 100644 --- a/coverage/cmdline.py +++ b/coverage/cmdline.py @@ -46,10 +46,6 @@ class Opts: "", "--branch", action="store_true", help="Measure branch coverage in addition to statement coverage.", ) - classes = optparse.make_option( - "", "--classes", action="store_true", metavar="CLASSES", - help="Report coverage for individual classes.", - ) concurrency = optparse.make_option( "", "--concurrency", action="store", metavar="LIBS", help=( @@ -105,10 +101,6 @@ class Opts: "", "--format", action="store", metavar="FORMAT", help="Output format, either text (default), markdown, or total.", ) - functions = optparse.make_option( - "", "--functions", action="store_true", metavar="FUNCTIONS", - help="Report coverage for individual functions and methods.", - ) help = optparse.make_option( "-h", "--help", action="store_true", help="Get help on this command.", @@ -467,11 +459,9 @@ def get_prog_name(self) -> str: "json": CmdOptionParser( "json", [ - Opts.classes, Opts.contexts, Opts.datafle_input, Opts.fail_under, - Opts.functions, Opts.ignore_errors, Opts.include, Opts.omit, @@ -747,8 +737,6 @@ def command_line(self, argv: list[str]) -> int: outfile=options.outfile, pretty_print=options.pretty_print, show_contexts=options.show_contexts, - json_classes=options.classes, - json_functions=options.functions, **report_args, ) elif options.action == "lcov": diff --git a/coverage/config.py b/coverage/config.py index 7def0f35d..5468ca490 100644 --- a/coverage/config.py +++ b/coverage/config.py @@ -244,8 +244,6 @@ def __init__(self) -> None: self.xml_package_depth = 99 # Defaults for [json] - self.json_classes = False - self.json_functions = False self.json_output = "coverage.json" self.json_pretty_print = False self.json_show_contexts = False @@ -424,8 +422,6 @@ def copy(self) -> CoverageConfig: ("xml_package_depth", "xml:package_depth", "int"), # [json] - ("json_classes", "json:classes", "boolean"), - ("json_functions", "json:functions", "boolean"), ("json_output", "json:output"), ("json_pretty_print", "json:pretty_print", "boolean"), ("json_show_contexts", "json:show_contexts", "boolean"), diff --git a/coverage/control.py b/coverage/control.py index b9cca3349..583fed946 100644 --- a/coverage/control.py +++ b/coverage/control.py @@ -1215,8 +1215,6 @@ def json_report( contexts: list[str] | None = None, pretty_print: bool | None = None, show_contexts: bool | None = None, - json_classes: bool | None = None, - json_functions: bool | None = None, ) -> float: """Generate a JSON report of coverage results. @@ -1242,8 +1240,6 @@ def json_report( report_contexts=contexts, json_pretty_print=pretty_print, json_show_contexts=show_contexts, - json_classes=json_classes, - json_functions=json_functions, ): return render_report(self.config.json_output, JsonReporter(self), morfs, self._message) diff --git a/coverage/jsonreport.py b/coverage/jsonreport.py index f9b89ecbf..18b05b3ac 100644 --- a/coverage/jsonreport.py +++ b/coverage/jsonreport.py @@ -126,14 +126,9 @@ def report_one_file( reported_file["missing_branches"] = list( _convert_branch_arcs(analysis.missing_branch_arcs()), ) - report_on = {"class": self.config.json_classes, "function": self.config.json_functions} - if not any(report_on.values()): - return reported_file for region in file_reporter.code_regions(): - if not report_on[region.kind]: - continue - elif region.kind not in reported_file: + if region.kind not in reported_file: reported_file[region.kind] = {} num_lines = len(file_reporter.source().splitlines()) outside_lines = set(range(1, num_lines + 1)) diff --git a/tests/test_cmdline.py b/tests/test_cmdline.py index 842a1d178..f832fcc94 100644 --- a/tests/test_cmdline.py +++ b/tests/test_cmdline.py @@ -58,8 +58,7 @@ class BaseCmdLineTest(CoverageTest): ) _defaults.Coverage().json_report( ignore_errors=None, include=None, omit=None, morfs=[], outfile=None, - contexts=None, pretty_print=None, show_contexts=None, json_classes=None, - json_functions=None, + contexts=None, pretty_print=None, show_contexts=None, ) _defaults.Coverage().lcov_report( ignore_errors=None, include=None, omit=None, morfs=[], outfile=None, @@ -502,16 +501,6 @@ def test_json(self) -> None: cov.load() cov.json_report() """) - self.cmd_executes("json --functions", """\ - cov = Coverage() - cov.load() - cov.json_report(json_functions=True) - """) - self.cmd_executes("json --classes", """\ - cov = Coverage() - cov.load() - cov.json_report(json_classes=True) - """) def test_lcov(self) -> None: # coverage lcov [-i] [--omit DIR,...] [FILE1 FILE2 ...] diff --git a/tests/test_json.py b/tests/test_json.py index e445691d1..b221bc167 100644 --- a/tests/test_json.py +++ b/tests/test_json.py @@ -25,7 +25,6 @@ def _assert_expected_json_report( self, cov: Coverage, expected_result: dict[str, Any], - **kwargs: Any, ) -> None: """ Helper that creates an example file for most tests. @@ -41,13 +40,12 @@ def _assert_expected_json_report( if not a: b = 4 """) - self._compare_json_reports(cov, expected_result, "a", **kwargs) + self._compare_json_reports(cov, expected_result, "a") def _assert_expected_json_report_with_regions( self, cov: Coverage, expected_result: dict[str, Any], - **kwargs: Any, ) -> None: """ Helper that creates an example file for regions tests. @@ -67,14 +65,13 @@ def e(self): def f(self): return 3 """) - self._compare_json_reports(cov, expected_result, "b", **kwargs) + self._compare_json_reports(cov, expected_result, "b") def _compare_json_reports( self, cov: Coverage, expected_result: dict[str, Any], mod_name: str, - **kwargs: Any, ) -> None: """ Helper that handles common ceremonies, comparing JSON reports that @@ -83,7 +80,7 @@ def _compare_json_reports( """ mod = self.start_import_stop(cov, mod_name) output_path = os.path.join(self.temp_dir, f"{mod_name}.json") - cov.json_report(mod, outfile=output_path, **kwargs) + cov.json_report(mod, outfile=output_path) with open(output_path) as result_file: parsed_result = json.load(result_file) self.assert_recent_datetime( @@ -280,9 +277,7 @@ def test_regions_coverage(self) -> None: "excluded_lines": 0 } } - self._assert_expected_json_report_with_regions( - cov, expected_result, json_classes=True, json_functions=True - ) + self._assert_expected_json_report_with_regions(cov, expected_result) def run_context_test(self, relative_files: bool) -> None: """A helper for two tests below."""