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
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cmd_snapshot.py
|
|
3
|
+
|
|
4
|
+
Click subcommands for managing temporal snapshots of PyCodeKG metrics:
|
|
5
|
+
|
|
6
|
+
snapshot save — capture current metrics and save snapshot
|
|
7
|
+
snapshot list — show all snapshots with key metrics
|
|
8
|
+
snapshot show — display full snapshot details
|
|
9
|
+
snapshot diff — compare two snapshots
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import click
|
|
18
|
+
|
|
19
|
+
from pycode_kg.cli.main import cli
|
|
20
|
+
from pycode_kg.cli.options import sqlite_option
|
|
21
|
+
from pycode_kg.kg import PyCodeKG
|
|
22
|
+
from pycode_kg.pycodekg import DEFAULT_MODEL
|
|
23
|
+
from pycode_kg.pycodekg_thorough_analysis import PyCodeKGAnalyzer
|
|
24
|
+
from pycode_kg.snapshots import SnapshotManager
|
|
25
|
+
from pycode_kg.store import GraphStore
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@cli.group("snapshot")
|
|
29
|
+
def snapshot() -> None:
|
|
30
|
+
"""Manage temporal snapshots of PyCodeKG metrics."""
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@snapshot.command("save")
|
|
35
|
+
@click.argument("version", metavar="VERSION", default="", required=False)
|
|
36
|
+
@click.option(
|
|
37
|
+
"--repo",
|
|
38
|
+
default=".",
|
|
39
|
+
type=click.Path(exists=True),
|
|
40
|
+
show_default=True,
|
|
41
|
+
help="Repository root path.",
|
|
42
|
+
)
|
|
43
|
+
@sqlite_option
|
|
44
|
+
@click.option(
|
|
45
|
+
"--snapshots-dir",
|
|
46
|
+
default=None,
|
|
47
|
+
type=click.Path(),
|
|
48
|
+
help="Snapshots directory (default: .pycodekg/snapshots).",
|
|
49
|
+
)
|
|
50
|
+
@click.option(
|
|
51
|
+
"--branch",
|
|
52
|
+
default=None,
|
|
53
|
+
type=str,
|
|
54
|
+
help="Branch name; auto-detected if not provided.",
|
|
55
|
+
)
|
|
56
|
+
@click.option(
|
|
57
|
+
"--tree-hash",
|
|
58
|
+
default="",
|
|
59
|
+
type=str,
|
|
60
|
+
help="Git tree hash; auto-detected if not provided.",
|
|
61
|
+
)
|
|
62
|
+
def save_snapshot(
|
|
63
|
+
version: str | None,
|
|
64
|
+
repo: str,
|
|
65
|
+
sqlite: str,
|
|
66
|
+
snapshots_dir: str | None,
|
|
67
|
+
branch: str | None,
|
|
68
|
+
tree_hash: str,
|
|
69
|
+
) -> None:
|
|
70
|
+
"""
|
|
71
|
+
Capture current PyCodeKG metrics and save as a temporal snapshot.
|
|
72
|
+
|
|
73
|
+
Reads graph statistics, docstring coverage, and complexity metrics from
|
|
74
|
+
the SQLite graph, then saves a snapshot tagged with the given VERSION.
|
|
75
|
+
The tree hash is auto-detected from git when not provided.
|
|
76
|
+
|
|
77
|
+
Snapshots are stored in .pycodekg/snapshots/{tree_hash}.json, with a
|
|
78
|
+
manifest.json tracking all snapshots and their metrics.
|
|
79
|
+
|
|
80
|
+
Example:
|
|
81
|
+
pycodekg snapshot save 0.5.1 --repo .
|
|
82
|
+
"""
|
|
83
|
+
repo_root = Path(repo).resolve()
|
|
84
|
+
db_path = Path(sqlite)
|
|
85
|
+
snapshots_path = (
|
|
86
|
+
Path(snapshots_dir).resolve() if snapshots_dir else (repo_root / ".pycodekg" / "snapshots")
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Load graph stats
|
|
90
|
+
store = GraphStore(db_path)
|
|
91
|
+
try:
|
|
92
|
+
stats = store.stats()
|
|
93
|
+
finally:
|
|
94
|
+
store.close()
|
|
95
|
+
|
|
96
|
+
# Load PyCodeKG and get docstring coverage + complexity metrics
|
|
97
|
+
kg = PyCodeKG(
|
|
98
|
+
repo_root=repo_root,
|
|
99
|
+
db_path=db_path,
|
|
100
|
+
lancedb_dir=repo_root / ".pycodekg" / "lancedb",
|
|
101
|
+
model=DEFAULT_MODEL,
|
|
102
|
+
)
|
|
103
|
+
snap_mgr = SnapshotManager(snapshots_path, db_path=db_path)
|
|
104
|
+
try:
|
|
105
|
+
analyzer = PyCodeKGAnalyzer(kg, snapshot_mgr=snap_mgr)
|
|
106
|
+
analysis = analyzer.run_analysis()
|
|
107
|
+
|
|
108
|
+
# Extract metrics from analysis results
|
|
109
|
+
docstring_cov = analysis.get("docstring_coverage", {})
|
|
110
|
+
coverage = docstring_cov.get("coverage_pct", 0.0) / 100.0 if docstring_cov else 0.0
|
|
111
|
+
issue_strings = analysis.get("issues", [])
|
|
112
|
+
critical_issues = len(issue_strings)
|
|
113
|
+
|
|
114
|
+
# Compile hotspots from function metrics
|
|
115
|
+
fn_metrics = analysis.get("function_metrics", {})
|
|
116
|
+
hotspots = [
|
|
117
|
+
{
|
|
118
|
+
"name": name,
|
|
119
|
+
"callers": metrics.get("fan_in", 0),
|
|
120
|
+
"callees": metrics.get("fan_out", 0),
|
|
121
|
+
"risk_level": metrics.get("risk_level", "low"),
|
|
122
|
+
}
|
|
123
|
+
for name, metrics in list(fn_metrics.items())[:10]
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
# Calculate complexity median from function metrics
|
|
127
|
+
fan_ins = [m.get("fan_in", 0) for m in fn_metrics.values()]
|
|
128
|
+
complexity_median = (
|
|
129
|
+
(sorted(fan_ins)[len(fan_ins) // 2] if len(fan_ins) > 0 else 0.0) if fan_ins else 0.0
|
|
130
|
+
)
|
|
131
|
+
finally:
|
|
132
|
+
kg.close()
|
|
133
|
+
|
|
134
|
+
# Capture snapshot
|
|
135
|
+
snapshot_obj = snap_mgr.capture(
|
|
136
|
+
version=version,
|
|
137
|
+
branch=branch,
|
|
138
|
+
graph_stats_dict=stats,
|
|
139
|
+
coverage=coverage,
|
|
140
|
+
critical_issues=critical_issues,
|
|
141
|
+
complexity_median=complexity_median,
|
|
142
|
+
hotspots=hotspots,
|
|
143
|
+
issues=issue_strings,
|
|
144
|
+
tree_hash=tree_hash,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
snapshot_file = snap_mgr.save_snapshot(snapshot_obj)
|
|
148
|
+
click.echo(f"OK Snapshot saved: {snapshot_file}")
|
|
149
|
+
click.echo(f" Key: {snapshot_obj.key}")
|
|
150
|
+
click.echo(f" Version: {snapshot_obj.version}")
|
|
151
|
+
click.echo(f" Nodes: {snapshot_obj.metrics.total_nodes}")
|
|
152
|
+
click.echo(f" Edges: {snapshot_obj.metrics.total_edges}")
|
|
153
|
+
click.echo(f" Coverage: {snapshot_obj.metrics.docstring_coverage:.1%}")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@snapshot.command("list")
|
|
157
|
+
@click.option(
|
|
158
|
+
"--snapshots-dir",
|
|
159
|
+
default=None,
|
|
160
|
+
type=click.Path(exists=True),
|
|
161
|
+
help="Snapshots directory (default: .pycodekg/snapshots).",
|
|
162
|
+
)
|
|
163
|
+
@click.option(
|
|
164
|
+
"--limit",
|
|
165
|
+
type=int,
|
|
166
|
+
default=None,
|
|
167
|
+
help="Max snapshots to show.",
|
|
168
|
+
)
|
|
169
|
+
@click.option(
|
|
170
|
+
"--json",
|
|
171
|
+
"output_json",
|
|
172
|
+
is_flag=True,
|
|
173
|
+
help="Output as JSON.",
|
|
174
|
+
)
|
|
175
|
+
def list_snapshots(snapshots_dir: str | None, limit: int | None, output_json: bool) -> None:
|
|
176
|
+
"""
|
|
177
|
+
List all temporal snapshots in reverse chronological order.
|
|
178
|
+
|
|
179
|
+
Shows key, timestamp, version, and key metrics (nodes, edges, coverage)
|
|
180
|
+
for each snapshot.
|
|
181
|
+
"""
|
|
182
|
+
snapshots_path = (
|
|
183
|
+
Path(snapshots_dir).resolve() if snapshots_dir else (Path.cwd() / ".pycodekg" / "snapshots")
|
|
184
|
+
)
|
|
185
|
+
mgr = SnapshotManager(snapshots_path)
|
|
186
|
+
snapshots = mgr.list_snapshots(limit=limit)
|
|
187
|
+
|
|
188
|
+
if not snapshots:
|
|
189
|
+
click.echo("No snapshots found.")
|
|
190
|
+
return
|
|
191
|
+
|
|
192
|
+
if output_json:
|
|
193
|
+
click.echo(json.dumps(snapshots, indent=2))
|
|
194
|
+
else:
|
|
195
|
+
# Table output
|
|
196
|
+
click.echo(
|
|
197
|
+
f"{'Key':<12} {'Timestamp':<20} {'Branch':<12} {'Version':<8} {'Nodes':<6} {'Edges':<6} {'Coverage':<9}"
|
|
198
|
+
)
|
|
199
|
+
click.echo("-" * 85)
|
|
200
|
+
for snap in snapshots:
|
|
201
|
+
key = snap["key"][:12]
|
|
202
|
+
# Parse ISO timestamp and format as YYYY-MM-DD HH:MM
|
|
203
|
+
ts = snap["timestamp"]
|
|
204
|
+
ts_display = ts[:16].replace("T", " ") if ts else "unknown"[:20]
|
|
205
|
+
branch = snap["branch"][:12]
|
|
206
|
+
version = snap["version"][:8]
|
|
207
|
+
nodes = snap["metrics"]["total_nodes"]
|
|
208
|
+
edges = snap["metrics"]["total_edges"]
|
|
209
|
+
coverage = snap["metrics"]["docstring_coverage"]
|
|
210
|
+
click.echo(
|
|
211
|
+
f"{key:<12} {ts_display:<20} {branch:<12} {version:<8} {nodes:<6} {edges:<6} {coverage:>6.1%}"
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@snapshot.command("show")
|
|
216
|
+
@click.argument("key", metavar="KEY")
|
|
217
|
+
@click.option(
|
|
218
|
+
"--snapshots-dir",
|
|
219
|
+
default=None,
|
|
220
|
+
type=click.Path(exists=True),
|
|
221
|
+
help="Snapshots directory (default: .pycodekg/snapshots).",
|
|
222
|
+
)
|
|
223
|
+
def show_snapshot(key: str, snapshots_dir: str | None) -> None:
|
|
224
|
+
"""
|
|
225
|
+
Display full details for a single snapshot by key (tree hash).
|
|
226
|
+
|
|
227
|
+
Shows all metrics, hotspots, and deltas vs. previous and baseline snapshots.
|
|
228
|
+
"""
|
|
229
|
+
snapshots_path = (
|
|
230
|
+
Path(snapshots_dir).resolve() if snapshots_dir else (Path.cwd() / ".pycodekg" / "snapshots")
|
|
231
|
+
)
|
|
232
|
+
mgr = SnapshotManager(snapshots_path)
|
|
233
|
+
snapshot_obj = mgr.load_snapshot(key)
|
|
234
|
+
|
|
235
|
+
if not snapshot_obj:
|
|
236
|
+
click.echo(f"Snapshot not found: {key}", err=True)
|
|
237
|
+
raise click.Abort()
|
|
238
|
+
|
|
239
|
+
# Print snapshot details
|
|
240
|
+
click.echo(f"Key: {snapshot_obj.key}")
|
|
241
|
+
click.echo(f"Branch: {snapshot_obj.branch}")
|
|
242
|
+
click.echo(f"Timestamp: {snapshot_obj.timestamp}")
|
|
243
|
+
click.echo(f"Version: {snapshot_obj.version}")
|
|
244
|
+
click.echo()
|
|
245
|
+
|
|
246
|
+
click.echo("Metrics:")
|
|
247
|
+
click.echo(f" Total Nodes: {snapshot_obj.metrics.total_nodes}")
|
|
248
|
+
click.echo(f" Total Edges: {snapshot_obj.metrics.total_edges}")
|
|
249
|
+
click.echo(f" Meaningful Nodes: {snapshot_obj.metrics.meaningful_nodes}")
|
|
250
|
+
click.echo(f" Docstring Coverage: {snapshot_obj.metrics.docstring_coverage:.1%}")
|
|
251
|
+
click.echo(f" Critical Issues: {snapshot_obj.metrics.critical_issues}")
|
|
252
|
+
click.echo(f" Complexity Median: {snapshot_obj.metrics.complexity_median:.2f}")
|
|
253
|
+
click.echo()
|
|
254
|
+
|
|
255
|
+
click.echo("Node/Edge Breakdown:")
|
|
256
|
+
for kind, count in sorted(snapshot_obj.metrics.node_counts.items()):
|
|
257
|
+
click.echo(f" {kind}: {count}")
|
|
258
|
+
click.echo()
|
|
259
|
+
for rel, count in sorted(snapshot_obj.metrics.edge_counts.items()):
|
|
260
|
+
click.echo(f" {rel}: {count}")
|
|
261
|
+
click.echo()
|
|
262
|
+
|
|
263
|
+
if snapshot_obj.hotspots:
|
|
264
|
+
click.echo("Top Hotspots (Fan-In):")
|
|
265
|
+
for i, hotspot in enumerate(snapshot_obj.hotspots[:5], 1):
|
|
266
|
+
name = hotspot.get("name", "unknown")
|
|
267
|
+
callers = hotspot.get("callers", 0)
|
|
268
|
+
click.echo(f" {i}. {name} ({callers} callers)")
|
|
269
|
+
click.echo()
|
|
270
|
+
|
|
271
|
+
if snapshot_obj.vs_previous:
|
|
272
|
+
click.echo("Delta vs. Previous:")
|
|
273
|
+
delta = snapshot_obj.vs_previous
|
|
274
|
+
click.echo(f" Nodes: {delta.nodes:+d}")
|
|
275
|
+
click.echo(f" Edges: {delta.edges:+d}")
|
|
276
|
+
click.echo(f" Coverage: {delta.coverage_delta:+.1%}")
|
|
277
|
+
click.echo(f" Issues: {delta.critical_issues_delta:+d}")
|
|
278
|
+
click.echo()
|
|
279
|
+
|
|
280
|
+
if snapshot_obj.vs_baseline:
|
|
281
|
+
click.echo("Delta vs. Baseline:")
|
|
282
|
+
delta = snapshot_obj.vs_baseline
|
|
283
|
+
click.echo(f" Nodes: {delta.nodes:+d}")
|
|
284
|
+
click.echo(f" Edges: {delta.edges:+d}")
|
|
285
|
+
click.echo(f" Coverage: {delta.coverage_delta:+.1%}")
|
|
286
|
+
click.echo(f" Issues: {delta.critical_issues_delta:+d}")
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
@snapshot.command("prune")
|
|
290
|
+
@click.option(
|
|
291
|
+
"--snapshots-dir",
|
|
292
|
+
default=None,
|
|
293
|
+
type=click.Path(),
|
|
294
|
+
help="Snapshots directory (default: .pycodekg/snapshots).",
|
|
295
|
+
)
|
|
296
|
+
@click.option(
|
|
297
|
+
"--dry-run",
|
|
298
|
+
is_flag=True,
|
|
299
|
+
help="Show what would be removed without deleting anything.",
|
|
300
|
+
)
|
|
301
|
+
def prune_snapshots(snapshots_dir: str | None, dry_run: bool) -> None:
|
|
302
|
+
"""
|
|
303
|
+
Remove vestigial snapshots that carry no new metric information.
|
|
304
|
+
|
|
305
|
+
Cleans up three categories:
|
|
306
|
+
|
|
307
|
+
\b
|
|
308
|
+
1. Metric-duplicates — interior snapshots with unchanged metrics.
|
|
309
|
+
2. Broken entries — manifest entries whose JSON file is missing.
|
|
310
|
+
3. Orphaned files — JSON files on disk not referenced by the manifest.
|
|
311
|
+
|
|
312
|
+
The oldest (baseline) and newest (latest) snapshots are always kept.
|
|
313
|
+
|
|
314
|
+
Example:
|
|
315
|
+
pycodekg snapshot prune --dry-run
|
|
316
|
+
pycodekg snapshot prune
|
|
317
|
+
"""
|
|
318
|
+
snapshots_path = (
|
|
319
|
+
Path(snapshots_dir).resolve() if snapshots_dir else (Path.cwd() / ".pycodekg" / "snapshots")
|
|
320
|
+
)
|
|
321
|
+
mgr = SnapshotManager(snapshots_path)
|
|
322
|
+
result = mgr.prune_snapshots(dry_run=dry_run)
|
|
323
|
+
|
|
324
|
+
prefix = "[dry-run] " if dry_run else ""
|
|
325
|
+
if result.total_cleaned == 0:
|
|
326
|
+
click.echo("Nothing to prune.")
|
|
327
|
+
return
|
|
328
|
+
|
|
329
|
+
if result.removed:
|
|
330
|
+
click.echo(f"{prefix}Metric-duplicates removed: {len(result.removed)}")
|
|
331
|
+
for key in result.removed:
|
|
332
|
+
click.echo(f" - {key}")
|
|
333
|
+
if result.broken_entries:
|
|
334
|
+
click.echo(f"{prefix}Broken manifest entries removed: {len(result.broken_entries)}")
|
|
335
|
+
for key in result.broken_entries:
|
|
336
|
+
click.echo(f" - {key}")
|
|
337
|
+
if result.orphaned_files:
|
|
338
|
+
click.echo(f"{prefix}Orphaned JSON files removed: {len(result.orphaned_files)}")
|
|
339
|
+
for fname in result.orphaned_files:
|
|
340
|
+
click.echo(f" - {fname}")
|
|
341
|
+
|
|
342
|
+
action = "would be" if dry_run else "were"
|
|
343
|
+
click.echo(f"\nTotal: {result.total_cleaned} item(s) {action} cleaned.")
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
@snapshot.command("diff")
|
|
347
|
+
@click.argument("key_a", metavar="KEY_A")
|
|
348
|
+
@click.argument("key_b", metavar="KEY_B")
|
|
349
|
+
@click.option(
|
|
350
|
+
"--snapshots-dir",
|
|
351
|
+
default=None,
|
|
352
|
+
type=click.Path(exists=True),
|
|
353
|
+
help="Snapshots directory (default: .pycodekg/snapshots).",
|
|
354
|
+
)
|
|
355
|
+
@click.option(
|
|
356
|
+
"--json",
|
|
357
|
+
"output_json",
|
|
358
|
+
is_flag=True,
|
|
359
|
+
help="Output as JSON.",
|
|
360
|
+
)
|
|
361
|
+
def diff_snapshots(key_a: str, key_b: str, snapshots_dir: str | None, output_json: bool) -> None:
|
|
362
|
+
"""
|
|
363
|
+
Compare two snapshots side-by-side.
|
|
364
|
+
|
|
365
|
+
Shows metrics from both snapshots and computed deltas (B - A).
|
|
366
|
+
|
|
367
|
+
Example:
|
|
368
|
+
pycodekg snapshot diff 660e4f0a 3487ed5b
|
|
369
|
+
"""
|
|
370
|
+
snapshots_path = (
|
|
371
|
+
Path(snapshots_dir).resolve() if snapshots_dir else (Path.cwd() / ".pycodekg" / "snapshots")
|
|
372
|
+
)
|
|
373
|
+
mgr = SnapshotManager(snapshots_path)
|
|
374
|
+
diff_result = mgr.diff_snapshots(key_a, key_b)
|
|
375
|
+
|
|
376
|
+
if "error" in diff_result:
|
|
377
|
+
click.echo(f"Error: {diff_result['error']}", err=True)
|
|
378
|
+
raise click.Abort()
|
|
379
|
+
|
|
380
|
+
if output_json:
|
|
381
|
+
click.echo(json.dumps(diff_result, indent=2))
|
|
382
|
+
else:
|
|
383
|
+
# Table output
|
|
384
|
+
a = diff_result["a"]
|
|
385
|
+
b = diff_result["b"]
|
|
386
|
+
|
|
387
|
+
click.echo(f"Comparing {a['key'][:10]} vs {b['key'][:10]}")
|
|
388
|
+
click.echo()
|
|
389
|
+
click.echo(f"{'Metric':<20} {'A':<12} {'B':<12} {'Δ':<12}")
|
|
390
|
+
click.echo("-" * 56)
|
|
391
|
+
|
|
392
|
+
metrics_a = a["metrics"]
|
|
393
|
+
metrics_b = b["metrics"]
|
|
394
|
+
|
|
395
|
+
for key in ["total_nodes", "total_edges", "meaningful_nodes"]:
|
|
396
|
+
val_a = metrics_a[key]
|
|
397
|
+
val_b = metrics_b[key]
|
|
398
|
+
delta_val = val_b - val_a
|
|
399
|
+
click.echo(f"{key:<20} {val_a:<12} {val_b:<12} {delta_val:+d}")
|
|
400
|
+
|
|
401
|
+
cov_a = metrics_a["docstring_coverage"]
|
|
402
|
+
cov_b = metrics_b["docstring_coverage"]
|
|
403
|
+
cov_delta = cov_b - cov_a
|
|
404
|
+
click.echo(f"{'docstring_coverage':<20} {cov_a:<12.1%} {cov_b:<12.1%} {cov_delta:+.1%}")
|
|
405
|
+
|
|
406
|
+
issues_a = metrics_a["critical_issues"]
|
|
407
|
+
issues_b = metrics_b["critical_issues"]
|
|
408
|
+
issues_delta = issues_b - issues_a
|
|
409
|
+
click.echo(f"{'critical_issues':<20} {issues_a:<12} {issues_b:<12} {issues_delta:+d}")
|
|
410
|
+
|
|
411
|
+
issues_data = diff_result.get("issues_delta", {})
|
|
412
|
+
introduced = issues_data.get("introduced", [])
|
|
413
|
+
resolved = issues_data.get("resolved", [])
|
|
414
|
+
|
|
415
|
+
if introduced or resolved:
|
|
416
|
+
click.echo()
|
|
417
|
+
click.echo("Issue Changes:")
|
|
418
|
+
for issue in introduced:
|
|
419
|
+
click.echo(f" [+] {issue}")
|
|
420
|
+
for issue in resolved:
|
|
421
|
+
click.echo(f" [-] {issue}")
|
pycode_kg/cli/cmd_viz.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cmd_viz.py
|
|
3
|
+
|
|
4
|
+
Click subcommands for launching PyCodeKG visualizers:
|
|
5
|
+
|
|
6
|
+
viz — Streamlit-based interactive graph explorer
|
|
7
|
+
viz3d — PyVista/PyQt5 3-D interactive knowledge-graph visualizer
|
|
8
|
+
viz-timeline — Interactive temporal metrics visualization from snapshots
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import importlib.util
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import click
|
|
19
|
+
|
|
20
|
+
from pycode_kg.cli.main import cli
|
|
21
|
+
|
|
22
|
+
_VIZ_EXTRA = 'pip install "pycode-kg[viz]"'
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@cli.command("viz")
|
|
26
|
+
@click.option(
|
|
27
|
+
"--db",
|
|
28
|
+
default=".pycodekg/graph.sqlite",
|
|
29
|
+
show_default=True,
|
|
30
|
+
help="SQLite database path.",
|
|
31
|
+
)
|
|
32
|
+
@click.option(
|
|
33
|
+
"--port",
|
|
34
|
+
default="8500",
|
|
35
|
+
show_default=True,
|
|
36
|
+
help="Streamlit server port.",
|
|
37
|
+
)
|
|
38
|
+
@click.option(
|
|
39
|
+
"--no-browser",
|
|
40
|
+
is_flag=True,
|
|
41
|
+
help="Do not open a browser window automatically.",
|
|
42
|
+
)
|
|
43
|
+
def viz(db: str, port: str, no_browser: bool) -> None:
|
|
44
|
+
"""Launch the PyCodeKG Streamlit visualizer."""
|
|
45
|
+
if importlib.util.find_spec("streamlit") is None:
|
|
46
|
+
raise click.UsageError(
|
|
47
|
+
f"streamlit is not installed. Install viz dependencies with:\n {_VIZ_EXTRA}"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
app_path = Path(__file__).parent.parent / "app.py"
|
|
51
|
+
|
|
52
|
+
if not app_path.exists():
|
|
53
|
+
click.echo(f"ERROR: Could not find app.py at {app_path}", err=True)
|
|
54
|
+
sys.exit(1)
|
|
55
|
+
|
|
56
|
+
cmd = [
|
|
57
|
+
sys.executable,
|
|
58
|
+
"-m",
|
|
59
|
+
"streamlit",
|
|
60
|
+
"run",
|
|
61
|
+
str(app_path),
|
|
62
|
+
"--server.port",
|
|
63
|
+
str(port),
|
|
64
|
+
"--",
|
|
65
|
+
"--db",
|
|
66
|
+
db,
|
|
67
|
+
]
|
|
68
|
+
if no_browser:
|
|
69
|
+
cmd[5:5] = ["--server.headless", "true"]
|
|
70
|
+
|
|
71
|
+
click.echo(f"Launching PyCodeKG Explorer on http://localhost:{port}")
|
|
72
|
+
click.echo(f" app : {app_path}")
|
|
73
|
+
click.echo(f" db : {db}")
|
|
74
|
+
click.echo(" Press Ctrl+C to stop.\n")
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
subprocess.run(cmd, check=True)
|
|
78
|
+
except KeyboardInterrupt:
|
|
79
|
+
click.echo("\nStopped.")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@cli.command("viz3d")
|
|
83
|
+
@click.option(
|
|
84
|
+
"--db",
|
|
85
|
+
default=".pycodekg/graph.sqlite",
|
|
86
|
+
show_default=True,
|
|
87
|
+
help="SQLite database path.",
|
|
88
|
+
)
|
|
89
|
+
@click.option(
|
|
90
|
+
"--layout",
|
|
91
|
+
type=click.Choice(["allium", "cake"]),
|
|
92
|
+
default="allium",
|
|
93
|
+
show_default=True,
|
|
94
|
+
help=(
|
|
95
|
+
"3-D layout strategy. "
|
|
96
|
+
"'allium' renders each module as a Giant Allium plant; "
|
|
97
|
+
"'cake' stratifies nodes by kind across Z layers."
|
|
98
|
+
),
|
|
99
|
+
)
|
|
100
|
+
@click.option(
|
|
101
|
+
"--width",
|
|
102
|
+
type=int,
|
|
103
|
+
default=1400,
|
|
104
|
+
show_default=True,
|
|
105
|
+
help="Window width in pixels.",
|
|
106
|
+
)
|
|
107
|
+
@click.option(
|
|
108
|
+
"--height",
|
|
109
|
+
type=int,
|
|
110
|
+
default=900,
|
|
111
|
+
show_default=True,
|
|
112
|
+
help="Window height in pixels.",
|
|
113
|
+
)
|
|
114
|
+
def viz3d(db: str, layout: str, width: int, height: int) -> None:
|
|
115
|
+
"""Launch the PyCodeKG 3-D PyVista knowledge-graph visualizer."""
|
|
116
|
+
db_path = Path(db)
|
|
117
|
+
if not db_path.exists():
|
|
118
|
+
raise click.UsageError(
|
|
119
|
+
f"Database not found: {db_path}\n"
|
|
120
|
+
"Run 'pycodekg build-sqlite' first to index your repository."
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
from pycode_kg.viz3d import launch # pylint: disable=import-outside-toplevel
|
|
124
|
+
|
|
125
|
+
launch(
|
|
126
|
+
db_path=str(db_path),
|
|
127
|
+
layout_name=layout,
|
|
128
|
+
width=width,
|
|
129
|
+
height=height,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@cli.command("viz-timeline")
|
|
134
|
+
@click.option(
|
|
135
|
+
"--snapshots",
|
|
136
|
+
default=".pycodekg/snapshots",
|
|
137
|
+
show_default=True,
|
|
138
|
+
help="Snapshots directory path.",
|
|
139
|
+
)
|
|
140
|
+
@click.option(
|
|
141
|
+
"--type",
|
|
142
|
+
type=click.Choice(["2d", "3d"]),
|
|
143
|
+
default="2d",
|
|
144
|
+
show_default=True,
|
|
145
|
+
help="Visualization type: 2d (subplots) or 3d (scatter plot).",
|
|
146
|
+
)
|
|
147
|
+
def viz_timeline(snapshots: str, type: str) -> None:
|
|
148
|
+
"""Display temporal metrics evolution across commits."""
|
|
149
|
+
snapshots_path = Path(snapshots)
|
|
150
|
+
if not snapshots_path.exists():
|
|
151
|
+
raise click.UsageError(
|
|
152
|
+
f"Snapshots directory not found: {snapshots_path}\n"
|
|
153
|
+
"Run 'pycodekg snapshot save' first to capture snapshots."
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
if importlib.util.find_spec("plotly") is None:
|
|
157
|
+
raise click.UsageError(
|
|
158
|
+
f"plotly is not installed. Install viz dependencies with:\n {_VIZ_EXTRA}"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
from pycode_kg.viz3d_timeline import ( # pylint: disable=import-outside-toplevel
|
|
162
|
+
create_3d_timeline_figure,
|
|
163
|
+
create_timeline_figure,
|
|
164
|
+
display_timeline_summary,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
# Display text summary
|
|
168
|
+
summary = display_timeline_summary(snapshots_path)
|
|
169
|
+
click.echo(summary)
|
|
170
|
+
|
|
171
|
+
# Create and display visualization
|
|
172
|
+
if type == "3d":
|
|
173
|
+
fig = create_3d_timeline_figure(snapshots_path)
|
|
174
|
+
else:
|
|
175
|
+
fig = create_timeline_figure(snapshots_path)
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
fig.show()
|
|
179
|
+
except (OSError, AttributeError, ImportError) as e:
|
|
180
|
+
click.echo(f"Could not display visualization: {e}", err=True)
|
pycode_kg/cli/main.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Root Click group for the PyCodeKG CLI.
|
|
3
|
+
|
|
4
|
+
Usage::
|
|
5
|
+
|
|
6
|
+
python -m pycode_kg --help
|
|
7
|
+
python -m pycode_kg --version
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import importlib.metadata
|
|
11
|
+
|
|
12
|
+
import click
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.group()
|
|
16
|
+
@click.version_option(version=importlib.metadata.version("pycode-kg"))
|
|
17
|
+
def cli():
|
|
18
|
+
"""PyCodeKG — knowledge graph tools for Python codebases."""
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
if __name__ == "__main__":
|
|
23
|
+
cli()
|
pycode_kg/cli/options.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared Click option decorators for PyCodeKG CLI commands.
|
|
3
|
+
|
|
4
|
+
Each symbol is a reusable decorator factory that can be stacked onto any
|
|
5
|
+
Click command to provide consistent option names, defaults, and help text::
|
|
6
|
+
|
|
7
|
+
@cli.command()
|
|
8
|
+
@sqlite_option
|
|
9
|
+
@lancedb_option
|
|
10
|
+
def my_command(sqlite, lancedb):
|
|
11
|
+
...
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import click
|
|
15
|
+
|
|
16
|
+
from pycode_kg.pycodekg import DEFAULT_MODEL
|
|
17
|
+
|
|
18
|
+
sqlite_option = click.option(
|
|
19
|
+
"--sqlite",
|
|
20
|
+
default=".pycodekg/graph.sqlite",
|
|
21
|
+
type=click.Path(),
|
|
22
|
+
show_default=True,
|
|
23
|
+
help="SQLite database path.",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
lancedb_option = click.option(
|
|
27
|
+
"--lancedb",
|
|
28
|
+
default=".pycodekg/lancedb",
|
|
29
|
+
type=click.Path(),
|
|
30
|
+
show_default=True,
|
|
31
|
+
help="LanceDB directory path.",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
model_option = click.option(
|
|
35
|
+
"--model",
|
|
36
|
+
default=DEFAULT_MODEL,
|
|
37
|
+
show_default=True,
|
|
38
|
+
help="Sentence-transformer model name.",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
repo_option = click.option(
|
|
42
|
+
"--repo",
|
|
43
|
+
default=".",
|
|
44
|
+
type=click.Path(exists=True, file_okay=False),
|
|
45
|
+
show_default=True,
|
|
46
|
+
help="Repository root directory.",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
include_option = click.option(
|
|
50
|
+
"--include-dir",
|
|
51
|
+
multiple=True,
|
|
52
|
+
help="Top-level directory names to include in indexing. Can be used multiple times. "
|
|
53
|
+
"When none are specified, all directories are indexed. "
|
|
54
|
+
"Also reads [tool.pycodekg].include from pyproject.toml.",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
exclude_option = click.option(
|
|
58
|
+
"--exclude-dir",
|
|
59
|
+
multiple=True,
|
|
60
|
+
help="Directory names to exclude at every depth during indexing. Can be used multiple times. "
|
|
61
|
+
"E.g. --exclude-dir tests --exclude-dir benchmarks. "
|
|
62
|
+
"Also reads [tool.pycodekg].exclude from pyproject.toml.",
|
|
63
|
+
)
|