deployproof 0.1.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.
deployproof/diff.py ADDED
@@ -0,0 +1,234 @@
1
+ """Git diff-scoping resolver for DeployProof."""
2
+
3
+ import os
4
+ import subprocess
5
+ from pathlib import Path
6
+ from typing import List, Optional, Set
7
+
8
+ IGNORED_DIRS = {
9
+ ".venv",
10
+ "venv",
11
+ ".env",
12
+ "env",
13
+ "node_modules",
14
+ "build",
15
+ "dist",
16
+ ".tox",
17
+ ".mutmut-cache",
18
+ "scratch_repos",
19
+ ".git",
20
+ "__pycache__",
21
+ ".pytest_cache",
22
+ }
23
+
24
+
25
+ class DiffScopeError(Exception):
26
+ """Base exception for diff scoping errors."""
27
+ pass
28
+
29
+
30
+ class NotAGitRepositoryError(DiffScopeError):
31
+ """Raised when current working directory is not a git repository."""
32
+ pass
33
+
34
+
35
+ class InvalidBaseRefError(DiffScopeError):
36
+ """Raised when a specified base ref does not exist."""
37
+ pass
38
+
39
+
40
+ def run_git(args: List[str], cwd: Path) -> subprocess.CompletedProcess:
41
+ """Run a git command in the specified directory."""
42
+ try:
43
+ return subprocess.run(
44
+ ["git", *args],
45
+ cwd=cwd,
46
+ capture_output=True,
47
+ text=True,
48
+ encoding="utf-8",
49
+ errors="replace",
50
+ check=False,
51
+ )
52
+ except FileNotFoundError:
53
+ raise DiffScopeError("git executable not found in PATH.")
54
+
55
+
56
+ def is_git_repo(cwd: Path) -> bool:
57
+ """Check if the directory is inside a git work tree."""
58
+ res = run_git(["rev-parse", "--is-inside-work-tree"], cwd)
59
+ return res.returncode == 0 and res.stdout.strip() == "true"
60
+
61
+
62
+ def get_git_root(cwd: Path) -> Path:
63
+ """Get the root directory of the git repository."""
64
+ if not is_git_repo(cwd):
65
+ raise NotAGitRepositoryError("Not a git repository.")
66
+ res = run_git(["rev-parse", "--show-toplevel"], cwd)
67
+ if res.returncode != 0:
68
+ raise NotAGitRepositoryError("Failed to determine git repository root.")
69
+ return Path(res.stdout.strip()).resolve()
70
+
71
+
72
+ def verify_ref_exists(ref: str, cwd: Path) -> bool:
73
+ """Check if a git ref exists."""
74
+ res = run_git(["rev-parse", "--verify", "--quiet", ref], cwd)
75
+ return res.returncode == 0
76
+
77
+
78
+ def has_commits(cwd: Path) -> bool:
79
+ """Check if the repository has at least one commit."""
80
+ res = run_git(["rev-parse", "--verify", "--quiet", "HEAD"], cwd)
81
+ return res.returncode == 0
82
+
83
+
84
+ def is_test_file(path: Path) -> bool:
85
+ """
86
+ Determine whether a path is a test file or located within a test folder.
87
+
88
+ Test files should be excluded from mutation targets (though they verify targets).
89
+ """
90
+ name = path.name.lower()
91
+ if name.startswith("test_") or name.endswith("_test.py") or name == "conftest.py":
92
+ return True
93
+
94
+ parts = [p.lower() for p in path.parts]
95
+ if "tests" in parts or "test" in parts or "testing" in parts:
96
+ return True
97
+ return False
98
+
99
+
100
+ def get_uncommitted_session_files(root: Path) -> Set[Path]:
101
+ """Get uncommitted (staged, unstaged, untracked) files across all extensions."""
102
+ files: Set[Path] = set()
103
+
104
+ # 1. Check status for modified, staged, untracked files
105
+ res = run_git(["status", "--porcelain"], root)
106
+ if res.returncode == 0:
107
+ for line in res.stdout.splitlines():
108
+ line = line.strip()
109
+ if not line:
110
+ continue
111
+ parts = line.split(maxsplit=1)
112
+ if len(parts) == 2:
113
+ status, rel_path = parts[0], parts[1]
114
+ if "->" in rel_path:
115
+ rel_path = rel_path.split("->")[-1].strip()
116
+ rel_path = rel_path.strip('"')
117
+ p = (root / rel_path).resolve()
118
+ if p.is_file():
119
+ files.add(p)
120
+
121
+ # 2. Check unstaged diff against HEAD (if commits exist)
122
+ if has_commits(root):
123
+ res_diff = run_git(["diff", "--name-only", "HEAD"], root)
124
+ if res_diff.returncode == 0:
125
+ for rel_path in res_diff.stdout.splitlines():
126
+ rel_path = rel_path.strip().strip('"')
127
+ if rel_path:
128
+ p = (root / rel_path).resolve()
129
+ if p.is_file():
130
+ files.add(p)
131
+
132
+ return files
133
+
134
+
135
+ def get_latest_commit_session_files(root: Path) -> Set[Path]:
136
+ """Get all files changed in the most recent commit (HEAD)."""
137
+ files: Set[Path] = set()
138
+ if not has_commits(root):
139
+ return files
140
+
141
+ res = run_git(["diff-tree", "--no-commit-id", "--name-only", "-r", "--root", "HEAD"], root)
142
+ if res.returncode == 0:
143
+ for rel_path in res.stdout.splitlines():
144
+ rel_path = rel_path.strip().strip('"')
145
+ if rel_path:
146
+ p = (root / rel_path).resolve()
147
+ if p.is_file():
148
+ files.add(p)
149
+ return files
150
+
151
+
152
+ def get_diff_against_base_session_files(root: Path, base: str) -> Set[Path]:
153
+ """Get files changed between base ref and working tree / HEAD."""
154
+ if not verify_ref_exists(base, root):
155
+ raise InvalidBaseRefError(f"Git reference '{base}' does not exist.")
156
+
157
+ files: Set[Path] = set()
158
+
159
+ # Diff base against working tree
160
+ res = run_git(["diff", "--name-only", base], root)
161
+ if res.returncode == 0:
162
+ for rel_path in res.stdout.splitlines():
163
+ rel_path = rel_path.strip().strip('"')
164
+ if rel_path:
165
+ p = (root / rel_path).resolve()
166
+ if p.is_file():
167
+ files.add(p)
168
+
169
+ # Include untracked files
170
+ res_status = run_git(["status", "--porcelain"], root)
171
+ if res_status.returncode == 0:
172
+ for line in res_status.stdout.splitlines():
173
+ line = line.strip()
174
+ if line.startswith("??"):
175
+ parts = line.split(maxsplit=1)
176
+ if len(parts) == 2:
177
+ rel_path = parts[1].strip().strip('"')
178
+ p = (root / rel_path).resolve()
179
+ if p.is_file():
180
+ files.add(p)
181
+
182
+ return files
183
+
184
+
185
+ def resolve_changed_session_files(
186
+ cwd: Optional[Path] = None,
187
+ base: Optional[str] = None,
188
+ ) -> List[Path]:
189
+ """
190
+ Resolve list of all changed session files (any extension) for secret scanning and general scoping.
191
+ """
192
+ target_dir = (cwd or Path.cwd()).resolve()
193
+ root = get_git_root(target_dir)
194
+
195
+ if base:
196
+ changed = get_diff_against_base_session_files(root, base)
197
+ else:
198
+ # Step 1: Check working-tree uncommitted changes
199
+ changed = get_uncommitted_session_files(root)
200
+ # Step 2: If clean, check latest commit
201
+ if not changed:
202
+ changed = get_latest_commit_session_files(root)
203
+
204
+ filtered: List[Path] = []
205
+ for p in changed:
206
+ try:
207
+ rel_parts = set(p.relative_to(root).parts)
208
+ except ValueError:
209
+ rel_parts = set(p.parts)
210
+ if rel_parts.intersection(IGNORED_DIRS):
211
+ continue
212
+ filtered.append(p)
213
+
214
+ return sorted(filtered)
215
+
216
+
217
+ def resolve_changed_python_files(
218
+ cwd: Optional[Path] = None,
219
+ base: Optional[str] = None,
220
+ include_tests: bool = False,
221
+ ) -> List[Path]:
222
+ """
223
+ Resolve list of changed Python files based on the Smart Session Cascade or --base flag.
224
+
225
+ Excludes virtualenvs, build directories, and test files (unless include_tests=True).
226
+ Returns sorted list of absolute Paths.
227
+ """
228
+ all_session_files = resolve_changed_session_files(cwd=cwd, base=base)
229
+ py_files = [p for p in all_session_files if p.suffix == ".py"]
230
+
231
+ if not include_tests:
232
+ py_files = [p for p in py_files if not is_test_file(p)]
233
+
234
+ return sorted(py_files)