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
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Parse git diff output to identify changed files and lines."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import subprocess
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Dict, List, Set
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class LineRange:
|
|
12
|
+
"""Represents a range of changed lines."""
|
|
13
|
+
start: int
|
|
14
|
+
count: int
|
|
15
|
+
|
|
16
|
+
def get_lines(self) -> Set[int]:
|
|
17
|
+
"""Get set of all line numbers in this range."""
|
|
18
|
+
if self.count == 0:
|
|
19
|
+
return set()
|
|
20
|
+
return set(range(self.start, self.start + self.count))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class FileChange:
|
|
25
|
+
"""Represents changes to a single file."""
|
|
26
|
+
file_path: str
|
|
27
|
+
is_new: bool = False
|
|
28
|
+
is_deleted: bool = False
|
|
29
|
+
added_lines: List[LineRange] = field(default_factory=list)
|
|
30
|
+
modified_lines: List[LineRange] = field(default_factory=list)
|
|
31
|
+
|
|
32
|
+
def get_all_changed_lines(self) -> Set[int]:
|
|
33
|
+
"""Get set of all changed line numbers."""
|
|
34
|
+
lines = set()
|
|
35
|
+
for line_range in self.added_lines + self.modified_lines:
|
|
36
|
+
lines.update(line_range.get_lines())
|
|
37
|
+
return lines
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class GitDiffParser:
|
|
41
|
+
"""Parse git diff output to find changed files and lines."""
|
|
42
|
+
|
|
43
|
+
# Regex for diff hunk header: @@ -old_start,old_count +new_start,new_count @@
|
|
44
|
+
HUNK_HEADER_RE = re.compile(r'^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@')
|
|
45
|
+
|
|
46
|
+
def __init__(self, repo_root: Path):
|
|
47
|
+
"""Initialize parser with repository root."""
|
|
48
|
+
self.repo_root = Path(repo_root)
|
|
49
|
+
|
|
50
|
+
def get_diff(self, base_branch: str = "master") -> str:
|
|
51
|
+
"""
|
|
52
|
+
Get git diff output comparing current HEAD to base branch.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
base_branch: Branch to compare against
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
Raw diff output
|
|
59
|
+
"""
|
|
60
|
+
try:
|
|
61
|
+
if base_branch == "HEAD":
|
|
62
|
+
# For pre-commit hooks, we usually want staged changes
|
|
63
|
+
cmd = ["git", "diff", "--cached", "--unified=0", "HEAD"]
|
|
64
|
+
else:
|
|
65
|
+
cmd = ["git", "diff", "--unified=0", f"{base_branch}...HEAD"]
|
|
66
|
+
|
|
67
|
+
result = subprocess.run(
|
|
68
|
+
cmd,
|
|
69
|
+
cwd=self.repo_root,
|
|
70
|
+
capture_output=True,
|
|
71
|
+
text=True,
|
|
72
|
+
check=True
|
|
73
|
+
)
|
|
74
|
+
return result.stdout
|
|
75
|
+
except subprocess.CalledProcessError as e:
|
|
76
|
+
raise RuntimeError(f"Failed to get git diff: {e.stderr}")
|
|
77
|
+
|
|
78
|
+
def parse_diff(self, diff_output: str) -> Dict[str, FileChange]:
|
|
79
|
+
"""
|
|
80
|
+
Parse git diff output into file changes.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
diff_output: Raw git diff output
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
Dict mapping file paths to FileChange objects
|
|
87
|
+
"""
|
|
88
|
+
changes = {}
|
|
89
|
+
current_file = None
|
|
90
|
+
|
|
91
|
+
for line in diff_output.split('\n'):
|
|
92
|
+
if line.startswith('diff --git'):
|
|
93
|
+
# Reset current file
|
|
94
|
+
current_file = None
|
|
95
|
+
|
|
96
|
+
elif line.startswith('---'):
|
|
97
|
+
# Old file path: --- a/path/to/file.py or --- /dev/null
|
|
98
|
+
filepath = line[6:].strip() # Remove '--- a/'
|
|
99
|
+
if filepath == '/dev/null':
|
|
100
|
+
# File is new (will be set when we see +++ line)
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
elif line.startswith('+++'):
|
|
104
|
+
# New file path: +++ b/path/to/file.py or +++ /dev/null
|
|
105
|
+
filepath = line[6:].strip() # Remove '+++ b/'
|
|
106
|
+
|
|
107
|
+
if filepath == '/dev/null':
|
|
108
|
+
# File was deleted
|
|
109
|
+
if current_file and current_file in changes:
|
|
110
|
+
changes[current_file].is_deleted = True
|
|
111
|
+
else:
|
|
112
|
+
current_file = filepath
|
|
113
|
+
if current_file not in changes:
|
|
114
|
+
changes[current_file] = FileChange(file_path=current_file)
|
|
115
|
+
|
|
116
|
+
# Check if file is new (previous line was --- /dev/null)
|
|
117
|
+
# We'll mark it as new if we haven't seen it before
|
|
118
|
+
|
|
119
|
+
elif line.startswith('new file mode'):
|
|
120
|
+
if current_file and current_file in changes:
|
|
121
|
+
changes[current_file].is_new = True
|
|
122
|
+
|
|
123
|
+
elif line.startswith('deleted file mode'):
|
|
124
|
+
if current_file and current_file in changes:
|
|
125
|
+
changes[current_file].is_deleted = True
|
|
126
|
+
|
|
127
|
+
elif line.startswith('@@') and current_file:
|
|
128
|
+
# Hunk header with line numbers
|
|
129
|
+
match = self.HUNK_HEADER_RE.match(line)
|
|
130
|
+
if match:
|
|
131
|
+
old_start = int(match.group(1)) if match.group(1) else 0
|
|
132
|
+
old_count = int(match.group(2)) if match.group(2) else 1
|
|
133
|
+
new_start = int(match.group(3)) if match.group(3) else 0
|
|
134
|
+
new_count = int(match.group(4)) if match.group(4) else 1
|
|
135
|
+
|
|
136
|
+
if new_count > 0:
|
|
137
|
+
line_range = LineRange(start=new_start, count=new_count)
|
|
138
|
+
|
|
139
|
+
# If old_count is 0, these are pure additions
|
|
140
|
+
if old_count == 0:
|
|
141
|
+
changes[current_file].added_lines.append(line_range)
|
|
142
|
+
else:
|
|
143
|
+
# Otherwise, treat as modifications
|
|
144
|
+
changes[current_file].modified_lines.append(line_range)
|
|
145
|
+
|
|
146
|
+
return changes
|
|
147
|
+
|
|
148
|
+
def filter_python_files(self, changes: Dict[str, FileChange]) -> Dict[str, FileChange]:
|
|
149
|
+
"""Filter changes to only include Python files."""
|
|
150
|
+
return {
|
|
151
|
+
path: change
|
|
152
|
+
for path, change in changes.items()
|
|
153
|
+
if path.endswith('.py') and not change.is_deleted
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
def get_changed_test_files(self, changes: Dict[str, FileChange]) -> Set[str]:
|
|
157
|
+
"""Extract test files from changes that should always be run."""
|
|
158
|
+
test_files = set()
|
|
159
|
+
for path, change in changes.items():
|
|
160
|
+
if self._is_test_file(path) and not change.is_deleted:
|
|
161
|
+
# Return full path for pytest
|
|
162
|
+
test_files.add(path)
|
|
163
|
+
return test_files
|
|
164
|
+
|
|
165
|
+
@staticmethod
|
|
166
|
+
def _is_test_file(filepath: str) -> bool:
|
|
167
|
+
"""Check if filepath is a test file."""
|
|
168
|
+
path = Path(filepath)
|
|
169
|
+
# A file is a test file if:
|
|
170
|
+
# 1. It's a Python file (.py extension)
|
|
171
|
+
# 2. Its name starts with 'test_'
|
|
172
|
+
# This ensures fixtures, helpers, and utilities in test directories are not mistaken for tests
|
|
173
|
+
return path.suffix == '.py' and path.name.startswith('test_')
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def main():
|
|
177
|
+
"""CLI for testing the parser."""
|
|
178
|
+
import sys
|
|
179
|
+
|
|
180
|
+
if len(sys.argv) < 2:
|
|
181
|
+
print("Usage: git_diff_parser.py <repo_root> [base_branch]")
|
|
182
|
+
sys.exit(1)
|
|
183
|
+
|
|
184
|
+
repo_root = Path(sys.argv[1])
|
|
185
|
+
base_branch = sys.argv[2] if len(sys.argv) > 2 else "master"
|
|
186
|
+
|
|
187
|
+
parser = GitDiffParser(repo_root)
|
|
188
|
+
|
|
189
|
+
try:
|
|
190
|
+
diff_output = parser.get_diff(base_branch)
|
|
191
|
+
changes = parser.parse_diff(diff_output)
|
|
192
|
+
python_changes = parser.filter_python_files(changes)
|
|
193
|
+
|
|
194
|
+
print(f"Total files changed: {len(changes)}")
|
|
195
|
+
print(f"Python files changed: {len(python_changes)}")
|
|
196
|
+
print(f"Test files changed: {len(parser.get_changed_test_files(changes))}")
|
|
197
|
+
print("\nChanged Python files:")
|
|
198
|
+
|
|
199
|
+
for filepath, change in sorted(python_changes.items()):
|
|
200
|
+
status = []
|
|
201
|
+
if change.is_new:
|
|
202
|
+
status.append("NEW")
|
|
203
|
+
if change.is_deleted:
|
|
204
|
+
status.append("DELETED")
|
|
205
|
+
|
|
206
|
+
status_str = f" [{', '.join(status)}]" if status else ""
|
|
207
|
+
print(f"\n {filepath}{status_str}")
|
|
208
|
+
|
|
209
|
+
all_lines = change.get_all_changed_lines()
|
|
210
|
+
if all_lines:
|
|
211
|
+
# Group consecutive lines into ranges for display
|
|
212
|
+
sorted_lines = sorted(all_lines)
|
|
213
|
+
ranges = []
|
|
214
|
+
start = sorted_lines[0]
|
|
215
|
+
prev = start
|
|
216
|
+
|
|
217
|
+
for line_num in sorted_lines[1:]:
|
|
218
|
+
if line_num != prev + 1:
|
|
219
|
+
ranges.append(f"{start}-{prev}" if start != prev else str(start))
|
|
220
|
+
start = line_num
|
|
221
|
+
prev = line_num
|
|
222
|
+
ranges.append(f"{start}-{prev}" if start != prev else str(start))
|
|
223
|
+
|
|
224
|
+
print(f" Changed lines: {', '.join(ranges)}")
|
|
225
|
+
|
|
226
|
+
except Exception as e:
|
|
227
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
228
|
+
sys.exit(1)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
if __name__ == "__main__":
|
|
232
|
+
main()
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Post-commit hook that automatically pushes commits.
|
|
4
|
+
|
|
5
|
+
This hook runs after a successful commit and pushes the changes to the remote repository.
|
|
6
|
+
Includes the updated test mapping database if it was modified.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main():
|
|
15
|
+
"""
|
|
16
|
+
Post-commit hook: automatically push changes to remote.
|
|
17
|
+
"""
|
|
18
|
+
# Get repository root
|
|
19
|
+
result = subprocess.run(
|
|
20
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
21
|
+
capture_output=True,
|
|
22
|
+
text=True,
|
|
23
|
+
check=False
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
if result.returncode != 0:
|
|
27
|
+
print("ā Failed to find git repository root", file=sys.stderr)
|
|
28
|
+
return 1
|
|
29
|
+
|
|
30
|
+
repo_root = Path(result.stdout.strip())
|
|
31
|
+
|
|
32
|
+
# Get current branch
|
|
33
|
+
result = subprocess.run(
|
|
34
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
35
|
+
cwd=repo_root,
|
|
36
|
+
capture_output=True,
|
|
37
|
+
text=True,
|
|
38
|
+
check=False
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
if result.returncode != 0:
|
|
42
|
+
print("ā Failed to get current branch", file=sys.stderr)
|
|
43
|
+
return 1
|
|
44
|
+
|
|
45
|
+
current_branch = result.stdout.strip()
|
|
46
|
+
|
|
47
|
+
# Check if we have a remote configured
|
|
48
|
+
result = subprocess.run(
|
|
49
|
+
["git", "remote"],
|
|
50
|
+
cwd=repo_root,
|
|
51
|
+
capture_output=True,
|
|
52
|
+
text=True,
|
|
53
|
+
check=False
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
if not result.stdout.strip():
|
|
57
|
+
# No remote configured - skip push
|
|
58
|
+
return 0
|
|
59
|
+
|
|
60
|
+
# Push to remote
|
|
61
|
+
print(f"\nš Pushing to remote: {current_branch}...")
|
|
62
|
+
|
|
63
|
+
result = subprocess.run(
|
|
64
|
+
["git", "push", "origin", current_branch],
|
|
65
|
+
cwd=repo_root
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
if result.returncode == 0:
|
|
69
|
+
print("ā
Successfully pushed to remote")
|
|
70
|
+
return 0
|
|
71
|
+
else:
|
|
72
|
+
print("ā ļø Push failed - you may need to pull or resolve conflicts", file=sys.stderr)
|
|
73
|
+
print(" Run 'git push' manually when ready", file=sys.stderr)
|
|
74
|
+
return 0 # Don't fail the commit if push fails
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
if __name__ == "__main__":
|
|
78
|
+
sys.exit(main())
|