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.
- diffimpactscout/__init__.py +3 -0
- diffimpactscout/__main__.py +8 -0
- diffimpactscout/base.py +95 -0
- diffimpactscout/checks/__init__.py +20 -0
- diffimpactscout/checks/base.py +206 -0
- diffimpactscout/checks/eslint.py +164 -0
- diffimpactscout/checks/hygiene.py +104 -0
- diffimpactscout/checks/prettier.py +103 -0
- diffimpactscout/checks/repo_checks.py +95 -0
- diffimpactscout/checks/ruff.py +210 -0
- diffimpactscout/checks/syntax.py +133 -0
- diffimpactscout/cli.py +220 -0
- diffimpactscout/config.py +205 -0
- diffimpactscout/env.py +29 -0
- diffimpactscout/gitrun.py +68 -0
- diffimpactscout/guard.py +116 -0
- diffimpactscout/impact/__init__.py +1 -0
- diffimpactscout/impact/cache.py +55 -0
- diffimpactscout/impact/diff_parser.py +181 -0
- diffimpactscout/impact/impact.py +345 -0
- diffimpactscout/impact/python_analyzer.py +284 -0
- diffimpactscout/impact/reporter.py +107 -0
- diffimpactscout/impact/route_linker.py +363 -0
- diffimpactscout/launcher.py +108 -0
- diffimpactscout/profiles/django.json +14 -0
- diffimpactscout/profiles/fastapi.json +14 -0
- diffimpactscout/profiles/plain.json +8 -0
- diffimpactscout/scope.py +210 -0
- diffimpactscout-0.1.0.dist-info/METADATA +314 -0
- diffimpactscout-0.1.0.dist-info/RECORD +34 -0
- diffimpactscout-0.1.0.dist-info/WHEEL +5 -0
- diffimpactscout-0.1.0.dist-info/entry_points.txt +2 -0
- diffimpactscout-0.1.0.dist-info/licenses/LICENSE +21 -0
- diffimpactscout-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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 ""
|