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/snapshots.py
ADDED
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
"""
|
|
2
|
+
snapshots.py — Temporal Snapshots of PyCodeKG Metrics
|
|
3
|
+
|
|
4
|
+
Thin compatibility layer over ``kg_snapshot``. The shared module provides
|
|
5
|
+
the canonical ``Snapshot``, ``SnapshotManifest``, and ``SnapshotManager``
|
|
6
|
+
implementations. This module:
|
|
7
|
+
|
|
8
|
+
- Re-exports ``Snapshot`` and ``SnapshotManifest`` from ``kg_snapshot``
|
|
9
|
+
for backwards compatibility.
|
|
10
|
+
- Keeps domain-specific ``SnapshotMetrics`` and ``SnapshotDelta`` dataclasses
|
|
11
|
+
(used by the CLI and tests) as local types.
|
|
12
|
+
- Provides ``metrics_to_dict`` / ``metrics_from_dict`` helpers to convert
|
|
13
|
+
between the dataclass representation and the dict-based shared model.
|
|
14
|
+
- Subclasses ``SnapshotManager`` to set ``package_name="pycode-kg"``, override
|
|
15
|
+
``_compute_delta`` / ``_compute_delta_from_metrics`` with the
|
|
16
|
+
pycode-kg-specific delta fields, and expose ``_collect_module_node_counts``.
|
|
17
|
+
|
|
18
|
+
Existing ``from pycode_kg.snapshots import ...`` call-sites continue to work
|
|
19
|
+
unchanged.
|
|
20
|
+
|
|
21
|
+
Usage
|
|
22
|
+
-----
|
|
23
|
+
>>> from pycode_kg.snapshots import SnapshotManager
|
|
24
|
+
>>> mgr = SnapshotManager(".pycodekg/snapshots")
|
|
25
|
+
>>> snapshot = mgr.capture("v0.5.1", "develop", graph_stats_dict)
|
|
26
|
+
>>> mgr.save_snapshot(snapshot)
|
|
27
|
+
>>> manifest = mgr.load_manifest()
|
|
28
|
+
>>> prev = mgr.get_previous(tree_hash)
|
|
29
|
+
|
|
30
|
+
Author: Eric G. Suchanek, PhD
|
|
31
|
+
Last Revision: 2026-04-07 09:13:36
|
|
32
|
+
|
|
33
|
+
License: Elastic 2.0
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import json
|
|
39
|
+
import sqlite3
|
|
40
|
+
from dataclasses import dataclass, field
|
|
41
|
+
from datetime import UTC, datetime
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
from typing import Any
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# Re-export shared data models
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
from kg_snapshot.snapshots import PruneResult as PruneResult # noqa: F401 — re-export
|
|
49
|
+
from kg_snapshot.snapshots import Snapshot as _BaseSnapshot
|
|
50
|
+
from kg_snapshot.snapshots import SnapshotManager as _BaseSnapshotManager
|
|
51
|
+
from kg_snapshot.snapshots import ( # noqa: F401 — re-export
|
|
52
|
+
SnapshotManifest as SnapshotManifest,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
# Domain-specific dataclasses (used by cmd_snapshot.py and tests)
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class SnapshotMetrics:
|
|
62
|
+
"""Core metrics captured in a pycode-kg snapshot."""
|
|
63
|
+
|
|
64
|
+
total_nodes: int
|
|
65
|
+
total_edges: int
|
|
66
|
+
meaningful_nodes: int
|
|
67
|
+
docstring_coverage: float # 0.0 to 1.0
|
|
68
|
+
node_counts: dict[str, int]
|
|
69
|
+
edge_counts: dict[str, int]
|
|
70
|
+
critical_issues: int # number of critical issues found
|
|
71
|
+
complexity_median: float # median fan-in across functions
|
|
72
|
+
module_node_counts: dict[str, int] = field(default_factory=dict) # nodes per module path
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class SnapshotDelta:
|
|
77
|
+
"""Deltas comparing this snapshot to a baseline or previous snapshot."""
|
|
78
|
+
|
|
79
|
+
nodes: int = 0
|
|
80
|
+
edges: int = 0
|
|
81
|
+
coverage_delta: float = 0.0
|
|
82
|
+
critical_issues_delta: int = 0
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
# Conversion helpers
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def metrics_to_dict(metrics: SnapshotMetrics) -> dict[str, Any]:
|
|
91
|
+
"""Convert a ``SnapshotMetrics`` dataclass to a plain dict for storage."""
|
|
92
|
+
return {
|
|
93
|
+
"total_nodes": metrics.total_nodes,
|
|
94
|
+
"total_edges": metrics.total_edges,
|
|
95
|
+
"meaningful_nodes": metrics.meaningful_nodes,
|
|
96
|
+
"docstring_coverage": metrics.docstring_coverage,
|
|
97
|
+
"node_counts": metrics.node_counts,
|
|
98
|
+
"edge_counts": metrics.edge_counts,
|
|
99
|
+
"critical_issues": metrics.critical_issues,
|
|
100
|
+
"complexity_median": metrics.complexity_median,
|
|
101
|
+
"module_node_counts": metrics.module_node_counts,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def metrics_from_dict(data: dict[str, Any]) -> SnapshotMetrics:
|
|
106
|
+
"""Reconstruct a ``SnapshotMetrics`` from a plain dict (e.g. loaded from JSON)."""
|
|
107
|
+
return SnapshotMetrics(
|
|
108
|
+
total_nodes=data.get("total_nodes", 0),
|
|
109
|
+
total_edges=data.get("total_edges", 0),
|
|
110
|
+
meaningful_nodes=data.get("meaningful_nodes", 0),
|
|
111
|
+
docstring_coverage=data.get("docstring_coverage", 0.0),
|
|
112
|
+
node_counts=data.get("node_counts", {}),
|
|
113
|
+
edge_counts=data.get("edge_counts", {}),
|
|
114
|
+
critical_issues=data.get("critical_issues", 0),
|
|
115
|
+
complexity_median=data.get("complexity_median", 0.0),
|
|
116
|
+
module_node_counts=data.get("module_node_counts", {}),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def delta_to_dict(delta: SnapshotDelta) -> dict[str, Any]:
|
|
121
|
+
"""Convert a ``SnapshotDelta`` dataclass to a plain dict for storage."""
|
|
122
|
+
return {
|
|
123
|
+
"nodes": delta.nodes,
|
|
124
|
+
"edges": delta.edges,
|
|
125
|
+
"coverage_delta": delta.coverage_delta,
|
|
126
|
+
"critical_issues_delta": delta.critical_issues_delta,
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def delta_from_dict(data: dict[str, Any] | None) -> SnapshotDelta | None:
|
|
131
|
+
"""Reconstruct a ``SnapshotDelta`` from a plain dict, or return ``None``."""
|
|
132
|
+
if data is None:
|
|
133
|
+
return None
|
|
134
|
+
return SnapshotDelta(
|
|
135
|
+
nodes=data.get("nodes", 0),
|
|
136
|
+
edges=data.get("edges", 0),
|
|
137
|
+
coverage_delta=data.get("coverage_delta", 0.0),
|
|
138
|
+
critical_issues_delta=data.get("critical_issues_delta", 0),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
# Backwards-compat Snapshot wrapper
|
|
144
|
+
#
|
|
145
|
+
# The shared kg_rag.snapshots.Snapshot stores metrics as a plain dict and
|
|
146
|
+
# vs_previous/vs_baseline as plain dicts. Legacy pycode-kg callers expect
|
|
147
|
+
# attribute access on these fields (snapshot.metrics.total_nodes, delta.nodes).
|
|
148
|
+
#
|
|
149
|
+
# We subclass Snapshot to override attribute access so that .metrics returns a
|
|
150
|
+
# SnapshotMetrics view and .vs_previous / .vs_baseline return SnapshotDelta
|
|
151
|
+
# views, while keeping the underlying dict storage for JSON serialization.
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class Snapshot(_BaseSnapshot):
|
|
156
|
+
"""pycode-kg Snapshot with attribute-accessible metrics and delta fields.
|
|
157
|
+
|
|
158
|
+
Subclasses the shared ``kg_rag.snapshots.Snapshot`` so that:
|
|
159
|
+
|
|
160
|
+
- ``snapshot.metrics`` returns a :class:`SnapshotMetrics` dataclass built
|
|
161
|
+
lazily from the underlying metrics dict.
|
|
162
|
+
- ``snapshot.vs_previous`` / ``snapshot.vs_baseline`` return
|
|
163
|
+
:class:`SnapshotDelta` dataclasses (or ``None``) built lazily from the
|
|
164
|
+
underlying delta dicts.
|
|
165
|
+
- All serialization goes through the shared ``to_dict`` / ``from_dict``
|
|
166
|
+
which store plain dicts, ensuring on-disk format compatibility.
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
# Class-level type declarations for the raw storage attributes
|
|
170
|
+
_metrics_raw: dict[str, Any]
|
|
171
|
+
_vs_previous_raw: dict[str, Any] | None
|
|
172
|
+
_vs_baseline_raw: dict[str, Any] | None
|
|
173
|
+
|
|
174
|
+
# ------------------------------------------------------------------
|
|
175
|
+
# Attribute-access shims
|
|
176
|
+
# ------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
@property # type: ignore[override]
|
|
179
|
+
def metrics(self) -> SnapshotMetrics: # type: ignore[override]
|
|
180
|
+
return metrics_from_dict(self._metrics_raw)
|
|
181
|
+
|
|
182
|
+
@metrics.setter
|
|
183
|
+
def metrics(self, value: SnapshotMetrics | dict[str, Any]) -> None:
|
|
184
|
+
self._metrics_raw = metrics_to_dict(value) if isinstance(value, SnapshotMetrics) else value
|
|
185
|
+
|
|
186
|
+
@property # type: ignore[override]
|
|
187
|
+
def vs_previous(self) -> SnapshotDelta | None: # type: ignore[override]
|
|
188
|
+
return delta_from_dict(self._vs_previous_raw)
|
|
189
|
+
|
|
190
|
+
@vs_previous.setter
|
|
191
|
+
def vs_previous(self, value: SnapshotDelta | dict[str, Any] | None) -> None:
|
|
192
|
+
self._vs_previous_raw = delta_to_dict(value) if isinstance(value, SnapshotDelta) else value
|
|
193
|
+
|
|
194
|
+
@property # type: ignore[override]
|
|
195
|
+
def vs_baseline(self) -> SnapshotDelta | None: # type: ignore[override]
|
|
196
|
+
return delta_from_dict(self._vs_baseline_raw)
|
|
197
|
+
|
|
198
|
+
@vs_baseline.setter
|
|
199
|
+
def vs_baseline(self, value: SnapshotDelta | dict[str, Any] | None) -> None:
|
|
200
|
+
self._vs_baseline_raw = delta_to_dict(value) if isinstance(value, SnapshotDelta) else value
|
|
201
|
+
|
|
202
|
+
# ------------------------------------------------------------------
|
|
203
|
+
# __init__: store raw dicts, pass them to super
|
|
204
|
+
# ------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
def __init__(
|
|
207
|
+
self,
|
|
208
|
+
branch: str,
|
|
209
|
+
timestamp: str,
|
|
210
|
+
metrics: SnapshotMetrics | dict[str, Any],
|
|
211
|
+
version: str = "",
|
|
212
|
+
hotspots: list[dict[str, Any]] | None = None,
|
|
213
|
+
issues: list[str] | None = None,
|
|
214
|
+
vs_previous: SnapshotDelta | dict[str, Any] | None = None,
|
|
215
|
+
vs_baseline: SnapshotDelta | dict[str, Any] | None = None,
|
|
216
|
+
tree_hash: str = "",
|
|
217
|
+
) -> None:
|
|
218
|
+
self._metrics_raw = (
|
|
219
|
+
metrics_to_dict(metrics) if isinstance(metrics, SnapshotMetrics) else (metrics or {})
|
|
220
|
+
)
|
|
221
|
+
self._vs_previous_raw = (
|
|
222
|
+
delta_to_dict(vs_previous) if isinstance(vs_previous, SnapshotDelta) else vs_previous
|
|
223
|
+
)
|
|
224
|
+
self._vs_baseline_raw = (
|
|
225
|
+
delta_to_dict(vs_baseline) if isinstance(vs_baseline, SnapshotDelta) else vs_baseline
|
|
226
|
+
)
|
|
227
|
+
super().__init__(
|
|
228
|
+
branch=branch,
|
|
229
|
+
timestamp=timestamp,
|
|
230
|
+
metrics=self._metrics_raw,
|
|
231
|
+
version=version,
|
|
232
|
+
hotspots=hotspots or [],
|
|
233
|
+
issues=issues or [],
|
|
234
|
+
vs_previous=self._vs_previous_raw,
|
|
235
|
+
vs_baseline=self._vs_baseline_raw,
|
|
236
|
+
tree_hash=tree_hash,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
# ------------------------------------------------------------------
|
|
240
|
+
# to_dict / from_dict — keep shared dict format on disk
|
|
241
|
+
# ------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
def to_dict(self) -> dict[str, Any]:
|
|
244
|
+
"""Serialize to JSON-compatible dict (plain dicts for all nested fields)."""
|
|
245
|
+
return {
|
|
246
|
+
"key": self.tree_hash,
|
|
247
|
+
"branch": self.branch,
|
|
248
|
+
"timestamp": self.timestamp,
|
|
249
|
+
"version": self.version,
|
|
250
|
+
"metrics": self._metrics_raw,
|
|
251
|
+
"hotspots": self.hotspots,
|
|
252
|
+
"issues": self.issues,
|
|
253
|
+
"vs_previous": self._vs_previous_raw,
|
|
254
|
+
"vs_baseline": self._vs_baseline_raw,
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
@staticmethod
|
|
258
|
+
def from_dict(data: dict[str, Any]) -> Snapshot:
|
|
259
|
+
"""Reconstruct from a dict loaded from JSON."""
|
|
260
|
+
raw = dict(data)
|
|
261
|
+
|
|
262
|
+
metrics_data = raw.pop("metrics", {})
|
|
263
|
+
vs_prev_data = raw.pop("vs_previous", None)
|
|
264
|
+
vs_base_data = raw.pop("vs_baseline", None)
|
|
265
|
+
|
|
266
|
+
# Normalise legacy 'tree_hash' field → 'key'
|
|
267
|
+
if "key" not in raw and "tree_hash" in raw:
|
|
268
|
+
raw["key"] = raw.pop("tree_hash")
|
|
269
|
+
else:
|
|
270
|
+
raw.pop("tree_hash", None)
|
|
271
|
+
|
|
272
|
+
key = raw.pop("key", "")
|
|
273
|
+
raw.pop("commit", None) # drop legacy field
|
|
274
|
+
raw.setdefault("version", "")
|
|
275
|
+
|
|
276
|
+
return Snapshot(
|
|
277
|
+
tree_hash=key,
|
|
278
|
+
metrics=metrics_data,
|
|
279
|
+
vs_previous=vs_prev_data,
|
|
280
|
+
vs_baseline=vs_base_data,
|
|
281
|
+
branch=raw.pop("branch", ""),
|
|
282
|
+
timestamp=raw.pop("timestamp", ""),
|
|
283
|
+
version=raw.pop("version", ""),
|
|
284
|
+
hotspots=raw.pop("hotspots", []),
|
|
285
|
+
issues=raw.pop("issues", []),
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# ---------------------------------------------------------------------------
|
|
290
|
+
# pycode-kg SnapshotManager subclass
|
|
291
|
+
# ---------------------------------------------------------------------------
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
class SnapshotManager(_BaseSnapshotManager):
|
|
295
|
+
"""pycode-kg specific snapshot manager.
|
|
296
|
+
|
|
297
|
+
Extends the shared :class:`kg_rag.snapshots.SnapshotManager` with:
|
|
298
|
+
|
|
299
|
+
- ``package_name="pycode-kg"`` for automatic version detection.
|
|
300
|
+
- Domain-specific delta fields: ``coverage_delta`` and
|
|
301
|
+
``critical_issues_delta`` in addition to the base ``nodes`` / ``edges``.
|
|
302
|
+
- ``_collect_module_node_counts()`` — SQLite query for per-module node
|
|
303
|
+
counts, stored in snapshot metrics under ``"module_node_counts"``.
|
|
304
|
+
|
|
305
|
+
The ``capture()`` signature is extended with named keyword arguments
|
|
306
|
+
(``coverage``, ``critical_issues``, ``complexity_median``) so that
|
|
307
|
+
existing call-sites in the CLI do not need to be changed.
|
|
308
|
+
"""
|
|
309
|
+
|
|
310
|
+
def __init__(
|
|
311
|
+
self,
|
|
312
|
+
snapshots_dir: Path | str,
|
|
313
|
+
*,
|
|
314
|
+
db_path: Path | str | None = None,
|
|
315
|
+
package_name: str = "pycode-kg",
|
|
316
|
+
) -> None:
|
|
317
|
+
super().__init__(snapshots_dir, package_name=package_name, db_path=db_path)
|
|
318
|
+
|
|
319
|
+
# ------------------------------------------------------------------
|
|
320
|
+
# Helper: wrap a base Snapshot in the local subclass
|
|
321
|
+
# ------------------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
@staticmethod
|
|
324
|
+
def _wrap_snapshot(base: _BaseSnapshot) -> Snapshot:
|
|
325
|
+
return Snapshot(
|
|
326
|
+
branch=base.branch,
|
|
327
|
+
timestamp=base.timestamp,
|
|
328
|
+
version=base.version,
|
|
329
|
+
metrics=base.metrics,
|
|
330
|
+
hotspots=base.hotspots,
|
|
331
|
+
issues=base.issues,
|
|
332
|
+
vs_previous=base.vs_previous,
|
|
333
|
+
vs_baseline=base.vs_baseline,
|
|
334
|
+
tree_hash=base.tree_hash,
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
# ------------------------------------------------------------------
|
|
338
|
+
# capture — extended signature for backwards compat
|
|
339
|
+
# ------------------------------------------------------------------
|
|
340
|
+
|
|
341
|
+
def capture( # type: ignore[override]
|
|
342
|
+
self,
|
|
343
|
+
version: str | None = None,
|
|
344
|
+
branch: str | None = None,
|
|
345
|
+
graph_stats_dict: dict[str, Any] | None = None,
|
|
346
|
+
coverage: float = 0.0,
|
|
347
|
+
critical_issues: int = 0,
|
|
348
|
+
complexity_median: float = 0.0,
|
|
349
|
+
hotspots: list[dict[str, Any]] | None = None,
|
|
350
|
+
issues: list[str] | None = None,
|
|
351
|
+
tree_hash: str = "",
|
|
352
|
+
) -> Snapshot:
|
|
353
|
+
"""Capture a pycode-kg snapshot.
|
|
354
|
+
|
|
355
|
+
Merges ``graph_stats_dict`` with pycode-kg-specific metric fields
|
|
356
|
+
(``docstring_coverage``, ``critical_issues``, ``complexity_median``,
|
|
357
|
+
``module_node_counts``) into a single metrics dict, then delegates to
|
|
358
|
+
the shared manager and returns a pycode-kg :class:`Snapshot` instance.
|
|
359
|
+
|
|
360
|
+
:param version: Version string; auto-detected from package if None.
|
|
361
|
+
:param branch: Git branch; auto-detected if None.
|
|
362
|
+
:param graph_stats_dict: Output from the KG ``stats()`` method.
|
|
363
|
+
:param coverage: Docstring coverage fraction (0.0–1.0).
|
|
364
|
+
:param critical_issues: Number of critical issues detected.
|
|
365
|
+
:param complexity_median: Median fan-in across functions.
|
|
366
|
+
:param hotspots: Top hotspot entries.
|
|
367
|
+
:param issues: Issue description strings.
|
|
368
|
+
:param tree_hash: Git tree hash; auto-detected if not provided.
|
|
369
|
+
:return: New :class:`Snapshot` instance (not yet persisted).
|
|
370
|
+
"""
|
|
371
|
+
# Collect per-module counts from SQLite (returns {} if db unavailable)
|
|
372
|
+
module_node_counts = self._collect_module_node_counts()
|
|
373
|
+
|
|
374
|
+
# Call the base implementation passing extra fields via **extra_metrics
|
|
375
|
+
base_snap = super().capture(
|
|
376
|
+
version=version,
|
|
377
|
+
branch=branch,
|
|
378
|
+
graph_stats_dict=graph_stats_dict,
|
|
379
|
+
tree_hash=tree_hash,
|
|
380
|
+
hotspots=hotspots,
|
|
381
|
+
issues=issues,
|
|
382
|
+
# Extra domain-specific metric fields:
|
|
383
|
+
docstring_coverage=coverage,
|
|
384
|
+
critical_issues=critical_issues,
|
|
385
|
+
complexity_median=complexity_median,
|
|
386
|
+
module_node_counts=module_node_counts,
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
return self._wrap_snapshot(base_snap)
|
|
390
|
+
|
|
391
|
+
# ------------------------------------------------------------------
|
|
392
|
+
# Override load_snapshot to return pycode-kg Snapshot instances
|
|
393
|
+
# ------------------------------------------------------------------
|
|
394
|
+
|
|
395
|
+
def load_snapshot(self, key: str) -> Snapshot | None:
|
|
396
|
+
"""Load a snapshot by key, returning a pycode-kg Snapshot instance."""
|
|
397
|
+
base_snap = super().load_snapshot(key)
|
|
398
|
+
return self._wrap_snapshot(base_snap) if base_snap is not None else None
|
|
399
|
+
|
|
400
|
+
# ------------------------------------------------------------------
|
|
401
|
+
# save_snapshot — convert dataclass fields to plain dicts for manifest
|
|
402
|
+
# ------------------------------------------------------------------
|
|
403
|
+
|
|
404
|
+
def save_snapshot(self, snapshot: Snapshot, *, force: bool = False) -> Path | None: # type: ignore[override]
|
|
405
|
+
"""Persist snapshot, normalising metrics to a plain dict for the manifest.
|
|
406
|
+
|
|
407
|
+
The base :meth:`~kg_snapshot.snapshots.SnapshotManager.save_snapshot`
|
|
408
|
+
writes ``snapshot.metrics`` directly into the manifest dict. For
|
|
409
|
+
pycode-kg :class:`Snapshot` instances that property returns a
|
|
410
|
+
:class:`SnapshotMetrics` dataclass — not JSON-serializable. This
|
|
411
|
+
override uses the raw dict for manifest entries while preserving the
|
|
412
|
+
base's dedup behaviour.
|
|
413
|
+
|
|
414
|
+
:param snapshot: Snapshot to persist.
|
|
415
|
+
:param force: If ``True``, always write a new history entry.
|
|
416
|
+
:return: Path to the saved JSON file, or ``None`` if unchanged (dedup).
|
|
417
|
+
:raises ValueError: If ``total_nodes`` is 0.
|
|
418
|
+
"""
|
|
419
|
+
metrics_dict = snapshot._metrics_raw
|
|
420
|
+
if metrics_dict.get("total_nodes", 0) == 0:
|
|
421
|
+
raise ValueError(
|
|
422
|
+
"Refusing to save degenerate snapshot with 0 nodes. "
|
|
423
|
+
"Build the KG before capturing a snapshot."
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
manifest = self.load_manifest()
|
|
427
|
+
|
|
428
|
+
# Dedup: if version + metrics unchanged, refresh the latest entry in-place.
|
|
429
|
+
if not force and manifest.snapshots:
|
|
430
|
+
latest = max(manifest.snapshots, key=lambda x: x.get("timestamp", ""))
|
|
431
|
+
if snapshot.version == latest.get("version", "") and not self._metrics_changed(
|
|
432
|
+
metrics_dict, latest.get("metrics", {})
|
|
433
|
+
):
|
|
434
|
+
old_key = latest["key"]
|
|
435
|
+
old_file = self.snapshots_dir / latest.get("file", f"{old_key}.json")
|
|
436
|
+
snapshot_file = self.snapshots_dir / f"{snapshot.key}.json"
|
|
437
|
+
snapshot_file.write_text(
|
|
438
|
+
json.dumps(snapshot.to_dict(), indent=2) + "\n",
|
|
439
|
+
encoding="utf-8",
|
|
440
|
+
)
|
|
441
|
+
if old_key != snapshot.key and old_file.exists():
|
|
442
|
+
old_file.unlink()
|
|
443
|
+
latest.update(
|
|
444
|
+
key=snapshot.key,
|
|
445
|
+
branch=snapshot.branch,
|
|
446
|
+
timestamp=snapshot.timestamp,
|
|
447
|
+
file=snapshot_file.name,
|
|
448
|
+
)
|
|
449
|
+
manifest.last_update = datetime.now(UTC).isoformat()
|
|
450
|
+
self._save_manifest(manifest)
|
|
451
|
+
return snapshot_file
|
|
452
|
+
|
|
453
|
+
# Normal path: new or changed snapshot.
|
|
454
|
+
snapshot_file = self.snapshots_dir / f"{snapshot.key}.json"
|
|
455
|
+
snapshot_file.write_text(json.dumps(snapshot.to_dict(), indent=2) + "\n", encoding="utf-8")
|
|
456
|
+
|
|
457
|
+
existing_idx = next(
|
|
458
|
+
(i for i, s in enumerate(manifest.snapshots) if s.get("key") == snapshot.key),
|
|
459
|
+
None,
|
|
460
|
+
)
|
|
461
|
+
vs_prev = snapshot._vs_previous_raw
|
|
462
|
+
vs_base = snapshot._vs_baseline_raw
|
|
463
|
+
manifest_entry: dict[str, Any] = {
|
|
464
|
+
"key": snapshot.key,
|
|
465
|
+
"branch": snapshot.branch,
|
|
466
|
+
"timestamp": snapshot.timestamp,
|
|
467
|
+
"version": snapshot.version,
|
|
468
|
+
"file": snapshot_file.name,
|
|
469
|
+
"metrics": metrics_dict,
|
|
470
|
+
"deltas": {"vs_previous": vs_prev, "vs_baseline": vs_base},
|
|
471
|
+
}
|
|
472
|
+
if existing_idx is not None:
|
|
473
|
+
manifest.snapshots[existing_idx] = manifest_entry
|
|
474
|
+
else:
|
|
475
|
+
manifest.snapshots.append(manifest_entry)
|
|
476
|
+
manifest.last_update = datetime.now(UTC).isoformat()
|
|
477
|
+
self._save_manifest(manifest)
|
|
478
|
+
return snapshot_file
|
|
479
|
+
|
|
480
|
+
# ------------------------------------------------------------------
|
|
481
|
+
# Delta computation — adds coverage_delta and critical_issues_delta
|
|
482
|
+
# ------------------------------------------------------------------
|
|
483
|
+
|
|
484
|
+
@staticmethod
|
|
485
|
+
def _as_metrics_dict(m: SnapshotMetrics | dict[str, Any]) -> dict[str, Any]:
|
|
486
|
+
"""Return a plain metrics dict whether ``m`` is a dataclass or already a dict."""
|
|
487
|
+
return metrics_to_dict(m) if isinstance(m, SnapshotMetrics) else m # type: ignore[arg-type]
|
|
488
|
+
|
|
489
|
+
def _compute_delta(self, snap_new: Snapshot, snap_old: Snapshot) -> dict[str, Any]:
|
|
490
|
+
"""Compute pycode-kg metrics delta including coverage and issue count."""
|
|
491
|
+
return self._compute_delta_from_metrics(
|
|
492
|
+
self._as_metrics_dict(snap_new.metrics),
|
|
493
|
+
self._as_metrics_dict(snap_old.metrics),
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
def _compute_delta_from_metrics(
|
|
497
|
+
self, new_m: dict[str, Any], old_m: dict[str, Any]
|
|
498
|
+
) -> dict[str, Any]:
|
|
499
|
+
"""Compute delta dict from two raw metrics dicts.
|
|
500
|
+
|
|
501
|
+
Includes the base ``nodes`` / ``edges`` plus the pycode-kg-specific
|
|
502
|
+
``coverage_delta`` and ``critical_issues_delta`` fields.
|
|
503
|
+
"""
|
|
504
|
+
return {
|
|
505
|
+
"nodes": new_m.get("total_nodes", 0) - old_m.get("total_nodes", 0),
|
|
506
|
+
"edges": new_m.get("total_edges", 0) - old_m.get("total_edges", 0),
|
|
507
|
+
"coverage_delta": new_m.get("docstring_coverage", 0.0)
|
|
508
|
+
- old_m.get("docstring_coverage", 0.0),
|
|
509
|
+
"critical_issues_delta": new_m.get("critical_issues", 0)
|
|
510
|
+
- old_m.get("critical_issues", 0),
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
# ------------------------------------------------------------------
|
|
514
|
+
# diff_snapshots — includes module_node_counts_delta and issues_delta
|
|
515
|
+
# ------------------------------------------------------------------
|
|
516
|
+
|
|
517
|
+
def diff_snapshots(self, key_a: str, key_b: str) -> dict[str, Any]:
|
|
518
|
+
"""Compare two snapshots side-by-side.
|
|
519
|
+
|
|
520
|
+
Extends the shared diff with ``module_node_counts_delta`` and
|
|
521
|
+
``issues_delta`` (introduced / resolved issue strings).
|
|
522
|
+
|
|
523
|
+
:param key_a: First snapshot key (tree hash).
|
|
524
|
+
:param key_b: Second snapshot key (tree hash).
|
|
525
|
+
:return: Dict with metrics from both, computed deltas.
|
|
526
|
+
"""
|
|
527
|
+
snap_a = self.load_snapshot(key_a)
|
|
528
|
+
snap_b = self.load_snapshot(key_b)
|
|
529
|
+
|
|
530
|
+
if not snap_a or not snap_b:
|
|
531
|
+
return {"error": "One or both snapshots not found"}
|
|
532
|
+
|
|
533
|
+
m_a = self._as_metrics_dict(snap_a.metrics)
|
|
534
|
+
m_b = self._as_metrics_dict(snap_b.metrics)
|
|
535
|
+
|
|
536
|
+
all_node_kinds = set(m_a.get("node_counts", {})) | set(m_b.get("node_counts", {}))
|
|
537
|
+
all_edge_rels = set(m_a.get("edge_counts", {})) | set(m_b.get("edge_counts", {}))
|
|
538
|
+
|
|
539
|
+
node_counts_delta = {
|
|
540
|
+
k: m_b.get("node_counts", {}).get(k, 0) - m_a.get("node_counts", {}).get(k, 0)
|
|
541
|
+
for k in all_node_kinds
|
|
542
|
+
}
|
|
543
|
+
edge_counts_delta = {
|
|
544
|
+
k: m_b.get("edge_counts", {}).get(k, 0) - m_a.get("edge_counts", {}).get(k, 0)
|
|
545
|
+
for k in all_edge_rels
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
all_modules = set(m_a.get("module_node_counts", {})) | set(
|
|
549
|
+
m_b.get("module_node_counts", {})
|
|
550
|
+
)
|
|
551
|
+
module_node_counts_delta = {
|
|
552
|
+
mod: m_b.get("module_node_counts", {}).get(mod, 0)
|
|
553
|
+
- m_a.get("module_node_counts", {}).get(mod, 0)
|
|
554
|
+
for mod in all_modules
|
|
555
|
+
if m_b.get("module_node_counts", {}).get(mod, 0)
|
|
556
|
+
!= m_a.get("module_node_counts", {}).get(mod, 0)
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
issues_a = set(snap_a.issues)
|
|
560
|
+
issues_b = set(snap_b.issues)
|
|
561
|
+
|
|
562
|
+
return {
|
|
563
|
+
"a": {"key": snap_a.key, "metrics": m_a, "issues": snap_a.issues},
|
|
564
|
+
"b": {"key": snap_b.key, "metrics": m_b, "issues": snap_b.issues},
|
|
565
|
+
"delta": self._compute_delta_from_metrics(m_b, m_a),
|
|
566
|
+
"node_counts_delta": node_counts_delta,
|
|
567
|
+
"edge_counts_delta": edge_counts_delta,
|
|
568
|
+
"module_node_counts_delta": module_node_counts_delta,
|
|
569
|
+
"issues_delta": {
|
|
570
|
+
"introduced": list(issues_b - issues_a),
|
|
571
|
+
"resolved": list(issues_a - issues_b),
|
|
572
|
+
},
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
# ------------------------------------------------------------------
|
|
576
|
+
# SQLite per-module node counts
|
|
577
|
+
# ------------------------------------------------------------------
|
|
578
|
+
|
|
579
|
+
def _collect_module_node_counts(self) -> dict[str, int]:
|
|
580
|
+
"""Query SQLite for per-module node counts.
|
|
581
|
+
|
|
582
|
+
:return: Dict mapping ``module_path`` to node count, or ``{}`` if the
|
|
583
|
+
database is unavailable or the query fails.
|
|
584
|
+
"""
|
|
585
|
+
if not self.db_path or not self.db_path.exists():
|
|
586
|
+
return {}
|
|
587
|
+
try:
|
|
588
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
589
|
+
rows = conn.execute(
|
|
590
|
+
"SELECT module_path, COUNT(*) FROM nodes GROUP BY module_path"
|
|
591
|
+
).fetchall()
|
|
592
|
+
return {row[0]: row[1] for row in rows if row[0]}
|
|
593
|
+
except sqlite3.Error:
|
|
594
|
+
return {}
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
# ---------------------------------------------------------------------------
|
|
598
|
+
# Public re-exports (so ``from pycode_kg.snapshots import X`` keeps working)
|
|
599
|
+
# ---------------------------------------------------------------------------
|
|
600
|
+
|
|
601
|
+
__all__ = [
|
|
602
|
+
"Snapshot",
|
|
603
|
+
"SnapshotManifest",
|
|
604
|
+
"SnapshotManager",
|
|
605
|
+
"SnapshotMetrics",
|
|
606
|
+
"SnapshotDelta",
|
|
607
|
+
"PruneResult",
|
|
608
|
+
"metrics_to_dict",
|
|
609
|
+
"metrics_from_dict",
|
|
610
|
+
"delta_to_dict",
|
|
611
|
+
"delta_from_dict",
|
|
612
|
+
]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS centrality_scores (
|
|
2
|
+
node_id TEXT NOT NULL,
|
|
3
|
+
metric TEXT NOT NULL,
|
|
4
|
+
score REAL NOT NULL,
|
|
5
|
+
rank INTEGER,
|
|
6
|
+
computed_at TEXT NOT NULL,
|
|
7
|
+
params_json TEXT NOT NULL,
|
|
8
|
+
PRIMARY KEY (node_id, metric)
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
CREATE INDEX IF NOT EXISTS idx_centrality_metric_rank
|
|
12
|
+
ON centrality_scores(metric, rank);
|