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
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cloud mapping database client for Pintest.
|
|
3
|
+
|
|
4
|
+
Drop-in replacement for TestMappingDBV2 when a Pintest API key is configured.
|
|
5
|
+
Makes direct API calls — no local state, no caching.
|
|
6
|
+
"""
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Set, List, Dict, Optional
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
import requests
|
|
13
|
+
except ImportError:
|
|
14
|
+
requests = None # will raise at init time if cloud is used
|
|
15
|
+
|
|
16
|
+
from .config import CloudConfig
|
|
17
|
+
from .test_mapping_db_v2 import normalize_test_name
|
|
18
|
+
from .range_set import RangeSet
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CloudMappingDB:
|
|
22
|
+
"""
|
|
23
|
+
Talks to api.pintest.dev instead of a local SQLite file.
|
|
24
|
+
Interface mirrors TestMappingDBV2 so pre_commit_hook.py needs minimal changes.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, config: CloudConfig):
|
|
28
|
+
if requests is None:
|
|
29
|
+
raise ImportError(
|
|
30
|
+
"The 'requests' library is required for cloud mode.\n"
|
|
31
|
+
"Install it with: pip install requests"
|
|
32
|
+
)
|
|
33
|
+
if not config.repo_id:
|
|
34
|
+
raise ValueError(
|
|
35
|
+
"No repo_id configured. Run: pintest repo init --name <your-repo>"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
self.config = config
|
|
39
|
+
self.session = requests.Session()
|
|
40
|
+
self.session.headers.update({
|
|
41
|
+
"Authorization": f"Bearer {config.api_key}",
|
|
42
|
+
"Content-Type": "application/json",
|
|
43
|
+
})
|
|
44
|
+
self._api = config.api_url.rstrip("/")
|
|
45
|
+
self._repo_id = config.repo_id
|
|
46
|
+
self._branch = config.branch
|
|
47
|
+
|
|
48
|
+
# ── Query (called by pre_commit_hook during commit) ────────────────────────
|
|
49
|
+
|
|
50
|
+
def find_tests_for_changes(self, changes: List[Dict], branch: Optional[str] = None) -> tuple[Set[str], List[str]]:
|
|
51
|
+
"""
|
|
52
|
+
Query the cloud API for affected tests.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
changes: [{"file": "src/auth.py", "lines": [42, 43]}, ...]
|
|
56
|
+
branch: Optional branch to query mapping for (overrides default)
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
(affected_tests: set[str], unmapped_files: list[str])
|
|
60
|
+
"""
|
|
61
|
+
payload = {
|
|
62
|
+
"branch": branch or self._branch,
|
|
63
|
+
"changes": changes,
|
|
64
|
+
}
|
|
65
|
+
try:
|
|
66
|
+
resp = self.session.post(
|
|
67
|
+
f"{self._api}/api/v1/repos/{self._repo_id}/query",
|
|
68
|
+
json=payload,
|
|
69
|
+
timeout=30,
|
|
70
|
+
)
|
|
71
|
+
resp.raise_for_status()
|
|
72
|
+
except requests.Timeout:
|
|
73
|
+
print("⚠️ Pintest API timed out — falling back to running all tests", file=sys.stderr)
|
|
74
|
+
return set(), [c["file"] for c in changes]
|
|
75
|
+
except requests.RequestException as e:
|
|
76
|
+
print(f"⚠️ Pintest API error: {e}", file=sys.stderr)
|
|
77
|
+
return set(), [c["file"] for c in changes]
|
|
78
|
+
|
|
79
|
+
data = resp.json()
|
|
80
|
+
return set(data["affected_tests"]), data.get("unmapped_files", [])
|
|
81
|
+
|
|
82
|
+
def find_tests_for_file_lines(self, file_path: str, line_numbers: Set[int]) -> Set[str]:
|
|
83
|
+
"""
|
|
84
|
+
Single-file wrapper — matches TestMappingDBV2 interface used by pre_commit_hook.
|
|
85
|
+
"""
|
|
86
|
+
tests, _ = self.find_tests_for_changes([
|
|
87
|
+
{"file": file_path, "lines": sorted(line_numbers)}
|
|
88
|
+
])
|
|
89
|
+
return tests
|
|
90
|
+
|
|
91
|
+
# ── Push (called after test run to update mappings) ────────────────────────
|
|
92
|
+
|
|
93
|
+
def push_coverage(
|
|
94
|
+
self,
|
|
95
|
+
coverage_file: Path,
|
|
96
|
+
run_stats: Optional[Dict] = None,
|
|
97
|
+
verbose: bool = False,
|
|
98
|
+
) -> bool:
|
|
99
|
+
"""
|
|
100
|
+
Parse a .coverage file and push new mappings to the cloud API.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
coverage_file: Path to the .coverage sqlite file
|
|
104
|
+
run_stats: Optional dict with tests_selected, tests_total, duration_ms, result
|
|
105
|
+
verbose: Print progress
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
True on success, False on failure
|
|
109
|
+
"""
|
|
110
|
+
from .coverage_mapper import CoverageMapper # existing module
|
|
111
|
+
|
|
112
|
+
if not coverage_file.exists():
|
|
113
|
+
if verbose:
|
|
114
|
+
print(f"⚠️ No coverage file at {coverage_file} — skipping cloud push")
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
if verbose:
|
|
118
|
+
print("☁️ Parsing coverage data for cloud upload...", flush=True)
|
|
119
|
+
|
|
120
|
+
mapper = CoverageMapper(coverage_file)
|
|
121
|
+
try:
|
|
122
|
+
mapper.load_coverage()
|
|
123
|
+
except Exception as e:
|
|
124
|
+
print(f"⚠️ Could not load coverage: {e}", file=sys.stderr)
|
|
125
|
+
return False
|
|
126
|
+
|
|
127
|
+
# Build range-compressed mappings (test -> file -> RangeSet)
|
|
128
|
+
test_file_ranges: Dict[tuple[str, str], RangeSet] = {}
|
|
129
|
+
|
|
130
|
+
for file_path, coverage_data in mapper.coverage_data.items():
|
|
131
|
+
for line_num, test_contexts in coverage_data.test_contexts.items():
|
|
132
|
+
for test_name in test_contexts:
|
|
133
|
+
# Normalize test name to aggregate parametrized tests
|
|
134
|
+
norm_name = normalize_test_name(test_name)
|
|
135
|
+
|
|
136
|
+
key = (norm_name, file_path)
|
|
137
|
+
if key not in test_file_ranges:
|
|
138
|
+
test_file_ranges[key] = RangeSet()
|
|
139
|
+
test_file_ranges[key].add_range(line_num, line_num)
|
|
140
|
+
|
|
141
|
+
mappings = []
|
|
142
|
+
for (test_name, file_path), rs in test_file_ranges.items():
|
|
143
|
+
mappings.append({
|
|
144
|
+
"test_name": test_name,
|
|
145
|
+
"file_path": file_path,
|
|
146
|
+
"ranges": rs.to_compact_string(),
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
if not mappings:
|
|
150
|
+
if verbose:
|
|
151
|
+
print("ℹ️ No coverage mappings to push")
|
|
152
|
+
return True
|
|
153
|
+
|
|
154
|
+
if verbose:
|
|
155
|
+
print(f"☁️ Pushing {len(mappings)} mappings to Pintest...", flush=True)
|
|
156
|
+
|
|
157
|
+
payload = {
|
|
158
|
+
"branch": self._branch,
|
|
159
|
+
"mappings": mappings,
|
|
160
|
+
}
|
|
161
|
+
if run_stats:
|
|
162
|
+
payload["run_stats"] = run_stats
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
resp = self.session.post(
|
|
166
|
+
f"{self._api}/api/v1/repos/{self._repo_id}/coverage",
|
|
167
|
+
json=payload,
|
|
168
|
+
timeout=60,
|
|
169
|
+
)
|
|
170
|
+
resp.raise_for_status()
|
|
171
|
+
data = resp.json()
|
|
172
|
+
if verbose:
|
|
173
|
+
print(
|
|
174
|
+
f"☁️ Cloud sync: {data['inserted']} new, "
|
|
175
|
+
f"{data['updated']} updated, "
|
|
176
|
+
f"{data['total_tests']} total tests"
|
|
177
|
+
)
|
|
178
|
+
return True
|
|
179
|
+
except requests.RequestException as e:
|
|
180
|
+
print(f"⚠️ Cloud push failed: {e}", file=sys.stderr)
|
|
181
|
+
return False
|
|
182
|
+
|
|
183
|
+
def get_all_test_names(self) -> Set[str]:
|
|
184
|
+
"""Fetch all unique test names from the cloud."""
|
|
185
|
+
try:
|
|
186
|
+
resp = self.session.get(
|
|
187
|
+
f"{self._api}/api/v1/repos/{self._repo_id}/tests",
|
|
188
|
+
timeout=10,
|
|
189
|
+
)
|
|
190
|
+
resp.raise_for_status()
|
|
191
|
+
return set(resp.json())
|
|
192
|
+
except Exception as e:
|
|
193
|
+
print(f"⚠️ Could not fetch test list from cloud: {e}", file=sys.stderr)
|
|
194
|
+
return set()
|
|
195
|
+
|
|
196
|
+
# ── Context manager support (mirrors TestMappingDBV2) ─────────────────────
|
|
197
|
+
|
|
198
|
+
def __enter__(self):
|
|
199
|
+
return self
|
|
200
|
+
|
|
201
|
+
def __exit__(self, *args):
|
|
202
|
+
self.session.close()
|
|
203
|
+
|
|
204
|
+
def is_initialized(self) -> bool:
|
|
205
|
+
"""Always True for cloud mode — API handles initialization."""
|
|
206
|
+
return True
|
|
207
|
+
|
|
208
|
+
def get_stats(self) -> Dict:
|
|
209
|
+
"""Fetch DB stats from API."""
|
|
210
|
+
try:
|
|
211
|
+
resp = self.session.get(
|
|
212
|
+
f"{self._api}/api/v1/repos/{self._repo_id}/stats",
|
|
213
|
+
timeout=10,
|
|
214
|
+
)
|
|
215
|
+
resp.raise_for_status()
|
|
216
|
+
return resp.json()
|
|
217
|
+
except Exception:
|
|
218
|
+
return {"total_tests": "?", "files_covered": "?"}
|
pintest/config.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Config management for Pintest cloud integration.
|
|
3
|
+
Reads/writes ~/.pintest/config.toml
|
|
4
|
+
"""
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
import tomllib # Python 3.11+
|
|
12
|
+
except ImportError:
|
|
13
|
+
try:
|
|
14
|
+
import tomli as tomllib # pip install tomli
|
|
15
|
+
except ImportError:
|
|
16
|
+
tomllib = None
|
|
17
|
+
|
|
18
|
+
CONFIG_DIR = Path.home() / ".pintest"
|
|
19
|
+
CONFIG_FILE = CONFIG_DIR / "config.toml"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class CloudConfig:
|
|
24
|
+
api_key: str
|
|
25
|
+
api_url: str = "https://api.pintest.dev"
|
|
26
|
+
repo_id: Optional[str] = None
|
|
27
|
+
branch: str = "main"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Config:
|
|
32
|
+
cloud: Optional[CloudConfig] = field(default=None)
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def load(cls) -> "Config":
|
|
36
|
+
"""Load config from ~/.pintest/config.toml. Returns empty config if not found."""
|
|
37
|
+
if not CONFIG_FILE.exists():
|
|
38
|
+
return cls()
|
|
39
|
+
|
|
40
|
+
# Manual TOML parser (minimal, avoids requiring tomllib/tomli on older Python)
|
|
41
|
+
if tomllib:
|
|
42
|
+
with open(CONFIG_FILE, "rb") as f:
|
|
43
|
+
data = tomllib.load(f)
|
|
44
|
+
else:
|
|
45
|
+
data = _parse_simple_toml(CONFIG_FILE)
|
|
46
|
+
|
|
47
|
+
cloud_data = data.get("cloud", {})
|
|
48
|
+
if cloud_data.get("api_key"):
|
|
49
|
+
cloud = CloudConfig(
|
|
50
|
+
api_key=cloud_data["api_key"],
|
|
51
|
+
api_url=cloud_data.get("api_url", "https://api.pintest.dev"),
|
|
52
|
+
repo_id=cloud_data.get("repo_id"),
|
|
53
|
+
branch=cloud_data.get("branch", "main"),
|
|
54
|
+
)
|
|
55
|
+
else:
|
|
56
|
+
cloud = None
|
|
57
|
+
|
|
58
|
+
return cls(cloud=cloud)
|
|
59
|
+
|
|
60
|
+
def save(self):
|
|
61
|
+
"""Write config to ~/.pintest/config.toml."""
|
|
62
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
CONFIG_FILE.chmod(0o600) if CONFIG_FILE.exists() else None
|
|
64
|
+
|
|
65
|
+
lines = []
|
|
66
|
+
if self.cloud:
|
|
67
|
+
lines.append("[cloud]")
|
|
68
|
+
lines.append(f'api_key = "{self.cloud.api_key}"')
|
|
69
|
+
lines.append(f'api_url = "{self.cloud.api_url}"')
|
|
70
|
+
if self.cloud.repo_id:
|
|
71
|
+
lines.append(f'repo_id = "{self.cloud.repo_id}"')
|
|
72
|
+
lines.append(f'branch = "{self.cloud.branch}"')
|
|
73
|
+
lines.append("")
|
|
74
|
+
|
|
75
|
+
CONFIG_FILE.write_text("\n".join(lines))
|
|
76
|
+
CONFIG_FILE.chmod(0o600) # owner read/write only — key is sensitive
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def is_cloud_enabled(self) -> bool:
|
|
80
|
+
return self.cloud is not None and bool(self.cloud.api_key)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _parse_simple_toml(path: Path) -> dict:
|
|
84
|
+
"""Minimal TOML parser for [section] + key = "value" files (no tomllib fallback)."""
|
|
85
|
+
result: dict = {}
|
|
86
|
+
current_section: dict = result
|
|
87
|
+
|
|
88
|
+
for line in path.read_text().splitlines():
|
|
89
|
+
line = line.strip()
|
|
90
|
+
if not line or line.startswith("#"):
|
|
91
|
+
continue
|
|
92
|
+
if line.startswith("[") and line.endswith("]"):
|
|
93
|
+
section = line[1:-1].strip()
|
|
94
|
+
current_section = {}
|
|
95
|
+
result[section] = current_section
|
|
96
|
+
elif "=" in line:
|
|
97
|
+
key, _, value = line.partition("=")
|
|
98
|
+
key = key.strip()
|
|
99
|
+
value = value.strip().strip('"').strip("'")
|
|
100
|
+
current_section[key] = value
|
|
101
|
+
|
|
102
|
+
return result
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
"""Map test coverage to changed lines using pytest-cov data."""
|
|
2
|
+
|
|
3
|
+
import sqlite3
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Dict, Set
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class CoverageData:
|
|
11
|
+
"""Coverage information for a file."""
|
|
12
|
+
file_path: str
|
|
13
|
+
executed_lines: Set[int] = field(default_factory=set)
|
|
14
|
+
test_contexts: Dict[int, Set[str]] = field(default_factory=dict)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CoverageMapper:
|
|
18
|
+
"""Map code coverage to test cases using .coverage database."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, coverage_file: Path = None):
|
|
21
|
+
"""
|
|
22
|
+
Initialize mapper with coverage database.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
coverage_file: Path to .coverage file (SQLite database)
|
|
26
|
+
"""
|
|
27
|
+
self.coverage_file = coverage_file or Path(".coverage")
|
|
28
|
+
self.coverage_data: Dict[str, CoverageData] = {}
|
|
29
|
+
self._loaded = False
|
|
30
|
+
|
|
31
|
+
def load_coverage(self):
|
|
32
|
+
"""Load coverage data from .coverage SQLite database."""
|
|
33
|
+
if not self.coverage_file.exists():
|
|
34
|
+
raise FileNotFoundError(
|
|
35
|
+
f"Coverage file not found: {self.coverage_file}\n"
|
|
36
|
+
"Generate it with: pytest --cov --cov-context=test --cov-report="
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
conn = sqlite3.connect(str(self.coverage_file))
|
|
40
|
+
cursor = conn.cursor()
|
|
41
|
+
|
|
42
|
+
# Check if we have the tables we need
|
|
43
|
+
cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
|
44
|
+
tables = {row[0] for row in cursor.fetchall()}
|
|
45
|
+
|
|
46
|
+
# Check for arc (branch) or line_bits (line) coverage
|
|
47
|
+
# Note: sometimes arc table exists but is empty if branch coverage is not enabled
|
|
48
|
+
has_arc = False
|
|
49
|
+
if 'arc' in tables:
|
|
50
|
+
cursor.execute("SELECT 1 FROM arc LIMIT 1")
|
|
51
|
+
if cursor.fetchone():
|
|
52
|
+
has_arc = True
|
|
53
|
+
|
|
54
|
+
has_line_bits = 'line_bits' in tables
|
|
55
|
+
|
|
56
|
+
if 'file' not in tables or (not has_arc and not has_line_bits):
|
|
57
|
+
conn.close()
|
|
58
|
+
raise RuntimeError(
|
|
59
|
+
"Coverage database doesn't have required tables. "
|
|
60
|
+
"Make sure to run: pytest --cov --cov-context=test"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# Check for context table (required for test-to-line mapping)
|
|
64
|
+
if 'context' not in tables:
|
|
65
|
+
conn.close()
|
|
66
|
+
raise RuntimeError(
|
|
67
|
+
"Coverage database doesn't have 'context' table. "
|
|
68
|
+
"This means coverage was run WITHOUT --cov-context=test flag.\n"
|
|
69
|
+
"Delete the old .coverage file and regenerate:\n"
|
|
70
|
+
" rm .coverage\n"
|
|
71
|
+
" pytest --cov=src --cov-context=test --cov-report="
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# Get file IDs and paths
|
|
75
|
+
cursor.execute("SELECT id, path FROM file")
|
|
76
|
+
files = {file_id: path for file_id, path in cursor.fetchall()}
|
|
77
|
+
|
|
78
|
+
# Get context IDs and names
|
|
79
|
+
cursor.execute("SELECT id, context FROM context")
|
|
80
|
+
contexts = {context_id: context for context_id, context in cursor.fetchall()}
|
|
81
|
+
|
|
82
|
+
# Use arc (branch coverage) if available, otherwise line_bits
|
|
83
|
+
if has_arc:
|
|
84
|
+
# Get arc coverage with context (test names)
|
|
85
|
+
# Arc table has: file_id, context_id, fromno, tono
|
|
86
|
+
cursor.execute("""
|
|
87
|
+
SELECT file_id, context_id, fromno, tono
|
|
88
|
+
FROM arc
|
|
89
|
+
""")
|
|
90
|
+
|
|
91
|
+
for file_id, context_id, fromno, tono in cursor.fetchall():
|
|
92
|
+
if file_id not in files:
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
file_path = files[file_id]
|
|
96
|
+
|
|
97
|
+
# Normalize file path
|
|
98
|
+
file_path = self._normalize_path(file_path)
|
|
99
|
+
|
|
100
|
+
if file_path not in self.coverage_data:
|
|
101
|
+
self.coverage_data[file_path] = CoverageData(file_path=file_path)
|
|
102
|
+
|
|
103
|
+
# Get context name
|
|
104
|
+
context = contexts.get(context_id, '')
|
|
105
|
+
|
|
106
|
+
# Extract lines from arc (both fromno and tono are line numbers)
|
|
107
|
+
# -1 means entry to the function
|
|
108
|
+
lines = set()
|
|
109
|
+
if fromno > 0:
|
|
110
|
+
lines.add(fromno)
|
|
111
|
+
if tono > 0:
|
|
112
|
+
lines.add(tono)
|
|
113
|
+
|
|
114
|
+
for line_num in lines:
|
|
115
|
+
self.coverage_data[file_path].executed_lines.add(line_num)
|
|
116
|
+
|
|
117
|
+
if line_num not in self.coverage_data[file_path].test_contexts:
|
|
118
|
+
self.coverage_data[file_path].test_contexts[line_num] = set()
|
|
119
|
+
|
|
120
|
+
# Context format: "testname|run" or "testname|setup" or just "testname"
|
|
121
|
+
if context:
|
|
122
|
+
# Extract test name from context
|
|
123
|
+
test_name = self._extract_test_name(context)
|
|
124
|
+
if test_name:
|
|
125
|
+
self.coverage_data[file_path].test_contexts[line_num].add(test_name)
|
|
126
|
+
else:
|
|
127
|
+
# Fallback to line_bits (line coverage)
|
|
128
|
+
cursor.execute("""
|
|
129
|
+
SELECT file_id, context_id, numbits
|
|
130
|
+
FROM line_bits
|
|
131
|
+
""")
|
|
132
|
+
|
|
133
|
+
for file_id, context_id, numbits in cursor.fetchall():
|
|
134
|
+
if file_id not in files:
|
|
135
|
+
continue
|
|
136
|
+
|
|
137
|
+
file_path = files[file_id]
|
|
138
|
+
|
|
139
|
+
# Normalize file path
|
|
140
|
+
file_path = self._normalize_path(file_path)
|
|
141
|
+
|
|
142
|
+
if file_path not in self.coverage_data:
|
|
143
|
+
self.coverage_data[file_path] = CoverageData(file_path=file_path)
|
|
144
|
+
|
|
145
|
+
# Get context name
|
|
146
|
+
context = contexts.get(context_id, '')
|
|
147
|
+
|
|
148
|
+
# Decode bit-packed line numbers
|
|
149
|
+
lines = self._decode_lines(numbits, 0)
|
|
150
|
+
|
|
151
|
+
for line_num in lines:
|
|
152
|
+
self.coverage_data[file_path].executed_lines.add(line_num)
|
|
153
|
+
|
|
154
|
+
if line_num not in self.coverage_data[file_path].test_contexts:
|
|
155
|
+
self.coverage_data[file_path].test_contexts[line_num] = set()
|
|
156
|
+
|
|
157
|
+
# Context format: "testname|run" or just "testname"
|
|
158
|
+
if context:
|
|
159
|
+
# Extract test name from context
|
|
160
|
+
test_name = self._extract_test_name(context)
|
|
161
|
+
if test_name:
|
|
162
|
+
self.coverage_data[file_path].test_contexts[line_num].add(test_name)
|
|
163
|
+
|
|
164
|
+
conn.close()
|
|
165
|
+
self._loaded = True
|
|
166
|
+
|
|
167
|
+
@staticmethod
|
|
168
|
+
def _decode_lines(numbits: bytes, number: int) -> Set[int]:
|
|
169
|
+
"""
|
|
170
|
+
Decode line numbers from coverage database bit encoding.
|
|
171
|
+
|
|
172
|
+
The coverage.py database stores line numbers in a compressed format:
|
|
173
|
+
- number: base line number
|
|
174
|
+
- numbits: bit-packed offsets from base
|
|
175
|
+
"""
|
|
176
|
+
lines = set()
|
|
177
|
+
|
|
178
|
+
if numbits:
|
|
179
|
+
# Bit-packed format: each bit represents an offset from 'number'
|
|
180
|
+
for byte_idx, byte_val in enumerate(numbits):
|
|
181
|
+
for bit_idx in range(8):
|
|
182
|
+
if byte_val & (1 << bit_idx):
|
|
183
|
+
line_num = number + (byte_idx * 8) + bit_idx
|
|
184
|
+
lines.add(line_num)
|
|
185
|
+
else:
|
|
186
|
+
# Single line number
|
|
187
|
+
if number > 0:
|
|
188
|
+
lines.add(number)
|
|
189
|
+
|
|
190
|
+
return lines
|
|
191
|
+
|
|
192
|
+
@staticmethod
|
|
193
|
+
def _extract_test_name(context: str) -> str:
|
|
194
|
+
"""Extract test name from coverage context string."""
|
|
195
|
+
if not context:
|
|
196
|
+
return ""
|
|
197
|
+
|
|
198
|
+
# Context can be:
|
|
199
|
+
# - "test_file.py::TestClass::test_method|run"
|
|
200
|
+
# - "test_file.py::test_function"
|
|
201
|
+
# - Just the test path
|
|
202
|
+
|
|
203
|
+
# Remove "|run" suffix if present
|
|
204
|
+
if '|' in context:
|
|
205
|
+
context = context.split('|')[0]
|
|
206
|
+
|
|
207
|
+
return context
|
|
208
|
+
|
|
209
|
+
def find_tests_for_lines(
|
|
210
|
+
self,
|
|
211
|
+
file_path: str,
|
|
212
|
+
changed_lines: Set[int]
|
|
213
|
+
) -> Set[str]:
|
|
214
|
+
"""
|
|
215
|
+
Find all tests that executed any of the changed lines.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
file_path: Path to source file
|
|
219
|
+
changed_lines: Set of changed line numbers
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
Set of test names that cover those lines
|
|
223
|
+
"""
|
|
224
|
+
if not self._loaded:
|
|
225
|
+
self.load_coverage()
|
|
226
|
+
|
|
227
|
+
# Normalize file path for lookup
|
|
228
|
+
normalized_path = self._normalize_path(file_path)
|
|
229
|
+
|
|
230
|
+
# Try to find coverage data with path matching
|
|
231
|
+
coverage_data = None
|
|
232
|
+
for stored_path in self.coverage_data.keys():
|
|
233
|
+
if self._paths_match(normalized_path, stored_path):
|
|
234
|
+
coverage_data = self.coverage_data[stored_path]
|
|
235
|
+
break
|
|
236
|
+
|
|
237
|
+
if not coverage_data:
|
|
238
|
+
return set()
|
|
239
|
+
|
|
240
|
+
# Collect all tests that executed any of the changed lines
|
|
241
|
+
tests = set()
|
|
242
|
+
for line_num in changed_lines:
|
|
243
|
+
if line_num in coverage_data.test_contexts:
|
|
244
|
+
tests.update(coverage_data.test_contexts[line_num])
|
|
245
|
+
|
|
246
|
+
return tests
|
|
247
|
+
|
|
248
|
+
@staticmethod
|
|
249
|
+
def _normalize_path(path: str) -> str:
|
|
250
|
+
"""
|
|
251
|
+
Normalize file path for comparison.
|
|
252
|
+
|
|
253
|
+
Removes common prefixes and standardizes separators.
|
|
254
|
+
"""
|
|
255
|
+
# Convert to forward slashes
|
|
256
|
+
path = path.replace('\\', '/')
|
|
257
|
+
|
|
258
|
+
import os
|
|
259
|
+
# Try to make relative to current directory if absolute
|
|
260
|
+
try:
|
|
261
|
+
cwd = os.getcwd().replace('\\', '/')
|
|
262
|
+
if path.startswith(cwd):
|
|
263
|
+
path = os.path.relpath(path, cwd).replace('\\', '/')
|
|
264
|
+
except Exception:
|
|
265
|
+
pass
|
|
266
|
+
|
|
267
|
+
# Remove common source prefix
|
|
268
|
+
for prefix in ['src/']:
|
|
269
|
+
if prefix in path:
|
|
270
|
+
idx = path.index(prefix)
|
|
271
|
+
path = path[idx + len(prefix):]
|
|
272
|
+
break
|
|
273
|
+
|
|
274
|
+
# Remove absolute path prefixes
|
|
275
|
+
if path.startswith('/'):
|
|
276
|
+
parts = Path(path).parts
|
|
277
|
+
# Find the first meaningful directory (not /, Users, home, etc.)
|
|
278
|
+
for i, part in enumerate(parts):
|
|
279
|
+
if part in ('src',):
|
|
280
|
+
path = '/'.join(parts[i:])
|
|
281
|
+
break
|
|
282
|
+
|
|
283
|
+
return path
|
|
284
|
+
|
|
285
|
+
@staticmethod
|
|
286
|
+
def _paths_match(path1: str, path2: str) -> bool:
|
|
287
|
+
"""Check if two paths refer to the same file."""
|
|
288
|
+
# Exact match
|
|
289
|
+
if path1 == path2:
|
|
290
|
+
return True
|
|
291
|
+
|
|
292
|
+
# Suffix matching (handles absolute vs relative paths)
|
|
293
|
+
if path1.endswith(path2) or path2.endswith(path1):
|
|
294
|
+
return True
|
|
295
|
+
|
|
296
|
+
# Compare just the filename and parent directory
|
|
297
|
+
p1_parts = Path(path1).parts[-2:] if len(Path(path1).parts) >= 2 else Path(path1).parts
|
|
298
|
+
p2_parts = Path(path2).parts[-2:] if len(Path(path2).parts) >= 2 else Path(path2).parts
|
|
299
|
+
|
|
300
|
+
return p1_parts == p2_parts
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def main():
|
|
304
|
+
"""CLI for testing the mapper."""
|
|
305
|
+
import sys
|
|
306
|
+
|
|
307
|
+
if len(sys.argv) < 2:
|
|
308
|
+
print("Usage: coverage_mapper.py <coverage_file> [file_path] [line_numbers...]")
|
|
309
|
+
print("\nExample:")
|
|
310
|
+
print(" coverage_mapper.py .coverage src/myfile.py 10 20 30")
|
|
311
|
+
sys.exit(1)
|
|
312
|
+
|
|
313
|
+
coverage_file = Path(sys.argv[1])
|
|
314
|
+
mapper = CoverageMapper(coverage_file)
|
|
315
|
+
|
|
316
|
+
try:
|
|
317
|
+
mapper.load_coverage()
|
|
318
|
+
|
|
319
|
+
print(f"Loaded coverage for {len(mapper.coverage_data)} files")
|
|
320
|
+
|
|
321
|
+
if len(sys.argv) >= 3:
|
|
322
|
+
# Look up specific file and lines
|
|
323
|
+
file_path = sys.argv[2]
|
|
324
|
+
line_numbers = set(map(int, sys.argv[3:])) if len(sys.argv) > 3 else set()
|
|
325
|
+
|
|
326
|
+
if line_numbers:
|
|
327
|
+
tests = mapper.find_tests_for_lines(file_path, line_numbers)
|
|
328
|
+
print(f"\nTests covering {file_path} lines {sorted(line_numbers)}:")
|
|
329
|
+
for test in sorted(tests):
|
|
330
|
+
print(f" - {test}")
|
|
331
|
+
else:
|
|
332
|
+
print(f"\nCoverage data for {file_path}:")
|
|
333
|
+
for path, data in mapper.coverage_data.items():
|
|
334
|
+
if file_path in path:
|
|
335
|
+
print(f" Executed lines: {sorted(data.executed_lines)[:10]}...")
|
|
336
|
+
print(f" Total tests: {len(set().union(*data.test_contexts.values()))}")
|
|
337
|
+
else:
|
|
338
|
+
# Show summary
|
|
339
|
+
print("\nCovered files:")
|
|
340
|
+
for path in sorted(mapper.coverage_data.keys())[:20]:
|
|
341
|
+
data = mapper.coverage_data[path]
|
|
342
|
+
all_tests = set().union(*data.test_contexts.values()) if data.test_contexts else set()
|
|
343
|
+
print(f" {path}: {len(data.executed_lines)} lines, {len(all_tests)} tests")
|
|
344
|
+
|
|
345
|
+
if len(mapper.coverage_data) > 20:
|
|
346
|
+
print(f" ... and {len(mapper.coverage_data) - 20} more files")
|
|
347
|
+
|
|
348
|
+
except Exception as e:
|
|
349
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
350
|
+
import traceback
|
|
351
|
+
traceback.print_exc()
|
|
352
|
+
sys.exit(1)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
if __name__ == "__main__":
|
|
356
|
+
main()
|