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.
Files changed (62) hide show
  1. {pycode_kg-0.24.0 → pycode_kg-0.25.0}/PKG-INFO +8 -8
  2. {pycode_kg-0.24.0 → pycode_kg-0.25.0}/README.md +5 -5
  3. {pycode_kg-0.24.0 → pycode_kg-0.25.0}/pyproject.toml +8 -3
  4. {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/__init__.py +1 -1
  5. {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/architecture.py +2 -2
  6. {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/cli/cmd_snapshot.py +24 -5
  7. {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/module/extractor.py +1 -0
  8. {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/pycodekg.py +103 -0
  9. {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/pycodekg_thorough_analysis.py +92 -8
  10. {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/report.py +14 -0
  11. {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/snapshots.py +15 -15
  12. {pycode_kg-0.24.0 → pycode_kg-0.25.0}/LICENSE +0 -0
  13. {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/.DS_Store +0 -0
  14. {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/__main__.py +0 -0
  15. {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/analysis/__init__.py +0 -0
  16. {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/analysis/bridge.py +0 -0
  17. {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/analysis/centrality.py +0 -0
  18. {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/analysis/framework_detector.py +0 -0
  19. {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/app.py +0 -0
  20. {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/build_pycodekg_sqlite.py +0 -0
  21. {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/cli/__init__.py +0 -0
  22. {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/cli/cmd_analyze.py +0 -0
  23. {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/cli/cmd_architecture.py +0 -0
  24. {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/cli/cmd_bridges.py +0 -0
  25. {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/cli/cmd_build.py +0 -0
  26. {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/cli/cmd_build_full.py +0 -0
  27. {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/cli/cmd_centrality.py +0 -0
  28. {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/cli/cmd_explain.py +0 -0
  29. {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/cli/cmd_framework_nodes.py +0 -0
  30. {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/cli/cmd_hooks.py +0 -0
  31. {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/cli/cmd_init.py +0 -0
  32. {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/cli/cmd_mcp.py +0 -0
  33. {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/cli/cmd_model.py +0 -0
  34. {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/cli/cmd_query.py +0 -0
  35. {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/cli/cmd_quilt.py +0 -0
  36. {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/cli/cmd_viz.py +0 -0
  37. {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/cli/main.py +0 -0
  38. {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/cli/options.py +0 -0
  39. {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/config.py +0 -0
  40. {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/explain.py +0 -0
  41. {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/graph.py +0 -0
  42. {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/graph_html.py +0 -0
  43. {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/index.py +0 -0
  44. {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/kg.py +0 -0
  45. {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/layout3d.py +0 -0
  46. {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/mcp_server.py +0 -0
  47. {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/module/__init__.py +0 -0
  48. {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/module/base.py +0 -0
  49. {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/module/types.py +0 -0
  50. {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/ranking/__init__.py +0 -0
  51. {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/ranking/cli_rank.py +0 -0
  52. {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/ranking/coderank.py +0 -0
  53. {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/render.py +0 -0
  54. {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/resolution.py +0 -0
  55. {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/scene3d.py +0 -0
  56. {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/sql/004_add_centrality_table.sql +0 -0
  57. {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/store.py +0 -0
  58. {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/theme.py +0 -0
  59. {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/utils.py +0 -0
  60. {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/visitor.py +0 -0
  61. {pycode_kg-0.24.0 → pycode_kg-0.25.0}/src/pycode_kg/viz3d.py +0 -0
  62. {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.24.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.18.0)
25
- Requires-Dist: kgmodule-utils[viz3d-qt,viz3d-render] (>=0.18.0) ; extra == "viz3d"
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
  [![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.24.0-blue.svg)](https://github.com/Flux-Frontiers/pycode_kg/releases)
63
+ [![Version](https://img.shields.io/badge/version-0.25.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.24.0.md](docs/analysis_v0.24.0.md), produced (of course) by `pycodekg analyze` against this very repo.
258
+ The MCP server, the CLI, and the Streamlit app are thin wrappers over the same store + index + ranking core — there is exactly one code path for each capability. The latest architectural deep-dive is in [docs/analysis_v0.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
  [![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.24.0) [Software]. Flux-Frontiers. https://doi.org/10.5281/zenodo.19737993
285
+ > Suchanek, E. G. (2026). *PyCodeKG: A Knowledge Graph for Python Codebases* (Version 0.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.24.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 · Last updated August 2026*
315
+ *Built for Python developers and AI agents that work alongside them — egs*
316
316
 
@@ -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.24.0-blue.svg)](https://github.com/Flux-Frontiers/pycode_kg/releases)
8
+ [![Version](https://img.shields.io/badge/version-0.25.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.24.0.md](docs/analysis_v0.24.0.md), produced (of course) by `pycodekg analyze` against this very repo.
203
+ The MCP server, the CLI, and the Streamlit app are thin wrappers over the same store + index + ranking core — there is exactly one code path for each capability. The latest architectural deep-dive is in [docs/analysis_v0.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
  [![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.24.0) [Software]. Flux-Frontiers. https://doi.org/10.5281/zenodo.19737993
230
+ > Suchanek, E. G. (2026). *PyCodeKG: A Knowledge Graph for Python Codebases* (Version 0.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.24.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 · Last updated August 2026*
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.24.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
- "kgmodule-utils[semantic,viz3d]>=0.18.0",
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.0",
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
@@ -36,7 +36,7 @@ Author: Eric G. Suchanek, PhD
36
36
  License: Elastic 2.0
37
37
  """
38
38
 
39
- __version__ = "0.24.0"
39
+ __version__ = "0.25.0"
40
40
  __author__ = "Eric G. Suchanek, PhD"
41
41
 
42
42
  # Low-level primitives (locked v0 contract)
@@ -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 tagged with the given VERSION.
79
- The tree hash is auto-detected from git when not provided.
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/{tree_hash}.json, with a
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)
@@ -118,6 +118,7 @@ class PyCodeKGExtractor(KGExtractor):
118
118
  lineno=n.lineno,
119
119
  end_lineno=n.end_lineno,
120
120
  docstring=n.docstring or "",
121
+ metadata={"receiver_class": n.receiver_class} if n.receiver_class else {},
121
122
  )
122
123
 
123
124
  for e in edges:
@@ -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.rglob("*.py"):
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 = (Path(repo_root) / module_path).read_text(encoding="utf-8")
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 key, value in self.stats.items():
2501
- stats_table.add_row(key, str(value))
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 if not provided.
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