pintest-cli 0.2.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.
- pintest/__init__.py +14 -0
- pintest/build_mapping_iterative.py +339 -0
- pintest/cli.py +681 -0
- pintest/cloud_mapping_db.py +218 -0
- pintest/config.py +102 -0
- pintest/coverage_mapper.py +356 -0
- pintest/git_diff_parser.py +232 -0
- pintest/post_commit_hook.py +78 -0
- pintest/pre_commit_hook.py +1472 -0
- pintest/range_set.py +173 -0
- pintest/test_mapping_db_v2.py +381 -0
- pintest/update_mapping.py +130 -0
- pintest_cli-0.2.0.dist-info/METADATA +527 -0
- pintest_cli-0.2.0.dist-info/RECORD +21 -0
- pintest_cli-0.2.0.dist-info/WHEEL +5 -0
- pintest_cli-0.2.0.dist-info/entry_points.txt +2 -0
- pintest_cli-0.2.0.dist-info/top_level.txt +2 -0
- tests/__init__.py +1 -0
- tests/test_git_diff_parser.py +60 -0
- tests/test_new_feature.py +1 -0
- tests/test_range_set.py +261 -0
pintest/range_set.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Efficient range-based storage for line numbers."""
|
|
2
|
+
|
|
3
|
+
from typing import List, Tuple, Set
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class RangeSet:
|
|
7
|
+
"""Efficient range-based storage with consistency maintenance."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, ranges: List[Tuple[int, int]] = None):
|
|
10
|
+
"""Initialize with optional list of (start, end) ranges."""
|
|
11
|
+
self.ranges: List[Tuple[int, int]] = []
|
|
12
|
+
if ranges:
|
|
13
|
+
for start, end in ranges:
|
|
14
|
+
self.add_range(start, end)
|
|
15
|
+
|
|
16
|
+
def add_lines(self, lines: Set[int]) -> None:
|
|
17
|
+
"""Add individual line numbers, maintaining range consistency."""
|
|
18
|
+
for line in sorted(lines):
|
|
19
|
+
self.add_range(line, line)
|
|
20
|
+
|
|
21
|
+
def add_range(self, start: int, end: int) -> None:
|
|
22
|
+
"""Add a range, merging with overlapping/adjacent ranges."""
|
|
23
|
+
if start > end:
|
|
24
|
+
start, end = end, start
|
|
25
|
+
|
|
26
|
+
# Find ranges that overlap or are adjacent to new range
|
|
27
|
+
merged_start = start
|
|
28
|
+
merged_end = end
|
|
29
|
+
new_ranges = []
|
|
30
|
+
merged = False
|
|
31
|
+
|
|
32
|
+
for r_start, r_end in self.ranges:
|
|
33
|
+
# Check if ranges overlap or are adjacent (can be merged)
|
|
34
|
+
if r_end < start - 1:
|
|
35
|
+
# This range is completely before new range
|
|
36
|
+
new_ranges.append((r_start, r_end))
|
|
37
|
+
elif r_start > end + 1:
|
|
38
|
+
# This range is completely after new range
|
|
39
|
+
if not merged:
|
|
40
|
+
new_ranges.append((merged_start, merged_end))
|
|
41
|
+
merged = True
|
|
42
|
+
new_ranges.append((r_start, r_end))
|
|
43
|
+
else:
|
|
44
|
+
# Ranges overlap or are adjacent - merge them
|
|
45
|
+
merged_start = min(merged_start, r_start)
|
|
46
|
+
merged_end = max(merged_end, r_end)
|
|
47
|
+
|
|
48
|
+
if not merged:
|
|
49
|
+
new_ranges.append((merged_start, merged_end))
|
|
50
|
+
|
|
51
|
+
self.ranges = new_ranges
|
|
52
|
+
|
|
53
|
+
def remove_lines(self, lines: Set[int]) -> None:
|
|
54
|
+
"""Remove individual line numbers, splitting ranges if needed."""
|
|
55
|
+
for line in sorted(lines):
|
|
56
|
+
self.remove_range(line, line)
|
|
57
|
+
|
|
58
|
+
def remove_range(self, start: int, end: int) -> None:
|
|
59
|
+
"""Remove a range, splitting existing ranges if needed."""
|
|
60
|
+
if start > end:
|
|
61
|
+
start, end = end, start
|
|
62
|
+
|
|
63
|
+
new_ranges = []
|
|
64
|
+
|
|
65
|
+
for r_start, r_end in self.ranges:
|
|
66
|
+
if r_end < start or r_start > end:
|
|
67
|
+
# No overlap - keep range as is
|
|
68
|
+
new_ranges.append((r_start, r_end))
|
|
69
|
+
else:
|
|
70
|
+
# Overlap - split the range
|
|
71
|
+
if r_start < start:
|
|
72
|
+
# Keep the part before removed range
|
|
73
|
+
new_ranges.append((r_start, start - 1))
|
|
74
|
+
if r_end > end:
|
|
75
|
+
# Keep the part after removed range
|
|
76
|
+
new_ranges.append((end + 1, r_end))
|
|
77
|
+
|
|
78
|
+
self.ranges = new_ranges
|
|
79
|
+
|
|
80
|
+
def contains(self, line: int) -> bool:
|
|
81
|
+
"""Check if a line number is covered by any range."""
|
|
82
|
+
# Binary search for efficiency
|
|
83
|
+
left, right = 0, len(self.ranges) - 1
|
|
84
|
+
|
|
85
|
+
while left <= right:
|
|
86
|
+
mid = (left + right) // 2
|
|
87
|
+
start, end = self.ranges[mid]
|
|
88
|
+
|
|
89
|
+
if line < start:
|
|
90
|
+
right = mid - 1
|
|
91
|
+
elif line > end:
|
|
92
|
+
left = mid + 1
|
|
93
|
+
else:
|
|
94
|
+
return True
|
|
95
|
+
|
|
96
|
+
return False
|
|
97
|
+
|
|
98
|
+
def intersects_any(self, line_numbers: Set[int]) -> bool:
|
|
99
|
+
"""Check if any of the line numbers are covered."""
|
|
100
|
+
for line in line_numbers:
|
|
101
|
+
if self.contains(line):
|
|
102
|
+
return True
|
|
103
|
+
return False
|
|
104
|
+
|
|
105
|
+
def intersection(self, other: 'RangeSet') -> 'RangeSet':
|
|
106
|
+
"""Return intersection of two range sets."""
|
|
107
|
+
result = RangeSet()
|
|
108
|
+
|
|
109
|
+
i = j = 0
|
|
110
|
+
while i < len(self.ranges) and j < len(other.ranges):
|
|
111
|
+
s1, e1 = self.ranges[i]
|
|
112
|
+
s2, e2 = other.ranges[j]
|
|
113
|
+
|
|
114
|
+
# Find overlap
|
|
115
|
+
overlap_start = max(s1, s2)
|
|
116
|
+
overlap_end = min(e1, e2)
|
|
117
|
+
|
|
118
|
+
if overlap_start <= overlap_end:
|
|
119
|
+
result.add_range(overlap_start, overlap_end)
|
|
120
|
+
|
|
121
|
+
# Advance the range that ends first
|
|
122
|
+
if e1 < e2:
|
|
123
|
+
i += 1
|
|
124
|
+
else:
|
|
125
|
+
j += 1
|
|
126
|
+
|
|
127
|
+
return result
|
|
128
|
+
|
|
129
|
+
def union(self, other: 'RangeSet') -> 'RangeSet':
|
|
130
|
+
"""Return union of two range sets."""
|
|
131
|
+
result = RangeSet(self.ranges[:])
|
|
132
|
+
for start, end in other.ranges:
|
|
133
|
+
result.add_range(start, end)
|
|
134
|
+
return result
|
|
135
|
+
|
|
136
|
+
def to_list(self) -> List[Tuple[int, int]]:
|
|
137
|
+
"""Export as list of (start, end) tuples."""
|
|
138
|
+
return self.ranges[:]
|
|
139
|
+
|
|
140
|
+
def to_compact_string(self) -> str:
|
|
141
|
+
"""Export as compact string representation: '1-5,10-12,20'."""
|
|
142
|
+
parts = []
|
|
143
|
+
for start, end in self.ranges:
|
|
144
|
+
if start == end:
|
|
145
|
+
parts.append(str(start))
|
|
146
|
+
else:
|
|
147
|
+
parts.append(f"{start}-{end}")
|
|
148
|
+
return ','.join(parts)
|
|
149
|
+
|
|
150
|
+
@classmethod
|
|
151
|
+
def from_compact_string(cls, s: str) -> 'RangeSet':
|
|
152
|
+
"""Import from compact string: '1-5,10-12,20'."""
|
|
153
|
+
if not s or not s.strip():
|
|
154
|
+
return cls()
|
|
155
|
+
|
|
156
|
+
ranges = []
|
|
157
|
+
for part in s.split(','):
|
|
158
|
+
part = part.strip()
|
|
159
|
+
if '-' in part:
|
|
160
|
+
start, end = part.split('-', 1)
|
|
161
|
+
ranges.append((int(start), int(end)))
|
|
162
|
+
else:
|
|
163
|
+
val = int(part)
|
|
164
|
+
ranges.append((val, val))
|
|
165
|
+
|
|
166
|
+
return cls(ranges)
|
|
167
|
+
|
|
168
|
+
def __len__(self) -> int:
|
|
169
|
+
"""Return total number of lines covered."""
|
|
170
|
+
return sum(end - start + 1 for start, end in self.ranges)
|
|
171
|
+
|
|
172
|
+
def __repr__(self) -> str:
|
|
173
|
+
return f"RangeSet({self.ranges})"
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
"""Range-based test mapping storage for dramatic size reduction.
|
|
2
|
+
|
|
3
|
+
Optimized version that stores line ranges instead of individual lines,
|
|
4
|
+
reducing 12M entries to ~100K ranges (99%+ reduction).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sqlite3
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Set, List, Dict, Optional
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
|
|
13
|
+
from .range_set import RangeSet
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def normalize_test_name(test_name: str) -> str:
|
|
17
|
+
"""
|
|
18
|
+
Normalize test name by removing parametrization.
|
|
19
|
+
|
|
20
|
+
This aggregates parametrized tests into their base test name:
|
|
21
|
+
- test_foo[param1] -> test_foo
|
|
22
|
+
- test_foo[param2] -> test_foo
|
|
23
|
+
|
|
24
|
+
This prevents:
|
|
25
|
+
1. Storing duplicate entries for each parameter combination
|
|
26
|
+
2. Issues with dynamic parameters that change between runs
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
test_name: Full test name including parametrization
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
Base test name without parametrization
|
|
33
|
+
"""
|
|
34
|
+
# Strip parametrization: test_name[params] -> test_name
|
|
35
|
+
if "[" in test_name and test_name.endswith("]"):
|
|
36
|
+
return test_name.split("[")[0]
|
|
37
|
+
return test_name
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class TestMapping:
|
|
43
|
+
"""Represents a test and the files/lines it covers."""
|
|
44
|
+
test_name: str
|
|
45
|
+
file_path: str
|
|
46
|
+
lines: RangeSet
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class TestMappingDBV2:
|
|
50
|
+
"""SQLite database with range-based storage for test-to-code mappings."""
|
|
51
|
+
|
|
52
|
+
SCHEMA_VERSION = 2
|
|
53
|
+
|
|
54
|
+
def __init__(self, db_path: Path):
|
|
55
|
+
"""
|
|
56
|
+
Initialize mapping database.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
db_path: Path to SQLite database file
|
|
60
|
+
"""
|
|
61
|
+
self.db_path = Path(db_path)
|
|
62
|
+
self.conn: Optional[sqlite3.Connection] = None
|
|
63
|
+
|
|
64
|
+
def __enter__(self):
|
|
65
|
+
"""Context manager entry."""
|
|
66
|
+
self.connect()
|
|
67
|
+
return self
|
|
68
|
+
|
|
69
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
70
|
+
"""Context manager exit."""
|
|
71
|
+
self.close()
|
|
72
|
+
|
|
73
|
+
def connect(self):
|
|
74
|
+
"""Open database connection."""
|
|
75
|
+
self.conn = sqlite3.connect(str(self.db_path))
|
|
76
|
+
self.conn.row_factory = sqlite3.Row
|
|
77
|
+
|
|
78
|
+
# Performance optimizations
|
|
79
|
+
self.conn.execute("PRAGMA journal_mode=WAL")
|
|
80
|
+
self.conn.execute("PRAGMA synchronous=NORMAL")
|
|
81
|
+
self.conn.execute("PRAGMA cache_size=-64000") # 64MB cache
|
|
82
|
+
self.conn.execute("PRAGMA temp_store=MEMORY")
|
|
83
|
+
self.conn.execute("PRAGMA mmap_size=268435456") # 256MB memory-mapped I/O
|
|
84
|
+
|
|
85
|
+
# Auto-initialize schema if not present
|
|
86
|
+
if not self.is_initialized():
|
|
87
|
+
self.initialize_schema()
|
|
88
|
+
|
|
89
|
+
def close(self):
|
|
90
|
+
"""Close database connection."""
|
|
91
|
+
if self.conn:
|
|
92
|
+
self.conn.close()
|
|
93
|
+
self.conn = None
|
|
94
|
+
|
|
95
|
+
def initialize_schema(self):
|
|
96
|
+
"""Create database tables with range-based storage."""
|
|
97
|
+
cursor = self.conn.cursor()
|
|
98
|
+
|
|
99
|
+
# Metadata table
|
|
100
|
+
cursor.execute("""
|
|
101
|
+
CREATE TABLE IF NOT EXISTS metadata (
|
|
102
|
+
key TEXT PRIMARY KEY,
|
|
103
|
+
value TEXT NOT NULL
|
|
104
|
+
)
|
|
105
|
+
""")
|
|
106
|
+
|
|
107
|
+
# Range-based test coverage table
|
|
108
|
+
cursor.execute("""
|
|
109
|
+
CREATE TABLE IF NOT EXISTS test_coverage_ranges (
|
|
110
|
+
test_name TEXT NOT NULL,
|
|
111
|
+
file_path TEXT NOT NULL,
|
|
112
|
+
ranges TEXT NOT NULL,
|
|
113
|
+
PRIMARY KEY (test_name, file_path)
|
|
114
|
+
)
|
|
115
|
+
""")
|
|
116
|
+
|
|
117
|
+
# Indexes for fast lookups
|
|
118
|
+
cursor.execute("""
|
|
119
|
+
CREATE INDEX IF NOT EXISTS idx_ranges_file_path
|
|
120
|
+
ON test_coverage_ranges(file_path)
|
|
121
|
+
""")
|
|
122
|
+
|
|
123
|
+
cursor.execute("""
|
|
124
|
+
CREATE INDEX IF NOT EXISTS idx_ranges_test_name
|
|
125
|
+
ON test_coverage_ranges(test_name)
|
|
126
|
+
""")
|
|
127
|
+
|
|
128
|
+
# Store schema version
|
|
129
|
+
cursor.execute("""
|
|
130
|
+
INSERT OR REPLACE INTO metadata (key, value)
|
|
131
|
+
VALUES ('schema_version', ?)
|
|
132
|
+
""", (str(self.SCHEMA_VERSION),))
|
|
133
|
+
|
|
134
|
+
# Store creation timestamp
|
|
135
|
+
cursor.execute("""
|
|
136
|
+
INSERT OR IGNORE INTO metadata (key, value)
|
|
137
|
+
VALUES ('created_at', ?)
|
|
138
|
+
""", (datetime.utcnow().isoformat(),))
|
|
139
|
+
|
|
140
|
+
self.conn.commit()
|
|
141
|
+
|
|
142
|
+
def is_initialized(self) -> bool:
|
|
143
|
+
"""Check if database schema is initialized."""
|
|
144
|
+
try:
|
|
145
|
+
cursor = self.conn.cursor()
|
|
146
|
+
cursor.execute("""
|
|
147
|
+
SELECT name FROM sqlite_master
|
|
148
|
+
WHERE type='table' AND name='test_coverage_ranges'
|
|
149
|
+
""")
|
|
150
|
+
return cursor.fetchone() is not None
|
|
151
|
+
except sqlite3.Error:
|
|
152
|
+
return False
|
|
153
|
+
|
|
154
|
+
def import_from_coverage(self, coverage_file: Path, incremental: bool = False):
|
|
155
|
+
"""
|
|
156
|
+
Import test mappings from pytest .coverage file.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
coverage_file: Path to .coverage SQLite file
|
|
160
|
+
incremental: If True, merge with existing mappings
|
|
161
|
+
"""
|
|
162
|
+
from .coverage_mapper import CoverageMapper
|
|
163
|
+
|
|
164
|
+
mapper = CoverageMapper(coverage_file)
|
|
165
|
+
mapper.load_coverage()
|
|
166
|
+
|
|
167
|
+
cursor = self.conn.cursor()
|
|
168
|
+
|
|
169
|
+
# Build range-compressed mappings
|
|
170
|
+
test_file_ranges: Dict[Tuple[str, str], RangeSet] = {}
|
|
171
|
+
|
|
172
|
+
for file_path, coverage_data in mapper.coverage_data.items():
|
|
173
|
+
for line_num, test_contexts in coverage_data.test_contexts.items():
|
|
174
|
+
for test_name in test_contexts:
|
|
175
|
+
# Normalize test name to aggregate parametrized tests
|
|
176
|
+
normalized_test = normalize_test_name(test_name)
|
|
177
|
+
|
|
178
|
+
key = (normalized_test, file_path)
|
|
179
|
+
if key not in test_file_ranges:
|
|
180
|
+
test_file_ranges[key] = RangeSet()
|
|
181
|
+
test_file_ranges[key].add_range(line_num, line_num)
|
|
182
|
+
|
|
183
|
+
if incremental:
|
|
184
|
+
# Merge with existing ranges
|
|
185
|
+
for (test_name, file_path), new_ranges in test_file_ranges.items():
|
|
186
|
+
cursor.execute("""
|
|
187
|
+
SELECT ranges FROM test_coverage_ranges
|
|
188
|
+
WHERE test_name = ? AND file_path = ?
|
|
189
|
+
""", (test_name, file_path))
|
|
190
|
+
|
|
191
|
+
row = cursor.fetchone()
|
|
192
|
+
if row:
|
|
193
|
+
# Merge with existing
|
|
194
|
+
existing = RangeSet.from_compact_string(row['ranges'])
|
|
195
|
+
merged = existing.union(new_ranges)
|
|
196
|
+
|
|
197
|
+
cursor.execute("""
|
|
198
|
+
UPDATE test_coverage_ranges
|
|
199
|
+
SET ranges = ?
|
|
200
|
+
WHERE test_name = ? AND file_path = ?
|
|
201
|
+
""", (merged.to_compact_string(), test_name, file_path))
|
|
202
|
+
else:
|
|
203
|
+
# Insert new
|
|
204
|
+
cursor.execute("""
|
|
205
|
+
INSERT INTO test_coverage_ranges (test_name, file_path, ranges)
|
|
206
|
+
VALUES (?, ?, ?)
|
|
207
|
+
""", (test_name, file_path, new_ranges.to_compact_string()))
|
|
208
|
+
else:
|
|
209
|
+
# Replace all mappings
|
|
210
|
+
cursor.execute("DELETE FROM test_coverage_ranges")
|
|
211
|
+
|
|
212
|
+
records = [
|
|
213
|
+
(test_name, file_path, ranges.to_compact_string())
|
|
214
|
+
for (test_name, file_path), ranges in test_file_ranges.items()
|
|
215
|
+
]
|
|
216
|
+
|
|
217
|
+
cursor.executemany("""
|
|
218
|
+
INSERT INTO test_coverage_ranges (test_name, file_path, ranges)
|
|
219
|
+
VALUES (?, ?, ?)
|
|
220
|
+
""", records)
|
|
221
|
+
|
|
222
|
+
# Update metadata
|
|
223
|
+
cursor.execute("""
|
|
224
|
+
INSERT OR REPLACE INTO metadata (key, value)
|
|
225
|
+
VALUES ('last_import', ?)
|
|
226
|
+
""", (datetime.utcnow().isoformat(),))
|
|
227
|
+
|
|
228
|
+
cursor.execute("""
|
|
229
|
+
INSERT OR REPLACE INTO metadata (key, value)
|
|
230
|
+
VALUES ('source_coverage_file', ?)
|
|
231
|
+
""", (str(coverage_file.absolute()),))
|
|
232
|
+
|
|
233
|
+
cursor.execute("""
|
|
234
|
+
INSERT OR REPLACE INTO metadata (key, value)
|
|
235
|
+
VALUES ('total_tests', ?)
|
|
236
|
+
""", (str(len(set(k[0] for k in test_file_ranges.keys()))),))
|
|
237
|
+
|
|
238
|
+
cursor.execute("""
|
|
239
|
+
INSERT OR REPLACE INTO metadata (key, value)
|
|
240
|
+
VALUES ('total_files', ?)
|
|
241
|
+
""", (str(len(mapper.coverage_data)),))
|
|
242
|
+
|
|
243
|
+
self.conn.commit()
|
|
244
|
+
|
|
245
|
+
return len(test_file_ranges)
|
|
246
|
+
|
|
247
|
+
def find_tests_for_file_lines(
|
|
248
|
+
self,
|
|
249
|
+
file_path: str,
|
|
250
|
+
line_numbers: Set[int]
|
|
251
|
+
) -> Set[str]:
|
|
252
|
+
"""
|
|
253
|
+
Find all tests that cover specific lines in a file.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
file_path: Path to source file
|
|
257
|
+
line_numbers: Set of line numbers to check
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
Set of test names that cover those lines
|
|
261
|
+
"""
|
|
262
|
+
if not line_numbers:
|
|
263
|
+
return set()
|
|
264
|
+
|
|
265
|
+
cursor = self.conn.cursor()
|
|
266
|
+
file_path = str(file_path)
|
|
267
|
+
|
|
268
|
+
# Query for all tests covering this file
|
|
269
|
+
cursor.execute("""
|
|
270
|
+
SELECT test_name, ranges
|
|
271
|
+
FROM test_coverage_ranges
|
|
272
|
+
WHERE file_path = ?
|
|
273
|
+
""", (file_path,))
|
|
274
|
+
|
|
275
|
+
tests = set()
|
|
276
|
+
for row in cursor.fetchall():
|
|
277
|
+
ranges = RangeSet.from_compact_string(row['ranges'])
|
|
278
|
+
if ranges.intersects_any(line_numbers):
|
|
279
|
+
tests.add(row['test_name'])
|
|
280
|
+
|
|
281
|
+
return tests
|
|
282
|
+
|
|
283
|
+
def get_all_test_files(self, file_path: str) -> Set[str]:
|
|
284
|
+
"""
|
|
285
|
+
Get all tests that cover any line in a file.
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
file_path: Path to source file
|
|
289
|
+
|
|
290
|
+
Returns:
|
|
291
|
+
Set of test names
|
|
292
|
+
"""
|
|
293
|
+
cursor = self.conn.cursor()
|
|
294
|
+
file_path = str(file_path)
|
|
295
|
+
|
|
296
|
+
cursor.execute("""
|
|
297
|
+
SELECT DISTINCT test_name
|
|
298
|
+
FROM test_coverage_ranges
|
|
299
|
+
WHERE file_path = ?
|
|
300
|
+
""", (file_path,))
|
|
301
|
+
|
|
302
|
+
return {row['test_name'] for row in cursor.fetchall()}
|
|
303
|
+
|
|
304
|
+
def get_metadata(self) -> Dict[str, str]:
|
|
305
|
+
"""Get all metadata key-value pairs."""
|
|
306
|
+
cursor = self.conn.cursor()
|
|
307
|
+
cursor.execute("SELECT key, value FROM metadata")
|
|
308
|
+
return {row['key']: row['value'] for row in cursor.fetchall()}
|
|
309
|
+
|
|
310
|
+
def get_stats(self) -> Dict[str, int]:
|
|
311
|
+
"""Get database statistics."""
|
|
312
|
+
cursor = self.conn.cursor()
|
|
313
|
+
|
|
314
|
+
cursor.execute("SELECT COUNT(DISTINCT test_name) FROM test_coverage_ranges")
|
|
315
|
+
total_tests = cursor.fetchone()[0]
|
|
316
|
+
|
|
317
|
+
cursor.execute("SELECT COUNT(DISTINCT file_path) FROM test_coverage_ranges")
|
|
318
|
+
total_files = cursor.fetchone()[0]
|
|
319
|
+
|
|
320
|
+
cursor.execute("SELECT COUNT(*) FROM test_coverage_ranges")
|
|
321
|
+
total_range_entries = cursor.fetchone()[0]
|
|
322
|
+
|
|
323
|
+
# Calculate actual line coverage and compression ratio
|
|
324
|
+
cursor.execute("SELECT ranges FROM test_coverage_ranges")
|
|
325
|
+
total_lines = 0
|
|
326
|
+
total_ranges = 0
|
|
327
|
+
for row in cursor.fetchall():
|
|
328
|
+
rs = RangeSet.from_compact_string(row['ranges'])
|
|
329
|
+
total_lines += len(rs)
|
|
330
|
+
total_ranges += len(rs.ranges)
|
|
331
|
+
|
|
332
|
+
return {
|
|
333
|
+
'total_tests': total_tests,
|
|
334
|
+
'total_files': total_files,
|
|
335
|
+
'total_range_entries': total_range_entries,
|
|
336
|
+
'total_lines_covered': total_lines,
|
|
337
|
+
'total_ranges': total_ranges,
|
|
338
|
+
'compression_ratio': round(total_lines / total_ranges, 2) if total_ranges > 0 else 0,
|
|
339
|
+
# Backward compatibility with old TestMappingDB
|
|
340
|
+
'total_mappings': total_lines, # Equivalent to total line coverage
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
def get_all_test_names(self) -> set:
|
|
344
|
+
"""
|
|
345
|
+
Get all unique test names efficiently.
|
|
346
|
+
|
|
347
|
+
Returns:
|
|
348
|
+
Set of all test names in the database
|
|
349
|
+
"""
|
|
350
|
+
cursor = self.conn.cursor()
|
|
351
|
+
# This query benefits from the idx_test_name index
|
|
352
|
+
cursor.execute("SELECT DISTINCT test_name FROM test_coverage_ranges")
|
|
353
|
+
return {row[0] for row in cursor.fetchall()}
|
|
354
|
+
|
|
355
|
+
def optimize(self):
|
|
356
|
+
"""Optimize database for query performance."""
|
|
357
|
+
cursor = self.conn.cursor()
|
|
358
|
+
cursor.execute("ANALYZE")
|
|
359
|
+
self.conn.commit()
|
|
360
|
+
|
|
361
|
+
def vacuum(self):
|
|
362
|
+
"""Reclaim unused space."""
|
|
363
|
+
self.conn.execute("VACUUM")
|
|
364
|
+
|
|
365
|
+
def is_stale(self, coverage_file: Path, max_age_hours: int = 24) -> bool:
|
|
366
|
+
"""Check if mapping is stale compared to coverage file."""
|
|
367
|
+
if coverage_file.exists():
|
|
368
|
+
coverage_mtime = coverage_file.stat().st_mtime
|
|
369
|
+
if self.db_path.exists():
|
|
370
|
+
db_mtime = self.db_path.stat().st_mtime
|
|
371
|
+
if coverage_mtime > db_mtime:
|
|
372
|
+
return True
|
|
373
|
+
|
|
374
|
+
metadata = self.get_metadata()
|
|
375
|
+
if 'last_import' in metadata:
|
|
376
|
+
last_import = datetime.fromisoformat(metadata['last_import'])
|
|
377
|
+
age_hours = (datetime.utcnow() - last_import).total_seconds() / 3600
|
|
378
|
+
if age_hours > max_age_hours:
|
|
379
|
+
return True
|
|
380
|
+
|
|
381
|
+
return False
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Update test mapping database from coverage data.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
python update_mapping.py --repo-root ~/workspace/myproject
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
# Add pintest to path
|
|
14
|
+
SCRIPT_DIR = Path(__file__).parent
|
|
15
|
+
sys.path.insert(0, str(SCRIPT_DIR))
|
|
16
|
+
|
|
17
|
+
from pintest.test_mapping_db_v2 import TestMappingDBV2
|
|
18
|
+
from pintest.pre_commit_hook import ensure_docker_containers, run_preflight_scripts
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def update_mapping(
|
|
22
|
+
repo_root: Path,
|
|
23
|
+
coverage_file: Path = None,
|
|
24
|
+
mapping_db: Path = None,
|
|
25
|
+
verbose: bool = False
|
|
26
|
+
):
|
|
27
|
+
"""
|
|
28
|
+
Update test mapping database from coverage file.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
repo_root: Repository root directory
|
|
32
|
+
coverage_file: Path to .coverage file
|
|
33
|
+
mapping_db: Path to mapping database file
|
|
34
|
+
verbose: Print verbose output
|
|
35
|
+
"""
|
|
36
|
+
repo_root = repo_root.resolve()
|
|
37
|
+
|
|
38
|
+
# Default paths
|
|
39
|
+
if coverage_file is None:
|
|
40
|
+
coverage_file = repo_root / ".coverage"
|
|
41
|
+
|
|
42
|
+
if mapping_db is None:
|
|
43
|
+
mapping_db = repo_root / ".test_mapping.db"
|
|
44
|
+
|
|
45
|
+
# Ensure Docker containers are running (if a compose file is present)
|
|
46
|
+
if verbose:
|
|
47
|
+
print("\nš§ Pre-checks...")
|
|
48
|
+
|
|
49
|
+
if not ensure_docker_containers(repo_root, verbose):
|
|
50
|
+
print("ā Docker container check failed.", file=sys.stderr)
|
|
51
|
+
return 1
|
|
52
|
+
|
|
53
|
+
if not run_preflight_scripts(repo_root, verbose):
|
|
54
|
+
print("ā Preflight checks failed.", file=sys.stderr)
|
|
55
|
+
return 1
|
|
56
|
+
|
|
57
|
+
if not coverage_file.exists():
|
|
58
|
+
print(f"ā Error: Coverage file not found: {coverage_file}", file=sys.stderr)
|
|
59
|
+
print("\nGenerate coverage first:", file=sys.stderr)
|
|
60
|
+
print(f" cd {repo_root}", file=sys.stderr)
|
|
61
|
+
print(" pytest --cov=src --cov-context=test --cov-report=", file=sys.stderr)
|
|
62
|
+
return 1
|
|
63
|
+
|
|
64
|
+
print(f"š Updating test mapping database...")
|
|
65
|
+
print(f" Coverage file: {coverage_file}")
|
|
66
|
+
print(f" Mapping DB: {mapping_db}")
|
|
67
|
+
|
|
68
|
+
# Initialize database
|
|
69
|
+
with TestMappingDBV2(mapping_db) as db:
|
|
70
|
+
db.initialize_schema()
|
|
71
|
+
|
|
72
|
+
# Import from coverage
|
|
73
|
+
print("\nš„ Importing coverage data...")
|
|
74
|
+
num_mappings = db.import_from_coverage(coverage_file)
|
|
75
|
+
|
|
76
|
+
# Get stats
|
|
77
|
+
stats = db.get_stats()
|
|
78
|
+
metadata = db.get_metadata()
|
|
79
|
+
|
|
80
|
+
print(f"\nā
Test mapping database updated successfully!")
|
|
81
|
+
print(f"\nš Statistics:")
|
|
82
|
+
print(f" Total tests: {stats['total_tests']}")
|
|
83
|
+
print(f" Total files: {stats['total_files']}")
|
|
84
|
+
print(f" Total mappings: {stats['total_mappings']}")
|
|
85
|
+
print(f" Last import: {metadata.get('last_import', 'N/A')}")
|
|
86
|
+
|
|
87
|
+
if verbose:
|
|
88
|
+
print(f"\nšļø Database size: {mapping_db.stat().st_size / 1024:.1f} KB")
|
|
89
|
+
|
|
90
|
+
return 0
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def main():
|
|
94
|
+
parser = argparse.ArgumentParser(
|
|
95
|
+
description="Update test mapping database from coverage data"
|
|
96
|
+
)
|
|
97
|
+
parser.add_argument(
|
|
98
|
+
"--repo-root",
|
|
99
|
+
type=Path,
|
|
100
|
+
default=Path.cwd(),
|
|
101
|
+
help="Repository root directory (default: current directory)"
|
|
102
|
+
)
|
|
103
|
+
parser.add_argument(
|
|
104
|
+
"--coverage-file",
|
|
105
|
+
type=Path,
|
|
106
|
+
help="Path to .coverage file (default: <repo>/.coverage)"
|
|
107
|
+
)
|
|
108
|
+
parser.add_argument(
|
|
109
|
+
"--mapping-db",
|
|
110
|
+
type=Path,
|
|
111
|
+
help="Path to mapping database (default: <repo>/.test_mapping.db)"
|
|
112
|
+
)
|
|
113
|
+
parser.add_argument(
|
|
114
|
+
"-v", "--verbose",
|
|
115
|
+
action="store_true",
|
|
116
|
+
help="Verbose output"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
args = parser.parse_args()
|
|
120
|
+
|
|
121
|
+
return update_mapping(
|
|
122
|
+
args.repo_root,
|
|
123
|
+
args.coverage_file,
|
|
124
|
+
args.mapping_db,
|
|
125
|
+
args.verbose
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
if __name__ == "__main__":
|
|
130
|
+
sys.exit(main())
|