pycode-kg 0.16.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.
- pycode_kg/.DS_Store +0 -0
- pycode_kg/__init__.py +91 -0
- pycode_kg/__main__.py +11 -0
- pycode_kg/analysis/__init__.py +15 -0
- pycode_kg/analysis/bridge.py +108 -0
- pycode_kg/analysis/centrality.py +412 -0
- pycode_kg/analysis/framework_detector.py +103 -0
- pycode_kg/analysis/hybrid_rank.py +53 -0
- pycode_kg/app.py +1335 -0
- pycode_kg/architecture.py +624 -0
- pycode_kg/build_pycodekg_lancedb.py +15 -0
- pycode_kg/build_pycodekg_sqlite.py +15 -0
- pycode_kg/cli/__init__.py +29 -0
- pycode_kg/cli/cmd_analyze.py +96 -0
- pycode_kg/cli/cmd_architecture.py +150 -0
- pycode_kg/cli/cmd_bridges.py +26 -0
- pycode_kg/cli/cmd_build.py +154 -0
- pycode_kg/cli/cmd_build_full.py +242 -0
- pycode_kg/cli/cmd_centrality.py +131 -0
- pycode_kg/cli/cmd_explain.py +180 -0
- pycode_kg/cli/cmd_framework_nodes.py +18 -0
- pycode_kg/cli/cmd_hooks.py +137 -0
- pycode_kg/cli/cmd_init.py +312 -0
- pycode_kg/cli/cmd_mcp.py +71 -0
- pycode_kg/cli/cmd_model.py +53 -0
- pycode_kg/cli/cmd_query.py +211 -0
- pycode_kg/cli/cmd_snapshot.py +421 -0
- pycode_kg/cli/cmd_viz.py +180 -0
- pycode_kg/cli/main.py +23 -0
- pycode_kg/cli/options.py +63 -0
- pycode_kg/config.py +78 -0
- pycode_kg/graph.py +125 -0
- pycode_kg/index.py +542 -0
- pycode_kg/kg.py +220 -0
- pycode_kg/layout3d.py +470 -0
- pycode_kg/mcp/bridge_tools.py +19 -0
- pycode_kg/mcp/framework_tools.py +18 -0
- pycode_kg/mcp_server.py +1965 -0
- pycode_kg/module/__init__.py +83 -0
- pycode_kg/module/base.py +720 -0
- pycode_kg/module/extractor.py +276 -0
- pycode_kg/module/types.py +532 -0
- pycode_kg/pycodekg.py +543 -0
- pycode_kg/pycodekg_query.py +1 -0
- pycode_kg/pycodekg_snippet_packer.py +1 -0
- pycode_kg/pycodekg_thorough_analysis.py +2751 -0
- pycode_kg/pycodekg_viz.py +1 -0
- pycode_kg/pycodekg_viz3d.py +1 -0
- pycode_kg/ranking/__init__.py +1 -0
- pycode_kg/ranking/cli_rank.py +92 -0
- pycode_kg/ranking/coderank.py +555 -0
- pycode_kg/snapshots.py +612 -0
- pycode_kg/sql/004_add_centrality_table.sql +12 -0
- pycode_kg/store.py +766 -0
- pycode_kg/utils.py +39 -0
- pycode_kg/visitor.py +413 -0
- pycode_kg/viz3d.py +1353 -0
- pycode_kg/viz3d_timeline.py +364 -0
- pycode_kg-0.16.0.dist-info/METADATA +305 -0
- pycode_kg-0.16.0.dist-info/RECORD +63 -0
- pycode_kg-0.16.0.dist-info/WHEEL +4 -0
- pycode_kg-0.16.0.dist-info/entry_points.txt +19 -0
- pycode_kg-0.16.0.dist-info/licenses/LICENSE +94 -0
pycode_kg/utils.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""
|
|
2
|
+
utils.py
|
|
3
|
+
|
|
4
|
+
Shared primitive utilities used by both pycodekg.py and visitor.py.
|
|
5
|
+
Kept in a separate module to avoid circular imports.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def rel_module_path(path: Path, repo_root: Path) -> str:
|
|
14
|
+
"""Convert file path to repo-relative module path.
|
|
15
|
+
|
|
16
|
+
:param path: Absolute file path
|
|
17
|
+
:param repo_root: Repo root
|
|
18
|
+
"""
|
|
19
|
+
return str(path.relative_to(repo_root)).replace("\\", "/")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def node_id(kind: str, module: str, qualname: str | None) -> str:
|
|
23
|
+
"""Construct stable node id.
|
|
24
|
+
|
|
25
|
+
:param kind: Node kind
|
|
26
|
+
:param module: Repo-relative module path
|
|
27
|
+
:param qualname: Qualified name
|
|
28
|
+
"""
|
|
29
|
+
if kind == "module":
|
|
30
|
+
return f"mod:{module}"
|
|
31
|
+
|
|
32
|
+
prefix = {
|
|
33
|
+
"class": "cls",
|
|
34
|
+
"function": "fn",
|
|
35
|
+
"method": "m",
|
|
36
|
+
"symbol": "sym",
|
|
37
|
+
}[kind]
|
|
38
|
+
|
|
39
|
+
return f"{prefix}:{module}:{qualname}" if qualname else f"{prefix}:{module}"
|
pycode_kg/visitor.py
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
|
|
3
|
+
from pycode_kg.utils import node_id
|
|
4
|
+
|
|
5
|
+
# Relation types (add to your constants or use strings directly)
|
|
6
|
+
REL_CALLS = "CALLS"
|
|
7
|
+
REL_CONTAINS = "CONTAINS"
|
|
8
|
+
REL_READS = "READS"
|
|
9
|
+
REL_WRITES = "WRITES"
|
|
10
|
+
REL_ATTR_ACCESS = "ATTR_ACCESS"
|
|
11
|
+
REL_DEPENDS_ON = "DEPENDS_ON" # placeholder for future control-flow
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PyCodeKGVisitor(ast.NodeVisitor):
|
|
15
|
+
"""AST visitor that walks a single Python source file and emits graph nodes and edges.
|
|
16
|
+
|
|
17
|
+
Tracks scope (module → class → function/method), records ``CONTAINS``,
|
|
18
|
+
``CALLS``, ``READS``, ``WRITES``, and ``ATTR_ACCESS`` relationships, and
|
|
19
|
+
builds a per-file node/edge registry consumed by :func:`~pycode_kg.graph.CodeGraph`.
|
|
20
|
+
|
|
21
|
+
Property accesses (``obj.attr`` where ``attr`` is decorated with
|
|
22
|
+
``@property``) are resolved to ``CALLS`` edges via a pre-scan of the
|
|
23
|
+
module tree performed in :meth:`visit_Module`.
|
|
24
|
+
|
|
25
|
+
:param module_id: Dot-separated module identifier for the file being visited
|
|
26
|
+
(e.g. ``pycode_kg.visitor``).
|
|
27
|
+
:param file_path: Absolute path to the source file, stored as evidence on
|
|
28
|
+
every edge emitted by this visitor.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, module_id: str, file_path: str):
|
|
32
|
+
"""Initialise the visitor for a single Python source file.
|
|
33
|
+
|
|
34
|
+
:param module_id: Dot-separated module identifier for the file being
|
|
35
|
+
visited (e.g. ``pycode_kg.visitor``).
|
|
36
|
+
:param file_path: Absolute path to the source file, stored as evidence
|
|
37
|
+
on every edge emitted by this visitor.
|
|
38
|
+
"""
|
|
39
|
+
self.module_id = module_id
|
|
40
|
+
self.file_path = file_path
|
|
41
|
+
self.current_scope: list[str] = [] # qualnames stack
|
|
42
|
+
self.current_func: str | None = None
|
|
43
|
+
self.vars_in_scope: dict[str, set[str]] = {} # scope → local vars set
|
|
44
|
+
self._scope_kinds: dict[str, str] = {} # qualname → kind
|
|
45
|
+
self.edges: list[tuple[str, str, str, dict | None]] = [] # (src_id, tgt_id, rel, evidence)
|
|
46
|
+
self.nodes: dict[str, dict] = {} # id → node props (your existing)
|
|
47
|
+
self._property_node_ids: dict[str, list[str]] = {} # attr_name → property node IDs
|
|
48
|
+
|
|
49
|
+
def _qualname(self, name: str) -> str:
|
|
50
|
+
"""Build the qualified name for *name* relative to the current scope.
|
|
51
|
+
|
|
52
|
+
:param name: Short symbol name to qualify.
|
|
53
|
+
:return: Dot-joined qualified name, e.g. ``MyClass.my_method``.
|
|
54
|
+
"""
|
|
55
|
+
if self.current_scope:
|
|
56
|
+
return f"{self.current_scope[-1]}.{name}"
|
|
57
|
+
return name
|
|
58
|
+
|
|
59
|
+
def _get_node_id(self, qualname: str) -> str:
|
|
60
|
+
"""Build a stable node id using the project's ``kind:module:qualname`` convention.
|
|
61
|
+
|
|
62
|
+
Looks up the kind for *qualname* in the internal scope-kinds registry,
|
|
63
|
+
defaulting to ``"symbol"`` when unknown. The resulting node is
|
|
64
|
+
registered in ``self.nodes`` if not already present.
|
|
65
|
+
|
|
66
|
+
:param qualname: Fully-qualified name of the symbol
|
|
67
|
+
(e.g. ``mymodule.MyClass.my_method``).
|
|
68
|
+
:return: Stable node identifier string suitable for use as a graph key.
|
|
69
|
+
"""
|
|
70
|
+
kind = self._scope_kinds.get(qualname, "symbol")
|
|
71
|
+
prefix = self.module_id + "."
|
|
72
|
+
if kind == "module" or qualname == self.module_id:
|
|
73
|
+
nid = node_id("module", self.module_id, None)
|
|
74
|
+
else:
|
|
75
|
+
rel_qn = qualname[len(prefix) :] if qualname.startswith(prefix) else qualname
|
|
76
|
+
nid = node_id(kind, self.module_id, rel_qn)
|
|
77
|
+
if nid not in self.nodes:
|
|
78
|
+
self.nodes[nid] = {
|
|
79
|
+
"qualname": qualname,
|
|
80
|
+
"kind": kind,
|
|
81
|
+
"file": self.file_path,
|
|
82
|
+
"lineno": None,
|
|
83
|
+
"end_lineno": None,
|
|
84
|
+
"docstring": None,
|
|
85
|
+
}
|
|
86
|
+
return nid
|
|
87
|
+
|
|
88
|
+
def _set_node_source_meta(self, node_id_value: str, ast_node: ast.AST) -> None:
|
|
89
|
+
"""Attach source span/docstring metadata to an existing visitor node.
|
|
90
|
+
|
|
91
|
+
:param node_id_value: Stable node ID already present in ``self.nodes``.
|
|
92
|
+
:param ast_node: AST node that carries ``lineno`` / ``end_lineno`` and
|
|
93
|
+
optionally a docstring.
|
|
94
|
+
"""
|
|
95
|
+
rec = self.nodes.get(node_id_value)
|
|
96
|
+
if rec is None:
|
|
97
|
+
return
|
|
98
|
+
rec["lineno"] = getattr(ast_node, "lineno", None)
|
|
99
|
+
rec["end_lineno"] = getattr(ast_node, "end_lineno", None)
|
|
100
|
+
if isinstance(ast_node, ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef):
|
|
101
|
+
rec["docstring"] = ast.get_docstring(ast_node)
|
|
102
|
+
|
|
103
|
+
def _add_edge(self, src_id: str, tgt_id: str, rel: str, evidence: ast.AST | None = None):
|
|
104
|
+
"""Record a directed edge between two graph nodes.
|
|
105
|
+
|
|
106
|
+
:param src_id: Node identifier for the source of the edge.
|
|
107
|
+
:param tgt_id: Node identifier for the target of the edge.
|
|
108
|
+
:param rel: Relation type label (e.g. ``CALLS``, ``CONTAINS``).
|
|
109
|
+
:param evidence: Optional AST node from which the line number is
|
|
110
|
+
extracted and stored alongside the edge.
|
|
111
|
+
"""
|
|
112
|
+
ev = {"lineno": getattr(evidence, "lineno", None), "file": self.file_path}
|
|
113
|
+
self.edges.append((src_id, tgt_id, rel, ev))
|
|
114
|
+
|
|
115
|
+
def _extract_reads(self, expr: ast.AST) -> set[str]:
|
|
116
|
+
"""Collect variable names that are loaded (READS).
|
|
117
|
+
|
|
118
|
+
Walks *expr* and returns the identifier of every ``ast.Name`` node
|
|
119
|
+
whose context is ``Load`` or ``Del`` (deletion can be read-like).
|
|
120
|
+
|
|
121
|
+
:param expr: Root AST node to walk.
|
|
122
|
+
:return: Set of variable name strings that are read within *expr*.
|
|
123
|
+
"""
|
|
124
|
+
reads = set()
|
|
125
|
+
for sub in ast.walk(expr):
|
|
126
|
+
if isinstance(sub, ast.Name) and isinstance(
|
|
127
|
+
sub.ctx, ast.Load | ast.Del
|
|
128
|
+
): # Del can be read-like
|
|
129
|
+
reads.add(sub.id)
|
|
130
|
+
return reads
|
|
131
|
+
|
|
132
|
+
def _add_var_edge(
|
|
133
|
+
self,
|
|
134
|
+
var_name: str,
|
|
135
|
+
rel: str,
|
|
136
|
+
target: str | None = None,
|
|
137
|
+
evidence: ast.AST | None = None,
|
|
138
|
+
):
|
|
139
|
+
"""Emit an edge involving a variable node in the current scope.
|
|
140
|
+
|
|
141
|
+
Creates a per-scope variable node for *var_name* and, when *target* is
|
|
142
|
+
provided, emits a directed edge from that variable node to a node for
|
|
143
|
+
*target* within the same scope.
|
|
144
|
+
|
|
145
|
+
:param var_name: Name of the variable that is the source of the edge.
|
|
146
|
+
:param rel: Relation type label (e.g. ``READS``, ``WRITES``,
|
|
147
|
+
``ATTR_ACCESS``).
|
|
148
|
+
:param target: Name of the symbol that is the target of the edge.
|
|
149
|
+
When ``None`` no edge is emitted (variable node is still created).
|
|
150
|
+
:param evidence: Optional AST node used to record the source line
|
|
151
|
+
number on the edge.
|
|
152
|
+
"""
|
|
153
|
+
scope = self.current_scope[-1] if self.current_scope else None
|
|
154
|
+
if not scope:
|
|
155
|
+
return
|
|
156
|
+
src_id = self._get_node_id(f"{scope}.{var_name}") # per-scope var node
|
|
157
|
+
if target:
|
|
158
|
+
tgt_id = self._get_node_id(f"{scope}.{target}")
|
|
159
|
+
self._add_edge(src_id, tgt_id, rel, evidence)
|
|
160
|
+
# Optionally annotate var node itself
|
|
161
|
+
|
|
162
|
+
# ────────────────────────────────────────────────
|
|
163
|
+
# Enhanced visitors
|
|
164
|
+
# ────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
def _prescan_properties(self, tree: ast.AST) -> None:
|
|
167
|
+
"""Pre-scan the AST to register all ``@property`` methods before the main visit.
|
|
168
|
+
|
|
169
|
+
Walks the full module tree and collects every method decorated with
|
|
170
|
+
``@property``, mapping its short name to its stable node ID. This
|
|
171
|
+
allows :meth:`visit_Attribute` to emit ``CALLS`` edges for property
|
|
172
|
+
accesses even when the property definition appears after the access site.
|
|
173
|
+
|
|
174
|
+
:param tree: Root ``ast.Module`` node for the file.
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
def _walk(node: ast.AST, class_parts: list[str]) -> None:
|
|
178
|
+
for child in ast.iter_child_nodes(node):
|
|
179
|
+
if isinstance(child, ast.ClassDef):
|
|
180
|
+
_walk(child, class_parts + [child.name])
|
|
181
|
+
elif isinstance(child, ast.FunctionDef | ast.AsyncFunctionDef):
|
|
182
|
+
if class_parts: # only methods, not module-level functions
|
|
183
|
+
is_property = any(
|
|
184
|
+
(isinstance(d, ast.Name) and d.id == "property")
|
|
185
|
+
or (isinstance(d, ast.Attribute) and d.attr == "property")
|
|
186
|
+
for d in child.decorator_list
|
|
187
|
+
)
|
|
188
|
+
if is_property:
|
|
189
|
+
rel_qn = ".".join(class_parts + [child.name])
|
|
190
|
+
nid = node_id("method", self.module_id, rel_qn)
|
|
191
|
+
self._property_node_ids.setdefault(child.name, []).append(nid)
|
|
192
|
+
|
|
193
|
+
_walk(tree, [])
|
|
194
|
+
|
|
195
|
+
def visit_Module(self, node: ast.Module):
|
|
196
|
+
"""Visit the top-level module node and initialise module scope state.
|
|
197
|
+
|
|
198
|
+
Pushes the module's identifier onto the scope stack, registers its
|
|
199
|
+
kind as ``"module"``, initialises its variable-in-scope set, then
|
|
200
|
+
recurses into all child nodes before popping the scope.
|
|
201
|
+
|
|
202
|
+
:param node: The ``ast.Module`` node for the file being visited.
|
|
203
|
+
"""
|
|
204
|
+
self._prescan_properties(node)
|
|
205
|
+
self.current_scope = [self.module_id]
|
|
206
|
+
self._scope_kinds[self.module_id] = "module"
|
|
207
|
+
self.vars_in_scope[self.module_id] = set()
|
|
208
|
+
self.generic_visit(node)
|
|
209
|
+
self.current_scope.pop()
|
|
210
|
+
|
|
211
|
+
def visit_ClassDef(self, node: ast.ClassDef):
|
|
212
|
+
"""Visit a class definition and emit a CONTAINS edge from its parent.
|
|
213
|
+
|
|
214
|
+
Registers the class as kind ``"class"``, pushes its qualified name onto
|
|
215
|
+
the scope stack, records a ``CONTAINS`` relationship from the enclosing
|
|
216
|
+
scope, then recurses into the class body before restoring the scope.
|
|
217
|
+
|
|
218
|
+
:param node: The ``ast.ClassDef`` node being visited.
|
|
219
|
+
"""
|
|
220
|
+
qualname = self._qualname(node.name)
|
|
221
|
+
self._scope_kinds[qualname] = "class"
|
|
222
|
+
self.current_scope.append(qualname)
|
|
223
|
+
parent_id = self._get_node_id(self.current_scope[-2])
|
|
224
|
+
class_id = self._get_node_id(qualname)
|
|
225
|
+
self._set_node_source_meta(class_id, node)
|
|
226
|
+
self._add_edge(parent_id, class_id, REL_CONTAINS)
|
|
227
|
+
self.generic_visit(node)
|
|
228
|
+
self.current_scope.pop()
|
|
229
|
+
|
|
230
|
+
def _seed_params(self, qualname: str, args: ast.arguments) -> None:
|
|
231
|
+
"""Seed all parameter names into the function's local variable scope.
|
|
232
|
+
|
|
233
|
+
:param qualname: Qualified name of the function being entered.
|
|
234
|
+
:param args: The ``ast.arguments`` node from the function definition.
|
|
235
|
+
"""
|
|
236
|
+
all_params = (
|
|
237
|
+
args.posonlyargs
|
|
238
|
+
+ args.args
|
|
239
|
+
+ args.kwonlyargs
|
|
240
|
+
+ ([args.vararg] if args.vararg else [])
|
|
241
|
+
+ ([args.kwarg] if args.kwarg else [])
|
|
242
|
+
)
|
|
243
|
+
for param in all_params:
|
|
244
|
+
self.vars_in_scope[qualname].add(param.arg)
|
|
245
|
+
|
|
246
|
+
def visit_FunctionDef(self, node: ast.FunctionDef):
|
|
247
|
+
"""Visit a synchronous function or method definition.
|
|
248
|
+
|
|
249
|
+
Determines whether the callable is a ``"function"`` or ``"method"``
|
|
250
|
+
based on the enclosing scope kind, processes default argument
|
|
251
|
+
expressions in the enclosing scope, then pushes a new scope for the
|
|
252
|
+
function body. Emits a ``CONTAINS`` edge from the parent scope and
|
|
253
|
+
seeds parameter names into the local variable set before recursing.
|
|
254
|
+
|
|
255
|
+
:param node: The ``ast.FunctionDef`` node being visited.
|
|
256
|
+
"""
|
|
257
|
+
qualname = self._qualname(node.name)
|
|
258
|
+
parent_kind = self._scope_kinds.get(
|
|
259
|
+
self.current_scope[-1] if self.current_scope else "", "module"
|
|
260
|
+
)
|
|
261
|
+
func_kind = "method" if parent_kind == "class" else "function"
|
|
262
|
+
self._scope_kinds[qualname] = func_kind
|
|
263
|
+
|
|
264
|
+
# Default expressions are evaluated in the enclosing scope, not the
|
|
265
|
+
# function scope — process them before pushing the new scope.
|
|
266
|
+
# kw_defaults may contain None for keyword-only args without a default.
|
|
267
|
+
defaults = node.args.defaults + [d for d in node.args.kw_defaults if d is not None]
|
|
268
|
+
for expr in defaults:
|
|
269
|
+
for r in self._extract_reads(expr):
|
|
270
|
+
self._add_var_edge(r, REL_READS, evidence=expr)
|
|
271
|
+
|
|
272
|
+
self.current_scope.append(qualname)
|
|
273
|
+
self.current_func = qualname
|
|
274
|
+
self.vars_in_scope[qualname] = set()
|
|
275
|
+
self._seed_params(qualname, node.args)
|
|
276
|
+
|
|
277
|
+
parent_id = self._get_node_id(self.current_scope[-2])
|
|
278
|
+
func_id = self._get_node_id(qualname)
|
|
279
|
+
self._set_node_source_meta(func_id, node)
|
|
280
|
+
self._add_edge(parent_id, func_id, REL_CONTAINS)
|
|
281
|
+
|
|
282
|
+
for stmt in node.body:
|
|
283
|
+
self.visit(stmt)
|
|
284
|
+
|
|
285
|
+
self.current_scope.pop()
|
|
286
|
+
self.current_func = None
|
|
287
|
+
|
|
288
|
+
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef):
|
|
289
|
+
"""Visit an asynchronous function or method definition.
|
|
290
|
+
|
|
291
|
+
Behaves identically to :meth:`visit_FunctionDef` but handles
|
|
292
|
+
``async def`` nodes. Determines kind (``"function"`` or
|
|
293
|
+
``"method"``), processes default argument expressions in the
|
|
294
|
+
enclosing scope, pushes the function scope, emits a ``CONTAINS``
|
|
295
|
+
edge, seeds parameters, and recurses into the body.
|
|
296
|
+
|
|
297
|
+
:param node: The ``ast.AsyncFunctionDef`` node being visited.
|
|
298
|
+
"""
|
|
299
|
+
qualname = self._qualname(node.name)
|
|
300
|
+
parent_kind = self._scope_kinds.get(
|
|
301
|
+
self.current_scope[-1] if self.current_scope else "", "module"
|
|
302
|
+
)
|
|
303
|
+
func_kind = "method" if parent_kind == "class" else "function"
|
|
304
|
+
self._scope_kinds[qualname] = func_kind
|
|
305
|
+
|
|
306
|
+
# Default expressions are evaluated in the enclosing scope, not the
|
|
307
|
+
# function scope — process them before pushing the new scope.
|
|
308
|
+
# kw_defaults may contain None for keyword-only args without a default.
|
|
309
|
+
defaults = node.args.defaults + [d for d in node.args.kw_defaults if d is not None]
|
|
310
|
+
for expr in defaults:
|
|
311
|
+
for r in self._extract_reads(expr):
|
|
312
|
+
self._add_var_edge(r, REL_READS, evidence=expr)
|
|
313
|
+
|
|
314
|
+
self.current_scope.append(qualname)
|
|
315
|
+
self.current_func = qualname
|
|
316
|
+
self.vars_in_scope[qualname] = set()
|
|
317
|
+
self._seed_params(qualname, node.args)
|
|
318
|
+
|
|
319
|
+
parent_id = self._get_node_id(self.current_scope[-2])
|
|
320
|
+
func_id = self._get_node_id(qualname)
|
|
321
|
+
self._set_node_source_meta(func_id, node)
|
|
322
|
+
self._add_edge(parent_id, func_id, REL_CONTAINS)
|
|
323
|
+
|
|
324
|
+
for stmt in node.body:
|
|
325
|
+
self.visit(stmt)
|
|
326
|
+
|
|
327
|
+
self.current_scope.pop()
|
|
328
|
+
self.current_func = None
|
|
329
|
+
|
|
330
|
+
def visit_Assign(self, node: ast.Assign):
|
|
331
|
+
"""Visit a simple assignment statement and record READS/WRITES edges.
|
|
332
|
+
|
|
333
|
+
For each ``ast.Name`` target, registers the variable in the current
|
|
334
|
+
scope's variable set, emits ``READS`` edges for every variable name
|
|
335
|
+
loaded from the right-hand side, and emits a ``WRITES`` edge for the
|
|
336
|
+
target variable.
|
|
337
|
+
|
|
338
|
+
:param node: The ``ast.Assign`` node being visited.
|
|
339
|
+
"""
|
|
340
|
+
for target in node.targets:
|
|
341
|
+
if isinstance(target, ast.Name):
|
|
342
|
+
var = target.id
|
|
343
|
+
scope = self.current_scope[-1] if self.current_scope else None
|
|
344
|
+
if scope:
|
|
345
|
+
self.vars_in_scope.setdefault(scope, set()).add(var)
|
|
346
|
+
|
|
347
|
+
# READS from value
|
|
348
|
+
reads = self._extract_reads(node.value)
|
|
349
|
+
for r in reads:
|
|
350
|
+
self._add_var_edge(r, REL_READS, evidence=node)
|
|
351
|
+
|
|
352
|
+
# WRITES to target
|
|
353
|
+
self._add_var_edge(var, REL_WRITES, evidence=node)
|
|
354
|
+
|
|
355
|
+
self.generic_visit(node)
|
|
356
|
+
|
|
357
|
+
def visit_Attribute(self, node: ast.Attribute):
|
|
358
|
+
"""Visit an attribute access and emit an ATTR_ACCESS edge.
|
|
359
|
+
|
|
360
|
+
When the object being accessed is a simple name (``ast.Name``), emits
|
|
361
|
+
an ``ATTR_ACCESS`` edge from the object's variable node to a node
|
|
362
|
+
representing the accessed attribute within the current scope.
|
|
363
|
+
|
|
364
|
+
Additionally, if the accessed attribute matches a ``@property`` method
|
|
365
|
+
discovered during the pre-scan, emits a ``CALLS`` edge from the
|
|
366
|
+
enclosing function to that property method node so that property
|
|
367
|
+
accesses appear in the call graph.
|
|
368
|
+
|
|
369
|
+
:param node: The ``ast.Attribute`` node being visited.
|
|
370
|
+
"""
|
|
371
|
+
if isinstance(node.value, ast.Name):
|
|
372
|
+
obj = node.value.id
|
|
373
|
+
attr = node.attr
|
|
374
|
+
self._add_var_edge(obj, REL_ATTR_ACCESS, target=attr, evidence=node)
|
|
375
|
+
if self.current_func and attr in self._property_node_ids:
|
|
376
|
+
caller_id = self._get_node_id(self.current_func)
|
|
377
|
+
for prop_nid in self._property_node_ids[attr]:
|
|
378
|
+
self._add_edge(caller_id, prop_nid, REL_CALLS, evidence=node)
|
|
379
|
+
self.generic_visit(node)
|
|
380
|
+
|
|
381
|
+
def visit_Call(self, node: ast.Call):
|
|
382
|
+
"""Visit a call expression and emit READS edges for all arguments.
|
|
383
|
+
|
|
384
|
+
Records ``READS`` edges for every variable loaded from positional
|
|
385
|
+
arguments and keyword argument values so that data-flow through call
|
|
386
|
+
sites is captured in the graph.
|
|
387
|
+
|
|
388
|
+
:param node: The ``ast.Call`` node being visited.
|
|
389
|
+
"""
|
|
390
|
+
# Assume you already have CALLS logic; add READS from args/kwargs
|
|
391
|
+
for arg in node.args:
|
|
392
|
+
reads = self._extract_reads(arg)
|
|
393
|
+
for r in reads:
|
|
394
|
+
self._add_var_edge(r, REL_READS, evidence=node)
|
|
395
|
+
|
|
396
|
+
for kw in node.keywords:
|
|
397
|
+
if kw.value:
|
|
398
|
+
reads = self._extract_reads(kw.value)
|
|
399
|
+
for r in reads:
|
|
400
|
+
self._add_var_edge(r, REL_READS, evidence=node)
|
|
401
|
+
|
|
402
|
+
self.generic_visit(node)
|
|
403
|
+
|
|
404
|
+
# Add more visitors as needed (e.g. visit_If for DEPENDS_ON stubs)
|
|
405
|
+
|
|
406
|
+
def finalize(self):
|
|
407
|
+
"""Return collected nodes and edges for database insertion.
|
|
408
|
+
|
|
409
|
+
:return: A two-tuple ``(nodes, edges)`` where *nodes* is a dict
|
|
410
|
+
mapping node id strings to their property dicts and *edges* is a
|
|
411
|
+
list of ``(src_id, tgt_id, rel, evidence)`` tuples.
|
|
412
|
+
"""
|
|
413
|
+
return self.nodes, self.edges
|