pycode-kg 0.25.0__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.0 → pycode_kg-0.26.0}/PKG-INFO +5 -5
  2. {pycode_kg-0.25.0 → pycode_kg-0.26.0}/README.md +4 -4
  3. {pycode_kg-0.25.0 → pycode_kg-0.26.0}/pyproject.toml +1 -1
  4. {pycode_kg-0.25.0 → pycode_kg-0.26.0}/src/pycode_kg/__init__.py +1 -1
  5. {pycode_kg-0.25.0 → pycode_kg-0.26.0}/src/pycode_kg/cli/cmd_snapshot.py +21 -14
  6. {pycode_kg-0.25.0 → pycode_kg-0.26.0}/src/pycode_kg/mcp_server.py +1 -1
  7. {pycode_kg-0.25.0 → 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.0/src/pycode_kg/snapshots.py +0 -531
  10. {pycode_kg-0.25.0 → pycode_kg-0.26.0}/LICENSE +0 -0
  11. {pycode_kg-0.25.0 → pycode_kg-0.26.0}/src/pycode_kg/.DS_Store +0 -0
  12. {pycode_kg-0.25.0 → pycode_kg-0.26.0}/src/pycode_kg/__main__.py +0 -0
  13. {pycode_kg-0.25.0 → pycode_kg-0.26.0}/src/pycode_kg/analysis/__init__.py +0 -0
  14. {pycode_kg-0.25.0 → pycode_kg-0.26.0}/src/pycode_kg/analysis/bridge.py +0 -0
  15. {pycode_kg-0.25.0 → pycode_kg-0.26.0}/src/pycode_kg/analysis/centrality.py +0 -0
  16. {pycode_kg-0.25.0 → pycode_kg-0.26.0}/src/pycode_kg/analysis/framework_detector.py +0 -0
  17. {pycode_kg-0.25.0 → pycode_kg-0.26.0}/src/pycode_kg/app.py +0 -0
  18. {pycode_kg-0.25.0 → pycode_kg-0.26.0}/src/pycode_kg/architecture.py +0 -0
  19. {pycode_kg-0.25.0 → pycode_kg-0.26.0}/src/pycode_kg/build_pycodekg_sqlite.py +0 -0
  20. {pycode_kg-0.25.0 → pycode_kg-0.26.0}/src/pycode_kg/cli/__init__.py +0 -0
  21. {pycode_kg-0.25.0 → pycode_kg-0.26.0}/src/pycode_kg/cli/cmd_analyze.py +0 -0
  22. {pycode_kg-0.25.0 → pycode_kg-0.26.0}/src/pycode_kg/cli/cmd_architecture.py +0 -0
  23. {pycode_kg-0.25.0 → pycode_kg-0.26.0}/src/pycode_kg/cli/cmd_bridges.py +0 -0
  24. {pycode_kg-0.25.0 → pycode_kg-0.26.0}/src/pycode_kg/cli/cmd_build.py +0 -0
  25. {pycode_kg-0.25.0 → pycode_kg-0.26.0}/src/pycode_kg/cli/cmd_build_full.py +0 -0
  26. {pycode_kg-0.25.0 → pycode_kg-0.26.0}/src/pycode_kg/cli/cmd_centrality.py +0 -0
  27. {pycode_kg-0.25.0 → pycode_kg-0.26.0}/src/pycode_kg/cli/cmd_explain.py +0 -0
  28. {pycode_kg-0.25.0 → pycode_kg-0.26.0}/src/pycode_kg/cli/cmd_framework_nodes.py +0 -0
  29. {pycode_kg-0.25.0 → pycode_kg-0.26.0}/src/pycode_kg/cli/cmd_hooks.py +0 -0
  30. {pycode_kg-0.25.0 → pycode_kg-0.26.0}/src/pycode_kg/cli/cmd_init.py +0 -0
  31. {pycode_kg-0.25.0 → pycode_kg-0.26.0}/src/pycode_kg/cli/cmd_mcp.py +0 -0
  32. {pycode_kg-0.25.0 → pycode_kg-0.26.0}/src/pycode_kg/cli/cmd_model.py +0 -0
  33. {pycode_kg-0.25.0 → pycode_kg-0.26.0}/src/pycode_kg/cli/cmd_query.py +0 -0
  34. {pycode_kg-0.25.0 → pycode_kg-0.26.0}/src/pycode_kg/cli/cmd_quilt.py +0 -0
  35. {pycode_kg-0.25.0 → pycode_kg-0.26.0}/src/pycode_kg/cli/cmd_viz.py +0 -0
  36. {pycode_kg-0.25.0 → pycode_kg-0.26.0}/src/pycode_kg/cli/main.py +0 -0
  37. {pycode_kg-0.25.0 → pycode_kg-0.26.0}/src/pycode_kg/cli/options.py +0 -0
  38. {pycode_kg-0.25.0 → pycode_kg-0.26.0}/src/pycode_kg/config.py +0 -0
  39. {pycode_kg-0.25.0 → pycode_kg-0.26.0}/src/pycode_kg/explain.py +0 -0
  40. {pycode_kg-0.25.0 → pycode_kg-0.26.0}/src/pycode_kg/graph.py +0 -0
  41. {pycode_kg-0.25.0 → pycode_kg-0.26.0}/src/pycode_kg/graph_html.py +0 -0
  42. {pycode_kg-0.25.0 → pycode_kg-0.26.0}/src/pycode_kg/index.py +0 -0
  43. {pycode_kg-0.25.0 → pycode_kg-0.26.0}/src/pycode_kg/kg.py +0 -0
  44. {pycode_kg-0.25.0 → pycode_kg-0.26.0}/src/pycode_kg/layout3d.py +0 -0
  45. {pycode_kg-0.25.0 → pycode_kg-0.26.0}/src/pycode_kg/module/__init__.py +0 -0
  46. {pycode_kg-0.25.0 → pycode_kg-0.26.0}/src/pycode_kg/module/base.py +0 -0
  47. {pycode_kg-0.25.0 → pycode_kg-0.26.0}/src/pycode_kg/module/extractor.py +0 -0
  48. {pycode_kg-0.25.0 → pycode_kg-0.26.0}/src/pycode_kg/module/types.py +0 -0
  49. {pycode_kg-0.25.0 → pycode_kg-0.26.0}/src/pycode_kg/pycodekg.py +0 -0
  50. {pycode_kg-0.25.0 → pycode_kg-0.26.0}/src/pycode_kg/ranking/__init__.py +0 -0
  51. {pycode_kg-0.25.0 → pycode_kg-0.26.0}/src/pycode_kg/ranking/cli_rank.py +0 -0
  52. {pycode_kg-0.25.0 → pycode_kg-0.26.0}/src/pycode_kg/ranking/coderank.py +0 -0
  53. {pycode_kg-0.25.0 → pycode_kg-0.26.0}/src/pycode_kg/render.py +0 -0
  54. {pycode_kg-0.25.0 → pycode_kg-0.26.0}/src/pycode_kg/report.py +0 -0
  55. {pycode_kg-0.25.0 → pycode_kg-0.26.0}/src/pycode_kg/resolution.py +0 -0
  56. {pycode_kg-0.25.0 → pycode_kg-0.26.0}/src/pycode_kg/scene3d.py +0 -0
  57. {pycode_kg-0.25.0 → pycode_kg-0.26.0}/src/pycode_kg/sql/004_add_centrality_table.sql +0 -0
  58. {pycode_kg-0.25.0 → pycode_kg-0.26.0}/src/pycode_kg/store.py +0 -0
  59. {pycode_kg-0.25.0 → pycode_kg-0.26.0}/src/pycode_kg/theme.py +0 -0
  60. {pycode_kg-0.25.0 → pycode_kg-0.26.0}/src/pycode_kg/utils.py +0 -0
  61. {pycode_kg-0.25.0 → pycode_kg-0.26.0}/src/pycode_kg/visitor.py +0 -0
  62. {pycode_kg-0.25.0 → pycode_kg-0.26.0}/src/pycode_kg/viz3d.py +0 -0
  63. {pycode_kg-0.25.0 → 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.0
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.0-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.0.md](docs/analysis_v0.25.0.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.0) [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.0},
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.0-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.0.md](docs/analysis_v0.25.0.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.0) [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.0},
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.0"
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.0"
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 {}
@@ -1,531 +0,0 @@
1
- """
2
- snapshots.py — Temporal Snapshots of PyCodeKG Metrics
3
-
4
- Thin compatibility layer over the shared ``kg_utils.snapshots`` module.
5
-
6
- The shared module provides canonical ``Snapshot``, ``SnapshotManifest``, and
7
- ``SnapshotManager`` backed by free-form dicts. This module re-exports those
8
- types and adds:
9
-
10
- - ``SnapshotMetrics`` — domain-specific dataclass (used by CLI and tests)
11
- - ``SnapshotDelta`` — domain-specific dataclass (used by CLI and tests)
12
- - ``SnapshotManager`` subclass that:
13
- * sets ``package_name="pycode-kg"`` by default
14
- * overrides ``capture()`` to accept the legacy per-field kwargs
15
- (``coverage``, ``critical_issues``, ``complexity_median``) and
16
- build the structured ``metrics`` dict
17
- * overrides ``_compute_delta_from_metrics`` to include
18
- ``coverage_delta`` and ``critical_issues_delta``
19
- * adds ``_collect_module_node_counts()`` — SQLite per-module counts
20
- - ``metrics_to_dict`` / ``metrics_from_dict`` — helpers for converting
21
- between ``SnapshotMetrics`` dataclass and the underlying dict
22
- - ``delta_to_dict`` / ``delta_from_dict`` — same for ``SnapshotDelta``
23
-
24
- ``Snapshot`` is re-exported from ``kg_utils.snapshots``. For backwards
25
- compatibility ``snapshot.metrics`` returns a ``SnapshotMetrics``-shaped
26
- view object when the snapshot was constructed via this module's helpers.
27
-
28
- Usage
29
- -----
30
- >>> from pycode_kg.snapshots import SnapshotManager
31
- >>> mgr = SnapshotManager(".pycodekg/snapshots")
32
- >>> snapshot = mgr.capture("v0.5.1", "develop", graph_stats_dict)
33
- >>> mgr.save_snapshot(snapshot)
34
- >>> manifest = mgr.load_manifest()
35
- >>> prev = mgr.get_previous(tree_hash)
36
-
37
- Author: Eric G. Suchanek, PhD
38
-
39
- License: Elastic 2.0
40
- """
41
-
42
- from __future__ import annotations
43
-
44
- import sqlite3
45
- from dataclasses import dataclass, field
46
- from pathlib import Path
47
- from typing import Any
48
-
49
- # ---------------------------------------------------------------------------
50
- # Re-export shared base types (backwards-compat public API)
51
- # ---------------------------------------------------------------------------
52
- from kg_utils.snapshots import (
53
- PruneResult, # noqa: F401 re-exported
54
- SnapshotManifest, # noqa: F401 re-exported
55
- )
56
- from kg_utils.snapshots import Snapshot as _BaseSnapshot
57
- from kg_utils.snapshots import SnapshotManager as _BaseSnapshotManager
58
-
59
- __all__ = [
60
- "SnapshotMetrics",
61
- "SnapshotDelta",
62
- "Snapshot",
63
- "SnapshotManifest",
64
- "SnapshotManager",
65
- "PruneResult",
66
- "metrics_to_dict",
67
- "metrics_from_dict",
68
- "delta_to_dict",
69
- "delta_from_dict",
70
- ]
71
-
72
-
73
- # ---------------------------------------------------------------------------
74
- # Domain-specific dataclasses (used by CLI and tests)
75
- # ---------------------------------------------------------------------------
76
-
77
-
78
- @dataclass
79
- class SnapshotMetrics:
80
- """Core metrics captured in a pycode-kg snapshot."""
81
-
82
- total_nodes: int
83
- total_edges: int
84
- meaningful_nodes: int
85
- docstring_coverage: float # 0.0 to 1.0
86
- node_counts: dict[str, int]
87
- edge_counts: dict[str, int]
88
- critical_issues: int
89
- complexity_median: float # median fan-in across functions
90
- module_node_counts: dict[str, int] = field(default_factory=dict)
91
- coverage_documented: int = 0 # nodes with a docstring
92
- coverage_total: int = 0 # nodes eligible for docstring coverage
93
-
94
-
95
- @dataclass
96
- class SnapshotDelta:
97
- """Deltas comparing this snapshot to a baseline or previous snapshot."""
98
-
99
- nodes: int = 0
100
- edges: int = 0
101
- coverage_delta: float = 0.0
102
- critical_issues_delta: int = 0
103
-
104
-
105
- # ---------------------------------------------------------------------------
106
- # Conversion helpers
107
- # ---------------------------------------------------------------------------
108
-
109
-
110
- def metrics_to_dict(m: SnapshotMetrics) -> dict[str, Any]:
111
- """Convert a ``SnapshotMetrics`` dataclass to a plain dict."""
112
- return {
113
- "total_nodes": m.total_nodes,
114
- "total_edges": m.total_edges,
115
- "meaningful_nodes": m.meaningful_nodes,
116
- "docstring_coverage": m.docstring_coverage,
117
- "node_counts": m.node_counts,
118
- "edge_counts": m.edge_counts,
119
- "critical_issues": m.critical_issues,
120
- "complexity_median": m.complexity_median,
121
- "module_node_counts": m.module_node_counts,
122
- "coverage_documented": m.coverage_documented,
123
- "coverage_total": m.coverage_total,
124
- }
125
-
126
-
127
- def metrics_from_dict(d: dict[str, Any]) -> SnapshotMetrics:
128
- """Reconstruct a ``SnapshotMetrics`` dataclass from a plain dict."""
129
- return SnapshotMetrics(
130
- total_nodes=int(d.get("total_nodes", 0)),
131
- total_edges=int(d.get("total_edges", 0)),
132
- meaningful_nodes=int(d.get("meaningful_nodes", 0)),
133
- docstring_coverage=float(d.get("docstring_coverage", 0.0)),
134
- node_counts=d.get("node_counts", {}),
135
- edge_counts=d.get("edge_counts", {}),
136
- critical_issues=int(d.get("critical_issues", 0)),
137
- complexity_median=float(d.get("complexity_median", 0.0)),
138
- module_node_counts=d.get("module_node_counts", {}),
139
- coverage_documented=int(d.get("coverage_documented", 0)),
140
- coverage_total=int(d.get("coverage_total", 0)),
141
- )
142
-
143
-
144
- def delta_to_dict(delta: SnapshotDelta | None) -> dict[str, Any] | None:
145
- """Convert a ``SnapshotDelta`` to a plain dict, or return None."""
146
- if delta is None:
147
- return None
148
- return {
149
- "nodes": delta.nodes,
150
- "edges": delta.edges,
151
- "coverage_delta": delta.coverage_delta,
152
- "critical_issues_delta": delta.critical_issues_delta,
153
- }
154
-
155
-
156
- def delta_from_dict(d: dict[str, Any] | None) -> SnapshotDelta | None:
157
- """Reconstruct a ``SnapshotDelta`` from a plain dict, or return None."""
158
- if d is None:
159
- return None
160
- return SnapshotDelta(
161
- nodes=int(d.get("nodes", 0)),
162
- edges=int(d.get("edges", 0)),
163
- coverage_delta=float(d.get("coverage_delta", 0.0)),
164
- critical_issues_delta=int(d.get("critical_issues_delta", 0)),
165
- )
166
-
167
-
168
- # ---------------------------------------------------------------------------
169
- # Snapshot — thin compatibility wrapper around the shared dict-based model
170
- # ---------------------------------------------------------------------------
171
-
172
-
173
- class Snapshot(_BaseSnapshot):
174
- """pycode-kg Snapshot with attribute-style access to metrics and deltas.
175
-
176
- Extends the shared ``kg_utils.snapshots.Snapshot`` (which stores metrics
177
- as a free-form dict) with ``@property`` accessors that return the typed
178
- ``SnapshotMetrics`` and ``SnapshotDelta`` objects that the CLI and tests
179
- expect.
180
-
181
- The underlying ``metrics``, ``vs_previous``, and ``vs_baseline`` fields
182
- remain plain dicts on disk; the properties are view-only adapters.
183
-
184
- ``to_dict`` is **not** overridden. The base reads those three fields out of
185
- ``__dict__`` rather than through these properties (kgmodule-utils 0.19.0),
186
- which is what the override used to exist for -- and the base is also what
187
- supplies the current key scheme, so an override here would silently keep
188
- writing tree-hash keys.
189
-
190
- Implementation note
191
- -------------------
192
- Python dataclass fields are stored in ``__dict__`` under their field name.
193
- The properties below always read and write the raw dict stored in
194
- ``self.__dict__`` directly so that the shared base-class infrastructure
195
- (which expects plain dicts) continues to work without modification.
196
- """
197
-
198
- @property # type: ignore[override]
199
- def metrics(self) -> SnapshotMetrics: # type: ignore[override]
200
- """Return metrics as a ``SnapshotMetrics`` dataclass view."""
201
- return metrics_from_dict(self.__dict__["metrics"])
202
-
203
- @metrics.setter
204
- def metrics(self, value: SnapshotMetrics | dict[str, Any]) -> None:
205
- if isinstance(value, SnapshotMetrics):
206
- self.__dict__["metrics"] = metrics_to_dict(value)
207
- else:
208
- self.__dict__["metrics"] = value
209
-
210
- @property # type: ignore[override]
211
- def vs_previous(self) -> SnapshotDelta | None: # type: ignore[override]
212
- """Return vs_previous as a ``SnapshotDelta`` dataclass view."""
213
- return delta_from_dict(self.__dict__["vs_previous"])
214
-
215
- @vs_previous.setter
216
- def vs_previous(self, value: SnapshotDelta | dict[str, Any] | None) -> None:
217
- if isinstance(value, SnapshotDelta):
218
- self.__dict__["vs_previous"] = delta_to_dict(value)
219
- else:
220
- self.__dict__["vs_previous"] = value
221
-
222
- @property # type: ignore[override]
223
- def vs_baseline(self) -> SnapshotDelta | None: # type: ignore[override]
224
- """Return vs_baseline as a ``SnapshotDelta`` dataclass view."""
225
- return delta_from_dict(self.__dict__["vs_baseline"])
226
-
227
- @vs_baseline.setter
228
- def vs_baseline(self, value: SnapshotDelta | dict[str, Any] | None) -> None:
229
- if isinstance(value, SnapshotDelta):
230
- self.__dict__["vs_baseline"] = delta_to_dict(value)
231
- else:
232
- self.__dict__["vs_baseline"] = value
233
-
234
- @staticmethod
235
- def from_dict(data: dict[str, Any]) -> Snapshot: # type: ignore[override]
236
- """Reconstruct a pycode-kg ``Snapshot`` from a dictionary."""
237
- base = _BaseSnapshot.from_dict(data)
238
- return _rewrap(base)
239
-
240
-
241
- # ---------------------------------------------------------------------------
242
- # SnapshotManager — pycode-kg specialisation of the shared manager
243
- # ---------------------------------------------------------------------------
244
-
245
-
246
- class SnapshotManager(_BaseSnapshotManager):
247
- """pycode-kg snapshot manager.
248
-
249
- Subclasses the shared ``kg_utils.snapshots.SnapshotManager`` and adds:
250
-
251
- * ``package_name="pycode-kg"`` default for version detection.
252
- * Legacy ``capture()`` kwargs: ``coverage``, ``critical_issues``,
253
- ``complexity_median`` — merged into the metrics dict.
254
- * ``_compute_delta_from_metrics`` extended with ``coverage_delta`` and
255
- ``critical_issues_delta``.
256
- * ``_collect_module_node_counts()`` — SQLite per-module node counts stored
257
- in snapshot metrics under ``"module_node_counts"``.
258
- * Returns ``pycode_kg.snapshots.Snapshot`` instances (with typed-accessor
259
- properties) from all load/capture methods.
260
- """
261
-
262
- def __init__(
263
- self,
264
- snapshots_dir: Path | str,
265
- *,
266
- db_path: Path | str | None = None,
267
- package_name: str = "pycode-kg",
268
- ) -> None:
269
- """Initialize the manager rooted at ``snapshots_dir``.
270
-
271
- :param snapshots_dir: Directory where snapshot JSON files live; created
272
- on first save if it does not exist.
273
- :param db_path: PyCodeKG SQLite graph path; required for collecting
274
- per-module node counts during ``capture()``. Optional otherwise.
275
- :param package_name: Package name used for version detection in saved
276
- snapshots. Defaults to ``"pycode-kg"``.
277
- """
278
- super().__init__(snapshots_dir, package_name=package_name, db_path=db_path)
279
-
280
- # ------------------------------------------------------------------
281
- # capture — backwards-compat wrapper
282
- # ------------------------------------------------------------------
283
-
284
- def capture( # ty: ignore[invalid-method-override]
285
- self,
286
- version: str | None = None,
287
- branch: str | None = None,
288
- graph_stats_dict: dict[str, Any] | None = None,
289
- coverage: float = 0.0,
290
- coverage_documented: int = 0,
291
- coverage_total: int = 0,
292
- critical_issues: int = 0,
293
- complexity_median: float = 0.0,
294
- hotspots: list[dict[str, Any]] | None = None,
295
- issues: list[str] | None = None,
296
- tree_hash: str = "",
297
- key: str = "",
298
- subject: str = "",
299
- ) -> Snapshot:
300
- """Capture a pycode-kg snapshot.
301
-
302
- Accepts the legacy per-field kwargs (``coverage``, ``critical_issues``,
303
- ``complexity_median``) in addition to the dict-based
304
- ``graph_stats_dict`` from the base class, and builds the full metrics
305
- dict expected by the shared infrastructure.
306
-
307
- :param version: Version string (e.g., "0.5.1").
308
- :param branch: Git branch name; auto-detected if None.
309
- :param graph_stats_dict: Output from ``graph_stats()`` / ``store.stats()``.
310
- :param coverage: Docstring coverage fraction (0.0–1.0).
311
- :param coverage_documented: Nodes with a non-empty docstring — the
312
- numerator behind ``coverage``. Shown beside the percentage in
313
- Snapshot History so a coverage drop caused by adding undocumented
314
- nodes reads differently from one caused by removing docstrings.
315
- :param coverage_total: Nodes eligible for docstring coverage — the
316
- denominator behind ``coverage``.
317
- :param critical_issues: Number of critical issues detected.
318
- :param complexity_median: Median fan-in across functions.
319
- :param hotspots: Top hotspot entries.
320
- :param issues: Issue description strings.
321
- :param tree_hash: Git tree hash, recorded as provenance; auto-detected
322
- if not provided. It is not the snapshot's key.
323
- :param key: Snapshot identifier. Pass the release tag at release time;
324
- omit it and the base assigns a UTC timestamp.
325
- :param subject: What was measured, e.g. ``repo:pycode-kg``.
326
- :return: New :class:`Snapshot` instance (not yet persisted).
327
- """
328
- module_node_counts = self._collect_module_node_counts()
329
-
330
- base_snap = super().capture(
331
- version=version,
332
- branch=branch,
333
- graph_stats_dict=graph_stats_dict,
334
- tree_hash=tree_hash,
335
- key=key,
336
- subject=subject,
337
- hotspots=hotspots,
338
- issues=issues,
339
- docstring_coverage=coverage,
340
- coverage_documented=coverage_documented,
341
- coverage_total=coverage_total,
342
- critical_issues=critical_issues,
343
- complexity_median=complexity_median,
344
- module_node_counts=module_node_counts,
345
- )
346
-
347
- return _rewrap(base_snap)
348
-
349
- # ------------------------------------------------------------------
350
- # diff_snapshots — adds module_node_counts_delta and issues_delta
351
- # ------------------------------------------------------------------
352
-
353
- def diff_snapshots(self, key_a: str, key_b: str) -> dict[str, Any]:
354
- """Compare two snapshots side-by-side.
355
-
356
- Extends the base diff with ``module_node_counts_delta`` and
357
- ``issues_delta`` (introduced / resolved issue strings).
358
-
359
- :param key_a: First snapshot key (tree hash).
360
- :param key_b: Second snapshot key (tree hash).
361
- :return: Dict with metrics from both, computed deltas.
362
- """
363
- snap_a = _BaseSnapshotManager.load_snapshot(self, key_a)
364
- snap_b = _BaseSnapshotManager.load_snapshot(self, key_b)
365
-
366
- if not snap_a or not snap_b:
367
- return {"error": "One or both snapshots not found"}
368
-
369
- m_a = snap_a.metrics
370
- m_b = snap_b.metrics
371
-
372
- all_node_kinds = set(m_a.get("node_counts", {})) | set(m_b.get("node_counts", {}))
373
- all_edge_rels = set(m_a.get("edge_counts", {})) | set(m_b.get("edge_counts", {}))
374
-
375
- node_counts_delta = {
376
- k: m_b.get("node_counts", {}).get(k, 0) - m_a.get("node_counts", {}).get(k, 0)
377
- for k in all_node_kinds
378
- }
379
- edge_counts_delta = {
380
- k: m_b.get("edge_counts", {}).get(k, 0) - m_a.get("edge_counts", {}).get(k, 0)
381
- for k in all_edge_rels
382
- }
383
-
384
- all_modules = set(m_a.get("module_node_counts", {})) | set(
385
- m_b.get("module_node_counts", {})
386
- )
387
- module_node_counts_delta = {
388
- mod: m_b.get("module_node_counts", {}).get(mod, 0)
389
- - m_a.get("module_node_counts", {}).get(mod, 0)
390
- for mod in all_modules
391
- if m_b.get("module_node_counts", {}).get(mod, 0)
392
- != m_a.get("module_node_counts", {}).get(mod, 0)
393
- }
394
-
395
- issues_a = set(snap_a.issues)
396
- issues_b = set(snap_b.issues)
397
-
398
- return {
399
- "a": {
400
- "key": snap_a.key,
401
- "metrics": m_a,
402
- "issues": snap_a.issues,
403
- "timestamp": snap_a.timestamp,
404
- },
405
- "b": {
406
- "key": snap_b.key,
407
- "metrics": m_b,
408
- "issues": snap_b.issues,
409
- "timestamp": snap_b.timestamp,
410
- },
411
- "delta": self._compute_delta_from_metrics(m_b, m_a),
412
- "node_counts_delta": node_counts_delta,
413
- "edge_counts_delta": edge_counts_delta,
414
- "module_node_counts_delta": module_node_counts_delta,
415
- "issues_delta": {
416
- "introduced": list(issues_b - issues_a),
417
- "resolved": list(issues_a - issues_b),
418
- },
419
- }
420
-
421
- # ------------------------------------------------------------------
422
- # Delta computation — adds coverage_delta and critical_issues_delta
423
- # ------------------------------------------------------------------
424
-
425
- def _compute_delta(self, snap_new: _BaseSnapshot, snap_old: _BaseSnapshot) -> dict[str, Any]:
426
- """Compute delta, extracting raw metric dicts to avoid the typed-property layer."""
427
- new_m = snap_new.__dict__.get("metrics", snap_new.metrics)
428
- old_m = snap_old.__dict__.get("metrics", snap_old.metrics)
429
- if isinstance(new_m, SnapshotMetrics):
430
- new_m = metrics_to_dict(new_m)
431
- if isinstance(old_m, SnapshotMetrics):
432
- old_m = metrics_to_dict(old_m)
433
- return self._compute_delta_from_metrics(new_m, old_m)
434
-
435
- def _compute_delta_from_metrics(
436
- self, new_m: dict[str, Any], old_m: dict[str, Any]
437
- ) -> dict[str, Any]:
438
- """Compute delta dict including pycode-kg specific fields."""
439
- return {
440
- "nodes": new_m.get("total_nodes", 0) - old_m.get("total_nodes", 0),
441
- "edges": new_m.get("total_edges", 0) - old_m.get("total_edges", 0),
442
- "coverage_delta": (
443
- new_m.get("docstring_coverage", 0.0) - old_m.get("docstring_coverage", 0.0)
444
- ),
445
- "critical_issues_delta": (
446
- new_m.get("critical_issues", 0) - old_m.get("critical_issues", 0)
447
- ),
448
- }
449
-
450
- # ------------------------------------------------------------------
451
- # save_snapshot — normalise typed properties back to raw dicts first
452
- # ------------------------------------------------------------------
453
-
454
- def save_snapshot(self, snapshot: _BaseSnapshot, *, force: bool = False) -> Any:
455
- """Persist snapshot, normalising any typed-property values to raw dicts.
456
-
457
- The base ``save_snapshot`` inspects ``snapshot.metrics`` (expects a
458
- dict) and ``snapshot.vs_previous`` / ``snapshot.vs_baseline`` (expects
459
- dicts or None) directly. If ``snapshot`` is a pycode-kg ``Snapshot``
460
- the properties return typed dataclasses instead; we substitute a plain
461
- ``_BaseSnapshot`` carrying the raw dicts so the base implementation
462
- can serialise without modification.
463
- """
464
- if isinstance(snapshot, Snapshot):
465
- raw = _BaseSnapshot(
466
- branch=snapshot.branch,
467
- timestamp=snapshot.timestamp,
468
- version=snapshot.version,
469
- metrics=snapshot.__dict__["metrics"],
470
- hotspots=snapshot.hotspots,
471
- issues=snapshot.issues,
472
- vs_previous=snapshot.__dict__["vs_previous"],
473
- vs_baseline=snapshot.__dict__["vs_baseline"],
474
- tree_hash=snapshot.tree_hash,
475
- )
476
- return super().save_snapshot(raw, force=force)
477
- return super().save_snapshot(snapshot, force=force)
478
-
479
- # ------------------------------------------------------------------
480
- # Load helpers — re-wrap base Snapshot instances as pycode-kg Snapshots
481
- # ------------------------------------------------------------------
482
-
483
- def load_snapshot(self, key: str) -> Snapshot | None: # type: ignore[override]
484
- """Load a snapshot by key and re-wrap it as a pycode-kg ``Snapshot`` with typed-accessor properties. Returns ``None`` if no snapshot matches."""
485
- snap = super().load_snapshot(key)
486
- return _rewrap(snap) if snap is not None else None
487
-
488
- def get_previous(self, key: str) -> Snapshot | None: # type: ignore[override]
489
- """Return the snapshot immediately preceding ``key`` in manifest chronology, re-wrapped as a pycode-kg ``Snapshot``. ``None`` when ``key`` is the earliest."""
490
- snap = super().get_previous(key)
491
- return _rewrap(snap) if snap is not None else None
492
-
493
- def get_baseline(self) -> Snapshot | None: # type: ignore[override]
494
- """Return the earliest (baseline) snapshot in the manifest, re-wrapped as a pycode-kg ``Snapshot``. ``None`` if no snapshots exist."""
495
- snap = super().get_baseline()
496
- return _rewrap(snap) if snap is not None else None
497
-
498
- # ------------------------------------------------------------------
499
- # SQLite per-module node counts
500
- # ------------------------------------------------------------------
501
-
502
- def _collect_module_node_counts(self) -> dict[str, int]:
503
- """Query SQLite for per-module node counts.
504
-
505
- :return: Dict mapping ``module_path`` to node count, or ``{}`` if the
506
- database is unavailable or the query fails.
507
- """
508
- if not self.db_path or not self.db_path.exists():
509
- return {}
510
- try:
511
- with sqlite3.connect(self.db_path) as conn:
512
- rows = conn.execute(
513
- "SELECT module_path, COUNT(*) FROM nodes GROUP BY module_path"
514
- ).fetchall()
515
- return {row[0]: row[1] for row in rows if row[0]}
516
- except sqlite3.Error:
517
- return {}
518
-
519
-
520
- # ---------------------------------------------------------------------------
521
- # Internal helper
522
- # ---------------------------------------------------------------------------
523
-
524
-
525
- def _rewrap(base: _BaseSnapshot) -> Snapshot:
526
- """Re-wrap a base Snapshot as a pycode-kg Snapshot (no data copying)."""
527
- if isinstance(base, Snapshot):
528
- return base
529
- snap = Snapshot.__new__(Snapshot)
530
- snap.__dict__.update(base.__dict__)
531
- return snap
File without changes