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.
@@ -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())