From 15e13db3d13557badc6eb78436fdddc3b623a942 Mon Sep 17 00:00:00 2001 From: Ilya Boyazitov Date: Sun, 29 Sep 2024 21:56:49 +0300 Subject: [PATCH 1/4] add flag for xml report --- src/slipcover/__init__.py | 2 +- src/slipcover/__main__.py | 8 + src/slipcover/slipcover.py | 56 +++++- src/slipcover/version.py | 1 + src/slipcover/xmlreport.py | 319 ++++++++++++++++++++++++++++++ tests/test_coverage.py | 391 ++++++++++++++++++++++++++++++++++++- 6 files changed, 763 insertions(+), 14 deletions(-) create mode 100644 src/slipcover/xmlreport.py diff --git a/src/slipcover/__init__.py b/src/slipcover/__init__.py index ba80be3..7290137 100644 --- a/src/slipcover/__init__.py +++ b/src/slipcover/__init__.py @@ -1,4 +1,4 @@ from .version import __version__ -from .slipcover import Slipcover, merge_coverage, print_coverage +from .slipcover import Slipcover, merge_coverage, print_coverage, print_xml from .importer import FileMatcher, ImportManager, wrap_pytest from .fuzz import wrap_function diff --git a/src/slipcover/__main__.py b/src/slipcover/__main__.py index a94f379..d38c151 100644 --- a/src/slipcover/__main__.py +++ b/src/slipcover/__main__.py @@ -130,6 +130,11 @@ def main(): ap.add_argument('--branch', action='store_true', help="measure both branch and line coverage") ap.add_argument('--json', action='store_true', help="select JSON output") ap.add_argument('--pretty-print', action='store_true', help="pretty-print JSON output") + ap.add_argument('--xml', action='store_true', help="select XML output") + ap.add_argument('--xml-package-depth', type=int, default=99, help=( + "Controls which directories are identified as packages in the report. " + "Directories deeper than this depth are not reported as packages. " + "The default is that all directories are reported as packages.")) ap.add_argument('--out', type=Path, help="specify output file name") ap.add_argument('--source', help="specify directories to cover") ap.add_argument('--omit', help="specify file(s) to omit") @@ -205,6 +210,9 @@ def sci_atexit(): def printit(coverage, outfile): if args.json: print(json.dumps(coverage, indent=(4 if args.pretty_print else None)), file=outfile) + elif args.xml: + sc.print_xml(coverage, source_paths=[str(base_path)], with_branches=args.branch, + xml_package_depth=args.xml_package_depth, outfile=outfile) else: sc.print_coverage(coverage, outfile=outfile, skip_covered=args.skip_covered, missing_width=args.missing_width) diff --git a/src/slipcover/slipcover.py b/src/slipcover/slipcover.py index ee882c2..31f692b 100644 --- a/src/slipcover/slipcover.py +++ b/src/slipcover/slipcover.py @@ -1,18 +1,21 @@ from __future__ import annotations -import sys + import dis -import types -from typing import Dict, Set, List, Tuple -from collections import defaultdict, Counter +import sys import threading +import types +from collections import Counter, defaultdict +from typing import TYPE_CHECKING, Dict, Iterable, Iterator, List, NotRequired, Tuple, TypedDict if sys.version_info[0:2] < (3,12): - from . import probe from . import bytecode as bc + from . import probe from pathlib import Path + from . import branch as br from .version import __version__ +from .xmlreport import XmlReporter # FIXME provide __all__ @@ -38,6 +41,32 @@ def findlinestarts(co: types.CodeType): else: findlinestarts = dis.findlinestarts +if TYPE_CHECKING: + class CoverageMeta(TypedDict): + software: str + version: str + timestamp: str + branch_coverage: bool + show_contexts: bool + + class CoverageSummary(TypedDict): + covered_lines: int + missing_lines: int + covered_branches: NotRequired[int] + missing_branches: NotRequired[int] + percent_covered: float + + class CoverageFile(TypedDict): + executed_lines: List[int] + missing_lines: List[int] + executed_branches: NotRequired[List[Tuple[int, int]]] + missing_branches: NotRequired[List[Tuple[int, int]]] + summary: CoverageSummary + + class Coverage(TypedDict): + meta: CoverageMeta + files: Dict[str, CoverageFile] + summary: CoverageSummary class SlipcoverError(Exception): pass @@ -56,7 +85,7 @@ def simplify(self, path : str) -> str: def format_missing(missing_lines: List[int], executed_lines: List[int], - missing_branches: List[tuple]) -> List[str]: + missing_branches: List[tuple]) -> str: """Formats ranges of missing lines, including non-code (e.g., comments) ones that fall between missed ones""" @@ -92,6 +121,21 @@ def find_ranges(): return ", ".join(find_ranges()) +def print_xml( + coverage: Coverage, + source_paths: Iterable[str], + *, + with_branches: bool = False, + xml_package_depth: int = 99, + outfile=sys.stdout +) -> None: + XmlReporter( + coverage=coverage, + source=source_paths, + with_branches=with_branches, + xml_package_depth=xml_package_depth, + ).report(outfile=outfile) + def print_coverage(coverage, *, outfile=sys.stdout, missing_width=None, skip_covered=False) -> None: """Prints coverage information for human consumption.""" diff --git a/src/slipcover/version.py b/src/slipcover/version.py index f871089..4c983e8 100644 --- a/src/slipcover/version.py +++ b/src/slipcover/version.py @@ -1 +1,2 @@ __version__ = "1.0.15" +__url__ = f"https://github.com/plasma-umass/slipcover/tree/v{__version__}" diff --git a/src/slipcover/xmlreport.py b/src/slipcover/xmlreport.py new file mode 100644 index 0000000..6cb4b3e --- /dev/null +++ b/src/slipcover/xmlreport.py @@ -0,0 +1,319 @@ +"""XML reporting for slipcover""" + +from __future__ import annotations + +import functools +import os +import os.path +import re +import sys +import time +import xml.dom.minidom +from collections import defaultdict +from dataclasses import dataclass +from pathlib import Path +from typing import IO, TYPE_CHECKING, Any, Dict, Iterable, List, Tuple + +from slipcover.version import __url__, __version__ + +if TYPE_CHECKING: + from typing import Sequence, TypeVar + + from slipcover.slipcover import Coverage, CoverageFile + + SortableItem = TypeVar("SortableItem", bound=Sequence[Any]) + +DTD_URL = "https://raw.githubusercontent.com/cobertura/web/master/htdocs/xml/coverage-04.dtd" + + +@functools.lru_cache(maxsize=None) +def _human_key(s: str) -> tuple[list[str | int], str]: + """Turn a string into a list of string and number chunks. + + "z23a" -> (["z", 23, "a"], "z23a") + + The original string is appended as a last value to ensure the + key is unique enough so that "x1y" and "x001y" can be distinguished. + """ + + def tryint(s: str) -> str | int: + """If `s` is a number, return an int, else `s` unchanged.""" + try: + return int(s) + except ValueError: + return s + + return ([tryint(c) for c in re.split(r"(\d+)", s)], s) + + +def human_sorted(strings: Iterable[str]) -> list[str]: + """Sort the given iterable of strings the way that humans expect. + + Numeric components in the strings are sorted as numbers. + + Returns the sorted list. + + """ + return sorted(strings, key=_human_key) + + +def human_sorted_items( + items: Iterable[SortableItem], + reverse: bool = False, +) -> list[SortableItem]: + """Sort (string, ...) items the way humans expect. + + The elements of `items` can be any tuple/list. They'll be sorted by the + first element (a string), with ties broken by the remaining elements. + + Returns the sorted list of items. + """ + return sorted(items, key=lambda item: (_human_key(item[0]), *item[1:]), reverse=reverse) + + +def rate(hit: int, num: int) -> str: + """Return the fraction of `hit`/`num`, as a string.""" + if num == 0: + return "1" + else: + return "%.4g" % (hit / num) + + +@dataclass +class PackageData: + """Data we keep about each "package" (in Java terms).""" + + elements: dict[str, xml.dom.minidom.Element] + hits: int + lines: int + br_hits: int + branches: int + + +def appendChild(parent: Any, child: Any) -> None: + """Append a child to a parent, in a way mypy will shut up about.""" + parent.appendChild(child) + + +def get_missing_branch_arcs(file_data: CoverageFile) -> Dict[int, List[int]]: + """Return arcs that weren't executed from branch lines. + + Returns {l1:[l2a,l2b,...], ...} + + """ + mba: Dict[int, List[int]] = {} + for branch in file_data["missing_branches"]: + mba.setdefault(branch[0], []).append(branch[1]) + + return mba + + +def get_branch_stats( + file_data: CoverageFile, missing_arcs: Dict[int, List[int]] +) -> Dict[int, Tuple[int, int]]: + """Get stats about branches. + + Returns a dict mapping line numbers to a tuple: + (total_exits, taken_exits). + + """ + all_branches = sorted(file_data["executed_branches"] + file_data["missing_branches"]) + + exits: Dict[int, int] = defaultdict(lambda: 0) + for branch in all_branches: + exits[branch[0]] += 1 + + stats = {} + for branch in all_branches: + lnum = branch[0] + stats[lnum] = (exits[lnum], exits[lnum] - len(missing_arcs.get(lnum, []))) + + return stats + + +class XmlReporter: + """A reporter for writing Cobertura-style XML coverage results.""" + + def __init__( + self, + coverage: Coverage, + source: Iterable[str], + with_branches: bool, + xml_package_depth: int + ) -> None: + self.coverage = coverage + self.xml_package_depth = xml_package_depth + self.with_branches = with_branches + + self.source_paths = set() + for src in source: + if os.path.exists(src): + self.source_paths.add(src.rstrip(r"\/")) + self.packages: dict[str, PackageData] = {} + self.xml_out: xml.dom.minidom.Document + + def report(self, outfile: IO[str] | None = None) -> None: + """Generate a Cobertura-compatible XML report. + + `outfile` is a file object to write the XML to. + + """ + # Initial setup. + outfile = outfile or sys.stdout + + # Create the DOM that will store the data. + impl = xml.dom.minidom.getDOMImplementation() + assert impl is not None + self.xml_out = impl.createDocument(None, "coverage", None) + + # Write header stuff. + xcoverage = self.xml_out.documentElement + xcoverage.setAttribute("version", __version__) + xcoverage.setAttribute("timestamp", str(int(time.time() * 1000))) + xcoverage.appendChild(self.xml_out.createComment(f" Generated by slipcover: {__url__} ")) + xcoverage.appendChild(self.xml_out.createComment(f" Based on {DTD_URL} ")) + + # Call xml_file for each file in the data. + for file_path, file_data in self.coverage["files"].items(): + self.xml_file(file_path, file_data) + + xsources = self.xml_out.createElement("sources") + xcoverage.appendChild(xsources) + + # Populate the XML DOM with the source info. + for path in human_sorted(self.source_paths): + xsource = self.xml_out.createElement("source") + appendChild(xsources, xsource) + txt = self.xml_out.createTextNode(path) + appendChild(xsource, txt) + + lnum_tot, lhits_tot = 0, 0 + bnum_tot, bhits_tot = 0, 0 + + xpackages = self.xml_out.createElement("packages") + xcoverage.appendChild(xpackages) + + # Populate the XML DOM with the package info. + for pkg_name, pkg_data in human_sorted_items(self.packages.items()): + xpackage = self.xml_out.createElement("package") + appendChild(xpackages, xpackage) + xclasses = self.xml_out.createElement("classes") + appendChild(xpackage, xclasses) + for _, class_elt in human_sorted_items(pkg_data.elements.items()): + appendChild(xclasses, class_elt) + xpackage.setAttribute("name", pkg_name.replace(os.sep, ".")) + xpackage.setAttribute("line-rate", rate(pkg_data.hits, pkg_data.lines)) + if self.with_branches: + branch_rate = rate(pkg_data.br_hits, pkg_data.branches) + else: + branch_rate = "0" + xpackage.setAttribute("branch-rate", branch_rate) + xpackage.setAttribute("complexity", "0") + + lhits_tot += pkg_data.hits + lnum_tot += pkg_data.lines + bhits_tot += pkg_data.br_hits + bnum_tot += pkg_data.branches + + xcoverage.setAttribute("lines-valid", str(lnum_tot)) + xcoverage.setAttribute("lines-covered", str(lhits_tot)) + xcoverage.setAttribute("line-rate", rate(lhits_tot, lnum_tot)) + if self.with_branches: + xcoverage.setAttribute("branches-valid", str(bnum_tot)) + xcoverage.setAttribute("branches-covered", str(bhits_tot)) + xcoverage.setAttribute("branch-rate", rate(bhits_tot, bnum_tot)) + else: + xcoverage.setAttribute("branches-covered", "0") + xcoverage.setAttribute("branches-valid", "0") + xcoverage.setAttribute("branch-rate", "0") + xcoverage.setAttribute("complexity", "0") + + # Write the output file. + outfile.write(serialize_xml(self.xml_out)) + + def xml_file(self, file_path: str, file_data: CoverageFile) -> None: + """Add to the XML report for a single file.""" + + # Create the "lines" and "package" XML elements, which + # are populated later. Note that a package == a directory. + filename = file_path.replace("\\", "/") + for source_path in self.source_paths: + if filename.startswith(source_path.replace("\\", "/") + "/"): + rel_name = filename[len(source_path) + 1:] + break + else: + full_name = Path(file_path).resolve() + rel_name = str(full_name.relative_to(Path.cwd())) + self.source_paths.add(str(full_name)[:-len(rel_name)].rstrip(r"\/")) + + dirname = os.path.dirname(rel_name) or "." + dirname = "/".join(dirname.split("/")[: self.xml_package_depth]) + package_name = dirname.replace("/", ".") + + package = self.packages.setdefault(package_name, PackageData({}, 0, 0, 0, 0)) + + xclass: xml.dom.minidom.Element = self.xml_out.createElement("class") + + appendChild(xclass, self.xml_out.createElement("methods")) + + xlines = self.xml_out.createElement("lines") + appendChild(xclass, xlines) + + xclass.setAttribute("name", os.path.relpath(rel_name, dirname)) + xclass.setAttribute("filename", rel_name.replace("\\", "/")) + xclass.setAttribute("complexity", "0") + + if self.with_branches: + missing_branch_arcs = get_missing_branch_arcs(file_data) + branch_stats = get_branch_stats(file_data, missing_branch_arcs) + + # For each statement, create an XML "line" element. + all_lines = sorted(file_data["executed_lines"] + file_data["missing_lines"]) + for line in all_lines: + xline = self.xml_out.createElement("line") + xline.setAttribute("number", str(line)) + xline.setAttribute("hits", str(int(line not in file_data["missing_lines"]))) + + if self.with_branches: + if line in branch_stats: + total, taken = branch_stats[line] + xline.setAttribute("branch", "true") + xline.setAttribute( + "condition-coverage", + "%d%% (%d/%d)" % (100 * taken // total, taken, total), + ) + if line in missing_branch_arcs: + annlines = ["exit" if b <= 0 else str(b) for b in missing_branch_arcs[line]] + xline.setAttribute("missing-branches", ",".join(annlines)) + + appendChild(xlines, xline) + + class_lines = len(all_lines) + class_hits = class_lines - len(file_data["missing_lines"]) + + if self.with_branches: + class_branches = sum(t for t, k in branch_stats.values()) + missing_branches = sum(t - k for t, k in branch_stats.values()) + class_br_hits = class_branches - missing_branches + else: + class_branches = 0 + class_br_hits = 0 + + # Finalize the statistics that are collected in the XML DOM. + xclass.setAttribute("line-rate", rate(class_hits, class_lines)) + if self.with_branches: + branch_rate = rate(class_br_hits, class_branches) + else: + branch_rate = "0" + xclass.setAttribute("branch-rate", branch_rate) + + package.elements[rel_name] = xclass + package.hits += class_hits + package.lines += class_lines + package.br_hits += class_br_hits + package.branches += class_branches + + +def serialize_xml(dom: xml.dom.minidom.Document) -> str: + """Serialize a minidom node to XML.""" + return dom.toprettyxml() diff --git a/tests/test_coverage.py b/tests/test_coverage.py index 5059e45..49cee40 100644 --- a/tests/test_coverage.py +++ b/tests/test_coverage.py @@ -1,15 +1,15 @@ -import pytest -import slipcover.slipcover as sc -import slipcover.branch as br -import types import dis -import sys -import platform +import json import re import subprocess +import sys +import xml.etree.ElementTree as ET from pathlib import Path -import json +import pytest + +import slipcover.branch as br +import slipcover.slipcover as sc PYTHON_VERSION = sys.version_info[0:2] @@ -1046,3 +1046,380 @@ def test_merge_flag_no_out(cov_merge_fixture): with pytest.raises(subprocess.CalledProcessError): subprocess.run([sys.executable, '-m', 'slipcover', '--merge', 'a.json', 'b.json'], check=True) + +def test_xml_flag(cov_merge_fixture: Path): + p = subprocess.run([sys.executable, '-m', 'slipcover', '--xml', '--out', "out.xml", "t.py"], check=True) + assert 0 == p.returncode + + xtext = (cov_merge_fixture / 'out.xml').read_text(encoding='utf8') + dom = ET.fromstring(xtext) + + assert dom.tag == 'coverage' + + assert dom.get('lines-valid') == '7' + assert dom.get('lines-covered') == '5' + assert dom.get('line-rate') == '0.7143' + assert dom.get('branch-rate') == '0' + assert dom.get('complexity') == '0' + + sources = dom.findall('.//sources/source') + assert [elt.text for elt in sources] == [str(Path.cwd())] + + package = dom.find('.//packages/package') + assert package.get('name') == '.' + assert package.get('line-rate') == '0.7143' + assert package.get('branch-rate') == '0' + assert package.get('complexity') == '0' + + class_ = package.find('.//classes/class') + assert class_.get('name') == 't.py' + assert class_.get('filename') == 't.py' + assert class_.get('complexity') == '0' + assert class_.get('line-rate') == '0.7143' + assert class_.get('branch-rate') == '0' + + lines = class_.findall('.//lines/line') + assert len(lines) == 7 + + assert lines[0].get('number') == '1' + assert lines[0].get('hits') == '1' + assert lines[0].get('branch') is None + assert lines[0].get('condition-coverage') is None + assert lines[0].get('missing-branches') is None + + assert lines[1].get('number') == '3' + assert lines[1].get('hits') == '1' + assert lines[1].get('branch') is None + assert lines[1].get('condition-coverage') is None + assert lines[1].get('missing-branches') is None + + assert lines[2].get('number') == '4' + assert lines[2].get('hits') == '1' + assert lines[2].get('branch') is None + assert lines[2].get('condition-coverage') is None + assert lines[2].get('missing-branches') is None + + assert lines[3].get('number') == '6' + assert lines[3].get('hits') == '0' + assert lines[3].get('branch') is None + assert lines[3].get('condition-coverage') is None + assert lines[3].get('missing-branches') is None + + assert lines[4].get('number') == '8' + assert lines[4].get('hits') == '1' + assert lines[4].get('branch') is None + assert lines[4].get('condition-coverage') is None + assert lines[4].get('missing-branches') is None + + assert lines[5].get('number') == '9' + assert lines[5].get('hits') == '0' + assert lines[5].get('branch') is None + assert lines[5].get('condition-coverage') is None + assert lines[5].get('missing-branches') is None + + assert lines[6].get('number') == '11' + assert lines[6].get('hits') == '1' + assert lines[6].get('branch') is None + assert lines[6].get('condition-coverage') is None + assert lines[6].get('missing-branches') is None + +def test_xml_flag_with_branches(cov_merge_fixture: Path): + p = subprocess.run([sys.executable, '-m', 'slipcover', '--branch', '--xml', '--out', "out.xml", "t.py"], check=True) + assert 0 == p.returncode + + xtext = (cov_merge_fixture / 'out.xml').read_text(encoding='utf8') + dom = ET.fromstring(xtext) + + assert dom.tag == 'coverage' + + assert dom.get('lines-valid') == '7' + assert dom.get('lines-covered') == '5' + assert dom.get('line-rate') == '0.7143' + assert dom.get('branch-rate') == '0.5' + assert dom.get('complexity') == '0' + + sources = dom.findall('.//sources/source') + assert [elt.text for elt in sources] == [str(Path.cwd())] + + package = dom.find('.//packages/package') + assert package.get('name') == '.' + assert package.get('line-rate') == '0.7143' + assert package.get('branch-rate') == '0.5' + assert package.get('complexity') == '0' + + class_ = package.find('.//classes/class') + assert class_.get('name') == 't.py' + assert class_.get('filename') == 't.py' + assert class_.get('complexity') == '0' + assert class_.get('line-rate') == '0.7143' + assert class_.get('branch-rate') == '0.5' + + lines = class_.findall('.//lines/line') + assert len(lines) == 7 + + assert lines[0].get('number') == '1' + assert lines[0].get('hits') == '1' + assert lines[0].get('branch') is None + assert lines[0].get('condition-coverage') is None + assert lines[0].get('missing-branches') is None + + assert lines[1].get('number') == '3' + assert lines[1].get('hits') == '1' + assert lines[1].get('branch') == 'true' + assert lines[1].get('condition-coverage') == '50% (1/2)' + assert lines[1].get('missing-branches') == '6' + + assert lines[2].get('number') == '4' + assert lines[2].get('hits') == '1' + assert lines[2].get('branch') is None + assert lines[2].get('condition-coverage') is None + assert lines[2].get('missing-branches') is None + + assert lines[3].get('number') == '6' + assert lines[3].get('hits') == '0' + assert lines[3].get('branch') is None + assert lines[3].get('condition-coverage') is None + assert lines[3].get('missing-branches') is None + + assert lines[4].get('number') == '8' + assert lines[4].get('hits') == '1' + assert lines[4].get('branch') == 'true' + assert lines[4].get('condition-coverage') == '50% (1/2)' + assert lines[4].get('missing-branches') == '9' + + assert lines[5].get('number') == '9' + assert lines[5].get('hits') == '0' + assert lines[5].get('branch') is None + assert lines[5].get('condition-coverage') is None + assert lines[5].get('missing-branches') is None + + assert lines[6].get('number') == '11' + assert lines[6].get('hits') == '1' + assert lines[6].get('branch') is None + assert lines[6].get('condition-coverage') is None + assert lines[6].get('missing-branches') is None + +def test_xml_flag_with_pytest(tmp_path): + out_file = tmp_path / "out.xml" + + test_file = str(Path('tests') / 'pyt.py') + + subprocess.run(f"{sys.executable} -m slipcover --xml --out {out_file} -m pytest {test_file}".split(), + check=True) + xtext = out_file.read_text(encoding='utf8') + dom = ET.fromstring(xtext) + + assert dom.tag == 'coverage' + + elts = dom.findall(".//sources/source") + assert [elt.text for elt in elts] == [str(Path.cwd())] + + assert dom.get('lines-valid') == '12' + assert dom.get('lines-covered') == '12' + assert dom.get('line-rate') == '1' + assert dom.get('branch-rate') == '0' + assert dom.get('complexity') == '0' + + sources = dom.findall('.//sources/source') + assert [elt.text for elt in sources] == [str(Path.cwd())] + + package = dom.find('.//packages/package') + assert package.get('name') == 'tests' + assert package.get('line-rate') == '1' + assert package.get('branch-rate') == '0' + assert package.get('complexity') == '0' + + class_ = package.find('.//classes/class') + assert class_.get('name') == 'pyt.py' + assert class_.get('filename') == 'tests/pyt.py' + assert class_.get('complexity') == '0' + assert class_.get('line-rate') == '1' + assert class_.get('branch-rate') == '0' + + lines = class_.findall('.//lines/line') + assert len(lines) == 12 + + assert lines[0].get('number') == '1' + assert lines[0].get('hits') == '1' + assert lines[0].get('branch') is None + assert lines[0].get('condition-coverage') is None + assert lines[0].get('missing-branches') is None + + assert lines[1].get('number') == '2' + assert lines[1].get('hits') == '1' + assert lines[1].get('branch') is None + assert lines[1].get('condition-coverage') is None + assert lines[1].get('missing-branches') is None + + assert lines[2].get('number') == '3' + assert lines[2].get('hits') == '1' + assert lines[2].get('branch') is None + assert lines[2].get('condition-coverage') is None + assert lines[2].get('missing-branches') is None + + assert lines[3].get('number') == '4' + assert lines[3].get('hits') == '1' + assert lines[3].get('branch') is None + assert lines[3].get('condition-coverage') is None + assert lines[3].get('missing-branches') is None + + assert lines[4].get('number') == '5' + assert lines[4].get('hits') == '1' + assert lines[4].get('branch') is None + assert lines[4].get('condition-coverage') is None + assert lines[4].get('missing-branches') is None + + assert lines[5].get('number') == '6' + assert lines[5].get('hits') == '1' + assert lines[5].get('branch') is None + assert lines[5].get('condition-coverage') is None + assert lines[5].get('missing-branches') is None + + assert lines[6].get('number') == '8' + assert lines[6].get('hits') == '1' + assert lines[6].get('branch') is None + assert lines[6].get('condition-coverage') is None + assert lines[6].get('missing-branches') is None + + assert lines[7].get('number') == '9' + assert lines[7].get('hits') == '1' + assert lines[7].get('branch') is None + assert lines[7].get('condition-coverage') is None + assert lines[7].get('missing-branches') is None + + assert lines[8].get('number') == '10' + assert lines[8].get('hits') == '1' + assert lines[8].get('branch') is None + assert lines[8].get('condition-coverage') is None + assert lines[8].get('missing-branches') is None + + assert lines[9].get('number') == '11' + assert lines[9].get('hits') == '1' + assert lines[9].get('branch') is None + assert lines[9].get('condition-coverage') is None + assert lines[9].get('missing-branches') is None + + assert lines[10].get('number') == '13' + assert lines[10].get('hits') == '1' + assert lines[10].get('branch') is None + assert lines[10].get('condition-coverage') is None + assert lines[10].get('missing-branches') is None + + assert lines[11].get('number') == '14' + assert lines[11].get('hits') == '1' + assert lines[11].get('branch') is None + assert lines[11].get('condition-coverage') is None + assert lines[11].get('missing-branches') is None + + +def test_xml_flag_with_branches_and_pytest(tmp_path): + out_file = tmp_path / "out.xml" + + test_file = str(Path('tests') / 'pyt.py') + + subprocess.run(f"{sys.executable} -m slipcover --branch --xml --out {out_file} -m pytest {test_file}".split(), + check=True) + xtext = out_file.read_text(encoding='utf8') + dom = ET.fromstring(xtext) + + assert dom.tag == 'coverage' + + elts = dom.findall(".//sources/source") + assert [elt.text for elt in elts] == [str(Path.cwd())] + + assert dom.get('lines-valid') == '12' + assert dom.get('lines-covered') == '12' + assert dom.get('line-rate') == '1' + assert dom.get('branch-rate') == '0.75' + assert dom.get('complexity') == '0' + + sources = dom.findall('.//sources/source') + assert [elt.text for elt in sources] == [str(Path.cwd())] + + package = dom.find('.//packages/package') + assert package.get('name') == 'tests' + assert package.get('line-rate') == '1' + assert package.get('branch-rate') == '0.75' + assert package.get('complexity') == '0' + + class_ = package.find('.//classes/class') + assert class_.get('name') == 'pyt.py' + assert class_.get('filename') == 'tests/pyt.py' + assert class_.get('complexity') == '0' + assert class_.get('line-rate') == '1' + assert class_.get('branch-rate') == '0.75' + + lines = class_.findall('.//lines/line') + assert len(lines) == 12 + + assert lines[0].get('number') == '1' + assert lines[0].get('hits') == '1' + assert lines[0].get('branch') is None + assert lines[0].get('condition-coverage') is None + assert lines[0].get('missing-branches') is None + + assert lines[1].get('number') == '2' + assert lines[1].get('hits') == '1' + assert lines[1].get('branch') is None + assert lines[1].get('condition-coverage') is None + assert lines[1].get('missing-branches') is None + + assert lines[2].get('number') == '3' + assert lines[2].get('hits') == '1' + assert lines[2].get('branch') == 'true' + assert lines[2].get('condition-coverage') == '50% (1/2)' + assert lines[2].get('missing-branches') == '6' + + assert lines[3].get('number') == '4' + assert lines[3].get('hits') == '1' + assert lines[3].get('branch') == 'true' + assert lines[3].get('condition-coverage') == '100% (2/2)' + assert lines[3].get('missing-branches') is None + + assert lines[4].get('number') == '5' + assert lines[4].get('hits') == '1' + assert lines[4].get('branch') is None + assert lines[4].get('condition-coverage') is None + assert lines[4].get('missing-branches') is None + + assert lines[5].get('number') == '6' + assert lines[5].get('hits') == '1' + assert lines[5].get('branch') is None + assert lines[5].get('condition-coverage') is None + assert lines[5].get('missing-branches') is None + + assert lines[6].get('number') == '8' + assert lines[6].get('hits') == '1' + assert lines[6].get('branch') is None + assert lines[6].get('condition-coverage') is None + assert lines[6].get('missing-branches') is None + + assert lines[7].get('number') == '9' + assert lines[7].get('hits') == '1' + assert lines[7].get('branch') is None + assert lines[7].get('condition-coverage') is None + assert lines[7].get('missing-branches') is None + + assert lines[8].get('number') == '10' + assert lines[8].get('hits') == '1' + assert lines[8].get('branch') is None + assert lines[8].get('condition-coverage') is None + assert lines[8].get('missing-branches') is None + + assert lines[9].get('number') == '11' + assert lines[9].get('hits') == '1' + assert lines[9].get('branch') is None + assert lines[9].get('condition-coverage') is None + assert lines[9].get('missing-branches') is None + + assert lines[10].get('number') == '13' + assert lines[10].get('hits') == '1' + assert lines[10].get('branch') is None + assert lines[10].get('condition-coverage') is None + assert lines[10].get('missing-branches') is None + + assert lines[11].get('number') == '14' + assert lines[11].get('hits') == '1' + assert lines[11].get('branch') is None + assert lines[11].get('condition-coverage') is None + assert lines[11].get('missing-branches') is None From b71b2deb6768021e2d65c58306c8b09d5767dfbe Mon Sep 17 00:00:00 2001 From: Ilya Boyazitov Date: Sun, 29 Sep 2024 22:37:12 +0300 Subject: [PATCH 2/4] fix some tox issues --- src/slipcover/slipcover.py | 4 +++- tox.ini | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/slipcover/slipcover.py b/src/slipcover/slipcover.py index 31f692b..92f0ab5 100644 --- a/src/slipcover/slipcover.py +++ b/src/slipcover/slipcover.py @@ -5,7 +5,7 @@ import threading import types from collections import Counter, defaultdict -from typing import TYPE_CHECKING, Dict, Iterable, Iterator, List, NotRequired, Tuple, TypedDict +from typing import TYPE_CHECKING if sys.version_info[0:2] < (3,12): from . import bytecode as bc @@ -42,6 +42,8 @@ def findlinestarts(co: types.CodeType): findlinestarts = dis.findlinestarts if TYPE_CHECKING: + from typing import Dict, Iterable, Iterator, List, NotRequired, Tuple, TypedDict + class CoverageMeta(TypedDict): software: str version: str diff --git a/tox.ini b/tox.ini index 28dd0fe..0220d0c 100644 --- a/tox.ini +++ b/tox.ini @@ -19,6 +19,8 @@ usedevelop = true # when package switches to src layout, remove usedevelop and switch to wheel install # wheel = true -deps = pytest +deps = + pytest + pytest-forked commands = - pytest + pytest From ed8c39cf9c1daa61ab2492a5c045466146f85ea8 Mon Sep 17 00:00:00 2001 From: Ilya Boyazitov Date: Mon, 30 Sep 2024 11:37:30 +0300 Subject: [PATCH 3/4] add missing copyright notice --- src/slipcover/xmlreport.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/slipcover/xmlreport.py b/src/slipcover/xmlreport.py index 6cb4b3e..838f53b 100644 --- a/src/slipcover/xmlreport.py +++ b/src/slipcover/xmlreport.py @@ -1,3 +1,18 @@ +# Copyright 2001 Gareth Rees. All rights reserved. +# Copyright 2004-2024 Ned Batchelder. All rights reserved. + +# Except where noted otherwise, this software is licensed under the Apache +# License, Version 2.0 (the "License"); you may not use this work except in +# compliance with the License. You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """XML reporting for slipcover""" from __future__ import annotations From 9ff483de5834eef4aceb9fe916b1233777faf920 Mon Sep 17 00:00:00 2001 From: Ilya Boyazitov Date: Tue, 29 Oct 2024 22:21:49 +0300 Subject: [PATCH 4/4] move TypedDict classes to schemas.py --- src/slipcover/schemas.py | 30 ++++++++++++++++++++++++++++++ src/slipcover/slipcover.py | 30 +++--------------------------- src/slipcover/xmlreport.py | 2 +- 3 files changed, 34 insertions(+), 28 deletions(-) create mode 100644 src/slipcover/schemas.py diff --git a/src/slipcover/schemas.py b/src/slipcover/schemas.py new file mode 100644 index 0000000..8fb2540 --- /dev/null +++ b/src/slipcover/schemas.py @@ -0,0 +1,30 @@ +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Dict, List, NotRequired, Tuple, TypedDict + + class CoverageMeta(TypedDict): + software: str + version: str + timestamp: str + branch_coverage: bool + show_contexts: bool + + class CoverageSummary(TypedDict): + covered_lines: int + missing_lines: int + covered_branches: NotRequired[int] + missing_branches: NotRequired[int] + percent_covered: float + + class CoverageFile(TypedDict): + executed_lines: List[int] + missing_lines: List[int] + executed_branches: NotRequired[List[Tuple[int, int]]] + missing_branches: NotRequired[List[Tuple[int, int]]] + summary: CoverageSummary + + class Coverage(TypedDict): + meta: CoverageMeta + files: Dict[str, CoverageFile] + summary: CoverageSummary \ No newline at end of file diff --git a/src/slipcover/slipcover.py b/src/slipcover/slipcover.py index ab3ba29..59a2b7a 100644 --- a/src/slipcover/slipcover.py +++ b/src/slipcover/slipcover.py @@ -42,33 +42,9 @@ def findlinestarts(co: types.CodeType): findlinestarts = dis.findlinestarts if TYPE_CHECKING: - from typing import Dict, Iterable, Iterator, List, NotRequired, Optional, Tuple, TypedDict - - class CoverageMeta(TypedDict): - software: str - version: str - timestamp: str - branch_coverage: bool - show_contexts: bool - - class CoverageSummary(TypedDict): - covered_lines: int - missing_lines: int - covered_branches: NotRequired[int] - missing_branches: NotRequired[int] - percent_covered: float - - class CoverageFile(TypedDict): - executed_lines: List[int] - missing_lines: List[int] - executed_branches: NotRequired[List[Tuple[int, int]]] - missing_branches: NotRequired[List[Tuple[int, int]]] - summary: CoverageSummary - - class Coverage(TypedDict): - meta: CoverageMeta - files: Dict[str, CoverageFile] - summary: CoverageSummary + from typing import Dict, Iterable, Iterator, List, Optional, Tuple + + from .schemas import Coverage class SlipcoverError(Exception): pass diff --git a/src/slipcover/xmlreport.py b/src/slipcover/xmlreport.py index 838f53b..b9dad11 100644 --- a/src/slipcover/xmlreport.py +++ b/src/slipcover/xmlreport.py @@ -34,7 +34,7 @@ if TYPE_CHECKING: from typing import Sequence, TypeVar - from slipcover.slipcover import Coverage, CoverageFile + from .schemas import Coverage, CoverageFile SortableItem = TypeVar("SortableItem", bound=Sequence[Any])