pintest-cli 0.2.8__tar.gz → 0.3.1__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.8 → pintest_cli-0.3.1}/PKG-INFO +1 -1
- {pintest_cli-0.2.8 → pintest_cli-0.3.1}/pintest/cli.py +45 -25
- {pintest_cli-0.2.8 → pintest_cli-0.3.1}/pintest/push_cache.py +1 -0
- pintest_cli-0.3.1/pintest/pytest_plugin.py +166 -0
- {pintest_cli-0.2.8 → pintest_cli-0.3.1}/pintest/test_mapping_db_v2.py +2 -1
- {pintest_cli-0.2.8 → pintest_cli-0.3.1}/pintest_cli.egg-info/PKG-INFO +1 -1
- pintest_cli-0.3.1/pintest_cli.egg-info/entry_points.txt +5 -0
- {pintest_cli-0.2.8 → pintest_cli-0.3.1}/setup.py +4 -1
- pintest_cli-0.2.8/pintest/pytest_plugin.py +0 -26
- pintest_cli-0.2.8/pintest_cli.egg-info/entry_points.txt +0 -2
- {pintest_cli-0.2.8 → pintest_cli-0.3.1}/README.md +0 -0
- {pintest_cli-0.2.8 → pintest_cli-0.3.1}/pintest/__init__.py +0 -0
- {pintest_cli-0.2.8 → pintest_cli-0.3.1}/pintest/build_mapping_iterative.py +0 -0
- {pintest_cli-0.2.8 → pintest_cli-0.3.1}/pintest/cloud_mapping_db.py +0 -0
- {pintest_cli-0.2.8 → pintest_cli-0.3.1}/pintest/config.py +0 -0
- {pintest_cli-0.2.8 → pintest_cli-0.3.1}/pintest/coverage_mapper.py +0 -0
- {pintest_cli-0.2.8 → pintest_cli-0.3.1}/pintest/git_diff_parser.py +0 -0
- {pintest_cli-0.2.8 → pintest_cli-0.3.1}/pintest/post_commit_hook.py +0 -0
- {pintest_cli-0.2.8 → pintest_cli-0.3.1}/pintest/pre_commit_hook.py +0 -0
- {pintest_cli-0.2.8 → pintest_cli-0.3.1}/pintest/range_set.py +0 -0
- {pintest_cli-0.2.8 → pintest_cli-0.3.1}/pintest/update_mapping.py +0 -0
- {pintest_cli-0.2.8 → pintest_cli-0.3.1}/pintest_cli.egg-info/SOURCES.txt +0 -0
- {pintest_cli-0.2.8 → pintest_cli-0.3.1}/pintest_cli.egg-info/dependency_links.txt +0 -0
- {pintest_cli-0.2.8 → pintest_cli-0.3.1}/pintest_cli.egg-info/requires.txt +0 -0
- {pintest_cli-0.2.8 → pintest_cli-0.3.1}/pintest_cli.egg-info/top_level.txt +0 -0
- {pintest_cli-0.2.8 → pintest_cli-0.3.1}/setup.cfg +0 -0
- {pintest_cli-0.2.8 → pintest_cli-0.3.1}/tests/__init__.py +0 -0
- {pintest_cli-0.2.8 → pintest_cli-0.3.1}/tests/test_git_diff_parser.py +0 -0
- {pintest_cli-0.2.8 → pintest_cli-0.3.1}/tests/test_new_feature.py +0 -0
- {pintest_cli-0.2.8 → pintest_cli-0.3.1}/tests/test_range_set.py +0 -0
|
@@ -133,37 +133,45 @@ class PintestRunner:
|
|
|
133
133
|
Returns:
|
|
134
134
|
Exit code from pytest (0 = success)
|
|
135
135
|
"""
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
#
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
# Automatically generate coverage for local updates (suppress terminal report)
|
|
144
|
-
cmd.extend(["--cov", "--cov-context=test", "--cov-append", "--cov-report="])
|
|
145
|
-
|
|
136
|
+
test_list = sorted(test_selection)
|
|
137
|
+
chunk_size = 500
|
|
138
|
+
needs_chunking = len(test_list) > chunk_size
|
|
139
|
+
|
|
140
|
+
# Base pytest command
|
|
141
|
+
base_cmd = [sys.executable, "-m", "pytest", "-p", "pintest.pytest_plugin"]
|
|
142
|
+
base_cmd.extend(["--cov", "--cov-context=test", "--cov-append", "--cov-report="])
|
|
146
143
|
if verbose:
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
# Add custom pytest args
|
|
144
|
+
base_cmd.append("-v")
|
|
150
145
|
if pytest_args:
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
# Add test files
|
|
154
|
-
for test in sorted(test_selection):
|
|
155
|
-
cmd.append(test)
|
|
156
|
-
|
|
146
|
+
base_cmd.extend(pytest_args)
|
|
147
|
+
|
|
157
148
|
if dry_run:
|
|
158
149
|
print(f"Would run {len(test_selection)} test(s):")
|
|
159
|
-
for test in
|
|
150
|
+
for test in test_list[:10]:
|
|
160
151
|
print(f" {test}")
|
|
161
|
-
|
|
152
|
+
if len(test_list) > 10:
|
|
153
|
+
print(f" ... and {len(test_list) - 10} more")
|
|
154
|
+
print(f"\nBase Command: {' '.join(base_cmd)}")
|
|
162
155
|
return 0
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
156
|
+
|
|
157
|
+
if needs_chunking:
|
|
158
|
+
print(f"Running {len(test_selection)} selected test(s) in chunks of {chunk_size}...")
|
|
159
|
+
total_chunks = (len(test_list) + chunk_size - 1) // chunk_size
|
|
160
|
+
final_returncode = 0
|
|
161
|
+
for i in range(0, len(test_list), chunk_size):
|
|
162
|
+
chunk = test_list[i:i + chunk_size]
|
|
163
|
+
chunk_num = (i // chunk_size) + 1
|
|
164
|
+
print(f"\n📦 Running chunk {chunk_num}/{total_chunks} ({len(chunk)} tests)...", flush=True)
|
|
165
|
+
cmd = base_cmd + chunk
|
|
166
|
+
result = subprocess.run(cmd, cwd=self.repo_root)
|
|
167
|
+
if result.returncode != 0 and final_returncode == 0:
|
|
168
|
+
final_returncode = result.returncode
|
|
169
|
+
return final_returncode
|
|
170
|
+
else:
|
|
171
|
+
print(f"Running {len(test_selection)} selected test(s)...")
|
|
172
|
+
cmd = base_cmd + test_list
|
|
173
|
+
result = subprocess.run(cmd, cwd=self.repo_root)
|
|
174
|
+
return result.returncode
|
|
167
175
|
|
|
168
176
|
|
|
169
177
|
def cmd_run(args):
|
|
@@ -237,12 +245,20 @@ def cmd_run(args):
|
|
|
237
245
|
base_branch
|
|
238
246
|
)
|
|
239
247
|
|
|
248
|
+
# Determine test_dir
|
|
249
|
+
test_dir = args.test_dir
|
|
250
|
+
if not test_dir and cfg.is_cloud_enabled and getattr(cfg.cloud, "test_dir", None):
|
|
251
|
+
test_dir = cfg.cloud.test_dir
|
|
252
|
+
if not test_dir:
|
|
253
|
+
test_dir = "tests"
|
|
254
|
+
|
|
240
255
|
# Unmapped tests discovery (Cloud mode only)
|
|
241
256
|
if use_cloud:
|
|
242
257
|
from .pre_commit_hook import find_unmapped_tests
|
|
243
258
|
unmapped = find_unmapped_tests(
|
|
244
259
|
repo_root,
|
|
245
260
|
mapping_db_obj,
|
|
261
|
+
test_dir=test_dir,
|
|
246
262
|
verbose=args.verbose
|
|
247
263
|
)
|
|
248
264
|
if unmapped:
|
|
@@ -598,6 +614,10 @@ Examples:
|
|
|
598
614
|
default=0,
|
|
599
615
|
help="Minimum number of tests to run (exit with error if below)"
|
|
600
616
|
)
|
|
617
|
+
run_parser.add_argument(
|
|
618
|
+
"--test-dir",
|
|
619
|
+
help="Directory containing tests (default: read from config or tests/)"
|
|
620
|
+
)
|
|
601
621
|
run_parser.add_argument(
|
|
602
622
|
"-v", "--verbose",
|
|
603
623
|
action="store_true",
|
|
@@ -15,6 +15,7 @@ class PushCache:
|
|
|
15
15
|
|
|
16
16
|
def _init_db(self):
|
|
17
17
|
"""Initialize the local push cache database schema."""
|
|
18
|
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
18
19
|
with sqlite3.connect(self.db_path) as conn:
|
|
19
20
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
20
21
|
conn.execute("""
|
|
@@ -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
|
|
@@ -6,7 +6,7 @@ reducing 12M entries to ~100K ranges (99%+ reduction).
|
|
|
6
6
|
|
|
7
7
|
import sqlite3
|
|
8
8
|
from pathlib import Path
|
|
9
|
-
from typing import Set, List, Dict, Optional
|
|
9
|
+
from typing import Set, List, Dict, Optional, Tuple
|
|
10
10
|
from dataclasses import dataclass
|
|
11
11
|
from datetime import datetime
|
|
12
12
|
|
|
@@ -72,6 +72,7 @@ class TestMappingDBV2:
|
|
|
72
72
|
|
|
73
73
|
def connect(self):
|
|
74
74
|
"""Open database connection."""
|
|
75
|
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
75
76
|
self.conn = sqlite3.connect(str(self.db_path))
|
|
76
77
|
self.conn.row_factory = sqlite3.Row
|
|
77
78
|
|
|
@@ -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.
|
|
8
|
+
version="0.3.1",
|
|
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=[
|
|
@@ -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
|