diffimpactscout 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.
@@ -0,0 +1,363 @@
1
+ """Extract Django and FastAPI routes, then link template and frontend references to those routes.
2
+
3
+ Example: a template tag {% url order-list %} resolves to the changed view
4
+ handler that backs the order-list route.
5
+ """
6
+
7
+ import ast
8
+ import os
9
+ import re
10
+
11
+ from diffimpactscout.config import _matches_glob
12
+
13
+
14
+ class Route(object):
15
+ def __init__(self, name, path, handler, module):
16
+ self.name = name
17
+ self.path = path
18
+ self.handler = handler
19
+ self.module = module
20
+
21
+
22
+ _HTTP_METHODS = ("get", "post", "put", "delete", "patch", "options")
23
+ _HTTP_METHOD_RE_SRC = r"get|post|put|delete|patch"
24
+
25
+ HTTP_CALL_RE = re.compile(
26
+ r"(\$http|HttpClient|http)\s*\.\s*(" + _HTTP_METHOD_RE_SRC + r")\s*\("
27
+ )
28
+ QUOTED_LITERAL_RE = re.compile(r"(['\"])([^'\"\r\n]{1,500})\1")
29
+ URL_TAG_RE = re.compile(r"{%\s*url\s+['\"]([^'\"]+)['\"]\s*%}")
30
+
31
+
32
+ def _str_value(node):
33
+ if isinstance(node, ast.Constant):
34
+ value = node.value
35
+ if isinstance(value, str):
36
+ return value
37
+ return None
38
+ Str = getattr(ast, "Str", None)
39
+ if Str is not None and isinstance(node, Str):
40
+ return node.s
41
+ return None
42
+
43
+
44
+ def _iter_files(root, globs, exts):
45
+ if isinstance(globs, str):
46
+ globs = [globs]
47
+ globs = globs or []
48
+ exts = tuple(exts or ())
49
+ exts = tuple(e.lower() for e in exts)
50
+ if not root or not os.path.isdir(root):
51
+ return
52
+ for dirpath, dirnames, filenames in os.walk(root):
53
+ dirnames[:] = [d for d in dirnames if d not in ("node_modules", ".git")]
54
+ for name in filenames:
55
+ if exts and os.path.splitext(name)[1].lower() not in exts:
56
+ continue
57
+ full = os.path.join(dirpath, name)
58
+ rel = os.path.relpath(full, root)
59
+ posix = rel.replace(os.sep, "/")
60
+ for pattern in globs:
61
+ if _matches_glob(posix, pattern):
62
+ yield posix
63
+ break
64
+
65
+
66
+ def _parse_file(full):
67
+ text = _read_text(full)
68
+ if text is None:
69
+ return None
70
+ try:
71
+ return ast.parse(text)
72
+ except (SyntaxError, ValueError, TypeError):
73
+ return None
74
+
75
+
76
+ def _read_text(full):
77
+ try:
78
+ with open(full, "rb") as fh:
79
+ data = fh.read()
80
+ except (OSError, ValueError):
81
+ return None
82
+ return data.decode("utf-8", "replace")
83
+
84
+
85
+ def _call_has_kwarg(call, name):
86
+ for kw in call.keywords:
87
+ if kw.arg == name:
88
+ return True
89
+ return False
90
+
91
+
92
+ def _call_kwarg_value(call, name):
93
+ for kw in call.keywords:
94
+ if kw.arg == name:
95
+ return kw.value
96
+ return None
97
+
98
+
99
+ def _attribute_qname(node):
100
+ parts = []
101
+ cur = node
102
+ while isinstance(cur, ast.Attribute):
103
+ parts.append(cur.attr)
104
+ cur = cur.value
105
+ if isinstance(cur, ast.Name):
106
+ parts.append(cur.id)
107
+ parts.reverse()
108
+ if len(parts) >= 2:
109
+ parts = parts[1:]
110
+ return ".".join(parts)
111
+
112
+
113
+ def _resolve_handler(node):
114
+ if isinstance(node, ast.Call):
115
+ return _resolve_handler(node.func)
116
+ if isinstance(node, ast.Attribute):
117
+ return _attribute_qname(node)
118
+ if isinstance(node, ast.Name):
119
+ return node.id
120
+ return None
121
+
122
+
123
+ def _handler_name(call):
124
+ if len(call.args) < 2:
125
+ return None
126
+ return _resolve_handler(call.args[1])
127
+
128
+
129
+ def extract_django_routes(root, urls_globs):
130
+ routes = []
131
+ for posix in _iter_files(root, urls_globs, (".py",)):
132
+ full = os.path.join(root, posix) if root else posix
133
+ tree = _parse_file(full)
134
+ if tree is None:
135
+ continue
136
+ for node in ast.walk(tree):
137
+ if not isinstance(node, ast.Call):
138
+ continue
139
+ func = node.func
140
+ if not isinstance(func, ast.Name) or func.id not in ("path", "re_path", "url"):
141
+ continue
142
+ if not _call_has_kwarg(node, "name"):
143
+ continue
144
+ path_val = _str_value(node.args[0]) if node.args else None
145
+ if path_val is None:
146
+ continue
147
+ name_val = _str_value(_call_kwarg_value(node, "name"))
148
+ handler = _handler_name(node)
149
+ routes.append(Route(name_val, path_val, handler, posix))
150
+ return routes
151
+
152
+
153
+ _ROUTE_BASES = ("router", "app", "api", "bp", "blueprint", "APIRouter", "FastAPI")
154
+
155
+
156
+ def _decorator_route_path(deco):
157
+ if not isinstance(deco, ast.Call) or not deco.args:
158
+ return None
159
+ if not isinstance(deco.func, ast.Attribute):
160
+ return None
161
+ if deco.func.attr not in _HTTP_METHODS:
162
+ return None
163
+ if not isinstance(deco.func.value, ast.Name):
164
+ return None
165
+ if deco.func.value.id not in _ROUTE_BASES:
166
+ return None
167
+ return _str_value(deco.args[0])
168
+
169
+
170
+ def extract_fastapi_routes(root, py_globs):
171
+ routes = []
172
+ for posix in _iter_files(root, py_globs, (".py",)):
173
+ full = os.path.join(root, posix) if root else posix
174
+ tree = _parse_file(full)
175
+ if tree is None:
176
+ continue
177
+ for node in ast.walk(tree):
178
+ if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
179
+ continue
180
+ for deco in node.decorator_list:
181
+ path = _decorator_route_path(deco)
182
+ if path is not None:
183
+ routes.append(Route(None, path, node.name, posix))
184
+ return routes
185
+
186
+
187
+ def extract_template_refs(root, template_globs):
188
+ refs = []
189
+ for posix in _iter_files(root, template_globs, (".html",)):
190
+ full = os.path.join(root, posix) if root else posix
191
+ text = _read_text(full)
192
+ if text is None:
193
+ continue
194
+ for m in URL_TAG_RE.finditer(text):
195
+ refs.append((posix, m.group(1)))
196
+ return refs
197
+
198
+
199
+ def _block_repl(match):
200
+ return "\n" * match.group(0).count("\n")
201
+
202
+
203
+ def _strip_comments(text):
204
+ text = re.sub(r"/\*.*?\*/", _block_repl, text, flags=re.S)
205
+ text = re.sub(r"(?m)(?<!:)//.*$", "", text)
206
+ return text
207
+
208
+
209
+ def extract_frontend_refs(root, frontend_globs):
210
+ refs = []
211
+ for posix in _iter_files(root, frontend_globs, (".ts", ".js", ".tsx", ".jsx")):
212
+ full = os.path.join(root, posix) if root else posix
213
+ text = _read_text(full)
214
+ if text is None:
215
+ continue
216
+ text = _strip_comments(text)
217
+ for m in HTTP_CALL_RE.finditer(text):
218
+ line = text[:m.start()].count("\n") + 1
219
+ method = m.group(2)
220
+ window = text[m.end():m.end() + 300]
221
+ stripped = window.lstrip()
222
+ if stripped[:1] in ("'", '"'):
223
+ lit = QUOTED_LITERAL_RE.match(stripped)
224
+ if lit:
225
+ refs.append(
226
+ {
227
+ "file": posix,
228
+ "ref": lit.group(2),
229
+ "dynamic": False,
230
+ "line": line,
231
+ "method": method,
232
+ }
233
+ )
234
+ continue
235
+ litm = QUOTED_LITERAL_RE.search(window)
236
+ literal = litm.group(2) if litm else None
237
+ refs.append(
238
+ {
239
+ "file": posix,
240
+ "ref": literal,
241
+ "dynamic": True,
242
+ "line": line,
243
+ "method": method,
244
+ }
245
+ )
246
+ return refs
247
+
248
+
249
+ def _route_regex(route_path):
250
+ parts = []
251
+ i = 0
252
+ n = len(route_path)
253
+ while i < n:
254
+ ch = route_path[i]
255
+ if ch == "<":
256
+ j = route_path.find(">", i)
257
+ if j == -1:
258
+ parts.append(re.escape(route_path[i:]))
259
+ break
260
+ parts.append(r"[^/]+")
261
+ i = j + 1
262
+ elif ch == "{":
263
+ j = route_path.find("}", i)
264
+ if j == -1:
265
+ parts.append(re.escape(route_path[i:]))
266
+ break
267
+ parts.append(r"[^/]+")
268
+ i = j + 1
269
+ else:
270
+ parts.append(re.escape(ch))
271
+ i += 1
272
+ return re.compile("^" + "".join(parts) + "$")
273
+
274
+
275
+ def _is_path_prefix(ref, route_path):
276
+ if not route_path.startswith(ref):
277
+ return False
278
+ if len(ref) == len(route_path):
279
+ return False
280
+ nxt = route_path[len(ref)]
281
+ return ref.endswith("/") or nxt in ("/", "<", "{")
282
+
283
+
284
+ def match_endpoints(refs, routes):
285
+ matched = []
286
+ unresolved = []
287
+ for ref in refs:
288
+ file = ref.get("file")
289
+ path = ref.get("ref")
290
+ dynamic = ref.get("dynamic", False)
291
+ line = ref.get("line")
292
+ method = ref.get("method")
293
+ if dynamic or path is None:
294
+ exact = _exact_route(path, routes) if path is not None else None
295
+ if exact is not None:
296
+ matched.append(
297
+ {
298
+ "route": exact,
299
+ "file": file,
300
+ "ref": path,
301
+ "line": line,
302
+ "method": method,
303
+ }
304
+ )
305
+ else:
306
+ unresolved.append({"file": file, "path": path, "dynamic": True})
307
+ continue
308
+ hit = _best_route_match(path, routes)
309
+ if hit is not None:
310
+ matched.append(
311
+ {
312
+ "route": hit,
313
+ "file": file,
314
+ "ref": path,
315
+ "line": line,
316
+ "method": method,
317
+ }
318
+ )
319
+ else:
320
+ unresolved.append({"file": file, "path": path, "dynamic": False})
321
+ return matched, unresolved
322
+
323
+
324
+ def _norm_slash(path):
325
+ if path and path.startswith("/") and not path.startswith("//"):
326
+ return path[1:]
327
+ return path
328
+
329
+
330
+ def _exact_route(ref_path, routes):
331
+ norm = _norm_slash(ref_path)
332
+ for route in routes:
333
+ if _norm_slash(route.path) == norm:
334
+ return route
335
+ return None
336
+
337
+
338
+ def _best_route_match(ref_path, routes):
339
+ ref_norm = _norm_slash(ref_path)
340
+ for route in routes:
341
+ if _norm_slash(route.path) == ref_norm:
342
+ return route
343
+ for route in routes:
344
+ if _route_regex(_norm_slash(route.path or "")).match(ref_norm):
345
+ return route
346
+ for route in routes:
347
+ route_norm = _norm_slash(route.path or "")
348
+ if ref_norm and route_norm and _is_path_prefix(ref_norm, route_norm):
349
+ return route
350
+ return None
351
+
352
+
353
+ def match_template_refs(refs, routes):
354
+ by_name = {r.name: r for r in routes if r.name is not None}
355
+ matched = []
356
+ unresolved = []
357
+ for file, name in refs:
358
+ route = by_name.get(name)
359
+ if route is not None:
360
+ matched.append({"route": route, "file": file, "ref": name})
361
+ else:
362
+ unresolved.append({"file": file, "ref": name})
363
+ return matched, unresolved
@@ -0,0 +1,108 @@
1
+ """Pre-push hook lifecycle.
2
+
3
+ Handles everything about the git pre-push hook: resolves where hook files
4
+ live (core.hooksPath, linked-worktree common dirs), renders the hook body
5
+ that forwards the pushed refs from stdin so the guard checks exactly the
6
+ range being pushed, falls back to PATH or the recorded interpreter, warns
7
+ without blocking when the tool is missing, and installs, inspects, and
8
+ removes the hook without overwriting a foreign one unless forced.
9
+ """
10
+
11
+ import os
12
+ import sys
13
+
14
+ import diffimpactscout.gitrun as gitrun
15
+
16
+ MARKER = "# diffimpactscout pre-push hook"
17
+ HOOK_NAME = "pre-push"
18
+
19
+
20
+ def _sh_single_quote(path):
21
+ return "'" + path.replace("'", "'\"'\"'") + "'"
22
+
23
+
24
+ def _hooks_dir(root):
25
+ configured = gitrun.git_out(["config", "core.hooksPath"], root)
26
+ if configured:
27
+ if os.path.isabs(configured):
28
+ return configured
29
+ return gitrun.git_abs_dir(root, configured)
30
+ git_dir = gitrun.git_out(["rev-parse", "--git-common-dir"], root)
31
+ if git_dir:
32
+ if os.path.isabs(git_dir):
33
+ base = git_dir
34
+ else:
35
+ base = os.path.join(root, git_dir)
36
+ return os.path.normpath(os.path.join(base, "hooks"))
37
+ return os.path.join(root, ".git", "hooks")
38
+
39
+
40
+ def _hook_path(root):
41
+ return os.path.join(_hooks_dir(root), HOOK_NAME)
42
+
43
+
44
+ _ZERO_OID = "0000000000000000000000000000000000000000"
45
+
46
+
47
+ def hook_body(executable):
48
+ quoted = _sh_single_quote(executable)
49
+ return (
50
+ "#!/bin/sh\n"
51
+ + MARKER
52
+ + "\n"
53
+ + "if IFS= read -r pre_push_line && [ -n \"$pre_push_line\" ]; then\n"
54
+ + " set -- $pre_push_line\n"
55
+ + " if [ $# -eq 4 ] && [ \"$2\" != \"%s\" ] && [ \"$4\" != \"%s\" ] && ! IFS= read -r pre_push_extra; then\n"
56
+ + " PRE_COMMIT_FROM_REF=\"$4\"\n"
57
+ + " PRE_COMMIT_TO_REF=\"$2\"\n"
58
+ + " export PRE_COMMIT_FROM_REF PRE_COMMIT_TO_REF\n"
59
+ + " fi\n"
60
+ + "fi\n"
61
+ + "if command -v diffimpactscout >/dev/null 2>&1 && diffimpactscout --version >/dev/null 2>&1; then\n"
62
+ + " exec diffimpactscout guard\n"
63
+ + "elif [ -x %s ]; then\n"
64
+ + " exec %s -m diffimpactscout guard\n"
65
+ + "else\n"
66
+ + " echo \"diffimpactscout: pre-push guard skipped (tool not found; install via 'pip install diffimpactscout' or activate your venv).\" >&2\n"
67
+ + " exit 0\n"
68
+ + "fi\n"
69
+ ) % (_ZERO_OID, _ZERO_OID, quoted, quoted)
70
+
71
+
72
+ def hook_installed(root):
73
+ path = _hook_path(root)
74
+ if not os.path.isfile(path):
75
+ return False
76
+ with open(path) as fh:
77
+ return MARKER in fh.read()
78
+
79
+
80
+ def install_hook(root, force=False):
81
+ directory = _hooks_dir(root)
82
+ path = os.path.join(directory, HOOK_NAME)
83
+ if os.path.isfile(path):
84
+ with open(path) as fh:
85
+ content = fh.read()
86
+ if MARKER not in content and not force:
87
+ sys.stderr.write(
88
+ "diffimpactscout: pre-push hook exists (use --force to overwrite)\n"
89
+ )
90
+ return False
91
+ if not os.path.isdir(directory):
92
+ os.makedirs(directory)
93
+ with open(path, "w") as fh:
94
+ fh.write(hook_body(sys.executable))
95
+ os.chmod(path, 0o755)
96
+ return True
97
+
98
+
99
+ def uninstall_hook(root):
100
+ path = _hook_path(root)
101
+ if not os.path.isfile(path):
102
+ return False
103
+ with open(path) as fh:
104
+ content = fh.read()
105
+ if MARKER not in content:
106
+ return False
107
+ os.remove(path)
108
+ return True
@@ -0,0 +1,14 @@
1
+ {
2
+ "impact": {
3
+ "profile": "django",
4
+ "urls_globs": ["**/urls.py"],
5
+ "template_globs": ["**/templates/**/*.html"],
6
+ "frontend_globs": ["**/src/**/*.ts", "**/app/**/*.js"]
7
+ },
8
+ "guard": {
9
+ "checks": [
10
+ {"id": "ruff"},
11
+ {"id": "ruff-format"}
12
+ ]
13
+ }
14
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "impact": {
3
+ "profile": "fastapi",
4
+ "urls_globs": ["**/*.py"],
5
+ "template_globs": [],
6
+ "frontend_globs": ["**/src/**/*.ts", "**/app/**/*.js"]
7
+ },
8
+ "guard": {
9
+ "checks": [
10
+ {"id": "ruff"},
11
+ {"id": "ruff-format"}
12
+ ]
13
+ }
14
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "impact": {
3
+ "profile": "plain",
4
+ "urls_globs": [],
5
+ "template_globs": [],
6
+ "frontend_globs": []
7
+ }
8
+ }