pintest-cli 0.3.6__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.
Files changed (30) hide show
  1. {pintest_cli-0.3.6 → pintest_cli-0.3.7}/PKG-INFO +1 -1
  2. {pintest_cli-0.3.6 → pintest_cli-0.3.7}/pintest/build_mapping_iterative.py +21 -4
  3. {pintest_cli-0.3.6 → pintest_cli-0.3.7}/pintest/cli.py +15 -2
  4. {pintest_cli-0.3.6 → pintest_cli-0.3.7}/pintest/cloud_mapping_db.py +22 -0
  5. {pintest_cli-0.3.6 → pintest_cli-0.3.7}/pintest/pre_commit_hook.py +45 -14
  6. pintest_cli-0.3.7/pintest/pytest_plugin.py +192 -0
  7. {pintest_cli-0.3.6 → pintest_cli-0.3.7}/pintest_cli.egg-info/PKG-INFO +1 -1
  8. {pintest_cli-0.3.6 → pintest_cli-0.3.7}/setup.py +1 -1
  9. pintest_cli-0.3.6/pintest/pytest_plugin.py +0 -178
  10. {pintest_cli-0.3.6 → pintest_cli-0.3.7}/README.md +0 -0
  11. {pintest_cli-0.3.6 → pintest_cli-0.3.7}/pintest/__init__.py +0 -0
  12. {pintest_cli-0.3.6 → pintest_cli-0.3.7}/pintest/config.py +0 -0
  13. {pintest_cli-0.3.6 → pintest_cli-0.3.7}/pintest/coverage_mapper.py +0 -0
  14. {pintest_cli-0.3.6 → pintest_cli-0.3.7}/pintest/git_diff_parser.py +0 -0
  15. {pintest_cli-0.3.6 → pintest_cli-0.3.7}/pintest/post_commit_hook.py +0 -0
  16. {pintest_cli-0.3.6 → pintest_cli-0.3.7}/pintest/push_cache.py +0 -0
  17. {pintest_cli-0.3.6 → pintest_cli-0.3.7}/pintest/range_set.py +0 -0
  18. {pintest_cli-0.3.6 → pintest_cli-0.3.7}/pintest/test_mapping_db_v2.py +0 -0
  19. {pintest_cli-0.3.6 → pintest_cli-0.3.7}/pintest/update_mapping.py +0 -0
  20. {pintest_cli-0.3.6 → pintest_cli-0.3.7}/pintest_cli.egg-info/SOURCES.txt +0 -0
  21. {pintest_cli-0.3.6 → pintest_cli-0.3.7}/pintest_cli.egg-info/dependency_links.txt +0 -0
  22. {pintest_cli-0.3.6 → pintest_cli-0.3.7}/pintest_cli.egg-info/entry_points.txt +0 -0
  23. {pintest_cli-0.3.6 → pintest_cli-0.3.7}/pintest_cli.egg-info/requires.txt +0 -0
  24. {pintest_cli-0.3.6 → pintest_cli-0.3.7}/pintest_cli.egg-info/top_level.txt +0 -0
  25. {pintest_cli-0.3.6 → pintest_cli-0.3.7}/setup.cfg +0 -0
  26. {pintest_cli-0.3.6 → pintest_cli-0.3.7}/tests/__init__.py +0 -0
  27. {pintest_cli-0.3.6 → pintest_cli-0.3.7}/tests/test_cli_e2e.py +0 -0
  28. {pintest_cli-0.3.6 → pintest_cli-0.3.7}/tests/test_git_diff_parser.py +0 -0
  29. {pintest_cli-0.3.6 → pintest_cli-0.3.7}/tests/test_new_feature.py +0 -0
  30. {pintest_cli-0.3.6 → pintest_cli-0.3.7}/tests/test_range_set.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pintest-cli
3
- Version: 0.3.6
3
+ Version: 0.3.7
4
4
  Summary: Run only the tests affected by your code changes.
5
5
  Author: Pintest Contributors
6
6
  Classifier: Development Status :: 3 - Alpha
@@ -64,7 +64,16 @@ def run_test_chunk_with_mapping(
64
64
  ]
65
65
  if pytest_args:
66
66
  cmd.extend(pytest_args)
67
- cmd.extend(test_names)
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
- needs_chunking = total_tests > chunk_size
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.parse_args()
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
- needs_chunking = len(test_list) > chunk_size
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
- cmd = base_cmd + test_list
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
- # Find the last '[' that indicates parameter start
172
- bracket_idx = test_name.rfind('[')
173
- if bracket_idx != -1:
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
- ] + normalized_tests
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
- needs_chunking = total_tests > chunk_size
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
- cmd.extend(sorted(test_files))
1042
- cmd.extend(sorted(test_node_ids))
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pintest-cli
3
- Version: 0.3.6
3
+ Version: 0.3.7
4
4
  Summary: Run only the tests affected by your code changes.
5
5
  Author: Pintest Contributors
6
6
  Classifier: Development Status :: 3 - Alpha
@@ -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.6",
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