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.
- {pycode_kg-0.25.1 → pycode_kg-0.27.0}/PKG-INFO +7 -7
- {pycode_kg-0.25.1 → pycode_kg-0.27.0}/README.md +4 -4
- {pycode_kg-0.25.1 → pycode_kg-0.27.0}/pyproject.toml +17 -3
- {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/__init__.py +1 -1
- {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/cli/cmd_init.py +1 -1
- {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/cli/cmd_snapshot.py +51 -16
- {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/mcp_server.py +1 -1
- {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/pycodekg_thorough_analysis.py +8 -1
- pycode_kg-0.27.0/src/pycode_kg/snapshots.py +273 -0
- pycode_kg-0.25.1/src/pycode_kg/snapshots.py +0 -535
- {pycode_kg-0.25.1 → pycode_kg-0.27.0}/LICENSE +0 -0
- {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/.DS_Store +0 -0
- {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/__main__.py +0 -0
- {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/analysis/__init__.py +0 -0
- {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/analysis/bridge.py +0 -0
- {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/analysis/centrality.py +0 -0
- {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/analysis/framework_detector.py +0 -0
- {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/app.py +0 -0
- {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/architecture.py +0 -0
- {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/build_pycodekg_sqlite.py +0 -0
- {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/cli/__init__.py +0 -0
- {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/cli/cmd_analyze.py +0 -0
- {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/cli/cmd_architecture.py +0 -0
- {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/cli/cmd_bridges.py +0 -0
- {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/cli/cmd_build.py +0 -0
- {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/cli/cmd_build_full.py +0 -0
- {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/cli/cmd_centrality.py +0 -0
- {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/cli/cmd_explain.py +0 -0
- {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/cli/cmd_framework_nodes.py +0 -0
- {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/cli/cmd_hooks.py +0 -0
- {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/cli/cmd_mcp.py +0 -0
- {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/cli/cmd_model.py +0 -0
- {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/cli/cmd_query.py +0 -0
- {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/cli/cmd_quilt.py +0 -0
- {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/cli/cmd_viz.py +0 -0
- {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/cli/main.py +0 -0
- {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/cli/options.py +0 -0
- {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/config.py +0 -0
- {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/explain.py +0 -0
- {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/graph.py +0 -0
- {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/graph_html.py +0 -0
- {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/index.py +0 -0
- {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/kg.py +0 -0
- {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/layout3d.py +0 -0
- {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/module/__init__.py +0 -0
- {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/module/base.py +0 -0
- {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/module/extractor.py +0 -0
- {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/module/types.py +0 -0
- {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/pycodekg.py +0 -0
- {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/ranking/__init__.py +0 -0
- {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/ranking/cli_rank.py +0 -0
- {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/ranking/coderank.py +0 -0
- {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/render.py +0 -0
- {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/report.py +0 -0
- {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/resolution.py +0 -0
- {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/scene3d.py +0 -0
- {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/sql/004_add_centrality_table.sql +0 -0
- {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/store.py +0 -0
- {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/theme.py +0 -0
- {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/utils.py +0 -0
- {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/visitor.py +0 -0
- {pycode_kg-0.25.1 → pycode_kg-0.27.0}/src/pycode_kg/viz3d.py +0 -0
- {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.
|
|
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.
|
|
25
|
-
Requires-Dist: kgmodule-utils[viz3d-qt,viz3d-render] (>=0.
|
|
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
|
[](https://www.python.org/)
|
|
62
62
|
[](https://www.elastic.co/licensing/elastic-license)
|
|
63
|
-
[](https://github.com/Flux-Frontiers/pycode_kg/releases)
|
|
64
64
|
[](https://github.com/Flux-Frontiers/pycode_kg/actions/workflows/ci.yml)
|
|
65
65
|
[](https://python-poetry.org/)
|
|
66
66
|
[](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.
|
|
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
|
[](https://zenodo.org/badge/latestdoi/1202379010)
|
|
284
284
|
|
|
285
|
-
> Suchanek, E. G. (2026). *PyCodeKG: A Knowledge Graph for Python Codebases* (Version 0.
|
|
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.
|
|
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
|
[](https://www.python.org/)
|
|
7
7
|
[](https://www.elastic.co/licensing/elastic-license)
|
|
8
|
-
[](https://github.com/Flux-Frontiers/pycode_kg/releases)
|
|
9
9
|
[](https://github.com/Flux-Frontiers/pycode_kg/actions/workflows/ci.yml)
|
|
10
10
|
[](https://python-poetry.org/)
|
|
11
11
|
[](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.
|
|
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
|
[](https://zenodo.org/badge/latestdoi/1202379010)
|
|
229
229
|
|
|
230
|
-
> Suchanek, E. G. (2026). *PyCodeKG: A Knowledge Graph for Python Codebases* (Version 0.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
|
@@ -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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
178
|
-
click.echo(f"
|
|
179
|
-
click.echo(f"
|
|
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
|
-
|
|
274
|
-
click.echo(f" Total
|
|
275
|
-
click.echo(f"
|
|
276
|
-
click.echo(f"
|
|
277
|
-
click.echo(f"
|
|
278
|
-
click.echo(f"
|
|
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(
|
|
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(
|
|
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
|
|
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 {}
|