covisible 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. covisible/__init__.py +3 -0
  2. covisible/analysis/__init__.py +17 -0
  3. covisible/analysis/blame.py +232 -0
  4. covisible/analysis/diff.py +229 -0
  5. covisible/analysis/grouping.py +218 -0
  6. covisible/analysis/history.py +130 -0
  7. covisible/analysis/pr_coverage.py +239 -0
  8. covisible/analysis/treemap.py +190 -0
  9. covisible/assets/css/style.css +2890 -0
  10. covisible/assets/favicon.svg +12 -0
  11. covisible/assets/js/app.js +203 -0
  12. covisible/assets/logo.png +0 -0
  13. covisible/cli.py +622 -0
  14. covisible/core/__init__.py +17 -0
  15. covisible/core/ignore.py +240 -0
  16. covisible/core/models.py +226 -0
  17. covisible/parsers/__init__.py +6 -0
  18. covisible/parsers/gcov_json.py +124 -0
  19. covisible/parsers/lcov.py +149 -0
  20. covisible/report/__init__.py +5 -0
  21. covisible/report/generator.py +1002 -0
  22. covisible/report/markdown.py +72 -0
  23. covisible/report/templates/base.html +48 -0
  24. covisible/report/templates/components/authors.html +24 -0
  25. covisible/report/templates/components/sunburst.html +452 -0
  26. covisible/report/templates/components/treemap.html +97 -0
  27. covisible/report/templates/components/trend.html +170 -0
  28. covisible/report/templates/directory.html +137 -0
  29. covisible/report/templates/file.html +370 -0
  30. covisible/report/templates/index.html +846 -0
  31. covisible/utils/__init__.py +5 -0
  32. covisible/utils/demangle.py +112 -0
  33. covisible-0.1.0.dist-info/METADATA +154 -0
  34. covisible-0.1.0.dist-info/RECORD +37 -0
  35. covisible-0.1.0.dist-info/WHEEL +4 -0
  36. covisible-0.1.0.dist-info/entry_points.txt +2 -0
  37. covisible-0.1.0.dist-info/licenses/LICENSE +21 -0
covisible/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Covisible — PR-first code coverage report generator."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,17 @@
1
+ """Coverage analysis tools."""
2
+
3
+ from covisible.analysis.blame import GitBlameAnalyzer, get_uncovered_blame_summary
4
+ from covisible.analysis.diff import DiffAnalyzer, DiffHunk, FileDiff
5
+ from covisible.analysis.pr_coverage import PRCoverageAnalyzer
6
+ from covisible.analysis.treemap import TreemapBuilder, build_treemap_data
7
+
8
+ __all__ = [
9
+ "DiffAnalyzer",
10
+ "DiffHunk",
11
+ "FileDiff",
12
+ "GitBlameAnalyzer",
13
+ "PRCoverageAnalyzer",
14
+ "TreemapBuilder",
15
+ "build_treemap_data",
16
+ "get_uncovered_blame_summary",
17
+ ]
@@ -0,0 +1,232 @@
1
+ """Git blame integration for uncovered code analysis."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+
11
+ @dataclass
12
+ class BlameInfo:
13
+ """Blame information for a single line."""
14
+
15
+ line_number: int
16
+ commit_hash: str
17
+ author: str
18
+ author_email: str
19
+ timestamp: str
20
+ line_content: str
21
+
22
+
23
+ @dataclass
24
+ class AuthorStats:
25
+ """Statistics for a single author."""
26
+
27
+ name: str
28
+ email: str
29
+ total_uncovered_lines: int = 0
30
+ files: set[str] = field(default_factory=set)
31
+ lines: list[tuple[str, int]] = field(default_factory=list)
32
+
33
+ def to_dict(self) -> dict[str, Any]:
34
+ return {
35
+ "name": self.name,
36
+ "email": self.email,
37
+ "total_uncovered_lines": self.total_uncovered_lines,
38
+ "files_count": len(self.files),
39
+ "files": sorted(self.files),
40
+ "lines": self.lines[:20],
41
+ }
42
+
43
+
44
+ class GitBlameAnalyzer:
45
+ """Analyzes git blame for uncovered lines."""
46
+
47
+ def __init__(self, repo_path: Path | str | None = None):
48
+ self.repo_path = Path(repo_path) if repo_path else Path.cwd()
49
+ self._cache: dict[Path, dict[int, BlameInfo]] = {}
50
+
51
+ def get_blame_for_file(self, file_path: Path | str) -> dict[int, BlameInfo]:
52
+ """Get blame information for all lines in a file.
53
+
54
+ Args:
55
+ file_path: Path to the file
56
+
57
+ Returns:
58
+ Dictionary mapping line numbers to BlameInfo
59
+ """
60
+ file_path = Path(file_path)
61
+
62
+ if file_path in self._cache:
63
+ return self._cache[file_path]
64
+
65
+ result: dict[int, BlameInfo] = {}
66
+
67
+ try:
68
+ cmd = [
69
+ "git", "blame",
70
+ "--line-porcelain",
71
+ str(file_path),
72
+ ]
73
+
74
+ output = subprocess.run(
75
+ cmd,
76
+ capture_output=True,
77
+ text=True,
78
+ cwd=self.repo_path,
79
+ check=True,
80
+ )
81
+
82
+ result = self._parse_porcelain_blame(output.stdout)
83
+
84
+ except subprocess.CalledProcessError:
85
+ pass
86
+
87
+ self._cache[file_path] = result
88
+ return result
89
+
90
+ def _parse_porcelain_blame(self, output: str) -> dict[int, BlameInfo]:
91
+ """Parse git blame --line-porcelain output."""
92
+ result: dict[int, BlameInfo] = {}
93
+ lines = output.split("\n")
94
+
95
+ i = 0
96
+ while i < len(lines):
97
+ line = lines[i]
98
+ if not line:
99
+ i += 1
100
+ continue
101
+
102
+ parts = line.split(" ")
103
+ if len(parts) < 3:
104
+ i += 1
105
+ continue
106
+
107
+ commit_hash = parts[0]
108
+ if len(commit_hash) != 40:
109
+ i += 1
110
+ continue
111
+
112
+ line_number = int(parts[2])
113
+
114
+ author = ""
115
+ author_email = ""
116
+ timestamp = ""
117
+ content = ""
118
+
119
+ i += 1
120
+ while i < len(lines) and not lines[i].startswith("\t"):
121
+ if lines[i].startswith("author "):
122
+ author = lines[i][7:]
123
+ elif lines[i].startswith("author-mail "):
124
+ author_email = lines[i][12:].strip("<>")
125
+ elif lines[i].startswith("author-time "):
126
+ timestamp = lines[i][12:]
127
+ i += 1
128
+
129
+ if i < len(lines) and lines[i].startswith("\t"):
130
+ content = lines[i][1:]
131
+ i += 1
132
+
133
+ result[line_number] = BlameInfo(
134
+ line_number=line_number,
135
+ commit_hash=commit_hash,
136
+ author=author,
137
+ author_email=author_email,
138
+ timestamp=timestamp,
139
+ line_content=content,
140
+ )
141
+
142
+ return result
143
+
144
+ def get_blame_for_lines(
145
+ self, file_path: Path | str, line_numbers: list[int]
146
+ ) -> dict[int, BlameInfo]:
147
+ """Get blame information for specific lines.
148
+
149
+ Args:
150
+ file_path: Path to the file
151
+ line_numbers: List of line numbers to get blame for
152
+
153
+ Returns:
154
+ Dictionary mapping line numbers to BlameInfo
155
+ """
156
+ all_blame = self.get_blame_for_file(file_path)
157
+ return {ln: all_blame[ln] for ln in line_numbers if ln in all_blame}
158
+
159
+ def analyze_uncovered_lines(
160
+ self,
161
+ uncovered_by_file: dict[Path, list[int]],
162
+ ) -> dict[str, AuthorStats]:
163
+ """Analyze who wrote uncovered lines.
164
+
165
+ Args:
166
+ uncovered_by_file: Dictionary mapping file paths to uncovered line numbers
167
+
168
+ Returns:
169
+ Dictionary mapping author emails to their stats
170
+ """
171
+ author_stats: dict[str, AuthorStats] = {}
172
+
173
+ for file_path, line_numbers in uncovered_by_file.items():
174
+ blame_info = self.get_blame_for_lines(file_path, line_numbers)
175
+
176
+ for line_num, info in blame_info.items():
177
+ email = info.author_email or info.author
178
+
179
+ if email not in author_stats:
180
+ author_stats[email] = AuthorStats(
181
+ name=info.author,
182
+ email=info.author_email,
183
+ )
184
+
185
+ stats = author_stats[email]
186
+ stats.total_uncovered_lines += 1
187
+ stats.files.add(str(file_path))
188
+ stats.lines.append((str(file_path), line_num))
189
+
190
+ return author_stats
191
+
192
+ def get_top_authors_by_uncovered(
193
+ self,
194
+ uncovered_by_file: dict[Path, list[int]],
195
+ limit: int = 10,
196
+ ) -> list[AuthorStats]:
197
+ """Get top authors by number of uncovered lines.
198
+
199
+ Args:
200
+ uncovered_by_file: Dictionary mapping file paths to uncovered line numbers
201
+ limit: Maximum number of authors to return
202
+
203
+ Returns:
204
+ List of AuthorStats sorted by uncovered lines descending
205
+ """
206
+ stats = self.analyze_uncovered_lines(uncovered_by_file)
207
+ sorted_stats = sorted(
208
+ stats.values(),
209
+ key=lambda x: x.total_uncovered_lines,
210
+ reverse=True,
211
+ )
212
+ return sorted_stats[:limit]
213
+
214
+
215
+ def get_uncovered_blame_summary(
216
+ uncovered_by_file: dict[Path, list[int]],
217
+ repo_path: Path | str | None = None,
218
+ limit: int = 10,
219
+ ) -> list[dict[str, Any]]:
220
+ """Get summary of who wrote uncovered code.
221
+
222
+ Args:
223
+ uncovered_by_file: Dictionary mapping file paths to uncovered line numbers
224
+ repo_path: Path to git repository
225
+ limit: Maximum number of authors to return
226
+
227
+ Returns:
228
+ List of author stats as dictionaries
229
+ """
230
+ analyzer = GitBlameAnalyzer(repo_path)
231
+ top_authors = analyzer.get_top_authors_by_uncovered(uncovered_by_file, limit)
232
+ return [author.to_dict() for author in top_authors]
@@ -0,0 +1,229 @@
1
+ """Git diff parsing and analysis."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import subprocess
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+
10
+
11
+ @dataclass
12
+ class DiffHunk:
13
+ """A single hunk in a diff."""
14
+
15
+ old_start: int
16
+ old_count: int
17
+ new_start: int
18
+ new_count: int
19
+ added_lines: set[int] = field(default_factory=set)
20
+ removed_lines: set[int] = field(default_factory=set)
21
+ context_lines: set[int] = field(default_factory=set)
22
+
23
+
24
+ @dataclass
25
+ class FileDiff:
26
+ """Diff information for a single file."""
27
+
28
+ old_path: Path | None
29
+ new_path: Path | None
30
+ hunks: list[DiffHunk] = field(default_factory=list)
31
+ is_new_file: bool = False
32
+ is_deleted_file: bool = False
33
+ is_renamed: bool = False
34
+
35
+ @property
36
+ def path(self) -> Path:
37
+ """Get the current path of the file."""
38
+ return self.new_path or self.old_path or Path("")
39
+
40
+ @property
41
+ def added_lines(self) -> set[int]:
42
+ """Get all added line numbers."""
43
+ result: set[int] = set()
44
+ for hunk in self.hunks:
45
+ result.update(hunk.added_lines)
46
+ return result
47
+
48
+ @property
49
+ def removed_lines(self) -> set[int]:
50
+ """Get all removed line numbers (in old file)."""
51
+ result: set[int] = set()
52
+ for hunk in self.hunks:
53
+ result.update(hunk.removed_lines)
54
+ return result
55
+
56
+ @property
57
+ def modified_lines(self) -> set[int]:
58
+ """Get all modified line numbers (added lines in new file)."""
59
+ return self.added_lines
60
+
61
+
62
+ @dataclass
63
+ class DiffAnalyzer:
64
+ """Analyzes git diffs to determine changed lines."""
65
+
66
+ files: dict[Path, FileDiff] = field(default_factory=dict)
67
+
68
+ @classmethod
69
+ def from_git_diff(
70
+ cls,
71
+ diff_range: str,
72
+ repo_path: Path | str | None = None,
73
+ ) -> DiffAnalyzer:
74
+ """Create DiffAnalyzer from git diff command.
75
+
76
+ Args:
77
+ diff_range: Git diff range (e.g., "main..HEAD", "HEAD~1..HEAD")
78
+ repo_path: Path to git repository (defaults to current directory)
79
+
80
+ Returns:
81
+ DiffAnalyzer with parsed diff information
82
+ """
83
+ cmd = ["git", "diff", "--no-color", "-U0", diff_range]
84
+ cwd = Path(repo_path) if repo_path else None
85
+
86
+ result = subprocess.run(
87
+ cmd,
88
+ capture_output=True,
89
+ text=True,
90
+ cwd=cwd,
91
+ check=True,
92
+ )
93
+
94
+ return cls.from_unified_diff(result.stdout)
95
+
96
+ @classmethod
97
+ def from_diff_file(cls, path: Path | str) -> DiffAnalyzer:
98
+ """Create DiffAnalyzer from a diff file.
99
+
100
+ Args:
101
+ path: Path to unified diff file
102
+
103
+ Returns:
104
+ DiffAnalyzer with parsed diff information
105
+ """
106
+ with open(path) as f:
107
+ return cls.from_unified_diff(f.read())
108
+
109
+ @classmethod
110
+ def from_unified_diff(cls, diff_content: str) -> DiffAnalyzer:
111
+ """Parse unified diff format.
112
+
113
+ Args:
114
+ diff_content: Unified diff content
115
+
116
+ Returns:
117
+ DiffAnalyzer with parsed diff information
118
+ """
119
+ analyzer = cls()
120
+ current_file: FileDiff | None = None
121
+ current_hunk: DiffHunk | None = None
122
+ new_line_num = 0
123
+
124
+ file_header_re = re.compile(r"^diff --git a/(.+) b/(.+)$")
125
+ old_file_re = re.compile(r"^--- (?:a/)?(.+)$")
126
+ new_file_re = re.compile(r"^\+\+\+ (?:b/)?(.+)$")
127
+ hunk_re = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
128
+ new_file_mode_re = re.compile(r"^new file mode")
129
+ deleted_file_mode_re = re.compile(r"^deleted file mode")
130
+ rename_re = re.compile(r"^rename (from|to) (.+)$")
131
+
132
+ for line in diff_content.splitlines():
133
+ if match := file_header_re.match(line):
134
+ if current_file and current_file.new_path:
135
+ analyzer.files[current_file.new_path] = current_file
136
+ current_file = FileDiff(
137
+ old_path=Path(match.group(1)),
138
+ new_path=Path(match.group(2)),
139
+ )
140
+ current_hunk = None
141
+
142
+ elif new_file_mode_re.match(line):
143
+ if current_file:
144
+ current_file.is_new_file = True
145
+
146
+ elif deleted_file_mode_re.match(line):
147
+ if current_file:
148
+ current_file.is_deleted_file = True
149
+
150
+ elif match := rename_re.match(line):
151
+ if current_file:
152
+ current_file.is_renamed = True
153
+
154
+ elif match := old_file_re.match(line):
155
+ if current_file and match.group(1) != "/dev/null":
156
+ current_file.old_path = Path(match.group(1))
157
+
158
+ elif match := new_file_re.match(line):
159
+ if current_file and match.group(1) != "/dev/null":
160
+ current_file.new_path = Path(match.group(1))
161
+
162
+ elif match := hunk_re.match(line):
163
+ if current_file:
164
+ current_hunk = DiffHunk(
165
+ old_start=int(match.group(1)),
166
+ old_count=int(match.group(2) or 1),
167
+ new_start=int(match.group(3)),
168
+ new_count=int(match.group(4) or 1),
169
+ )
170
+ current_file.hunks.append(current_hunk)
171
+ new_line_num = current_hunk.new_start
172
+
173
+ elif current_hunk is not None:
174
+ if line.startswith("+") and not line.startswith("+++"):
175
+ current_hunk.added_lines.add(new_line_num)
176
+ new_line_num += 1
177
+ elif line.startswith("-") and not line.startswith("---"):
178
+ pass
179
+ elif line.startswith(" "):
180
+ current_hunk.context_lines.add(new_line_num)
181
+ new_line_num += 1
182
+
183
+ if current_file and current_file.new_path:
184
+ analyzer.files[current_file.new_path] = current_file
185
+
186
+ return analyzer
187
+
188
+ def get_file_diff(self, path: Path | str) -> FileDiff | None:
189
+ """Get diff for a specific file."""
190
+ if isinstance(path, str):
191
+ path = Path(path)
192
+
193
+ if path in self.files:
194
+ return self.files[path]
195
+
196
+ for file_path, diff in self.files.items():
197
+ if file_path.name == path.name or str(file_path).endswith(str(path)):
198
+ return diff
199
+
200
+ return None
201
+
202
+ def get_added_lines(self, path: Path | str) -> set[int]:
203
+ """Get added line numbers for a file."""
204
+ diff = self.get_file_diff(path)
205
+ return diff.added_lines if diff else set()
206
+
207
+ def get_modified_files(self) -> list[Path]:
208
+ """Get list of modified files."""
209
+ return [
210
+ diff.path
211
+ for diff in self.files.values()
212
+ if not diff.is_deleted_file
213
+ ]
214
+
215
+ def get_new_files(self) -> list[Path]:
216
+ """Get list of new files."""
217
+ return [
218
+ diff.path
219
+ for diff in self.files.values()
220
+ if diff.is_new_file
221
+ ]
222
+
223
+ def get_deleted_files(self) -> list[Path]:
224
+ """Get list of deleted files."""
225
+ return [
226
+ diff.old_path
227
+ for diff in self.files.values()
228
+ if diff.is_deleted_file and diff.old_path
229
+ ]
@@ -0,0 +1,218 @@
1
+ """File grouping by module/directory for coverage analysis."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from covisible.core.models import CoverageData, FileCoverage
10
+
11
+
12
+ @dataclass
13
+ class ModuleGroup:
14
+ """A group of files representing a module or directory."""
15
+
16
+ name: str
17
+ path: str
18
+ files: list[FileCoverage] = field(default_factory=list)
19
+
20
+ @property
21
+ def total_lines(self) -> int:
22
+ return sum(f.total_lines for f in self.files)
23
+
24
+ @property
25
+ def covered_lines(self) -> int:
26
+ return sum(f.covered_lines for f in self.files)
27
+
28
+ @property
29
+ def uncovered_lines(self) -> int:
30
+ return self.total_lines - self.covered_lines
31
+
32
+ @property
33
+ def coverage_percent(self) -> float:
34
+ if self.total_lines == 0:
35
+ return 100.0
36
+ return (self.covered_lines / self.total_lines) * 100
37
+
38
+ @property
39
+ def total_functions(self) -> int:
40
+ return sum(f.total_functions for f in self.files)
41
+
42
+ @property
43
+ def covered_functions(self) -> int:
44
+ return sum(f.covered_functions for f in self.files)
45
+
46
+ @property
47
+ def file_count(self) -> int:
48
+ return len(self.files)
49
+
50
+ def to_dict(self) -> dict[str, Any]:
51
+ return {
52
+ "name": self.name,
53
+ "path": self.path,
54
+ "file_count": self.file_count,
55
+ "total_lines": self.total_lines,
56
+ "covered_lines": self.covered_lines,
57
+ "uncovered_lines": self.uncovered_lines,
58
+ "coverage_percent": round(self.coverage_percent, 2),
59
+ "total_functions": self.total_functions,
60
+ "covered_functions": self.covered_functions,
61
+ }
62
+
63
+
64
+ class ModuleGrouper:
65
+ """Groups files by module/directory."""
66
+
67
+ def __init__(
68
+ self,
69
+ coverage: CoverageData,
70
+ base_path: Path | str | None = None,
71
+ depth: int = 1,
72
+ ):
73
+ """Initialize grouper.
74
+
75
+ Args:
76
+ coverage: Coverage data
77
+ base_path: Base path to make paths relative to
78
+ depth: Directory depth for grouping (1 = top-level dirs)
79
+ """
80
+ self.coverage = coverage
81
+ self.base_path = Path(base_path) if base_path else None
82
+ self.depth = depth
83
+
84
+ def _find_common_prefix(self) -> Path | None:
85
+ """Find common path prefix for all files."""
86
+ paths = list(self.coverage.files.keys())
87
+ if not paths:
88
+ return None
89
+
90
+ # Get all parent directories
91
+ first_parts = paths[0].parts
92
+ common_parts: list[str] = []
93
+
94
+ for i, part in enumerate(first_parts[:-1]): # Exclude filename
95
+ if all(len(p.parts) > i and p.parts[i] == part for p in paths):
96
+ common_parts.append(part)
97
+ else:
98
+ break
99
+
100
+ if common_parts:
101
+ return Path(*common_parts)
102
+ return None
103
+
104
+ def group_by_directory(self) -> list[ModuleGroup]:
105
+ """Group files by directory at specified depth.
106
+
107
+ Returns:
108
+ List of ModuleGroup sorted by uncovered lines descending
109
+ """
110
+ groups: dict[str, ModuleGroup] = {}
111
+
112
+ # Auto-detect base path if not provided
113
+ base_path = self.base_path or self._find_common_prefix()
114
+
115
+ for file_path, file_cov in self.coverage.files.items():
116
+ if base_path:
117
+ try:
118
+ rel_path = file_path.relative_to(base_path)
119
+ except ValueError:
120
+ rel_path = file_path
121
+ else:
122
+ rel_path = file_path
123
+
124
+ parts = rel_path.parts
125
+ if len(parts) > self.depth:
126
+ group_path = str(Path(*parts[: self.depth]))
127
+ group_name = parts[self.depth - 1]
128
+ else:
129
+ # File is in root or shallow directory
130
+ if len(parts) > 0:
131
+ group_path = parts[0] if len(parts) > 1 else "."
132
+ group_name = parts[0] if len(parts) > 1 else rel_path.name
133
+ else:
134
+ group_path = "."
135
+ group_name = "root"
136
+
137
+ if group_path not in groups:
138
+ groups[group_path] = ModuleGroup(name=group_name, path=group_path)
139
+
140
+ groups[group_path].files.append(file_cov)
141
+
142
+ return sorted(
143
+ groups.values(),
144
+ key=lambda g: g.uncovered_lines,
145
+ reverse=True,
146
+ )
147
+
148
+ def group_by_pattern(self, patterns: dict[str, str]) -> list[ModuleGroup]:
149
+ """Group files by custom patterns.
150
+
151
+ Args:
152
+ patterns: Dictionary mapping group names to glob patterns
153
+
154
+ Returns:
155
+ List of ModuleGroup
156
+ """
157
+ import fnmatch
158
+
159
+ groups: dict[str, ModuleGroup] = {}
160
+ unmatched = ModuleGroup(name="Other", path="other")
161
+
162
+ for name, pattern in patterns.items():
163
+ groups[name] = ModuleGroup(name=name, path=pattern)
164
+
165
+ for file_path, file_cov in self.coverage.files.items():
166
+ matched = False
167
+ for name, pattern in patterns.items():
168
+ if fnmatch.fnmatch(str(file_path), pattern):
169
+ groups[name].files.append(file_cov)
170
+ matched = True
171
+ break
172
+
173
+ if not matched:
174
+ unmatched.files.append(file_cov)
175
+
176
+ result = list(groups.values())
177
+ if unmatched.files:
178
+ result.append(unmatched)
179
+
180
+ return sorted(result, key=lambda g: g.uncovered_lines, reverse=True)
181
+
182
+
183
+ def group_coverage_by_directory(
184
+ coverage: CoverageData,
185
+ base_path: Path | str | None = None,
186
+ depth: int = 1,
187
+ ) -> list[dict[str, Any]]:
188
+ """Group coverage data by directory.
189
+
190
+ Args:
191
+ coverage: Coverage data
192
+ base_path: Base path to make paths relative to
193
+ depth: Directory depth for grouping
194
+
195
+ Returns:
196
+ List of module group dictionaries
197
+ """
198
+ grouper = ModuleGrouper(coverage, base_path, depth)
199
+ groups = grouper.group_by_directory()
200
+ return [g.to_dict() for g in groups]
201
+
202
+
203
+ def group_coverage_by_pattern(
204
+ coverage: CoverageData,
205
+ patterns: dict[str, str],
206
+ ) -> list[dict[str, Any]]:
207
+ """Group coverage data by custom patterns.
208
+
209
+ Args:
210
+ coverage: Coverage data
211
+ patterns: Dictionary mapping group names to glob patterns
212
+
213
+ Returns:
214
+ List of module group dictionaries
215
+ """
216
+ grouper = ModuleGrouper(coverage)
217
+ groups = grouper.group_by_pattern(patterns)
218
+ return [g.to_dict() for g in groups]