pintest-cli 0.2.0__py3-none-any.whl
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/__init__.py +14 -0
- pintest/build_mapping_iterative.py +339 -0
- pintest/cli.py +681 -0
- pintest/cloud_mapping_db.py +218 -0
- pintest/config.py +102 -0
- pintest/coverage_mapper.py +356 -0
- pintest/git_diff_parser.py +232 -0
- pintest/post_commit_hook.py +78 -0
- pintest/pre_commit_hook.py +1472 -0
- pintest/range_set.py +173 -0
- pintest/test_mapping_db_v2.py +381 -0
- pintest/update_mapping.py +130 -0
- pintest_cli-0.2.0.dist-info/METADATA +527 -0
- pintest_cli-0.2.0.dist-info/RECORD +21 -0
- pintest_cli-0.2.0.dist-info/WHEEL +5 -0
- pintest_cli-0.2.0.dist-info/entry_points.txt +2 -0
- pintest_cli-0.2.0.dist-info/top_level.txt +2 -0
- tests/__init__.py +1 -0
- tests/test_git_diff_parser.py +60 -0
- tests/test_new_feature.py +1 -0
- tests/test_range_set.py +261 -0
pintest/cli.py
ADDED
|
@@ -0,0 +1,681 @@
|
|
|
1
|
+
"""CLI for Pintest."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import getpass
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Set
|
|
9
|
+
|
|
10
|
+
from .git_diff_parser import GitDiffParser
|
|
11
|
+
from .coverage_mapper import CoverageMapper
|
|
12
|
+
from .config import Config
|
|
13
|
+
from .cloud_mapping_db import CloudMappingDB
|
|
14
|
+
from .test_mapping_db_v2 import TestMappingDBV2
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PintestRunner:
|
|
18
|
+
"""Select and run tests based on code coverage and git changes."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, repo_root: Path, mapping_db=None, verbose: bool = False):
|
|
21
|
+
"""
|
|
22
|
+
Initialize runner.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
repo_root: Repository root directory
|
|
26
|
+
mapping_db: Mapping database object (TestMappingDBV2 or CloudMappingDB)
|
|
27
|
+
verbose: Enable verbose output
|
|
28
|
+
"""
|
|
29
|
+
self.repo_root = Path(repo_root).resolve()
|
|
30
|
+
self.git_parser = GitDiffParser(self.repo_root)
|
|
31
|
+
self.mapping_db = mapping_db
|
|
32
|
+
self.verbose = verbose
|
|
33
|
+
|
|
34
|
+
def find_affected_tests(self, base_branch: str = "master") -> Set[str]:
|
|
35
|
+
"""
|
|
36
|
+
Find all tests affected by changes since base_branch.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
Set of test identifiers (pytest format)
|
|
40
|
+
"""
|
|
41
|
+
# Get git diff
|
|
42
|
+
try:
|
|
43
|
+
diff_output = self.git_parser.get_diff(base_branch)
|
|
44
|
+
except RuntimeError as e:
|
|
45
|
+
print(f"Error getting git diff: {e}", file=sys.stderr)
|
|
46
|
+
return set()
|
|
47
|
+
|
|
48
|
+
changes = self.git_parser.parse_diff(diff_output)
|
|
49
|
+
python_changes = self.git_parser.filter_python_files(changes)
|
|
50
|
+
|
|
51
|
+
if self.verbose:
|
|
52
|
+
print(f"đ Found {len(python_changes)} changed Python files", file=sys.stderr)
|
|
53
|
+
|
|
54
|
+
# Get changed test files (always run these)
|
|
55
|
+
changed_test_files = self.git_parser.get_changed_test_files(python_changes)
|
|
56
|
+
|
|
57
|
+
if self.verbose and changed_test_files:
|
|
58
|
+
print(f"đ Changed test files: {len(changed_test_files)}", file=sys.stderr)
|
|
59
|
+
for test_file in sorted(changed_test_files):
|
|
60
|
+
print(f" - {test_file}", file=sys.stderr)
|
|
61
|
+
|
|
62
|
+
# Find tests that cover changed lines
|
|
63
|
+
affected_tests = set()
|
|
64
|
+
|
|
65
|
+
if self.mapping_db:
|
|
66
|
+
if not python_changes:
|
|
67
|
+
if self.verbose:
|
|
68
|
+
print("âšī¸ No Python source changes detected. Skipping mapping query.", file=sys.stderr)
|
|
69
|
+
elif hasattr(self.mapping_db, 'find_tests_for_changes'):
|
|
70
|
+
# CloudMappingDB or optimized interface
|
|
71
|
+
# Format changes for the API: [{"file": "path", "lines": [1, 2]}, ...]
|
|
72
|
+
formatted_changes = [
|
|
73
|
+
{"file": path, "lines": change.get_all_changed_lines()}
|
|
74
|
+
for path, change in python_changes.items()
|
|
75
|
+
if not change.is_new and change.get_all_changed_lines()
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
if formatted_changes:
|
|
79
|
+
# Pass base_branch as the target branch for mapping lookup
|
|
80
|
+
affected_tests, unmapped = self.mapping_db.find_tests_for_changes(
|
|
81
|
+
formatted_changes,
|
|
82
|
+
branch=base_branch
|
|
83
|
+
)
|
|
84
|
+
if self.verbose and unmapped:
|
|
85
|
+
print(f"â ī¸ {len(unmapped)} files have no coverage mapping", file=sys.stderr)
|
|
86
|
+
elif self.verbose:
|
|
87
|
+
print("âšī¸ Changes contain no covered lines. Skipping mapping query.", file=sys.stderr)
|
|
88
|
+
else:
|
|
89
|
+
# Local TestMappingDBV2
|
|
90
|
+
for file_path, change in python_changes.items():
|
|
91
|
+
# Skip test files (we already have them)
|
|
92
|
+
if file_path in changed_test_files:
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
if change.is_new:
|
|
96
|
+
continue
|
|
97
|
+
|
|
98
|
+
changed_lines = change.get_all_changed_lines()
|
|
99
|
+
if not changed_lines:
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
# Find tests covering these lines
|
|
103
|
+
tests = self.mapping_db.find_tests_for_file_lines(file_path, changed_lines)
|
|
104
|
+
affected_tests.update(tests)
|
|
105
|
+
|
|
106
|
+
if self.verbose and tests:
|
|
107
|
+
print(f" â {file_path}: {len(tests)} tests affected", file=sys.stderr)
|
|
108
|
+
else:
|
|
109
|
+
print("â ī¸ No mapping database found. Only running changed test files.", file=sys.stderr)
|
|
110
|
+
print(" Run 'pintest push' or 'pintest build-mapping' to create one.", file=sys.stderr)
|
|
111
|
+
|
|
112
|
+
# Always include changed test files
|
|
113
|
+
affected_tests.update(changed_test_files)
|
|
114
|
+
|
|
115
|
+
return affected_tests
|
|
116
|
+
|
|
117
|
+
def run_tests(
|
|
118
|
+
self,
|
|
119
|
+
test_selection: Set[str],
|
|
120
|
+
dry_run: bool = False,
|
|
121
|
+
verbose: bool = False,
|
|
122
|
+
pytest_args: list = None
|
|
123
|
+
) -> int:
|
|
124
|
+
"""
|
|
125
|
+
Run selected tests with pytest.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
test_selection: Set of test identifiers
|
|
129
|
+
dry_run: If True, only print tests without running
|
|
130
|
+
verbose: Show detailed output
|
|
131
|
+
pytest_args: Additional pytest arguments
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
Exit code from pytest (0 = success)
|
|
135
|
+
"""
|
|
136
|
+
if not test_selection:
|
|
137
|
+
print("No tests to run!")
|
|
138
|
+
return 0
|
|
139
|
+
|
|
140
|
+
# Build pytest command
|
|
141
|
+
cmd = ["pytest"]
|
|
142
|
+
|
|
143
|
+
if verbose:
|
|
144
|
+
cmd.append("-v")
|
|
145
|
+
|
|
146
|
+
# Add custom pytest args
|
|
147
|
+
if pytest_args:
|
|
148
|
+
cmd.extend(pytest_args)
|
|
149
|
+
|
|
150
|
+
# Add test files
|
|
151
|
+
for test in sorted(test_selection):
|
|
152
|
+
cmd.append(test)
|
|
153
|
+
|
|
154
|
+
if dry_run:
|
|
155
|
+
print(f"Would run {len(test_selection)} test(s):")
|
|
156
|
+
for test in sorted(test_selection):
|
|
157
|
+
print(f" {test}")
|
|
158
|
+
print(f"\nCommand: {' '.join(cmd)}")
|
|
159
|
+
return 0
|
|
160
|
+
|
|
161
|
+
print(f"Running {len(test_selection)} selected test(s)...")
|
|
162
|
+
result = subprocess.run(cmd, cwd=self.repo_root)
|
|
163
|
+
return result.returncode
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def cmd_run(args):
|
|
167
|
+
"""Run affected tests."""
|
|
168
|
+
repo_root = args.repo_root.resolve()
|
|
169
|
+
if not repo_root.exists():
|
|
170
|
+
print(f"â Error: Repository not found: {repo_root}", file=sys.stderr)
|
|
171
|
+
sys.exit(1)
|
|
172
|
+
|
|
173
|
+
# ââ Mapping source detection âââââââââââââââââââââââââââââââââââââââââââ
|
|
174
|
+
mapping_db_obj = None
|
|
175
|
+
|
|
176
|
+
# 1. Try Cloud Mode
|
|
177
|
+
cfg = Config.load()
|
|
178
|
+
use_cloud = cfg.is_cloud_enabled and bool(cfg.cloud.repo_id)
|
|
179
|
+
|
|
180
|
+
if use_cloud:
|
|
181
|
+
if args.verbose:
|
|
182
|
+
print(f"âī¸ Mapping Service: Pintest Cloud (repo {cfg.cloud.repo_id[:8]}...)", file=sys.stderr)
|
|
183
|
+
mapping_db_obj = CloudMappingDB(cfg.cloud)
|
|
184
|
+
else:
|
|
185
|
+
# 2. Try Local V2 Mapping DB
|
|
186
|
+
mapping_db = args.mapping_db if hasattr(args, 'mapping_db') else None
|
|
187
|
+
if not mapping_db:
|
|
188
|
+
mapping_db = repo_root / ".test_mapping.db"
|
|
189
|
+
|
|
190
|
+
if mapping_db.exists():
|
|
191
|
+
if args.verbose:
|
|
192
|
+
print(f"đĨī¸ Local mode: {mapping_db}", file=sys.stderr)
|
|
193
|
+
mapping_db_obj = TestMappingDBV2(mapping_db)
|
|
194
|
+
mapping_db_obj.connect()
|
|
195
|
+
|
|
196
|
+
# Initialize runner
|
|
197
|
+
runner = PintestRunner(
|
|
198
|
+
repo_root,
|
|
199
|
+
mapping_db=mapping_db_obj,
|
|
200
|
+
verbose=args.verbose
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
# Find affected tests
|
|
205
|
+
affected_tests = runner.find_affected_tests(
|
|
206
|
+
args.base_branch
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
# Unmapped tests discovery (Cloud mode only)
|
|
210
|
+
if use_cloud:
|
|
211
|
+
from .pre_commit_hook import find_unmapped_tests
|
|
212
|
+
unmapped = find_unmapped_tests(
|
|
213
|
+
repo_root,
|
|
214
|
+
mapping_db_obj,
|
|
215
|
+
verbose=args.verbose
|
|
216
|
+
)
|
|
217
|
+
if unmapped:
|
|
218
|
+
if args.verbose:
|
|
219
|
+
print(f"â ī¸ Found {len(unmapped)} unmapped tests", file=sys.stderr)
|
|
220
|
+
affected_tests.update(unmapped)
|
|
221
|
+
|
|
222
|
+
# Check minimum threshold
|
|
223
|
+
if args.min_tests > 0 and len(affected_tests) < args.min_tests:
|
|
224
|
+
print(
|
|
225
|
+
f"Only {len(affected_tests)} test(s) found, below minimum {args.min_tests}",
|
|
226
|
+
file=sys.stderr
|
|
227
|
+
)
|
|
228
|
+
print("Exiting with error (run full test suite instead)", file=sys.stderr)
|
|
229
|
+
sys.exit(1)
|
|
230
|
+
|
|
231
|
+
# Remove '--' separator if present in pytest_args
|
|
232
|
+
pytest_extra_args = args.pytest_args
|
|
233
|
+
if pytest_extra_args and pytest_extra_args[0] == '--':
|
|
234
|
+
pytest_extra_args = pytest_extra_args[1:]
|
|
235
|
+
|
|
236
|
+
# Run tests
|
|
237
|
+
exit_code = runner.run_tests(
|
|
238
|
+
affected_tests,
|
|
239
|
+
dry_run=args.dry_run,
|
|
240
|
+
verbose=args.verbose,
|
|
241
|
+
pytest_args=pytest_extra_args
|
|
242
|
+
)
|
|
243
|
+
sys.exit(exit_code)
|
|
244
|
+
|
|
245
|
+
except KeyboardInterrupt:
|
|
246
|
+
print("\nInterrupted by user", file=sys.stderr)
|
|
247
|
+
sys.exit(130)
|
|
248
|
+
except Exception as e:
|
|
249
|
+
print(f"â Error: {e}", file=sys.stderr)
|
|
250
|
+
if args.verbose:
|
|
251
|
+
import traceback
|
|
252
|
+
traceback.print_exc()
|
|
253
|
+
sys.exit(1)
|
|
254
|
+
finally:
|
|
255
|
+
if mapping_db_obj and hasattr(mapping_db_obj, 'close'):
|
|
256
|
+
mapping_db_obj.close()
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def cmd_update_mapping(args):
|
|
260
|
+
"""Update test mapping database from coverage."""
|
|
261
|
+
from .update_mapping import update_mapping
|
|
262
|
+
sys.exit(update_mapping(
|
|
263
|
+
args.repo_root,
|
|
264
|
+
args.coverage_file,
|
|
265
|
+
args.mapping_db,
|
|
266
|
+
args.verbose
|
|
267
|
+
))
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def cmd_build_mapping(args):
|
|
271
|
+
"""Build test mapping database iteratively (resumable)."""
|
|
272
|
+
from .build_mapping_iterative import build_mapping_iteratively
|
|
273
|
+
|
|
274
|
+
repo_root = args.repo_root.resolve()
|
|
275
|
+
mapping_db = args.mapping_db or (repo_root / ".test_mapping.db")
|
|
276
|
+
|
|
277
|
+
sys.exit(build_mapping_iteratively(
|
|
278
|
+
repo_root,
|
|
279
|
+
mapping_db,
|
|
280
|
+
verbose=args.verbose
|
|
281
|
+
))
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def cmd_login(args):
|
|
285
|
+
"""Authenticate with Pintest and save API key to ~/.pintest/config.toml."""
|
|
286
|
+
from .config import Config, CloudConfig
|
|
287
|
+
|
|
288
|
+
api_url = args.api_url.rstrip("/")
|
|
289
|
+
|
|
290
|
+
print(f"\nđ Pintest Login ({api_url})")
|
|
291
|
+
print("ââââââââââââââââââââââââââââââââââââââââ")
|
|
292
|
+
|
|
293
|
+
# Offer two paths: API key directly, or email/password
|
|
294
|
+
print("\nOptions:")
|
|
295
|
+
print(" 1) Paste an existing API key (from https://pintest.dev/dashboard/keys)")
|
|
296
|
+
print(" 2) Login with email + password to generate a new key")
|
|
297
|
+
choice = input("\nChoice [1/2]: ").strip()
|
|
298
|
+
|
|
299
|
+
try:
|
|
300
|
+
import requests
|
|
301
|
+
except ImportError:
|
|
302
|
+
print("â 'requests' is required: pip install requests")
|
|
303
|
+
sys.exit(1)
|
|
304
|
+
|
|
305
|
+
if choice == "1":
|
|
306
|
+
api_key = getpass.getpass("Paste API key (pt_live_...): ").strip()
|
|
307
|
+
if not api_key.startswith("pt_live_"):
|
|
308
|
+
print("â Invalid key format â expected 'pt_live_...'")
|
|
309
|
+
sys.exit(1)
|
|
310
|
+
else:
|
|
311
|
+
email = input("Email: ").strip()
|
|
312
|
+
password = getpass.getpass("Password: ")
|
|
313
|
+
|
|
314
|
+
# Login to get JWT, then create a new API key
|
|
315
|
+
resp = requests.post(
|
|
316
|
+
f"{api_url}/api/v1/auth/login",
|
|
317
|
+
json={"email": email, "password": password},
|
|
318
|
+
timeout=15,
|
|
319
|
+
)
|
|
320
|
+
if resp.status_code == 401:
|
|
321
|
+
print("â Invalid email or password")
|
|
322
|
+
sys.exit(1)
|
|
323
|
+
resp.raise_for_status()
|
|
324
|
+
jwt_token = resp.json()["access_token"]
|
|
325
|
+
|
|
326
|
+
# Create a new API key scoped to query + push
|
|
327
|
+
key_resp = requests.post(
|
|
328
|
+
f"{api_url}/api/v1/auth/keys",
|
|
329
|
+
json={"name": "cli-key", "scopes": ["query", "push"]},
|
|
330
|
+
headers={"Authorization": f"Bearer {jwt_token}"},
|
|
331
|
+
timeout=15,
|
|
332
|
+
)
|
|
333
|
+
key_resp.raise_for_status()
|
|
334
|
+
api_key = key_resp.json()["key"]
|
|
335
|
+
print(f"â New API key created (ID: {key_resp.json()['id']})")
|
|
336
|
+
|
|
337
|
+
# Save to config
|
|
338
|
+
cfg = Config.load()
|
|
339
|
+
cfg.cloud = CloudConfig(api_key=api_key, api_url=api_url)
|
|
340
|
+
cfg.save()
|
|
341
|
+
|
|
342
|
+
print(f"\nâ
Logged in! Config saved to {Config.CONFIG_FILE}")
|
|
343
|
+
print(f" Next step: pintest track")
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def cmd_register(args):
|
|
347
|
+
"""Register a new account with Pintest."""
|
|
348
|
+
api_url = args.api_url.rstrip("/")
|
|
349
|
+
print(f"\nđ Pintest Registration ({api_url})")
|
|
350
|
+
print("ââââââââââââââââââââââââââââââââââââââââ")
|
|
351
|
+
|
|
352
|
+
email = input("Email: ").strip()
|
|
353
|
+
password = getpass.getpass("Password: ")
|
|
354
|
+
org_name = input("Organization Name: ").strip()
|
|
355
|
+
|
|
356
|
+
try:
|
|
357
|
+
import requests
|
|
358
|
+
resp = requests.post(
|
|
359
|
+
f"{api_url}/api/v1/auth/register",
|
|
360
|
+
json={"email": email, "password": password, "org_name": org_name},
|
|
361
|
+
timeout=15,
|
|
362
|
+
)
|
|
363
|
+
if resp.status_code == 400:
|
|
364
|
+
print(f"â {resp.json().get('detail', 'Registration failed')}")
|
|
365
|
+
sys.exit(1)
|
|
366
|
+
resp.raise_for_status()
|
|
367
|
+
print("â
Account created successfully!")
|
|
368
|
+
print(f" Organization '{org_name}' registered on the Free plan.")
|
|
369
|
+
print(" You can now log in using 'pintest login'")
|
|
370
|
+
except Exception as e:
|
|
371
|
+
print(f"â Error: {e}")
|
|
372
|
+
sys.exit(1)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def cmd_track(args):
|
|
376
|
+
"""Register a repository with Pintest and save repo_id to config."""
|
|
377
|
+
import os
|
|
378
|
+
import sys
|
|
379
|
+
from .config import Config
|
|
380
|
+
|
|
381
|
+
repo_name = args.name
|
|
382
|
+
if not repo_name:
|
|
383
|
+
repo_name = os.path.basename(os.getcwd())
|
|
384
|
+
|
|
385
|
+
branch = args.branch
|
|
386
|
+
if not branch:
|
|
387
|
+
try:
|
|
388
|
+
branch = input("đŋ Target branch to track [main]: ").strip()
|
|
389
|
+
if not branch:
|
|
390
|
+
branch = "main"
|
|
391
|
+
except (KeyboardInterrupt, EOFError):
|
|
392
|
+
print("\nâ Cancelled")
|
|
393
|
+
sys.exit(1)
|
|
394
|
+
|
|
395
|
+
cfg = Config.load()
|
|
396
|
+
if not cfg.is_cloud_enabled:
|
|
397
|
+
print("â Not logged in. Run: pintest login")
|
|
398
|
+
sys.exit(1)
|
|
399
|
+
|
|
400
|
+
try:
|
|
401
|
+
import requests
|
|
402
|
+
except ImportError:
|
|
403
|
+
print("â 'requests' is required: pip install requests")
|
|
404
|
+
sys.exit(1)
|
|
405
|
+
|
|
406
|
+
api_url = cfg.cloud.api_url
|
|
407
|
+
headers = {
|
|
408
|
+
"Authorization": f"Bearer {cfg.cloud.api_key}",
|
|
409
|
+
"Content-Type": "application/json",
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
print(f"\nđĻ Registering repo '{repo_name}' with Pintest...")
|
|
413
|
+
|
|
414
|
+
resp = requests.post(
|
|
415
|
+
f"{api_url}/api/v1/repos",
|
|
416
|
+
json={
|
|
417
|
+
"name": repo_name,
|
|
418
|
+
"remote_url": args.remote_url,
|
|
419
|
+
"default_branch": branch,
|
|
420
|
+
},
|
|
421
|
+
headers=headers,
|
|
422
|
+
timeout=15,
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
if resp.status_code == 400:
|
|
426
|
+
detail = resp.json().get("detail", "")
|
|
427
|
+
print(f"â {detail}")
|
|
428
|
+
sys.exit(1)
|
|
429
|
+
|
|
430
|
+
if resp.status_code == 402:
|
|
431
|
+
detail = resp.json().get("detail", "")
|
|
432
|
+
print(f"\nâ {detail}")
|
|
433
|
+
sys.exit(1)
|
|
434
|
+
|
|
435
|
+
resp.raise_for_status()
|
|
436
|
+
|
|
437
|
+
repo = resp.json()
|
|
438
|
+
repo_id = repo["id"]
|
|
439
|
+
|
|
440
|
+
# Save repo_id and branch to config
|
|
441
|
+
cfg.cloud.repo_id = repo_id
|
|
442
|
+
cfg.cloud.branch = branch
|
|
443
|
+
cfg.save()
|
|
444
|
+
|
|
445
|
+
print(f"\nâ
Repository registered!")
|
|
446
|
+
print(f" Name: {repo['name']}")
|
|
447
|
+
print(f" ID: {repo_id}")
|
|
448
|
+
print(f" Branch: {branch}")
|
|
449
|
+
print(f" Config: ~/.pintest/config.toml")
|
|
450
|
+
print(f"\n Your pre-commit hook will now use the Pintest cloud API automatically.")
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def cmd_push(args):
|
|
454
|
+
"""Sync local coverage mappings to Pintest cloud."""
|
|
455
|
+
from .cloud_mapping_db import CloudMappingDB
|
|
456
|
+
from .config import Config
|
|
457
|
+
|
|
458
|
+
repo_root = Path.cwd()
|
|
459
|
+
config = Config.load()
|
|
460
|
+
|
|
461
|
+
if not config.is_cloud_enabled or not config.cloud.repo_id:
|
|
462
|
+
print("â Repository not registered. Run 'pintest track' first.")
|
|
463
|
+
sys.exit(1)
|
|
464
|
+
|
|
465
|
+
coverage_file = repo_root / ".coverage"
|
|
466
|
+
if not coverage_file.exists():
|
|
467
|
+
print(f"â Coverage file not found at {coverage_file}")
|
|
468
|
+
print("Run your tests first (e.g. 'pytest --cov --cov-context=test')")
|
|
469
|
+
sys.exit(1)
|
|
470
|
+
|
|
471
|
+
print(f"âī¸ Pintest Cloud Sync (repo: {config.cloud.repo_id[:8]}...)")
|
|
472
|
+
db = CloudMappingDB(config.cloud)
|
|
473
|
+
|
|
474
|
+
success = db.push_coverage(coverage_file, verbose=args.verbose)
|
|
475
|
+
|
|
476
|
+
if success:
|
|
477
|
+
print("â
Success!")
|
|
478
|
+
else:
|
|
479
|
+
print("â Push failed.")
|
|
480
|
+
sys.exit(1)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def main():
|
|
484
|
+
"""CLI entry point."""
|
|
485
|
+
parser = argparse.ArgumentParser(
|
|
486
|
+
description="Pintest - Run only tests affected by code changes",
|
|
487
|
+
formatter_class=argparse.RawDescriptionHelpFormatter
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
subparsers = parser.add_subparsers(dest='command', help='Commands')
|
|
491
|
+
|
|
492
|
+
# Run command (default behavior)
|
|
493
|
+
run_parser = subparsers.add_parser(
|
|
494
|
+
'run',
|
|
495
|
+
help='Run affected tests',
|
|
496
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
497
|
+
epilog="""
|
|
498
|
+
Examples:
|
|
499
|
+
pintest run --repo-root ~/workspace/myproject
|
|
500
|
+
pintest run --repo-root ~/workspace/myproject --dry-run
|
|
501
|
+
pintest run --repo-root ~/workspace/myproject --base-branch develop
|
|
502
|
+
"""
|
|
503
|
+
)
|
|
504
|
+
run_parser.add_argument(
|
|
505
|
+
"--repo-root",
|
|
506
|
+
type=Path,
|
|
507
|
+
default=Path.cwd(),
|
|
508
|
+
help="Repository root directory (default: current directory)"
|
|
509
|
+
)
|
|
510
|
+
run_parser.add_argument(
|
|
511
|
+
"--base-branch",
|
|
512
|
+
default="master",
|
|
513
|
+
help="Base branch to compare against (default: master)"
|
|
514
|
+
)
|
|
515
|
+
run_parser.add_argument(
|
|
516
|
+
"--coverage-file",
|
|
517
|
+
type=Path,
|
|
518
|
+
help="Path to coverage database file (default: <repo>/.coverage)"
|
|
519
|
+
)
|
|
520
|
+
run_parser.add_argument(
|
|
521
|
+
"--dry-run",
|
|
522
|
+
action="store_true",
|
|
523
|
+
help="Print tests without running them"
|
|
524
|
+
)
|
|
525
|
+
run_parser.add_argument(
|
|
526
|
+
"--min-tests",
|
|
527
|
+
type=int,
|
|
528
|
+
default=0,
|
|
529
|
+
help="Minimum number of tests to run (exit with error if below)"
|
|
530
|
+
)
|
|
531
|
+
run_parser.add_argument(
|
|
532
|
+
"-v", "--verbose",
|
|
533
|
+
action="store_true",
|
|
534
|
+
help="Show detailed output"
|
|
535
|
+
)
|
|
536
|
+
run_parser.add_argument(
|
|
537
|
+
"pytest_args",
|
|
538
|
+
nargs=argparse.REMAINDER,
|
|
539
|
+
help="Additional arguments to pass to pytest (after --)"
|
|
540
|
+
)
|
|
541
|
+
run_parser.set_defaults(func=cmd_run)
|
|
542
|
+
|
|
543
|
+
# Update mapping command
|
|
544
|
+
update_parser = subparsers.add_parser(
|
|
545
|
+
'update-mapping',
|
|
546
|
+
help='Update test mapping database from coverage',
|
|
547
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
548
|
+
epilog="""
|
|
549
|
+
Examples:
|
|
550
|
+
pintest update-mapping --repo-root ~/workspace/myproject
|
|
551
|
+
pintest update-mapping --repo-root ~/workspace/myproject --verbose
|
|
552
|
+
"""
|
|
553
|
+
)
|
|
554
|
+
update_parser.add_argument(
|
|
555
|
+
"--repo-root",
|
|
556
|
+
type=Path,
|
|
557
|
+
default=Path.cwd(),
|
|
558
|
+
help="Repository root directory (default: current directory)"
|
|
559
|
+
)
|
|
560
|
+
update_parser.add_argument(
|
|
561
|
+
"--coverage-file",
|
|
562
|
+
type=Path,
|
|
563
|
+
help="Path to .coverage file (default: <repo>/.coverage)"
|
|
564
|
+
)
|
|
565
|
+
update_parser.add_argument(
|
|
566
|
+
"--mapping-db",
|
|
567
|
+
type=Path,
|
|
568
|
+
help="Path to mapping database (default: <repo>/.test_mapping.db)"
|
|
569
|
+
)
|
|
570
|
+
update_parser.add_argument(
|
|
571
|
+
"-v", "--verbose",
|
|
572
|
+
action="store_true",
|
|
573
|
+
help="Verbose output"
|
|
574
|
+
)
|
|
575
|
+
update_parser.set_defaults(func=cmd_update_mapping)
|
|
576
|
+
|
|
577
|
+
# Build mapping iteratively command
|
|
578
|
+
build_parser = subparsers.add_parser(
|
|
579
|
+
'build-mapping',
|
|
580
|
+
help='Build test mapping database iteratively (resumable)',
|
|
581
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
582
|
+
epilog="""
|
|
583
|
+
Examples:
|
|
584
|
+
pintest build-mapping --repo-root ~/workspace/myproject
|
|
585
|
+
pintest build-mapping --repo-root ~/workspace/myproject --resume
|
|
586
|
+
pintest build-mapping --repo-root ~/workspace/myproject --verbose
|
|
587
|
+
"""
|
|
588
|
+
)
|
|
589
|
+
build_parser.add_argument(
|
|
590
|
+
"--repo-root",
|
|
591
|
+
type=Path,
|
|
592
|
+
default=Path.cwd(),
|
|
593
|
+
help="Repository root directory (default: current directory)"
|
|
594
|
+
)
|
|
595
|
+
build_parser.add_argument(
|
|
596
|
+
"--mapping-db",
|
|
597
|
+
type=Path,
|
|
598
|
+
help="Path to mapping database (default: <repo>/.test_mapping.db)"
|
|
599
|
+
)
|
|
600
|
+
build_parser.add_argument(
|
|
601
|
+
"-v", "--verbose",
|
|
602
|
+
action="store_true",
|
|
603
|
+
help="Verbose output"
|
|
604
|
+
)
|
|
605
|
+
build_parser.set_defaults(func=cmd_build_mapping)
|
|
606
|
+
|
|
607
|
+
# Login command
|
|
608
|
+
login_parser = subparsers.add_parser(
|
|
609
|
+
'login',
|
|
610
|
+
help='Authenticate with Pintest cloud (saves API key to ~/.pintest/config.toml)',
|
|
611
|
+
)
|
|
612
|
+
login_parser.add_argument(
|
|
613
|
+
"--api-url",
|
|
614
|
+
default="https://api.pintest.dev",
|
|
615
|
+
help="Pintest API URL (default: https://api.pintest.dev)"
|
|
616
|
+
)
|
|
617
|
+
login_parser.set_defaults(func=cmd_login)
|
|
618
|
+
|
|
619
|
+
# Register command
|
|
620
|
+
register_parser = subparsers.add_parser(
|
|
621
|
+
'register',
|
|
622
|
+
help='Create a new Pintest account',
|
|
623
|
+
)
|
|
624
|
+
register_parser.add_argument(
|
|
625
|
+
"--api-url",
|
|
626
|
+
default="https://api.pintest.dev",
|
|
627
|
+
help="Pintest API URL (default: https://api.pintest.dev)"
|
|
628
|
+
)
|
|
629
|
+
register_parser.set_defaults(func=cmd_register)
|
|
630
|
+
|
|
631
|
+
# Track command
|
|
632
|
+
track_parser = subparsers.add_parser(
|
|
633
|
+
'track',
|
|
634
|
+
help='Register this repository with Pintest cloud to track it',
|
|
635
|
+
)
|
|
636
|
+
track_parser.add_argument(
|
|
637
|
+
'--name', required=False,
|
|
638
|
+
help='Repository name (defaults to current directory name)'
|
|
639
|
+
)
|
|
640
|
+
track_parser.add_argument(
|
|
641
|
+
'--branch', '-b', default=None,
|
|
642
|
+
help='Default branch to track (prompts if not provided)'
|
|
643
|
+
)
|
|
644
|
+
track_parser.add_argument(
|
|
645
|
+
'--remote-url',
|
|
646
|
+
default=None,
|
|
647
|
+
help='Remote URL (e.g. https://github.com/org/repo) â optional'
|
|
648
|
+
)
|
|
649
|
+
track_parser.set_defaults(func=cmd_track)
|
|
650
|
+
|
|
651
|
+
# Push command
|
|
652
|
+
push_parser = subparsers.add_parser(
|
|
653
|
+
'push',
|
|
654
|
+
help='Sync local coverage mappings to Pintest cloud',
|
|
655
|
+
)
|
|
656
|
+
push_parser.add_argument(
|
|
657
|
+
"-v", "--verbose",
|
|
658
|
+
action="store_true",
|
|
659
|
+
help="Verbose output"
|
|
660
|
+
)
|
|
661
|
+
push_parser.set_defaults(func=cmd_push)
|
|
662
|
+
|
|
663
|
+
# Parse arguments
|
|
664
|
+
args = parser.parse_args()
|
|
665
|
+
|
|
666
|
+
# If no command specified, default to 'run'
|
|
667
|
+
if not args.command:
|
|
668
|
+
# Parse as 'run' command for backward compatibility
|
|
669
|
+
run_args = ['run'] + sys.argv[1:]
|
|
670
|
+
args = parser.parse_args(run_args)
|
|
671
|
+
|
|
672
|
+
# Execute command
|
|
673
|
+
if hasattr(args, 'func'):
|
|
674
|
+
args.func(args)
|
|
675
|
+
else:
|
|
676
|
+
parser.print_help()
|
|
677
|
+
sys.exit(1)
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
if __name__ == "__main__":
|
|
681
|
+
main()
|