graphlens-python 0.3.0__py3-none-any.whl → 0.4.0__py3-none-any.whl
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.
- graphlens_python/__init__.py +2 -1
- graphlens_python/_adapter.py +142 -5
- graphlens_python/_module_resolver.py +1 -1
- graphlens_python/_resolver.py +392 -0
- graphlens_python/_visitor.py +413 -80
- {graphlens_python-0.3.0.dist-info → graphlens_python-0.4.0.dist-info}/METADATA +2 -1
- graphlens_python-0.4.0.dist-info/RECORD +11 -0
- {graphlens_python-0.3.0.dist-info → graphlens_python-0.4.0.dist-info}/WHEEL +1 -1
- graphlens_python-0.3.0.dist-info/RECORD +0 -10
- {graphlens_python-0.3.0.dist-info → graphlens_python-0.4.0.dist-info}/entry_points.txt +0 -0
graphlens_python/__init__.py
CHANGED
graphlens_python/_adapter.py
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import logging
|
|
6
|
+
from pathlib import Path
|
|
6
7
|
from typing import TYPE_CHECKING
|
|
7
8
|
|
|
8
9
|
from graphlens import (
|
|
@@ -13,7 +14,7 @@ from graphlens import (
|
|
|
13
14
|
Relation,
|
|
14
15
|
RelationKind,
|
|
15
16
|
)
|
|
16
|
-
from graphlens.utils import make_node_id
|
|
17
|
+
from graphlens.utils import SpanIndex, make_node_id
|
|
17
18
|
from graphlens.utils.roots import filter_nested_root_files
|
|
18
19
|
|
|
19
20
|
from graphlens_python._deps import (
|
|
@@ -29,22 +30,34 @@ from graphlens_python._project_detector import (
|
|
|
29
30
|
find_python_roots,
|
|
30
31
|
is_python_project,
|
|
31
32
|
)
|
|
33
|
+
from graphlens_python._resolver import TyResolver
|
|
32
34
|
from graphlens_python._visitor import (
|
|
33
35
|
ImportClassifier,
|
|
36
|
+
OccurrenceRef,
|
|
34
37
|
PythonASTVisitor,
|
|
35
38
|
VisitorContext,
|
|
36
39
|
parse_python,
|
|
37
40
|
)
|
|
38
41
|
|
|
39
42
|
if TYPE_CHECKING:
|
|
40
|
-
from
|
|
41
|
-
|
|
42
|
-
from graphlens.contracts import DependencyFileParser
|
|
43
|
+
from graphlens.contracts import DependencyFileParser, SymbolResolver
|
|
43
44
|
|
|
44
45
|
logger = logging.getLogger("graphlens_python")
|
|
45
46
|
|
|
46
47
|
_STDLIB = get_stdlib_names()
|
|
47
48
|
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
# Role → RelationKind mapping
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
_ROLE_TO_KIND: dict[str, RelationKind] = {
|
|
54
|
+
"call": RelationKind.CALLS,
|
|
55
|
+
"base": RelationKind.INHERITS_FROM,
|
|
56
|
+
"annotation": RelationKind.HAS_TYPE,
|
|
57
|
+
"read": RelationKind.REFERENCES,
|
|
58
|
+
"write": RelationKind.REFERENCES,
|
|
59
|
+
}
|
|
60
|
+
|
|
48
61
|
|
|
49
62
|
class PythonAdapter(LanguageAdapter):
|
|
50
63
|
"""Language adapter for Python projects."""
|
|
@@ -52,6 +65,7 @@ class PythonAdapter(LanguageAdapter):
|
|
|
52
65
|
def __init__(
|
|
53
66
|
self,
|
|
54
67
|
dep_parsers: list[DependencyFileParser] | None = None,
|
|
68
|
+
resolver: SymbolResolver | None = None,
|
|
55
69
|
) -> None:
|
|
56
70
|
"""
|
|
57
71
|
Initialize the Python adapter.
|
|
@@ -63,6 +77,11 @@ class PythonAdapter(LanguageAdapter):
|
|
|
63
77
|
non-standard package managers (poetry-only setup,
|
|
64
78
|
pip-tools, pnpm, etc.).
|
|
65
79
|
Defaults to ``PYTHON_DEFAULT_DEP_PARSERS``.
|
|
80
|
+
resolver: symbol resolver used for cross-file resolution of
|
|
81
|
+
calls, references, annotations, and base classes.
|
|
82
|
+
Defaults to ``TyResolver`` (requires ``ty`` in PATH).
|
|
83
|
+
Pass ``None`` to disable resolution, or inject a custom
|
|
84
|
+
``SymbolResolver`` subclass.
|
|
66
85
|
|
|
67
86
|
"""
|
|
68
87
|
self._dep_parsers = (
|
|
@@ -70,6 +89,9 @@ class PythonAdapter(LanguageAdapter):
|
|
|
70
89
|
if dep_parsers is not None
|
|
71
90
|
else PYTHON_DEFAULT_DEP_PARSERS
|
|
72
91
|
)
|
|
92
|
+
self._resolver = (
|
|
93
|
+
resolver if resolver is not None else TyResolver()
|
|
94
|
+
)
|
|
73
95
|
|
|
74
96
|
def language(self) -> str:
|
|
75
97
|
return "python"
|
|
@@ -94,6 +116,7 @@ class PythonAdapter(LanguageAdapter):
|
|
|
94
116
|
project_root,
|
|
95
117
|
files,
|
|
96
118
|
self._dep_parsers,
|
|
119
|
+
self._resolver,
|
|
97
120
|
)
|
|
98
121
|
else:
|
|
99
122
|
py_roots = find_python_roots(project_root)
|
|
@@ -110,17 +133,19 @@ class PythonAdapter(LanguageAdapter):
|
|
|
110
133
|
py_root,
|
|
111
134
|
root_files,
|
|
112
135
|
self._dep_parsers,
|
|
136
|
+
self._resolver,
|
|
113
137
|
)
|
|
114
138
|
|
|
115
139
|
return graph
|
|
116
140
|
|
|
117
141
|
|
|
118
|
-
def _analyze_root(
|
|
142
|
+
def _analyze_root( # noqa: PLR0913, PLR0915
|
|
119
143
|
graph: GraphLens,
|
|
120
144
|
project_root: Path,
|
|
121
145
|
py_root: Path,
|
|
122
146
|
files: list[Path],
|
|
123
147
|
dep_parsers: list[DependencyFileParser],
|
|
148
|
+
resolver: SymbolResolver,
|
|
124
149
|
) -> None:
|
|
125
150
|
"""Analyze one Python project root and populate graph in-place."""
|
|
126
151
|
project_name = detect_project_name(py_root)
|
|
@@ -164,6 +189,7 @@ def _analyze_root(
|
|
|
164
189
|
)
|
|
165
190
|
|
|
166
191
|
modules: dict[str, str] = {}
|
|
192
|
+
all_occurrences: list[tuple[str, OccurrenceRef]] = []
|
|
167
193
|
|
|
168
194
|
for file in files:
|
|
169
195
|
source_root = (
|
|
@@ -232,6 +258,16 @@ def _analyze_root(
|
|
|
232
258
|
ctx, graph, file_id, source_bytes, classifier
|
|
233
259
|
)
|
|
234
260
|
visitor.visit(tree.root_node)
|
|
261
|
+
all_occurrences.extend(
|
|
262
|
+
(visitor.abs_file_path, o) for o in visitor.occurrences
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
# Resolution pass: bind occurrences to real nodes or EXTERNAL_SYMBOL
|
|
266
|
+
span_index = SpanIndex.from_graph(graph)
|
|
267
|
+
resolver.prepare(py_root, files)
|
|
268
|
+
_resolve_occurrences(
|
|
269
|
+
graph, project_name, resolver, span_index, all_occurrences
|
|
270
|
+
)
|
|
235
271
|
|
|
236
272
|
# PROJECT --CONTAINS--> top-level modules
|
|
237
273
|
top_level = {qn: mid for qn, mid in modules.items() if "." not in qn}
|
|
@@ -245,6 +281,107 @@ def _analyze_root(
|
|
|
245
281
|
)
|
|
246
282
|
|
|
247
283
|
|
|
284
|
+
def _ensure_external_symbol(
|
|
285
|
+
graph: GraphLens, project_name: str, qname: str, origin: str
|
|
286
|
+
) -> str:
|
|
287
|
+
"""
|
|
288
|
+
Return the id of an EXTERNAL_SYMBOL node for ``qname``.
|
|
289
|
+
|
|
290
|
+
Creates the node if it does not yet exist in ``graph``.
|
|
291
|
+
|
|
292
|
+
Args:
|
|
293
|
+
graph: the graph to update in-place.
|
|
294
|
+
project_name: used as the namespace for ``make_node_id``.
|
|
295
|
+
qname: fully-qualified name of the external symbol.
|
|
296
|
+
origin: one of ``"stdlib"``, ``"third_party"``, ``"unknown"``,
|
|
297
|
+
or ``"internal"`` (fallback when the module node is absent).
|
|
298
|
+
|
|
299
|
+
Returns:
|
|
300
|
+
The node id of the EXTERNAL_SYMBOL.
|
|
301
|
+
|
|
302
|
+
"""
|
|
303
|
+
sym_id = make_node_id(
|
|
304
|
+
project_name, qname, NodeKind.EXTERNAL_SYMBOL.value
|
|
305
|
+
)
|
|
306
|
+
if sym_id not in graph.nodes:
|
|
307
|
+
graph.add_node(
|
|
308
|
+
Node(
|
|
309
|
+
id=sym_id,
|
|
310
|
+
kind=NodeKind.EXTERNAL_SYMBOL,
|
|
311
|
+
qualified_name=qname,
|
|
312
|
+
name=qname.rsplit(".", maxsplit=1)[-1],
|
|
313
|
+
metadata={"origin": origin},
|
|
314
|
+
)
|
|
315
|
+
)
|
|
316
|
+
return sym_id
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _resolve_occurrences(
|
|
320
|
+
graph: GraphLens,
|
|
321
|
+
project_name: str,
|
|
322
|
+
resolver: SymbolResolver,
|
|
323
|
+
span_index: SpanIndex,
|
|
324
|
+
occurrences: list[tuple[str, OccurrenceRef]],
|
|
325
|
+
) -> None:
|
|
326
|
+
"""
|
|
327
|
+
Resolve all accumulated occurrences and emit edges.
|
|
328
|
+
|
|
329
|
+
For each ``(abs_path, occ)`` pair:
|
|
330
|
+
|
|
331
|
+
1. Ask the resolver for the definition site.
|
|
332
|
+
2. If the definition is internal, look up the target node id via
|
|
333
|
+
``span_index.at()``.
|
|
334
|
+
3. If the node is not found (or origin is external), create/reuse an
|
|
335
|
+
``EXTERNAL_SYMBOL`` fallback node.
|
|
336
|
+
4. Emit a ``Relation`` of the appropriate kind, with span metadata
|
|
337
|
+
and, for read/write occurrences, an ``access`` key.
|
|
338
|
+
|
|
339
|
+
Args:
|
|
340
|
+
graph: the graph to update in-place.
|
|
341
|
+
project_name: namespace used for EXTERNAL_SYMBOL node ids.
|
|
342
|
+
resolver: the symbol resolver that was already ``prepare()``d.
|
|
343
|
+
span_index: pre-built index of node spans from ``graph``.
|
|
344
|
+
occurrences: list of ``(absolute_file_path, OccurrenceRef)`` pairs
|
|
345
|
+
collected during the file-visit loop.
|
|
346
|
+
|
|
347
|
+
"""
|
|
348
|
+
for abs_path, occ in occurrences:
|
|
349
|
+
rel_kind = _ROLE_TO_KIND[occ.role]
|
|
350
|
+
ref = resolver.definition_at(Path(abs_path), occ.line, occ.col)
|
|
351
|
+
if ref is None:
|
|
352
|
+
continue
|
|
353
|
+
target_id: str | None = None
|
|
354
|
+
if ref.origin == "internal" and ref.file_path is not None:
|
|
355
|
+
target_id = span_index.at(
|
|
356
|
+
str(ref.file_path), ref.line, ref.col
|
|
357
|
+
)
|
|
358
|
+
if target_id is None:
|
|
359
|
+
# When full_name is absent, use a position-qualified key so that
|
|
360
|
+
# distinct unresolved sites don't collapse into the same node.
|
|
361
|
+
fallback_qname = (
|
|
362
|
+
ref.full_name
|
|
363
|
+
if ref.full_name
|
|
364
|
+
else f"{occ.role}@{occ.line}:{occ.col}"
|
|
365
|
+
)
|
|
366
|
+
target_id = _ensure_external_symbol(
|
|
367
|
+
graph,
|
|
368
|
+
project_name,
|
|
369
|
+
fallback_qname,
|
|
370
|
+
ref.origin,
|
|
371
|
+
)
|
|
372
|
+
metadata: dict[str, object] = {"span": occ.span}
|
|
373
|
+
if occ.role in ("read", "write"):
|
|
374
|
+
metadata["access"] = occ.role
|
|
375
|
+
graph.add_relation(
|
|
376
|
+
Relation(
|
|
377
|
+
source_id=occ.enclosing_id,
|
|
378
|
+
target_id=target_id,
|
|
379
|
+
kind=rel_kind,
|
|
380
|
+
metadata=metadata,
|
|
381
|
+
)
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
|
|
248
385
|
def _find_source_root_for(file: Path, source_roots: list[Path]) -> Path | None:
|
|
249
386
|
for root in source_roots:
|
|
250
387
|
try:
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
"""TyResolver — LSP-backed Python symbol resolver using ``ty server``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import select
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from urllib.parse import unquote
|
|
16
|
+
|
|
17
|
+
from graphlens.contracts import Occurrence, ResolvedRef, SymbolResolver
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger("graphlens_python")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _uri_to_path(uri: str) -> Path | None:
|
|
23
|
+
"""Convert a ``file://`` URI to a ``Path``; None for other schemes."""
|
|
24
|
+
if not uri.startswith("file://"):
|
|
25
|
+
return None
|
|
26
|
+
return Path(unquote(uri[7:]))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class _TyLspClient:
|
|
30
|
+
"""Minimal synchronous LSP JSON-RPC client for ``ty server`` (stdio)."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, project_root: Path) -> None:
|
|
33
|
+
ty_bin = shutil.which("ty") or "ty"
|
|
34
|
+
self._proc: subprocess.Popen = subprocess.Popen( # type: ignore[type-arg]
|
|
35
|
+
[ty_bin, "server"],
|
|
36
|
+
stdin=subprocess.PIPE,
|
|
37
|
+
stdout=subprocess.PIPE,
|
|
38
|
+
stderr=subprocess.DEVNULL,
|
|
39
|
+
cwd=str(project_root),
|
|
40
|
+
)
|
|
41
|
+
self._next_id = 0
|
|
42
|
+
self._opened_uris: set[str] = set()
|
|
43
|
+
self._initialize(project_root)
|
|
44
|
+
|
|
45
|
+
# ------------------------------------------------------------------
|
|
46
|
+
# Transport
|
|
47
|
+
# ------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
def _write(self, msg: dict) -> None: # type: ignore[type-arg]
|
|
50
|
+
if self._proc.stdin is None or self._proc.poll() is not None:
|
|
51
|
+
return
|
|
52
|
+
body = json.dumps(msg, separators=(",", ":")).encode()
|
|
53
|
+
header = f"Content-Length: {len(body)}\r\n\r\n".encode()
|
|
54
|
+
try:
|
|
55
|
+
self._proc.stdin.write(header + body)
|
|
56
|
+
self._proc.stdin.flush()
|
|
57
|
+
except OSError:
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
def _read_one(self, timeout: float = 30.0) -> dict | None: # type: ignore[type-arg]
|
|
61
|
+
"""Read one LSP message, waiting at most *timeout* seconds."""
|
|
62
|
+
if self._proc.stdout is None or self._proc.poll() is not None:
|
|
63
|
+
return None
|
|
64
|
+
ready, _, _ = select.select([self._proc.stdout], [], [], timeout)
|
|
65
|
+
if not ready:
|
|
66
|
+
logger.warning("ty server timed out after %.0fs", timeout)
|
|
67
|
+
return None
|
|
68
|
+
content_length = 0
|
|
69
|
+
try:
|
|
70
|
+
while True:
|
|
71
|
+
raw = self._proc.stdout.readline()
|
|
72
|
+
if not raw:
|
|
73
|
+
return None # EOF — server exited
|
|
74
|
+
stripped = raw.strip()
|
|
75
|
+
if not stripped:
|
|
76
|
+
break # blank line ends LSP headers
|
|
77
|
+
if stripped.lower().startswith(b"content-length:"):
|
|
78
|
+
content_length = int(stripped.split(b":", 1)[1].strip())
|
|
79
|
+
if not content_length:
|
|
80
|
+
return {}
|
|
81
|
+
body = self._proc.stdout.read(content_length)
|
|
82
|
+
return json.loads(body) if body else {}
|
|
83
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
84
|
+
logger.debug("ty server read error: %s", exc)
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
def _recv_response(
|
|
88
|
+
self, expected_id: int, timeout: float = 30.0
|
|
89
|
+
) -> dict | None: # type: ignore[type-arg]
|
|
90
|
+
"""
|
|
91
|
+
Consume messages until the one with *expected_id* arrives.
|
|
92
|
+
|
|
93
|
+
Server-to-client requests (have both ``id`` and ``method``) receive a
|
|
94
|
+
MethodNotFound error so ty does not stall waiting for a reply.
|
|
95
|
+
Notifications (no ``id``) are silently discarded.
|
|
96
|
+
"""
|
|
97
|
+
for _ in range(500): # cap to prevent accidental infinite loop
|
|
98
|
+
msg = self._read_one(timeout=timeout)
|
|
99
|
+
if msg is None:
|
|
100
|
+
return None
|
|
101
|
+
msg_id = msg.get("id")
|
|
102
|
+
if "method" in msg:
|
|
103
|
+
# Server-to-client request — reply with error to unblock ty
|
|
104
|
+
if msg_id is not None:
|
|
105
|
+
self._write(
|
|
106
|
+
{
|
|
107
|
+
"jsonrpc": "2.0",
|
|
108
|
+
"id": msg_id,
|
|
109
|
+
"error": {
|
|
110
|
+
"code": -32601,
|
|
111
|
+
"message": "Method not found",
|
|
112
|
+
},
|
|
113
|
+
}
|
|
114
|
+
)
|
|
115
|
+
continue # notification or server request — skip
|
|
116
|
+
if msg_id == expected_id:
|
|
117
|
+
return msg
|
|
118
|
+
logger.warning("ty server did not respond to request %d", expected_id)
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
def _request(
|
|
122
|
+
self, method: str, params: object, timeout: float = 30.0
|
|
123
|
+
) -> dict | None: # type: ignore[type-arg]
|
|
124
|
+
self._next_id += 1
|
|
125
|
+
mid = self._next_id
|
|
126
|
+
self._write(
|
|
127
|
+
{"jsonrpc": "2.0", "id": mid, "method": method, "params": params}
|
|
128
|
+
)
|
|
129
|
+
return self._recv_response(mid, timeout=timeout)
|
|
130
|
+
|
|
131
|
+
def _notify(self, method: str, params: object) -> None:
|
|
132
|
+
self._write({"jsonrpc": "2.0", "method": method, "params": params})
|
|
133
|
+
|
|
134
|
+
# ------------------------------------------------------------------
|
|
135
|
+
# LSP lifecycle
|
|
136
|
+
# ------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
def _initialize(self, project_root: Path) -> None:
|
|
139
|
+
resp = self._request(
|
|
140
|
+
"initialize",
|
|
141
|
+
{
|
|
142
|
+
"processId": os.getpid(),
|
|
143
|
+
"rootUri": project_root.as_uri(),
|
|
144
|
+
"capabilities": {
|
|
145
|
+
"textDocument": {
|
|
146
|
+
"definition": {"dynamicRegistration": False},
|
|
147
|
+
"references": {"dynamicRegistration": False},
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
"workspaceFolders": [
|
|
151
|
+
{"uri": project_root.as_uri(), "name": project_root.name},
|
|
152
|
+
],
|
|
153
|
+
},
|
|
154
|
+
)
|
|
155
|
+
if resp is not None:
|
|
156
|
+
self._notify("initialized", {})
|
|
157
|
+
|
|
158
|
+
# ------------------------------------------------------------------
|
|
159
|
+
# File management
|
|
160
|
+
# ------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
def open_file(self, file: Path) -> str:
|
|
163
|
+
"""Open *file* in ty (idempotent) and return its ``file://`` URI."""
|
|
164
|
+
uri = file.as_uri()
|
|
165
|
+
if uri not in self._opened_uris:
|
|
166
|
+
self._opened_uris.add(uri)
|
|
167
|
+
try:
|
|
168
|
+
text = file.read_text(encoding="utf-8", errors="replace")
|
|
169
|
+
except OSError:
|
|
170
|
+
text = ""
|
|
171
|
+
self._notify(
|
|
172
|
+
"textDocument/didOpen",
|
|
173
|
+
{
|
|
174
|
+
"textDocument": {
|
|
175
|
+
"uri": uri,
|
|
176
|
+
"languageId": "python",
|
|
177
|
+
"version": 1,
|
|
178
|
+
"text": text,
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
)
|
|
182
|
+
# Wait for ty to finish analyzing the file before returning.
|
|
183
|
+
# Without this, definition queries sent immediately after didOpen
|
|
184
|
+
# block for 30+ seconds until ty's background analysis completes.
|
|
185
|
+
self._drain_for_diagnostics(uri)
|
|
186
|
+
return uri
|
|
187
|
+
|
|
188
|
+
def _drain_for_diagnostics(self, uri: str, timeout: float = 5.0) -> None:
|
|
189
|
+
"""Drain messages until publishDiagnostics for *uri* (or timeout)."""
|
|
190
|
+
deadline = time.monotonic() + timeout
|
|
191
|
+
while time.monotonic() < deadline:
|
|
192
|
+
remaining = deadline - time.monotonic()
|
|
193
|
+
msg = self._read_one(timeout=min(remaining, 1.0))
|
|
194
|
+
if msg is None:
|
|
195
|
+
break
|
|
196
|
+
method = msg.get("method", "")
|
|
197
|
+
msg_id = msg.get("id")
|
|
198
|
+
if method and msg_id is not None:
|
|
199
|
+
# Server-to-client request — reply with error to unblock ty
|
|
200
|
+
self._write(
|
|
201
|
+
{
|
|
202
|
+
"jsonrpc": "2.0",
|
|
203
|
+
"id": msg_id,
|
|
204
|
+
"error": {"code": -32601, "message": "MethodNotFound"},
|
|
205
|
+
}
|
|
206
|
+
)
|
|
207
|
+
elif (
|
|
208
|
+
method == "textDocument/publishDiagnostics"
|
|
209
|
+
and msg.get("params", {}).get("uri") == uri
|
|
210
|
+
):
|
|
211
|
+
return # ty finished analyzing this file
|
|
212
|
+
|
|
213
|
+
# ------------------------------------------------------------------
|
|
214
|
+
# Queries
|
|
215
|
+
# ------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
def definition(
|
|
218
|
+
self, file: Path, line: int, col: int
|
|
219
|
+
) -> dict | None: # type: ignore[type-arg]
|
|
220
|
+
"""Return the first LSP ``Location`` for 1-based *(line, col)*."""
|
|
221
|
+
uri = self.open_file(file)
|
|
222
|
+
resp = self._request(
|
|
223
|
+
"textDocument/definition",
|
|
224
|
+
{
|
|
225
|
+
"textDocument": {"uri": uri},
|
|
226
|
+
"position": {"line": line - 1, "character": col - 1},
|
|
227
|
+
},
|
|
228
|
+
timeout=30.0,
|
|
229
|
+
)
|
|
230
|
+
if resp is None:
|
|
231
|
+
return None
|
|
232
|
+
result = resp.get("result")
|
|
233
|
+
if not result:
|
|
234
|
+
return None
|
|
235
|
+
return result[0] if isinstance(result, list) else result
|
|
236
|
+
|
|
237
|
+
def references(
|
|
238
|
+
self, file: Path, line: int, col: int
|
|
239
|
+
) -> list[dict]: # type: ignore[type-arg]
|
|
240
|
+
"""Return LSP ``Location`` list for 1-based *(line, col)*."""
|
|
241
|
+
uri = self.open_file(file)
|
|
242
|
+
resp = self._request(
|
|
243
|
+
"textDocument/references",
|
|
244
|
+
{
|
|
245
|
+
"textDocument": {"uri": uri},
|
|
246
|
+
"position": {"line": line - 1, "character": col - 1},
|
|
247
|
+
"context": {"includeDeclaration": False},
|
|
248
|
+
},
|
|
249
|
+
timeout=30.0,
|
|
250
|
+
)
|
|
251
|
+
if resp is None:
|
|
252
|
+
return []
|
|
253
|
+
result = resp.get("result")
|
|
254
|
+
return result if isinstance(result, list) else []
|
|
255
|
+
|
|
256
|
+
# ------------------------------------------------------------------
|
|
257
|
+
# Cleanup
|
|
258
|
+
# ------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
def shutdown(self) -> None:
|
|
261
|
+
if self._proc.poll() is not None:
|
|
262
|
+
return
|
|
263
|
+
try:
|
|
264
|
+
self._request("shutdown", None)
|
|
265
|
+
self._notify("exit", None)
|
|
266
|
+
self._proc.wait(timeout=5)
|
|
267
|
+
except Exception:
|
|
268
|
+
with contextlib.suppress(Exception):
|
|
269
|
+
self._proc.kill()
|
|
270
|
+
|
|
271
|
+
def __del__(self) -> None:
|
|
272
|
+
with contextlib.suppress(Exception):
|
|
273
|
+
self.shutdown()
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class TyResolver(SymbolResolver):
|
|
277
|
+
"""
|
|
278
|
+
Resolve Python symbols via ``ty server`` (Astral ty, Rust-based).
|
|
279
|
+
|
|
280
|
+
Spawns one ``ty server`` LSP subprocess per :meth:`prepare` call.
|
|
281
|
+
Files are opened lazily on the first ``definition_at`` query — bulk
|
|
282
|
+
pre-opening is intentionally avoided because sending hundreds of
|
|
283
|
+
``textDocument/didOpen`` notifications at once fills ty's stdin buffer
|
|
284
|
+
and deadlocks on large projects.
|
|
285
|
+
|
|
286
|
+
Requires ``ty`` to be in ``PATH``. Install from
|
|
287
|
+
https://docs.astral.sh/ty/. If ``ty`` is not found, :meth:`prepare`
|
|
288
|
+
logs a warning and all queries return ``None``/``[]`` (the structural
|
|
289
|
+
graph is still produced correctly).
|
|
290
|
+
|
|
291
|
+
All methods return ``None``/``[]`` on any error — never raise.
|
|
292
|
+
``infer_type_at`` always returns ``None`` (type inference via LSP hover
|
|
293
|
+
would require parsing ty's markdown output).
|
|
294
|
+
"""
|
|
295
|
+
|
|
296
|
+
def __init__(self) -> None:
|
|
297
|
+
self._client: _TyLspClient | None = None
|
|
298
|
+
self._root: Path | None = None
|
|
299
|
+
|
|
300
|
+
def prepare(self, project_root: Path, files: list[Path]) -> None: # noqa: ARG002
|
|
301
|
+
if self._client is not None:
|
|
302
|
+
with contextlib.suppress(Exception):
|
|
303
|
+
self._client.shutdown()
|
|
304
|
+
self._client = None
|
|
305
|
+
self._root = project_root
|
|
306
|
+
try:
|
|
307
|
+
self._client = _TyLspClient(project_root)
|
|
308
|
+
except Exception:
|
|
309
|
+
logger.warning("Failed to start ty server for %s", project_root)
|
|
310
|
+
self._client = None
|
|
311
|
+
|
|
312
|
+
def definition_at(
|
|
313
|
+
self, file: Path, line: int, col: int
|
|
314
|
+
) -> ResolvedRef | None:
|
|
315
|
+
if self._client is None:
|
|
316
|
+
return None
|
|
317
|
+
try:
|
|
318
|
+
loc = self._client.definition(file, line, col)
|
|
319
|
+
except Exception:
|
|
320
|
+
return None
|
|
321
|
+
if loc is None:
|
|
322
|
+
return None
|
|
323
|
+
return self._loc_to_ref(loc)
|
|
324
|
+
|
|
325
|
+
def infer_type_at(
|
|
326
|
+
self, file: Path, line: int, col: int # noqa: ARG002
|
|
327
|
+
) -> ResolvedRef | None:
|
|
328
|
+
return None
|
|
329
|
+
|
|
330
|
+
def references_to(
|
|
331
|
+
self, file: Path, line: int, col: int
|
|
332
|
+
) -> list[Occurrence]:
|
|
333
|
+
if self._client is None:
|
|
334
|
+
return []
|
|
335
|
+
try:
|
|
336
|
+
locs = self._client.references(file, line, col)
|
|
337
|
+
except Exception:
|
|
338
|
+
return []
|
|
339
|
+
out: list[Occurrence] = []
|
|
340
|
+
for loc in locs:
|
|
341
|
+
fp = _uri_to_path(loc.get("uri", ""))
|
|
342
|
+
if fp is None:
|
|
343
|
+
continue
|
|
344
|
+
start = loc.get("range", {}).get("start", {})
|
|
345
|
+
out.append(
|
|
346
|
+
Occurrence(
|
|
347
|
+
file_path=fp,
|
|
348
|
+
line=start.get("line", 0) + 1,
|
|
349
|
+
col=start.get("character", 0) + 1,
|
|
350
|
+
is_definition=False,
|
|
351
|
+
access="unknown",
|
|
352
|
+
)
|
|
353
|
+
)
|
|
354
|
+
return out
|
|
355
|
+
|
|
356
|
+
def _loc_to_ref(self, loc: dict) -> ResolvedRef: # type: ignore[type-arg]
|
|
357
|
+
fp = _uri_to_path(loc.get("uri", ""))
|
|
358
|
+
start = loc.get("range", {}).get("start", {})
|
|
359
|
+
return ResolvedRef(
|
|
360
|
+
full_name="",
|
|
361
|
+
file_path=fp,
|
|
362
|
+
line=start.get("line", 0) + 1,
|
|
363
|
+
col=start.get("character", 0) + 1,
|
|
364
|
+
kind="",
|
|
365
|
+
origin=self._classify(fp),
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
def _classify(self, file_path: Path | None) -> str:
|
|
369
|
+
if file_path is None:
|
|
370
|
+
return "stdlib"
|
|
371
|
+
parts = file_path.parts
|
|
372
|
+
if "typeshed" in parts:
|
|
373
|
+
return "stdlib"
|
|
374
|
+
if "site-packages" in parts or "dist-packages" in parts:
|
|
375
|
+
return "third_party"
|
|
376
|
+
if self._root is not None:
|
|
377
|
+
with contextlib.suppress(ValueError):
|
|
378
|
+
file_path.relative_to(self._root)
|
|
379
|
+
return "internal"
|
|
380
|
+
# Resolve symlinks before comparing: uv manages Pythons via symlinks
|
|
381
|
+
# (cpython-3.13-... → cpython-3.13.5-...) and ty returns real paths.
|
|
382
|
+
real = file_path.resolve()
|
|
383
|
+
base = Path(sys.base_prefix).resolve()
|
|
384
|
+
with contextlib.suppress(ValueError):
|
|
385
|
+
real.relative_to(base)
|
|
386
|
+
return "stdlib"
|
|
387
|
+
return "unknown"
|
|
388
|
+
|
|
389
|
+
def __del__(self) -> None:
|
|
390
|
+
if self._client is not None:
|
|
391
|
+
with contextlib.suppress(Exception):
|
|
392
|
+
self._client.shutdown()
|
graphlens_python/_visitor.py
CHANGED
|
@@ -26,6 +26,33 @@ if TYPE_CHECKING:
|
|
|
26
26
|
logger = logging.getLogger("graphlens_python")
|
|
27
27
|
|
|
28
28
|
_PY_LANGUAGE = Language(tspython.language())
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# Occurrence reference (use-site record)
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class OccurrenceRef:
|
|
38
|
+
"""
|
|
39
|
+
A use-site that the resolver will bind to a definition.
|
|
40
|
+
|
|
41
|
+
Coordinates are 1-based (matching Span convention).
|
|
42
|
+
|
|
43
|
+
Roles:
|
|
44
|
+
``call`` — call-site of a function/method
|
|
45
|
+
``read`` — identifier read on the right-hand side
|
|
46
|
+
``write`` — assignment target
|
|
47
|
+
``annotation`` — type annotation or TypeAlias RHS
|
|
48
|
+
``base`` — class base in the heritage list
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
role: str
|
|
52
|
+
line: int
|
|
53
|
+
col: int
|
|
54
|
+
enclosing_id: str
|
|
55
|
+
span: Span
|
|
29
56
|
_parser = Parser(_PY_LANGUAGE)
|
|
30
57
|
|
|
31
58
|
|
|
@@ -109,6 +136,9 @@ class PythonASTVisitor:
|
|
|
109
136
|
self._container_stack: list[str] = [file_node_id]
|
|
110
137
|
# Stack of NodeKind to know if we're inside a class
|
|
111
138
|
self._kind_stack: list[NodeKind] = [NodeKind.FILE]
|
|
139
|
+
# Occurrence use-sites collected during this visit
|
|
140
|
+
self.occurrences: list[OccurrenceRef] = []
|
|
141
|
+
self.abs_file_path: str = str(ctx.file_path)
|
|
112
142
|
|
|
113
143
|
# -------------------------------------------------------------------------
|
|
114
144
|
# Dispatch
|
|
@@ -133,9 +163,8 @@ class PythonASTVisitor:
|
|
|
133
163
|
self._visit_children(node)
|
|
134
164
|
|
|
135
165
|
def _visit_decorated_definition(self, node: TSNode) -> None:
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
]
|
|
166
|
+
decorator_nodes = [c for c in node.children if c.type == "decorator"]
|
|
167
|
+
decorators = [_decorator_name(c) for c in decorator_nodes]
|
|
139
168
|
inner = next(
|
|
140
169
|
(
|
|
141
170
|
c
|
|
@@ -147,15 +176,15 @@ class PythonASTVisitor:
|
|
|
147
176
|
if inner is None:
|
|
148
177
|
return
|
|
149
178
|
if inner.type == "class_definition":
|
|
150
|
-
self._handle_class(inner, decorators)
|
|
179
|
+
self._handle_class(inner, decorators, decorator_nodes)
|
|
151
180
|
else:
|
|
152
|
-
self._handle_function(inner, decorators)
|
|
181
|
+
self._handle_function(inner, decorators, decorator_nodes)
|
|
153
182
|
|
|
154
183
|
def _visit_class_definition(self, node: TSNode) -> None:
|
|
155
|
-
self._handle_class(node, decorators=[])
|
|
184
|
+
self._handle_class(node, decorators=[], decorator_nodes=[])
|
|
156
185
|
|
|
157
186
|
def _visit_function_definition(self, node: TSNode) -> None:
|
|
158
|
-
self._handle_function(node, decorators=[])
|
|
187
|
+
self._handle_function(node, decorators=[], decorator_nodes=[])
|
|
159
188
|
|
|
160
189
|
def _visit_import_statement(self, node: TSNode) -> None:
|
|
161
190
|
# import X / import X.Y / import X as Y
|
|
@@ -275,7 +304,12 @@ class PythonASTVisitor:
|
|
|
275
304
|
# Class and function handlers
|
|
276
305
|
# -------------------------------------------------------------------------
|
|
277
306
|
|
|
278
|
-
def _handle_class(
|
|
307
|
+
def _handle_class(
|
|
308
|
+
self,
|
|
309
|
+
node: TSNode,
|
|
310
|
+
decorators: list[str],
|
|
311
|
+
decorator_nodes: list[TSNode] | None = None,
|
|
312
|
+
) -> None:
|
|
279
313
|
name_node = next(
|
|
280
314
|
(c for c in node.children if c.type == "identifier"), None
|
|
281
315
|
)
|
|
@@ -296,6 +330,10 @@ class PythonASTVisitor:
|
|
|
296
330
|
bases.append(base_name)
|
|
297
331
|
|
|
298
332
|
is_abstract = "ABC" in bases or "ABCMeta" in bases
|
|
333
|
+
_enum_names = {"Enum", "IntEnum", "StrEnum", "Flag", "IntFlag"}
|
|
334
|
+
is_enum = any(
|
|
335
|
+
b.rsplit(".", 1)[-1] in _enum_names for b in bases
|
|
336
|
+
)
|
|
299
337
|
class_node = self._make_node(
|
|
300
338
|
NodeKind.CLASS,
|
|
301
339
|
qname,
|
|
@@ -305,20 +343,24 @@ class PythonASTVisitor:
|
|
|
305
343
|
"decorators": decorators,
|
|
306
344
|
"bases": bases,
|
|
307
345
|
"is_abstract": is_abstract,
|
|
346
|
+
"is_enum": is_enum,
|
|
308
347
|
},
|
|
348
|
+
name_node=name_node,
|
|
309
349
|
)
|
|
310
350
|
self._add_node_with_relation(class_node, RelationKind.DECLARES)
|
|
311
351
|
|
|
312
|
-
#
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
352
|
+
# Decorator arguments used as values (e.g. @deco(handler)).
|
|
353
|
+
self._scan_decorators(decorator_nodes, class_node.id)
|
|
354
|
+
|
|
355
|
+
# Record base occurrences (resolver emits INHERITS_FROM later)
|
|
356
|
+
if arg_list:
|
|
357
|
+
for c in arg_list.children:
|
|
358
|
+
if c.type in ("identifier", "attribute"):
|
|
359
|
+
base_name_node = _first_identifier(c)
|
|
360
|
+
if base_name_node is not None:
|
|
361
|
+
self._record_occurrence(
|
|
362
|
+
"base", base_name_node, class_node.id
|
|
363
|
+
)
|
|
322
364
|
|
|
323
365
|
self._push(qname, class_node.id, NodeKind.CLASS)
|
|
324
366
|
body = next((c for c in node.children if c.type == "block"), None)
|
|
@@ -326,7 +368,12 @@ class PythonASTVisitor:
|
|
|
326
368
|
self._visit_children(body)
|
|
327
369
|
self._pop()
|
|
328
370
|
|
|
329
|
-
def _handle_function(
|
|
371
|
+
def _handle_function(
|
|
372
|
+
self,
|
|
373
|
+
node: TSNode,
|
|
374
|
+
decorators: list[str],
|
|
375
|
+
decorator_nodes: list[TSNode] | None = None,
|
|
376
|
+
) -> None:
|
|
330
377
|
is_async = any(c.type == "async" for c in node.children)
|
|
331
378
|
parent_kind = self._kind_stack[-1]
|
|
332
379
|
kind = (
|
|
@@ -368,9 +415,18 @@ class PythonASTVisitor:
|
|
|
368
415
|
"is_property": "property" in decorators,
|
|
369
416
|
"return_annotation": return_annotation,
|
|
370
417
|
},
|
|
418
|
+
name_node=name_node,
|
|
371
419
|
)
|
|
372
420
|
self._add_node_with_relation(func_node, RelationKind.DECLARES)
|
|
373
421
|
|
|
422
|
+
# Decorator arguments used as values (e.g. @deco(handler)).
|
|
423
|
+
self._scan_decorators(decorator_nodes, func_node.id)
|
|
424
|
+
|
|
425
|
+
# Record return annotation occurrence
|
|
426
|
+
if type_node is not None:
|
|
427
|
+
self._record_annotation(type_node, func_node.id)
|
|
428
|
+
self._scan_annotation_calls(type_node, func_node.id)
|
|
429
|
+
|
|
374
430
|
self._push(qname, func_node.id, kind)
|
|
375
431
|
|
|
376
432
|
# Parameters
|
|
@@ -380,18 +436,11 @@ class PythonASTVisitor:
|
|
|
380
436
|
if params_node:
|
|
381
437
|
self._extract_parameters(params_node, func_node.id, qname)
|
|
382
438
|
|
|
383
|
-
# Body:
|
|
439
|
+
# Body: single traversal records calls + reads + dispatches nested
|
|
440
|
+
# defs, with no double-counting.
|
|
384
441
|
body = next((c for c in node.children if c.type == "block"), None)
|
|
385
442
|
if body:
|
|
386
|
-
self.
|
|
387
|
-
# Visit nested class/function definitions
|
|
388
|
-
for child in body.children:
|
|
389
|
-
if child.type in (
|
|
390
|
-
"function_definition",
|
|
391
|
-
"class_definition",
|
|
392
|
-
"decorated_definition",
|
|
393
|
-
):
|
|
394
|
-
self.visit(child)
|
|
443
|
+
self._walk_body(body, func_node.id)
|
|
395
444
|
|
|
396
445
|
self._pop()
|
|
397
446
|
|
|
@@ -407,8 +456,11 @@ class PythonASTVisitor:
|
|
|
407
456
|
annotation: str | None = None
|
|
408
457
|
has_default = False
|
|
409
458
|
is_variadic = False
|
|
459
|
+
id_node: TSNode | None = None
|
|
460
|
+
ann_type_node: TSNode | None = None
|
|
410
461
|
|
|
411
462
|
if child.type == "identifier":
|
|
463
|
+
id_node = child
|
|
412
464
|
param_name = _node_text(child)
|
|
413
465
|
|
|
414
466
|
elif child.type == "default_parameter":
|
|
@@ -423,20 +475,24 @@ class PythonASTVisitor:
|
|
|
423
475
|
(c for c in child.children if c.type == "identifier"), None
|
|
424
476
|
)
|
|
425
477
|
param_name = _node_text(id_node) if id_node else None
|
|
426
|
-
|
|
478
|
+
ann_type_node = next(
|
|
427
479
|
(c for c in child.children if c.type == "type"), None
|
|
428
480
|
)
|
|
429
|
-
annotation =
|
|
481
|
+
annotation = (
|
|
482
|
+
_node_text(ann_type_node) if ann_type_node else None
|
|
483
|
+
)
|
|
430
484
|
|
|
431
485
|
elif child.type == "typed_default_parameter":
|
|
432
486
|
id_node = next(
|
|
433
487
|
(c for c in child.children if c.type == "identifier"), None
|
|
434
488
|
)
|
|
435
489
|
param_name = _node_text(id_node) if id_node else None
|
|
436
|
-
|
|
490
|
+
ann_type_node = next(
|
|
437
491
|
(c for c in child.children if c.type == "type"), None
|
|
438
492
|
)
|
|
439
|
-
annotation =
|
|
493
|
+
annotation = (
|
|
494
|
+
_node_text(ann_type_node) if ann_type_node else None
|
|
495
|
+
)
|
|
440
496
|
has_default = True
|
|
441
497
|
|
|
442
498
|
elif child.type in {
|
|
@@ -464,6 +520,7 @@ class PythonASTVisitor:
|
|
|
464
520
|
"has_default": has_default,
|
|
465
521
|
"is_variadic": is_variadic,
|
|
466
522
|
},
|
|
523
|
+
name_node=id_node,
|
|
467
524
|
)
|
|
468
525
|
self._safe_add_node(param_node)
|
|
469
526
|
self._graph.add_relation(
|
|
@@ -473,59 +530,285 @@ class PythonASTVisitor:
|
|
|
473
530
|
kind=RelationKind.DECLARES,
|
|
474
531
|
)
|
|
475
532
|
)
|
|
533
|
+
# Record annotation occurrence for typed parameters
|
|
534
|
+
if ann_type_node is not None:
|
|
535
|
+
self._record_annotation(ann_type_node, param_node.id)
|
|
536
|
+
# Calls embedded in the annotation (e.g. Depends(get_dep))
|
|
537
|
+
# are value uses enclosed by the function, not the parameter.
|
|
538
|
+
self._scan_annotation_calls(ann_type_node, function_id)
|
|
476
539
|
|
|
477
540
|
# -------------------------------------------------------------------------
|
|
478
|
-
#
|
|
541
|
+
# Value scanning (single source of truth for read + call occurrences)
|
|
479
542
|
# -------------------------------------------------------------------------
|
|
480
543
|
|
|
481
|
-
|
|
482
|
-
""
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
544
|
+
_NESTED_DEF_TYPES = (
|
|
545
|
+
"function_definition",
|
|
546
|
+
"class_definition",
|
|
547
|
+
"decorated_definition",
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
def _scan_value(self, node: TSNode, enclosing_id: str) -> None:
|
|
551
|
+
"""
|
|
552
|
+
Record ``read`` and ``call`` occurrences for a value expression.
|
|
553
|
+
|
|
554
|
+
This is the single place that turns a value-position subtree into
|
|
555
|
+
occurrences, so each identifier yields exactly one ``read`` and each
|
|
556
|
+
call yields exactly one ``call``. Used for assignment right-hand
|
|
557
|
+
sides, return expressions, standalone expression statements, call
|
|
558
|
+
argument lists, decorator arguments, and ``Annotated[...]`` /
|
|
559
|
+
``Depends(...)`` annotations.
|
|
560
|
+
|
|
561
|
+
Rules:
|
|
562
|
+
- ``call`` → record a ``call`` on the callee name, then scan each
|
|
563
|
+
argument (nested calls + identifier args become occurrences).
|
|
564
|
+
The callee's receiver (``obj`` in ``obj.m()``) is not recorded.
|
|
565
|
+
- ``identifier`` → record a ``read``.
|
|
566
|
+
- otherwise → recurse into children.
|
|
567
|
+
|
|
568
|
+
Value expressions never contain nested definitions (those are
|
|
569
|
+
statements), so no def-skipping guard is needed here.
|
|
570
|
+
"""
|
|
487
571
|
if node.type == "call":
|
|
488
|
-
|
|
489
|
-
(
|
|
490
|
-
|
|
491
|
-
for c in node.children
|
|
492
|
-
if c.type in ("identifier", "attribute")
|
|
493
|
-
),
|
|
572
|
+
callee = next(
|
|
573
|
+
(c for c in node.children
|
|
574
|
+
if c.type in ("identifier", "attribute")),
|
|
494
575
|
None,
|
|
495
576
|
)
|
|
496
|
-
if
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
577
|
+
if callee is not None:
|
|
578
|
+
name_node = (
|
|
579
|
+
callee.children[-1]
|
|
580
|
+
if callee.type == "attribute" else callee
|
|
581
|
+
)
|
|
582
|
+
self._record_occurrence("call", name_node, enclosing_id)
|
|
583
|
+
arg_list = next(
|
|
584
|
+
(c for c in node.children if c.type == "argument_list"),
|
|
585
|
+
None,
|
|
586
|
+
)
|
|
587
|
+
if arg_list is not None:
|
|
588
|
+
for c in arg_list.children:
|
|
589
|
+
if c.type not in ("(", ")", ","):
|
|
590
|
+
if c.type == "keyword_argument":
|
|
591
|
+
# Scan only the value (last child), not the name,
|
|
592
|
+
# to avoid spurious REFERENCES on kwarg names.
|
|
593
|
+
val = c.children[-1] if c.children else None
|
|
594
|
+
if val is not None:
|
|
595
|
+
self._scan_value(val, enclosing_id)
|
|
596
|
+
else:
|
|
597
|
+
self._scan_value(c, enclosing_id)
|
|
598
|
+
return
|
|
599
|
+
if node.type == "identifier":
|
|
600
|
+
self._record_occurrence("read", node, enclosing_id)
|
|
601
|
+
return
|
|
602
|
+
for child in node.children:
|
|
603
|
+
self._scan_value(child, enclosing_id)
|
|
604
|
+
|
|
605
|
+
def _walk_body(self, body: TSNode, enclosing_id: str) -> None:
|
|
606
|
+
"""
|
|
607
|
+
Walk a function/module/class body once, recording occurrences.
|
|
608
|
+
|
|
609
|
+
A single traversal records calls and reads with no double-counting:
|
|
610
|
+
assignments and return statements go through their dedicated
|
|
611
|
+
handlers; every other value-position expression goes through
|
|
612
|
+
``_scan_value``. Nested definitions are dispatched to ``visit`` so
|
|
613
|
+
their own nodes/bodies are built. Compound statements (if/for/while/
|
|
614
|
+
with/try/...) are descended into so calls and reads in nested blocks
|
|
615
|
+
and headers are captured.
|
|
616
|
+
"""
|
|
617
|
+
for child in body.children:
|
|
618
|
+
self._walk_statement(child, enclosing_id)
|
|
619
|
+
|
|
620
|
+
def _walk_statement(self, node: TSNode, enclosing_id: str) -> None:
|
|
621
|
+
"""
|
|
622
|
+
Dispatch a single statement (or clause) node (see ``_walk_body``).
|
|
623
|
+
|
|
624
|
+
Assignments and returns go through their dedicated handlers; nested
|
|
625
|
+
definitions are dispatched to ``visit``; ``block`` children and
|
|
626
|
+
block-bearing clauses (``else_clause``, ``except_clause``, ...) are
|
|
627
|
+
recursed into; every remaining child is a value expression scanned
|
|
628
|
+
via ``_scan_value``.
|
|
629
|
+
"""
|
|
630
|
+
if node.type in self._NESTED_DEF_TYPES:
|
|
631
|
+
self.visit(node)
|
|
632
|
+
return
|
|
633
|
+
if node.type == "expression_statement":
|
|
527
634
|
for child in node.children:
|
|
528
|
-
|
|
635
|
+
if child.type == "assignment":
|
|
636
|
+
self._handle_assignment(child)
|
|
637
|
+
else:
|
|
638
|
+
self._scan_value(child, enclosing_id)
|
|
639
|
+
return
|
|
640
|
+
if node.type == "return_statement":
|
|
641
|
+
self._visit_return_statement(node)
|
|
642
|
+
return
|
|
643
|
+
# Compound / clause / other statements. Direct children are either
|
|
644
|
+
# blocks, block-bearing clauses (else/except/elif/finally/...), or
|
|
645
|
+
# header value expressions (conditions, iterables, context managers).
|
|
646
|
+
for child in node.children:
|
|
647
|
+
if child.type == "block":
|
|
648
|
+
self._walk_body(child, enclosing_id)
|
|
649
|
+
elif _has_block(child):
|
|
650
|
+
self._walk_statement(child, enclosing_id)
|
|
651
|
+
else:
|
|
652
|
+
self._scan_value(child, enclosing_id)
|
|
653
|
+
|
|
654
|
+
def _record_occurrence(
|
|
655
|
+
self, role: str, name_node: TSNode, enclosing_id: str
|
|
656
|
+
) -> None:
|
|
657
|
+
"""Append an OccurrenceRef for the given name node and role."""
|
|
658
|
+
span = _make_span(name_node)
|
|
659
|
+
if span is None:
|
|
660
|
+
return
|
|
661
|
+
self.occurrences.append(
|
|
662
|
+
OccurrenceRef(
|
|
663
|
+
role=role,
|
|
664
|
+
line=span.start_line,
|
|
665
|
+
col=span.start_col,
|
|
666
|
+
enclosing_id=enclosing_id,
|
|
667
|
+
span=span,
|
|
668
|
+
)
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
def _record_annotation(
|
|
672
|
+
self, type_node: TSNode, enclosing_id: str
|
|
673
|
+
) -> None:
|
|
674
|
+
"""
|
|
675
|
+
Record an ``annotation`` occurrence for the leading identifier.
|
|
676
|
+
|
|
677
|
+
The leading identifier is taken from a ``type`` node (return
|
|
678
|
+
annotation or parameter annotation).
|
|
679
|
+
"""
|
|
680
|
+
ident = _first_identifier(type_node)
|
|
681
|
+
if ident is not None:
|
|
682
|
+
self._record_occurrence("annotation", ident, enclosing_id)
|
|
683
|
+
|
|
684
|
+
def _scan_decorators(
|
|
685
|
+
self, decorator_nodes: list[TSNode] | None, enclosing_id: str
|
|
686
|
+
) -> None:
|
|
687
|
+
"""
|
|
688
|
+
Record call/read occurrences for decorator call arguments.
|
|
689
|
+
|
|
690
|
+
For ``@deco(handler)`` this records a ``call`` on ``deco`` and a
|
|
691
|
+
``read`` on the ``handler`` value argument. Bare decorators
|
|
692
|
+
(``@deco``) have no call node, so nothing is recorded.
|
|
693
|
+
"""
|
|
694
|
+
for dec in decorator_nodes or []:
|
|
695
|
+
call = next(
|
|
696
|
+
(c for c in dec.children if c.type == "call"), None
|
|
697
|
+
)
|
|
698
|
+
if call is not None:
|
|
699
|
+
self._scan_value(call, enclosing_id)
|
|
700
|
+
|
|
701
|
+
def _scan_annotation_calls(
|
|
702
|
+
self, type_node: TSNode, enclosing_id: str
|
|
703
|
+
) -> None:
|
|
704
|
+
"""
|
|
705
|
+
Scan ``call`` nodes embedded in a type annotation as values.
|
|
706
|
+
|
|
707
|
+
Covers ``Annotated[T, Depends(get_dep)]`` and similar: the
|
|
708
|
+
``Depends(...)`` call records a ``call`` on ``Depends`` and a
|
|
709
|
+
``read`` on ``get_dep``. Plain type identifiers are left to
|
|
710
|
+
``_record_annotation`` (HAS_TYPE), not recorded here.
|
|
711
|
+
"""
|
|
712
|
+
for call in _find_calls(type_node):
|
|
713
|
+
self._scan_value(call, enclosing_id)
|
|
714
|
+
|
|
715
|
+
# -------------------------------------------------------------------------
|
|
716
|
+
# Assignment / variable handling
|
|
717
|
+
# -------------------------------------------------------------------------
|
|
718
|
+
|
|
719
|
+
def _visit_return_statement(self, node: TSNode) -> None:
|
|
720
|
+
"""
|
|
721
|
+
Record ``read``/``call`` occurrences for a return expression.
|
|
722
|
+
|
|
723
|
+
Only the non-keyword children are inspected (the ``return`` keyword
|
|
724
|
+
is skipped). Value scanning is delegated to ``_scan_value`` so reads
|
|
725
|
+
and calls in the returned expression are recorded exactly once.
|
|
726
|
+
"""
|
|
727
|
+
for child in node.children:
|
|
728
|
+
if child.type != "return":
|
|
729
|
+
self._scan_value(child, self._container_stack[-1])
|
|
730
|
+
|
|
731
|
+
def _visit_expression_statement(self, node: TSNode) -> None:
|
|
732
|
+
"""
|
|
733
|
+
Dispatch expression_statement children at module/class scope.
|
|
734
|
+
|
|
735
|
+
Assignments create VARIABLE/ATTRIBUTE/TYPE_ALIAS nodes; every other
|
|
736
|
+
value-position expression is scanned via ``_scan_value`` so calls and
|
|
737
|
+
their argument reads are recorded exactly once. Function bodies do
|
|
738
|
+
not reach this method (they are driven by ``_walk_body``).
|
|
739
|
+
"""
|
|
740
|
+
for child in node.children:
|
|
741
|
+
if child.type == "assignment":
|
|
742
|
+
self._handle_assignment(child)
|
|
743
|
+
else:
|
|
744
|
+
self._scan_value(child, self._container_stack[-1])
|
|
745
|
+
|
|
746
|
+
def _handle_assignment(self, node: TSNode) -> None:
|
|
747
|
+
# TODO(deferred): tuple-unpacking / augmented / walrus assignments not
|
|
748
|
+
# modeled (see spec §9 deferred)
|
|
749
|
+
"""
|
|
750
|
+
Create a VARIABLE, ATTRIBUTE, or TYPE_ALIAS node from an assignment.
|
|
751
|
+
|
|
752
|
+
Dispatch rules:
|
|
753
|
+
- ``x: TypeAlias = v`` → TYPE_ALIAS
|
|
754
|
+
- ``self.attr = v`` → ATTRIBUTE
|
|
755
|
+
- inside class body → ATTRIBUTE
|
|
756
|
+
- otherwise → VARIABLE
|
|
757
|
+
"""
|
|
758
|
+
lhs = node.children[0]
|
|
759
|
+
annotation = next(
|
|
760
|
+
(c for c in node.children if c.type == "type"), None
|
|
761
|
+
)
|
|
762
|
+
rhs = node.children[-1] if node.children[-1] is not lhs else None
|
|
763
|
+
# For self.attr = v, use the LAST identifier child (the attribute
|
|
764
|
+
# name), not the first (which would be 'self').
|
|
765
|
+
if lhs.type == "attribute":
|
|
766
|
+
name_node = next(
|
|
767
|
+
(c for c in reversed(lhs.children) if c.type == "identifier"),
|
|
768
|
+
None,
|
|
769
|
+
)
|
|
770
|
+
else:
|
|
771
|
+
name_node = _first_identifier(lhs)
|
|
772
|
+
if name_node is None:
|
|
773
|
+
return
|
|
774
|
+
name = _node_text(name_node)
|
|
775
|
+
is_alias = (
|
|
776
|
+
annotation is not None
|
|
777
|
+
and _node_text(annotation) == "TypeAlias"
|
|
778
|
+
)
|
|
779
|
+
# Attribute: inside class body or lhs is self.<attr>
|
|
780
|
+
in_class = self._kind_stack[-1] == NodeKind.CLASS
|
|
781
|
+
is_self_attr = (
|
|
782
|
+
lhs.type == "attribute"
|
|
783
|
+
and lhs.children
|
|
784
|
+
and _node_text(lhs.children[0]) == "self"
|
|
785
|
+
)
|
|
786
|
+
kind: NodeKind
|
|
787
|
+
if is_alias:
|
|
788
|
+
kind = NodeKind.TYPE_ALIAS
|
|
789
|
+
elif in_class or is_self_attr:
|
|
790
|
+
kind = NodeKind.ATTRIBUTE
|
|
791
|
+
else:
|
|
792
|
+
kind = NodeKind.VARIABLE
|
|
793
|
+
qname = f"{self._scope_stack[-1]}.{name}"
|
|
794
|
+
var_node = self._make_node(
|
|
795
|
+
kind,
|
|
796
|
+
qname,
|
|
797
|
+
name,
|
|
798
|
+
node,
|
|
799
|
+
metadata={"is_constant": name.isupper()},
|
|
800
|
+
name_node=name_node,
|
|
801
|
+
)
|
|
802
|
+
self._add_node_with_relation(var_node, RelationKind.DECLARES)
|
|
803
|
+
self._record_occurrence("write", name_node, self._container_stack[-1])
|
|
804
|
+
if rhs is not None and not is_alias:
|
|
805
|
+
self._scan_value(rhs, self._container_stack[-1])
|
|
806
|
+
elif is_alias and rhs is not None:
|
|
807
|
+
ident = _first_identifier(rhs)
|
|
808
|
+
if ident is not None:
|
|
809
|
+
self._record_occurrence(
|
|
810
|
+
"annotation", ident, self._container_stack[-1]
|
|
811
|
+
)
|
|
529
812
|
|
|
530
813
|
# -------------------------------------------------------------------------
|
|
531
814
|
# Import helper
|
|
@@ -630,14 +913,26 @@ class PythonASTVisitor:
|
|
|
630
913
|
if node.id not in self._graph.nodes:
|
|
631
914
|
self._graph.add_node(node)
|
|
632
915
|
|
|
633
|
-
def _make_node(
|
|
916
|
+
def _make_node( # noqa: PLR0913
|
|
634
917
|
self,
|
|
635
918
|
kind: NodeKind,
|
|
636
919
|
qualified_name: str,
|
|
637
920
|
name: str,
|
|
638
921
|
ts_node: TSNode | None = None,
|
|
639
922
|
metadata: dict[str, object] | None = None,
|
|
923
|
+
name_node: TSNode | None = None,
|
|
640
924
|
) -> Node:
|
|
925
|
+
"""
|
|
926
|
+
Create a graph Node, optionally recording a ``name_span``.
|
|
927
|
+
|
|
928
|
+
The ``name_span`` captures the identifier token position so jedi
|
|
929
|
+
can map definition locations back to nodes.
|
|
930
|
+
"""
|
|
931
|
+
md = dict(metadata or {})
|
|
932
|
+
if name_node is not None:
|
|
933
|
+
name_span = _make_span(name_node)
|
|
934
|
+
if name_span is not None:
|
|
935
|
+
md["name_span"] = name_span
|
|
641
936
|
return Node(
|
|
642
937
|
id=make_node_id(
|
|
643
938
|
self._ctx.project_name, qualified_name, kind.value
|
|
@@ -647,7 +942,7 @@ class PythonASTVisitor:
|
|
|
647
942
|
name=name,
|
|
648
943
|
file_path=str(self._ctx.file_path),
|
|
649
944
|
span=_make_span(ts_node) if ts_node else None,
|
|
650
|
-
metadata=
|
|
945
|
+
metadata=md,
|
|
651
946
|
)
|
|
652
947
|
|
|
653
948
|
def _push(self, qname: str, node_id: str, kind: NodeKind) -> None:
|
|
@@ -732,3 +1027,41 @@ def _make_span(node: TSNode | None) -> Span | None:
|
|
|
732
1027
|
)
|
|
733
1028
|
except Exception:
|
|
734
1029
|
return None
|
|
1030
|
+
|
|
1031
|
+
|
|
1032
|
+
def _has_block(node: TSNode) -> bool:
|
|
1033
|
+
"""Return True if *node* directly contains a ``block`` child."""
|
|
1034
|
+
return any(c.type == "block" for c in node.children)
|
|
1035
|
+
|
|
1036
|
+
|
|
1037
|
+
def _find_calls(node: TSNode) -> list[TSNode]:
|
|
1038
|
+
"""
|
|
1039
|
+
Return the outermost ``call`` nodes reachable from *node*.
|
|
1040
|
+
|
|
1041
|
+
Does not descend into a call's own children, so nested calls inside
|
|
1042
|
+
arguments are left to the value scanner that processes each returned
|
|
1043
|
+
call. Used to locate ``Depends(...)``-style calls embedded inside type
|
|
1044
|
+
annotations.
|
|
1045
|
+
"""
|
|
1046
|
+
if node.type == "call":
|
|
1047
|
+
return [node]
|
|
1048
|
+
out: list[TSNode] = []
|
|
1049
|
+
for child in node.children:
|
|
1050
|
+
out.extend(_find_calls(child))
|
|
1051
|
+
return out
|
|
1052
|
+
|
|
1053
|
+
|
|
1054
|
+
def _first_identifier(node: TSNode) -> TSNode | None:
|
|
1055
|
+
"""
|
|
1056
|
+
Return the first ``identifier`` leaf reachable from *node* (pre-order).
|
|
1057
|
+
|
|
1058
|
+
Used to resolve the leading name in a composite type expression such as
|
|
1059
|
+
``list[int]``, ``Optional[str]``, or ``dict[str, int]``.
|
|
1060
|
+
"""
|
|
1061
|
+
if node.type == "identifier":
|
|
1062
|
+
return node
|
|
1063
|
+
for child in node.children:
|
|
1064
|
+
found = _first_identifier(child)
|
|
1065
|
+
if found is not None:
|
|
1066
|
+
return found
|
|
1067
|
+
return None
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: graphlens-python
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Python language adapter for graphlens
|
|
5
5
|
Requires-Dist: graphlens
|
|
6
6
|
Requires-Dist: tree-sitter>=0.24
|
|
7
7
|
Requires-Dist: tree-sitter-python>=0.23
|
|
8
|
+
Requires-Dist: ty
|
|
8
9
|
Requires-Python: >=3.13
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
graphlens_python/__init__.py,sha256=txtdNJKr1SrA_Z9PWqaPYA7UHIQuGbHcArvQeekOkC0,191
|
|
2
|
+
graphlens_python/_adapter.py,sha256=5M-OB6IPGuy_KW18OJ6mblQW2LMgPH1OCd9TlYNeUB4,13532
|
|
3
|
+
graphlens_python/_deps.py,sha256=cs1NbyRbKYbYh_qKFoM7IBvkp89vYtd2KWWp__SGRsQ,6716
|
|
4
|
+
graphlens_python/_module_resolver.py,sha256=0Haso97nTndH23xJn0kvr4MsMMh1mvEp0tDGdIFoPiY,2668
|
|
5
|
+
graphlens_python/_project_detector.py,sha256=-vRHZot3aHMvHXwE3nrXCXC9mSYX4y90xwwuQjY3u-c,3880
|
|
6
|
+
graphlens_python/_resolver.py,sha256=r18ake5S-aOCb3CpKtMukKhEkXsVGfy6bfQyoqWZWA8,14369
|
|
7
|
+
graphlens_python/_visitor.py,sha256=PS1-UVHzC3Bz_dO-WQIcX5kaSWQWgeQ50-BI1Po5T68,38277
|
|
8
|
+
graphlens_python-0.4.0.dist-info/WHEEL,sha256=oBsDExVIEya4llboy9Ce1l6on8xt3GrtT29y6pYVypw,81
|
|
9
|
+
graphlens_python-0.4.0.dist-info/entry_points.txt,sha256=HRwCWZJIrgqLXDR5lit5XnCkXDYc3lwWICTrrHPhWhw,62
|
|
10
|
+
graphlens_python-0.4.0.dist-info/METADATA,sha256=lm4mOiWTQ1DQ6_3X2pGS9j0ri0cT5eD9kC2nHYPs6Qc,247
|
|
11
|
+
graphlens_python-0.4.0.dist-info/RECORD,,
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
graphlens_python/__init__.py,sha256=eadIHZwAbZCE7TvpWUNONO3oMIFZQG3hdTVY_un9JIk,127
|
|
2
|
-
graphlens_python/_adapter.py,sha256=QDYlQ1ts6CiW64FhbXWJ__3vTI-EnwGPC-7vGvS5cQY,8572
|
|
3
|
-
graphlens_python/_deps.py,sha256=cs1NbyRbKYbYh_qKFoM7IBvkp89vYtd2KWWp__SGRsQ,6716
|
|
4
|
-
graphlens_python/_module_resolver.py,sha256=gABfLKnAOJLGFtKHPlrCAAv1MAwCV_DpNzpZXbzadik,2654
|
|
5
|
-
graphlens_python/_project_detector.py,sha256=-vRHZot3aHMvHXwE3nrXCXC9mSYX4y90xwwuQjY3u-c,3880
|
|
6
|
-
graphlens_python/_visitor.py,sha256=nCrKi3Fuub6R2XrvlXbdI1k2Flalb9lkU9dFCWy3bAo,25173
|
|
7
|
-
graphlens_python-0.3.0.dist-info/WHEEL,sha256=-unyBNsdNo6Y_XVO25zjvxasG_uUFN_fBd_kLyKE-Fk,81
|
|
8
|
-
graphlens_python-0.3.0.dist-info/entry_points.txt,sha256=HRwCWZJIrgqLXDR5lit5XnCkXDYc3lwWICTrrHPhWhw,62
|
|
9
|
-
graphlens_python-0.3.0.dist-info/METADATA,sha256=oRb802KwatpoZ6b1NBACry6TpIaQRx-qaVuAlcrxM1U,229
|
|
10
|
-
graphlens_python-0.3.0.dist-info/RECORD,,
|
|
File without changes
|