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.
- {pintest_cli-0.2.9 → pintest_cli-0.3.2}/PKG-INFO +1 -1
- {pintest_cli-0.2.9 → pintest_cli-0.3.2}/pintest/cli.py +55 -22
- {pintest_cli-0.2.9 → pintest_cli-0.3.2}/pintest/coverage_mapper.py +0 -7
- pintest_cli-0.3.2/pintest/pytest_plugin.py +166 -0
- {pintest_cli-0.2.9 → pintest_cli-0.3.2}/pintest_cli.egg-info/PKG-INFO +1 -1
- {pintest_cli-0.2.9 → pintest_cli-0.3.2}/pintest_cli.egg-info/SOURCES.txt +1 -0
- pintest_cli-0.3.2/pintest_cli.egg-info/entry_points.txt +5 -0
- {pintest_cli-0.2.9 → pintest_cli-0.3.2}/setup.py +4 -1
- pintest_cli-0.3.2/tests/test_cli_e2e.py +184 -0
- pintest_cli-0.2.9/pintest/pytest_plugin.py +0 -26
- pintest_cli-0.2.9/pintest_cli.egg-info/entry_points.txt +0 -2
- {pintest_cli-0.2.9 → pintest_cli-0.3.2}/README.md +0 -0
- {pintest_cli-0.2.9 → pintest_cli-0.3.2}/pintest/__init__.py +0 -0
- {pintest_cli-0.2.9 → pintest_cli-0.3.2}/pintest/build_mapping_iterative.py +0 -0
- {pintest_cli-0.2.9 → pintest_cli-0.3.2}/pintest/cloud_mapping_db.py +0 -0
- {pintest_cli-0.2.9 → pintest_cli-0.3.2}/pintest/config.py +0 -0
- {pintest_cli-0.2.9 → pintest_cli-0.3.2}/pintest/git_diff_parser.py +0 -0
- {pintest_cli-0.2.9 → pintest_cli-0.3.2}/pintest/post_commit_hook.py +0 -0
- {pintest_cli-0.2.9 → pintest_cli-0.3.2}/pintest/pre_commit_hook.py +0 -0
- {pintest_cli-0.2.9 → pintest_cli-0.3.2}/pintest/push_cache.py +0 -0
- {pintest_cli-0.2.9 → pintest_cli-0.3.2}/pintest/range_set.py +0 -0
- {pintest_cli-0.2.9 → pintest_cli-0.3.2}/pintest/test_mapping_db_v2.py +0 -0
- {pintest_cli-0.2.9 → pintest_cli-0.3.2}/pintest/update_mapping.py +0 -0
- {pintest_cli-0.2.9 → pintest_cli-0.3.2}/pintest_cli.egg-info/dependency_links.txt +0 -0
- {pintest_cli-0.2.9 → pintest_cli-0.3.2}/pintest_cli.egg-info/requires.txt +0 -0
- {pintest_cli-0.2.9 → pintest_cli-0.3.2}/pintest_cli.egg-info/top_level.txt +0 -0
- {pintest_cli-0.2.9 → pintest_cli-0.3.2}/setup.cfg +0 -0
- {pintest_cli-0.2.9 → pintest_cli-0.3.2}/tests/__init__.py +0 -0
- {pintest_cli-0.2.9 → pintest_cli-0.3.2}/tests/test_git_diff_parser.py +0 -0
- {pintest_cli-0.2.9 → pintest_cli-0.3.2}/tests/test_new_feature.py +0 -0
- {pintest_cli-0.2.9 → pintest_cli-0.3.2}/tests/test_range_set.py +0 -0
|
@@ -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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
# Add custom pytest args
|
|
151
|
+
base_cmd.append("-v")
|
|
150
152
|
if pytest_args:
|
|
151
|
-
|
|
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
|
|
157
|
+
for test in test_list[:10]:
|
|
160
158
|
print(f" {test}")
|
|
161
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
|
@@ -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
|
|
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
|
|
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
|