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
@@ -0,0 +1,131 @@
1
+ #!/usr/bin/env python3
2
+ """Click command for Structural Importance Ranking (SIR)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ from pathlib import Path
8
+
9
+ import click
10
+
11
+ from pycode_kg.analysis.centrality import (
12
+ StructuralImportanceRanker,
13
+ aggregate_module_scores,
14
+ )
15
+ from pycode_kg.cli.main import cli
16
+
17
+
18
+ @cli.command("centrality")
19
+ @click.option(
20
+ "--db",
21
+ type=click.Path(path_type=Path, dir_okay=False),
22
+ default=Path(".pycodekg/graph.sqlite"),
23
+ show_default=True,
24
+ help="Path to the PyCodeKG SQLite graph.",
25
+ )
26
+ @click.option(
27
+ "--kind",
28
+ "kinds",
29
+ multiple=True,
30
+ type=click.Choice(["module", "class", "function", "method"], case_sensitive=False),
31
+ help="Restrict output to one or more node kinds.",
32
+ )
33
+ @click.option("--top", type=int, default=25, show_default=True, help="Maximum rows to show.")
34
+ @click.option(
35
+ "--group-by",
36
+ type=click.Choice(["node", "module"], case_sensitive=False),
37
+ default="node",
38
+ show_default=True,
39
+ help="Return node-level or module-level rankings.",
40
+ )
41
+ @click.option("--json", "as_json", is_flag=True, help="Emit JSON instead of a table.")
42
+ @click.option("--write-db", is_flag=True, help="Persist node-level scores into centrality_scores.")
43
+ def cmd_centrality(
44
+ db: Path,
45
+ kinds: tuple[str, ...],
46
+ top: int,
47
+ group_by: str,
48
+ as_json: bool,
49
+ write_db: bool,
50
+ ) -> None:
51
+ """Compute Structural Importance Ranking over the resolved PyCodeKG graph."""
52
+ ranker = StructuralImportanceRanker(db)
53
+ records = ranker.compute(kinds=set(kinds) if kinds else None, top=None)
54
+
55
+ if write_db:
56
+ ranker.write_scores(records)
57
+
58
+ if group_by == "module":
59
+ payload = aggregate_module_scores(records)[:top]
60
+ if as_json:
61
+ click.echo(json.dumps(payload, indent=2))
62
+ return
63
+ _print_module_table(payload)
64
+ return
65
+
66
+ node_payload = records[:top]
67
+ if as_json:
68
+ click.echo(json.dumps([_record_to_dict(r) for r in node_payload], indent=2))
69
+ return
70
+ _print_node_table(node_payload)
71
+
72
+
73
+ def _record_to_dict(record) -> dict:
74
+ return {
75
+ "node_id": record.node_id,
76
+ "kind": record.kind,
77
+ "name": record.name,
78
+ "module_path": record.module_path,
79
+ "score": record.score,
80
+ "rank": record.rank,
81
+ "inbound_count": record.inbound_count,
82
+ "cross_module_inbound": record.cross_module_inbound,
83
+ "rel_breakdown": record.rel_breakdown,
84
+ "top_contributors": record.top_contributors,
85
+ }
86
+
87
+
88
+ def _print_node_table(records) -> None:
89
+ headers = (
90
+ "Rank",
91
+ "Score",
92
+ "Kind",
93
+ "Name",
94
+ "Inbound",
95
+ "XMod",
96
+ "Module",
97
+ )
98
+ rows = [
99
+ (
100
+ r.rank,
101
+ f"{r.score:.6f}",
102
+ r.kind,
103
+ r.name,
104
+ r.inbound_count,
105
+ r.cross_module_inbound,
106
+ r.module_path or "",
107
+ )
108
+ for r in records
109
+ ]
110
+ click.echo(click.format_filename(""))
111
+ click.echo(_format_table(headers, rows))
112
+
113
+
114
+ def _print_module_table(payload) -> None:
115
+ headers = ("Rank", "Score", "Members", "Module")
116
+ rows = [
117
+ (row["rank"], f"{row['score']:.6f}", row["member_count"], row["module_path"])
118
+ for row in payload
119
+ ]
120
+ click.echo(_format_table(headers, rows))
121
+
122
+
123
+ def _format_table(headers, rows) -> str:
124
+ widths = [len(str(h)) for h in headers]
125
+ for row in rows:
126
+ for i, value in enumerate(row):
127
+ widths[i] = max(widths[i], len(str(value)))
128
+ fmt = " ".join(f"{{:{w}}}" for w in widths)
129
+ lines = [fmt.format(*headers), fmt.format(*["-" * w for w in widths])]
130
+ lines.extend(fmt.format(*[str(v) for v in row]) for row in rows)
131
+ return "\n".join(lines)
@@ -0,0 +1,180 @@
1
+ """
2
+ cmd_explain.py
3
+
4
+ Click subcommand for explaining code nodes:
5
+
6
+ explain — get a natural-language explanation of a code node by its ID
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+ import click
14
+
15
+ from pycode_kg.cli.main import cli
16
+ from pycode_kg.cli.options import lancedb_option, model_option, sqlite_option
17
+ from pycode_kg.kg import PyCodeKG
18
+
19
+
20
+ @cli.command("explain")
21
+ @click.argument("node_id", metavar="NODE_ID")
22
+ @sqlite_option
23
+ @lancedb_option
24
+ @click.option(
25
+ "--table",
26
+ default="pycodekg_nodes",
27
+ show_default=True,
28
+ help="LanceDB table name.",
29
+ )
30
+ @model_option
31
+ @click.option(
32
+ "--out",
33
+ type=click.Path(),
34
+ default=None,
35
+ help="Output file path (default: stdout).",
36
+ )
37
+ def explain(
38
+ node_id: str,
39
+ sqlite: str,
40
+ lancedb: str,
41
+ table: str,
42
+ model: str,
43
+ out: str | None,
44
+ ) -> None:
45
+ """Get a natural-language explanation of a code node.
46
+
47
+ NODE_ID is the stable identifier of a node, e.g.:
48
+ fn:src/pycode_kg/store.py:GraphStore.expand
49
+ """
50
+ # explain-only does not need repo_root for source reading; use sqlite parent as placeholder
51
+ repo_root = Path(sqlite).parent
52
+
53
+ kg = PyCodeKG(
54
+ repo_root=repo_root,
55
+ db_path=Path(sqlite),
56
+ lancedb_dir=Path(lancedb),
57
+ model=model,
58
+ table=table,
59
+ )
60
+
61
+ node = kg.node(node_id)
62
+
63
+ if node is None:
64
+ click.echo(f"[ERROR] Node not found: {node_id}", err=True)
65
+ kg.close()
66
+ raise SystemExit(1)
67
+
68
+ markdown_output = _explain_node(kg, node_id, node)
69
+
70
+ if out:
71
+ Path(out).write_text(markdown_output)
72
+ click.echo(f"[OK] Explanation written to {out}")
73
+ else:
74
+ click.echo(markdown_output)
75
+
76
+ kg.close()
77
+
78
+
79
+ def _explain_node(kg: PyCodeKG, node_id: str, node: dict) -> str:
80
+ """Generate markdown explanation for a node.
81
+
82
+ :param kg: PyCodeKG instance
83
+ :param node_id: Node identifier
84
+ :param node: Node dict from kg.node()
85
+ :return: Markdown-formatted explanation
86
+ """
87
+ out: list[str] = []
88
+
89
+ # Header with kind and name
90
+ kind = node.get("kind", "unknown")
91
+ name = node.get("qualname") or node.get("name", "unknown")
92
+ out.append(f"# {kind.capitalize()}: `{name}`\n")
93
+
94
+ # Metadata
95
+ out.append("## Metadata\n")
96
+ if node.get("module_path"):
97
+ out.append(f"- **Module**: `{node['module_path']}`")
98
+ if node.get("lineno") is not None:
99
+ out.append(
100
+ f"- **Location**: line {node['lineno']}"
101
+ + (f"–{node['end_lineno']}" if node.get("end_lineno") else "")
102
+ )
103
+ out.append(f"- **ID**: `{node_id}`")
104
+ out.append("")
105
+
106
+ # Docstring
107
+ docstring = node.get("docstring", "").strip()
108
+ if docstring:
109
+ out.append("## Documentation\n")
110
+ out.append(docstring)
111
+ out.append("")
112
+
113
+ # Get callers
114
+ try:
115
+ caller_list = kg.callers(node_id, rel="CALLS")
116
+ if caller_list:
117
+ out.append("## Called By (Callers)\n")
118
+ out.append(f"This {kind} is called by **{len(caller_list)}** other function(s):\n")
119
+ for caller in caller_list[:10]:
120
+ caller_name = caller.get("qualname") or caller.get("name", "unknown")
121
+ caller_module = caller.get("module_path", "")
122
+ out.append(f"- `{caller_name}` ({caller_module})")
123
+ if len(caller_list) > 10:
124
+ out.append(f"- ... and {len(caller_list) - 10} more")
125
+ out.append("")
126
+ except (AttributeError, ValueError, RuntimeError):
127
+ pass
128
+
129
+ # Get callees (what this function calls)
130
+ try:
131
+ if hasattr(kg, "_store") and kg._store is not None:
132
+ edges = kg._store.edges_from(node_id, rel="CALLS", limit=50)
133
+ if edges:
134
+ out.append("## Calls (Callees)\n")
135
+ out.append(f"This {kind} calls **{len(edges)}** other function(s):\n")
136
+ callees = set()
137
+ for edge in edges:
138
+ dst_id = edge.get("dst")
139
+ if dst_id is not None:
140
+ dst_node = kg.node(dst_id)
141
+ if dst_node:
142
+ dst_name = dst_node.get("qualname") or dst_node.get("name", "unknown")
143
+ callees.add(f"- `{dst_name}`")
144
+ for callee in sorted(list(callees))[:10]:
145
+ out.append(callee)
146
+ if len(callees) > 10:
147
+ out.append(f"- ... and {len(callees) - 10} more")
148
+ out.append("")
149
+ except (AttributeError, ValueError, RuntimeError):
150
+ pass
151
+
152
+ # Role assessment
153
+ out.append("## Role in Codebase\n")
154
+ try:
155
+ caller_count = len(kg.callers(node_id, rel="CALLS"))
156
+ if caller_count > 50:
157
+ out.append(
158
+ f"**High-value function**: Called {caller_count} times. "
159
+ "This is likely a core API or bottleneck. Changes here may have wide impact."
160
+ )
161
+ elif caller_count > 10:
162
+ out.append(
163
+ f"**Important function**: Called {caller_count} times. "
164
+ "Part of the essential infrastructure."
165
+ )
166
+ elif caller_count > 0:
167
+ out.append(
168
+ f"**Utility function**: Called {caller_count} time(s). "
169
+ "Specific to particular use cases."
170
+ )
171
+ else:
172
+ out.append("**Orphaned**: Never called. This may be dead code or internal utility.")
173
+ except (AttributeError, ValueError, RuntimeError):
174
+ out.append("Unable to determine call graph role.")
175
+
176
+ out.append("")
177
+ out.append("---\n")
178
+ out.append("*Use `pycodekg pack` to retrieve the full source code.*")
179
+
180
+ return "\n".join(out)
@@ -0,0 +1,18 @@
1
+ """
2
+ CLI command for framework node detection in PyCodeKG.
3
+ """
4
+
5
+ import argparse
6
+
7
+ from pycode_kg.analysis.framework_detector import detect_framework_nodes
8
+
9
+
10
+ def main():
11
+ parser = argparse.ArgumentParser(description="Show top framework-like modules.")
12
+ parser.add_argument("--db", default="pycodekg.sqlite", help="Path to PyCodeKG SQLite DB")
13
+ parser.add_argument("--top", type=int, default=25, help="Number of top framework nodes to show")
14
+ args = parser.parse_args()
15
+ nodes = detect_framework_nodes(limit=args.top, db_path=args.db)
16
+ print(f"Top {args.top} framework-like modules:")
17
+ for node_id, score, label in nodes:
18
+ print(f"{label:50s} {score:.5f} ({node_id})")
@@ -0,0 +1,137 @@
1
+ """
2
+ cmd_hooks.py
3
+
4
+ CLI command for installing PyCodeKG git hooks:
5
+
6
+ install-hooks — install the pre-commit snapshot hook into .git/hooks/
7
+
8
+ Author: Eric G. Suchanek, PhD
9
+ Last Revision: 2026-03-08 21:30:55
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import stat
15
+ from pathlib import Path
16
+
17
+ import click
18
+
19
+ from pycode_kg.cli.main import cli
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # Hook script content (embedded so this module is self-contained when
23
+ # installed as a package in any repo, not just pycode_kg itself)
24
+ # ---------------------------------------------------------------------------
25
+
26
+ _PRE_COMMIT_HOOK = """\
27
+ #!/usr/bin/env bash
28
+ # PyCodeKG + DocKG pre-commit hook — keeps local indices in sync and captures
29
+ # metrics snapshots BEFORE quality checks run.
30
+ # Installed by: pycodekg install-hooks
31
+ # Skip with: PYCODEKG_SKIP_SNAPSHOT=1 git commit ...
32
+ set -euo pipefail
33
+
34
+ [ "${PYCODEKG_SKIP_SNAPSHOT:-0}" = "1" ] && exit 0
35
+
36
+ REPO_ROOT="$(git rev-parse --show-toplevel)"
37
+ WORKSPACE_ROOT="$(cd "$REPO_ROOT/.." && pwd)"
38
+ DOCKG_REPO="${WORKSPACE_ROOT}/doc_kg"
39
+
40
+ cd "$REPO_ROOT"
41
+
42
+ # Capture the tree hash of the staged index NOW — before any tool modifies files.
43
+ TREE_HASH=$(git write-tree)
44
+ BRANCH=$(git rev-parse --abbrev-ref HEAD)
45
+
46
+ # Rebuild both local indices to keep them in sync with staged content.
47
+ "$REPO_ROOT/.venv/bin/pycodekg" build --repo "$REPO_ROOT" || exit 1
48
+ if [ -d "$DOCKG_REPO" ]; then
49
+ "$REPO_ROOT/.venv/bin/dockg" build --repo "$DOCKG_REPO" || true
50
+ fi
51
+
52
+ # Snapshot PyCodeKG (version auto-detected from installed package).
53
+ "$REPO_ROOT/.venv/bin/pycodekg" snapshot save \\
54
+ --repo . \\
55
+ --tree-hash "$TREE_HASH" \\
56
+ --branch "$BRANCH" \\
57
+ || { echo "[pycodekg] snapshot skipped (run 'pycodekg build' to initialize)" >&2; }
58
+
59
+ # Snapshot DocKG if available (version auto-detected from installed package).
60
+ if [ -d "$DOCKG_REPO/.dockg" ]; then
61
+ (cd "$DOCKG_REPO" && "$REPO_ROOT/.venv/bin/dockg" snapshot save \\
62
+ --repo . \\
63
+ --tree-hash "$TREE_HASH" \\
64
+ --branch "$BRANCH") || true
65
+ fi
66
+
67
+ # Stage both snapshot directories so they are included in the commit.
68
+ git add .pycodekg/snapshots/ 2>/dev/null || true
69
+ if [ -d "$DOCKG_REPO" ]; then
70
+ (cd "$DOCKG_REPO" && git add .dockg/snapshots/ 2>/dev/null || true)
71
+ fi
72
+
73
+ # Run pre-commit framework checks (ruff, mypy, detect-secrets, etc.) AFTER
74
+ # snapshots are captured and staged. Delegates to .pre-commit-config.yaml so
75
+ # quality checks stay in one place.
76
+ PRECOMMIT="$REPO_ROOT/.venv/bin/pre-commit"
77
+ if [ -x "$PRECOMMIT" ]; then
78
+ "$PRECOMMIT" run || exit 1
79
+ elif command -v pre-commit &>/dev/null; then
80
+ pre-commit run || exit 1
81
+ fi
82
+
83
+ exit 0
84
+ """
85
+
86
+
87
+ @cli.command("install-hooks")
88
+ @click.option(
89
+ "--repo",
90
+ default=".",
91
+ type=click.Path(exists=True),
92
+ show_default=True,
93
+ help="Repository root.",
94
+ )
95
+ @click.option(
96
+ "--force",
97
+ is_flag=True,
98
+ help="Overwrite an existing pre-commit hook.",
99
+ )
100
+ def install_hooks(repo: str, force: bool) -> None:
101
+ """Install the PyCodeKG pre-commit git hook.
102
+
103
+ After installation, before each commit:
104
+ 1. Rebuilds local PyCodeKG index (full wipe)
105
+ 2. Rebuilds local DocKG index if available (full wipe)
106
+ 3. Captures metrics snapshots for both KGs, keyed by tree hash
107
+ 4. Stages both snapshot directories atomically
108
+
109
+ This keeps PyCodeKG and DocKG indices in sync and ensures snapshots
110
+ reflect the current state of both knowledge graphs at commit time.
111
+
112
+ Example:
113
+ pycodekg install-hooks --repo .
114
+ """
115
+ repo_root = Path(repo).resolve()
116
+ git_dir = repo_root / ".git"
117
+
118
+ if not git_dir.is_dir():
119
+ click.echo(f"Error: {repo_root} is not a git repository.", err=True)
120
+ raise SystemExit(1)
121
+
122
+ hooks_dir = git_dir / "hooks"
123
+ hooks_dir.mkdir(exist_ok=True)
124
+ hook_path = hooks_dir / "pre-commit"
125
+
126
+ if hook_path.exists() and not force:
127
+ click.echo(f"Hook already exists: {hook_path}")
128
+ click.echo("Use --force to overwrite.")
129
+ raise SystemExit(1)
130
+
131
+ hook_path.write_text(_PRE_COMMIT_HOOK)
132
+ mode = hook_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
133
+ hook_path.chmod(mode)
134
+
135
+ click.echo(f"OK Installed pre-commit hook: {hook_path}")
136
+ click.echo(" Snapshots will be captured automatically before each commit.")
137
+ click.echo(" Run 'pycodekg build' first if you haven't built the graph yet.")