pintest-cli 0.2.9__tar.gz → 0.3.2__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 (31) hide show
  1. {pintest_cli-0.2.9 → pintest_cli-0.3.2}/PKG-INFO +1 -1
  2. {pintest_cli-0.2.9 → pintest_cli-0.3.2}/pintest/cli.py +55 -22
  3. {pintest_cli-0.2.9 → pintest_cli-0.3.2}/pintest/coverage_mapper.py +0 -7
  4. pintest_cli-0.3.2/pintest/pytest_plugin.py +166 -0
  5. {pintest_cli-0.2.9 → pintest_cli-0.3.2}/pintest_cli.egg-info/PKG-INFO +1 -1
  6. {pintest_cli-0.2.9 → pintest_cli-0.3.2}/pintest_cli.egg-info/SOURCES.txt +1 -0
  7. pintest_cli-0.3.2/pintest_cli.egg-info/entry_points.txt +5 -0
  8. {pintest_cli-0.2.9 → pintest_cli-0.3.2}/setup.py +4 -1
  9. pintest_cli-0.3.2/tests/test_cli_e2e.py +184 -0
  10. pintest_cli-0.2.9/pintest/pytest_plugin.py +0 -26
  11. pintest_cli-0.2.9/pintest_cli.egg-info/entry_points.txt +0 -2
  12. {pintest_cli-0.2.9 → pintest_cli-0.3.2}/README.md +0 -0
  13. {pintest_cli-0.2.9 → pintest_cli-0.3.2}/pintest/__init__.py +0 -0
  14. {pintest_cli-0.2.9 → pintest_cli-0.3.2}/pintest/build_mapping_iterative.py +0 -0
  15. {pintest_cli-0.2.9 → pintest_cli-0.3.2}/pintest/cloud_mapping_db.py +0 -0
  16. {pintest_cli-0.2.9 → pintest_cli-0.3.2}/pintest/config.py +0 -0
  17. {pintest_cli-0.2.9 → pintest_cli-0.3.2}/pintest/git_diff_parser.py +0 -0
  18. {pintest_cli-0.2.9 → pintest_cli-0.3.2}/pintest/post_commit_hook.py +0 -0
  19. {pintest_cli-0.2.9 → pintest_cli-0.3.2}/pintest/pre_commit_hook.py +0 -0
  20. {pintest_cli-0.2.9 → pintest_cli-0.3.2}/pintest/push_cache.py +0 -0
  21. {pintest_cli-0.2.9 → pintest_cli-0.3.2}/pintest/range_set.py +0 -0
  22. {pintest_cli-0.2.9 → pintest_cli-0.3.2}/pintest/test_mapping_db_v2.py +0 -0
  23. {pintest_cli-0.2.9 → pintest_cli-0.3.2}/pintest/update_mapping.py +0 -0
  24. {pintest_cli-0.2.9 → pintest_cli-0.3.2}/pintest_cli.egg-info/dependency_links.txt +0 -0
  25. {pintest_cli-0.2.9 → pintest_cli-0.3.2}/pintest_cli.egg-info/requires.txt +0 -0
  26. {pintest_cli-0.2.9 → pintest_cli-0.3.2}/pintest_cli.egg-info/top_level.txt +0 -0
  27. {pintest_cli-0.2.9 → pintest_cli-0.3.2}/setup.cfg +0 -0
  28. {pintest_cli-0.2.9 → pintest_cli-0.3.2}/tests/__init__.py +0 -0
  29. {pintest_cli-0.2.9 → pintest_cli-0.3.2}/tests/test_git_diff_parser.py +0 -0
  30. {pintest_cli-0.2.9 → pintest_cli-0.3.2}/tests/test_new_feature.py +0 -0
  31. {pintest_cli-0.2.9 → pintest_cli-0.3.2}/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.2.9
3
+ Version: 0.3.2
4
4
  Summary: Run only the tests affected by your code changes.
5
5
  Author: Pintest Contributors
6
6
  Classifier: Development Status :: 3 - Alpha
@@ -134,36 +134,57 @@ class PintestRunner:
134
134
  Exit code from pytest (0 = success)
135
135
  """
136
136
  if not test_selection:
137
+ if dry_run:
138
+ print("Would run 0 test(s)")
139
+ return 0
137
140
  print("No tests to run!")
138
141
  return 0
139
-
140
- # Build pytest command
141
- cmd = [sys.executable, "-m", "pytest", "-p", "pintest.pytest_plugin"]
142
-
143
- # Automatically generate coverage for local updates (suppress terminal report)
144
- cmd.extend(["--cov", "--cov-context=test", "--cov-append", "--cov-report="])
145
-
142
+
143
+ test_list = sorted(test_selection)
144
+ chunk_size = 500
145
+ needs_chunking = len(test_list) > chunk_size
146
+
147
+ # Base pytest command
148
+ base_cmd = [sys.executable, "-m", "pytest", "-p", "pintest.pytest_plugin"]
149
+ base_cmd.extend(["--cov", "--cov-context=test", "--cov-append", "--cov-report="])
146
150
  if verbose:
147
- cmd.append("-v")
148
-
149
- # Add custom pytest args
151
+ base_cmd.append("-v")
150
152
  if pytest_args:
151
- cmd.extend(pytest_args)
152
-
153
- # Add test files
154
- for test in sorted(test_selection):
155
- cmd.append(test)
156
-
153
+ base_cmd.extend(pytest_args)
154
+
157
155
  if dry_run:
158
156
  print(f"Would run {len(test_selection)} test(s):")
159
- for test in sorted(test_selection):
157
+ for test in test_list[:10]:
160
158
  print(f" {test}")
161
- print(f"\nCommand: {' '.join(cmd)}")
159
+ if len(test_list) > 10:
160
+ print(f" ... and {len(test_list) - 10} more")
161
+ print(f"\nBase Command: {' '.join(base_cmd)}")
162
162
  return 0
163
-
164
- print(f"Running {len(test_selection)} selected test(s)...")
165
- result = subprocess.run(cmd, cwd=self.repo_root)
166
- return result.returncode
163
+
164
+ if needs_chunking:
165
+ print(f"Running {len(test_selection)} selected test(s) in chunks of {chunk_size}...")
166
+ total_chunks = (len(test_list) + chunk_size - 1) // chunk_size
167
+ final_returncode = 0
168
+ fail_fast = pytest_args and any(arg == '-x' or arg.startswith('--maxfail') for arg in pytest_args)
169
+
170
+ for i in range(0, len(test_list), chunk_size):
171
+ chunk = test_list[i:i + chunk_size]
172
+ chunk_num = (i // chunk_size) + 1
173
+ print(f"\n📦 Running chunk {chunk_num}/{total_chunks} ({len(chunk)} tests)...", flush=True)
174
+ cmd = base_cmd + chunk
175
+ result = subprocess.run(cmd, cwd=self.repo_root)
176
+ if result.returncode != 0:
177
+ if final_returncode == 0:
178
+ final_returncode = result.returncode
179
+ if fail_fast:
180
+ print(f"\n⚠️ Fail-fast enabled (-x / --maxfail). Aborting remaining {total_chunks - chunk_num} chunk(s).", file=sys.stderr)
181
+ break
182
+ return final_returncode
183
+ else:
184
+ print(f"Running {len(test_selection)} selected test(s)...")
185
+ cmd = base_cmd + test_list
186
+ result = subprocess.run(cmd, cwd=self.repo_root)
187
+ return result.returncode
167
188
 
168
189
 
169
190
  def cmd_run(args):
@@ -237,12 +258,20 @@ def cmd_run(args):
237
258
  base_branch
238
259
  )
239
260
 
261
+ # Determine test_dir
262
+ test_dir = args.test_dir
263
+ if not test_dir and cfg.is_cloud_enabled and getattr(cfg.cloud, "test_dir", None):
264
+ test_dir = cfg.cloud.test_dir
265
+ if not test_dir:
266
+ test_dir = "tests"
267
+
240
268
  # Unmapped tests discovery (Cloud mode only)
241
269
  if use_cloud:
242
270
  from .pre_commit_hook import find_unmapped_tests
243
271
  unmapped = find_unmapped_tests(
244
272
  repo_root,
245
273
  mapping_db_obj,
274
+ test_dir=test_dir,
246
275
  verbose=args.verbose
247
276
  )
248
277
  if unmapped:
@@ -598,6 +627,10 @@ Examples:
598
627
  default=0,
599
628
  help="Minimum number of tests to run (exit with error if below)"
600
629
  )
630
+ run_parser.add_argument(
631
+ "--test-dir",
632
+ help="Directory containing tests (default: read from config or tests/)"
633
+ )
601
634
  run_parser.add_argument(
602
635
  "-v", "--verbose",
603
636
  action="store_true",
@@ -264,13 +264,6 @@ class CoverageMapper:
264
264
  except Exception:
265
265
  pass
266
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
267
  # Remove absolute path prefixes
275
268
  if path.startswith('/'):
276
269
  parts = Path(path).parts
@@ -0,0 +1,166 @@
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
+ with open(out_file, "w") as f:
163
+ json.dump(test_durations, f)
164
+ except Exception as e:
165
+ # Silently pass if we cannot write to rootdir
166
+ pass
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pintest-cli
3
- Version: 0.2.9
3
+ Version: 0.3.2
4
4
  Summary: Run only the tests affected by your code changes.
5
5
  Author: Pintest Contributors
6
6
  Classifier: Development Status :: 3 - Alpha
@@ -21,6 +21,7 @@ pintest_cli.egg-info/entry_points.txt
21
21
  pintest_cli.egg-info/requires.txt
22
22
  pintest_cli.egg-info/top_level.txt
23
23
  tests/__init__.py
24
+ tests/test_cli_e2e.py
24
25
  tests/test_git_diff_parser.py
25
26
  tests/test_new_feature.py
26
27
  tests/test_range_set.py
@@ -0,0 +1,5 @@
1
+ [console_scripts]
2
+ pintest = pintest.cli:main
3
+
4
+ [pytest11]
5
+ pintest.pytest_plugin = pintest.pytest_plugin
@@ -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.2.9",
8
+ version="0.3.2",
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",
@@ -21,6 +21,9 @@ setup(
21
21
  "console_scripts": [
22
22
  "pintest=pintest.cli:main",
23
23
  ],
24
+ "pytest11": [
25
+ "pintest.pytest_plugin = pintest.pytest_plugin",
26
+ ],
24
27
  },
25
28
  python_requires=">=3.8",
26
29
  classifiers=[
@@ -0,0 +1,184 @@
1
+ import os
2
+ import subprocess
3
+ import pytest
4
+ import sys
5
+
6
+ @pytest.fixture
7
+ def dummy_repo(tmp_path):
8
+ # 1. Create a dummy Python project in tmp_path
9
+ repo_dir = tmp_path / "dummy_repo"
10
+ repo_dir.mkdir()
11
+
12
+ # 2. Initialize git
13
+ subprocess.run(["git", "init", "-b", "main"], cwd=repo_dir, check=True, capture_output=True)
14
+ subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=repo_dir, check=True, capture_output=True)
15
+ subprocess.run(["git", "config", "user.name", "Test User"], cwd=repo_dir, check=True, capture_output=True)
16
+
17
+ # 3. Create pintest.toml
18
+ pintest_toml = repo_dir / "pintest.toml"
19
+ pintest_toml.write_text("""
20
+ [cloud]
21
+ enabled = false
22
+ [local]
23
+ test_dir = "tests"
24
+ """)
25
+
26
+ # 4. Create source files
27
+ src_dir = repo_dir / "src"
28
+ src_dir.mkdir()
29
+
30
+ math_py = src_dir / "math_utils.py"
31
+ math_py.write_text("""def add(a, b):
32
+ return a + b
33
+
34
+ def subtract(a, b):
35
+ return a - b
36
+ """)
37
+
38
+ string_py = src_dir / "string_utils.py"
39
+ string_py.write_text("""def to_upper(s):
40
+ return s.upper()
41
+ """)
42
+
43
+ # 5. Create test files
44
+ tests_dir = repo_dir / "tests"
45
+ tests_dir.mkdir()
46
+
47
+ test_math_py = tests_dir / "test_math.py"
48
+ test_math_py.write_text("""import sys
49
+ import os
50
+ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
51
+ from src.math_utils import add, subtract
52
+
53
+ def test_add():
54
+ assert add(1, 2) == 3
55
+
56
+ def test_subtract():
57
+ assert subtract(2, 1) == 1
58
+ """)
59
+
60
+ test_string_py = tests_dir / "test_string.py"
61
+ test_string_py.write_text("""import sys
62
+ import os
63
+ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
64
+ from src.string_utils import to_upper
65
+
66
+ def test_to_upper():
67
+ assert to_upper("a") == "A"
68
+ """)
69
+
70
+ # 6. Commit everything
71
+ subprocess.run(["git", "add", "."], cwd=repo_dir, check=True, capture_output=True)
72
+ subprocess.run(["git", "commit", "-m", "Initial commit"], cwd=repo_dir, check=True, capture_output=True)
73
+
74
+ return repo_dir
75
+
76
+ def run_pintest(args, cwd):
77
+ env = os.environ.copy()
78
+ env["HOME"] = str(cwd) # Prevent reading global ~/.pintest/config.toml
79
+ repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
80
+ env["PYTHONPATH"] = repo_root + (os.pathsep + env["PYTHONPATH"] if "PYTHONPATH" in env else "")
81
+ cmd = [sys.executable, "-m", "pintest.cli"] + args
82
+ result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, env=env)
83
+ return result
84
+
85
+ def test_pintest_track_and_run(dummy_repo):
86
+ # Scenario A (Baseline): Run pintest build-mapping
87
+ res = run_pintest(["build-mapping"], cwd=dummy_repo)
88
+ assert res.returncode == 0
89
+
90
+ # Check if mapping db is created
91
+ db_path = dummy_repo / ".pintest" / "test_mapping.db"
92
+ assert db_path.exists()
93
+
94
+ # Verify nothing to run on clean working tree
95
+ res = run_pintest(["run", "--dry-run", "--base-branch=main"], cwd=dummy_repo)
96
+ assert res.returncode == 0
97
+ # Expected: "Would run 0 test" or "No tests to run!" or "Running 0 selected"
98
+ # Actually, current code outputs "Would run 0 test(s)" if test_selection is not empty but dry-run is provided?
99
+ # No, cli.py has `if not test_selection: print("No tests to run!"); return 0`
100
+ assert "No tests to run" in res.stdout or "0 test(s)" in res.stdout
101
+
102
+ # Scenario C (True Positive): Modify a tracked line
103
+ math_py = dummy_repo / "src" / "math_utils.py"
104
+ content = math_py.read_text()
105
+ # Change add function
106
+ content = content.replace("return a + b", "return a + b + 0")
107
+ math_py.write_text(content)
108
+
109
+ res = run_pintest(["run", "--dry-run", "--base-branch=main", "-v"], cwd=dummy_repo)
110
+ print("STDOUT:", res.stdout)
111
+ print("STDERR:", res.stderr)
112
+ assert res.returncode == 0
113
+ assert "tests/test_math.py::test_add" in res.stdout or "test_math.py" in res.stdout
114
+ assert "test_string.py" not in res.stdout
115
+
116
+ # Revert math.py
117
+ subprocess.run(["git", "checkout", "--", "src/math_utils.py"], cwd=dummy_repo, check=True)
118
+
119
+ # Scenario D (True Negative): Add a comment
120
+ content = math_py.read_text()
121
+ content += "\n# This is a comment\n"
122
+ math_py.write_text(content)
123
+
124
+ res = run_pintest(["run", "--dry-run", "--base-branch=main"], cwd=dummy_repo)
125
+ assert res.returncode == 0
126
+ # The comment line was never executed, so adding it shouldn't trigger any tests.
127
+ assert "No tests to run" in res.stdout or "0 test(s)" in res.stdout
128
+
129
+ # Revert math.py
130
+ subprocess.run(["git", "checkout", "--", "src/math_utils.py"], cwd=dummy_repo, check=True)
131
+
132
+ # Scenario E (New Test Discovery): Add a new test file
133
+ test_new_py = dummy_repo / "tests" / "test_new.py"
134
+ test_new_py.write_text("""def test_something_new():\n assert True\n""")
135
+ subprocess.run(["git", "add", "tests/test_new.py"], cwd=dummy_repo, check=True)
136
+
137
+ res = run_pintest(["run", "--dry-run", "--base-branch=main"], cwd=dummy_repo)
138
+ assert res.returncode == 0
139
+ assert "test_new.py" in res.stdout
140
+
141
+ # Remove test_new.py and unstage
142
+ subprocess.run(["git", "rm", "-f", "tests/test_new.py"], cwd=dummy_repo, check=True)
143
+
144
+ # Scenario F (Deleted File): Delete a test file and modify related source
145
+ os.remove(dummy_repo / "tests" / "test_string.py")
146
+ subprocess.run(["git", "add", "."], cwd=dummy_repo, check=True)
147
+ subprocess.run(["git", "commit", "-m", "Remove test_string.py"], cwd=dummy_repo, check=True)
148
+
149
+ # Modify string_utils.py which used to be covered by the deleted test
150
+ string_py = dummy_repo / "src" / "string_utils.py"
151
+ content = string_py.read_text()
152
+ content = content.replace("return s.upper()", "return s.upper() + ''")
153
+ string_py.write_text(content)
154
+
155
+ res = run_pintest(["run", "--dry-run", "--base-branch=main"], cwd=dummy_repo)
156
+ assert res.returncode == 0
157
+ # test_string_py is gone. pintest run shouldn't crash trying to find it.
158
+ assert "Exception" not in res.stderr
159
+ assert "Traceback" not in res.stderr
160
+
161
+
162
+ def test_pintest_chunking_and_fail_fast(dummy_repo):
163
+ from pintest.cli import PintestRunner
164
+ runner = PintestRunner(dummy_repo, verbose=True)
165
+
166
+ # Generate 505 dummy test names
167
+ dummy_tests = {f"tests/test_math.py::test_dummy_{i}" for i in range(505)}
168
+
169
+ # Run with fail-fast (-x)
170
+ # Since these dummy tests don't exist, pytest will fail on the first chunk.
171
+ # PintestRunner should catch the failure and abort the second chunk.
172
+ import io
173
+ from unittest.mock import patch
174
+
175
+ # Capture stderr to verify the abort message
176
+ stderr_buf = io.StringIO()
177
+ with patch("sys.stderr", stderr_buf):
178
+ returncode = runner.run_tests(dummy_tests, dry_run=False, verbose=True, pytest_args=["-x"])
179
+
180
+ assert returncode != 0
181
+ stderr_output = stderr_buf.getvalue()
182
+ assert "Fail-fast enabled" in stderr_output
183
+ assert "Aborting remaining 1 chunk(s)" in stderr_output
184
+
@@ -1,26 +0,0 @@
1
- """Pytest plugin to measure individual test execution duration for Pintest."""
2
- import json
3
- from pathlib import Path
4
-
5
- # Stores test_node_id -> duration_ms
6
- test_durations = {}
7
-
8
-
9
- def pytest_runtest_makereport(item, call):
10
- """Record test execution time during the 'call' phase."""
11
- if call.when == "call":
12
- # call.duration is a float representing exact seconds
13
- test_durations[item.nodeid] = int(call.duration * 1000)
14
-
15
-
16
- def pytest_sessionfinish(session, exitstatus):
17
- """Dump durations to a temporary file for the CLI push phase."""
18
- try:
19
- pintest_dir = Path(session.config.rootdir) / ".pintest"
20
- pintest_dir.mkdir(parents=True, exist_ok=True)
21
- out_file = pintest_dir / "durations.json"
22
- with open(out_file, "w") as f:
23
- json.dump(test_durations, f)
24
- except Exception as e:
25
- # Silently pass if we cannot write to rootdir
26
- pass
@@ -1,2 +0,0 @@
1
- [console_scripts]
2
- pintest = pintest.cli:main
File without changes
File without changes