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/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.