diff --git a/coverage/cmdline.py b/coverage/cmdline.py index efed90404..9f5c9ea8c 100644 --- a/coverage/cmdline.py +++ b/coverage/cmdline.py @@ -127,6 +127,11 @@ class Opts: '', '--pretty-print', action='store_true', help="Format the JSON for human readers.", ) + lcov = optparse.make_option( + '-o', '', action='store', dest='outfile', + metavar="OUTFILE", + help="Write the LCOV report to this file. Defaults to 'coverage.lcov'" + ) parallel_mode = optparse.make_option( '-p', '--parallel-mode', action='store_true', help=( @@ -473,6 +478,20 @@ def get_prog_name(self): usage="[options] [modules]", description="Generate an XML report of coverage results." ), + + 'lcov': CmdOptionParser( + "lcov", + [ + Opts.fail_under, + Opts.ignore_errors, + Opts.include, + Opts.lcov, + Opts.omit, + Opts.quiet, + ] + GLOBAL_ARGS, + usage="[options] [modules]", + description="Generate an LCOV report of coverage results." + ) } @@ -657,6 +676,12 @@ def command_line(self, argv): show_contexts=options.show_contexts, **report_args ) + elif options.action == "lcov": + total = self.coverage.lcov_report( + outfile=options.outfile, + **report_args + ) + else: # There are no other possible actions. raise AssertionError @@ -854,6 +879,7 @@ def unglob_args(args): report Report coverage stats on modules. run Run a Python program and measure code execution. xml Create an XML report of coverage results. + lcov Create an LCOV report of coverage results. Use "{program_name} help " for detailed help on any command. """, diff --git a/coverage/config.py b/coverage/config.py index 9835e3417..75217def8 100644 --- a/coverage/config.py +++ b/coverage/config.py @@ -227,6 +227,9 @@ def __init__(self): self.json_pretty_print = False self.json_show_contexts = False + # Default output filename for lcov_reporter + self.lcov_output = "coverage.lcov" + # Defaults for [paths] self.paths = collections.OrderedDict() diff --git a/coverage/control.py b/coverage/control.py index 99319c056..bd51ffc56 100644 --- a/coverage/control.py +++ b/coverage/control.py @@ -26,6 +26,7 @@ from coverage.html import HtmlReporter from coverage.inorout import InOrOut from coverage.jsonreport import JsonReporter +from coverage.lcovreport import LcovReporter from coverage.misc import bool_or_none, join_regex, human_sorted, human_sorted_items from coverage.misc import DefaultValue, ensure_dir_for_file, isolate_module from coverage.plugin import FileReporter @@ -1049,6 +1050,25 @@ def json_report( ): return render_report(self.config.json_output, JsonReporter(self), morfs, self._message) + def lcov_report( + self, morfs=None, outfile=None, ignore_errors=None, + omit=None, include=None, contexts=None, + ): + """Generate an LCOV report of coverage results. + + Each module in 'morfs' is included in the report. 'outfile' is the + path to write the file to, "-" will write to stdout. + + See :meth 'report' for other arguments. + + .. versionadded:: 6.3 + """ + with override_config(self, + ignore_errors=ignore_errors, report_omit=omit, report_include=include, + lcov_output=outfile, report_contexts=contexts, + ): + return render_report(self.config.lcov_output, LcovReporter(self), morfs, self._message) + def sys_info(self): """Return a list of (key, value) pairs showing internal information.""" diff --git a/coverage/lcovreport.py b/coverage/lcovreport.py new file mode 100644 index 000000000..5a49ac4a4 --- /dev/null +++ b/coverage/lcovreport.py @@ -0,0 +1,106 @@ +# Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 +# For details: https://github.com/nedbat/coveragepy/blob/master/NOTICE.txt + +"""LCOV reporting for coverage.py.""" + +import sys +import base64 +from hashlib import md5 + +from coverage.report import get_analysis_to_report + + +class LcovReporter: + """A reporter for writing LCOV coverage reports.""" + + report_type = "LCOV report" + + def __init__(self, coverage): + self.coverage = coverage + self.config = self.coverage.config + + def report(self, morfs, outfile=None): + """Renders the full lcov report + + 'morfs' is a list of modules or filenames + + outfile is the file object to write the file into. + """ + + self.coverage.get_data() + outfile = outfile or sys.stdout + + for fr, analysis in get_analysis_to_report(self.coverage, morfs): + self.get_lcov(fr, analysis, outfile) + + def get_lcov(self, fr, analysis, outfile=None): + """Produces the lcov data for a single file + + get_lcov currently supports both line and branch coverage, + however function coverage is not supported. + + """ + + outfile.write("TN:\n") + outfile.write(f"SF:{fr.relative_filename()}\n") + source_lines = fr.source().splitlines() + for covered in sorted(analysis.executed): + # Note: Coveragepy currently only supports checking *if* a line has + # been executed, not how many times, so we set this to 1 for nice + # output even if it's technically incorrect + + # The lines below calculate a 64 bit encoded md5 hash of the line + # corresponding to the DA lines in the lcov file, + # for either case of the line being covered or missed in Coveragepy + # The final two characters of the encoding ("==") are removed from + # the hash to allow genhtml to run on the resulting lcov file + if source_lines: + line = source_lines[covered - 1].encode("utf-8") + else: + line = b"" + hashed = str(base64.b64encode(md5(line).digest())[:-2], encoding="utf-8") + outfile.write(f"DA:{covered},1,{hashed}\n") + for missed in sorted(analysis.missing): + if source_lines: + line = source_lines[missed - 1].encode("utf-8") + else: + line = b"" + hashed = str(base64.b64encode(md5(line).digest())[:-2], encoding="utf-8") + outfile.write(f"DA:{missed},0,{hashed}\n") + outfile.write(f"LF:{len(analysis.statements)}\n") + outfile.write(f"LH:{len(analysis.executed)}\n") + + # More information dense branch coverage data + missing_arcs = analysis.missing_branch_arcs() + executed_arcs = analysis.executed_branch_arcs() + for block_number, block_line_number in enumerate( + sorted(analysis.branch_stats().keys()) + ): + for branch_number, line_number in enumerate( + sorted(missing_arcs[block_line_number]) + ): + # The exit branches have a negative line number, + # this will not produce valid lcov, and so setting + # the line number of the exit branch to 0 will allow + # for valid lcov, while preserving the data + line_number = max(line_number, 0) + outfile.write(f"BRDA:{line_number},{block_number},{branch_number},-\n") + # The start value below allows for the block number to be + # preserved between these two for loops (stopping the loop from + # resetting the value of the block number to 0) + for branch_number, line_number in enumerate( + sorted(executed_arcs[block_line_number]), + start=len(missing_arcs[block_line_number]), + ): + line_number = max(line_number, 0) + outfile.write(f"BRDA:{line_number},{block_number},{branch_number},1\n") + + # Summary of the branch coverage + if analysis.has_arcs(): + branch_stats = analysis.branch_stats() + brf = sum(t for t, k in branch_stats.values()) + brh = brf - sum(t - k for t, k in branch_stats.values()) + outfile.write(f"BRF:{brf}\n") + outfile.write(f"BRH:{brh}\n") + + outfile.write("end_of_record\n") diff --git a/coverage/results.py b/coverage/results.py index 7bb4781cb..9675bff99 100644 --- a/coverage/results.py +++ b/coverage/results.py @@ -136,6 +136,21 @@ def missing_branch_arcs(self): mba[l1].append(l2) return mba + @contract(returns='dict(int: list(int))') + def executed_branch_arcs(self): + """Return arcs that were executed from branch lines. + + Returns {l1:[l2a,l2b,...], ...} + + """ + executed = self.arcs_executed() + branch_lines = set(self._branch_lines()) + eba = collections.defaultdict(list) + for l1, l2 in executed: + if l1 in branch_lines: + eba[l1].append(l2) + return eba + @contract(returns='dict(int: tuple(int, int))') def branch_stats(self): """Get stats about branches. diff --git a/doc/cmd.rst b/doc/cmd.rst index a26aa8ac3..f4cbff349 100644 --- a/doc/cmd.rst +++ b/doc/cmd.rst @@ -58,6 +58,8 @@ Coverage.py has a number of commands: * **json** -- :ref:`Produce a JSON report with coverage results `. +* **lcov** -- :ref:`Produce an LCOV report with coverage results `. + * **annotate** -- :ref:`Annotate source files with coverage results `. @@ -430,8 +432,8 @@ Reporting --------- Coverage.py provides a few styles of reporting, with the **report**, **html**, -**annotate**, **json**, and **xml** commands. They share a number of common -options. +**annotate**, **json**, **lcov**, and **xml** commands. They share a number +of common options. The command-line arguments are module or file names to report on, if you'd like to report on a subset of the data collected. @@ -785,6 +787,42 @@ The **json** command writes coverage data to a "coverage.json" file. You can specify the name of the output file with the ``-o`` switch. The JSON can be nicely formatted by specifying the ``--pretty-print`` switch. +.. _cmd_lcov: + +LCOV reporting: ``coverage lcov`` +--------------------------------- + +The **json** command writes coverage data to a "coverage.lcov" file. + +.. [[[cog show_help("lcov") ]]] +.. code:: + + $ coverage lcov --help + Usage: coverage lcov [options] [modules] + + Generate an LCOV report of coverage results. + + Options: + --fail-under=MIN Exit with a status of 2 if the total coverage is less + than MIN. + -i, --ignore-errors Ignore errors while reading source files. + --include=PAT1,PAT2,... + Include only files whose paths match one of these + patterns. Accepts shell-style wildcards, which must be + quoted. + -o OUTFILE Write the LCOV report to this file. Defaults to + 'coverage.lcov' + --omit=PAT1,PAT2,... Omit files whose paths match one of these patterns. + Accepts shell-style wildcards, which must be quoted. + -q, --quiet Don't print messages about what is happening. + --debug=OPTS Debug options, separated by commas. [env: + COVERAGE_DEBUG] + -h, --help Get help on this command. + --rcfile=RCFILE Specify configuration file. By default '.coveragerc', + 'setup.cfg', 'tox.ini', and 'pyproject.toml' are + tried. [env: COVERAGE_RCFILE] +.. [[[end]]] (checksum: 4d078e4637e5b507cbb997803a0d4758) + Other common reporting options are described above in :ref:`cmd_reporting`. diff --git a/doc/dict.txt b/doc/dict.txt index 70c4a8901..b9411bb98 100644 --- a/doc/dict.txt +++ b/doc/dict.txt @@ -107,6 +107,7 @@ jquery json jython kwargs +lcov Mako matcher matchers diff --git a/doc/index.rst b/doc/index.rst index 9661797bb..74193cbb4 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -152,7 +152,8 @@ Coverage.py can do a number of things: - It can tell you :ref:`what tests ran which lines `. - It can produce reports in a number of formats: :ref:`text `, - :ref:`HTML `, :ref:`XML `, and :ref:`JSON `. + :ref:`HTML `, :ref:`XML `, :ref:`LCOV `, + and :ref:`JSON `. - For advanced uses, there's an :ref:`API `, and the result data is available in a :ref:`SQLite database `. diff --git a/doc/python-coverage.1.txt b/doc/python-coverage.1.txt index bffc70722..41a3bbea8 100644 --- a/doc/python-coverage.1.txt +++ b/doc/python-coverage.1.txt @@ -67,6 +67,9 @@ COMMAND OVERVIEW |command| **xml** Create an XML report of coverage results. +|command| **lcov** + Create an LCOV report of coverage results. + GLOBAL OPTIONS ============== @@ -229,6 +232,31 @@ COMMAND REFERENCE \--show-contexts Include information about the contexts that executed each line. +**lcov** [ `option` ... ] [ `MODULE` ... ] + + Create an LCOV report of the coverage results. + + Options: + + \--fail-under `MIN` + Exit with a status of 2 if the total coverage is less than `MIN`. + + \-i, --ignore-errors + Ignore errors while reading source files. + + \-o `OUTFILE` + Write the LCOV report to `OUTFILE`. Defaults to ``coverage.lcov``. + + \--include `PATTERN` [ , ... ] + Include only files whose paths match one of these + PATTERNs. Accepts shell-style wildcards, which must be quoted. + + \--omit `PATTERN` [ , ... ] + Omit files when their file name matches one of these PATTERNs. + Usually needs quoting on the command line. + + \-q, --quiet + Don't print messages about what is happening. **report** [ `option` ... ] [ `MODULE` ... ] diff --git a/doc/source.rst b/doc/source.rst index 85a46a339..34aa611f7 100644 --- a/doc/source.rst +++ b/doc/source.rst @@ -88,7 +88,8 @@ reported. Usually you want to see all the code that was measured, but if you are measuring a large project, you may want to get reports for just certain parts. -The report commands (``report``, ``html``, ``json``, ``annotate``, and ``xml``) +The report commands (``report``, ``html``, ``json``, ``lcov``, ``annotate``, +and ``xml``) all take optional ``modules`` arguments, and ``--include`` and ``--omit`` switches. The ``modules`` arguments specify particular modules to report on. The ``include`` and ``omit`` values are lists of file name patterns, just as diff --git a/tests/test_cmdline.py b/tests/test_cmdline.py index 42f313f81..ad46ded33 100644 --- a/tests/test_cmdline.py +++ b/tests/test_cmdline.py @@ -52,6 +52,10 @@ class BaseCmdLineTest(CoverageTest): ignore_errors=None, include=None, omit=None, morfs=[], outfile=None, contexts=None, pretty_print=None, show_contexts=None, ) + _defaults.Coverage().lcov_report( + ignore_errors=None, include=None, omit=None, morfs=[], outfile=None, + contexts=None, + ) _defaults.Coverage( cover_pylib=None, data_suffix=None, timid=None, branch=None, config_file=True, source=None, include=None, omit=None, debug=None, @@ -76,6 +80,7 @@ def model_object(self): cov.html_report.return_value = 50.0 cov.xml_report.return_value = 50.0 cov.json_report.return_value = 50.0 + cov.lcov_report.return_value = 50.0 return mk @@ -438,6 +443,49 @@ def test_json(self): cov.json_report() """) + def test_lcov(self): + # coverage lcov [-i] [--omit DIR,...] [FILE1 FILE2 ...] + self.cmd_executes("lcov", """\ + cov = Coverage() + cov.load() + cov.lcov_report() + """) + self.cmd_executes("lcov -i", """\ + cov = Coverage() + cov.load() + cov.lcov_report(ignore_errors=True) + """) + self.cmd_executes("lcov -o mylcov.foo", """\ + cov = Coverage() + cov.load() + cov.lcov_report(outfile="mylcov.foo") + """) + self.cmd_executes("lcov -o -", """\ + cov = Coverage() + cov.load() + cov.lcov_report(outfile="-") + """) + self.cmd_executes("lcov --omit fooey", """\ + cov = Coverage(omit=["fooey"]) + cov.load() + cov.lcov_report(omit=["fooey"]) + """) + self.cmd_executes("lcov --omit fooey,booey", """\ + cov = Coverage(omit=["fooey", "booey"]) + cov.load() + cov.lcov_report(omit=["fooey", "booey"]) + """) + self.cmd_executes("lcov -q", """\ + cov = Coverage(messages=False) + cov.load() + cov.lcov_report() + """) + self.cmd_executes("lcov --quiet", """\ + cov = Coverage(messages=False) + cov.load() + cov.lcov_report() + """) + def test_report(self): # coverage report [-m] [-i] [-o DIR,...] [FILE1 FILE2 ...] self.cmd_executes("report", """\ @@ -1006,12 +1054,13 @@ def test_exit(self): class CoverageReportingFake: """A fake Coverage.coverage test double.""" # pylint: disable=missing-function-docstring - def __init__(self, report_result, html_result, xml_result, json_report): + def __init__(self, report_result, html_result, xml_result, json_report, lcov_result): self.config = CoverageConfig() self.report_result = report_result self.html_result = html_result self.xml_result = xml_result self.json_result = json_report + self.lcov_result = lcov_result def set_option(self, optname, optvalue): self.config.set_option(optname, optvalue) @@ -1034,28 +1083,34 @@ def xml_report(self, *args_unused, **kwargs_unused): def json_report(self, *args_unused, **kwargs_unused): return self.json_result + def lcov_report(self, *args_unused, **kwargs_unused): + return self.lcov_result @pytest.mark.parametrize("results, fail_under, cmd, ret", [ # Command-line switch properly checks the result of reporting functions. - ((20, 30, 40, 50), None, "report --fail-under=19", 0), - ((20, 30, 40, 50), None, "report --fail-under=21", 2), - ((20, 30, 40, 50), None, "html --fail-under=29", 0), - ((20, 30, 40, 50), None, "html --fail-under=31", 2), - ((20, 30, 40, 50), None, "xml --fail-under=39", 0), - ((20, 30, 40, 50), None, "xml --fail-under=41", 2), - ((20, 30, 40, 50), None, "json --fail-under=49", 0), - ((20, 30, 40, 50), None, "json --fail-under=51", 2), + ((20, 30, 40, 50, 60), None, "report --fail-under=19", 0), + ((20, 30, 40, 50, 60), None, "report --fail-under=21", 2), + ((20, 30, 40, 50, 60), None, "html --fail-under=29", 0), + ((20, 30, 40, 50, 60), None, "html --fail-under=31", 2), + ((20, 30, 40, 50, 60), None, "xml --fail-under=39", 0), + ((20, 30, 40, 50, 60), None, "xml --fail-under=41", 2), + ((20, 30, 40, 50, 60), None, "json --fail-under=49", 0), + ((20, 30, 40, 50, 60), None, "json --fail-under=51", 2), + ((20, 30, 40, 50, 60), None, "lcov --fail-under=59", 0), + ((20, 30, 40, 50, 60), None, "lcov --fail-under=61", 2), # Configuration file setting properly checks the result of reporting. - ((20, 30, 40, 50), 19, "report", 0), - ((20, 30, 40, 50), 21, "report", 2), - ((20, 30, 40, 50), 29, "html", 0), - ((20, 30, 40, 50), 31, "html", 2), - ((20, 30, 40, 50), 39, "xml", 0), - ((20, 30, 40, 50), 41, "xml", 2), - ((20, 30, 40, 50), 49, "json", 0), - ((20, 30, 40, 50), 51, "json", 2), + ((20, 30, 40, 50, 60), 19, "report", 0), + ((20, 30, 40, 50, 60), 21, "report", 2), + ((20, 30, 40, 50, 60), 29, "html", 0), + ((20, 30, 40, 50, 60), 31, "html", 2), + ((20, 30, 40, 50, 60), 39, "xml", 0), + ((20, 30, 40, 50, 60), 41, "xml", 2), + ((20, 30, 40, 50, 60), 49, "json", 0), + ((20, 30, 40, 50, 60), 51, "json", 2), + ((20, 30, 40, 50, 60), 59, "lcov", 0), + ((20, 30, 40, 50, 60), 61, "lcov", 2), # Command-line overrides configuration. - ((20, 30, 40, 50), 19, "report --fail-under=21", 2), + ((20, 30, 40, 50, 60), 19, "report --fail-under=21", 2), ]) def test_fail_under(results, fail_under, cmd, ret): cov = CoverageReportingFake(*results) diff --git a/tests/test_lcov.py b/tests/test_lcov.py new file mode 100644 index 000000000..9d2f8ec6c --- /dev/null +++ b/tests/test_lcov.py @@ -0,0 +1,308 @@ +# Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 +# For details: https://github.com/nedbat/coveragepy/blob/master/NOTICE.txt + +"""Test LCOV-based summary reporting for coverage.py.""" + +import textwrap +import coverage + +from tests.coveragetest import CoverageTest + + +class LcovTest(CoverageTest): + """Tests of the LCOV reports from coverage.py.""" + + def create_initial_files(self): + """ + Helper for tests that handles the common ceremony so the tests can + show the consequences of changes in the setup. + """ + self.make_file( + "main_file.py", + """\ + #!/usr/bin/env python3 + + def cuboid_volume(l): + return (l*l*l) + + def IsItTrue(): + return True + + """, + ) + + self.make_file( + "test_file.py", + """\ + #!/usr/bin/env python3 + + from main_file import cuboid_volume + import unittest + + class TestCuboid(unittest.TestCase): + def test_volume(self): + self.assertAlmostEqual(cuboid_volume(2),8) + self.assertAlmostEqual(cuboid_volume(1),1) + self.assertAlmostEqual(cuboid_volume(0),0) + self.assertAlmostEqual(cuboid_volume(5.5),166.375) + + """, + ) + + def get_lcov_report_content(self): + """Return the content of the LCOV report.""" + filename = "coverage.lcov" + with open(filename, "r") as file: + file_contents = file.read() + return file_contents + + def test_lone_file(self): + """For a single file with a couple of functions, the lcov should cover + the function definitions themselves, but not the returns.""" + self.make_file( + "main_file.py", + """\ + #!/usr/bin/env python3 + + def cuboid_volume(l): + return (l*l*l) + + def IsItTrue(): + return True + + """, + ) + expected_result = """\ + TN: + SF:main_file.py + DA:3,1,7URou3io0zReBkk69lEb/Q + DA:6,1,ilhb4KUfytxtEuClijZPlQ + DA:4,0,Xqj6H1iz/nsARMCAbE90ng + DA:7,0,LWILTcvARcydjFFyo9qM0A + LF:4 + LH:2 + end_of_record + """ + expected_result = textwrap.dedent(expected_result) + self.assert_doesnt_exist(".coverage") + cov = coverage.Coverage(source=["."]) + self.start_import_stop(cov, "main_file") + cov.lcov_report() + actual_result = self.get_lcov_report_content() + assert expected_result == actual_result + + def test_simple_line_coverage_two_files(self): + """Test that line coverage is created when coverage is run, + and matches the output of the file below.""" + self.create_initial_files() + self.assert_doesnt_exist(".coverage") + cov = coverage.Coverage(source=".") + self.start_import_stop(cov, "test_file") + cov.lcov_report() + self.assert_exists("coverage.lcov") + expected_result = """\ + TN: + SF:main_file.py + DA:3,1,7URou3io0zReBkk69lEb/Q + DA:6,1,ilhb4KUfytxtEuClijZPlQ + DA:4,0,Xqj6H1iz/nsARMCAbE90ng + DA:7,0,LWILTcvARcydjFFyo9qM0A + LF:4 + LH:2 + end_of_record + TN: + SF:test_file.py + DA:3,1,R5Rb4IzmjKRgY/vFFc1TRg + DA:4,1,E/tvV9JPVDhEcTCkgrwOFw + DA:6,1,GP08LPBYJq8EzYveHJy2qA + DA:7,1,MV+jSLi6PFEl+WatEAptog + DA:8,0,qyqd1mF289dg6oQAQHA+gQ + DA:9,0,nmEYd5F1KrxemgC9iVjlqg + DA:10,0,jodMK26WYDizOO1C7ekBbg + DA:11,0,LtxfKehkX8o4KvC5GnN52g + LF:8 + LH:4 + end_of_record + """ + expected_result = textwrap.dedent(expected_result) + actual_result = self.get_lcov_report_content() + assert expected_result == actual_result + + def test_branch_coverage_one_file(self): + """Test that the reporter produces valid branch coverage.""" + self.make_file( + "main_file.py", + """\ + #!/usr/bin/env python3 + + def is_it_x(x): + if x == 3: + return x + else: + return False + + """, + ) + self.assert_doesnt_exist(".coverage") + cov = coverage.Coverage(branch=True, source=".") + self.start_import_stop(cov, "main_file") + cov.lcov_report() + self.assert_exists("coverage.lcov") + expected_result = """\ + TN: + SF:main_file.py + DA:3,1,4MDXMbvwQ3L7va1tsphVzw + DA:4,0,MuERA6EYyZNpKPqoJfzwkA + DA:5,0,sAyiiE6iAuPMte9kyd0+3g + DA:7,0,W/g8GJDAYJkSSurt59Mzfw + LF:4 + LH:1 + BRDA:5,0,0,- + BRDA:7,0,1,- + BRF:2 + BRH:0 + end_of_record + """ + expected_result = textwrap.dedent(expected_result) + actual_result = self.get_lcov_report_content() + assert expected_result == actual_result + + def test_branch_coverage_two_files(self): + """Test that valid branch coverage is generated + in the case of two files.""" + self.make_file( + "main_file.py", + """\ + #!/usr/bin/env python3 + + def is_it_x(x): + if x == 3: + return x + else: + return False + + """, + ) + + self.make_file( + "test_file.py", + """\ + #!/usr/bin/env python3 + + from main_file import * + import unittest + + class TestIsItX(unittest.TestCase): + def test_is_it_x(self): + self.assertEqual(is_it_x(3), 3) + self.assertEqual(is_it_x(4), False) + + """, + ) + self.assert_doesnt_exist(".coverage") + cov = coverage.Coverage(branch=True, source=".") + self.start_import_stop(cov, "test_file") + cov.lcov_report() + self.assert_exists("coverage.lcov") + expected_result = """\ + TN: + SF:main_file.py + DA:3,1,4MDXMbvwQ3L7va1tsphVzw + DA:4,0,MuERA6EYyZNpKPqoJfzwkA + DA:5,0,sAyiiE6iAuPMte9kyd0+3g + DA:7,0,W/g8GJDAYJkSSurt59Mzfw + LF:4 + LH:1 + BRDA:5,0,0,- + BRDA:7,0,1,- + BRF:2 + BRH:0 + end_of_record + TN: + SF:test_file.py + DA:3,1,9TxKIyoBtmhopmlbDNa8FQ + DA:4,1,E/tvV9JPVDhEcTCkgrwOFw + DA:6,1,C3s/c8C1Yd/zoNG1GnGexg + DA:7,1,9qPyWexYysgeKtB+YvuzAg + DA:8,0,LycuNcdqoUhPXeuXUTf5lA + DA:9,0,FPTWzd68bDx76HN7VHu1wA + LF:6 + LH:4 + BRDA:0,0,0,1 + BRDA:7,0,1,1 + BRF:2 + BRH:2 + end_of_record + """ + expected_result = textwrap.dedent(expected_result) + actual_result = self.get_lcov_report_content() + assert actual_result == expected_result + + def test_half_covered_branch(self): + """Test that for a given branch that is only half covered, + the block numbers remain the same, and produces valid lcov. + """ + self.make_file( + "main_file.py", + """\ + something = True + + if something: + print("Yes, something") + else: + print("No, nothing") + + """, + ) + self.assert_doesnt_exist(".coverage") + cov = coverage.Coverage(branch=True, source=".") + self.start_import_stop(cov, "main_file") + cov.lcov_report() + self.assert_exists("coverage.lcov") + expected_result = """\ + TN: + SF:main_file.py + DA:1,1,N4kbVOlkNI1rqOfCArBClw + DA:3,1,CmlqqPf0/H+R/p7/PLEXZw + DA:4,1,rE3mWnpoMq2W2sMETVk/uQ + DA:6,0,+Aov7ekIts7C96udNDVIIQ + LF:4 + LH:3 + BRDA:6,0,0,- + BRDA:4,0,1,1 + BRF:2 + BRH:1 + end_of_record + """ + expected_result = textwrap.dedent(expected_result) + actual_result = self.get_lcov_report_content() + assert actual_result == expected_result + + def test_empty_init_files(self): + """Test that in the case of an empty __init__.py file, the lcov + reporter will note that the file is there, and will note the empty + line. It will also note the lack of branches, and the checksum for + the line. + + Although there are no lines found, it will note one line as hit. + """ + + self.make_file("__init__.py", "") + self.assert_doesnt_exist(".coverage") + cov = coverage.Coverage(branch=True, source=".") + self.start_import_stop(cov, "__init__") + cov.lcov_report() + self.assert_exists("coverage.lcov") + expected_result = """\ + TN: + SF:__init__.py + DA:1,1,1B2M2Y8AsgTpgAmY7PhCfg + LF:0 + LH:1 + BRF:0 + BRH:0 + end_of_record + """ + expected_result = textwrap.dedent(expected_result) + actual_result = self.get_lcov_report_content() + assert actual_result == expected_result