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/kg.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
kg.py
|
|
4
|
+
|
|
5
|
+
PyCodeKG — concrete KGModule implementation for Python codebases.
|
|
6
|
+
|
|
7
|
+
Owns the Python-specific extraction layer (CodeGraph / AST) and delegates
|
|
8
|
+
all generic infrastructure (SQLite, LanceDB, hybrid query, snippet packing)
|
|
9
|
+
to the KGModule base class.
|
|
10
|
+
|
|
11
|
+
Also re-exports the result types (BuildStats, QueryResult, Snippet,
|
|
12
|
+
SnippetPack) from module.types for backwards API compatibility.
|
|
13
|
+
|
|
14
|
+
Author: Eric G. Suchanek, PhD
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from pycode_kg.graph import CodeGraph
|
|
22
|
+
from pycode_kg.module.base import KGModule
|
|
23
|
+
from pycode_kg.module.extractor import KGExtractor, PyCodeKGExtractor
|
|
24
|
+
from pycode_kg.module.types import (
|
|
25
|
+
BuildStats,
|
|
26
|
+
QueryResult,
|
|
27
|
+
Snippet,
|
|
28
|
+
SnippetPack,
|
|
29
|
+
)
|
|
30
|
+
from pycode_kg.pycodekg import DEFAULT_MODEL
|
|
31
|
+
from pycode_kg.store import GraphStore
|
|
32
|
+
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
# Re-export result types for backwards compatibility
|
|
35
|
+
# (code that does ``from pycode_kg.kg import BuildStats`` keeps working)
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"PyCodeKG",
|
|
40
|
+
"BuildStats",
|
|
41
|
+
"QueryResult",
|
|
42
|
+
"Snippet",
|
|
43
|
+
"SnippetPack",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
# Python-specific kind priority
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
_PYCODEKG_KIND_PRIORITY: dict[str, int] = {
|
|
51
|
+
"function": 0,
|
|
52
|
+
"method": 1,
|
|
53
|
+
"class": 2,
|
|
54
|
+
"module": 3,
|
|
55
|
+
"symbol": 4,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
# PyCodeKG — concrete KGModule for Python
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class PyCodeKG(KGModule):
|
|
65
|
+
"""
|
|
66
|
+
Top-level orchestrator for the Code Knowledge Graph.
|
|
67
|
+
|
|
68
|
+
Subclasses :class:`~pycode_kg.module.base.KGModule` and provides the
|
|
69
|
+
Python-AST-specific extraction layer via :class:`PyCodeKGExtractor`.
|
|
70
|
+
All generic infrastructure — SQLite persistence, LanceDB indexing,
|
|
71
|
+
hybrid query, snippet packing, snapshots — is inherited from
|
|
72
|
+
:class:`~pycode_kg.module.base.KGModule`.
|
|
73
|
+
|
|
74
|
+
Typical usage::
|
|
75
|
+
|
|
76
|
+
kg = PyCodeKG(repo_root="/path/to/repo")
|
|
77
|
+
stats = kg.build(wipe=True)
|
|
78
|
+
print(stats)
|
|
79
|
+
|
|
80
|
+
result = kg.query("database connection setup", k=8, hop=1)
|
|
81
|
+
result.print_summary()
|
|
82
|
+
|
|
83
|
+
pack = kg.pack("configuration loading", k=8, hop=1)
|
|
84
|
+
pack.save("context.md")
|
|
85
|
+
|
|
86
|
+
:param repo_root: Repository root directory.
|
|
87
|
+
:param db_path: SQLite database path (defaults to
|
|
88
|
+
``<repo_root>/.pycodekg/graph.sqlite``).
|
|
89
|
+
:param lancedb_dir: LanceDB directory (defaults to
|
|
90
|
+
``<repo_root>/.pycodekg/lancedb``).
|
|
91
|
+
:param model: Sentence-transformer model name.
|
|
92
|
+
:param table: LanceDB table name.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
_default_dir = ".pycodekg"
|
|
96
|
+
|
|
97
|
+
def __init__(
|
|
98
|
+
self,
|
|
99
|
+
repo_root: str | Path,
|
|
100
|
+
db_path: str | Path | None = None,
|
|
101
|
+
lancedb_dir: str | Path | None = None,
|
|
102
|
+
*,
|
|
103
|
+
model: str = DEFAULT_MODEL,
|
|
104
|
+
table: str = "pycodekg_nodes",
|
|
105
|
+
) -> None:
|
|
106
|
+
"""Initialise ``PyCodeKG`` and resolve all paths.
|
|
107
|
+
|
|
108
|
+
:param repo_root: Repository root directory; resolved to an absolute path.
|
|
109
|
+
:param db_path: Path to the SQLite database file.
|
|
110
|
+
:param lancedb_dir: Directory used by LanceDB for the vector index.
|
|
111
|
+
:param model: Sentence-transformer model name used for embedding.
|
|
112
|
+
:param table: LanceDB table name for the node index.
|
|
113
|
+
"""
|
|
114
|
+
super().__init__(
|
|
115
|
+
repo_root,
|
|
116
|
+
db_path=db_path,
|
|
117
|
+
lancedb_dir=lancedb_dir,
|
|
118
|
+
model=model,
|
|
119
|
+
table=table,
|
|
120
|
+
)
|
|
121
|
+
# Backwards-compatible cached CodeGraph (direct access still works)
|
|
122
|
+
self._graph: CodeGraph | None = None
|
|
123
|
+
|
|
124
|
+
# ------------------------------------------------------------------
|
|
125
|
+
# KGModule abstract interface
|
|
126
|
+
# ------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
def make_extractor(self) -> KGExtractor:
|
|
129
|
+
"""Return a PyCodeKGExtractor for this repository.
|
|
130
|
+
|
|
131
|
+
:return: :class:`PyCodeKGExtractor` targeting :attr:`repo_root`.
|
|
132
|
+
"""
|
|
133
|
+
return PyCodeKGExtractor(self.repo_root)
|
|
134
|
+
|
|
135
|
+
def kind(self) -> str:
|
|
136
|
+
"""Return the KG kind string.
|
|
137
|
+
|
|
138
|
+
:return: ``"code"``
|
|
139
|
+
"""
|
|
140
|
+
return "code"
|
|
141
|
+
|
|
142
|
+
def analyze(self, *, include_edge_provenance: bool = False) -> str:
|
|
143
|
+
"""Run full nine-phase architectural analysis and return Markdown.
|
|
144
|
+
|
|
145
|
+
Delegates to :class:`~pycode_kg.pycodekg_thorough_analysis.PyCodeKGAnalyzer`.
|
|
146
|
+
This is the same analysis produced by ``pycodekg analyze``.
|
|
147
|
+
|
|
148
|
+
:param include_edge_provenance: Annotate edges with confidence metadata.
|
|
149
|
+
:return: Markdown-formatted architectural analysis report.
|
|
150
|
+
"""
|
|
151
|
+
try:
|
|
152
|
+
from io import StringIO # pylint: disable=import-outside-toplevel
|
|
153
|
+
|
|
154
|
+
from rich.console import Console # pylint: disable=import-outside-toplevel
|
|
155
|
+
|
|
156
|
+
from pycode_kg.pycodekg_thorough_analysis import ( # pylint: disable=import-outside-toplevel
|
|
157
|
+
PyCodeKGAnalyzer,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
silent = Console(file=StringIO(), highlight=False)
|
|
161
|
+
analyzer = PyCodeKGAnalyzer(self, console=silent)
|
|
162
|
+
analyzer.run_analysis()
|
|
163
|
+
return analyzer.to_markdown()
|
|
164
|
+
except Exception as exc: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
|
165
|
+
return f"# PyCodeKG Analysis\n\nAnalysis failed: {exc}\n"
|
|
166
|
+
|
|
167
|
+
# ------------------------------------------------------------------
|
|
168
|
+
# Python-specific overrides
|
|
169
|
+
# ------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
def _kind_priority(self, kind: str) -> int:
|
|
172
|
+
"""Return Python-specific node kind sort priority.
|
|
173
|
+
|
|
174
|
+
:param kind: Node kind string.
|
|
175
|
+
:return: Integer priority (lower = ranked first).
|
|
176
|
+
"""
|
|
177
|
+
return _PYCODEKG_KIND_PRIORITY.get(kind, 99)
|
|
178
|
+
|
|
179
|
+
def _post_build_hook(self, store: GraphStore) -> None:
|
|
180
|
+
"""Resolve Python symbol stubs after writing nodes/edges.
|
|
181
|
+
|
|
182
|
+
Called by :meth:`~pycode_kg.module.base.KGModule.build_graph` after
|
|
183
|
+
``store.write()``. Links ``sym:`` import stubs to their definitions
|
|
184
|
+
via ``RESOLVES_TO`` edges.
|
|
185
|
+
|
|
186
|
+
:param store: The :class:`~pycode_kg.store.GraphStore` just written to.
|
|
187
|
+
"""
|
|
188
|
+
store.resolve_symbols()
|
|
189
|
+
|
|
190
|
+
# ------------------------------------------------------------------
|
|
191
|
+
# Backwards-compatible CodeGraph property
|
|
192
|
+
# ------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def graph(self) -> CodeGraph:
|
|
196
|
+
"""Python AST extraction layer (lazy, cached).
|
|
197
|
+
|
|
198
|
+
Kept for backwards compatibility with code that accesses
|
|
199
|
+
``kg.graph`` directly. The build pipeline uses
|
|
200
|
+
:meth:`make_extractor` instead.
|
|
201
|
+
"""
|
|
202
|
+
if self._graph is None:
|
|
203
|
+
self._graph = CodeGraph(self.repo_root)
|
|
204
|
+
return self._graph
|
|
205
|
+
|
|
206
|
+
# ------------------------------------------------------------------
|
|
207
|
+
# Repr
|
|
208
|
+
# ------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
def __repr__(self) -> str:
|
|
211
|
+
"""Return an unambiguous string representation.
|
|
212
|
+
|
|
213
|
+
:return: ``PyCodeKG(repo_root=..., db_path=..., lancedb_dir=..., model=...)``.
|
|
214
|
+
"""
|
|
215
|
+
return (
|
|
216
|
+
f"PyCodeKG(repo_root={self.repo_root!r}, "
|
|
217
|
+
f"db_path={self.db_path!r}, "
|
|
218
|
+
f"lancedb_dir={self.lancedb_dir!r}, "
|
|
219
|
+
f"model={self.model_name!r})"
|
|
220
|
+
)
|
pycode_kg/layout3d.py
ADDED
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
layout3d.py — Pluggable 3-D layout engine for the PyCodeKG knowledge graph.
|
|
4
|
+
|
|
5
|
+
Provides an abstract :class:`Layout3D` base class and two concrete
|
|
6
|
+
implementations:
|
|
7
|
+
|
|
8
|
+
- :class:`AlliumLayout`: Each module is rendered as a Giant Allium plant
|
|
9
|
+
(a vertical stem with a Fibonacci-sphere "head" of classes and functions).
|
|
10
|
+
Modules are arranged in a Fibonacci annulus in the XY plane.
|
|
11
|
+
|
|
12
|
+
- :class:`LayerCakeLayout`: Node kind determines the Z level (modules at
|
|
13
|
+
the bottom, classes above, functions/methods at the top). XY positions
|
|
14
|
+
are spread via a golden-angle spiral within each layer.
|
|
15
|
+
|
|
16
|
+
The Fibonacci utilities (``fibonacci_sphere``, ``fibonacci_annulus``) are
|
|
17
|
+
adapted from *repo_vis* ``pkg_visualizer/utility.py``
|
|
18
|
+
(Eric G. Suchanek, PhD — https://github.com/Suchanek/repo_vis).
|
|
19
|
+
|
|
20
|
+
Author: Eric G. Suchanek, PhD
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from abc import ABC, abstractmethod
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
|
|
28
|
+
import numpy as np
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# Fibonacci spatial utilities (adapted from repo_vis/pkg_visualizer/utility.py)
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def fibonacci_sphere(
|
|
36
|
+
samples: int,
|
|
37
|
+
radius: float = 1.0,
|
|
38
|
+
center: np.ndarray | None = None,
|
|
39
|
+
) -> list[np.ndarray]:
|
|
40
|
+
"""
|
|
41
|
+
Distribute *samples* points uniformly on a sphere using the Fibonacci spiral.
|
|
42
|
+
|
|
43
|
+
Adapted from ``utility.fibonacci_sphere`` in *repo_vis*.
|
|
44
|
+
|
|
45
|
+
:param samples: Number of points to generate.
|
|
46
|
+
:param radius: Sphere radius.
|
|
47
|
+
:param center: Centre of the sphere (default: origin).
|
|
48
|
+
:return: List of 3-D coordinate arrays.
|
|
49
|
+
"""
|
|
50
|
+
if center is None:
|
|
51
|
+
center = np.zeros(3)
|
|
52
|
+
if samples <= 0:
|
|
53
|
+
return []
|
|
54
|
+
if samples == 1:
|
|
55
|
+
return [center + radius * np.array([0.0, 0.0, 1.0])]
|
|
56
|
+
|
|
57
|
+
phi = np.pi * (3.0 - np.sqrt(5.0)) # golden angle in radians
|
|
58
|
+
points: list[np.ndarray] = []
|
|
59
|
+
for i in range(samples):
|
|
60
|
+
y = 1.0 - (i / float(samples - 1)) * 2.0
|
|
61
|
+
r_at_y = np.sqrt(max(0.0, 1.0 - y * y))
|
|
62
|
+
theta = phi * i
|
|
63
|
+
x = np.cos(theta) * r_at_y
|
|
64
|
+
z = np.sin(theta) * r_at_y
|
|
65
|
+
points.append(center + radius * np.array([x, y, z]))
|
|
66
|
+
return points
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def fibonacci_annulus(
|
|
70
|
+
samples: int,
|
|
71
|
+
inner_radius: float = 1.0,
|
|
72
|
+
outer_radius: float = 2.0,
|
|
73
|
+
center: np.ndarray | None = None,
|
|
74
|
+
z_thickness: float = 0.2,
|
|
75
|
+
) -> list[np.ndarray]:
|
|
76
|
+
"""
|
|
77
|
+
Distribute *samples* points in a flat annular ring in the XY plane.
|
|
78
|
+
|
|
79
|
+
A small Z jitter (``z_thickness``) adds visual depth when non-zero.
|
|
80
|
+
Adapted from ``utility.fibonacci_annulus`` in *repo_vis*.
|
|
81
|
+
|
|
82
|
+
:param samples: Number of points to generate.
|
|
83
|
+
:param inner_radius: Inner radius of the annulus.
|
|
84
|
+
:param outer_radius: Outer radius of the annulus.
|
|
85
|
+
:param center: Centre of the annulus (default: origin).
|
|
86
|
+
:param z_thickness: Half-range of Z jitter applied to each point.
|
|
87
|
+
:return: List of 3-D coordinate arrays.
|
|
88
|
+
"""
|
|
89
|
+
if center is None:
|
|
90
|
+
center = np.zeros(3)
|
|
91
|
+
if samples <= 0:
|
|
92
|
+
return []
|
|
93
|
+
if samples == 1:
|
|
94
|
+
mid = (inner_radius + outer_radius) / 2.0
|
|
95
|
+
return [center + np.array([mid, 0.0, 0.0])]
|
|
96
|
+
|
|
97
|
+
phi = np.pi * (3.0 - np.sqrt(5.0))
|
|
98
|
+
r_range = outer_radius - inner_radius
|
|
99
|
+
r_step = r_range / max(samples - 1, 1)
|
|
100
|
+
rng = np.random.default_rng(42) # deterministic jitter seed
|
|
101
|
+
|
|
102
|
+
points: list[np.ndarray] = []
|
|
103
|
+
for i in range(samples):
|
|
104
|
+
r = inner_radius + i * r_step
|
|
105
|
+
theta = phi * i
|
|
106
|
+
x = np.cos(theta) * r
|
|
107
|
+
y = np.sin(theta) * r
|
|
108
|
+
z = (rng.random() * 2.0 - 1.0) * z_thickness
|
|
109
|
+
points.append(center + np.array([x, y, z]))
|
|
110
|
+
return points
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _golden_spiral_2d(
|
|
114
|
+
samples: int,
|
|
115
|
+
radius: float = 1.0,
|
|
116
|
+
center: np.ndarray | None = None,
|
|
117
|
+
z: float = 0.0,
|
|
118
|
+
) -> list[np.ndarray]:
|
|
119
|
+
"""
|
|
120
|
+
Place *samples* points in the XY plane using a golden-angle disc spiral.
|
|
121
|
+
|
|
122
|
+
:param samples: Number of points.
|
|
123
|
+
:param radius: Outer radius of the disc.
|
|
124
|
+
:param center: XY centre (Z component ignored; overridden by *z*).
|
|
125
|
+
:param z: Fixed Z coordinate for all output points.
|
|
126
|
+
:return: List of 3-D coordinate arrays.
|
|
127
|
+
"""
|
|
128
|
+
if center is None:
|
|
129
|
+
center = np.zeros(3)
|
|
130
|
+
if samples <= 0:
|
|
131
|
+
return []
|
|
132
|
+
|
|
133
|
+
phi = np.pi * (3.0 - np.sqrt(5.0))
|
|
134
|
+
points: list[np.ndarray] = []
|
|
135
|
+
for i in range(samples):
|
|
136
|
+
r = radius * np.sqrt(i / max(samples - 1, 1))
|
|
137
|
+
theta = phi * i
|
|
138
|
+
x = r * np.cos(theta)
|
|
139
|
+
y = r * np.sin(theta)
|
|
140
|
+
points.append(center + np.array([x, y, z]))
|
|
141
|
+
return points
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
# Data transfer objects
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass
|
|
150
|
+
class LayoutNode:
|
|
151
|
+
"""
|
|
152
|
+
Thin wrapper around a node dict from :class:`~pycode_kg.store.GraphStore`.
|
|
153
|
+
|
|
154
|
+
:param id: Stable node identifier (e.g. ``mod:src/foo.py``).
|
|
155
|
+
:param kind: Node kind — ``module``, ``class``, ``function``, ``method``,
|
|
156
|
+
or ``symbol``.
|
|
157
|
+
:param name: Short name of the node.
|
|
158
|
+
:param module_path: Source module path (may be ``None`` for symbol stubs).
|
|
159
|
+
:param docstring: Raw docstring text (may be ``None``).
|
|
160
|
+
:param lineno: First source line number (may be ``None``).
|
|
161
|
+
:param end_lineno: Last source line number (may be ``None``).
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
id: str
|
|
165
|
+
kind: str
|
|
166
|
+
name: str
|
|
167
|
+
module_path: str | None = None
|
|
168
|
+
docstring: str | None = None
|
|
169
|
+
lineno: int | None = None
|
|
170
|
+
end_lineno: int | None = None
|
|
171
|
+
|
|
172
|
+
@classmethod
|
|
173
|
+
def from_dict(cls, d: dict) -> LayoutNode:
|
|
174
|
+
"""Construct from a GraphStore node dict.
|
|
175
|
+
|
|
176
|
+
:param d: Dict with keys ``id``, ``kind``, ``name``, etc.
|
|
177
|
+
:return: New :class:`LayoutNode`.
|
|
178
|
+
"""
|
|
179
|
+
return cls(
|
|
180
|
+
id=d["id"],
|
|
181
|
+
kind=d["kind"],
|
|
182
|
+
name=d["name"],
|
|
183
|
+
module_path=d.get("module_path"),
|
|
184
|
+
docstring=d.get("docstring"),
|
|
185
|
+
lineno=d.get("lineno"),
|
|
186
|
+
end_lineno=d.get("end_lineno"),
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
@property
|
|
190
|
+
def line_count(self) -> int:
|
|
191
|
+
"""Approximate source size in lines (0 if unknown).
|
|
192
|
+
|
|
193
|
+
:return: ``end_lineno - lineno`` or 0.
|
|
194
|
+
"""
|
|
195
|
+
if self.lineno and self.end_lineno:
|
|
196
|
+
return max(0, self.end_lineno - self.lineno)
|
|
197
|
+
return 0
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@dataclass
|
|
201
|
+
class LayoutEdge:
|
|
202
|
+
"""
|
|
203
|
+
Thin wrapper around an edge dict from :class:`~pycode_kg.store.GraphStore`.
|
|
204
|
+
|
|
205
|
+
:param src: Source node ID.
|
|
206
|
+
:param rel: Relation type — ``CONTAINS``, ``CALLS``, ``IMPORTS``,
|
|
207
|
+
``INHERITS``.
|
|
208
|
+
:param dst: Destination node ID.
|
|
209
|
+
"""
|
|
210
|
+
|
|
211
|
+
src: str
|
|
212
|
+
rel: str
|
|
213
|
+
dst: str
|
|
214
|
+
|
|
215
|
+
@classmethod
|
|
216
|
+
def from_dict(cls, d: dict) -> LayoutEdge:
|
|
217
|
+
"""Construct from a GraphStore edge dict.
|
|
218
|
+
|
|
219
|
+
:param d: Dict with keys ``src``, ``rel``, ``dst``.
|
|
220
|
+
:return: New :class:`LayoutEdge`.
|
|
221
|
+
"""
|
|
222
|
+
return cls(src=d["src"], rel=d["rel"], dst=d["dst"])
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
# ---------------------------------------------------------------------------
|
|
226
|
+
# Abstract base
|
|
227
|
+
# ---------------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class Layout3D(ABC):
|
|
231
|
+
"""
|
|
232
|
+
Abstract base class for 3-D graph layout strategies.
|
|
233
|
+
|
|
234
|
+
Subclasses implement :meth:`compute` to assign a 3-D position to every
|
|
235
|
+
node, returning a ``{node_id: np.ndarray([x, y, z])}`` mapping that the
|
|
236
|
+
:class:`~pycode_kg.viz3d.KGViz3D` renderer consumes.
|
|
237
|
+
"""
|
|
238
|
+
|
|
239
|
+
@abstractmethod
|
|
240
|
+
def compute(
|
|
241
|
+
self,
|
|
242
|
+
nodes: list[LayoutNode],
|
|
243
|
+
edges: list[LayoutEdge],
|
|
244
|
+
) -> dict[str, np.ndarray]:
|
|
245
|
+
"""
|
|
246
|
+
Compute 3-D positions for all *nodes*.
|
|
247
|
+
|
|
248
|
+
:param nodes: All nodes in the graph.
|
|
249
|
+
:param edges: All edges in the graph (used to derive hierarchy).
|
|
250
|
+
:return: Mapping from node ID to ``[x, y, z]`` position.
|
|
251
|
+
"""
|
|
252
|
+
...
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# ---------------------------------------------------------------------------
|
|
256
|
+
# AlliumLayout
|
|
257
|
+
# ---------------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class AlliumLayout(Layout3D):
|
|
261
|
+
"""
|
|
262
|
+
Allium-plant layout: each module is visualised as a Giant Allium flower.
|
|
263
|
+
|
|
264
|
+
Spatial structure:
|
|
265
|
+
|
|
266
|
+
- **Stem base** — module node sits at XY position in a Fibonacci annulus
|
|
267
|
+
at ``Z = 0``.
|
|
268
|
+
- **Head** — classes and top-level functions are distributed on a
|
|
269
|
+
Fibonacci sphere centred at the stem apex (``Z = stem_height``).
|
|
270
|
+
Head radius scales with ``sqrt(n_children)``.
|
|
271
|
+
- **Florets** — methods orbit their parent class on a smaller Fibonacci
|
|
272
|
+
sphere, slightly above the head.
|
|
273
|
+
- **Orphans** — nodes with no CONTAINS parent cluster on a small sphere
|
|
274
|
+
at the origin.
|
|
275
|
+
|
|
276
|
+
Multiple module-alliums are arranged in a Fibonacci annulus in the XY
|
|
277
|
+
plane so they are evenly spaced regardless of count.
|
|
278
|
+
|
|
279
|
+
Inspired by :class:`GiantAllium` in *repo_vis/pkg_visualizer/plants3d.py*.
|
|
280
|
+
|
|
281
|
+
:param stem_height: Height of each allium stem (Z offset of the head).
|
|
282
|
+
:param base_head_radius: Minimum radius for the Fibonacci sphere head.
|
|
283
|
+
:param method_orbit_radius: Base radius for method sub-spheres.
|
|
284
|
+
:param annulus_inner_radius: Inner radius of the module placement ring.
|
|
285
|
+
:param annulus_outer_radius: Minimum outer radius (auto-scaled for large graphs).
|
|
286
|
+
"""
|
|
287
|
+
|
|
288
|
+
def __init__(
|
|
289
|
+
self,
|
|
290
|
+
stem_height: float = 8.0,
|
|
291
|
+
base_head_radius: float = 2.0,
|
|
292
|
+
method_orbit_radius: float = 0.8,
|
|
293
|
+
annulus_inner_radius: float = 8.0,
|
|
294
|
+
annulus_outer_radius: float = 20.0,
|
|
295
|
+
) -> None:
|
|
296
|
+
"""Initialise layout parameters.
|
|
297
|
+
|
|
298
|
+
:param stem_height: Vertical height of each allium stem.
|
|
299
|
+
:param base_head_radius: Minimum allium head sphere radius.
|
|
300
|
+
:param method_orbit_radius: Base orbit radius for methods.
|
|
301
|
+
:param annulus_inner_radius: Inner radius for module ring placement.
|
|
302
|
+
:param annulus_outer_radius: Minimum outer radius for module ring.
|
|
303
|
+
"""
|
|
304
|
+
self.stem_height = stem_height
|
|
305
|
+
self.base_head_radius = base_head_radius
|
|
306
|
+
self.method_orbit_radius = method_orbit_radius
|
|
307
|
+
self.annulus_inner_radius = annulus_inner_radius
|
|
308
|
+
self.annulus_outer_radius = annulus_outer_radius
|
|
309
|
+
|
|
310
|
+
def compute(
|
|
311
|
+
self,
|
|
312
|
+
nodes: list[LayoutNode],
|
|
313
|
+
edges: list[LayoutEdge],
|
|
314
|
+
) -> dict[str, np.ndarray]:
|
|
315
|
+
"""
|
|
316
|
+
Compute allium-plant 3-D positions for all nodes.
|
|
317
|
+
|
|
318
|
+
:param nodes: All nodes in the graph.
|
|
319
|
+
:param edges: All edges (``CONTAINS`` used to derive hierarchy).
|
|
320
|
+
:return: Mapping from node ID to ``[x, y, z]`` position.
|
|
321
|
+
"""
|
|
322
|
+
# Build CONTAINS hierarchy: child_id -> parent_id, parent_id -> [child_ids]
|
|
323
|
+
parent: dict[str, str] = {}
|
|
324
|
+
children: dict[str, list[str]] = {}
|
|
325
|
+
for e in edges:
|
|
326
|
+
if e.rel == "CONTAINS":
|
|
327
|
+
parent[e.dst] = e.src
|
|
328
|
+
children.setdefault(e.src, []).append(e.dst)
|
|
329
|
+
|
|
330
|
+
node_by_id: dict[str, LayoutNode] = {n.id: n for n in nodes}
|
|
331
|
+
positions: dict[str, np.ndarray] = {}
|
|
332
|
+
|
|
333
|
+
# Module nodes form the allium stems
|
|
334
|
+
modules = [n for n in nodes if n.kind == "module"]
|
|
335
|
+
if not modules:
|
|
336
|
+
# Fallback: treat nodes without a CONTAINS parent as pseudo-modules
|
|
337
|
+
modules = [n for n in nodes if n.id not in parent]
|
|
338
|
+
|
|
339
|
+
n_mods = len(modules)
|
|
340
|
+
inner = self.annulus_inner_radius
|
|
341
|
+
# Scale outer radius so stems don't crowd each other
|
|
342
|
+
outer = max(self.annulus_outer_radius, inner + n_mods * 2.5)
|
|
343
|
+
|
|
344
|
+
mod_positions = fibonacci_annulus(
|
|
345
|
+
n_mods,
|
|
346
|
+
inner_radius=inner,
|
|
347
|
+
outer_radius=outer,
|
|
348
|
+
center=np.zeros(3),
|
|
349
|
+
z_thickness=0.0, # flat ring — alliums stand vertically
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
for mod_node, mod_pos in zip(modules, mod_positions):
|
|
353
|
+
positions[mod_node.id] = np.array(mod_pos)
|
|
354
|
+
stem_apex = np.array([mod_pos[0], mod_pos[1], self.stem_height])
|
|
355
|
+
|
|
356
|
+
# Direct children (classes, top-level functions)
|
|
357
|
+
direct_ids = children.get(mod_node.id, [])
|
|
358
|
+
direct = [node_by_id[cid] for cid in direct_ids if cid in node_by_id]
|
|
359
|
+
n_direct = len(direct)
|
|
360
|
+
if not direct:
|
|
361
|
+
continue
|
|
362
|
+
|
|
363
|
+
# Head radius scales with child count
|
|
364
|
+
head_r = self.base_head_radius + np.sqrt(n_direct) * 0.4
|
|
365
|
+
head_positions = fibonacci_sphere(n_direct, radius=head_r, center=stem_apex)
|
|
366
|
+
|
|
367
|
+
for child, child_pos in zip(direct, head_positions):
|
|
368
|
+
positions[child.id] = np.array(child_pos)
|
|
369
|
+
|
|
370
|
+
# Grandchildren (methods) orbit their parent class
|
|
371
|
+
grand_ids = children.get(child.id, [])
|
|
372
|
+
grand = [node_by_id[gid] for gid in grand_ids if gid in node_by_id]
|
|
373
|
+
n_grand = len(grand)
|
|
374
|
+
if not grand:
|
|
375
|
+
continue
|
|
376
|
+
|
|
377
|
+
method_r = self.method_orbit_radius + np.sqrt(n_grand) * 0.15
|
|
378
|
+
method_positions = fibonacci_sphere(
|
|
379
|
+
n_grand, radius=method_r, center=np.array(child_pos)
|
|
380
|
+
)
|
|
381
|
+
for gc, gc_pos in zip(grand, method_positions):
|
|
382
|
+
positions[gc.id] = np.array(gc_pos)
|
|
383
|
+
|
|
384
|
+
# Orphan nodes: anything not yet placed (symbols, unrooted nodes)
|
|
385
|
+
orphans = [n for n in nodes if n.id not in positions]
|
|
386
|
+
if orphans:
|
|
387
|
+
orphan_positions = fibonacci_sphere(len(orphans), radius=3.0, center=np.zeros(3))
|
|
388
|
+
for n, pos in zip(orphans, orphan_positions):
|
|
389
|
+
positions[n.id] = np.array(pos)
|
|
390
|
+
|
|
391
|
+
return positions
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
# ---------------------------------------------------------------------------
|
|
395
|
+
# LayerCakeLayout
|
|
396
|
+
# ---------------------------------------------------------------------------
|
|
397
|
+
|
|
398
|
+
# Z level per node kind
|
|
399
|
+
_KIND_ZLEVEL: dict[str, int] = {
|
|
400
|
+
"module": 0,
|
|
401
|
+
"class": 1,
|
|
402
|
+
"function": 2,
|
|
403
|
+
"method": 2,
|
|
404
|
+
"symbol": 3,
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
class LayerCakeLayout(Layout3D):
|
|
409
|
+
"""
|
|
410
|
+
Stratified layout: node *kind* determines the Z layer; XY positions use a
|
|
411
|
+
golden-angle disc spiral within each layer.
|
|
412
|
+
|
|
413
|
+
Layers (bottom to top):
|
|
414
|
+
|
|
415
|
+
- **Z = 0** — modules
|
|
416
|
+
- **Z = layer_gap** — classes
|
|
417
|
+
- **Z = 2 × layer_gap** — functions and methods
|
|
418
|
+
- **Z = 3 × layer_gap** — symbol stubs
|
|
419
|
+
|
|
420
|
+
Cross-cutting edges (``CALLS``, ``IMPORTS``, ``INHERITS``) arc between
|
|
421
|
+
layers, making structural coupling immediately visible from any angle.
|
|
422
|
+
|
|
423
|
+
:param layer_gap: Vertical distance between adjacent layers.
|
|
424
|
+
:param disc_radius: Base outer radius of the golden-angle disc per layer.
|
|
425
|
+
"""
|
|
426
|
+
|
|
427
|
+
def __init__(
|
|
428
|
+
self,
|
|
429
|
+
layer_gap: float = 12.0,
|
|
430
|
+
disc_radius: float = 28.0,
|
|
431
|
+
) -> None:
|
|
432
|
+
"""Initialise layout parameters.
|
|
433
|
+
|
|
434
|
+
:param layer_gap: Vertical separation between layers.
|
|
435
|
+
:param disc_radius: Base XY spread radius per layer disc.
|
|
436
|
+
"""
|
|
437
|
+
self.layer_gap = layer_gap
|
|
438
|
+
self.disc_radius = disc_radius
|
|
439
|
+
|
|
440
|
+
def compute(
|
|
441
|
+
self,
|
|
442
|
+
nodes: list[LayoutNode],
|
|
443
|
+
edges: list[LayoutEdge],
|
|
444
|
+
) -> dict[str, np.ndarray]:
|
|
445
|
+
"""
|
|
446
|
+
Compute layer-cake 3-D positions for all nodes.
|
|
447
|
+
|
|
448
|
+
:param nodes: All nodes in the graph.
|
|
449
|
+
:param edges: Unused by this layout (present for API compatibility).
|
|
450
|
+
:return: Mapping from node ID to ``[x, y, z]`` position.
|
|
451
|
+
"""
|
|
452
|
+
# Group nodes by Z layer
|
|
453
|
+
layers: dict[int, list[LayoutNode]] = {}
|
|
454
|
+
for n in nodes:
|
|
455
|
+
level = _KIND_ZLEVEL.get(n.kind, 3)
|
|
456
|
+
layers.setdefault(level, []).append(n)
|
|
457
|
+
|
|
458
|
+
positions: dict[str, np.ndarray] = {}
|
|
459
|
+
n_total = max(len(nodes), 1)
|
|
460
|
+
|
|
461
|
+
for level, layer_nodes in layers.items():
|
|
462
|
+
z = level * self.layer_gap
|
|
463
|
+
# Scale disc radius proportionally to the layer's node count
|
|
464
|
+
r = self.disc_radius * np.sqrt(len(layer_nodes) / n_total)
|
|
465
|
+
r = max(r, 4.0) # minimum spread
|
|
466
|
+
pts = _golden_spiral_2d(len(layer_nodes), radius=r, z=z)
|
|
467
|
+
for n, pt in zip(layer_nodes, pts):
|
|
468
|
+
positions[n.id] = pt
|
|
469
|
+
|
|
470
|
+
return positions
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MCP tool hooks for bridge centrality in PyCodeKG.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
# Placeholder for MCP integration
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def top_bridges(kind="module", limit=20):
|
|
9
|
+
from pycode_kg.analysis.bridge import ( # pylint: disable=import-outside-toplevel
|
|
10
|
+
compute_bridge_centrality,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
bridges = compute_bridge_centrality(kind=kind, top=limit)
|
|
14
|
+
return bridges
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def why_bridge(node_id):
|
|
18
|
+
# TODO: Explain why a node is a bridge
|
|
19
|
+
pass
|