pintest-cli 0.3.6__tar.gz → 0.3.8__tar.gz
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_cli-0.3.6 → pintest_cli-0.3.8}/PKG-INFO +1 -1
- {pintest_cli-0.3.6 → pintest_cli-0.3.8}/pintest/build_mapping_iterative.py +21 -4
- {pintest_cli-0.3.6 → pintest_cli-0.3.8}/pintest/cli.py +16 -3
- {pintest_cli-0.3.6 → pintest_cli-0.3.8}/pintest/cloud_mapping_db.py +22 -0
- {pintest_cli-0.3.6 → pintest_cli-0.3.8}/pintest/pre_commit_hook.py +45 -14
- pintest_cli-0.3.8/pintest/pytest_plugin.py +192 -0
- {pintest_cli-0.3.6 → pintest_cli-0.3.8}/pintest_cli.egg-info/PKG-INFO +1 -1
- {pintest_cli-0.3.6 → pintest_cli-0.3.8}/setup.py +1 -1
- {pintest_cli-0.3.6 → pintest_cli-0.3.8}/tests/test_cli_e2e.py +2 -2
- pintest_cli-0.3.6/pintest/pytest_plugin.py +0 -178
- {pintest_cli-0.3.6 → pintest_cli-0.3.8}/README.md +0 -0
- {pintest_cli-0.3.6 → pintest_cli-0.3.8}/pintest/__init__.py +0 -0
- {pintest_cli-0.3.6 → pintest_cli-0.3.8}/pintest/config.py +0 -0
- {pintest_cli-0.3.6 → pintest_cli-0.3.8}/pintest/coverage_mapper.py +0 -0
- {pintest_cli-0.3.6 → pintest_cli-0.3.8}/pintest/git_diff_parser.py +0 -0
- {pintest_cli-0.3.6 → pintest_cli-0.3.8}/pintest/post_commit_hook.py +0 -0
- {pintest_cli-0.3.6 → pintest_cli-0.3.8}/pintest/push_cache.py +0 -0
- {pintest_cli-0.3.6 → pintest_cli-0.3.8}/pintest/range_set.py +0 -0
- {pintest_cli-0.3.6 → pintest_cli-0.3.8}/pintest/test_mapping_db_v2.py +0 -0
- {pintest_cli-0.3.6 → pintest_cli-0.3.8}/pintest/update_mapping.py +0 -0
- {pintest_cli-0.3.6 → pintest_cli-0.3.8}/pintest_cli.egg-info/SOURCES.txt +0 -0
- {pintest_cli-0.3.6 → pintest_cli-0.3.8}/pintest_cli.egg-info/dependency_links.txt +0 -0
- {pintest_cli-0.3.6 → pintest_cli-0.3.8}/pintest_cli.egg-info/entry_points.txt +0 -0
- {pintest_cli-0.3.6 → pintest_cli-0.3.8}/pintest_cli.egg-info/requires.txt +0 -0
- {pintest_cli-0.3.6 → pintest_cli-0.3.8}/pintest_cli.egg-info/top_level.txt +0 -0
- {pintest_cli-0.3.6 → pintest_cli-0.3.8}/setup.cfg +0 -0
- {pintest_cli-0.3.6 → pintest_cli-0.3.8}/tests/__init__.py +0 -0
- {pintest_cli-0.3.6 → pintest_cli-0.3.8}/tests/test_git_diff_parser.py +0 -0
- {pintest_cli-0.3.6 → pintest_cli-0.3.8}/tests/test_new_feature.py +0 -0
- {pintest_cli-0.3.6 → pintest_cli-0.3.8}/tests/test_range_set.py +0 -0
|
@@ -64,7 +64,16 @@ def run_test_chunk_with_mapping(
|
|
|
64
64
|
]
|
|
65
65
|
if pytest_args:
|
|
66
66
|
cmd.extend(pytest_args)
|
|
67
|
-
|
|
67
|
+
|
|
68
|
+
if len(test_names) > 1000:
|
|
69
|
+
pintest_dir = repo_root / ".pintest"
|
|
70
|
+
pintest_dir.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
select_file = pintest_dir / "xdist_select.json"
|
|
72
|
+
with open(select_file, "w") as f:
|
|
73
|
+
json.dump(test_names, f)
|
|
74
|
+
cmd.extend(["--pintest-select-file", str(select_file)])
|
|
75
|
+
else:
|
|
76
|
+
cmd.extend(test_names)
|
|
68
77
|
|
|
69
78
|
print(f" Running: {' '.join(cmd[:6])} ... {len(test_names)} tests", flush=True)
|
|
70
79
|
|
|
@@ -196,7 +205,11 @@ def build_mapping_iteratively(
|
|
|
196
205
|
total_tests = len(remaining_list)
|
|
197
206
|
chunk_size = 1000 # Safe threshold to avoid "Argument list too long"
|
|
198
207
|
|
|
199
|
-
|
|
208
|
+
has_xdist = pytest_args and any(
|
|
209
|
+
arg == '-n' or arg.startswith('-n=') or arg == '--numprocesses' or arg.startswith('--numprocesses=')
|
|
210
|
+
for arg in pytest_args
|
|
211
|
+
)
|
|
212
|
+
needs_chunking = total_tests > chunk_size and not has_xdist
|
|
200
213
|
|
|
201
214
|
print(f"\n📊 Test Statistics:")
|
|
202
215
|
print(f" Unmapped tests: {total_tests}")
|
|
@@ -322,7 +335,10 @@ def main():
|
|
|
322
335
|
help="Verbose output"
|
|
323
336
|
)
|
|
324
337
|
|
|
325
|
-
args = parser.
|
|
338
|
+
args, unknown = parser.parse_known_args()
|
|
339
|
+
pytest_args = unknown
|
|
340
|
+
if pytest_args and pytest_args[0] == '--':
|
|
341
|
+
pytest_args = pytest_args[1:]
|
|
326
342
|
|
|
327
343
|
repo_root = args.repo_root.resolve()
|
|
328
344
|
if args.mapping_db:
|
|
@@ -343,7 +359,8 @@ def main():
|
|
|
343
359
|
repo_root,
|
|
344
360
|
mapping_db,
|
|
345
361
|
args.test_dir,
|
|
346
|
-
args.verbose
|
|
362
|
+
args.verbose,
|
|
363
|
+
pytest_args=pytest_args
|
|
347
364
|
)
|
|
348
365
|
|
|
349
366
|
|
|
@@ -141,8 +141,12 @@ class PintestRunner:
|
|
|
141
141
|
return 0
|
|
142
142
|
|
|
143
143
|
test_list = sorted(test_selection)
|
|
144
|
-
chunk_size =
|
|
145
|
-
|
|
144
|
+
chunk_size = 1000
|
|
145
|
+
has_xdist = pytest_args and any(
|
|
146
|
+
arg == '-n' or arg.startswith('-n=') or arg == '--numprocesses' or arg.startswith('--numprocesses=')
|
|
147
|
+
for arg in pytest_args
|
|
148
|
+
)
|
|
149
|
+
needs_chunking = len(test_list) > chunk_size and not has_xdist
|
|
146
150
|
|
|
147
151
|
# Base pytest command
|
|
148
152
|
base_cmd = [sys.executable, "-m", "pytest", "-p", "pintest.pytest_plugin"]
|
|
@@ -182,7 +186,16 @@ class PintestRunner:
|
|
|
182
186
|
return final_returncode
|
|
183
187
|
else:
|
|
184
188
|
print(f"Running {len(test_selection)} selected test(s)...")
|
|
185
|
-
|
|
189
|
+
if len(test_list) > chunk_size:
|
|
190
|
+
import json
|
|
191
|
+
pintest_dir = self.repo_root / ".pintest"
|
|
192
|
+
pintest_dir.mkdir(parents=True, exist_ok=True)
|
|
193
|
+
select_file = pintest_dir / "xdist_select.json"
|
|
194
|
+
with open(select_file, "w") as f:
|
|
195
|
+
json.dump(test_list, f)
|
|
196
|
+
cmd = base_cmd + ["--pintest-select-file", str(select_file)]
|
|
197
|
+
else:
|
|
198
|
+
cmd = base_cmd + test_list
|
|
186
199
|
result = subprocess.run(cmd, cwd=self.repo_root)
|
|
187
200
|
return result.returncode
|
|
188
201
|
|
|
@@ -68,6 +68,14 @@ class CloudMappingDB:
|
|
|
68
68
|
json=payload,
|
|
69
69
|
timeout=30,
|
|
70
70
|
)
|
|
71
|
+
if resp.status_code == 402:
|
|
72
|
+
try:
|
|
73
|
+
err_data = resp.json()
|
|
74
|
+
msg = err_data.get("detail", "Free tier quota exceeded.")
|
|
75
|
+
except Exception:
|
|
76
|
+
msg = "Free tier quota exceeded."
|
|
77
|
+
print(f"⚠️ Pintest Cloud free tier limit reached ({msg}). Please upgrade at https://pintest.dev/pricing", file=sys.stderr)
|
|
78
|
+
return set(), [c["file"] for c in changes]
|
|
71
79
|
resp.raise_for_status()
|
|
72
80
|
except requests.Timeout:
|
|
73
81
|
print("⚠️ Pintest API timed out — falling back to running all tests", file=sys.stderr)
|
|
@@ -213,6 +221,15 @@ class CloudMappingDB:
|
|
|
213
221
|
json=payload,
|
|
214
222
|
timeout=60,
|
|
215
223
|
)
|
|
224
|
+
if resp.status_code == 402:
|
|
225
|
+
try:
|
|
226
|
+
err_data = resp.json()
|
|
227
|
+
msg = err_data.get("detail", "Free tier quota exceeded.")
|
|
228
|
+
except Exception:
|
|
229
|
+
msg = "Free tier quota exceeded."
|
|
230
|
+
raise requests.RequestException(
|
|
231
|
+
f"Pintest Cloud free tier limit reached ({msg}). Please upgrade your plan at https://pintest.dev/pricing to sync large repositories."
|
|
232
|
+
)
|
|
216
233
|
resp.raise_for_status()
|
|
217
234
|
push_cache.batch_upsert(self._branch, chunk_data)
|
|
218
235
|
data = resp.json()
|
|
@@ -260,6 +277,9 @@ class CloudMappingDB:
|
|
|
260
277
|
f"{self._api}/api/v1/repos/{self._repo_id}/tests",
|
|
261
278
|
timeout=10,
|
|
262
279
|
)
|
|
280
|
+
if resp.status_code == 402:
|
|
281
|
+
print(f"⚠️ Pintest Cloud free tier limit reached. Please upgrade at https://pintest.dev/pricing", file=sys.stderr)
|
|
282
|
+
return set()
|
|
263
283
|
resp.raise_for_status()
|
|
264
284
|
return set(resp.json())
|
|
265
285
|
except Exception as e:
|
|
@@ -285,6 +305,8 @@ class CloudMappingDB:
|
|
|
285
305
|
f"{self._api}/api/v1/repos/{self._repo_id}/stats",
|
|
286
306
|
timeout=10,
|
|
287
307
|
)
|
|
308
|
+
if resp.status_code == 402:
|
|
309
|
+
return {"total_tests": "Quota Exceeded", "files_covered": "Quota Exceeded"}
|
|
288
310
|
resp.raise_for_status()
|
|
289
311
|
return resp.json()
|
|
290
312
|
except Exception:
|
|
@@ -168,10 +168,9 @@ def strip_test_parameters(test_name: str) -> str:
|
|
|
168
168
|
Returns:
|
|
169
169
|
Test name without parameters
|
|
170
170
|
"""
|
|
171
|
-
#
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
return test_name[:bracket_idx]
|
|
171
|
+
# Strip parametrization by splitting on the first '['
|
|
172
|
+
if "[" in test_name and test_name.endswith("]"):
|
|
173
|
+
return test_name.split("[")[0]
|
|
175
174
|
return test_name
|
|
176
175
|
|
|
177
176
|
|
|
@@ -398,7 +397,8 @@ def run_test_chunk_with_mapping_update(
|
|
|
398
397
|
repo_root: Path,
|
|
399
398
|
test_names: list,
|
|
400
399
|
mapping_db_path: Path,
|
|
401
|
-
verbose: bool = False
|
|
400
|
+
verbose: bool = False,
|
|
401
|
+
pytest_args: list = None
|
|
402
402
|
) -> tuple[Set[str], Set[str], bool]:
|
|
403
403
|
"""
|
|
404
404
|
Run tests with coverage and update the mapping database.
|
|
@@ -433,7 +433,20 @@ def run_test_chunk_with_mapping_update(
|
|
|
433
433
|
"--cov-report=",
|
|
434
434
|
"-v", # Always verbose so users see test failures
|
|
435
435
|
"--tb=short", # Short traceback format
|
|
436
|
-
]
|
|
436
|
+
]
|
|
437
|
+
if pytest_args:
|
|
438
|
+
cmd.extend(pytest_args)
|
|
439
|
+
|
|
440
|
+
if len(normalized_tests) > 1000:
|
|
441
|
+
import json
|
|
442
|
+
pintest_dir = repo_root / ".pintest"
|
|
443
|
+
pintest_dir.mkdir(parents=True, exist_ok=True)
|
|
444
|
+
select_file = pintest_dir / "xdist_select.json"
|
|
445
|
+
with open(select_file, "w") as f:
|
|
446
|
+
json.dump(normalized_tests, f)
|
|
447
|
+
cmd.extend(["--pintest-select-file", str(select_file)])
|
|
448
|
+
else:
|
|
449
|
+
cmd.extend(normalized_tests)
|
|
437
450
|
|
|
438
451
|
result = subprocess.run(
|
|
439
452
|
cmd,
|
|
@@ -602,7 +615,8 @@ def run_unmapped_tests_iteratively(
|
|
|
602
615
|
unmapped_tests: Set[str],
|
|
603
616
|
mapping_db_path: Path,
|
|
604
617
|
verbose: bool = False,
|
|
605
|
-
chunk_size: int = 1000
|
|
618
|
+
chunk_size: int = 1000,
|
|
619
|
+
pytest_args: list = None
|
|
606
620
|
) -> tuple[bool, int]:
|
|
607
621
|
"""
|
|
608
622
|
Run unmapped tests with automatic chunking to avoid OS argument limits.
|
|
@@ -625,7 +639,11 @@ def run_unmapped_tests_iteratively(
|
|
|
625
639
|
|
|
626
640
|
# Determine if we need to chunk (OS has ARG_MAX limit)
|
|
627
641
|
# Safe threshold: 1000 tests per chunk to avoid "Argument list too long"
|
|
628
|
-
|
|
642
|
+
has_xdist = pytest_args and any(
|
|
643
|
+
arg == '-n' or arg.startswith('-n=') or arg == '--numprocesses' or arg.startswith('--numprocesses=')
|
|
644
|
+
for arg in pytest_args
|
|
645
|
+
)
|
|
646
|
+
needs_chunking = total_tests > chunk_size and not has_xdist
|
|
629
647
|
|
|
630
648
|
print(f"\n{'='*80}", flush=True)
|
|
631
649
|
print(f"Building coverage mapping for {total_tests} unmapped test(s)", flush=True)
|
|
@@ -668,7 +686,8 @@ def run_unmapped_tests_iteratively(
|
|
|
668
686
|
repo_root,
|
|
669
687
|
chunk,
|
|
670
688
|
mapping_db_path,
|
|
671
|
-
verbose
|
|
689
|
+
verbose,
|
|
690
|
+
pytest_args
|
|
672
691
|
)
|
|
673
692
|
|
|
674
693
|
count_run += len(chunk)
|
|
@@ -692,7 +711,8 @@ def run_unmapped_tests_iteratively(
|
|
|
692
711
|
repo_root,
|
|
693
712
|
test_list,
|
|
694
713
|
mapping_db_path,
|
|
695
|
-
verbose
|
|
714
|
+
verbose,
|
|
715
|
+
pytest_args
|
|
696
716
|
)
|
|
697
717
|
|
|
698
718
|
count_run = total_tests
|
|
@@ -1038,8 +1058,17 @@ def run_tests_with_coverage(
|
|
|
1038
1058
|
cmd.extend(pytest_args)
|
|
1039
1059
|
|
|
1040
1060
|
# Add test files and node IDs
|
|
1041
|
-
|
|
1042
|
-
|
|
1061
|
+
all_tests = sorted(test_files) + sorted(test_node_ids)
|
|
1062
|
+
if len(all_tests) > 1000:
|
|
1063
|
+
import json
|
|
1064
|
+
pintest_dir = repo_root / ".pintest"
|
|
1065
|
+
pintest_dir.mkdir(parents=True, exist_ok=True)
|
|
1066
|
+
select_file = pintest_dir / "xdist_select.json"
|
|
1067
|
+
with open(select_file, "w") as f:
|
|
1068
|
+
json.dump(all_tests, f)
|
|
1069
|
+
cmd.extend(["--pintest-select-file", str(select_file)])
|
|
1070
|
+
else:
|
|
1071
|
+
cmd.extend(all_tests)
|
|
1043
1072
|
|
|
1044
1073
|
if verbose:
|
|
1045
1074
|
print(f"\n🧪 Running command: {' '.join(cmd)}")
|
|
@@ -1193,7 +1222,8 @@ def main():
|
|
|
1193
1222
|
repo_root,
|
|
1194
1223
|
mapping_db,
|
|
1195
1224
|
args.test_dir,
|
|
1196
|
-
args.verbose
|
|
1225
|
+
args.verbose,
|
|
1226
|
+
pytest_args=args.pytest_args
|
|
1197
1227
|
)
|
|
1198
1228
|
|
|
1199
1229
|
if result != 0:
|
|
@@ -1277,7 +1307,8 @@ def main():
|
|
|
1277
1307
|
repo_root,
|
|
1278
1308
|
unmapped_tests,
|
|
1279
1309
|
mapping_db,
|
|
1280
|
-
args.verbose
|
|
1310
|
+
args.verbose,
|
|
1311
|
+
pytest_args=args.pytest_args
|
|
1281
1312
|
)
|
|
1282
1313
|
|
|
1283
1314
|
if not unmapped_passed:
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Pytest plugin for Pintest (smart test selection and duration tracking)."""
|
|
2
|
+
import json
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
# Stores test_node_id -> duration_ms
|
|
7
|
+
test_durations = {}
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def pytest_addoption(parser):
|
|
11
|
+
"""Add Pintest command line options."""
|
|
12
|
+
group = parser.getgroup("pintest")
|
|
13
|
+
group.addoption(
|
|
14
|
+
"--pintest",
|
|
15
|
+
action="store_true",
|
|
16
|
+
default=False,
|
|
17
|
+
help="Enable Pintest smart test selection (run only affected tests)",
|
|
18
|
+
)
|
|
19
|
+
group.addoption(
|
|
20
|
+
"--pintest-base",
|
|
21
|
+
action="store",
|
|
22
|
+
default="master",
|
|
23
|
+
help="Base branch for Pintest git diffing (default: master)",
|
|
24
|
+
)
|
|
25
|
+
group.addoption(
|
|
26
|
+
"--pintest-select-file",
|
|
27
|
+
action="store",
|
|
28
|
+
default=None,
|
|
29
|
+
help="Path to a JSON file containing specific tests to run (used for xdist large test lists)",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def pytest_collection_modifyitems(session, config, items):
|
|
34
|
+
"""Filter collected tests if --pintest or --pintest-select-file is enabled."""
|
|
35
|
+
select_file = config.getoption("--pintest-select-file")
|
|
36
|
+
if not config.getoption("--pintest") and not select_file:
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
from pintest.config import Config
|
|
40
|
+
from pintest.cloud_mapping_db import CloudMappingDB
|
|
41
|
+
from pintest.test_mapping_db_v2 import TestMappingDBV2
|
|
42
|
+
from pintest.cli import PintestRunner
|
|
43
|
+
|
|
44
|
+
repo_root = Path(config.rootdir).resolve()
|
|
45
|
+
affected_tests = set()
|
|
46
|
+
mapping_db_obj = None
|
|
47
|
+
|
|
48
|
+
if select_file:
|
|
49
|
+
try:
|
|
50
|
+
with open(select_file, "r") as f:
|
|
51
|
+
affected_tests = set(json.load(f))
|
|
52
|
+
except Exception as e:
|
|
53
|
+
print(f"❌ Error loading pintest select file {select_file}: {e}", file=sys.stderr)
|
|
54
|
+
return
|
|
55
|
+
else:
|
|
56
|
+
# ── Mapping source detection ───────────────────────────────────────────
|
|
57
|
+
# 1. Try Cloud Mode
|
|
58
|
+
cfg = Config.load()
|
|
59
|
+
use_cloud = cfg.is_cloud_enabled and bool(cfg.cloud.repo_id)
|
|
60
|
+
|
|
61
|
+
if use_cloud:
|
|
62
|
+
print(f"☁️ Mapping Service: Pintest Cloud (repo {cfg.cloud.repo_id[:8]}...)", file=sys.stderr)
|
|
63
|
+
mapping_db_obj = CloudMappingDB(cfg.cloud)
|
|
64
|
+
else:
|
|
65
|
+
# 2. Try Local V2 Mapping DB
|
|
66
|
+
mapping_db = repo_root / ".pintest" / "test_mapping.db"
|
|
67
|
+
legacy_db = repo_root / ".test_mapping.db"
|
|
68
|
+
if not mapping_db.exists() and legacy_db.exists():
|
|
69
|
+
mapping_db.parent.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
try:
|
|
71
|
+
legacy_db.rename(mapping_db)
|
|
72
|
+
except Exception:
|
|
73
|
+
mapping_db = legacy_db
|
|
74
|
+
|
|
75
|
+
if not mapping_db.exists():
|
|
76
|
+
print("🏗️ No local mapping DB found. Initializing build...", file=sys.stderr)
|
|
77
|
+
from pintest.build_mapping_iterative import build_mapping_iteratively
|
|
78
|
+
|
|
79
|
+
default_test_dir = "tests"
|
|
80
|
+
if getattr(cfg.cloud, "test_dir", None):
|
|
81
|
+
default_test_dir = cfg.cloud.test_dir
|
|
82
|
+
|
|
83
|
+
exit_code = build_mapping_iteratively(
|
|
84
|
+
repo_root,
|
|
85
|
+
mapping_db,
|
|
86
|
+
test_dir=default_test_dir,
|
|
87
|
+
verbose=False
|
|
88
|
+
)
|
|
89
|
+
if exit_code != 0:
|
|
90
|
+
print("❌ Failed to build mapping DB.", file=sys.stderr)
|
|
91
|
+
sys.exit(exit_code)
|
|
92
|
+
|
|
93
|
+
print(f"🖥️ Local mode: {mapping_db}", file=sys.stderr)
|
|
94
|
+
mapping_db_obj = TestMappingDBV2(mapping_db)
|
|
95
|
+
mapping_db_obj.connect()
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
base_branch = config.getoption("--pintest-base")
|
|
99
|
+
if base_branch == "master" and cfg.is_cloud_enabled and getattr(cfg.cloud, "branch", None):
|
|
100
|
+
base_branch = cfg.cloud.branch
|
|
101
|
+
|
|
102
|
+
# Initialize runner
|
|
103
|
+
runner = PintestRunner(
|
|
104
|
+
repo_root,
|
|
105
|
+
mapping_db=mapping_db_obj,
|
|
106
|
+
verbose=False
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# Find affected tests
|
|
110
|
+
affected_tests = runner.find_affected_tests(base_branch)
|
|
111
|
+
|
|
112
|
+
# Unmapped tests discovery (Cloud mode only)
|
|
113
|
+
if use_cloud:
|
|
114
|
+
from pintest.pre_commit_hook import find_unmapped_tests
|
|
115
|
+
unmapped = find_unmapped_tests(
|
|
116
|
+
repo_root,
|
|
117
|
+
mapping_db_obj,
|
|
118
|
+
verbose=False
|
|
119
|
+
)
|
|
120
|
+
if unmapped:
|
|
121
|
+
affected_tests.update(unmapped)
|
|
122
|
+
finally:
|
|
123
|
+
if mapping_db_obj and hasattr(mapping_db_obj, 'close'):
|
|
124
|
+
mapping_db_obj.close()
|
|
125
|
+
|
|
126
|
+
# Filter items in-place
|
|
127
|
+
selected_items = []
|
|
128
|
+
deselected_items = []
|
|
129
|
+
|
|
130
|
+
for item in items:
|
|
131
|
+
# Check exact nodeid match
|
|
132
|
+
match = item.nodeid in affected_tests
|
|
133
|
+
|
|
134
|
+
# Check file match (nodeid prefix before ::)
|
|
135
|
+
if not match:
|
|
136
|
+
file_part = item.nodeid.split("::")[0]
|
|
137
|
+
if file_part in affected_tests:
|
|
138
|
+
match = True
|
|
139
|
+
|
|
140
|
+
# Check fspath/path relative match
|
|
141
|
+
if not match:
|
|
142
|
+
try:
|
|
143
|
+
item_path = getattr(item, 'path', None)
|
|
144
|
+
if not item_path and hasattr(item, 'fspath'):
|
|
145
|
+
item_path = Path(item.fspath)
|
|
146
|
+
if item_path:
|
|
147
|
+
rel_path = str(item_path.relative_to(repo_root))
|
|
148
|
+
if rel_path in affected_tests:
|
|
149
|
+
match = True
|
|
150
|
+
except Exception:
|
|
151
|
+
pass
|
|
152
|
+
|
|
153
|
+
if match:
|
|
154
|
+
selected_items.append(item)
|
|
155
|
+
else:
|
|
156
|
+
deselected_items.append(item)
|
|
157
|
+
|
|
158
|
+
items[:] = selected_items
|
|
159
|
+
if deselected_items:
|
|
160
|
+
config.hook.pytest_deselected(items=deselected_items)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def pytest_runtest_makereport(item, call):
|
|
164
|
+
"""Record test execution time during the 'call' phase."""
|
|
165
|
+
if call.when == "call":
|
|
166
|
+
# call.duration is a float representing exact seconds
|
|
167
|
+
test_durations[item.nodeid] = int(call.duration * 1000)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def pytest_sessionfinish(session, exitstatus):
|
|
171
|
+
"""Dump durations to a temporary file for the CLI push phase."""
|
|
172
|
+
try:
|
|
173
|
+
pintest_dir = Path(session.config.rootdir) / ".pintest"
|
|
174
|
+
pintest_dir.mkdir(parents=True, exist_ok=True)
|
|
175
|
+
out_file = pintest_dir / "durations.json"
|
|
176
|
+
|
|
177
|
+
# Load existing durations if present to merge/upsert them
|
|
178
|
+
existing_durations = {}
|
|
179
|
+
if out_file.exists():
|
|
180
|
+
try:
|
|
181
|
+
with open(out_file, "r") as f:
|
|
182
|
+
existing_durations = json.load(f)
|
|
183
|
+
except Exception:
|
|
184
|
+
pass
|
|
185
|
+
|
|
186
|
+
existing_durations.update(test_durations)
|
|
187
|
+
|
|
188
|
+
with open(out_file, "w") as f:
|
|
189
|
+
json.dump(existing_durations, f)
|
|
190
|
+
except Exception as e:
|
|
191
|
+
# Silently pass if we cannot write to rootdir
|
|
192
|
+
pass
|
|
@@ -5,7 +5,7 @@ with open("README.md", "r", encoding="utf-8") as fh:
|
|
|
5
5
|
|
|
6
6
|
setup(
|
|
7
7
|
name="pintest-cli",
|
|
8
|
-
version="0.3.
|
|
8
|
+
version="0.3.8",
|
|
9
9
|
description="Run only the tests affected by your code changes.",
|
|
10
10
|
long_description=long_description,
|
|
11
11
|
long_description_content_type="text/markdown",
|
|
@@ -163,8 +163,8 @@ def test_pintest_chunking_and_fail_fast(dummy_repo):
|
|
|
163
163
|
from pintest.cli import PintestRunner
|
|
164
164
|
runner = PintestRunner(dummy_repo, verbose=True)
|
|
165
165
|
|
|
166
|
-
# Generate
|
|
167
|
-
dummy_tests = {f"tests/test_math.py::test_dummy_{i}" for i in range(
|
|
166
|
+
# Generate 1005 dummy test names
|
|
167
|
+
dummy_tests = {f"tests/test_math.py::test_dummy_{i}" for i in range(1005)}
|
|
168
168
|
|
|
169
169
|
# Run with fail-fast (-x)
|
|
170
170
|
# Since these dummy tests don't exist, pytest will fail on the first chunk.
|
|
@@ -1,178 +0,0 @@
|
|
|
1
|
-
"""Pytest plugin for Pintest (smart test selection and duration tracking)."""
|
|
2
|
-
import json
|
|
3
|
-
import sys
|
|
4
|
-
from pathlib import Path
|
|
5
|
-
|
|
6
|
-
# Stores test_node_id -> duration_ms
|
|
7
|
-
test_durations = {}
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
def pytest_addoption(parser):
|
|
11
|
-
"""Add Pintest command line options."""
|
|
12
|
-
group = parser.getgroup("pintest")
|
|
13
|
-
group.addoption(
|
|
14
|
-
"--pintest",
|
|
15
|
-
action="store_true",
|
|
16
|
-
default=False,
|
|
17
|
-
help="Enable Pintest smart test selection (run only affected tests)",
|
|
18
|
-
)
|
|
19
|
-
group.addoption(
|
|
20
|
-
"--pintest-base",
|
|
21
|
-
action="store",
|
|
22
|
-
default="master",
|
|
23
|
-
help="Base branch for Pintest git diffing (default: master)",
|
|
24
|
-
)
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def pytest_collection_modifyitems(session, config, items):
|
|
28
|
-
"""Filter collected tests if --pintest is enabled."""
|
|
29
|
-
if not config.getoption("--pintest"):
|
|
30
|
-
return
|
|
31
|
-
|
|
32
|
-
from pintest.config import Config
|
|
33
|
-
from pintest.cloud_mapping_db import CloudMappingDB
|
|
34
|
-
from pintest.test_mapping_db_v2 import TestMappingDBV2
|
|
35
|
-
from pintest.cli import PintestRunner
|
|
36
|
-
|
|
37
|
-
repo_root = Path(config.rootdir).resolve()
|
|
38
|
-
base_branch = config.getoption("--pintest-base")
|
|
39
|
-
|
|
40
|
-
# ── Mapping source detection ───────────────────────────────────────────
|
|
41
|
-
mapping_db_obj = None
|
|
42
|
-
|
|
43
|
-
# 1. Try Cloud Mode
|
|
44
|
-
cfg = Config.load()
|
|
45
|
-
use_cloud = cfg.is_cloud_enabled and bool(cfg.cloud.repo_id)
|
|
46
|
-
|
|
47
|
-
if use_cloud:
|
|
48
|
-
print(f"☁️ Mapping Service: Pintest Cloud (repo {cfg.cloud.repo_id[:8]}...)", file=sys.stderr)
|
|
49
|
-
mapping_db_obj = CloudMappingDB(cfg.cloud)
|
|
50
|
-
else:
|
|
51
|
-
# 2. Try Local V2 Mapping DB
|
|
52
|
-
mapping_db = repo_root / ".pintest" / "test_mapping.db"
|
|
53
|
-
legacy_db = repo_root / ".test_mapping.db"
|
|
54
|
-
if not mapping_db.exists() and legacy_db.exists():
|
|
55
|
-
mapping_db.parent.mkdir(parents=True, exist_ok=True)
|
|
56
|
-
try:
|
|
57
|
-
legacy_db.rename(mapping_db)
|
|
58
|
-
except Exception:
|
|
59
|
-
mapping_db = legacy_db
|
|
60
|
-
|
|
61
|
-
if not mapping_db.exists():
|
|
62
|
-
print("🏗️ No local mapping DB found. Initializing build...", file=sys.stderr)
|
|
63
|
-
from pintest.build_mapping_iterative import build_mapping_iteratively
|
|
64
|
-
|
|
65
|
-
default_test_dir = "tests"
|
|
66
|
-
if getattr(cfg.cloud, "test_dir", None):
|
|
67
|
-
default_test_dir = cfg.cloud.test_dir
|
|
68
|
-
|
|
69
|
-
exit_code = build_mapping_iteratively(
|
|
70
|
-
repo_root,
|
|
71
|
-
mapping_db,
|
|
72
|
-
test_dir=default_test_dir,
|
|
73
|
-
verbose=False
|
|
74
|
-
)
|
|
75
|
-
if exit_code != 0:
|
|
76
|
-
print("❌ Failed to build mapping DB.", file=sys.stderr)
|
|
77
|
-
sys.exit(exit_code)
|
|
78
|
-
|
|
79
|
-
print(f"🖥️ Local mode: {mapping_db}", file=sys.stderr)
|
|
80
|
-
mapping_db_obj = TestMappingDBV2(mapping_db)
|
|
81
|
-
mapping_db_obj.connect()
|
|
82
|
-
|
|
83
|
-
try:
|
|
84
|
-
if base_branch == "master" and cfg.is_cloud_enabled and getattr(cfg.cloud, "branch", None):
|
|
85
|
-
base_branch = cfg.cloud.branch
|
|
86
|
-
|
|
87
|
-
# Initialize runner
|
|
88
|
-
runner = PintestRunner(
|
|
89
|
-
repo_root,
|
|
90
|
-
mapping_db=mapping_db_obj,
|
|
91
|
-
verbose=False
|
|
92
|
-
)
|
|
93
|
-
|
|
94
|
-
# Find affected tests
|
|
95
|
-
affected_tests = runner.find_affected_tests(base_branch)
|
|
96
|
-
|
|
97
|
-
# Unmapped tests discovery (Cloud mode only)
|
|
98
|
-
if use_cloud:
|
|
99
|
-
from pintest.pre_commit_hook import find_unmapped_tests
|
|
100
|
-
unmapped = find_unmapped_tests(
|
|
101
|
-
repo_root,
|
|
102
|
-
mapping_db_obj,
|
|
103
|
-
verbose=False
|
|
104
|
-
)
|
|
105
|
-
if unmapped:
|
|
106
|
-
affected_tests.update(unmapped)
|
|
107
|
-
|
|
108
|
-
# Filter items in-place
|
|
109
|
-
selected_items = []
|
|
110
|
-
deselected_items = []
|
|
111
|
-
|
|
112
|
-
for item in items:
|
|
113
|
-
# Check exact nodeid match
|
|
114
|
-
match = item.nodeid in affected_tests
|
|
115
|
-
|
|
116
|
-
# Check file match (nodeid prefix before ::)
|
|
117
|
-
if not match:
|
|
118
|
-
file_part = item.nodeid.split("::")[0]
|
|
119
|
-
if file_part in affected_tests:
|
|
120
|
-
match = True
|
|
121
|
-
|
|
122
|
-
# Check fspath/path relative match
|
|
123
|
-
if not match:
|
|
124
|
-
try:
|
|
125
|
-
item_path = getattr(item, 'path', None)
|
|
126
|
-
if not item_path and hasattr(item, 'fspath'):
|
|
127
|
-
item_path = Path(item.fspath)
|
|
128
|
-
if item_path:
|
|
129
|
-
rel_path = str(item_path.relative_to(repo_root))
|
|
130
|
-
if rel_path in affected_tests:
|
|
131
|
-
match = True
|
|
132
|
-
except Exception:
|
|
133
|
-
pass
|
|
134
|
-
|
|
135
|
-
if match:
|
|
136
|
-
selected_items.append(item)
|
|
137
|
-
else:
|
|
138
|
-
deselected_items.append(item)
|
|
139
|
-
|
|
140
|
-
items[:] = selected_items
|
|
141
|
-
if deselected_items:
|
|
142
|
-
config.hook.pytest_deselected(items=deselected_items)
|
|
143
|
-
|
|
144
|
-
finally:
|
|
145
|
-
if mapping_db_obj and hasattr(mapping_db_obj, 'close'):
|
|
146
|
-
mapping_db_obj.close()
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
def pytest_runtest_makereport(item, call):
|
|
150
|
-
"""Record test execution time during the 'call' phase."""
|
|
151
|
-
if call.when == "call":
|
|
152
|
-
# call.duration is a float representing exact seconds
|
|
153
|
-
test_durations[item.nodeid] = int(call.duration * 1000)
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
def pytest_sessionfinish(session, exitstatus):
|
|
157
|
-
"""Dump durations to a temporary file for the CLI push phase."""
|
|
158
|
-
try:
|
|
159
|
-
pintest_dir = Path(session.config.rootdir) / ".pintest"
|
|
160
|
-
pintest_dir.mkdir(parents=True, exist_ok=True)
|
|
161
|
-
out_file = pintest_dir / "durations.json"
|
|
162
|
-
|
|
163
|
-
# Load existing durations if present to merge/upsert them
|
|
164
|
-
existing_durations = {}
|
|
165
|
-
if out_file.exists():
|
|
166
|
-
try:
|
|
167
|
-
with open(out_file, "r") as f:
|
|
168
|
-
existing_durations = json.load(f)
|
|
169
|
-
except Exception:
|
|
170
|
-
pass
|
|
171
|
-
|
|
172
|
-
existing_durations.update(test_durations)
|
|
173
|
-
|
|
174
|
-
with open(out_file, "w") as f:
|
|
175
|
-
json.dump(existing_durations, f)
|
|
176
|
-
except Exception as e:
|
|
177
|
-
# Silently pass if we cannot write to rootdir
|
|
178
|
-
pass
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|