pycode-kg 0.25.1__tar.gz → 0.26.0__tar.gz

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-0.25.1 → pycode_kg-0.26.0}/PKG-INFO +5 -5
  2. {pycode_kg-0.25.1 → pycode_kg-0.26.0}/README.md +4 -4
  3. {pycode_kg-0.25.1 → pycode_kg-0.26.0}/pyproject.toml +1 -1
  4. {pycode_kg-0.25.1 → pycode_kg-0.26.0}/src/pycode_kg/__init__.py +1 -1
  5. {pycode_kg-0.25.1 → pycode_kg-0.26.0}/src/pycode_kg/cli/cmd_snapshot.py +21 -14
  6. {pycode_kg-0.25.1 → pycode_kg-0.26.0}/src/pycode_kg/mcp_server.py +1 -1
  7. {pycode_kg-0.25.1 → pycode_kg-0.26.0}/src/pycode_kg/pycodekg_thorough_analysis.py +8 -1
  8. pycode_kg-0.26.0/src/pycode_kg/snapshots.py +357 -0
  9. pycode_kg-0.25.1/src/pycode_kg/snapshots.py +0 -535
  10. {pycode_kg-0.25.1 → pycode_kg-0.26.0}/LICENSE +0 -0
  11. {pycode_kg-0.25.1 → pycode_kg-0.26.0}/src/pycode_kg/.DS_Store +0 -0
  12. {pycode_kg-0.25.1 → pycode_kg-0.26.0}/src/pycode_kg/__main__.py +0 -0
  13. {pycode_kg-0.25.1 → pycode_kg-0.26.0}/src/pycode_kg/analysis/__init__.py +0 -0
  14. {pycode_kg-0.25.1 → pycode_kg-0.26.0}/src/pycode_kg/analysis/bridge.py +0 -0
  15. {pycode_kg-0.25.1 → pycode_kg-0.26.0}/src/pycode_kg/analysis/centrality.py +0 -0
  16. {pycode_kg-0.25.1 → pycode_kg-0.26.0}/src/pycode_kg/analysis/framework_detector.py +0 -0
  17. {pycode_kg-0.25.1 → pycode_kg-0.26.0}/src/pycode_kg/app.py +0 -0
  18. {pycode_kg-0.25.1 → pycode_kg-0.26.0}/src/pycode_kg/architecture.py +0 -0
  19. {pycode_kg-0.25.1 → pycode_kg-0.26.0}/src/pycode_kg/build_pycodekg_sqlite.py +0 -0
  20. {pycode_kg-0.25.1 → pycode_kg-0.26.0}/src/pycode_kg/cli/__init__.py +0 -0
  21. {pycode_kg-0.25.1 → pycode_kg-0.26.0}/src/pycode_kg/cli/cmd_analyze.py +0 -0
  22. {pycode_kg-0.25.1 → pycode_kg-0.26.0}/src/pycode_kg/cli/cmd_architecture.py +0 -0
  23. {pycode_kg-0.25.1 → pycode_kg-0.26.0}/src/pycode_kg/cli/cmd_bridges.py +0 -0
  24. {pycode_kg-0.25.1 → pycode_kg-0.26.0}/src/pycode_kg/cli/cmd_build.py +0 -0
  25. {pycode_kg-0.25.1 → pycode_kg-0.26.0}/src/pycode_kg/cli/cmd_build_full.py +0 -0
  26. {pycode_kg-0.25.1 → pycode_kg-0.26.0}/src/pycode_kg/cli/cmd_centrality.py +0 -0
  27. {pycode_kg-0.25.1 → pycode_kg-0.26.0}/src/pycode_kg/cli/cmd_explain.py +0 -0
  28. {pycode_kg-0.25.1 → pycode_kg-0.26.0}/src/pycode_kg/cli/cmd_framework_nodes.py +0 -0
  29. {pycode_kg-0.25.1 → pycode_kg-0.26.0}/src/pycode_kg/cli/cmd_hooks.py +0 -0
  30. {pycode_kg-0.25.1 → pycode_kg-0.26.0}/src/pycode_kg/cli/cmd_init.py +0 -0
  31. {pycode_kg-0.25.1 → pycode_kg-0.26.0}/src/pycode_kg/cli/cmd_mcp.py +0 -0
  32. {pycode_kg-0.25.1 → pycode_kg-0.26.0}/src/pycode_kg/cli/cmd_model.py +0 -0
  33. {pycode_kg-0.25.1 → pycode_kg-0.26.0}/src/pycode_kg/cli/cmd_query.py +0 -0
  34. {pycode_kg-0.25.1 → pycode_kg-0.26.0}/src/pycode_kg/cli/cmd_quilt.py +0 -0
  35. {pycode_kg-0.25.1 → pycode_kg-0.26.0}/src/pycode_kg/cli/cmd_viz.py +0 -0
  36. {pycode_kg-0.25.1 → pycode_kg-0.26.0}/src/pycode_kg/cli/main.py +0 -0
  37. {pycode_kg-0.25.1 → pycode_kg-0.26.0}/src/pycode_kg/cli/options.py +0 -0
  38. {pycode_kg-0.25.1 → pycode_kg-0.26.0}/src/pycode_kg/config.py +0 -0
  39. {pycode_kg-0.25.1 → pycode_kg-0.26.0}/src/pycode_kg/explain.py +0 -0
  40. {pycode_kg-0.25.1 → pycode_kg-0.26.0}/src/pycode_kg/graph.py +0 -0
  41. {pycode_kg-0.25.1 → pycode_kg-0.26.0}/src/pycode_kg/graph_html.py +0 -0
  42. {pycode_kg-0.25.1 → pycode_kg-0.26.0}/src/pycode_kg/index.py +0 -0
  43. {pycode_kg-0.25.1 → pycode_kg-0.26.0}/src/pycode_kg/kg.py +0 -0
  44. {pycode_kg-0.25.1 → pycode_kg-0.26.0}/src/pycode_kg/layout3d.py +0 -0
  45. {pycode_kg-0.25.1 → pycode_kg-0.26.0}/src/pycode_kg/module/__init__.py +0 -0
  46. {pycode_kg-0.25.1 → pycode_kg-0.26.0}/src/pycode_kg/module/base.py +0 -0
  47. {pycode_kg-0.25.1 → pycode_kg-0.26.0}/src/pycode_kg/module/extractor.py +0 -0
  48. {pycode_kg-0.25.1 → pycode_kg-0.26.0}/src/pycode_kg/module/types.py +0 -0
  49. {pycode_kg-0.25.1 → pycode_kg-0.26.0}/src/pycode_kg/pycodekg.py +0 -0
  50. {pycode_kg-0.25.1 → pycode_kg-0.26.0}/src/pycode_kg/ranking/__init__.py +0 -0
  51. {pycode_kg-0.25.1 → pycode_kg-0.26.0}/src/pycode_kg/ranking/cli_rank.py +0 -0
  52. {pycode_kg-0.25.1 → pycode_kg-0.26.0}/src/pycode_kg/ranking/coderank.py +0 -0
  53. {pycode_kg-0.25.1 → pycode_kg-0.26.0}/src/pycode_kg/render.py +0 -0
  54. {pycode_kg-0.25.1 → pycode_kg-0.26.0}/src/pycode_kg/report.py +0 -0
  55. {pycode_kg-0.25.1 → pycode_kg-0.26.0}/src/pycode_kg/resolution.py +0 -0
  56. {pycode_kg-0.25.1 → pycode_kg-0.26.0}/src/pycode_kg/scene3d.py +0 -0
  57. {pycode_kg-0.25.1 → pycode_kg-0.26.0}/src/pycode_kg/sql/004_add_centrality_table.sql +0 -0
  58. {pycode_kg-0.25.1 → pycode_kg-0.26.0}/src/pycode_kg/store.py +0 -0
  59. {pycode_kg-0.25.1 → pycode_kg-0.26.0}/src/pycode_kg/theme.py +0 -0
  60. {pycode_kg-0.25.1 → pycode_kg-0.26.0}/src/pycode_kg/utils.py +0 -0
  61. {pycode_kg-0.25.1 → pycode_kg-0.26.0}/src/pycode_kg/visitor.py +0 -0
  62. {pycode_kg-0.25.1 → pycode_kg-0.26.0}/src/pycode_kg/viz3d.py +0 -0
  63. {pycode_kg-0.25.1 → pycode_kg-0.26.0}/src/pycode_kg/viz3d_timeline.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycode-kg
3
- Version: 0.25.1
3
+ Version: 0.26.0
4
4
  Summary: A tool to build a searchable knowledge graph from Python repositories
5
5
  License-Expression: Elastic-2.0
6
6
  License-File: LICENSE
@@ -60,7 +60,7 @@ Description-Content-Type: text/markdown
60
60
 
61
61
  [![Python](https://img.shields.io/badge/python-3.12%20%7C%203.13-blue.svg)](https://www.python.org/)
62
62
  [![License: Elastic-2.0](https://img.shields.io/badge/License-Elastic%202.0-blue.svg)](https://www.elastic.co/licensing/elastic-license)
63
- [![Version](https://img.shields.io/badge/version-0.25.1-blue.svg)](https://github.com/Flux-Frontiers/pycode_kg/releases)
63
+ [![Version](https://img.shields.io/badge/version-0.26.0-blue.svg)](https://github.com/Flux-Frontiers/pycode_kg/releases)
64
64
  [![CI](https://github.com/Flux-Frontiers/pycode_kg/actions/workflows/ci.yml/badge.svg)](https://github.com/Flux-Frontiers/pycode_kg/actions/workflows/ci.yml)
65
65
  [![Poetry](https://img.shields.io/endpoint?url=https://python-poetry.org/badge/v0.json)](https://python-poetry.org/)
66
66
  [![DOI](https://zenodo.org/badge/1202379010.svg)](https://zenodo.org/badge/latestdoi/1202379010)
@@ -255,7 +255,7 @@ src/pycode_kg/
255
255
  └── viz3d_timeline.py # Metric history timeline
256
256
  ```
257
257
 
258
- The MCP server, the CLI, and the Streamlit app are thin wrappers over the same store + index + ranking core — there is exactly one code path for each capability. The latest architectural deep-dive is in [docs/analysis_v0.25.1.md](docs/analysis_v0.25.1.md), produced (of course) by `pycodekg analyze` against this very repo.
258
+ The MCP server, the CLI, and the Streamlit app are thin wrappers over the same store + index + ranking core — there is exactly one code path for each capability. The latest architectural deep-dive is in [docs/analysis_v0.26.0.md](docs/analysis_v0.26.0.md), produced (of course) by `pycodekg analyze` against this very repo.
259
259
 
260
260
  ---
261
261
 
@@ -282,13 +282,13 @@ If you use PyCodeKG in your research or project, please cite it:
282
282
 
283
283
  [![DOI](https://zenodo.org/badge/1202379010.svg)](https://zenodo.org/badge/latestdoi/1202379010)
284
284
 
285
- > Suchanek, E. G. (2026). *PyCodeKG: A Knowledge Graph for Python Codebases* (Version 0.25.1) [Software]. Flux-Frontiers. https://doi.org/10.5281/zenodo.19737993
285
+ > Suchanek, E. G. (2026). *PyCodeKG: A Knowledge Graph for Python Codebases* (Version 0.26.0) [Software]. Flux-Frontiers. https://doi.org/10.5281/zenodo.19737993
286
286
 
287
287
  ```bibtex
288
288
  @software{suchanek_pycode_kg,
289
289
  author = {Suchanek, Eric G.},
290
290
  title = {{PyCodeKG}: A Knowledge Graph for Python Codebases},
291
- version = {0.25.1},
291
+ version = {0.26.0},
292
292
  year = {2026},
293
293
  publisher = {Flux-Frontiers},
294
294
  url = {https://github.com/Flux-Frontiers/pycode_kg},
@@ -5,7 +5,7 @@
5
5
 
6
6
  [![Python](https://img.shields.io/badge/python-3.12%20%7C%203.13-blue.svg)](https://www.python.org/)
7
7
  [![License: Elastic-2.0](https://img.shields.io/badge/License-Elastic%202.0-blue.svg)](https://www.elastic.co/licensing/elastic-license)
8
- [![Version](https://img.shields.io/badge/version-0.25.1-blue.svg)](https://github.com/Flux-Frontiers/pycode_kg/releases)
8
+ [![Version](https://img.shields.io/badge/version-0.26.0-blue.svg)](https://github.com/Flux-Frontiers/pycode_kg/releases)
9
9
  [![CI](https://github.com/Flux-Frontiers/pycode_kg/actions/workflows/ci.yml/badge.svg)](https://github.com/Flux-Frontiers/pycode_kg/actions/workflows/ci.yml)
10
10
  [![Poetry](https://img.shields.io/endpoint?url=https://python-poetry.org/badge/v0.json)](https://python-poetry.org/)
11
11
  [![DOI](https://zenodo.org/badge/1202379010.svg)](https://zenodo.org/badge/latestdoi/1202379010)
@@ -200,7 +200,7 @@ src/pycode_kg/
200
200
  └── viz3d_timeline.py # Metric history timeline
201
201
  ```
202
202
 
203
- The MCP server, the CLI, and the Streamlit app are thin wrappers over the same store + index + ranking core — there is exactly one code path for each capability. The latest architectural deep-dive is in [docs/analysis_v0.25.1.md](docs/analysis_v0.25.1.md), produced (of course) by `pycodekg analyze` against this very repo.
203
+ The MCP server, the CLI, and the Streamlit app are thin wrappers over the same store + index + ranking core — there is exactly one code path for each capability. The latest architectural deep-dive is in [docs/analysis_v0.26.0.md](docs/analysis_v0.26.0.md), produced (of course) by `pycodekg analyze` against this very repo.
204
204
 
205
205
  ---
206
206
 
@@ -227,13 +227,13 @@ If you use PyCodeKG in your research or project, please cite it:
227
227
 
228
228
  [![DOI](https://zenodo.org/badge/1202379010.svg)](https://zenodo.org/badge/latestdoi/1202379010)
229
229
 
230
- > Suchanek, E. G. (2026). *PyCodeKG: A Knowledge Graph for Python Codebases* (Version 0.25.1) [Software]. Flux-Frontiers. https://doi.org/10.5281/zenodo.19737993
230
+ > Suchanek, E. G. (2026). *PyCodeKG: A Knowledge Graph for Python Codebases* (Version 0.26.0) [Software]. Flux-Frontiers. https://doi.org/10.5281/zenodo.19737993
231
231
 
232
232
  ```bibtex
233
233
  @software{suchanek_pycode_kg,
234
234
  author = {Suchanek, Eric G.},
235
235
  title = {{PyCodeKG}: A Knowledge Graph for Python Codebases},
236
- version = {0.25.1},
236
+ version = {0.26.0},
237
237
  year = {2026},
238
238
  publisher = {Flux-Frontiers},
239
239
  url = {https://github.com/Flux-Frontiers/pycode_kg},
@@ -70,7 +70,7 @@ torch = [
70
70
  # ---------------------------------------------------------------------------
71
71
  [project]
72
72
  name = "pycode-kg"
73
- version = "0.25.1"
73
+ version = "0.26.0"
74
74
  description = "A tool to build a searchable knowledge graph from Python repositories"
75
75
  readme = "README.md"
76
76
  license = "Elastic-2.0"
@@ -36,7 +36,7 @@ Author: Eric G. Suchanek, PhD
36
36
  License: Elastic 2.0
37
37
  """
38
38
 
39
- __version__ = "0.25.1"
39
+ __version__ = "0.26.0"
40
40
  __author__ = "Eric G. Suchanek, PhD"
41
41
 
42
42
  # Low-level primitives (locked v0 contract)
@@ -25,7 +25,12 @@ from pycode_kg.cli.options import sqlite_option
25
25
  from pycode_kg.kg import PyCodeKG
26
26
  from pycode_kg.pycodekg import DEFAULT_MODEL
27
27
  from pycode_kg.pycodekg_thorough_analysis import PyCodeKGAnalyzer
28
- from pycode_kg.snapshots import SnapshotManager
28
+ from pycode_kg.snapshots import (
29
+ SnapshotDelta,
30
+ SnapshotManager,
31
+ delta_from_dict,
32
+ metrics_from_dict,
33
+ )
29
34
  from pycode_kg.store import GraphStore
30
35
 
31
36
 
@@ -174,9 +179,10 @@ def save_snapshot(
174
179
  click.echo(f"OK Snapshot saved: {snapshot_file}")
175
180
  click.echo(f" Key: {snapshot_obj.key}")
176
181
  click.echo(f" Version: {snapshot_obj.version}")
177
- click.echo(f" Nodes: {snapshot_obj.metrics.total_nodes}")
178
- click.echo(f" Edges: {snapshot_obj.metrics.total_edges}")
179
- click.echo(f" Coverage: {snapshot_obj.metrics.docstring_coverage:.1%}")
182
+ saved_metrics = metrics_from_dict(snapshot_obj.metrics)
183
+ click.echo(f" Nodes: {saved_metrics.total_nodes}")
184
+ click.echo(f" Edges: {saved_metrics.total_edges}")
185
+ click.echo(f" Coverage: {saved_metrics.docstring_coverage:.1%}")
180
186
 
181
187
 
182
188
  @snapshot.command("list")
@@ -270,19 +276,20 @@ def show_snapshot(key: str, snapshots_dir: str | None) -> None:
270
276
  click.echo()
271
277
 
272
278
  click.echo("Metrics:")
273
- click.echo(f" Total Nodes: {snapshot_obj.metrics.total_nodes}")
274
- click.echo(f" Total Edges: {snapshot_obj.metrics.total_edges}")
275
- click.echo(f" Meaningful Nodes: {snapshot_obj.metrics.meaningful_nodes}")
276
- click.echo(f" Docstring Coverage: {snapshot_obj.metrics.docstring_coverage:.1%}")
277
- click.echo(f" Critical Issues: {snapshot_obj.metrics.critical_issues}")
278
- click.echo(f" Complexity Median: {snapshot_obj.metrics.complexity_median:.2f}")
279
+ metrics = metrics_from_dict(snapshot_obj.metrics)
280
+ click.echo(f" Total Nodes: {metrics.total_nodes}")
281
+ click.echo(f" Total Edges: {metrics.total_edges}")
282
+ click.echo(f" Meaningful Nodes: {metrics.meaningful_nodes}")
283
+ click.echo(f" Docstring Coverage: {metrics.docstring_coverage:.1%}")
284
+ click.echo(f" Critical Issues: {metrics.critical_issues}")
285
+ click.echo(f" Complexity Median: {metrics.complexity_median:.2f}")
279
286
  click.echo()
280
287
 
281
288
  click.echo("Node/Edge Breakdown:")
282
- for kind, count in sorted(snapshot_obj.metrics.node_counts.items()):
289
+ for kind, count in sorted(metrics.node_counts.items()):
283
290
  click.echo(f" {kind}: {count}")
284
291
  click.echo()
285
- for rel, count in sorted(snapshot_obj.metrics.edge_counts.items()):
292
+ for rel, count in sorted(metrics.edge_counts.items()):
286
293
  click.echo(f" {rel}: {count}")
287
294
  click.echo()
288
295
 
@@ -296,7 +303,7 @@ def show_snapshot(key: str, snapshots_dir: str | None) -> None:
296
303
 
297
304
  if snapshot_obj.vs_previous:
298
305
  click.echo("Delta vs. Previous:")
299
- delta = snapshot_obj.vs_previous
306
+ delta = delta_from_dict(snapshot_obj.vs_previous) or SnapshotDelta()
300
307
  click.echo(f" Nodes: {delta.nodes:+d}")
301
308
  click.echo(f" Edges: {delta.edges:+d}")
302
309
  click.echo(f" Coverage: {delta.coverage_delta:+.1%}")
@@ -305,7 +312,7 @@ def show_snapshot(key: str, snapshots_dir: str | None) -> None:
305
312
 
306
313
  if snapshot_obj.vs_baseline:
307
314
  click.echo("Delta vs. Baseline:")
308
- delta = snapshot_obj.vs_baseline
315
+ delta = delta_from_dict(snapshot_obj.vs_baseline) or SnapshotDelta()
309
316
  click.echo(f" Nodes: {delta.nodes:+d}")
310
317
  click.echo(f" Edges: {delta.edges:+d}")
311
318
  click.echo(f" Coverage: {delta.coverage_delta:+.1%}")
@@ -1653,7 +1653,7 @@ def snapshot_show(key: str = "latest") -> str:
1653
1653
  if snapshot is None:
1654
1654
  return json.dumps({"error": f"Snapshot not found for key: {key!r}"})
1655
1655
  out = snapshot.to_dict()
1656
- out["freshness"] = _snapshot_freshness(snapshot.metrics.total_nodes)
1656
+ out["freshness"] = _snapshot_freshness(snapshot.metrics.get("total_nodes", 0))
1657
1657
  return json.dumps(out, indent=2, ensure_ascii=False)
1658
1658
 
1659
1659
 
@@ -333,12 +333,19 @@ def _has_property_decorator(source_lines: list[str], def_lineno: int) -> bool:
333
333
  found, matching ``@property``, ``@cached_property`` (bare or via
334
334
  ``functools``), and ``@<name>.setter/getter/deleter``.
335
335
 
336
+ ``def_lineno`` comes from the graph and ``source_lines`` from the file on
337
+ disk, so the two disagree whenever the graph is stale: a line number past
338
+ the end of the current file is normal after an edit, not a bug. Treat that
339
+ as "no property decorator" rather than indexing past the end -- a node
340
+ wrongly listed as an orphan is a report to re-read, an ``IndexError`` here
341
+ aborts the whole analysis phase.
342
+
336
343
  :param source_lines: Module source split into lines.
337
344
  :param def_lineno: 1-based line number of the ``def`` statement.
338
345
  :return: True when a property-family decorator precedes the definition.
339
346
  """
340
347
  i = def_lineno - 2 # 0-based index of the line above the def
341
- while i >= 0:
348
+ while 0 <= i < len(source_lines):
342
349
  stripped = source_lines[i].strip()
343
350
  if not stripped.startswith("@"):
344
351
  break
@@ -0,0 +1,357 @@
1
+ """
2
+ snapshots.py — Temporal Snapshots of PyCodeKG Metrics
3
+
4
+ Thin layer over the shared ``kg_utils.snapshots`` module.
5
+
6
+ ``Snapshot``, ``SnapshotManifest`` and ``PruneResult`` are re-exported from
7
+ ``kg_utils.snapshots`` unchanged. A snapshot's ``metrics``, ``vs_previous``
8
+ and ``vs_baseline`` are plain dicts, which is what the shared manager reads
9
+ and writes.
10
+
11
+ This module adds:
12
+
13
+ - ``SnapshotMetrics`` / ``SnapshotDelta`` — domain dataclasses, used as
14
+ converters by callers that want attribute access. Convert with
15
+ ``metrics_from_dict`` / ``metrics_to_dict`` and ``delta_from_dict`` /
16
+ ``delta_to_dict``; a ``Snapshot`` never holds one.
17
+ - a ``SnapshotManager`` subclass that sets ``package_name="pycode-kg"``,
18
+ names the pycode-kg metric fields in ``capture()``, adds
19
+ ``coverage_delta`` and ``critical_issues_delta`` to deltas, collects
20
+ per-module node counts from SQLite, and extends a diff with
21
+ ``module_node_counts_delta``, ``issues_delta`` and ``timestamp``.
22
+
23
+ Do not subclass ``Snapshot`` here. A subclass that exposes ``metrics``,
24
+ ``vs_previous`` or ``vs_baseline`` as properties breaks every shared manager
25
+ method that reads those fields by attribute, and each one then needs a
26
+ hand-written copy. One such copy dropped ``snapshot_key``, ``subject`` and
27
+ ``tool`` on the way to disk, which shipped in 0.25.0.
28
+
29
+ Usage
30
+ -----
31
+ >>> from pycode_kg.snapshots import SnapshotManager, metrics_from_dict
32
+ >>> mgr = SnapshotManager(".pycodekg/snapshots")
33
+ >>> snapshot = mgr.capture(version="0.5.1", key="v0.5.1", subject="repo:pycode-kg")
34
+ >>> mgr.save_snapshot(snapshot)
35
+ >>> metrics_from_dict(snapshot.metrics).docstring_coverage
36
+ 0.0
37
+
38
+ Author: Eric G. Suchanek, PhD
39
+
40
+ License: Elastic 2.0
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import sqlite3
46
+ from dataclasses import dataclass, field
47
+ from pathlib import Path
48
+ from typing import Any
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # Re-export shared base types (backwards-compat public API)
52
+ # ---------------------------------------------------------------------------
53
+ from kg_utils.snapshots import (
54
+ PruneResult, # noqa: F401 re-exported
55
+ Snapshot, # noqa: F401 re-exported
56
+ SnapshotManifest, # noqa: F401 re-exported
57
+ )
58
+ from kg_utils.snapshots import SnapshotManager as _BaseSnapshotManager
59
+
60
+ __all__ = [
61
+ "SnapshotMetrics",
62
+ "SnapshotDelta",
63
+ "Snapshot",
64
+ "SnapshotManifest",
65
+ "SnapshotManager",
66
+ "PruneResult",
67
+ "metrics_to_dict",
68
+ "metrics_from_dict",
69
+ "delta_to_dict",
70
+ "delta_from_dict",
71
+ ]
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # Domain-specific dataclasses (used by CLI and tests)
76
+ # ---------------------------------------------------------------------------
77
+
78
+
79
+ @dataclass
80
+ class SnapshotMetrics:
81
+ """Core metrics captured in a pycode-kg snapshot."""
82
+
83
+ total_nodes: int
84
+ total_edges: int
85
+ meaningful_nodes: int
86
+ docstring_coverage: float # 0.0 to 1.0
87
+ node_counts: dict[str, int]
88
+ edge_counts: dict[str, int]
89
+ critical_issues: int
90
+ complexity_median: float # median fan-in across functions
91
+ module_node_counts: dict[str, int] = field(default_factory=dict)
92
+ coverage_documented: int = 0 # nodes with a docstring
93
+ coverage_total: int = 0 # nodes eligible for docstring coverage
94
+
95
+
96
+ @dataclass
97
+ class SnapshotDelta:
98
+ """Deltas comparing this snapshot to a baseline or previous snapshot."""
99
+
100
+ nodes: int = 0
101
+ edges: int = 0
102
+ coverage_delta: float = 0.0
103
+ critical_issues_delta: int = 0
104
+
105
+
106
+ # ---------------------------------------------------------------------------
107
+ # Conversion helpers
108
+ # ---------------------------------------------------------------------------
109
+
110
+
111
+ def metrics_to_dict(m: SnapshotMetrics) -> dict[str, Any]:
112
+ """Convert a ``SnapshotMetrics`` dataclass to a plain dict."""
113
+ return {
114
+ "total_nodes": m.total_nodes,
115
+ "total_edges": m.total_edges,
116
+ "meaningful_nodes": m.meaningful_nodes,
117
+ "docstring_coverage": m.docstring_coverage,
118
+ "node_counts": m.node_counts,
119
+ "edge_counts": m.edge_counts,
120
+ "critical_issues": m.critical_issues,
121
+ "complexity_median": m.complexity_median,
122
+ "module_node_counts": m.module_node_counts,
123
+ "coverage_documented": m.coverage_documented,
124
+ "coverage_total": m.coverage_total,
125
+ }
126
+
127
+
128
+ def metrics_from_dict(d: dict[str, Any]) -> SnapshotMetrics:
129
+ """Reconstruct a ``SnapshotMetrics`` dataclass from a plain dict."""
130
+ return SnapshotMetrics(
131
+ total_nodes=int(d.get("total_nodes", 0)),
132
+ total_edges=int(d.get("total_edges", 0)),
133
+ meaningful_nodes=int(d.get("meaningful_nodes", 0)),
134
+ docstring_coverage=float(d.get("docstring_coverage", 0.0)),
135
+ node_counts=d.get("node_counts", {}),
136
+ edge_counts=d.get("edge_counts", {}),
137
+ critical_issues=int(d.get("critical_issues", 0)),
138
+ complexity_median=float(d.get("complexity_median", 0.0)),
139
+ module_node_counts=d.get("module_node_counts", {}),
140
+ coverage_documented=int(d.get("coverage_documented", 0)),
141
+ coverage_total=int(d.get("coverage_total", 0)),
142
+ )
143
+
144
+
145
+ def delta_to_dict(delta: SnapshotDelta | None) -> dict[str, Any] | None:
146
+ """Convert a ``SnapshotDelta`` to a plain dict, or return None."""
147
+ if delta is None:
148
+ return None
149
+ return {
150
+ "nodes": delta.nodes,
151
+ "edges": delta.edges,
152
+ "coverage_delta": delta.coverage_delta,
153
+ "critical_issues_delta": delta.critical_issues_delta,
154
+ }
155
+
156
+
157
+ def delta_from_dict(d: dict[str, Any] | None) -> SnapshotDelta | None:
158
+ """Reconstruct a ``SnapshotDelta`` from a plain dict, or return None."""
159
+ if d is None:
160
+ return None
161
+ return SnapshotDelta(
162
+ nodes=int(d.get("nodes", 0)),
163
+ edges=int(d.get("edges", 0)),
164
+ coverage_delta=float(d.get("coverage_delta", 0.0)),
165
+ critical_issues_delta=int(d.get("critical_issues_delta", 0)),
166
+ )
167
+
168
+
169
+ # ---------------------------------------------------------------------------
170
+ # SnapshotManager — pycode-kg specialisation of the shared manager
171
+ # ---------------------------------------------------------------------------
172
+
173
+
174
+ class SnapshotManager(_BaseSnapshotManager):
175
+ """pycode-kg snapshot manager.
176
+
177
+ Subclasses the shared ``kg_utils.snapshots.SnapshotManager`` and adds:
178
+
179
+ * ``package_name="pycode-kg"`` default for version detection.
180
+ * A ``capture()`` naming the pycode-kg metric fields (``coverage``,
181
+ ``critical_issues``, ``complexity_median`` and the coverage numerator
182
+ and denominator) and collecting per-module node counts.
183
+ * ``_compute_delta_from_metrics`` extended with ``coverage_delta`` and
184
+ ``critical_issues_delta``.
185
+ * ``diff_snapshots`` extended with ``module_node_counts_delta``,
186
+ ``issues_delta`` and ``timestamp``.
187
+ * ``_collect_module_node_counts()`` — SQLite per-module node counts.
188
+
189
+ Everything else -- saving, loading, listing, pruning, key handling -- is
190
+ inherited unchanged. Overriding those to convert between dicts and the
191
+ domain dataclasses is what this module used to do, and is what let the
192
+ 0.25.0 snapshot key regression through.
193
+ """
194
+
195
+ def __init__(
196
+ self,
197
+ snapshots_dir: Path | str,
198
+ *,
199
+ db_path: Path | str | None = None,
200
+ package_name: str = "pycode-kg",
201
+ ) -> None:
202
+ """Initialize the manager rooted at ``snapshots_dir``.
203
+
204
+ :param snapshots_dir: Directory where snapshot JSON files live; created
205
+ on first save if it does not exist.
206
+ :param db_path: PyCodeKG SQLite graph path; required for collecting
207
+ per-module node counts during ``capture()``. Optional otherwise.
208
+ :param package_name: Package name used for version detection in saved
209
+ snapshots. Defaults to ``"pycode-kg"``.
210
+ """
211
+ super().__init__(snapshots_dir, package_name=package_name, db_path=db_path)
212
+
213
+ # ------------------------------------------------------------------
214
+ # capture — name the pycode-kg metric fields
215
+ # ------------------------------------------------------------------
216
+
217
+ def capture( # ty: ignore[invalid-method-override]
218
+ self,
219
+ version: str | None = None,
220
+ branch: str | None = None,
221
+ graph_stats_dict: dict[str, Any] | None = None,
222
+ coverage: float = 0.0,
223
+ coverage_documented: int = 0,
224
+ coverage_total: int = 0,
225
+ critical_issues: int = 0,
226
+ complexity_median: float = 0.0,
227
+ hotspots: list[dict[str, Any]] | None = None,
228
+ issues: list[str] | None = None,
229
+ tree_hash: str = "",
230
+ key: str = "",
231
+ subject: str = "",
232
+ ) -> Snapshot:
233
+ """Capture a pycode-kg snapshot.
234
+
235
+ Names the pycode-kg metric fields explicitly rather than taking them
236
+ through ``**extra_metrics``, and adds per-module node counts, then
237
+ delegates to the shared implementation.
238
+
239
+ :param version: Version string (e.g., "0.5.1").
240
+ :param branch: Git branch name; auto-detected if None.
241
+ :param graph_stats_dict: Output from ``graph_stats()`` / ``store.stats()``.
242
+ :param coverage: Docstring coverage fraction (0.0-1.0).
243
+ :param coverage_documented: Nodes with a non-empty docstring — the
244
+ numerator behind ``coverage``. Shown beside the percentage in
245
+ Snapshot History so a coverage drop caused by adding undocumented
246
+ nodes reads differently from one caused by removing docstrings.
247
+ :param coverage_total: Nodes eligible for docstring coverage — the
248
+ denominator behind ``coverage``.
249
+ :param critical_issues: Number of critical issues detected.
250
+ :param complexity_median: Median fan-in across functions.
251
+ :param hotspots: Top hotspot entries.
252
+ :param issues: Issue description strings.
253
+ :param tree_hash: Git tree hash, recorded as provenance; auto-detected
254
+ if not provided. It is not the snapshot's key.
255
+ :param key: Snapshot identifier. Pass the release tag at release time;
256
+ omit it and the base assigns a UTC timestamp.
257
+ :param subject: What was measured, e.g. ``repo:pycode-kg``.
258
+ :return: New :class:`~kg_utils.snapshots.Snapshot` (not yet persisted).
259
+ """
260
+ return super().capture(
261
+ version=version,
262
+ branch=branch,
263
+ graph_stats_dict=graph_stats_dict,
264
+ tree_hash=tree_hash,
265
+ key=key,
266
+ subject=subject,
267
+ hotspots=hotspots,
268
+ issues=issues,
269
+ docstring_coverage=coverage,
270
+ coverage_documented=coverage_documented,
271
+ coverage_total=coverage_total,
272
+ critical_issues=critical_issues,
273
+ complexity_median=complexity_median,
274
+ module_node_counts=self._collect_module_node_counts(),
275
+ )
276
+
277
+ # ------------------------------------------------------------------
278
+ # diff_snapshots — adds module_node_counts_delta, issues_delta, timestamp
279
+ # ------------------------------------------------------------------
280
+
281
+ def diff_snapshots(self, key_a: str, key_b: str) -> dict[str, Any]:
282
+ """Compare two snapshots side-by-side.
283
+
284
+ Extends the shared diff with ``module_node_counts_delta``,
285
+ ``issues_delta`` (introduced / resolved issue strings) and the
286
+ ``timestamp`` of each side, which the CLI prints.
287
+
288
+ :param key_a: Earlier snapshot key.
289
+ :param key_b: Later snapshot key.
290
+ :return: The shared diff result with the pycode-kg additions.
291
+ """
292
+ result = super().diff_snapshots(key_a, key_b)
293
+ if "error" in result:
294
+ return result
295
+
296
+ for side, key in (("a", key_a), ("b", key_b)):
297
+ snap = self.load_snapshot(key)
298
+ if snap is not None:
299
+ result[side]["timestamp"] = snap.timestamp
300
+
301
+ m_a: dict[str, Any] = result["a"]["metrics"]
302
+ m_b: dict[str, Any] = result["b"]["metrics"]
303
+ counts_a: dict[str, int] = m_a.get("module_node_counts", {})
304
+ counts_b: dict[str, int] = m_b.get("module_node_counts", {})
305
+ result["module_node_counts_delta"] = {
306
+ mod: counts_b.get(mod, 0) - counts_a.get(mod, 0)
307
+ for mod in set(counts_a) | set(counts_b)
308
+ if counts_b.get(mod, 0) != counts_a.get(mod, 0)
309
+ }
310
+
311
+ issues_a = set(result["a"]["issues"])
312
+ issues_b = set(result["b"]["issues"])
313
+ result["issues_delta"] = {
314
+ "introduced": list(issues_b - issues_a),
315
+ "resolved": list(issues_a - issues_b),
316
+ }
317
+ return result
318
+
319
+ # ------------------------------------------------------------------
320
+ # Delta computation — adds coverage_delta and critical_issues_delta
321
+ # ------------------------------------------------------------------
322
+
323
+ def _compute_delta_from_metrics(
324
+ self, new_m: dict[str, Any], old_m: dict[str, Any]
325
+ ) -> dict[str, Any]:
326
+ """Compute delta dict including pycode-kg specific fields."""
327
+ return {
328
+ "nodes": new_m.get("total_nodes", 0) - old_m.get("total_nodes", 0),
329
+ "edges": new_m.get("total_edges", 0) - old_m.get("total_edges", 0),
330
+ "coverage_delta": (
331
+ new_m.get("docstring_coverage", 0.0) - old_m.get("docstring_coverage", 0.0)
332
+ ),
333
+ "critical_issues_delta": (
334
+ new_m.get("critical_issues", 0) - old_m.get("critical_issues", 0)
335
+ ),
336
+ }
337
+
338
+ # ------------------------------------------------------------------
339
+ # SQLite per-module node counts
340
+ # ------------------------------------------------------------------
341
+
342
+ def _collect_module_node_counts(self) -> dict[str, int]:
343
+ """Query SQLite for per-module node counts.
344
+
345
+ :return: Dict mapping ``module_path`` to node count, or ``{}`` if the
346
+ database is unavailable or the query fails.
347
+ """
348
+ if not self.db_path or not self.db_path.exists():
349
+ return {}
350
+ try:
351
+ with sqlite3.connect(self.db_path) as conn:
352
+ rows = conn.execute(
353
+ "SELECT module_path, COUNT(*) FROM nodes GROUP BY module_path"
354
+ ).fetchall()
355
+ return {row[0]: row[1] for row in rows if row[0]}
356
+ except sqlite3.Error:
357
+ return {}