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.
Files changed (63) hide show
  1. pycode_kg/.DS_Store +0 -0
  2. pycode_kg/__init__.py +91 -0
  3. pycode_kg/__main__.py +11 -0
  4. pycode_kg/analysis/__init__.py +15 -0
  5. pycode_kg/analysis/bridge.py +108 -0
  6. pycode_kg/analysis/centrality.py +412 -0
  7. pycode_kg/analysis/framework_detector.py +103 -0
  8. pycode_kg/analysis/hybrid_rank.py +53 -0
  9. pycode_kg/app.py +1335 -0
  10. pycode_kg/architecture.py +624 -0
  11. pycode_kg/build_pycodekg_lancedb.py +15 -0
  12. pycode_kg/build_pycodekg_sqlite.py +15 -0
  13. pycode_kg/cli/__init__.py +29 -0
  14. pycode_kg/cli/cmd_analyze.py +96 -0
  15. pycode_kg/cli/cmd_architecture.py +150 -0
  16. pycode_kg/cli/cmd_bridges.py +26 -0
  17. pycode_kg/cli/cmd_build.py +154 -0
  18. pycode_kg/cli/cmd_build_full.py +242 -0
  19. pycode_kg/cli/cmd_centrality.py +131 -0
  20. pycode_kg/cli/cmd_explain.py +180 -0
  21. pycode_kg/cli/cmd_framework_nodes.py +18 -0
  22. pycode_kg/cli/cmd_hooks.py +137 -0
  23. pycode_kg/cli/cmd_init.py +312 -0
  24. pycode_kg/cli/cmd_mcp.py +71 -0
  25. pycode_kg/cli/cmd_model.py +53 -0
  26. pycode_kg/cli/cmd_query.py +211 -0
  27. pycode_kg/cli/cmd_snapshot.py +421 -0
  28. pycode_kg/cli/cmd_viz.py +180 -0
  29. pycode_kg/cli/main.py +23 -0
  30. pycode_kg/cli/options.py +63 -0
  31. pycode_kg/config.py +78 -0
  32. pycode_kg/graph.py +125 -0
  33. pycode_kg/index.py +542 -0
  34. pycode_kg/kg.py +220 -0
  35. pycode_kg/layout3d.py +470 -0
  36. pycode_kg/mcp/bridge_tools.py +19 -0
  37. pycode_kg/mcp/framework_tools.py +18 -0
  38. pycode_kg/mcp_server.py +1965 -0
  39. pycode_kg/module/__init__.py +83 -0
  40. pycode_kg/module/base.py +720 -0
  41. pycode_kg/module/extractor.py +276 -0
  42. pycode_kg/module/types.py +532 -0
  43. pycode_kg/pycodekg.py +543 -0
  44. pycode_kg/pycodekg_query.py +1 -0
  45. pycode_kg/pycodekg_snippet_packer.py +1 -0
  46. pycode_kg/pycodekg_thorough_analysis.py +2751 -0
  47. pycode_kg/pycodekg_viz.py +1 -0
  48. pycode_kg/pycodekg_viz3d.py +1 -0
  49. pycode_kg/ranking/__init__.py +1 -0
  50. pycode_kg/ranking/cli_rank.py +92 -0
  51. pycode_kg/ranking/coderank.py +555 -0
  52. pycode_kg/snapshots.py +612 -0
  53. pycode_kg/sql/004_add_centrality_table.sql +12 -0
  54. pycode_kg/store.py +766 -0
  55. pycode_kg/utils.py +39 -0
  56. pycode_kg/visitor.py +413 -0
  57. pycode_kg/viz3d.py +1353 -0
  58. pycode_kg/viz3d_timeline.py +364 -0
  59. pycode_kg-0.16.0.dist-info/METADATA +305 -0
  60. pycode_kg-0.16.0.dist-info/RECORD +63 -0
  61. pycode_kg-0.16.0.dist-info/WHEEL +4 -0
  62. pycode_kg-0.16.0.dist-info/entry_points.txt +19 -0
  63. 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