pycode-kg 0.25.1__tar.gz → 0.27.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.27.0}/PKG-INFO +7 -7
  2. {pycode_kg-0.25.1 → pycode_kg-0.27.0}/README.md +4 -4
  3. {pycode_kg-0.25.1 → pycode_kg-0.27.0}/pyproject.toml +17 -3
  4. {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/__init__.py +1 -1
  5. {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/cli/cmd_init.py +1 -1
  6. {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/cli/cmd_snapshot.py +51 -16
  7. {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/mcp_server.py +1 -1
  8. {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/pycodekg_thorough_analysis.py +8 -1
  9. pycode_kg-0.27.0/src/pycode_kg/snapshots.py +273 -0
  10. pycode_kg-0.25.1/src/pycode_kg/snapshots.py +0 -535
  11. {pycode_kg-0.25.1 → pycode_kg-0.27.0}/LICENSE +0 -0
  12. {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/.DS_Store +0 -0
  13. {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/__main__.py +0 -0
  14. {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/analysis/__init__.py +0 -0
  15. {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/analysis/bridge.py +0 -0
  16. {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/analysis/centrality.py +0 -0
  17. {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/analysis/framework_detector.py +0 -0
  18. {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/app.py +0 -0
  19. {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/architecture.py +0 -0
  20. {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/build_pycodekg_sqlite.py +0 -0
  21. {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/cli/__init__.py +0 -0
  22. {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/cli/cmd_analyze.py +0 -0
  23. {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/cli/cmd_architecture.py +0 -0
  24. {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/cli/cmd_bridges.py +0 -0
  25. {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/cli/cmd_build.py +0 -0
  26. {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/cli/cmd_build_full.py +0 -0
  27. {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/cli/cmd_centrality.py +0 -0
  28. {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/cli/cmd_explain.py +0 -0
  29. {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/cli/cmd_framework_nodes.py +0 -0
  30. {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/cli/cmd_hooks.py +0 -0
  31. {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/cli/cmd_mcp.py +0 -0
  32. {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/cli/cmd_model.py +0 -0
  33. {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/cli/cmd_query.py +0 -0
  34. {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/cli/cmd_quilt.py +0 -0
  35. {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/cli/cmd_viz.py +0 -0
  36. {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/cli/main.py +0 -0
  37. {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/cli/options.py +0 -0
  38. {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/config.py +0 -0
  39. {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/explain.py +0 -0
  40. {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/graph.py +0 -0
  41. {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/graph_html.py +0 -0
  42. {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/index.py +0 -0
  43. {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/kg.py +0 -0
  44. {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/layout3d.py +0 -0
  45. {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/module/__init__.py +0 -0
  46. {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/module/base.py +0 -0
  47. {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/module/extractor.py +0 -0
  48. {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/module/types.py +0 -0
  49. {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/pycodekg.py +0 -0
  50. {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/ranking/__init__.py +0 -0
  51. {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/ranking/cli_rank.py +0 -0
  52. {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/ranking/coderank.py +0 -0
  53. {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/render.py +0 -0
  54. {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/report.py +0 -0
  55. {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/resolution.py +0 -0
  56. {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/scene3d.py +0 -0
  57. {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/sql/004_add_centrality_table.sql +0 -0
  58. {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/store.py +0 -0
  59. {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/theme.py +0 -0
  60. {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/utils.py +0 -0
  61. {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/visitor.py +0 -0
  62. {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/viz3d.py +0 -0
  63. {pycode_kg-0.25.1 → pycode_kg-0.27.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.27.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
@@ -21,8 +21,8 @@ Provides-Extra: viz3d
21
21
  Requires-Dist: PyQt5 (>=5.15.0) ; extra == "all"
22
22
  Requires-Dist: PyQt5 (>=5.15.0) ; extra == "viz3d"
23
23
  Requires-Dist: click (>=8.1.0,<9)
24
- Requires-Dist: kgmodule-utils[semantic,viz3d] (>=0.19.0)
25
- Requires-Dist: kgmodule-utils[viz3d-qt,viz3d-render] (>=0.18.1) ; extra == "viz3d"
24
+ Requires-Dist: kgmodule-utils[semantic,viz3d] (>=0.20.0)
25
+ Requires-Dist: kgmodule-utils[viz3d-qt,viz3d-render] (>=0.20.0) ; extra == "viz3d"
26
26
  Requires-Dist: markdown (>=3.6) ; extra == "all"
27
27
  Requires-Dist: markdown (>=3.6) ; extra == "viz3d"
28
28
  Requires-Dist: mcp (>=1.0.0,<2)
@@ -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.27.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.27.0.md](docs/analysis_v0.27.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.27.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.27.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.27.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.27.0.md](docs/analysis_v0.27.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.27.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.27.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.27.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"
@@ -155,7 +155,21 @@ dependencies = [
155
155
  # this repo's visitor writes as of 0.24.1 -- against 0.18.0 the stubs are
156
156
  # tagged correctly but the tag is silently ignored, so resolution stays on
157
157
  # the old untyped trailing-name match with no error to say so.
158
- "kgmodule-utils[semantic,viz3d]>=0.19.0",
158
+ #
159
+ # 0.19.1 because SnapshotManager.load_snapshot back-fills a missing
160
+ # vs_previous through _compute_delta_from_metrics as of that release.
161
+ # Against 0.19.0 the backfill uses a hardcoded nodes/edges dict, so
162
+ # coverage_delta and critical_issues_delta read as zero in `snapshot show`
163
+ # for every snapshot whose deltas were not persisted at capture -- which is
164
+ # the normal path, not a legacy one.
165
+ #
166
+ # 0.20.0 is a hard requirement, not a preference: snapshots.py has no
167
+ # __init__, capture or diff_snapshots of its own any more. It configures
168
+ # the base through the package_name and dict_metric_deltas class attributes
169
+ # and the _domain_metrics() hook, none of which exist before 0.20.0.
170
+ # Against 0.19.x the manager silently reports itself as "kg-utils" and
171
+ # drops module_node_counts entirely.
172
+ "kgmodule-utils[semantic,viz3d]>=0.20.0",
159
173
  ]
160
174
 
161
175
  [project.optional-dependencies]
@@ -180,7 +194,7 @@ viz3d = [
180
194
  # the four-step build/render/write/cast itself. It subsumes [viz3d-render]
181
195
  # (it declares pyvista too), but both are named so the dependency reads as
182
196
  # what it is rather than as a side effect.
183
- "kgmodule-utils[viz3d-render,viz3d-qt]>=0.18.1",
197
+ "kgmodule-utils[viz3d-render,viz3d-qt]>=0.20.0",
184
198
  "pyvistaqt>=0.11.0",
185
199
  "trame-vtk>=2.0.0",
186
200
  # Looking Glass quilts. 0.4.0 supplies depth_report() and widened
@@ -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.27.0"
40
40
  __author__ = "Eric G. Suchanek, PhD"
41
41
 
42
42
  # Low-level primitives (locked v0 contract)
@@ -277,7 +277,7 @@ def init(
277
277
  version=version,
278
278
  branch=branch,
279
279
  graph_stats_dict=stats,
280
- coverage=coverage,
280
+ docstring_coverage=coverage,
281
281
  coverage_documented=coverage_documented,
282
282
  coverage_total=coverage_total,
283
283
  critical_issues=critical_issues,
@@ -19,16 +19,49 @@ import json
19
19
  from pathlib import Path
20
20
 
21
21
  import click
22
+ from click.core import ParameterSource
22
23
 
23
24
  from pycode_kg.cli.main import cli
24
25
  from pycode_kg.cli.options import sqlite_option
25
26
  from pycode_kg.kg import PyCodeKG
26
27
  from pycode_kg.pycodekg import DEFAULT_MODEL
27
28
  from pycode_kg.pycodekg_thorough_analysis import PyCodeKGAnalyzer
28
- from pycode_kg.snapshots import SnapshotManager
29
+ from pycode_kg.snapshots import (
30
+ SnapshotDelta,
31
+ SnapshotManager,
32
+ delta_from_dict,
33
+ metrics_from_dict,
34
+ )
29
35
  from pycode_kg.store import GraphStore
30
36
 
31
37
 
38
+ def _graph_for_repo(sqlite: str, repo_root: Path) -> Path:
39
+ """Resolve the graph path, anchoring the default to ``--repo``.
40
+
41
+ ``--sqlite`` defaults to the relative ``.pycodekg/graph.sqlite``, which
42
+ click resolves against the *current working directory*, while
43
+ ``--repo`` decides where the snapshot is filed. Left alone, the two
44
+ disagree: ``pycodekg snapshot save 1.0.0 --repo /other/project`` run from
45
+ inside this repo reads *this* repo's graph and writes the result into
46
+ */other/project*'s snapshots directory -- a snapshot whose metrics belong
47
+ to one project and whose provenance claims another, with no error.
48
+
49
+ An explicitly passed ``--sqlite`` is always honoured as given; only the
50
+ default is re-anchored. The other commands sharing ``sqlite_option``
51
+ (``query``, ``explain``) take no ``--repo``, so cwd is the right base for
52
+ them and they are unaffected.
53
+
54
+ :param sqlite: The ``--sqlite`` value, explicit or default.
55
+ :param repo_root: The resolved ``--repo`` path.
56
+ :return: The graph database path to read.
57
+ """
58
+ ctx = click.get_current_context(silent=True)
59
+ source = ctx.get_parameter_source("sqlite") if ctx is not None else None
60
+ if source is not None and source is not ParameterSource.DEFAULT:
61
+ return Path(sqlite)
62
+ return repo_root / ".pycodekg" / "graph.sqlite"
63
+
64
+
32
65
  @cli.group("snapshot")
33
66
  def snapshot() -> None:
34
67
  """Manage temporal snapshots of PyCodeKG metrics."""
@@ -100,7 +133,7 @@ def save_snapshot(
100
133
  pycodekg snapshot save 0.5.1 --repo .
101
134
  """
102
135
  repo_root = Path(repo).resolve()
103
- db_path = Path(sqlite)
136
+ db_path = _graph_for_repo(sqlite, repo_root)
104
137
  snapshots_path = (
105
138
  Path(snapshots_dir).resolve() if snapshots_dir else (repo_root / ".pycodekg" / "snapshots")
106
139
  )
@@ -156,7 +189,7 @@ def save_snapshot(
156
189
  version=version,
157
190
  branch=branch,
158
191
  graph_stats_dict=stats,
159
- coverage=coverage,
192
+ docstring_coverage=coverage,
160
193
  coverage_documented=coverage_documented,
161
194
  coverage_total=coverage_total,
162
195
  critical_issues=critical_issues,
@@ -174,9 +207,10 @@ def save_snapshot(
174
207
  click.echo(f"OK Snapshot saved: {snapshot_file}")
175
208
  click.echo(f" Key: {snapshot_obj.key}")
176
209
  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%}")
210
+ saved_metrics = metrics_from_dict(snapshot_obj.metrics)
211
+ click.echo(f" Nodes: {saved_metrics.total_nodes}")
212
+ click.echo(f" Edges: {saved_metrics.total_edges}")
213
+ click.echo(f" Coverage: {saved_metrics.docstring_coverage:.1%}")
180
214
 
181
215
 
182
216
  @snapshot.command("list")
@@ -270,19 +304,20 @@ def show_snapshot(key: str, snapshots_dir: str | None) -> None:
270
304
  click.echo()
271
305
 
272
306
  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}")
307
+ metrics = metrics_from_dict(snapshot_obj.metrics)
308
+ click.echo(f" Total Nodes: {metrics.total_nodes}")
309
+ click.echo(f" Total Edges: {metrics.total_edges}")
310
+ click.echo(f" Meaningful Nodes: {metrics.meaningful_nodes}")
311
+ click.echo(f" Docstring Coverage: {metrics.docstring_coverage:.1%}")
312
+ click.echo(f" Critical Issues: {metrics.critical_issues}")
313
+ click.echo(f" Complexity Median: {metrics.complexity_median:.2f}")
279
314
  click.echo()
280
315
 
281
316
  click.echo("Node/Edge Breakdown:")
282
- for kind, count in sorted(snapshot_obj.metrics.node_counts.items()):
317
+ for kind, count in sorted(metrics.node_counts.items()):
283
318
  click.echo(f" {kind}: {count}")
284
319
  click.echo()
285
- for rel, count in sorted(snapshot_obj.metrics.edge_counts.items()):
320
+ for rel, count in sorted(metrics.edge_counts.items()):
286
321
  click.echo(f" {rel}: {count}")
287
322
  click.echo()
288
323
 
@@ -296,7 +331,7 @@ def show_snapshot(key: str, snapshots_dir: str | None) -> None:
296
331
 
297
332
  if snapshot_obj.vs_previous:
298
333
  click.echo("Delta vs. Previous:")
299
- delta = snapshot_obj.vs_previous
334
+ delta = delta_from_dict(snapshot_obj.vs_previous) or SnapshotDelta()
300
335
  click.echo(f" Nodes: {delta.nodes:+d}")
301
336
  click.echo(f" Edges: {delta.edges:+d}")
302
337
  click.echo(f" Coverage: {delta.coverage_delta:+.1%}")
@@ -305,7 +340,7 @@ def show_snapshot(key: str, snapshots_dir: str | None) -> None:
305
340
 
306
341
  if snapshot_obj.vs_baseline:
307
342
  click.echo("Delta vs. Baseline:")
308
- delta = snapshot_obj.vs_baseline
343
+ delta = delta_from_dict(snapshot_obj.vs_baseline) or SnapshotDelta()
309
344
  click.echo(f" Nodes: {delta.nodes:+d}")
310
345
  click.echo(f" Edges: {delta.edges:+d}")
311
346
  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,273 @@
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
+ collects the pycode-kg metric fields in ``_domain_metrics()``, 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 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
+ Snapshot, # noqa: F401 re-exported
55
+ SnapshotManifest, # noqa: F401 re-exported
56
+ )
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
+ # SnapshotManager — pycode-kg specialisation of the shared manager
170
+ # ---------------------------------------------------------------------------
171
+
172
+
173
+ class SnapshotManager(_BaseSnapshotManager):
174
+ """pycode-kg snapshot manager.
175
+
176
+ Subclasses the shared ``kg_utils.snapshots.SnapshotManager``. Three of the
177
+ four additions are class attributes rather than methods, because the base
178
+ supplies the behaviour and this module only names the domain values:
179
+
180
+ * ``package_name = "pycode-kg"`` for version detection.
181
+ * ``dict_metric_deltas`` naming ``module_node_counts``, which the base
182
+ turns into ``module_node_counts_delta`` in ``diff_snapshots``.
183
+ * ``_domain_metrics()`` collecting per-module node counts at capture time.
184
+ * ``_compute_delta_from_metrics`` extended with ``coverage_delta`` and
185
+ ``critical_issues_delta`` — the one genuinely domain-specific method.
186
+ * ``_collect_module_node_counts()`` — SQLite per-module node counts.
187
+
188
+ Everything else -- capture, saving, loading, listing, pruning, diffing, key
189
+ handling -- is inherited unchanged. Overriding those is what this module
190
+ used to do, and is what let the 0.25.0 snapshot key regression through.
191
+
192
+ Note that ``capture()`` takes the coverage fraction as
193
+ ``docstring_coverage``, the name it is stored under. Until 0.27.0 this
194
+ class overrode ``capture()`` to accept it as ``coverage`` and rename it on
195
+ the way through; that override is gone, and with it the signature-restating
196
+ pattern that let a ``key=`` go missing. ``coverage=`` still works and warns
197
+ -- see ``capture_aliases`` below.
198
+ """
199
+
200
+ #: Version detection reads this; the base uses it as the snapshot's ``tool``.
201
+ package_name = "pycode-kg"
202
+
203
+ #: ``diff_snapshots`` emits ``module_node_counts_delta`` from this, holding
204
+ #: only the modules whose node count actually changed.
205
+ dict_metric_deltas = ("module_node_counts",)
206
+
207
+ #: Until 0.27.0 this class overrode ``capture()`` to accept the docstring
208
+ #: coverage fraction as ``coverage`` and rename it on the way through. The
209
+ #: override is gone, and the stored name is the only name. Without this
210
+ #: entry a caller still passing ``coverage=`` would get no error: the base
211
+ #: ``**extra_metrics`` would record a ``coverage`` metric nobody reads and
212
+ #: leave ``docstring_coverage`` absent.
213
+ capture_aliases = {"coverage": "docstring_coverage"}
214
+
215
+ # ------------------------------------------------------------------
216
+ # Capture-time metrics collected by this module
217
+ # ------------------------------------------------------------------
218
+
219
+ def _domain_metrics(self, stats: dict[str, Any]) -> dict[str, Any]:
220
+ """Collect the metrics this module gathers for itself.
221
+
222
+ Called by the inherited ``capture()``. Overriding this rather than
223
+ ``capture()`` is deliberate: a ``capture()`` override has to restate the
224
+ base signature, and restating it is what let ``key=`` fall into
225
+ ``**extra_metrics`` and ship 0.25.0 with every snapshot keyed on a tree
226
+ hash.
227
+
228
+ :param stats: Graph stats passed to ``capture()``; unused here, the
229
+ counts come from SQLite.
230
+ :return: ``{"module_node_counts": {module_path: node_count}}``, empty
231
+ if no SQLite graph is configured.
232
+ """
233
+ return {"module_node_counts": self._collect_module_node_counts()}
234
+
235
+ # ------------------------------------------------------------------
236
+ # Delta computation — adds coverage_delta and critical_issues_delta
237
+ # ------------------------------------------------------------------
238
+
239
+ def _compute_delta_from_metrics(
240
+ self, new_m: dict[str, Any], old_m: dict[str, Any]
241
+ ) -> dict[str, Any]:
242
+ """Compute delta dict including pycode-kg specific fields."""
243
+ return {
244
+ "nodes": new_m.get("total_nodes", 0) - old_m.get("total_nodes", 0),
245
+ "edges": new_m.get("total_edges", 0) - old_m.get("total_edges", 0),
246
+ "coverage_delta": (
247
+ new_m.get("docstring_coverage", 0.0) - old_m.get("docstring_coverage", 0.0)
248
+ ),
249
+ "critical_issues_delta": (
250
+ new_m.get("critical_issues", 0) - old_m.get("critical_issues", 0)
251
+ ),
252
+ }
253
+
254
+ # ------------------------------------------------------------------
255
+ # SQLite per-module node counts
256
+ # ------------------------------------------------------------------
257
+
258
+ def _collect_module_node_counts(self) -> dict[str, int]:
259
+ """Query SQLite for per-module node counts.
260
+
261
+ :return: Dict mapping ``module_path`` to node count, or ``{}`` if the
262
+ database is unavailable or the query fails.
263
+ """
264
+ if not self.db_path or not self.db_path.exists():
265
+ return {}
266
+ try:
267
+ with sqlite3.connect(self.db_path) as conn:
268
+ rows = conn.execute(
269
+ "SELECT module_path, COUNT(*) FROM nodes GROUP BY module_path"
270
+ ).fetchall()
271
+ return {row[0]: row[1] for row in rows if row[0]}
272
+ except sqlite3.Error:
273
+ return {}