pycode-kg 0.24.0__tar.gz → 0.25.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.24.0 → pycode_kg-0.25.0}/PKG-INFO +8 -8
- {pycode_kg-0.24.0 → pycode_kg-0.25.0}/README.md +5 -5
- {pycode_kg-0.24.0 → pycode_kg-0.25.0}/pyproject.toml +8 -3
- {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/__init__.py +1 -1
- {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/architecture.py +2 -2
- {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/cli/cmd_snapshot.py +24 -5
- {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/module/extractor.py +1 -0
- {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/pycodekg.py +103 -0
- {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/pycodekg_thorough_analysis.py +92 -8
- {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/report.py +14 -0
- {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/snapshots.py +15 -15
- {pycode_kg-0.24.0 → pycode_kg-0.25.0}/LICENSE +0 -0
- {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/.DS_Store +0 -0
- {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/__main__.py +0 -0
- {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/analysis/__init__.py +0 -0
- {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/analysis/bridge.py +0 -0
- {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/analysis/centrality.py +0 -0
- {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/analysis/framework_detector.py +0 -0
- {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/app.py +0 -0
- {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/build_pycodekg_sqlite.py +0 -0
- {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/cli/__init__.py +0 -0
- {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/cli/cmd_analyze.py +0 -0
- {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/cli/cmd_architecture.py +0 -0
- {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/cli/cmd_bridges.py +0 -0
- {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/cli/cmd_build.py +0 -0
- {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/cli/cmd_build_full.py +0 -0
- {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/cli/cmd_centrality.py +0 -0
- {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/cli/cmd_explain.py +0 -0
- {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/cli/cmd_framework_nodes.py +0 -0
- {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/cli/cmd_hooks.py +0 -0
- {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/cli/cmd_init.py +0 -0
- {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/cli/cmd_mcp.py +0 -0
- {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/cli/cmd_model.py +0 -0
- {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/cli/cmd_query.py +0 -0
- {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/cli/cmd_quilt.py +0 -0
- {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/cli/cmd_viz.py +0 -0
- {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/cli/main.py +0 -0
- {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/cli/options.py +0 -0
- {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/config.py +0 -0
- {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/explain.py +0 -0
- {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/graph.py +0 -0
- {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/graph_html.py +0 -0
- {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/index.py +0 -0
- {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/kg.py +0 -0
- {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/layout3d.py +0 -0
- {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/mcp_server.py +0 -0
- {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/module/__init__.py +0 -0
- {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/module/base.py +0 -0
- {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/module/types.py +0 -0
- {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/ranking/__init__.py +0 -0
- {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/ranking/cli_rank.py +0 -0
- {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/ranking/coderank.py +0 -0
- {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/render.py +0 -0
- {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/resolution.py +0 -0
- {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/scene3d.py +0 -0
- {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/sql/004_add_centrality_table.sql +0 -0
- {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/store.py +0 -0
- {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/theme.py +0 -0
- {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/utils.py +0 -0
- {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/visitor.py +0 -0
- {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/viz3d.py +0 -0
- {pycode_kg-0.24.0 → pycode_kg-0.25.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.25.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.18.
|
|
24
|
+
Requires-Dist: kgmodule-utils[semantic,viz3d] (>=0.19.0)
|
|
25
|
+
Requires-Dist: kgmodule-utils[viz3d-qt,viz3d-render] (>=0.18.1) ; 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.25.0.md](docs/analysis_v0.25.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.25.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.25.0},
|
|
292
292
|
year = {2026},
|
|
293
293
|
publisher = {Flux-Frontiers},
|
|
294
294
|
url = {https://github.com/Flux-Frontiers/pycode_kg},
|
|
@@ -312,5 +312,5 @@ If you use PyCodeKG in your research or project, please cite it:
|
|
|
312
312
|
|
|
313
313
|
---
|
|
314
314
|
|
|
315
|
-
*Built for Python developers and AI agents that work alongside them — egs
|
|
315
|
+
*Built for Python developers and AI agents that work alongside them — egs*
|
|
316
316
|
|
|
@@ -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.25.0.md](docs/analysis_v0.25.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.25.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.25.0},
|
|
237
237
|
year = {2026},
|
|
238
238
|
publisher = {Flux-Frontiers},
|
|
239
239
|
url = {https://github.com/Flux-Frontiers/pycode_kg},
|
|
@@ -257,4 +257,4 @@ If you use PyCodeKG in your research or project, please cite it:
|
|
|
257
257
|
|
|
258
258
|
---
|
|
259
259
|
|
|
260
|
-
*Built for Python developers and AI agents that work alongside them — egs
|
|
260
|
+
*Built for Python developers and AI agents that work alongside them — egs*
|
|
@@ -70,7 +70,7 @@ torch = [
|
|
|
70
70
|
# ---------------------------------------------------------------------------
|
|
71
71
|
[project]
|
|
72
72
|
name = "pycode-kg"
|
|
73
|
-
version = "0.
|
|
73
|
+
version = "0.25.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"
|
|
@@ -150,7 +150,12 @@ dependencies = [
|
|
|
150
150
|
# repo's own rule is one floor per package — a lower floor here would leave
|
|
151
151
|
# a reader working out which constraint actually binds. Nothing in the core
|
|
152
152
|
# install needs 0.15.0 functionally; it is a superset of 0.14.0.
|
|
153
|
-
|
|
153
|
+
#
|
|
154
|
+
# 0.18.1 because resolve_symbols() only reads the receiver_class metadata
|
|
155
|
+
# this repo's visitor writes as of 0.24.1 -- against 0.18.0 the stubs are
|
|
156
|
+
# tagged correctly but the tag is silently ignored, so resolution stays on
|
|
157
|
+
# the old untyped trailing-name match with no error to say so.
|
|
158
|
+
"kgmodule-utils[semantic,viz3d]>=0.19.0",
|
|
154
159
|
]
|
|
155
160
|
|
|
156
161
|
[project.optional-dependencies]
|
|
@@ -175,7 +180,7 @@ viz3d = [
|
|
|
175
180
|
# the four-step build/render/write/cast itself. It subsumes [viz3d-render]
|
|
176
181
|
# (it declares pyvista too), but both are named so the dependency reads as
|
|
177
182
|
# what it is rather than as a side effect.
|
|
178
|
-
"kgmodule-utils[viz3d-render,viz3d-qt]>=0.18.
|
|
183
|
+
"kgmodule-utils[viz3d-render,viz3d-qt]>=0.18.1",
|
|
179
184
|
"pyvistaqt>=0.11.0",
|
|
180
185
|
"trame-vtk>=2.0.0",
|
|
181
186
|
# Looking Glass quilts. 0.4.0 supplies depth_report() and widened
|
|
@@ -610,11 +610,11 @@ class ArchitectureAnalyzer:
|
|
|
610
610
|
readme = self.repo_root / "README.md"
|
|
611
611
|
if readme.exists():
|
|
612
612
|
try:
|
|
613
|
-
with open(readme) as f:
|
|
613
|
+
with open(readme, encoding="utf-8") as f:
|
|
614
614
|
for line in f:
|
|
615
615
|
if line.startswith("#"):
|
|
616
616
|
return line.lstrip("#").strip()
|
|
617
|
-
except OSError:
|
|
617
|
+
except (OSError, UnicodeDecodeError):
|
|
618
618
|
pass
|
|
619
619
|
return "PyCodeKG Architecture"
|
|
620
620
|
|
|
@@ -61,7 +61,13 @@ def snapshot() -> None:
|
|
|
61
61
|
"--tree-hash",
|
|
62
62
|
default="",
|
|
63
63
|
type=str,
|
|
64
|
-
help="Git tree hash; auto-detected if not provided.",
|
|
64
|
+
help="Git tree hash, recorded as provenance; auto-detected if not provided.",
|
|
65
|
+
)
|
|
66
|
+
@click.option(
|
|
67
|
+
"--subject",
|
|
68
|
+
default="",
|
|
69
|
+
type=str,
|
|
70
|
+
help="What was measured, e.g. 'repo:pycode-kg' or 'corpus:pepys'.",
|
|
65
71
|
)
|
|
66
72
|
def save_snapshot(
|
|
67
73
|
version: str | None,
|
|
@@ -70,16 +76,25 @@ def save_snapshot(
|
|
|
70
76
|
snapshots_dir: str | None,
|
|
71
77
|
branch: str | None,
|
|
72
78
|
tree_hash: str,
|
|
79
|
+
subject: str,
|
|
73
80
|
) -> None:
|
|
74
81
|
"""
|
|
75
82
|
Capture current PyCodeKG metrics and save as a temporal snapshot.
|
|
76
83
|
|
|
77
84
|
Reads graph statistics, docstring coverage, and complexity metrics from
|
|
78
|
-
the SQLite graph, then saves a snapshot
|
|
79
|
-
|
|
85
|
+
the SQLite graph, then saves a snapshot keyed on VERSION.
|
|
86
|
+
|
|
87
|
+
**Pass VERSION explicitly at release time.** An omitted VERSION is
|
|
88
|
+
auto-detected from the installed pycode-kg package, which names the
|
|
89
|
+
measuring tool rather than the repo being measured -- in any repo other
|
|
90
|
+
than this one that is the wrong number, so it is recorded as the version
|
|
91
|
+
but never used as the key. Omitting it keys the snapshot on a UTC
|
|
92
|
+
timestamp instead, which is the right answer for a corpus.
|
|
80
93
|
|
|
81
|
-
Snapshots are stored in .pycodekg/snapshots/{
|
|
82
|
-
manifest.json tracking all snapshots and their metrics.
|
|
94
|
+
Snapshots are stored in .pycodekg/snapshots/{key}.json, with a
|
|
95
|
+
manifest.json tracking all snapshots and their metrics. The git tree hash
|
|
96
|
+
is recorded as provenance and is no longer the key: it is read before
|
|
97
|
+
`git add` stages the snapshot, so it names a tree that is never committed.
|
|
83
98
|
|
|
84
99
|
Example:
|
|
85
100
|
pycodekg snapshot save 0.5.1 --repo .
|
|
@@ -149,6 +164,10 @@ def save_snapshot(
|
|
|
149
164
|
hotspots=hotspots,
|
|
150
165
|
issues=issue_strings,
|
|
151
166
|
tree_hash=tree_hash,
|
|
167
|
+
# An explicit VERSION is a release tag and becomes the key. An
|
|
168
|
+
# auto-detected one is the measuring tool's version and must not be.
|
|
169
|
+
key=version or "",
|
|
170
|
+
subject=subject or "",
|
|
152
171
|
)
|
|
153
172
|
|
|
154
173
|
snapshot_file = snap_mgr.save_snapshot(snapshot_obj)
|
|
@@ -61,6 +61,11 @@ class Node:
|
|
|
61
61
|
:param lineno: Starting line number
|
|
62
62
|
:param end_lineno: Ending line number (if available)
|
|
63
63
|
:param docstring: Extracted docstring (may be None)
|
|
64
|
+
:param receiver_class: For a dotted call stub (``kind == "symbol"``), the
|
|
65
|
+
class name resolved from the receiver's parameter or local variable
|
|
66
|
+
annotation, if any — e.g. ``"Plotter"`` for a call site
|
|
67
|
+
``plotter.render()`` where ``plotter: pv.Plotter``. ``None`` when the
|
|
68
|
+
receiver has no known annotation, or the node isn't a call stub.
|
|
64
69
|
"""
|
|
65
70
|
|
|
66
71
|
id: str
|
|
@@ -71,6 +76,7 @@ class Node:
|
|
|
71
76
|
lineno: int | None
|
|
72
77
|
end_lineno: int | None
|
|
73
78
|
docstring: str | None
|
|
79
|
+
receiver_class: str | None = None
|
|
74
80
|
|
|
75
81
|
|
|
76
82
|
@dataclass(frozen=True)
|
|
@@ -211,6 +217,91 @@ def _owner_id(
|
|
|
211
217
|
return module_locals[module].get(fn.name)
|
|
212
218
|
|
|
213
219
|
|
|
220
|
+
def _own_scope_nodes(node: ast.AST) -> Iterable[ast.AST]:
|
|
221
|
+
"""Yield descendants of *node*, not descending into nested function/class scopes.
|
|
222
|
+
|
|
223
|
+
A nested ``def``/``class``/``lambda`` introduces its own name binding for
|
|
224
|
+
any local it declares, so an ``AnnAssign`` inside one does not describe a
|
|
225
|
+
name in *node*'s own scope.
|
|
226
|
+
|
|
227
|
+
:param node: Root AST node (typically a function body).
|
|
228
|
+
:return: Iterator over descendants in *node*'s own scope.
|
|
229
|
+
"""
|
|
230
|
+
for child in ast.iter_child_nodes(node):
|
|
231
|
+
if isinstance(child, ast.FunctionDef | ast.AsyncFunctionDef | ast.Lambda | ast.ClassDef):
|
|
232
|
+
continue
|
|
233
|
+
yield child
|
|
234
|
+
yield from _own_scope_nodes(child)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _annotation_class_name(expr: ast.AST) -> str | None:
|
|
238
|
+
"""Extract a class name from a type annotation, unwrapping ``Optional`` shapes.
|
|
239
|
+
|
|
240
|
+
``X | None`` (PEP 604) and ``Optional[X]``/``typing.Optional[X]`` are the
|
|
241
|
+
common "this local starts as ``None``, gets assigned later" shape --
|
|
242
|
+
exactly the pattern a loop-accumulated match object or lazily-built
|
|
243
|
+
instance tends to carry. Neither is a class name on its own, so both are
|
|
244
|
+
unwrapped to the non-``None`` member before falling back to
|
|
245
|
+
:func:`expr_to_name`, which does not know about either.
|
|
246
|
+
|
|
247
|
+
:param expr: The annotation expression.
|
|
248
|
+
:return: Dotted class name, or ``None`` if it can't be determined.
|
|
249
|
+
"""
|
|
250
|
+
if isinstance(expr, ast.BinOp) and isinstance(expr.op, ast.BitOr):
|
|
251
|
+
for side in (expr.left, expr.right):
|
|
252
|
+
if isinstance(side, ast.Constant) and side.value is None:
|
|
253
|
+
continue
|
|
254
|
+
resolved = _annotation_class_name(side)
|
|
255
|
+
if resolved:
|
|
256
|
+
return resolved
|
|
257
|
+
return None
|
|
258
|
+
|
|
259
|
+
if isinstance(expr, ast.Subscript):
|
|
260
|
+
base = expr_to_name(expr.value)
|
|
261
|
+
if base and base.rsplit(".", 1)[-1] == "Optional":
|
|
262
|
+
return _annotation_class_name(expr.slice)
|
|
263
|
+
return expr_to_name(expr)
|
|
264
|
+
|
|
265
|
+
if isinstance(expr, ast.Constant) and expr.value is None:
|
|
266
|
+
return None
|
|
267
|
+
|
|
268
|
+
return expr_to_name(expr)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _receiver_annotation(
|
|
272
|
+
fn: ast.FunctionDef | ast.AsyncFunctionDef, receiver_name: str
|
|
273
|
+
) -> str | None:
|
|
274
|
+
"""Resolve a call receiver's class name from its parameter or local annotation.
|
|
275
|
+
|
|
276
|
+
Looks at *fn*'s own parameters, then any ``AnnAssign`` in *fn*'s own body
|
|
277
|
+
(see :func:`_own_scope_nodes`), for a name matching *receiver_name*. The
|
|
278
|
+
first annotation found wins -- no reassignment or narrowing is tracked,
|
|
279
|
+
matching the conservative stance the rest of this module takes toward
|
|
280
|
+
ambiguity.
|
|
281
|
+
|
|
282
|
+
:param fn: Enclosing function or async function definition.
|
|
283
|
+
:param receiver_name: Bare name of the call's receiver, e.g. ``"plotter"``.
|
|
284
|
+
:return: The annotation's last dotted segment (e.g. ``"Plotter"`` for a
|
|
285
|
+
``pv.Plotter`` annotation, or ``"Match"`` for ``re.Match | None``), or
|
|
286
|
+
``None`` if *receiver_name* has no known annotation.
|
|
287
|
+
"""
|
|
288
|
+
for a in (*fn.args.posonlyargs, *fn.args.args, *fn.args.kwonlyargs):
|
|
289
|
+
if a.arg == receiver_name and a.annotation is not None:
|
|
290
|
+
cls = _annotation_class_name(a.annotation)
|
|
291
|
+
return cls.rsplit(".", 1)[-1] if cls else None
|
|
292
|
+
|
|
293
|
+
for stmt in _own_scope_nodes(fn):
|
|
294
|
+
if (
|
|
295
|
+
isinstance(stmt, ast.AnnAssign)
|
|
296
|
+
and isinstance(stmt.target, ast.Name)
|
|
297
|
+
and stmt.target.id == receiver_name
|
|
298
|
+
):
|
|
299
|
+
cls = _annotation_class_name(stmt.annotation)
|
|
300
|
+
return cls.rsplit(".", 1)[-1] if cls else None
|
|
301
|
+
|
|
302
|
+
return None
|
|
303
|
+
|
|
304
|
+
|
|
214
305
|
# ============================================================================
|
|
215
306
|
# Core extraction logic
|
|
216
307
|
# ============================================================================
|
|
@@ -466,6 +557,8 @@ def extract_repo(
|
|
|
466
557
|
if not callee:
|
|
467
558
|
continue
|
|
468
559
|
|
|
560
|
+
receiver_class: str | None = None
|
|
561
|
+
|
|
469
562
|
# resolution rules (LOCKED)
|
|
470
563
|
if callee in module_locals[module]:
|
|
471
564
|
dst_id = module_locals[module][callee]
|
|
@@ -480,6 +573,15 @@ def extract_repo(
|
|
|
480
573
|
dst_id = module_class_methods[module].get(meth) or f"sym:{callee}"
|
|
481
574
|
else:
|
|
482
575
|
dst_id = f"sym:{callee}"
|
|
576
|
+
# A plain local variable/parameter receiver, not self/cls and
|
|
577
|
+
# not a module-level import alias -- the one case an
|
|
578
|
+
# annotation can disambiguate. self./cls. never reach here.
|
|
579
|
+
if (
|
|
580
|
+
isinstance(n.func, ast.Attribute)
|
|
581
|
+
and isinstance(n.func.value, ast.Name)
|
|
582
|
+
and n.func.value.id not in ("self", "cls")
|
|
583
|
+
):
|
|
584
|
+
receiver_class = _receiver_annotation(fn, n.func.value.id)
|
|
483
585
|
|
|
484
586
|
if dst_id.startswith("sym:"):
|
|
485
587
|
nodes.setdefault(
|
|
@@ -493,6 +595,7 @@ def extract_repo(
|
|
|
493
595
|
None,
|
|
494
596
|
None,
|
|
495
597
|
None,
|
|
598
|
+
receiver_class,
|
|
496
599
|
),
|
|
497
600
|
)
|
|
498
601
|
|
|
@@ -43,10 +43,12 @@ from collections.abc import Callable
|
|
|
43
43
|
from dataclasses import asdict, dataclass
|
|
44
44
|
from pathlib import Path
|
|
45
45
|
|
|
46
|
+
from rich.columns import Columns
|
|
46
47
|
from rich.console import Console
|
|
47
48
|
from rich.panel import Panel
|
|
48
49
|
from rich.table import Table
|
|
49
50
|
|
|
51
|
+
from pycode_kg.pycodekg import iter_python_files
|
|
50
52
|
from pycode_kg.report import render_markdown
|
|
51
53
|
from pycode_kg.resolution import BUILTIN_METHOD_NAMES
|
|
52
54
|
from pycode_kg.snapshots import SnapshotManager
|
|
@@ -160,6 +162,33 @@ _PROTOCOL_ATTRS_FALLBACK = frozenset(
|
|
|
160
162
|
)
|
|
161
163
|
_protocol_attrs_cache: frozenset[str] | None = None
|
|
162
164
|
|
|
165
|
+
# Console labels for the scalar baseline metrics, in display order. Keys
|
|
166
|
+
# absent here still render (under their raw name) so a new stat can never be
|
|
167
|
+
# dropped silently.
|
|
168
|
+
_STAT_LABELS = {
|
|
169
|
+
"total_nodes": "Total nodes",
|
|
170
|
+
"meaningful_nodes": "Meaningful nodes",
|
|
171
|
+
"total_edges": "Total edges",
|
|
172
|
+
"docstring_coverage": "Docstring coverage",
|
|
173
|
+
"snapshot_count": "Snapshots",
|
|
174
|
+
"vector_backend": "Vector backend",
|
|
175
|
+
"db_path": "Database",
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
# Rendered as their own breakout tables instead: the two ``*_counts`` dicts
|
|
179
|
+
# print as raw reprs, and the per-kind ``*_count`` scalars only repeat what
|
|
180
|
+
# ``node_counts`` already says.
|
|
181
|
+
_STAT_BREAKOUT = frozenset(
|
|
182
|
+
{
|
|
183
|
+
"node_counts",
|
|
184
|
+
"edge_counts",
|
|
185
|
+
"module_count",
|
|
186
|
+
"class_count",
|
|
187
|
+
"function_count",
|
|
188
|
+
"method_count",
|
|
189
|
+
}
|
|
190
|
+
)
|
|
191
|
+
|
|
163
192
|
|
|
164
193
|
def _protocol_attr_names() -> frozenset[str]:
|
|
165
194
|
"""Attribute names of the framework base classes the KG SDK dispatches on.
|
|
@@ -229,10 +258,11 @@ def _declared_export_names(repo_root: Path) -> set[str]:
|
|
|
229
258
|
import ast as _ast # noqa: PLC0415
|
|
230
259
|
|
|
231
260
|
names: set[str] = set()
|
|
232
|
-
for py_path in repo_root
|
|
261
|
+
for py_path in iter_python_files(repo_root):
|
|
233
262
|
try:
|
|
234
263
|
tree = _ast.parse(py_path.read_text(encoding="utf-8"))
|
|
235
|
-
except (OSError, SyntaxError):
|
|
264
|
+
except (OSError, SyntaxError, UnicodeDecodeError) as e:
|
|
265
|
+
logger.debug(f"Skipping {py_path} while scanning for declared exports: {e}")
|
|
236
266
|
continue
|
|
237
267
|
for node in _ast.walk(tree):
|
|
238
268
|
if (
|
|
@@ -729,9 +759,11 @@ class PyCodeKGAnalyzer:
|
|
|
729
759
|
text: str | None = None
|
|
730
760
|
repo_root = getattr(self.kg, "repo_root", None)
|
|
731
761
|
if repo_root:
|
|
762
|
+
src_path = Path(repo_root) / module_path
|
|
732
763
|
try:
|
|
733
|
-
text =
|
|
734
|
-
except OSError:
|
|
764
|
+
text = src_path.read_text(encoding="utf-8")
|
|
765
|
+
except (OSError, UnicodeDecodeError) as e:
|
|
766
|
+
logger.debug(f"Could not read {src_path}: {e}")
|
|
735
767
|
text = None
|
|
736
768
|
self._source_cache[module_path] = text
|
|
737
769
|
return self._source_cache[module_path]
|
|
@@ -767,7 +799,8 @@ class PyCodeKGAnalyzer:
|
|
|
767
799
|
for path in sorted(tests_dir.rglob("*.py")):
|
|
768
800
|
try:
|
|
769
801
|
chunks.append(path.read_text(encoding="utf-8"))
|
|
770
|
-
except OSError:
|
|
802
|
+
except (OSError, UnicodeDecodeError) as e:
|
|
803
|
+
logger.debug(f"Skipping {path} while building tests corpus: {e}")
|
|
771
804
|
continue
|
|
772
805
|
return "\n".join(chunks)
|
|
773
806
|
|
|
@@ -2493,16 +2526,31 @@ class PyCodeKGAnalyzer:
|
|
|
2493
2526
|
self.console.print()
|
|
2494
2527
|
|
|
2495
2528
|
# Stats table
|
|
2496
|
-
stats_table = Table(title="Baseline Metrics", show_header=True)
|
|
2529
|
+
stats_table = Table(title="Baseline Metrics", show_header=True, title_style="bold")
|
|
2497
2530
|
stats_table.add_column("Metric", style="dim")
|
|
2498
2531
|
stats_table.add_column("Value")
|
|
2499
2532
|
|
|
2500
|
-
for
|
|
2501
|
-
|
|
2533
|
+
ordered = [k for k in _STAT_LABELS if k in self.stats]
|
|
2534
|
+
extra = [k for k in self.stats if k not in _STAT_LABELS and k not in _STAT_BREAKOUT]
|
|
2535
|
+
for key in (*ordered, *extra):
|
|
2536
|
+
stats_table.add_row(_STAT_LABELS.get(key, key), _format_stat(key, self.stats[key]))
|
|
2502
2537
|
|
|
2503
2538
|
self.console.print(stats_table)
|
|
2504
2539
|
self.console.print()
|
|
2505
2540
|
|
|
2541
|
+
# Node/edge distributions, side by side rather than as raw dict reprs
|
|
2542
|
+
breakouts = [
|
|
2543
|
+
_counts_table(title, label, counts)
|
|
2544
|
+
for title, label, counts in (
|
|
2545
|
+
("Nodes by Kind", "Kind", self.stats.get("node_counts") or {}),
|
|
2546
|
+
("Edges by Relation", "Relation", self.stats.get("edge_counts") or {}),
|
|
2547
|
+
)
|
|
2548
|
+
if counts
|
|
2549
|
+
]
|
|
2550
|
+
if breakouts:
|
|
2551
|
+
self.console.print(Columns(breakouts, padding=(0, 4)))
|
|
2552
|
+
self.console.print()
|
|
2553
|
+
|
|
2506
2554
|
# Most called functions
|
|
2507
2555
|
if self.function_metrics:
|
|
2508
2556
|
calls_table = Table(title="Most Called Functions (Fan-In)", show_header=True)
|
|
@@ -2537,6 +2585,42 @@ class PyCodeKGAnalyzer:
|
|
|
2537
2585
|
self.console.print()
|
|
2538
2586
|
|
|
2539
2587
|
|
|
2588
|
+
def _format_stat(key: str, value: object) -> str:
|
|
2589
|
+
"""Render one baseline-metric value for console display.
|
|
2590
|
+
|
|
2591
|
+
:param key: Stat key, used to recognize the ratio-valued metrics.
|
|
2592
|
+
:param value: Raw value from ``PyCodeKG.stats()``.
|
|
2593
|
+
:return: Display string -- percentage for coverage ratios, thousands
|
|
2594
|
+
separators for counts, ``str()`` otherwise.
|
|
2595
|
+
"""
|
|
2596
|
+
if key == "docstring_coverage" and isinstance(value, int | float):
|
|
2597
|
+
return f"{value:.1%}"
|
|
2598
|
+
if isinstance(value, bool):
|
|
2599
|
+
return str(value)
|
|
2600
|
+
if isinstance(value, int):
|
|
2601
|
+
return f"{value:,}"
|
|
2602
|
+
return str(value)
|
|
2603
|
+
|
|
2604
|
+
|
|
2605
|
+
def _counts_table(title: str, label: str, counts: dict[str, int]) -> Table:
|
|
2606
|
+
"""Build a breakdown table for a ``{name: count}`` stat, largest first.
|
|
2607
|
+
|
|
2608
|
+
``node_counts``/``edge_counts`` arrive as dicts; printing them through
|
|
2609
|
+
``str()`` dumps a raw Python repr that wraps across the value column.
|
|
2610
|
+
|
|
2611
|
+
:param title: Table title, e.g. ``"Nodes by Kind"``.
|
|
2612
|
+
:param label: Header for the name column, e.g. ``"Kind"``.
|
|
2613
|
+
:param counts: Mapping of name to count.
|
|
2614
|
+
:return: Rich table sorted by count descending, then name.
|
|
2615
|
+
"""
|
|
2616
|
+
table = Table(title=title, show_header=True, title_style="bold")
|
|
2617
|
+
table.add_column(label, style="cyan")
|
|
2618
|
+
table.add_column("Count", justify="right")
|
|
2619
|
+
for name, count in sorted(counts.items(), key=lambda kv: (-kv[1], kv[0])):
|
|
2620
|
+
table.add_row(str(name), f"{count:,}")
|
|
2621
|
+
return table
|
|
2622
|
+
|
|
2623
|
+
|
|
2540
2624
|
def _default_report_name(repo_root: Path) -> str:
|
|
2541
2625
|
"""Derive a timestamped default markdown report path under ``analysis/``.
|
|
2542
2626
|
|
|
@@ -21,6 +21,11 @@ from pathlib import Path
|
|
|
21
21
|
|
|
22
22
|
from pycode_kg.render import md_table
|
|
23
23
|
|
|
24
|
+
# The code relationships worth reporting. RESOLVES_TO is deliberately
|
|
25
|
+
# absent: it links a ``sym:`` stub to the definition it resolved to, which is
|
|
26
|
+
# the graph's own bookkeeping rather than a relationship between two pieces of
|
|
27
|
+
# code. Because it is excluded, this table does not sum to ``total_edges`` --
|
|
28
|
+
# the renderer states the difference rather than leaving it unaccounted for.
|
|
24
29
|
_EDGE_RELS = ("CALLS", "CONTAINS", "IMPORTS", "ATTR_ACCESS", "INHERITS")
|
|
25
30
|
|
|
26
31
|
# A single depth-3 chain carries no signal — show the section only once the
|
|
@@ -179,6 +184,15 @@ def render_markdown(analyzer, *, metadata: str = "", elapsed_seconds: float | No
|
|
|
179
184
|
[(rel, edge_counts.get(rel, 0)) for rel in _EDGE_RELS],
|
|
180
185
|
aligns="lr",
|
|
181
186
|
)
|
|
187
|
+
shown = sum(edge_counts.get(rel, 0) for rel in _EDGE_RELS)
|
|
188
|
+
excluded = stats.get("total_edges", shown) - shown
|
|
189
|
+
if excluded > 0:
|
|
190
|
+
out += [
|
|
191
|
+
"",
|
|
192
|
+
f"_Excludes {excluded:,} `RESOLVES_TO` edges: internal symbol-stub "
|
|
193
|
+
"resolutions, not relationships between two pieces of code. This "
|
|
194
|
+
"table therefore does not sum to Total Edges._",
|
|
195
|
+
]
|
|
182
196
|
rule()
|
|
183
197
|
|
|
184
198
|
# ── Fan-In Ranking ───────────────────────────────────────────────────
|
|
@@ -181,6 +181,12 @@ class Snapshot(_BaseSnapshot):
|
|
|
181
181
|
The underlying ``metrics``, ``vs_previous``, and ``vs_baseline`` fields
|
|
182
182
|
remain plain dicts on disk; the properties are view-only adapters.
|
|
183
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
|
+
|
|
184
190
|
Implementation note
|
|
185
191
|
-------------------
|
|
186
192
|
Python dataclass fields are stored in ``__dict__`` under their field name.
|
|
@@ -225,20 +231,6 @@ class Snapshot(_BaseSnapshot):
|
|
|
225
231
|
else:
|
|
226
232
|
self.__dict__["vs_baseline"] = value
|
|
227
233
|
|
|
228
|
-
def to_dict(self) -> dict[str, Any]:
|
|
229
|
-
"""Convert snapshot to a JSON-serializable dictionary."""
|
|
230
|
-
return {
|
|
231
|
-
"key": self.tree_hash,
|
|
232
|
-
"branch": self.branch,
|
|
233
|
-
"timestamp": self.timestamp,
|
|
234
|
-
"version": self.version,
|
|
235
|
-
"metrics": self.__dict__["metrics"],
|
|
236
|
-
"hotspots": self.hotspots,
|
|
237
|
-
"issues": self.issues,
|
|
238
|
-
"vs_previous": self.__dict__["vs_previous"],
|
|
239
|
-
"vs_baseline": self.__dict__["vs_baseline"],
|
|
240
|
-
}
|
|
241
|
-
|
|
242
234
|
@staticmethod
|
|
243
235
|
def from_dict(data: dict[str, Any]) -> Snapshot: # type: ignore[override]
|
|
244
236
|
"""Reconstruct a pycode-kg ``Snapshot`` from a dictionary."""
|
|
@@ -302,6 +294,8 @@ class SnapshotManager(_BaseSnapshotManager):
|
|
|
302
294
|
hotspots: list[dict[str, Any]] | None = None,
|
|
303
295
|
issues: list[str] | None = None,
|
|
304
296
|
tree_hash: str = "",
|
|
297
|
+
key: str = "",
|
|
298
|
+
subject: str = "",
|
|
305
299
|
) -> Snapshot:
|
|
306
300
|
"""Capture a pycode-kg snapshot.
|
|
307
301
|
|
|
@@ -324,7 +318,11 @@ class SnapshotManager(_BaseSnapshotManager):
|
|
|
324
318
|
:param complexity_median: Median fan-in across functions.
|
|
325
319
|
:param hotspots: Top hotspot entries.
|
|
326
320
|
:param issues: Issue description strings.
|
|
327
|
-
:param tree_hash: Git tree hash; auto-detected
|
|
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``.
|
|
328
326
|
:return: New :class:`Snapshot` instance (not yet persisted).
|
|
329
327
|
"""
|
|
330
328
|
module_node_counts = self._collect_module_node_counts()
|
|
@@ -334,6 +332,8 @@ class SnapshotManager(_BaseSnapshotManager):
|
|
|
334
332
|
branch=branch,
|
|
335
333
|
graph_stats_dict=graph_stats_dict,
|
|
336
334
|
tree_hash=tree_hash,
|
|
335
|
+
key=key,
|
|
336
|
+
subject=subject,
|
|
337
337
|
hotspots=hotspots,
|
|
338
338
|
issues=issues,
|
|
339
339
|
docstring_coverage=coverage,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|