vibefpmgui.coverage

Test coverage (Build options → Coverage): which lines the tests ran.

With coverage on, fpm compiles with gfortran's --coverage (a build folder of its own, as fpm does for any flags), and the test programs write how often each line ran into .gcda files next to the objects. After a test run, a Collector has gcov (the one that matches the compiler) read them, as JSON, several at a time; read_gcov turns that into FileCoverage objects: the editor's gutter shows covered and missed lines, and the Coverage panel the totals and each file's numbers.

No Qt here: vibefpmgui.window.coverage runs the collector on a pool thread.

  1"""Test coverage (Build options → Coverage): which lines the tests ran.
  2
  3With coverage on, fpm compiles with gfortran's ``--coverage`` (a build
  4folder of its own, as fpm does for any flags), and the test programs write
  5how often each line ran into ``.gcda`` files next to the objects. After a
  6test run, a `Collector` has ``gcov`` (the one that matches the compiler)
  7read them, as JSON, several at a time; `read_gcov` turns that into
  8`FileCoverage` objects: the editor's gutter shows covered and missed lines,
  9and the *Coverage* panel the totals and each file's numbers.
 10
 11No Qt here: `vibefpmgui.window.coverage` runs the collector on a pool thread.
 12"""
 13
 14from __future__ import annotations
 15
 16import json
 17import os
 18import re
 19import shutil
 20import subprocess
 21import threading
 22from collections.abc import Iterable
 23from concurrent.futures import ThreadPoolExecutor
 24from dataclasses import dataclass, field
 25from pathlib import Path
 26
 27from .console import NO_WINDOW
 28
 29FLAG = "--coverage"  # gfortran's (compile and link)
 30FILES_PER_GCOV = 10  # .gcda files given to each gcov run
 31
 32
 33@dataclass
 34class FunctionCoverage:
 35    name: str
 36    line: int
 37    end_line: int
 38    hits: int
 39
 40
 41@dataclass
 42class FileCoverage:
 43    path: Path
 44    lines: dict[int, int] = field(default_factory=dict)  # line -> times run (lines with code only)
 45    functions: list[FunctionCoverage] = field(default_factory=list)
 46    branches_found: int = 0
 47    branches_hit: int = 0
 48    line_branches: dict[int, tuple[int, int]] = field(default_factory=dict)  # line -> (taken, total)
 49
 50    @property
 51    def lines_found(self) -> int:
 52        return len(self.lines)
 53
 54    @property
 55    def lines_hit(self) -> int:
 56        return sum(1 for n in self.lines.values() if n > 0)
 57
 58    @property
 59    def functions_found(self) -> int:
 60        return len(self.functions)
 61
 62    @property
 63    def functions_hit(self) -> int:
 64        return sum(1 for f in self.functions if f.hits > 0)
 65
 66    @property
 67    def percent(self) -> float:
 68        return 100.0 * self.lines_hit / self.lines_found if self.lines_found else 0.0
 69
 70    def partial(self, line: int) -> bool:
 71        """The line ran, but not every one of its branches was taken."""
 72        taken, total = self.line_branches.get(line, (0, 0))
 73        return self.lines.get(line, 0) > 0 and taken < total
 74
 75    def missed(self) -> list[tuple[int, int]]:
 76        """The lines not run, as (first, last) ranges: lines in between
 77        without code don't break a range."""
 78        ranges: list[tuple[int, int]] = []
 79        for line in sorted(self.lines):
 80            if self.lines[line] > 0:
 81                start = None
 82                continue
 83            if ranges and start is not None:
 84                ranges[-1] = (ranges[-1][0], line)
 85            else:
 86                ranges.append((line, line))
 87            start = line
 88        return ranges
 89
 90
 91PROCEDURE_RE = re.compile(r"^(?!\s*end\b)\s*(?:[\w\s(),=*:]*?\s)?(program|subroutine|function)\s+\w+",
 92                          re.IGNORECASE)
 93
 94
 95def procedure_kind(lines: list[str], line: int) -> str | None:
 96    """What a function of the coverage starts at (1-based ``line``):
 97    "program", "subroutine" or "function" (gcov only knows gfortran's names
 98    for them), read from its first line; None if it doesn't say."""
 99    if not 1 <= line <= len(lines):
100        return None
101    text = lines[line - 1].split("!", 1)[0]
102    found = PROCEDURE_RE.match(text)
103    return found.group(1).lower() if found else None
104
105
106def ranges_text(ranges: list[tuple[int, int]], limit: int = 12) -> str:
107    """ "8, 10–15, 22" (the first ``limit`` ranges, then "…")."""
108    parts = [str(a) if a == b else f"{a}–{b}" for a, b in ranges[:limit]]
109    return ", ".join(parts) + (", …" if len(ranges) > limit else "")
110
111
112def read_gcov(outputs: Iterable[str]) -> dict[Path, FileCoverage]:
113    """gcov's JSON (``--stdout --json-format``: a document per line, one per
114    .gcda file), by source file (absolute paths). A file in several (built
115    for the library and for the tests, say) has its counts added."""
116    lines: dict[Path, dict[int, int]] = {}
117    functions: dict[Path, dict[tuple[str, int], list[int]]] = {}  # (name, start) -> [end, hits]
118    branches: dict[Path, dict[tuple, int]] = {}  # (line, function, branch) -> times taken
119    for text in outputs:
120        for raw in text.splitlines():
121            if not raw.startswith("{"):
122                continue
123            try:
124                doc = json.loads(raw)
125            except ValueError:
126                continue
127            folder = Path(doc.get("current_working_directory") or ".")  # (where it was compiled)
128            for entry in doc.get("files", []):
129                path = (folder / entry["file"]).resolve()
130                counts = lines.setdefault(path, {})
131                for line in entry.get("lines", []):
132                    number = line["line_number"]
133                    counts[number] = counts.get(number, 0) + max(line["count"], 0)
134                    taken = branches.setdefault(path, {})
135                    for n, branch in enumerate(b for b in line.get("branches", []) if not b.get("throw")):
136                        key = (number, line.get("function_name", ""), n)
137                        taken[key] = taken.get(key, 0) + branch["count"]
138                found = functions.setdefault(path, {})
139                for fn in entry.get("functions", []):
140                    end_hits = found.setdefault((fn["name"], fn["start_line"]), [fn["end_line"], 0])
141                    end_hits[1] += fn["execution_count"]
142    files = {}
143    for path, counts in lines.items():
144        per_line: dict[int, tuple[int, int]] = {}
145        for (number, _fn, _n), count in branches.get(path, {}).items():
146            taken, total = per_line.get(number, (0, 0))
147            per_line[number] = (taken + (count > 0), total + 1)
148        fns = sorted((FunctionCoverage(name, start, end, hits)
149                      for (name, start), (end, hits) in functions.get(path, {}).items()), key=lambda f: f.line)
150        files[path] = FileCoverage(path, counts, fns, sum(t for _h, t in per_line.values()),
151                                   sum(h for h, _t in per_line.values()), per_line)
152    return files
153
154
155def totals(files: list[FileCoverage]) -> dict[str, tuple[int, int]]:
156    """(hit, found) for lines, functions and branches, over ``files``."""
157    return {"lines": (sum(f.lines_hit for f in files), sum(f.lines_found for f in files)),
158            "functions": (sum(f.functions_hit for f in files), sum(f.functions_found for f in files)),
159            "branches": (sum(f.branches_hit for f in files), sum(f.branches_found for f in files))}
160
161
162def find_gcov(compiler: str) -> str | None:
163    """The gcov that goes with the compiler (the same GCC: a gcov of another
164    version can't read the counts): next to it, plain or with the same
165    prefix (``arm64-apple-darwin20.0.0-gcov``), else the one on the PATH."""
166    found = shutil.which(compiler or "gfortran")
167    if found:
168        folder = Path(found).resolve().parent
169        name, ext = Path(found).stem, Path(found).suffix  # (".exe" on Windows)
170        prefix = name[:-len("gfortran")] if name.endswith("gfortran") else ""
171        for candidate in (f"{prefix}gcov{ext}", f"gcov{ext}"):
172            if (folder / candidate).is_file():
173                return str(folder / candidate)
174        for candidate in sorted(folder.glob(f"*-gcov{ext}")):
175            return str(candidate)
176    return shutil.which("gcov")
177
178
179def gcov_command(gcov: str, data_files: list[Path]) -> list[str]:
180    return [gcov, "--stdout", "--json-format", "--branch-probabilities", *map(str, data_files)]
181
182
183class Collector:
184    """gcov reading every .gcda file under the project's build folder,
185    ``jobs`` runs at a time (default: one per CPU). `run` blocks (call it on
186    a pool thread); `stop`, from another thread, ends it early."""
187
188    def __init__(self, gcov: str, build_dir: Path, jobs: int | None = None):
189        self.gcov, self.build_dir = gcov, build_dir
190        self.jobs = jobs or os.cpu_count() or 1
191        self._stopped = False
192        self._running: set[subprocess.Popen] = set()
193        self._lock = threading.Lock()
194
195    def run(self) -> tuple[dict[Path, FileCoverage], str]:
196        """The coverage, and an error message ("" if none)."""
197        data = sorted(self.build_dir.rglob("*.gcda")) if self.build_dir.is_dir() else []
198        chunks = [data[i:i + FILES_PER_GCOV] for i in range(0, len(data), FILES_PER_GCOV)]
199        with ThreadPoolExecutor(self.jobs) as pool:
200            results = list(pool.map(self._gcov, chunks))
201        if self._stopped:
202            return {}, "stopped"
203        files = read_gcov(out for out, _err in results)
204        errors = [err for _out, err in results if err]
205        return files, ("" if files or not errors else errors[-1])
206
207    def _gcov(self, chunk: list[Path]) -> tuple[str, str]:
208        """One gcov run's output, and its error ("" if it worked)."""
209        if self._stopped:
210            return "", ""
211        try:
212            process = subprocess.Popen(
213                gcov_command(self.gcov, chunk), cwd=self.build_dir, stdout=subprocess.PIPE,
214                stderr=subprocess.PIPE, text=True, errors="replace",
215                **NO_WINDOW)
216        except OSError as error:
217            return "", str(error)
218        with self._lock:
219            self._running.add(process)
220        out, err = process.communicate()
221        with self._lock:
222            self._running.discard(process)
223        message = err.strip().splitlines()[-1] if err.strip() else f"gcov exited with {process.returncode}"
224        return out, ("" if process.returncode == 0 else message)
225
226    def stop(self):
227        self._stopped = True
228        with self._lock:
229            for process in self._running:
230                process.kill()
231
232
233def clear_counts(build_dir: Path) -> int:
234    """Remove the counts of earlier runs (.gcda files), so the next run's
235    coverage is its own. Returns how many there were."""
236    removed = 0
237    if build_dir.is_dir():
238        for path in build_dir.rglob("*.gcda"):
239            try:
240                path.unlink()
241                removed += 1
242            except OSError:
243                pass
244    return removed
FLAG = '--coverage'
FILES_PER_GCOV = 10
@dataclass
class FunctionCoverage:
34@dataclass
35class FunctionCoverage:
36    name: str
37    line: int
38    end_line: int
39    hits: int
FunctionCoverage(name: str, line: int, end_line: int, hits: int)
name: str
line: int
end_line: int
hits: int
@dataclass
class FileCoverage:
42@dataclass
43class FileCoverage:
44    path: Path
45    lines: dict[int, int] = field(default_factory=dict)  # line -> times run (lines with code only)
46    functions: list[FunctionCoverage] = field(default_factory=list)
47    branches_found: int = 0
48    branches_hit: int = 0
49    line_branches: dict[int, tuple[int, int]] = field(default_factory=dict)  # line -> (taken, total)
50
51    @property
52    def lines_found(self) -> int:
53        return len(self.lines)
54
55    @property
56    def lines_hit(self) -> int:
57        return sum(1 for n in self.lines.values() if n > 0)
58
59    @property
60    def functions_found(self) -> int:
61        return len(self.functions)
62
63    @property
64    def functions_hit(self) -> int:
65        return sum(1 for f in self.functions if f.hits > 0)
66
67    @property
68    def percent(self) -> float:
69        return 100.0 * self.lines_hit / self.lines_found if self.lines_found else 0.0
70
71    def partial(self, line: int) -> bool:
72        """The line ran, but not every one of its branches was taken."""
73        taken, total = self.line_branches.get(line, (0, 0))
74        return self.lines.get(line, 0) > 0 and taken < total
75
76    def missed(self) -> list[tuple[int, int]]:
77        """The lines not run, as (first, last) ranges: lines in between
78        without code don't break a range."""
79        ranges: list[tuple[int, int]] = []
80        for line in sorted(self.lines):
81            if self.lines[line] > 0:
82                start = None
83                continue
84            if ranges and start is not None:
85                ranges[-1] = (ranges[-1][0], line)
86            else:
87                ranges.append((line, line))
88            start = line
89        return ranges
FileCoverage( path: pathlib.Path, lines: dict[int, int] = <factory>, functions: list[FunctionCoverage] = <factory>, branches_found: int = 0, branches_hit: int = 0, line_branches: dict[int, tuple[int, int]] = <factory>)
path: pathlib.Path
lines: dict[int, int]
functions: list[FunctionCoverage]
branches_found: int = 0
branches_hit: int = 0
line_branches: dict[int, tuple[int, int]]
lines_found: int
51    @property
52    def lines_found(self) -> int:
53        return len(self.lines)
lines_hit: int
55    @property
56    def lines_hit(self) -> int:
57        return sum(1 for n in self.lines.values() if n > 0)
functions_found: int
59    @property
60    def functions_found(self) -> int:
61        return len(self.functions)
functions_hit: int
63    @property
64    def functions_hit(self) -> int:
65        return sum(1 for f in self.functions if f.hits > 0)
percent: float
67    @property
68    def percent(self) -> float:
69        return 100.0 * self.lines_hit / self.lines_found if self.lines_found else 0.0
def partial(self, line: int) -> bool:
71    def partial(self, line: int) -> bool:
72        """The line ran, but not every one of its branches was taken."""
73        taken, total = self.line_branches.get(line, (0, 0))
74        return self.lines.get(line, 0) > 0 and taken < total

The line ran, but not every one of its branches was taken.

def missed(self) -> list[tuple[int, int]]:
76    def missed(self) -> list[tuple[int, int]]:
77        """The lines not run, as (first, last) ranges: lines in between
78        without code don't break a range."""
79        ranges: list[tuple[int, int]] = []
80        for line in sorted(self.lines):
81            if self.lines[line] > 0:
82                start = None
83                continue
84            if ranges and start is not None:
85                ranges[-1] = (ranges[-1][0], line)
86            else:
87                ranges.append((line, line))
88            start = line
89        return ranges

The lines not run, as (first, last) ranges: lines in between without code don't break a range.

PROCEDURE_RE = re.compile('^(?!\\s*end\\b)\\s*(?:[\\w\\s(),=*:]*?\\s)?(program|subroutine|function)\\s+\\w+', re.IGNORECASE)
def procedure_kind(lines: list[str], line: int) -> str | None:
 96def procedure_kind(lines: list[str], line: int) -> str | None:
 97    """What a function of the coverage starts at (1-based ``line``):
 98    "program", "subroutine" or "function" (gcov only knows gfortran's names
 99    for them), read from its first line; None if it doesn't say."""
100    if not 1 <= line <= len(lines):
101        return None
102    text = lines[line - 1].split("!", 1)[0]
103    found = PROCEDURE_RE.match(text)
104    return found.group(1).lower() if found else None

What a function of the coverage starts at (1-based line): "program", "subroutine" or "function" (gcov only knows gfortran's names for them), read from its first line; None if it doesn't say.

def ranges_text(ranges: list[tuple[int, int]], limit: int = 12) -> str:
107def ranges_text(ranges: list[tuple[int, int]], limit: int = 12) -> str:
108    """ "8, 10–15, 22" (the first ``limit`` ranges, then "…")."""
109    parts = [str(a) if a == b else f"{a}–{b}" for a, b in ranges[:limit]]
110    return ", ".join(parts) + (", …" if len(ranges) > limit else "")

"8, 10–15, 22" (the first limit ranges, then "…").

def read_gcov( outputs: Iterable[str]) -> dict[pathlib.Path, FileCoverage]:
113def read_gcov(outputs: Iterable[str]) -> dict[Path, FileCoverage]:
114    """gcov's JSON (``--stdout --json-format``: a document per line, one per
115    .gcda file), by source file (absolute paths). A file in several (built
116    for the library and for the tests, say) has its counts added."""
117    lines: dict[Path, dict[int, int]] = {}
118    functions: dict[Path, dict[tuple[str, int], list[int]]] = {}  # (name, start) -> [end, hits]
119    branches: dict[Path, dict[tuple, int]] = {}  # (line, function, branch) -> times taken
120    for text in outputs:
121        for raw in text.splitlines():
122            if not raw.startswith("{"):
123                continue
124            try:
125                doc = json.loads(raw)
126            except ValueError:
127                continue
128            folder = Path(doc.get("current_working_directory") or ".")  # (where it was compiled)
129            for entry in doc.get("files", []):
130                path = (folder / entry["file"]).resolve()
131                counts = lines.setdefault(path, {})
132                for line in entry.get("lines", []):
133                    number = line["line_number"]
134                    counts[number] = counts.get(number, 0) + max(line["count"], 0)
135                    taken = branches.setdefault(path, {})
136                    for n, branch in enumerate(b for b in line.get("branches", []) if not b.get("throw")):
137                        key = (number, line.get("function_name", ""), n)
138                        taken[key] = taken.get(key, 0) + branch["count"]
139                found = functions.setdefault(path, {})
140                for fn in entry.get("functions", []):
141                    end_hits = found.setdefault((fn["name"], fn["start_line"]), [fn["end_line"], 0])
142                    end_hits[1] += fn["execution_count"]
143    files = {}
144    for path, counts in lines.items():
145        per_line: dict[int, tuple[int, int]] = {}
146        for (number, _fn, _n), count in branches.get(path, {}).items():
147            taken, total = per_line.get(number, (0, 0))
148            per_line[number] = (taken + (count > 0), total + 1)
149        fns = sorted((FunctionCoverage(name, start, end, hits)
150                      for (name, start), (end, hits) in functions.get(path, {}).items()), key=lambda f: f.line)
151        files[path] = FileCoverage(path, counts, fns, sum(t for _h, t in per_line.values()),
152                                   sum(h for h, _t in per_line.values()), per_line)
153    return files

gcov's JSON (--stdout --json-format: a document per line, one per .gcda file), by source file (absolute paths). A file in several (built for the library and for the tests, say) has its counts added.

def totals( files: list[FileCoverage]) -> dict[str, tuple[int, int]]:
156def totals(files: list[FileCoverage]) -> dict[str, tuple[int, int]]:
157    """(hit, found) for lines, functions and branches, over ``files``."""
158    return {"lines": (sum(f.lines_hit for f in files), sum(f.lines_found for f in files)),
159            "functions": (sum(f.functions_hit for f in files), sum(f.functions_found for f in files)),
160            "branches": (sum(f.branches_hit for f in files), sum(f.branches_found for f in files))}

(hit, found) for lines, functions and branches, over files.

def find_gcov(compiler: str) -> str | None:
163def find_gcov(compiler: str) -> str | None:
164    """The gcov that goes with the compiler (the same GCC: a gcov of another
165    version can't read the counts): next to it, plain or with the same
166    prefix (``arm64-apple-darwin20.0.0-gcov``), else the one on the PATH."""
167    found = shutil.which(compiler or "gfortran")
168    if found:
169        folder = Path(found).resolve().parent
170        name, ext = Path(found).stem, Path(found).suffix  # (".exe" on Windows)
171        prefix = name[:-len("gfortran")] if name.endswith("gfortran") else ""
172        for candidate in (f"{prefix}gcov{ext}", f"gcov{ext}"):
173            if (folder / candidate).is_file():
174                return str(folder / candidate)
175        for candidate in sorted(folder.glob(f"*-gcov{ext}")):
176            return str(candidate)
177    return shutil.which("gcov")

The gcov that goes with the compiler (the same GCC: a gcov of another version can't read the counts): next to it, plain or with the same prefix (arm64-apple-darwin20.0.0-gcov), else the one on the PATH.

def gcov_command(gcov: str, data_files: list[pathlib.Path]) -> list[str]:
180def gcov_command(gcov: str, data_files: list[Path]) -> list[str]:
181    return [gcov, "--stdout", "--json-format", "--branch-probabilities", *map(str, data_files)]
class Collector:
184class Collector:
185    """gcov reading every .gcda file under the project's build folder,
186    ``jobs`` runs at a time (default: one per CPU). `run` blocks (call it on
187    a pool thread); `stop`, from another thread, ends it early."""
188
189    def __init__(self, gcov: str, build_dir: Path, jobs: int | None = None):
190        self.gcov, self.build_dir = gcov, build_dir
191        self.jobs = jobs or os.cpu_count() or 1
192        self._stopped = False
193        self._running: set[subprocess.Popen] = set()
194        self._lock = threading.Lock()
195
196    def run(self) -> tuple[dict[Path, FileCoverage], str]:
197        """The coverage, and an error message ("" if none)."""
198        data = sorted(self.build_dir.rglob("*.gcda")) if self.build_dir.is_dir() else []
199        chunks = [data[i:i + FILES_PER_GCOV] for i in range(0, len(data), FILES_PER_GCOV)]
200        with ThreadPoolExecutor(self.jobs) as pool:
201            results = list(pool.map(self._gcov, chunks))
202        if self._stopped:
203            return {}, "stopped"
204        files = read_gcov(out for out, _err in results)
205        errors = [err for _out, err in results if err]
206        return files, ("" if files or not errors else errors[-1])
207
208    def _gcov(self, chunk: list[Path]) -> tuple[str, str]:
209        """One gcov run's output, and its error ("" if it worked)."""
210        if self._stopped:
211            return "", ""
212        try:
213            process = subprocess.Popen(
214                gcov_command(self.gcov, chunk), cwd=self.build_dir, stdout=subprocess.PIPE,
215                stderr=subprocess.PIPE, text=True, errors="replace",
216                **NO_WINDOW)
217        except OSError as error:
218            return "", str(error)
219        with self._lock:
220            self._running.add(process)
221        out, err = process.communicate()
222        with self._lock:
223            self._running.discard(process)
224        message = err.strip().splitlines()[-1] if err.strip() else f"gcov exited with {process.returncode}"
225        return out, ("" if process.returncode == 0 else message)
226
227    def stop(self):
228        self._stopped = True
229        with self._lock:
230            for process in self._running:
231                process.kill()

gcov reading every .gcda file under the project's build folder, jobs runs at a time (default: one per CPU). run blocks (call it on a pool thread); stop, from another thread, ends it early.

Collector(gcov: str, build_dir: pathlib.Path, jobs: int | None = None)
189    def __init__(self, gcov: str, build_dir: Path, jobs: int | None = None):
190        self.gcov, self.build_dir = gcov, build_dir
191        self.jobs = jobs or os.cpu_count() or 1
192        self._stopped = False
193        self._running: set[subprocess.Popen] = set()
194        self._lock = threading.Lock()
jobs
def run(self) -> tuple[dict[pathlib.Path, FileCoverage], str]:
196    def run(self) -> tuple[dict[Path, FileCoverage], str]:
197        """The coverage, and an error message ("" if none)."""
198        data = sorted(self.build_dir.rglob("*.gcda")) if self.build_dir.is_dir() else []
199        chunks = [data[i:i + FILES_PER_GCOV] for i in range(0, len(data), FILES_PER_GCOV)]
200        with ThreadPoolExecutor(self.jobs) as pool:
201            results = list(pool.map(self._gcov, chunks))
202        if self._stopped:
203            return {}, "stopped"
204        files = read_gcov(out for out, _err in results)
205        errors = [err for _out, err in results if err]
206        return files, ("" if files or not errors else errors[-1])

The coverage, and an error message ("" if none).

def stop(self):
227    def stop(self):
228        self._stopped = True
229        with self._lock:
230            for process in self._running:
231                process.kill()
def clear_counts(build_dir: pathlib.Path) -> int:
234def clear_counts(build_dir: Path) -> int:
235    """Remove the counts of earlier runs (.gcda files), so the next run's
236    coverage is its own. Returns how many there were."""
237    removed = 0
238    if build_dir.is_dir():
239        for path in build_dir.rglob("*.gcda"):
240            try:
241                path.unlink()
242                removed += 1
243            except OSError:
244                pass
245    return removed

Remove the counts of earlier runs (.gcda files), so the next run's coverage is its own. Returns how many there were.