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,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}")
@@ -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()
@@ -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
+ )