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.
@@ -1,5 +1,6 @@
1
1
  """Python language adapter for graphlens."""
2
2
 
3
3
  from graphlens_python._adapter import PythonAdapter
4
+ from graphlens_python._resolver import TyResolver
4
5
 
5
- __all__ = ["PythonAdapter"]
6
+ __all__ = ["PythonAdapter", "TyResolver"]
@@ -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 pathlib import Path
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:
@@ -17,7 +17,7 @@ def find_source_roots(project_root: Path, files: list[Path]) -> list[Path]:
17
17
  and any(files)
18
18
  and any(f.is_relative_to(src) for f in files)
19
19
  ):
20
- return [src]
20
+ return [src, project_root]
21
21
  return [project_root]
22
22
 
23
23
 
@@ -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()
@@ -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
- decorators = [
137
- _decorator_name(c) for c in node.children if c.type == "decorator"
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(self, node: TSNode, decorators: list[str]) -> None:
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
- # INHERITS_FROM
313
- for base_name in bases:
314
- sym = self._get_or_create_external_symbol(base_name)
315
- self._graph.add_relation(
316
- Relation(
317
- source_id=class_node.id,
318
- target_id=sym.id,
319
- kind=RelationKind.INHERITS_FROM,
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(self, node: TSNode, decorators: list[str]) -> None:
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: extract calls + visit nested defs
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._extract_calls(body, func_node.id)
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
- type_node = next(
478
+ ann_type_node = next(
427
479
  (c for c in child.children if c.type == "type"), None
428
480
  )
429
- annotation = _node_text(type_node) if type_node else None
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
- type_node = next(
490
+ ann_type_node = next(
437
491
  (c for c in child.children if c.type == "type"), None
438
492
  )
439
- annotation = _node_text(type_node) if type_node else None
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
- # Call extraction
541
+ # Value scanning (single source of truth for read + call occurrences)
479
542
  # -------------------------------------------------------------------------
480
543
 
481
- def _extract_calls(self, body: TSNode, caller_id: str) -> None:
482
- """Find all call nodes in body and emit CALLS relations."""
483
- for child in body.children:
484
- self._find_calls_in_node(child, caller_id)
485
-
486
- def _find_calls_in_node(self, node: TSNode, caller_id: str) -> None:
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
- func_node = next(
489
- (
490
- c
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 func_node:
497
- callee_name = _name_from_node(func_node)
498
- if callee_name:
499
- sym_id = make_node_id(
500
- self._ctx.project_name,
501
- callee_name,
502
- NodeKind.SYMBOL.value,
503
- )
504
- if sym_id not in self._graph.nodes:
505
- self._graph.add_node(
506
- Node(
507
- id=sym_id,
508
- kind=NodeKind.SYMBOL,
509
- qualified_name=callee_name,
510
- name=callee_name.split(".")[-1],
511
- span=_make_span(node),
512
- )
513
- )
514
- self._graph.add_relation(
515
- Relation(
516
- source_id=caller_id,
517
- target_id=sym_id,
518
- kind=RelationKind.CALLS,
519
- )
520
- )
521
- # Don't recurse into nested function/class definitions
522
- if node.type not in (
523
- "function_definition",
524
- "class_definition",
525
- "decorated_definition",
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
- self._find_calls_in_node(child, caller_id)
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=metadata or {},
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.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,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: uv 0.11.12
2
+ Generator: uv 0.11.23
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -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,,