pintest-cli 0.3.5__tar.gz → 0.3.7__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.5 → pintest_cli-0.3.7}/PKG-INFO +1 -1
- {pintest_cli-0.3.5 → pintest_cli-0.3.7}/pintest/build_mapping_iterative.py +21 -4
- {pintest_cli-0.3.5 → pintest_cli-0.3.7}/pintest/cli.py +18 -19
- {pintest_cli-0.3.5 → pintest_cli-0.3.7}/pintest/cloud_mapping_db.py +22 -0
- {pintest_cli-0.3.5 → pintest_cli-0.3.7}/pintest/pre_commit_hook.py +45 -14
- pintest_cli-0.3.7/pintest/pytest_plugin.py +192 -0
- {pintest_cli-0.3.5 → pintest_cli-0.3.7}/pintest_cli.egg-info/PKG-INFO +1 -1
- {pintest_cli-0.3.5 → pintest_cli-0.3.7}/setup.py +1 -1
- pintest_cli-0.3.5/pintest/pytest_plugin.py +0 -178
- {pintest_cli-0.3.5 → pintest_cli-0.3.7}/README.md +0 -0
- {pintest_cli-0.3.5 → pintest_cli-0.3.7}/pintest/__init__.py +0 -0
- {pintest_cli-0.3.5 → pintest_cli-0.3.7}/pintest/config.py +0 -0
- {pintest_cli-0.3.5 → pintest_cli-0.3.7}/pintest/coverage_mapper.py +0 -0
- {pintest_cli-0.3.5 → pintest_cli-0.3.7}/pintest/git_diff_parser.py +0 -0
- {pintest_cli-0.3.5 → pintest_cli-0.3.7}/pintest/post_commit_hook.py +0 -0
- {pintest_cli-0.3.5 → pintest_cli-0.3.7}/pintest/push_cache.py +0 -0
- {pintest_cli-0.3.5 → pintest_cli-0.3.7}/pintest/range_set.py +0 -0
- {pintest_cli-0.3.5 → pintest_cli-0.3.7}/pintest/test_mapping_db_v2.py +0 -0
- {pintest_cli-0.3.5 → pintest_cli-0.3.7}/pintest/update_mapping.py +0 -0
- {pintest_cli-0.3.5 → pintest_cli-0.3.7}/pintest_cli.egg-info/SOURCES.txt +0 -0
- {pintest_cli-0.3.5 → pintest_cli-0.3.7}/pintest_cli.egg-info/dependency_links.txt +0 -0
- {pintest_cli-0.3.5 → pintest_cli-0.3.7}/pintest_cli.egg-info/entry_points.txt +0 -0
- {pintest_cli-0.3.5 → pintest_cli-0.3.7}/pintest_cli.egg-info/requires.txt +0 -0
- {pintest_cli-0.3.5 → pintest_cli-0.3.7}/pintest_cli.egg-info/top_level.txt +0 -0
- {pintest_cli-0.3.5 → pintest_cli-0.3.7}/setup.cfg +0 -0
- {pintest_cli-0.3.5 → pintest_cli-0.3.7}/tests/__init__.py +0 -0
- {pintest_cli-0.3.5 → pintest_cli-0.3.7}/tests/test_cli_e2e.py +0 -0
- {pintest_cli-0.3.5 → pintest_cli-0.3.7}/tests/test_git_diff_parser.py +0 -0
- {pintest_cli-0.3.5 → pintest_cli-0.3.7}/tests/test_new_feature.py +0 -0
- {pintest_cli-0.3.5 → pintest_cli-0.3.7}/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
|
|
|
@@ -142,7 +142,11 @@ class PintestRunner:
|
|
|
142
142
|
|
|
143
143
|
test_list = sorted(test_selection)
|
|
144
144
|
chunk_size = 500
|
|
145
|
-
|
|
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
|
|
|
@@ -288,10 +301,8 @@ def cmd_run(args):
|
|
|
288
301
|
print("Exiting with error (run full test suite instead)", file=sys.stderr)
|
|
289
302
|
sys.exit(1)
|
|
290
303
|
|
|
291
|
-
# Remove '--' separator if present in
|
|
292
|
-
pytest_extra_args = args
|
|
293
|
-
if hasattr(args, 'unknown_args') and args.unknown_args:
|
|
294
|
-
pytest_extra_args.extend(args.unknown_args)
|
|
304
|
+
# Remove '--' separator if present in unknown_args
|
|
305
|
+
pytest_extra_args = getattr(args, 'unknown_args', [])
|
|
295
306
|
if pytest_extra_args and pytest_extra_args[0] == '--':
|
|
296
307
|
pytest_extra_args = pytest_extra_args[1:]
|
|
297
308
|
|
|
@@ -363,9 +374,7 @@ def cmd_build_mapping(args):
|
|
|
363
374
|
# Use args.test_dir if explicitly provided and not the default "tests", otherwise use config
|
|
364
375
|
final_test_dir = args.test_dir if args.test_dir != "tests" else default_test_dir
|
|
365
376
|
|
|
366
|
-
pytest_args = getattr(args, '
|
|
367
|
-
if hasattr(args, 'unknown_args') and args.unknown_args:
|
|
368
|
-
pytest_args.extend(args.unknown_args)
|
|
377
|
+
pytest_args = getattr(args, 'unknown_args', [])
|
|
369
378
|
if pytest_args and pytest_args[0] == '--':
|
|
370
379
|
pytest_args = pytest_args[1:]
|
|
371
380
|
|
|
@@ -658,11 +667,6 @@ Examples:
|
|
|
658
667
|
action="store_true",
|
|
659
668
|
help="Show detailed output"
|
|
660
669
|
)
|
|
661
|
-
run_parser.add_argument(
|
|
662
|
-
"pytest_args",
|
|
663
|
-
nargs=argparse.REMAINDER,
|
|
664
|
-
help="Additional arguments to pass to pytest (after --)"
|
|
665
|
-
)
|
|
666
670
|
run_parser.set_defaults(func=cmd_run)
|
|
667
671
|
|
|
668
672
|
# Update mapping command
|
|
@@ -732,11 +736,6 @@ Examples:
|
|
|
732
736
|
action="store_true",
|
|
733
737
|
help="Verbose output"
|
|
734
738
|
)
|
|
735
|
-
build_parser.add_argument(
|
|
736
|
-
"pytest_args",
|
|
737
|
-
nargs=argparse.REMAINDER,
|
|
738
|
-
help="Additional arguments to pass to pytest (after --, e.g. -n auto -k foo)"
|
|
739
|
-
)
|
|
740
739
|
build_parser.set_defaults(func=cmd_build_mapping)
|
|
741
740
|
|
|
742
741
|
# Login command
|
|
@@ -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.7",
|
|
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",
|
|
@@ -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
|
|
File without changes
|