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,181 @@
1
+ """Parse git diffs into file changes and extract Python definitions and usages from source content.
2
+
3
+ Example: a renamed module shows up with both its new and old path so
4
+ references to the old name can still be found.
5
+ """
6
+
7
+ import ast
8
+ import os
9
+ import re
10
+
11
+ import diffimpactscout.gitrun as gitrun
12
+
13
+
14
+ class Entity(object):
15
+ def __init__(self, name, kind, line, qualname):
16
+ self.name = name
17
+ self.kind = kind
18
+ self.line = line
19
+ self.qualname = qualname
20
+
21
+
22
+ class FileChange(object):
23
+ def __init__(self, path, status, old_path=None, ext=None):
24
+ self.path = path
25
+ self.status = status
26
+ self.old_path = old_path
27
+ self.ext = ext
28
+
29
+
30
+ _STATUS_RE = re.compile(r"^[AMDCTR]\d*$")
31
+ _FUNCS = (ast.FunctionDef, ast.AsyncFunctionDef)
32
+
33
+
34
+ def get_file_changes(root, anchor=None, staged=False, from_ref=None, to_ref=None):
35
+ if staged:
36
+ return _diff_changes(root, ["--cached"])
37
+ if anchor:
38
+ return _diff_changes(root, [anchor, "HEAD"])
39
+ if from_ref and to_ref:
40
+ return _diff_changes(root, [from_ref, to_ref])
41
+ changes = {}
42
+ for tail in (["--cached"], ["HEAD"], []):
43
+ for change in _diff_changes(root, tail):
44
+ if change.path not in changes:
45
+ changes[change.path] = change
46
+ return list(changes.values())
47
+
48
+
49
+ def _diff_changes(root, tail):
50
+ args = ["diff", "--name-status", "-z", "--diff-filter=ACMRTD", "--find-renames"]
51
+ args.extend(tail)
52
+ changes = _parse_name_status(gitrun.git_nul(args, root), root)
53
+ for change in changes:
54
+ if change.status[0] == "D" and change.old_path is None:
55
+ change.old_path = change.path
56
+ return changes
57
+
58
+
59
+ def _parse_name_status(data, root):
60
+ changes = []
61
+ i = 0
62
+ n = len(data)
63
+ while i < n:
64
+ field = data[i]
65
+ if not _STATUS_RE.match(field):
66
+ i += 1
67
+ continue
68
+ status = field
69
+ i += 1
70
+ if status[0] in "RC":
71
+ if i + 1 >= n:
72
+ break
73
+ old_path = _relpath(root, data[i])
74
+ new_path = _relpath(root, data[i + 1])
75
+ i += 2
76
+ changes.append(
77
+ FileChange(new_path, status, old_path, _ext(new_path))
78
+ )
79
+ else:
80
+ if i >= n:
81
+ break
82
+ path = _relpath(root, data[i])
83
+ i += 1
84
+ changes.append(FileChange(path, status, None, _ext(path)))
85
+ return changes
86
+
87
+
88
+ def _relpath(root, path):
89
+ if path is None:
90
+ return None
91
+ if os.path.isabs(path) and root:
92
+ return os.path.relpath(path, root)
93
+ return path
94
+
95
+
96
+ def _ext(path):
97
+ return os.path.splitext(path)[1].lstrip(".").lower()
98
+
99
+
100
+ def read_path_at_ref(root, path, ref):
101
+ result = gitrun.git(["show", "%s:%s" % (ref, path)], root)
102
+ if not result.ok():
103
+ return ""
104
+ return result.out_
105
+
106
+
107
+ def extract_entities(source):
108
+ try:
109
+ tree = ast.parse(source)
110
+ except SyntaxError:
111
+ return []
112
+ entities = []
113
+ _walk(tree, [], entities)
114
+ return entities
115
+
116
+
117
+ def _walk(node, scope, entities):
118
+ for child in ast.iter_child_nodes(node):
119
+ if isinstance(child, ast.ClassDef):
120
+ entities.append(
121
+ Entity(
122
+ child.name,
123
+ "class",
124
+ getattr(child, "lineno", 0),
125
+ _qualname(scope, child.name),
126
+ )
127
+ )
128
+ _walk(child, scope + [("class", child.name)], entities)
129
+ elif isinstance(child, _FUNCS):
130
+ kind = None
131
+ if not scope:
132
+ kind = "function"
133
+ elif scope[-1][0] == "class":
134
+ kind = "method"
135
+ if kind is not None:
136
+ entities.append(
137
+ Entity(
138
+ child.name,
139
+ kind,
140
+ getattr(child, "lineno", 0),
141
+ _qualname(scope, child.name),
142
+ )
143
+ )
144
+ _walk(child, scope + [("function", child.name)], entities)
145
+ elif isinstance(child, (ast.Assign, ast.AnnAssign)):
146
+ for target in _assign_targets(child):
147
+ if not scope:
148
+ entities.append(
149
+ Entity(target, "module_field", getattr(child, "lineno", 0), target)
150
+ )
151
+ elif scope[-1][0] == "class":
152
+ entities.append(
153
+ Entity(
154
+ target,
155
+ "class_field",
156
+ getattr(child, "lineno", 0),
157
+ _qualname(scope, target),
158
+ )
159
+ )
160
+ _walk(child, scope, entities)
161
+ else:
162
+ _walk(child, scope, entities)
163
+
164
+
165
+ def _assign_targets(node):
166
+ if isinstance(node, ast.AnnAssign):
167
+ target = node.target
168
+ if isinstance(target, ast.Name):
169
+ return [target.id]
170
+ return []
171
+ targets = []
172
+ for target in node.targets:
173
+ if isinstance(target, ast.Name):
174
+ targets.append(target.id)
175
+ return targets
176
+
177
+
178
+ def _qualname(scope, name):
179
+ parts = [n for _kind, n in scope]
180
+ parts.append(name)
181
+ return ".".join(parts)
@@ -0,0 +1,345 @@
1
+ """Blast-radius orchestration: link changed symbols to template and frontend references and emit a report.
2
+
3
+ Example: deleting an endpoint class raises a High row for every place still
4
+ referencing its old name.
5
+ """
6
+
7
+ import ast
8
+ import json
9
+ import os
10
+ import sys
11
+
12
+ import diffimpactscout.env as env
13
+ import diffimpactscout.gitrun as gitrun
14
+ import diffimpactscout.scope as scope
15
+ from diffimpactscout.config import is_excluded
16
+ from diffimpactscout.impact import reporter
17
+ from diffimpactscout.impact import route_linker
18
+ from diffimpactscout.impact import python_analyzer as pa
19
+ from diffimpactscout.impact.cache import SymbolCache
20
+ from diffimpactscout.impact.diff_parser import extract_entities, get_file_changes, read_path_at_ref
21
+
22
+ _ENTITY_KINDS = {
23
+ "class_field": ("attr",),
24
+ "function": ("name", "attr", "import"),
25
+ "module_field": ("name", "attr", "import"),
26
+ "method": ("name", "attr"),
27
+ "class": ("name", "attr", "import"),
28
+ }
29
+
30
+ _DEFAULT_KINDS = ("name",)
31
+
32
+
33
+ def run_impact(root, cfg, staged=False, fast=False, json_out=False):
34
+ if _skip_requested():
35
+ return 0
36
+ impact_cfg = cfg.get("impact") or {}
37
+ profile = impact_cfg.get("profile") or "plain"
38
+ from_ref, to_ref = env.pre_commit_refs()
39
+ anchor = None
40
+ if not staged:
41
+ anchor = scope.DevScope(root).scope_base(from_ref, to_ref)
42
+ changes = get_file_changes(root, anchor, staged, from_ref, to_ref)
43
+ changed_paths = [c.path for c in changes]
44
+ if staged:
45
+ old_ref = "HEAD"
46
+ elif anchor:
47
+ old_ref = anchor
48
+ else:
49
+ old_ref = from_ref
50
+ entities = _changed_entities(changes, root, old_ref)
51
+ cache_file = impact_cfg.get("cache_file") or ".impact_analysis_cache.json"
52
+ if root and not os.path.isabs(cache_file):
53
+ cache_file = os.path.join(root, cache_file)
54
+ cache = SymbolCache(cache_file)
55
+ py_files = _python_files(root, cfg)
56
+ if fast:
57
+ sys.stderr.write(
58
+ "diffimpactscout: fast mode; template/frontend scanning skipped, "
59
+ "python scans limited to import-linked files\n"
60
+ )
61
+ scan = _fast_subset(root, py_files, cache.load(), entities, changes)
62
+ else:
63
+ scan = py_files
64
+ for path in scan:
65
+ pa.analyze_path(path, root, cache)
66
+ cache.prune(py_files)
67
+ cache.save(cache.load())
68
+ analyses = cache.load()
69
+ rows, unresolved = _compose_rows(
70
+ root, impact_cfg, profile, entities, analyses, changed_paths, fast
71
+ )
72
+ changed_count = len(changes)
73
+ if json_out:
74
+ sys.stdout.write(
75
+ json.dumps(
76
+ {
77
+ "changed_count": changed_count,
78
+ "rows": rows,
79
+ "unresolved": unresolved,
80
+ }
81
+ )
82
+ + "\n"
83
+ )
84
+ else:
85
+ sys.stdout.write(reporter.render_report(rows, unresolved, changed_count))
86
+ strict_env = os.environ.get("IMPACT_CHECK_STRICT")
87
+ tty = reporter.interactive_tty()
88
+ block, reason = reporter.should_block(strict_env, tty)
89
+ if block:
90
+ if tty:
91
+ block = not reporter.confirm("Proceed with push? (Y/n)")
92
+ else:
93
+ sys.stderr.write("diffimpactscout: %s\n" % reason)
94
+ return 1 if block else 0
95
+
96
+
97
+ def _skip_requested():
98
+ return env.skip_requested()
99
+
100
+
101
+ def _changed_entities(changes, root, old_ref):
102
+ entities = {}
103
+ for change in changes:
104
+ if change.ext != "py":
105
+ continue
106
+ old_path = change.old_path
107
+ if old_path is None or change.status[0] != "R":
108
+ old_path = change.path
109
+ old_src = read_path_at_ref(root, old_path, old_ref) if old_ref else ""
110
+ new_src = _read_disk(root, change.path)
111
+ old_map = _entity_map(old_src)
112
+ new_map = _entity_map(new_src)
113
+ for name, kind in new_map.items():
114
+ if name not in entities:
115
+ entities[name] = {"kind": kind, "deleted": False}
116
+ elif entities[name].get("deleted"):
117
+ entities[name] = {"kind": kind, "deleted": False}
118
+ for name, kind in old_map.items():
119
+ if name not in new_map:
120
+ entities[name] = {"kind": kind, "deleted": True}
121
+ return entities
122
+
123
+
124
+ def _entity_map(source):
125
+ return {e.name: e.kind for e in extract_entities(source)}
126
+
127
+
128
+ def _compose_rows(root, impact_cfg, profile, entities, analyses, changed_paths, fast):
129
+ rows = []
130
+ layers = {}
131
+ for name, ent in entities.items():
132
+ kinds = _ENTITY_KINDS.get(ent["kind"], _DEFAULT_KINDS)
133
+ hits = pa.find_references(analyses, [name], kinds=kinds)
134
+ layers.setdefault(name, set())
135
+ for hit in hits:
136
+ layers[name].add("python")
137
+ rows.append(_python_row(hit, name, ent))
138
+ routes = _routes(root, impact_cfg, profile)
139
+ if fast:
140
+ template_refs = []
141
+ frontend_refs = []
142
+ else:
143
+ template_refs = route_linker.extract_template_refs(
144
+ root, impact_cfg.get("template_globs")
145
+ )
146
+ frontend_refs = route_linker.extract_frontend_refs(
147
+ root, impact_cfg.get("frontend_globs")
148
+ )
149
+ template_matched, template_unresolved = route_linker.match_template_refs(
150
+ template_refs, routes
151
+ )
152
+ frontend_matched, frontend_unresolved = route_linker.match_endpoints(
153
+ frontend_refs, routes
154
+ )
155
+ unresolved = list(template_unresolved)
156
+ unresolved.extend(frontend_unresolved)
157
+ for hit in template_matched:
158
+ name = _handler_entity(hit.get("route"), entities)
159
+ if name is None:
160
+ continue
161
+ layers.setdefault(name, set()).add("template")
162
+ rows.append(_template_row(hit, name, entities[name]))
163
+ for hit in frontend_matched:
164
+ name = _handler_entity(hit.get("route"), entities)
165
+ if name is None:
166
+ continue
167
+ layers.setdefault(name, set()).add("frontend")
168
+ rows.append(_frontend_row(hit, name, entities[name]))
169
+ rows = _dedup_rows(rows)
170
+ rows.sort(key=_row_key)
171
+ for row in rows:
172
+ name = row.pop("_entity")
173
+ deleted = row.pop("_deleted")
174
+ row["severity"] = reporter.classify_severity(
175
+ layers.get(name) or (), deleted, changed_paths, row.get("path")
176
+ )
177
+ row["action"] = _action(row["severity"], deleted)
178
+ return rows, unresolved
179
+
180
+
181
+ def _dedup_rows(rows):
182
+ out = []
183
+ seen = set()
184
+ for row in rows:
185
+ key = (row.get("path") or "", row.get("category") or "", row.get("ref") or "")
186
+ if key in seen:
187
+ continue
188
+ seen.add(key)
189
+ out.append(row)
190
+ return out
191
+
192
+
193
+ def _row_key(row):
194
+ return (row.get("path") or "", row.get("category") or "", row.get("ref") or "")
195
+
196
+
197
+ def _python_row(hit, name, ent):
198
+ path = hit.get("path") or ""
199
+ ctx = hit.get("ctx_qname") or ""
200
+ return {
201
+ "path": path,
202
+ "module": ctx if ctx else _dir_of(path),
203
+ "category": "python",
204
+ "ref": "%s (%s at %s:%d)" % (hit.get("name"), hit.get("how"), path, hit.get("line", 0)),
205
+ "_entity": name,
206
+ "_deleted": bool(ent.get("deleted")),
207
+ }
208
+
209
+
210
+ def _template_row(hit, name, ent):
211
+ path = hit.get("file") or ""
212
+ return {
213
+ "path": path,
214
+ "module": _dir_of(path),
215
+ "category": "template",
216
+ "ref": "{%% url '%s' %%} at %s" % (hit.get("ref"), path),
217
+ "_entity": name,
218
+ "_deleted": bool(ent.get("deleted")),
219
+ }
220
+
221
+
222
+ def _frontend_row(hit, name, ent):
223
+ path = hit.get("file") or ""
224
+ method = hit.get("method") or "get"
225
+ line = hit.get("line") or 0
226
+ return {
227
+ "path": path,
228
+ "module": _dir_of(path),
229
+ "category": "frontend",
230
+ "ref": 'http.%s("%s") at %s:%d' % (method, hit.get("ref"), path, line),
231
+ "_entity": name,
232
+ "_deleted": bool(ent.get("deleted")),
233
+ }
234
+
235
+
236
+ def _handler_entity(route, entities):
237
+ if route is None or not route.handler:
238
+ return None
239
+ handler = route.handler
240
+ if handler.endswith("()"):
241
+ handler = handler[:-2]
242
+ if handler.endswith(".as_view"):
243
+ handler = handler[:-len(".as_view")]
244
+ base = handler.rsplit(".", 1)[-1]
245
+ if base in entities:
246
+ return base
247
+ return None
248
+
249
+
250
+ def _routes(root, impact_cfg, profile):
251
+ if profile == "django":
252
+ return route_linker.extract_django_routes(root, impact_cfg.get("urls_globs"))
253
+ if profile == "fastapi":
254
+ return route_linker.extract_fastapi_routes(root, impact_cfg.get("urls_globs"))
255
+ return []
256
+
257
+
258
+ def _action(severity, deleted):
259
+ if deleted:
260
+ return "verify dangling references"
261
+ if severity == "High":
262
+ return "review/verify"
263
+ if severity == "Medium":
264
+ return "verify"
265
+ return "ok"
266
+
267
+
268
+ def _python_files(root, cfg):
269
+ files = []
270
+ for path in gitrun.git_nul(["ls-files", "-z", "*.py"], root):
271
+ if not is_excluded(path, cfg, root):
272
+ files.append(path)
273
+ return files
274
+
275
+
276
+ def _fast_subset(root, py_files, cache_data, entities, changes):
277
+ seed = set(entities.keys())
278
+ for change in changes:
279
+ if change.ext == "py":
280
+ seed.add(change.path[:-3].rsplit("/", 1)[-1])
281
+ index = {}
282
+ for path in py_files:
283
+ analysis = _peel(cache_data.get(path))
284
+ if isinstance(analysis, dict):
285
+ index[path] = _analysis_imports(analysis)
286
+ else:
287
+ index[path] = _import_names(_read_disk(root, path))
288
+ subset = set()
289
+ for change in changes:
290
+ if change.ext == "py":
291
+ subset.add(change.path)
292
+ for path in py_files:
293
+ if index.get(path) and (index[path] & seed):
294
+ subset.add(path)
295
+ return sorted(subset)
296
+
297
+
298
+ def _peel(entry):
299
+ if isinstance(entry, dict):
300
+ analysis = entry.get("analysis")
301
+ if isinstance(analysis, dict):
302
+ return analysis
303
+ return entry
304
+
305
+
306
+ def _analysis_imports(analysis):
307
+ names = set()
308
+ if not isinstance(analysis, dict):
309
+ return names
310
+ for usage in analysis.get("usages") or []:
311
+ if usage.get("kind") == "import" and usage.get("name"):
312
+ names.add(usage["name"])
313
+ return names
314
+
315
+
316
+ def _import_names(source):
317
+ try:
318
+ tree = ast.parse(source)
319
+ except (SyntaxError, ValueError, TypeError):
320
+ return set()
321
+ names = set()
322
+ for node in ast.walk(tree):
323
+ if isinstance(node, ast.Import):
324
+ for alias in node.names:
325
+ names.add(alias.name.split(".")[0])
326
+ elif isinstance(node, ast.ImportFrom):
327
+ for alias in node.names:
328
+ names.add(alias.name)
329
+ return names
330
+
331
+
332
+ def _read_disk(root, path):
333
+ if not path:
334
+ return ""
335
+ full = os.path.join(root, path) if root else path
336
+ try:
337
+ with open(full, "rb") as fh:
338
+ data = fh.read()
339
+ except (OSError, ValueError):
340
+ return ""
341
+ return data.decode("utf-8", "replace")
342
+
343
+
344
+ def _dir_of(path):
345
+ return os.path.dirname(path or "") or ""