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/pycodekg.py
ADDED
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Code Knowledge Graph (PyCodeKG) Extractor
|
|
4
|
+
|
|
5
|
+
Extracts a deterministic, side-effect-free knowledge graph from Python repositories
|
|
6
|
+
via AST analysis. Produces nodes (modules, classes, functions, methods, symbols) and
|
|
7
|
+
edges (CONTAINS, IMPORTS, INHERITS, CALLS, READS, WRITES, ATTR_ACCESS, DEPENDS_ON).
|
|
8
|
+
|
|
9
|
+
**Core Principles:**
|
|
10
|
+
- Pure: No side effects, deterministic output given the same input
|
|
11
|
+
- Honest: Only extracts what's explicitly visible in the AST—no guessing,
|
|
12
|
+
no embeddings, no LLM inference
|
|
13
|
+
- Modular: Three-pass approach (structure, call graph, data-flow)
|
|
14
|
+
- Extensible: Designed to feed downstream analysis (visualization, querying,
|
|
15
|
+
semantic indexing)
|
|
16
|
+
|
|
17
|
+
**Design:**
|
|
18
|
+
- PASS 1: Structural extraction (modules, classes, functions, methods, imports, inheritance)
|
|
19
|
+
- PASS 2: Call graph via AST-based function call analysis
|
|
20
|
+
- PASS 3: Data-flow edges (reads, writes, attribute access) via PyCodeKGVisitor
|
|
21
|
+
|
|
22
|
+
No persistence, no embeddings, no LLMs—just pure AST extraction. Integration with
|
|
23
|
+
vector databases and semantic search happens downstream.
|
|
24
|
+
|
|
25
|
+
Author: Eric G. Suchanek, PhD
|
|
26
|
+
Last Revision: 2026-03-01 20:33:41
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import ast
|
|
32
|
+
import os
|
|
33
|
+
from collections.abc import Iterable
|
|
34
|
+
from dataclasses import dataclass
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
from pycode_kg.utils import node_id, rel_module_path
|
|
38
|
+
from pycode_kg.visitor import PyCodeKGVisitor
|
|
39
|
+
|
|
40
|
+
# ============================================================================
|
|
41
|
+
# Configuration
|
|
42
|
+
# ============================================================================
|
|
43
|
+
|
|
44
|
+
#: Default sentence-transformer model. Override via the ``PYCODEKG_MODEL``
|
|
45
|
+
#: environment variable, e.g. ``export PYCODEKG_MODEL=BAAI/bge-small-en-v1.5``.
|
|
46
|
+
DEFAULT_MODEL: str = os.environ.get("PYCODEKG_MODEL", "BAAI/bge-small-en-v1.5")
|
|
47
|
+
|
|
48
|
+
# ============================================================================
|
|
49
|
+
# Graph primitives (LOCKED v0 CONTRACT)
|
|
50
|
+
# ============================================================================
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class Node:
|
|
55
|
+
"""
|
|
56
|
+
Graph node.
|
|
57
|
+
|
|
58
|
+
:param id: Stable node id (e.g. fn:pkg/util.py:parse_file)
|
|
59
|
+
:param kind: module | class | function | method | symbol
|
|
60
|
+
:param name: Short name
|
|
61
|
+
:param qualname: Qualified name within module
|
|
62
|
+
:param module_path: Repo-relative module path
|
|
63
|
+
:param lineno: Starting line number
|
|
64
|
+
:param end_lineno: Ending line number (if available)
|
|
65
|
+
:param docstring: Extracted docstring (may be None)
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
id: str
|
|
69
|
+
kind: str
|
|
70
|
+
name: str
|
|
71
|
+
qualname: str | None
|
|
72
|
+
module_path: str | None
|
|
73
|
+
lineno: int | None
|
|
74
|
+
end_lineno: int | None
|
|
75
|
+
docstring: str | None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class Edge:
|
|
80
|
+
"""
|
|
81
|
+
Graph edge.
|
|
82
|
+
|
|
83
|
+
:param src: Source node id
|
|
84
|
+
:param rel: Relationship type
|
|
85
|
+
:param dst: Destination node id
|
|
86
|
+
:param evidence: Optional evidence dict (lineno, expr, etc.)
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
src: str
|
|
90
|
+
rel: str
|
|
91
|
+
dst: str
|
|
92
|
+
evidence: dict | None = None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ============================================================================
|
|
96
|
+
# Constants
|
|
97
|
+
# ============================================================================
|
|
98
|
+
|
|
99
|
+
NODE_KINDS = {"module", "class", "function", "method", "symbol"}
|
|
100
|
+
EDGE_KINDS = {
|
|
101
|
+
"CONTAINS",
|
|
102
|
+
"IMPORTS",
|
|
103
|
+
"INHERITS",
|
|
104
|
+
"CALLS",
|
|
105
|
+
"READS",
|
|
106
|
+
"WRITES",
|
|
107
|
+
"ATTR_ACCESS",
|
|
108
|
+
"DEPENDS_ON",
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
SKIP_DIRS = {
|
|
112
|
+
".git",
|
|
113
|
+
".venv",
|
|
114
|
+
"venv",
|
|
115
|
+
"__pycache__",
|
|
116
|
+
".mypy_cache",
|
|
117
|
+
".pytest_cache",
|
|
118
|
+
".pycodekg",
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ============================================================================
|
|
123
|
+
# Utility helpers
|
|
124
|
+
# ============================================================================
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def iter_python_files(
|
|
128
|
+
repo_root: Path,
|
|
129
|
+
include: set[str] | None = None,
|
|
130
|
+
exclude: set[str] | None = None,
|
|
131
|
+
) -> Iterable[Path]:
|
|
132
|
+
"""
|
|
133
|
+
Yield Python files under repo_root.
|
|
134
|
+
|
|
135
|
+
:param repo_root: Repository root
|
|
136
|
+
:param include: Set of directory names to include (e.g., {"src", "lib"}).
|
|
137
|
+
When non-empty, only these top-level directories are walked.
|
|
138
|
+
When empty/None, all directories are walked (except SKIP_DIRS).
|
|
139
|
+
:param exclude: Set of directory names to prune at every depth of the walk
|
|
140
|
+
(e.g., {"tests", "benchmarks"}). Combined with SKIP_DIRS.
|
|
141
|
+
"""
|
|
142
|
+
skip = SKIP_DIRS | (exclude or set())
|
|
143
|
+
walk_roots = (
|
|
144
|
+
[repo_root / d for d in include if (repo_root / d).is_dir()] if include else [repo_root]
|
|
145
|
+
)
|
|
146
|
+
for walk_root in walk_roots:
|
|
147
|
+
for root, dirs, files in os.walk(walk_root):
|
|
148
|
+
dirs[:] = [d for d in dirs if d not in skip and not d.startswith(".")]
|
|
149
|
+
for f in files:
|
|
150
|
+
if f.endswith(".py") and not f.startswith("."):
|
|
151
|
+
yield Path(root) / f
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def expr_to_name(expr: ast.AST) -> str | None:
|
|
155
|
+
"""
|
|
156
|
+
Convert AST expression to dotted name (best effort).
|
|
157
|
+
|
|
158
|
+
:param expr: AST node
|
|
159
|
+
"""
|
|
160
|
+
if isinstance(expr, ast.Name):
|
|
161
|
+
return expr.id
|
|
162
|
+
if isinstance(expr, ast.Attribute):
|
|
163
|
+
left = expr_to_name(expr.value)
|
|
164
|
+
return f"{left}.{expr.attr}" if left else expr.attr
|
|
165
|
+
if isinstance(expr, ast.Call):
|
|
166
|
+
return expr_to_name(expr.func)
|
|
167
|
+
if isinstance(expr, ast.Subscript):
|
|
168
|
+
return expr_to_name(expr.value)
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# ============================================================================
|
|
173
|
+
# AST helpers (module-level to avoid closure-in-loop)
|
|
174
|
+
# ============================================================================
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _enclosing_def(
|
|
178
|
+
n: ast.AST,
|
|
179
|
+
parent: dict[ast.AST, ast.AST],
|
|
180
|
+
) -> ast.FunctionDef | ast.AsyncFunctionDef | None:
|
|
181
|
+
"""Find the nearest enclosing function or async function definition.
|
|
182
|
+
|
|
183
|
+
:param n: AST node whose enclosing function definition is sought.
|
|
184
|
+
:param parent: Mapping from child node to its parent node.
|
|
185
|
+
:return: Nearest enclosing ``FunctionDef`` or ``AsyncFunctionDef``,
|
|
186
|
+
or ``None`` if not inside any function.
|
|
187
|
+
"""
|
|
188
|
+
cur = parent.get(n)
|
|
189
|
+
while cur:
|
|
190
|
+
if isinstance(cur, ast.FunctionDef | ast.AsyncFunctionDef):
|
|
191
|
+
return cur
|
|
192
|
+
cur = parent.get(cur)
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _owner_id(
|
|
197
|
+
fn: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
198
|
+
parent: dict[ast.AST, ast.AST],
|
|
199
|
+
module: str,
|
|
200
|
+
module_locals: dict[str, dict[str, str]],
|
|
201
|
+
) -> str | None:
|
|
202
|
+
"""Compute the graph node ID for the owner of a function definition.
|
|
203
|
+
|
|
204
|
+
:param fn: Function or async function definition node.
|
|
205
|
+
:param parent: Mapping from child node to its parent node.
|
|
206
|
+
:param module: Repo-relative module path for the current file.
|
|
207
|
+
:param module_locals: Per-module mapping of qualname to node ID.
|
|
208
|
+
:return: Graph node ID string if the function is tracked, otherwise ``None``.
|
|
209
|
+
"""
|
|
210
|
+
p = parent.get(fn)
|
|
211
|
+
if isinstance(p, ast.ClassDef):
|
|
212
|
+
return module_locals[module].get(f"{p.name}.{fn.name}")
|
|
213
|
+
return module_locals[module].get(fn.name)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# ============================================================================
|
|
217
|
+
# Core extraction logic
|
|
218
|
+
# ============================================================================
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def extract_repo(
|
|
222
|
+
repo_root: Path,
|
|
223
|
+
include: set[str] | None = None,
|
|
224
|
+
exclude: set[str] | None = None,
|
|
225
|
+
) -> tuple[list[Node], list[Edge]]:
|
|
226
|
+
"""
|
|
227
|
+
Extract a code knowledge graph from a repository.
|
|
228
|
+
|
|
229
|
+
This function is:
|
|
230
|
+
- pure
|
|
231
|
+
- deterministic
|
|
232
|
+
- side-effect free
|
|
233
|
+
|
|
234
|
+
:param repo_root: Path to repository root
|
|
235
|
+
:param include: Set of directory names to include (e.g., {"src", "lib"}).
|
|
236
|
+
When non-empty, only these top-level directories are indexed.
|
|
237
|
+
When empty/None, all directories are indexed.
|
|
238
|
+
:param exclude: Set of directory names to prune at every walk depth
|
|
239
|
+
(e.g., {"tests", "benchmarks"}).
|
|
240
|
+
:return: (nodes, edges)
|
|
241
|
+
"""
|
|
242
|
+
nodes: dict[str, Node] = {}
|
|
243
|
+
edges: dict[tuple[str, str, str], Edge] = {}
|
|
244
|
+
|
|
245
|
+
# ------------------------------------------------------------------
|
|
246
|
+
# PASS 1: modules, classes, functions, methods
|
|
247
|
+
# ------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
module_locals: dict[str, dict[str, str]] = {}
|
|
250
|
+
module_class_methods: dict[str, dict[str, str]] = {}
|
|
251
|
+
module_import_aliases: dict[str, dict[str, str]] = {}
|
|
252
|
+
|
|
253
|
+
for pyfile in iter_python_files(repo_root, include=include, exclude=exclude):
|
|
254
|
+
module = rel_module_path(pyfile, repo_root)
|
|
255
|
+
|
|
256
|
+
try:
|
|
257
|
+
src = pyfile.read_text(encoding="utf-8")
|
|
258
|
+
tree = ast.parse(src, filename=module)
|
|
259
|
+
except (SyntaxError, UnicodeDecodeError):
|
|
260
|
+
continue
|
|
261
|
+
|
|
262
|
+
# module node
|
|
263
|
+
mod_id = node_id("module", module, None)
|
|
264
|
+
nodes[mod_id] = Node(
|
|
265
|
+
id=mod_id,
|
|
266
|
+
kind="module",
|
|
267
|
+
name=Path(module).stem,
|
|
268
|
+
qualname=None,
|
|
269
|
+
module_path=module,
|
|
270
|
+
lineno=1,
|
|
271
|
+
end_lineno=src.count("\n") + 1,
|
|
272
|
+
docstring=ast.get_docstring(tree),
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
module_locals[module] = {}
|
|
276
|
+
module_class_methods[module] = {}
|
|
277
|
+
module_import_aliases[module] = {}
|
|
278
|
+
|
|
279
|
+
# traverse module body only (NOT ast.walk)
|
|
280
|
+
for stmt in tree.body:
|
|
281
|
+
# --------------------
|
|
282
|
+
# class definitions
|
|
283
|
+
# --------------------
|
|
284
|
+
if isinstance(stmt, ast.ClassDef):
|
|
285
|
+
cls_qn = stmt.name
|
|
286
|
+
cls_id = node_id("class", module, cls_qn)
|
|
287
|
+
|
|
288
|
+
nodes[cls_id] = Node(
|
|
289
|
+
id=cls_id,
|
|
290
|
+
kind="class",
|
|
291
|
+
name=stmt.name,
|
|
292
|
+
qualname=cls_qn,
|
|
293
|
+
module_path=module,
|
|
294
|
+
lineno=getattr(stmt, "lineno", None),
|
|
295
|
+
end_lineno=getattr(stmt, "end_lineno", None),
|
|
296
|
+
docstring=ast.get_docstring(stmt),
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
edges[(mod_id, "CONTAINS", cls_id)] = Edge(
|
|
300
|
+
src=mod_id,
|
|
301
|
+
rel="CONTAINS",
|
|
302
|
+
dst=cls_id,
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
module_locals[module][stmt.name] = cls_id
|
|
306
|
+
|
|
307
|
+
# inheritance
|
|
308
|
+
for base in stmt.bases:
|
|
309
|
+
bname = expr_to_name(base)
|
|
310
|
+
if not bname:
|
|
311
|
+
continue
|
|
312
|
+
sym_id = f"sym:{bname}"
|
|
313
|
+
nodes.setdefault(
|
|
314
|
+
sym_id,
|
|
315
|
+
Node(
|
|
316
|
+
sym_id,
|
|
317
|
+
"symbol",
|
|
318
|
+
bname.split(".")[-1],
|
|
319
|
+
bname,
|
|
320
|
+
None,
|
|
321
|
+
None,
|
|
322
|
+
None,
|
|
323
|
+
None,
|
|
324
|
+
),
|
|
325
|
+
)
|
|
326
|
+
edges[(cls_id, "INHERITS", sym_id)] = Edge(
|
|
327
|
+
src=cls_id,
|
|
328
|
+
rel="INHERITS",
|
|
329
|
+
dst=sym_id,
|
|
330
|
+
evidence={"lineno": getattr(stmt, "lineno", None)},
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
# methods
|
|
334
|
+
for cstmt in stmt.body:
|
|
335
|
+
if isinstance(cstmt, ast.FunctionDef | ast.AsyncFunctionDef):
|
|
336
|
+
m_qn = f"{stmt.name}.{cstmt.name}"
|
|
337
|
+
m_id = node_id("method", module, m_qn)
|
|
338
|
+
|
|
339
|
+
nodes[m_id] = Node(
|
|
340
|
+
id=m_id,
|
|
341
|
+
kind="method",
|
|
342
|
+
name=cstmt.name,
|
|
343
|
+
qualname=m_qn,
|
|
344
|
+
module_path=module,
|
|
345
|
+
lineno=getattr(cstmt, "lineno", None),
|
|
346
|
+
end_lineno=getattr(cstmt, "end_lineno", None),
|
|
347
|
+
docstring=ast.get_docstring(cstmt),
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
edges[(cls_id, "CONTAINS", m_id)] = Edge(
|
|
351
|
+
src=cls_id,
|
|
352
|
+
rel="CONTAINS",
|
|
353
|
+
dst=m_id,
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
module_class_methods[module][cstmt.name] = m_id
|
|
357
|
+
module_locals[module][m_qn] = m_id
|
|
358
|
+
|
|
359
|
+
# --------------------
|
|
360
|
+
# top-level functions
|
|
361
|
+
# --------------------
|
|
362
|
+
elif isinstance(stmt, ast.FunctionDef | ast.AsyncFunctionDef):
|
|
363
|
+
fn_qn = stmt.name
|
|
364
|
+
fn_id = node_id("function", module, fn_qn)
|
|
365
|
+
|
|
366
|
+
nodes[fn_id] = Node(
|
|
367
|
+
id=fn_id,
|
|
368
|
+
kind="function",
|
|
369
|
+
name=stmt.name,
|
|
370
|
+
qualname=fn_qn,
|
|
371
|
+
module_path=module,
|
|
372
|
+
lineno=getattr(stmt, "lineno", None),
|
|
373
|
+
end_lineno=getattr(stmt, "end_lineno", None),
|
|
374
|
+
docstring=ast.get_docstring(stmt),
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
edges[(mod_id, "CONTAINS", fn_id)] = Edge(
|
|
378
|
+
src=mod_id,
|
|
379
|
+
rel="CONTAINS",
|
|
380
|
+
dst=fn_id,
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
module_locals[module][stmt.name] = fn_id
|
|
384
|
+
|
|
385
|
+
# --------------------
|
|
386
|
+
# imports
|
|
387
|
+
# --------------------
|
|
388
|
+
elif isinstance(stmt, ast.Import):
|
|
389
|
+
for alias in stmt.names:
|
|
390
|
+
sym = alias.name
|
|
391
|
+
alias_name = alias.asname or alias.name.split(".")[0]
|
|
392
|
+
module_import_aliases[module][alias_name] = sym
|
|
393
|
+
sym_id = f"sym:{sym}"
|
|
394
|
+
nodes.setdefault(
|
|
395
|
+
sym_id,
|
|
396
|
+
Node(
|
|
397
|
+
sym_id,
|
|
398
|
+
"symbol",
|
|
399
|
+
sym.split(".")[-1],
|
|
400
|
+
sym,
|
|
401
|
+
None,
|
|
402
|
+
None,
|
|
403
|
+
None,
|
|
404
|
+
None,
|
|
405
|
+
),
|
|
406
|
+
)
|
|
407
|
+
edges[(mod_id, "IMPORTS", sym_id)] = Edge(
|
|
408
|
+
src=mod_id,
|
|
409
|
+
rel="IMPORTS",
|
|
410
|
+
dst=sym_id,
|
|
411
|
+
evidence={"lineno": getattr(stmt, "lineno", None)},
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
elif isinstance(stmt, ast.ImportFrom):
|
|
415
|
+
mod = stmt.module or ""
|
|
416
|
+
for alias in stmt.names:
|
|
417
|
+
full = f"{mod}.{alias.name}" if mod else alias.name
|
|
418
|
+
alias_name = alias.asname or alias.name
|
|
419
|
+
module_import_aliases[module][alias_name] = full
|
|
420
|
+
sym_id = f"sym:{full}"
|
|
421
|
+
nodes.setdefault(
|
|
422
|
+
sym_id,
|
|
423
|
+
Node(sym_id, "symbol", alias.name, full, None, None, None, None),
|
|
424
|
+
)
|
|
425
|
+
edges[(mod_id, "IMPORTS", sym_id)] = Edge(
|
|
426
|
+
src=mod_id,
|
|
427
|
+
rel="IMPORTS",
|
|
428
|
+
dst=sym_id,
|
|
429
|
+
evidence={"lineno": getattr(stmt, "lineno", None)},
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
# Capture function-local imports too so call resolution can disambiguate
|
|
433
|
+
# symbols imported inside function bodies (e.g., ``from pkg.mod import x``).
|
|
434
|
+
for imp in ast.walk(tree):
|
|
435
|
+
if isinstance(imp, ast.Import):
|
|
436
|
+
for alias in imp.names:
|
|
437
|
+
alias_name = alias.asname or alias.name.split(".")[0]
|
|
438
|
+
module_import_aliases[module][alias_name] = alias.name
|
|
439
|
+
elif isinstance(imp, ast.ImportFrom):
|
|
440
|
+
mod = imp.module or ""
|
|
441
|
+
for alias in imp.names:
|
|
442
|
+
full = f"{mod}.{alias.name}" if mod else alias.name
|
|
443
|
+
alias_name = alias.asname or alias.name
|
|
444
|
+
module_import_aliases[module][alias_name] = full
|
|
445
|
+
|
|
446
|
+
# ------------------------------------------------------------------
|
|
447
|
+
# PASS 2: call graph (best-effort, honest)
|
|
448
|
+
# ------------------------------------------------------------------
|
|
449
|
+
|
|
450
|
+
parent: dict[ast.AST, ast.AST] = {}
|
|
451
|
+
for p in ast.walk(tree):
|
|
452
|
+
for c in ast.iter_child_nodes(p):
|
|
453
|
+
parent[c] = p
|
|
454
|
+
|
|
455
|
+
for n in ast.walk(tree):
|
|
456
|
+
if not isinstance(n, ast.Call):
|
|
457
|
+
continue
|
|
458
|
+
|
|
459
|
+
fn = _enclosing_def(n, parent)
|
|
460
|
+
if fn is None:
|
|
461
|
+
continue
|
|
462
|
+
|
|
463
|
+
src_id = _owner_id(fn, parent, module, module_locals)
|
|
464
|
+
if not src_id:
|
|
465
|
+
continue
|
|
466
|
+
|
|
467
|
+
callee = expr_to_name(n.func)
|
|
468
|
+
if not callee:
|
|
469
|
+
continue
|
|
470
|
+
|
|
471
|
+
# resolution rules (LOCKED)
|
|
472
|
+
if callee in module_locals[module]:
|
|
473
|
+
dst_id = module_locals[module][callee]
|
|
474
|
+
elif callee in module_import_aliases[module]:
|
|
475
|
+
dst_id = f"sym:{module_import_aliases[module][callee]}"
|
|
476
|
+
elif "." in callee and callee.split(".", 1)[0] in module_import_aliases[module]:
|
|
477
|
+
head, tail = callee.split(".", 1)
|
|
478
|
+
resolved = module_import_aliases[module][head]
|
|
479
|
+
dst_id = f"sym:{resolved}.{tail}"
|
|
480
|
+
elif callee.startswith("self."):
|
|
481
|
+
meth = callee.split(".", 1)[1]
|
|
482
|
+
dst_id = module_class_methods[module].get(meth) or f"sym:{callee}"
|
|
483
|
+
else:
|
|
484
|
+
dst_id = f"sym:{callee}"
|
|
485
|
+
|
|
486
|
+
if dst_id.startswith("sym:"):
|
|
487
|
+
nodes.setdefault(
|
|
488
|
+
dst_id,
|
|
489
|
+
Node(
|
|
490
|
+
dst_id,
|
|
491
|
+
"symbol",
|
|
492
|
+
dst_id.split(":")[-1].split(".")[-1],
|
|
493
|
+
dst_id[4:],
|
|
494
|
+
None,
|
|
495
|
+
None,
|
|
496
|
+
None,
|
|
497
|
+
None,
|
|
498
|
+
),
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
edges[(src_id, "CALLS", dst_id)] = Edge(
|
|
502
|
+
src=src_id,
|
|
503
|
+
rel="CALLS",
|
|
504
|
+
dst=dst_id,
|
|
505
|
+
evidence={
|
|
506
|
+
"lineno": getattr(n, "lineno", None),
|
|
507
|
+
"expr": callee,
|
|
508
|
+
},
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
# ------------------------------------------------------------------
|
|
512
|
+
# PASS 3: data-flow edges via PyCodeKGVisitor (READS, WRITES, ATTR_ACCESS)
|
|
513
|
+
# ------------------------------------------------------------------
|
|
514
|
+
|
|
515
|
+
vis = PyCodeKGVisitor(module_id=module, file_path=str(pyfile))
|
|
516
|
+
vis.visit(tree)
|
|
517
|
+
vis_nodes, vis_edges = vis.finalize()
|
|
518
|
+
|
|
519
|
+
# Merge new symbol/var nodes that Pass 1 didn't create.
|
|
520
|
+
for nid, props in vis_nodes.items():
|
|
521
|
+
nodes.setdefault(
|
|
522
|
+
nid,
|
|
523
|
+
Node(
|
|
524
|
+
id=nid,
|
|
525
|
+
kind=props["kind"],
|
|
526
|
+
name=props["qualname"].split(".")[-1],
|
|
527
|
+
qualname=props["qualname"],
|
|
528
|
+
module_path=module,
|
|
529
|
+
lineno=props.get("lineno"),
|
|
530
|
+
end_lineno=props.get("end_lineno"),
|
|
531
|
+
docstring=props.get("docstring"),
|
|
532
|
+
),
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
# Merge data-flow edges; setdefault keeps Pass 1/2 edges authoritative
|
|
536
|
+
# for any CONTAINS/CALLS duplicates.
|
|
537
|
+
for src_id, tgt_id, rel, ev in vis_edges:
|
|
538
|
+
edges.setdefault(
|
|
539
|
+
(src_id, rel, tgt_id),
|
|
540
|
+
Edge(src=src_id, rel=rel, dst=tgt_id, evidence=ev),
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
return list(nodes.values()), list(edges.values())
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# This module has been removed. Use `pycodekg query` instead.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# This module has been removed. Use `pycodekg pack` instead.
|