loomweave-plugin-python 1.0.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.
- loomweave_plugin_python/__init__.py +3 -0
- loomweave_plugin_python/__main__.py +15 -0
- loomweave_plugin_python/call_resolver.py +65 -0
- loomweave_plugin_python/entity_id.py +75 -0
- loomweave_plugin_python/extractor.py +1312 -0
- loomweave_plugin_python/py.typed +0 -0
- loomweave_plugin_python/pyright_session.py +1655 -0
- loomweave_plugin_python/qualname.py +48 -0
- loomweave_plugin_python/reference_resolver.py +70 -0
- loomweave_plugin_python/server.py +310 -0
- loomweave_plugin_python/stdout_guard.py +62 -0
- loomweave_plugin_python/wardline_descriptor.py +197 -0
- loomweave_plugin_python-1.0.0.data/data/share/loomweave/plugins/python/plugin.toml +71 -0
- loomweave_plugin_python-1.0.0.dist-info/METADATA +73 -0
- loomweave_plugin_python-1.0.0.dist-info/RECORD +17 -0
- loomweave_plugin_python-1.0.0.dist-info/WHEEL +4 -0
- loomweave_plugin_python-1.0.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,1312 @@
|
|
|
1
|
+
"""AST → entity extractor for the Python plugin (Sprint 2 / B.2).
|
|
2
|
+
|
|
3
|
+
Walks a parsed Python file and emits one ``module`` entity per file plus
|
|
4
|
+
one ``function`` entity per ``FunctionDef`` / ``AsyncFunctionDef`` and one
|
|
5
|
+
``class`` entity per ``ClassDef``. It also emits anchored scan-time
|
|
6
|
+
``imports``, ``calls``, and ``references`` candidate edges.
|
|
7
|
+
|
|
8
|
+
Entity shape matches the Rust host's ``RawEntity`` + ``RawSource``
|
|
9
|
+
contract (``crates/loomweave-core/src/plugin/host.rs:132-154``)::
|
|
10
|
+
|
|
11
|
+
{
|
|
12
|
+
"id": "python:function:...",
|
|
13
|
+
"kind": "function",
|
|
14
|
+
"qualified_name": "pkg.module.func",
|
|
15
|
+
"source": {
|
|
16
|
+
"file_path": "pkg/module.py",
|
|
17
|
+
"source_range": {
|
|
18
|
+
"start_line": 1, "start_col": 0,
|
|
19
|
+
"end_line": 3, "end_col": 4,
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
``source.file_path`` lands in the host's path jail (canonicalised +
|
|
25
|
+
checked against ``project_root``); any other source-side fields flow
|
|
26
|
+
through ``RawSource.extra`` (serde flatten) and are bounded by
|
|
27
|
+
``MAX_ENTITY_EXTRA_BYTES`` (64 KiB). ``qualified_name`` is the dotted
|
|
28
|
+
module prefix joined to Python's own ``__qualname__`` (reconstructed
|
|
29
|
+
per L7). The file_path passed on the wire may be absolute (what the
|
|
30
|
+
host sent) while the prefix used for qualified-name dotting can be the
|
|
31
|
+
relativised form — the two are decoupled via ``extract``'s
|
|
32
|
+
``module_prefix_path`` kwarg.
|
|
33
|
+
|
|
34
|
+
Behaviour (B.2 §3 Q1 supersedes Sprint-1 UQ-WP3-11 for module entities):
|
|
35
|
+
|
|
36
|
+
- Every analyzed file produces exactly one ``module`` entity. Empty
|
|
37
|
+
files and comment-only files emit one with ``parse_status="ok"``.
|
|
38
|
+
Zero *function* entities for empty files still holds (UQ-WP3-11).
|
|
39
|
+
- ``SyntaxError`` during ``ast.parse`` → one degraded module entity
|
|
40
|
+
with ``parse_status="syntax_error"`` plus one stderr log line
|
|
41
|
+
(UQ-WP3-02). The run continues; WP4-era findings can later attach a
|
|
42
|
+
``LMWV-PY-SYNTAX-ERROR`` annotation.
|
|
43
|
+
- Top-level ``__init__.py`` (where the dotted module name resolves to
|
|
44
|
+
``""``) is skipped with stderr; the entity-ID assembler rejects an
|
|
45
|
+
empty ``canonical_qualified_name``.
|
|
46
|
+
- Paths starting with ``src/`` have the prefix stripped (UQ-WP3-05).
|
|
47
|
+
- ``pkg/__init__.py`` files yield qualified_names rooted at ``pkg``
|
|
48
|
+
(not ``pkg.__init__``) — UQ-WP3-06.
|
|
49
|
+
|
|
50
|
+
Module-entity ``source_range`` is a whole-file cover with ``end_col=0``
|
|
51
|
+
as a sentinel for module entities only — class and function entities
|
|
52
|
+
carry real ``ast.*.end_col_offset`` data, so consumers must NOT infer
|
|
53
|
+
column semantics by analogy across kinds.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
from __future__ import annotations
|
|
57
|
+
|
|
58
|
+
import ast
|
|
59
|
+
import sys
|
|
60
|
+
import time
|
|
61
|
+
from dataclasses import dataclass, field
|
|
62
|
+
from pathlib import PurePosixPath
|
|
63
|
+
from typing import TYPE_CHECKING, Literal, NotRequired, TypedDict, cast
|
|
64
|
+
|
|
65
|
+
from loomweave_plugin_python.call_resolver import (
|
|
66
|
+
CallResolutionResult,
|
|
67
|
+
CallResolver,
|
|
68
|
+
CallsEdgeProperties,
|
|
69
|
+
Finding,
|
|
70
|
+
NoOpCallResolver,
|
|
71
|
+
UnresolvedCallSite,
|
|
72
|
+
)
|
|
73
|
+
from loomweave_plugin_python.entity_id import entity_id
|
|
74
|
+
from loomweave_plugin_python.qualname import reconstruct_qualname
|
|
75
|
+
from loomweave_plugin_python.reference_resolver import (
|
|
76
|
+
NoOpReferenceResolver,
|
|
77
|
+
ReferenceResolutionResult,
|
|
78
|
+
ReferenceResolver,
|
|
79
|
+
ReferencesEdgeProperties,
|
|
80
|
+
ReferenceSite,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
if TYPE_CHECKING:
|
|
84
|
+
from loomweave_plugin_python.wardline_descriptor import WardlineVocabulary
|
|
85
|
+
|
|
86
|
+
_PLUGIN_ID = "python"
|
|
87
|
+
_NOOP_CALL_RESOLVER = NoOpCallResolver()
|
|
88
|
+
_NOOP_REFERENCE_RESOLVER = NoOpReferenceResolver()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class SourceRange(TypedDict):
|
|
92
|
+
start_line: int
|
|
93
|
+
start_col: int
|
|
94
|
+
end_line: int
|
|
95
|
+
end_col: int
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class EntitySource(TypedDict):
|
|
99
|
+
file_path: str
|
|
100
|
+
source_range: SourceRange
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class DefinitionSpan(TypedDict):
|
|
104
|
+
"""Sub-ranges within a function/class entity (clarion-460def6a51).
|
|
105
|
+
|
|
106
|
+
``decl_line`` is the line of the ``def``/``class`` keyword (Python 3.8+
|
|
107
|
+
``node.lineno``). ``body_line_start`` is the line of the first body
|
|
108
|
+
statement (a docstring counts). The two ``decorator_*`` keys are present
|
|
109
|
+
only when the entity is decorated; ``decorator_line_start`` is the
|
|
110
|
+
topmost decorator line and matches the entity's expanded
|
|
111
|
+
``source_range.start_line``. They let a reader explain *why* a given
|
|
112
|
+
line resolved to this entity (decorator vs declaration vs body) without
|
|
113
|
+
re-reading source.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
decl_line: int
|
|
117
|
+
body_line_start: NotRequired[int]
|
|
118
|
+
decorator_line_start: NotRequired[int]
|
|
119
|
+
decorator_line_end: NotRequired[int]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# SEI signature schema version (ADR-038 REQ-C-01). Mirrors plugin.toml's
|
|
123
|
+
# `[signature] schema_version`; bumped when a per-kind shape changes
|
|
124
|
+
# incompatibly. The value is stamped into every emitted signature's ``v`` field
|
|
125
|
+
# so a consumer can detect a version change as a changed signature.
|
|
126
|
+
SIGNATURE_SCHEMA_VERSION = 1
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class FunctionSignature(TypedDict):
|
|
130
|
+
"""SEI signature for a ``function`` entity (ADR-038 REQ-C-01).
|
|
131
|
+
|
|
132
|
+
``params`` is the ordered parameter list rendered as ``name`` or
|
|
133
|
+
``name: annotation`` strings (positional-only, positional, ``*args``,
|
|
134
|
+
keyword-only, ``**kwargs`` in source order). ``return_ann`` is the unparsed
|
|
135
|
+
return annotation or ``None``.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
v: int
|
|
139
|
+
params: list[str]
|
|
140
|
+
return_ann: str | None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class ClassSignature(TypedDict):
|
|
144
|
+
"""SEI signature for a ``class`` entity (ADR-038 REQ-C-01): the unparsed
|
|
145
|
+
base-class expressions in declaration order."""
|
|
146
|
+
|
|
147
|
+
v: int
|
|
148
|
+
bases: list[str]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class WardlineDecoratorMetadata(TypedDict):
|
|
152
|
+
canonical_name: str
|
|
153
|
+
qualified_name: str
|
|
154
|
+
group: int
|
|
155
|
+
attrs: dict[str, str]
|
|
156
|
+
line: int
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class WardlineEntityMetadata(TypedDict):
|
|
160
|
+
descriptor_version: str
|
|
161
|
+
confidence_basis: Literal["descriptor", "descriptor_version_skew"]
|
|
162
|
+
decorators: list[WardlineDecoratorMetadata]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class RawEntity(TypedDict):
|
|
166
|
+
"""Wire shape matching the Rust host's RawEntity contract.
|
|
167
|
+
|
|
168
|
+
``parse_status`` is set on module entities only and rides through the
|
|
169
|
+
host's ``serde(flatten) extra`` map. Class and function entities omit
|
|
170
|
+
it; the field is ``NotRequired`` to keep mypy --strict happy.
|
|
171
|
+
|
|
172
|
+
``parent_id`` is a B.3 addition (ADR-026 decision 2): the dual-encoded
|
|
173
|
+
half of the parent/contains relationship. Omitted entirely for module
|
|
174
|
+
entities (they have no parent within the file); set on every
|
|
175
|
+
function/class entity.
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
id: str
|
|
179
|
+
kind: str # "function" | "class" | "module"; not narrowed to keep extension cheap.
|
|
180
|
+
qualified_name: str
|
|
181
|
+
source: EntitySource
|
|
182
|
+
parent_id: NotRequired[str]
|
|
183
|
+
parse_status: NotRequired[Literal["ok", "syntax_error"]]
|
|
184
|
+
# entity_context evidence (clarion-460def6a51). Set on function/class
|
|
185
|
+
# entities; omitted for modules. Rides the host's RawEntity `extra` flatten
|
|
186
|
+
# into `properties_json`, so no host or storage schema change is needed.
|
|
187
|
+
definition: NotRequired[DefinitionSpan]
|
|
188
|
+
# SEI signature (ADR-038 REQ-C-01 / Wave 1). A plugin-declared, versioned
|
|
189
|
+
# JSON object the core stores verbatim and compares by string equality as
|
|
190
|
+
# the matcher's move-case input. Set on function/class entities; omitted for
|
|
191
|
+
# modules (the move case abstains — fail closed). Typed top-level field on
|
|
192
|
+
# the host's RawEntity, not routed through `extra`.
|
|
193
|
+
signature: NotRequired[FunctionSignature | ClassSignature]
|
|
194
|
+
# WS5b catalogue/reachability categorisations. Typed top-level because the
|
|
195
|
+
# core denormalises these into `entity_tags`; unknown/empty means no signal.
|
|
196
|
+
tags: NotRequired[list[str]]
|
|
197
|
+
# Short natural-language text used by analyze-time semantic embeddings.
|
|
198
|
+
docstring: NotRequired[str]
|
|
199
|
+
# Wardline descriptor-backed source-observed decorator facts. Wardline owns
|
|
200
|
+
# the vocabulary; Loomweave stores only the annotation facts seen on entities.
|
|
201
|
+
wardline: NotRequired[WardlineEntityMetadata]
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class RawEdge(TypedDict):
|
|
205
|
+
"""Wire shape matching the Rust host's RawEdge contract (B.3 / ADR-026).
|
|
206
|
+
|
|
207
|
+
Source range fields are NotRequired and omitted entirely for structural
|
|
208
|
+
kinds (``contains``); anchored kinds (``calls``, etc.) include them when
|
|
209
|
+
the language reaches that part of the ontology in later sprints.
|
|
210
|
+
"""
|
|
211
|
+
|
|
212
|
+
kind: str
|
|
213
|
+
from_id: str
|
|
214
|
+
to_id: str
|
|
215
|
+
source_byte_start: NotRequired[int]
|
|
216
|
+
source_byte_end: NotRequired[int]
|
|
217
|
+
confidence: NotRequired[Literal["resolved", "ambiguous", "inferred"]]
|
|
218
|
+
properties: NotRequired[CallsEdgeProperties | ReferencesEdgeProperties | ImportsEdgeProperties]
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class ImportsEdgeProperties(TypedDict):
|
|
222
|
+
imported_name: str
|
|
223
|
+
import_style: Literal["import", "from_import"]
|
|
224
|
+
level: int
|
|
225
|
+
type_only: NotRequired[bool]
|
|
226
|
+
scope: NotRequired[Literal["function"]]
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@dataclass
|
|
230
|
+
class ExtractionStats:
|
|
231
|
+
unresolved_call_sites_total: int = 0
|
|
232
|
+
unresolved_call_sites: list[UnresolvedCallSite] = field(default_factory=list)
|
|
233
|
+
reference_sites_total: int = 0
|
|
234
|
+
references_resolved_total: int = 0
|
|
235
|
+
references_skipped_external_total: int = 0
|
|
236
|
+
references_skipped_cap_total: int = 0
|
|
237
|
+
unresolved_reference_sites_total: int = 0
|
|
238
|
+
pyright_query_latency_ms: list[int] = field(default_factory=list)
|
|
239
|
+
pyright_index_parse_latency_ms: list[int] = field(default_factory=list)
|
|
240
|
+
extractor_parse_latency_ms: int = 0
|
|
241
|
+
findings: list[Finding] = field(default_factory=list)
|
|
242
|
+
duplicate_entities_dropped_total: int = 0
|
|
243
|
+
|
|
244
|
+
@classmethod
|
|
245
|
+
def from_resolution_results(
|
|
246
|
+
cls,
|
|
247
|
+
calls: CallResolutionResult,
|
|
248
|
+
references: ReferenceResolutionResult,
|
|
249
|
+
) -> ExtractionStats:
|
|
250
|
+
return cls(
|
|
251
|
+
unresolved_call_sites_total=calls.unresolved_call_sites_total,
|
|
252
|
+
unresolved_call_sites=calls.unresolved_call_sites,
|
|
253
|
+
reference_sites_total=references.reference_sites_total,
|
|
254
|
+
references_resolved_total=references.references_resolved_total,
|
|
255
|
+
references_skipped_external_total=references.references_skipped_external_total,
|
|
256
|
+
references_skipped_cap_total=references.references_skipped_cap_total,
|
|
257
|
+
unresolved_reference_sites_total=references.unresolved_reference_sites_total,
|
|
258
|
+
pyright_query_latency_ms=[
|
|
259
|
+
*calls.pyright_query_latency_ms,
|
|
260
|
+
*references.pyright_query_latency_ms,
|
|
261
|
+
],
|
|
262
|
+
pyright_index_parse_latency_ms=[
|
|
263
|
+
*calls.pyright_index_parse_latency_ms,
|
|
264
|
+
*references.pyright_index_parse_latency_ms,
|
|
265
|
+
],
|
|
266
|
+
findings=[*calls.findings, *references.findings],
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@dataclass
|
|
271
|
+
class ExtractResult:
|
|
272
|
+
entities: list[RawEntity]
|
|
273
|
+
edges: list[RawEdge]
|
|
274
|
+
stats: ExtractionStats
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _module_source_range(source: str) -> SourceRange:
|
|
278
|
+
"""Whole-file cover for module entities (Q4 resolution, B.2 §3 Q4).
|
|
279
|
+
|
|
280
|
+
Uniform formula regardless of ``parse_status``: ``end_line =
|
|
281
|
+
source.count('\\n') + 1``, ``end_col = 0``. The ``end_col = 0`` value
|
|
282
|
+
is a sentinel for module entities only — it means "end-of-file," NOT
|
|
283
|
+
"column 0 of the last line." Class and function entities use real
|
|
284
|
+
``ast.*.end_col_offset`` data; consumers must not infer column
|
|
285
|
+
semantics by analogy across kinds.
|
|
286
|
+
"""
|
|
287
|
+
return {
|
|
288
|
+
"start_line": 1,
|
|
289
|
+
"start_col": 0,
|
|
290
|
+
"end_line": source.count("\n") + 1,
|
|
291
|
+
"end_col": 0,
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def module_dotted_name(module_path: str) -> str:
|
|
296
|
+
"""Derive the dotted module prefix from a root-relative source path.
|
|
297
|
+
|
|
298
|
+
Rules:
|
|
299
|
+
- Leading ``src/`` is stripped (UQ-WP3-05).
|
|
300
|
+
- The ``.py`` suffix is dropped.
|
|
301
|
+
- ``__init__`` filenames collapse to their containing package
|
|
302
|
+
(UQ-WP3-06: ``pkg/__init__.py`` → ``pkg``).
|
|
303
|
+
- Path separators become ``.``.
|
|
304
|
+
|
|
305
|
+
``module_path`` itself remains unchanged; it's stored on the entity
|
|
306
|
+
as a property so WP4 can still find the file on disk.
|
|
307
|
+
"""
|
|
308
|
+
parts = list(PurePosixPath(module_path).parts)
|
|
309
|
+
if parts and parts[0] == "src":
|
|
310
|
+
parts = parts[1:]
|
|
311
|
+
if parts:
|
|
312
|
+
last = parts[-1]
|
|
313
|
+
if last.endswith(".py"):
|
|
314
|
+
stem = last[:-3]
|
|
315
|
+
if stem == "__init__":
|
|
316
|
+
parts = parts[:-1]
|
|
317
|
+
else:
|
|
318
|
+
parts[-1] = stem
|
|
319
|
+
return ".".join(parts)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _build_module_entity(
|
|
323
|
+
source: str,
|
|
324
|
+
dotted_module: str,
|
|
325
|
+
file_path: str,
|
|
326
|
+
parse_status: Literal["ok", "syntax_error"],
|
|
327
|
+
docstring: str | None = None,
|
|
328
|
+
) -> RawEntity:
|
|
329
|
+
"""Build the per-file module entity (Q1 + Q4 resolutions)."""
|
|
330
|
+
entity: RawEntity = {
|
|
331
|
+
"id": entity_id(_PLUGIN_ID, "module", dotted_module),
|
|
332
|
+
"kind": "module",
|
|
333
|
+
"qualified_name": dotted_module,
|
|
334
|
+
"source": {
|
|
335
|
+
"file_path": file_path,
|
|
336
|
+
"source_range": _module_source_range(source),
|
|
337
|
+
},
|
|
338
|
+
"parse_status": parse_status,
|
|
339
|
+
}
|
|
340
|
+
_attach_optional_entity_metadata(entity, docstring=docstring, tags=[])
|
|
341
|
+
return entity
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def extract( # noqa: PLR0913 - resolver seams + optional Wardline vocabulary are caller-owned.
|
|
345
|
+
source: str,
|
|
346
|
+
file_path: str,
|
|
347
|
+
*,
|
|
348
|
+
module_prefix_path: str | None = None,
|
|
349
|
+
call_resolver: CallResolver = _NOOP_CALL_RESOLVER,
|
|
350
|
+
reference_resolver: ReferenceResolver = _NOOP_REFERENCE_RESOLVER,
|
|
351
|
+
wardline_vocabulary: WardlineVocabulary | None = None,
|
|
352
|
+
) -> tuple[list[RawEntity], list[RawEdge]]:
|
|
353
|
+
result = extract_with_stats(
|
|
354
|
+
source,
|
|
355
|
+
file_path,
|
|
356
|
+
module_prefix_path=module_prefix_path,
|
|
357
|
+
call_resolver=call_resolver,
|
|
358
|
+
reference_resolver=reference_resolver,
|
|
359
|
+
wardline_vocabulary=wardline_vocabulary,
|
|
360
|
+
)
|
|
361
|
+
return result.entities, result.edges
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def extract_with_stats( # noqa: PLR0913 - resolver seams + optional Wardline vocabulary are caller-owned.
|
|
365
|
+
source: str,
|
|
366
|
+
file_path: str,
|
|
367
|
+
*,
|
|
368
|
+
module_prefix_path: str | None = None,
|
|
369
|
+
call_resolver: CallResolver = _NOOP_CALL_RESOLVER,
|
|
370
|
+
reference_resolver: ReferenceResolver = _NOOP_REFERENCE_RESOLVER,
|
|
371
|
+
wardline_vocabulary: WardlineVocabulary | None = None,
|
|
372
|
+
) -> ExtractResult:
|
|
373
|
+
"""Return extracted entities/edges plus resolver observability stats.
|
|
374
|
+
|
|
375
|
+
Always emits exactly one module entity (B.2 Q1) prepended to the
|
|
376
|
+
entity list; functions and classes follow. B.3 also emits one
|
|
377
|
+
``contains`` edge per non-module entity (immediate-parent → child),
|
|
378
|
+
plus a ``parent_id`` field on each non-module entity (the dual
|
|
379
|
+
encoding from ADR-026 decision 2). Module entities have no parent
|
|
380
|
+
within the file, so they omit ``parent_id`` and have no contains edge.
|
|
381
|
+
|
|
382
|
+
``file_path`` lands in each entity's ``source.file_path`` verbatim.
|
|
383
|
+
``module_prefix_path`` (default: same as ``file_path``) is the path
|
|
384
|
+
whose dotted form prefixes every entity's ``qualified_name`` —
|
|
385
|
+
callers can supply a project-relative path here while keeping
|
|
386
|
+
``file_path`` absolute so the host's path jail validates the
|
|
387
|
+
original path.
|
|
388
|
+
|
|
389
|
+
Same-id collisions (PEP-484 ``@overload`` stubs, ``singledispatch``
|
|
390
|
+
``def _(...):`` sequences, intentional redefinitions) are resolved at
|
|
391
|
+
the emit boundary so the host's ``UNIQUE(entities.id)`` never trips
|
|
392
|
+
mid-run. ``@overload`` stubs are recognised and dropped *before* the
|
|
393
|
+
walk descends — their bodies (``...``) carry only type-checker hints
|
|
394
|
+
so signature references are also suppressed. Any other duplicates
|
|
395
|
+
that survive (e.g. aliased ``from typing import overload as o``,
|
|
396
|
+
``singledispatch.register`` users writing ``def _():`` repeatedly)
|
|
397
|
+
are deduplicated first-wins with a stderr line per drop and
|
|
398
|
+
``ExtractionStats.duplicate_entities_dropped_total`` bumped per drop.
|
|
399
|
+
"""
|
|
400
|
+
prefix_source = module_prefix_path if module_prefix_path is not None else file_path
|
|
401
|
+
dotted_module = module_dotted_name(prefix_source)
|
|
402
|
+
is_package_module = PurePosixPath(prefix_source).name == "__init__.py"
|
|
403
|
+
|
|
404
|
+
# Top-level __init__.py would resolve to "" — entity_id() rejects that
|
|
405
|
+
# (crates/loomweave-core/src/entity_id.rs:97-101). Skip with stderr.
|
|
406
|
+
if not dotted_module:
|
|
407
|
+
sys.stderr.write(
|
|
408
|
+
f"loomweave-plugin-python: skipping {file_path}: "
|
|
409
|
+
f"top-level __init__.py has no package name\n",
|
|
410
|
+
)
|
|
411
|
+
return ExtractResult([], [], ExtractionStats())
|
|
412
|
+
|
|
413
|
+
parse_started_ns = time.perf_counter_ns()
|
|
414
|
+
try:
|
|
415
|
+
tree = ast.parse(source)
|
|
416
|
+
except SyntaxError as exc:
|
|
417
|
+
parse_latency_ms = _elapsed_ms(parse_started_ns)
|
|
418
|
+
sys.stderr.write(
|
|
419
|
+
f"loomweave-plugin-python: skipping {file_path}: syntax error at "
|
|
420
|
+
f"line {exc.lineno}: {exc.msg}\n",
|
|
421
|
+
)
|
|
422
|
+
return ExtractResult(
|
|
423
|
+
[_build_module_entity(source, dotted_module, file_path, "syntax_error")],
|
|
424
|
+
[],
|
|
425
|
+
ExtractionStats(extractor_parse_latency_ms=parse_latency_ms),
|
|
426
|
+
)
|
|
427
|
+
parse_latency_ms = _elapsed_ms(parse_started_ns)
|
|
428
|
+
|
|
429
|
+
module_entity = _build_module_entity(
|
|
430
|
+
source, dotted_module, file_path, "ok", ast.get_docstring(tree)
|
|
431
|
+
)
|
|
432
|
+
entities: list[RawEntity] = [module_entity]
|
|
433
|
+
edges: list[RawEdge] = []
|
|
434
|
+
function_ids: list[str] = []
|
|
435
|
+
walk_state = _WalkState(
|
|
436
|
+
seen_ids={module_entity["id"]},
|
|
437
|
+
file_path=file_path,
|
|
438
|
+
exported_names=_module_export_names(tree),
|
|
439
|
+
wardline_vocabulary=wardline_vocabulary,
|
|
440
|
+
)
|
|
441
|
+
_walk(
|
|
442
|
+
tree,
|
|
443
|
+
[tree],
|
|
444
|
+
dotted_module,
|
|
445
|
+
file_path,
|
|
446
|
+
module_entity["id"],
|
|
447
|
+
entities,
|
|
448
|
+
edges,
|
|
449
|
+
function_ids,
|
|
450
|
+
walk_state,
|
|
451
|
+
)
|
|
452
|
+
edges.extend(
|
|
453
|
+
_collect_import_edges(
|
|
454
|
+
source,
|
|
455
|
+
tree,
|
|
456
|
+
dotted_module,
|
|
457
|
+
module_entity["id"],
|
|
458
|
+
is_package_module=is_package_module,
|
|
459
|
+
),
|
|
460
|
+
)
|
|
461
|
+
reference_sites = _collect_reference_sites(source, tree, dotted_module, module_entity["id"])
|
|
462
|
+
call_stats = call_resolver.resolve_calls(file_path, function_ids)
|
|
463
|
+
reference_stats = reference_resolver.resolve_references(file_path, reference_sites)
|
|
464
|
+
edges.extend(cast("list[RawEdge]", call_stats.edges))
|
|
465
|
+
edges.extend(cast("list[RawEdge]", reference_stats.edges))
|
|
466
|
+
stats = ExtractionStats.from_resolution_results(call_stats, reference_stats)
|
|
467
|
+
stats.extractor_parse_latency_ms = parse_latency_ms
|
|
468
|
+
stats.duplicate_entities_dropped_total = walk_state.duplicate_entities_dropped
|
|
469
|
+
return ExtractResult(entities, edges, stats)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _elapsed_ms(started_ns: int) -> int:
|
|
473
|
+
return max(1, (time.perf_counter_ns() - started_ns + 999_999) // 1_000_000)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _collect_import_edges(
|
|
477
|
+
source: str,
|
|
478
|
+
tree: ast.Module,
|
|
479
|
+
dotted_module: str,
|
|
480
|
+
module_entity_id: str,
|
|
481
|
+
*,
|
|
482
|
+
is_package_module: bool,
|
|
483
|
+
) -> list[RawEdge]:
|
|
484
|
+
collector = _ImportEdgeCollector(
|
|
485
|
+
source,
|
|
486
|
+
dotted_module,
|
|
487
|
+
module_entity_id,
|
|
488
|
+
is_package_module=is_package_module,
|
|
489
|
+
)
|
|
490
|
+
collector.visit(tree)
|
|
491
|
+
return collector.edges
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
class _ImportEdgeCollector(ast.NodeVisitor):
|
|
495
|
+
def __init__(
|
|
496
|
+
self,
|
|
497
|
+
source: str,
|
|
498
|
+
dotted_module: str,
|
|
499
|
+
module_entity_id: str,
|
|
500
|
+
*,
|
|
501
|
+
is_package_module: bool,
|
|
502
|
+
) -> None:
|
|
503
|
+
self.source = source
|
|
504
|
+
self.dotted_module = dotted_module
|
|
505
|
+
self.module_entity_id = module_entity_id
|
|
506
|
+
self.is_package_module = is_package_module
|
|
507
|
+
self.edges: list[RawEdge] = []
|
|
508
|
+
self._function_depth = 0
|
|
509
|
+
self._type_only_depth = 0
|
|
510
|
+
|
|
511
|
+
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
512
|
+
self._function_depth += 1
|
|
513
|
+
try:
|
|
514
|
+
self.generic_visit(node)
|
|
515
|
+
finally:
|
|
516
|
+
self._function_depth -= 1
|
|
517
|
+
|
|
518
|
+
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
|
|
519
|
+
self._function_depth += 1
|
|
520
|
+
try:
|
|
521
|
+
self.generic_visit(node)
|
|
522
|
+
finally:
|
|
523
|
+
self._function_depth -= 1
|
|
524
|
+
|
|
525
|
+
def visit_If(self, node: ast.If) -> None:
|
|
526
|
+
if _is_type_checking_guard(node.test):
|
|
527
|
+
self._type_only_depth += 1
|
|
528
|
+
try:
|
|
529
|
+
for child in node.body:
|
|
530
|
+
self.visit(child)
|
|
531
|
+
finally:
|
|
532
|
+
self._type_only_depth -= 1
|
|
533
|
+
for child in node.orelse:
|
|
534
|
+
self.visit(child)
|
|
535
|
+
return
|
|
536
|
+
self.generic_visit(node)
|
|
537
|
+
|
|
538
|
+
def visit_Import(self, node: ast.Import) -> None:
|
|
539
|
+
source_byte_start, source_byte_end = _node_byte_range(self.source, node)
|
|
540
|
+
for alias in node.names:
|
|
541
|
+
self.edges.append(
|
|
542
|
+
self._edge(
|
|
543
|
+
target_module=alias.name,
|
|
544
|
+
imported_name=alias.name,
|
|
545
|
+
import_style="import",
|
|
546
|
+
level=0,
|
|
547
|
+
source_range=(source_byte_start, source_byte_end),
|
|
548
|
+
),
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
|
552
|
+
source_byte_start, source_byte_end = _node_byte_range(self.source, node)
|
|
553
|
+
for alias in node.names:
|
|
554
|
+
target_module = _import_from_target(
|
|
555
|
+
self.dotted_module,
|
|
556
|
+
node.module,
|
|
557
|
+
node.level,
|
|
558
|
+
alias.name,
|
|
559
|
+
is_package_module=self.is_package_module,
|
|
560
|
+
)
|
|
561
|
+
if target_module is None:
|
|
562
|
+
continue
|
|
563
|
+
self.edges.append(
|
|
564
|
+
self._edge(
|
|
565
|
+
target_module=target_module,
|
|
566
|
+
imported_name=alias.name,
|
|
567
|
+
import_style="from_import",
|
|
568
|
+
level=node.level,
|
|
569
|
+
source_range=(source_byte_start, source_byte_end),
|
|
570
|
+
),
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
def _edge(
|
|
574
|
+
self,
|
|
575
|
+
*,
|
|
576
|
+
target_module: str,
|
|
577
|
+
imported_name: str,
|
|
578
|
+
import_style: Literal["import", "from_import"],
|
|
579
|
+
level: int,
|
|
580
|
+
source_range: tuple[int, int],
|
|
581
|
+
) -> RawEdge:
|
|
582
|
+
source_byte_start, source_byte_end = source_range
|
|
583
|
+
properties: ImportsEdgeProperties = {
|
|
584
|
+
"imported_name": imported_name,
|
|
585
|
+
"import_style": import_style,
|
|
586
|
+
"level": level,
|
|
587
|
+
}
|
|
588
|
+
if self._type_only_depth > 0:
|
|
589
|
+
properties["type_only"] = True
|
|
590
|
+
if self._function_depth > 0:
|
|
591
|
+
properties["scope"] = "function"
|
|
592
|
+
return {
|
|
593
|
+
"kind": "imports",
|
|
594
|
+
"from_id": self.module_entity_id,
|
|
595
|
+
"to_id": entity_id(_PLUGIN_ID, "module", target_module),
|
|
596
|
+
"source_byte_start": source_byte_start,
|
|
597
|
+
"source_byte_end": source_byte_end,
|
|
598
|
+
"confidence": "resolved",
|
|
599
|
+
"properties": properties,
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def _is_type_checking_guard(expr: ast.expr) -> bool:
|
|
604
|
+
if isinstance(expr, ast.Name):
|
|
605
|
+
return expr.id == "TYPE_CHECKING"
|
|
606
|
+
if isinstance(expr, ast.Attribute):
|
|
607
|
+
return (
|
|
608
|
+
expr.attr == "TYPE_CHECKING"
|
|
609
|
+
and isinstance(expr.value, ast.Name)
|
|
610
|
+
and expr.value.id == "typing"
|
|
611
|
+
)
|
|
612
|
+
if isinstance(expr, ast.BoolOp):
|
|
613
|
+
if isinstance(expr.op, ast.And):
|
|
614
|
+
return any(_is_type_checking_guard(value) for value in expr.values)
|
|
615
|
+
if isinstance(expr.op, ast.Or):
|
|
616
|
+
return all(_is_type_checking_guard(value) for value in expr.values)
|
|
617
|
+
return False
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def _import_from_target(
|
|
621
|
+
dotted_module: str,
|
|
622
|
+
module: str | None,
|
|
623
|
+
level: int,
|
|
624
|
+
imported_name: str,
|
|
625
|
+
*,
|
|
626
|
+
is_package_module: bool,
|
|
627
|
+
) -> str | None:
|
|
628
|
+
if level == 0:
|
|
629
|
+
return module
|
|
630
|
+
|
|
631
|
+
base_parts = _relative_import_base_parts(
|
|
632
|
+
dotted_module,
|
|
633
|
+
level,
|
|
634
|
+
is_package_module=is_package_module,
|
|
635
|
+
)
|
|
636
|
+
if base_parts is None:
|
|
637
|
+
return None
|
|
638
|
+
|
|
639
|
+
target_parts = [*base_parts]
|
|
640
|
+
if module:
|
|
641
|
+
target_parts.extend(part for part in module.split(".") if part)
|
|
642
|
+
elif imported_name != "*":
|
|
643
|
+
target_parts.append(imported_name)
|
|
644
|
+
|
|
645
|
+
return ".".join(target_parts) if target_parts else None
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def _relative_import_base_parts(
|
|
649
|
+
dotted_module: str,
|
|
650
|
+
level: int,
|
|
651
|
+
*,
|
|
652
|
+
is_package_module: bool,
|
|
653
|
+
) -> list[str] | None:
|
|
654
|
+
all_parts = dotted_module.split(".")
|
|
655
|
+
package_parts = all_parts if is_package_module else all_parts[:-1]
|
|
656
|
+
keep = len(package_parts) - (level - 1)
|
|
657
|
+
if keep < 0:
|
|
658
|
+
return None
|
|
659
|
+
return package_parts[:keep]
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def _node_byte_range(source: str, node: ast.Import | ast.ImportFrom) -> tuple[int, int]:
|
|
663
|
+
line_starts = _line_starts(source)
|
|
664
|
+
start_line = node.lineno - 1
|
|
665
|
+
end_line = (node.end_lineno or node.lineno) - 1
|
|
666
|
+
end_col = node.end_col_offset if node.end_col_offset is not None else node.col_offset
|
|
667
|
+
return line_starts[start_line] + node.col_offset, line_starts[end_line] + end_col
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def _collect_reference_sites(
|
|
671
|
+
source: str,
|
|
672
|
+
tree: ast.Module,
|
|
673
|
+
dotted_module: str,
|
|
674
|
+
module_entity_id: str,
|
|
675
|
+
) -> list[ReferenceSite]:
|
|
676
|
+
collector = _ReferenceSiteCollector(source, tree, dotted_module, module_entity_id)
|
|
677
|
+
collector.visit(tree)
|
|
678
|
+
return collector.sites
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
class _ReferenceSiteCollector(ast.NodeVisitor):
|
|
682
|
+
def __init__(
|
|
683
|
+
self,
|
|
684
|
+
source: str,
|
|
685
|
+
tree: ast.Module,
|
|
686
|
+
dotted_module: str,
|
|
687
|
+
module_entity_id: str,
|
|
688
|
+
) -> None:
|
|
689
|
+
self.source = source
|
|
690
|
+
self.source_lines = source.splitlines(keepends=True)
|
|
691
|
+
self.line_starts = _line_starts(source)
|
|
692
|
+
self.dotted_module = dotted_module
|
|
693
|
+
self.parents: list[ast.AST] = [tree]
|
|
694
|
+
self.owner_stack = [module_entity_id]
|
|
695
|
+
self.bound_stack = [_scope_local_names(tree)]
|
|
696
|
+
self.annotation_depth = 0
|
|
697
|
+
self.sites: list[ReferenceSite] = []
|
|
698
|
+
|
|
699
|
+
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
700
|
+
self._visit_function(node)
|
|
701
|
+
|
|
702
|
+
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
|
|
703
|
+
self._visit_function(node)
|
|
704
|
+
|
|
705
|
+
def _visit_function(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
|
|
706
|
+
# PEP 484 stub: signature annotations are type-checker hints, not
|
|
707
|
+
# references the consult-mode briefing cares about; the body is `...`.
|
|
708
|
+
# Skipping keeps reference-site ownership consistent with `_walk` (no
|
|
709
|
+
# entity for the stub → nothing to attribute references to).
|
|
710
|
+
if _has_overload_decorator(node):
|
|
711
|
+
return
|
|
712
|
+
function_id = self._entity_id_for_scope("function", node)
|
|
713
|
+
self.owner_stack.append(function_id)
|
|
714
|
+
self.bound_stack.append(_scope_local_names(node))
|
|
715
|
+
self._visit_function_signature(node)
|
|
716
|
+
self.parents.append(node)
|
|
717
|
+
for statement in node.body:
|
|
718
|
+
self.visit(statement)
|
|
719
|
+
self.parents.pop()
|
|
720
|
+
self.bound_stack.pop()
|
|
721
|
+
self.owner_stack.pop()
|
|
722
|
+
|
|
723
|
+
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
724
|
+
class_id = self._entity_id_for_scope("class", node)
|
|
725
|
+
self.owner_stack.append(class_id)
|
|
726
|
+
self.bound_stack.append(_scope_local_names(node))
|
|
727
|
+
self.parents.append(node)
|
|
728
|
+
for statement in node.body:
|
|
729
|
+
self.visit(statement)
|
|
730
|
+
self.parents.pop()
|
|
731
|
+
self.bound_stack.pop()
|
|
732
|
+
self.owner_stack.pop()
|
|
733
|
+
|
|
734
|
+
def visit_Lambda(self, node: ast.Lambda) -> None:
|
|
735
|
+
# Lambdas are not entities in v0.1; keep the surrounding owner and
|
|
736
|
+
# suppress lambda-local argument names.
|
|
737
|
+
self.bound_stack.append(_lambda_bound_names(node))
|
|
738
|
+
self.visit(node.body)
|
|
739
|
+
self.bound_stack.pop()
|
|
740
|
+
|
|
741
|
+
def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
|
|
742
|
+
self._visit_annotation(node.annotation)
|
|
743
|
+
if node.value is not None:
|
|
744
|
+
self.visit(node.value)
|
|
745
|
+
|
|
746
|
+
def visit_arg(self, node: ast.arg) -> None:
|
|
747
|
+
if node.annotation is not None:
|
|
748
|
+
self._visit_annotation(node.annotation)
|
|
749
|
+
|
|
750
|
+
def visit_Call(self, node: ast.Call) -> None:
|
|
751
|
+
# `calls` owns the callee expression; references inside it are suppressed.
|
|
752
|
+
for arg in node.args:
|
|
753
|
+
self.visit(arg)
|
|
754
|
+
for keyword in node.keywords:
|
|
755
|
+
self.visit(keyword.value)
|
|
756
|
+
|
|
757
|
+
def visit_Name(self, node: ast.Name) -> None:
|
|
758
|
+
if isinstance(node.ctx, ast.Load) and not self._is_non_entity_local(node.id):
|
|
759
|
+
self.sites.append(self._site_for_name(node))
|
|
760
|
+
|
|
761
|
+
def _is_non_entity_local(self, name: str) -> bool:
|
|
762
|
+
return any(name in scope for scope in self.bound_stack[1:])
|
|
763
|
+
|
|
764
|
+
def _visit_function_signature(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
|
|
765
|
+
for arg in [
|
|
766
|
+
*node.args.posonlyargs,
|
|
767
|
+
*node.args.args,
|
|
768
|
+
*node.args.kwonlyargs,
|
|
769
|
+
]:
|
|
770
|
+
self.visit(arg)
|
|
771
|
+
if node.args.vararg is not None:
|
|
772
|
+
self.visit(node.args.vararg)
|
|
773
|
+
if node.args.kwarg is not None:
|
|
774
|
+
self.visit(node.args.kwarg)
|
|
775
|
+
if node.returns is not None:
|
|
776
|
+
self._visit_annotation(node.returns)
|
|
777
|
+
for default in [*node.args.defaults, *(d for d in node.args.kw_defaults if d is not None)]:
|
|
778
|
+
self.visit(default)
|
|
779
|
+
|
|
780
|
+
def _visit_annotation(self, node: ast.expr) -> None:
|
|
781
|
+
self.annotation_depth += 1
|
|
782
|
+
self.visit(node)
|
|
783
|
+
self.annotation_depth -= 1
|
|
784
|
+
|
|
785
|
+
def _entity_id_for_scope(
|
|
786
|
+
self,
|
|
787
|
+
kind: Literal["class", "function"],
|
|
788
|
+
node: ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef,
|
|
789
|
+
) -> str:
|
|
790
|
+
python_qualname = reconstruct_qualname(node, self.parents)
|
|
791
|
+
qualified_name = (
|
|
792
|
+
f"{self.dotted_module}.{python_qualname}" if self.dotted_module else python_qualname
|
|
793
|
+
)
|
|
794
|
+
return entity_id(_PLUGIN_ID, kind, qualified_name)
|
|
795
|
+
|
|
796
|
+
def _site_for_name(self, node: ast.Name) -> ReferenceSite:
|
|
797
|
+
line = node.lineno - 1
|
|
798
|
+
end_line = (node.end_lineno or node.lineno) - 1
|
|
799
|
+
end_col = node.end_col_offset or node.col_offset + len(node.id.encode("utf-8"))
|
|
800
|
+
source_byte_start = self.line_starts[line] + node.col_offset
|
|
801
|
+
source_byte_end = self.line_starts[end_line] + end_col
|
|
802
|
+
return ReferenceSite(
|
|
803
|
+
from_id=self.owner_stack[-1],
|
|
804
|
+
line=line,
|
|
805
|
+
character=_byte_col_to_lsp_character(self.source_lines[line], node.col_offset),
|
|
806
|
+
end_line=end_line,
|
|
807
|
+
end_character=_byte_col_to_lsp_character(self.source_lines[end_line], end_col),
|
|
808
|
+
source_byte_start=source_byte_start,
|
|
809
|
+
source_byte_end=source_byte_end,
|
|
810
|
+
kind="annotation" if self.annotation_depth else "name",
|
|
811
|
+
)
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
def _line_starts(source: str) -> tuple[int, ...]:
|
|
815
|
+
starts = [0]
|
|
816
|
+
total = 0
|
|
817
|
+
for line in source.splitlines(keepends=True):
|
|
818
|
+
total += len(line.encode("utf-8"))
|
|
819
|
+
starts.append(total)
|
|
820
|
+
return tuple(starts)
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
def _byte_col_to_lsp_character(line: str, byte_col: int) -> int:
|
|
824
|
+
prefix = line.encode("utf-8")[:byte_col].decode("utf-8")
|
|
825
|
+
return len(prefix.encode("utf-16-le")) // 2
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def _scope_local_names(
|
|
829
|
+
scope: ast.Module | ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef,
|
|
830
|
+
) -> set[str]:
|
|
831
|
+
collector = _LocalNameCollector()
|
|
832
|
+
if isinstance(scope, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
833
|
+
collector.names.update(_function_arg_names(scope))
|
|
834
|
+
for statement in scope.body:
|
|
835
|
+
collector.visit(statement)
|
|
836
|
+
return collector.names
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def _lambda_bound_names(node: ast.Lambda) -> set[str]:
|
|
840
|
+
return set(_arguments_arg_names(node.args))
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
def _function_arg_names(node: ast.FunctionDef | ast.AsyncFunctionDef) -> set[str]:
|
|
844
|
+
return set(_arguments_arg_names(node.args))
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def _arguments_arg_names(args: ast.arguments) -> list[str]:
|
|
848
|
+
names = [
|
|
849
|
+
*(arg.arg for arg in args.posonlyargs),
|
|
850
|
+
*(arg.arg for arg in args.args),
|
|
851
|
+
*(arg.arg for arg in args.kwonlyargs),
|
|
852
|
+
]
|
|
853
|
+
if args.vararg is not None:
|
|
854
|
+
names.append(args.vararg.arg)
|
|
855
|
+
if args.kwarg is not None:
|
|
856
|
+
names.append(args.kwarg.arg)
|
|
857
|
+
return names
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
class _LocalNameCollector(ast.NodeVisitor):
|
|
861
|
+
def __init__(self) -> None:
|
|
862
|
+
self.names: set[str] = set()
|
|
863
|
+
|
|
864
|
+
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
865
|
+
_ = node
|
|
866
|
+
|
|
867
|
+
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
|
|
868
|
+
_ = node
|
|
869
|
+
|
|
870
|
+
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
871
|
+
_ = node
|
|
872
|
+
|
|
873
|
+
def visit_Name(self, node: ast.Name) -> None:
|
|
874
|
+
if isinstance(node.ctx, (ast.Store, ast.Del)):
|
|
875
|
+
self.names.add(node.id)
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
@dataclass
|
|
879
|
+
class _WalkState:
|
|
880
|
+
"""Mutable accumulator threaded through ``_walk`` for cross-cutting bookkeeping.
|
|
881
|
+
|
|
882
|
+
``seen_ids`` is seeded with the module-entity id by ``extract_with_stats``
|
|
883
|
+
so the safety net catches the (degenerate) case of a function colliding
|
|
884
|
+
with the module's id. ``duplicate_entities_dropped`` is bumped once per
|
|
885
|
+
same-id drop; the caller copies it into ``ExtractionStats``.
|
|
886
|
+
"""
|
|
887
|
+
|
|
888
|
+
seen_ids: set[str]
|
|
889
|
+
file_path: str
|
|
890
|
+
wardline_vocabulary: WardlineVocabulary | None = None
|
|
891
|
+
exported_names: set[str] = field(default_factory=set)
|
|
892
|
+
duplicate_entities_dropped: int = 0
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
def _has_overload_decorator(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
|
|
896
|
+
"""Return True if ``node`` is decorated with ``@overload`` (PEP 484 stub).
|
|
897
|
+
|
|
898
|
+
Recognises three import-name forms in source: bare ``@overload`` (from
|
|
899
|
+
``from typing import overload``), ``@typing.overload``, and
|
|
900
|
+
``@typing_extensions.overload``. Aliased re-imports such as
|
|
901
|
+
``from typing import overload as o`` defeat this pattern-based check —
|
|
902
|
+
the safety-net dedup in ``_walk`` catches the resulting same-id
|
|
903
|
+
collision and keeps the run alive.
|
|
904
|
+
"""
|
|
905
|
+
for decorator in node.decorator_list:
|
|
906
|
+
match decorator:
|
|
907
|
+
case ast.Name(id="overload"):
|
|
908
|
+
return True
|
|
909
|
+
case ast.Attribute(
|
|
910
|
+
value=ast.Name(id="typing" | "typing_extensions"),
|
|
911
|
+
attr="overload",
|
|
912
|
+
):
|
|
913
|
+
return True
|
|
914
|
+
return False
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
def _walk( # noqa: PLR0913 - recursive walker needs both accumulators + parent context (B.3)
|
|
918
|
+
node: ast.AST,
|
|
919
|
+
parents: list[ast.AST],
|
|
920
|
+
dotted_module: str,
|
|
921
|
+
file_path: str,
|
|
922
|
+
parent_entity_id: str,
|
|
923
|
+
out_entities: list[RawEntity],
|
|
924
|
+
out_edges: list[RawEdge],
|
|
925
|
+
out_function_ids: list[str],
|
|
926
|
+
state: _WalkState,
|
|
927
|
+
) -> None:
|
|
928
|
+
"""Recursively walk ``node``'s AST children, emitting entities + contains edges.
|
|
929
|
+
|
|
930
|
+
``parent_entity_id`` is the immediate-parent entity id for direct
|
|
931
|
+
children of ``node``. When a child entity is itself an entity-bearing
|
|
932
|
+
node (Class/FunctionDef), recursion drops into it with the child's
|
|
933
|
+
own id as the new parent — so grandchildren get the right ``from_id``
|
|
934
|
+
on their contains edge (B.3 Q3: emitter is exhaustive, never
|
|
935
|
+
transitive).
|
|
936
|
+
|
|
937
|
+
Two stub-skip rules keep the host's ``UNIQUE(entities.id)`` from
|
|
938
|
+
tripping. (1) ``@overload``-decorated functions are recognised
|
|
939
|
+
semantically: skip emission, skip recursion (PEP 484 stub bodies are
|
|
940
|
+
``...``). The implementation appears last in source order and emits
|
|
941
|
+
normally. (2) Any other surviving same-id collision (aliased
|
|
942
|
+
``overload`` imports, ``singledispatch.register`` ``def _():``
|
|
943
|
+
sequences, manual redefinition) is dropped first-wins with a stderr
|
|
944
|
+
line and a ``state.duplicate_entities_dropped`` bump. Recursion into
|
|
945
|
+
the dropped child is suppressed too: its nested entities would carry
|
|
946
|
+
a parent_id whose entity the host never sees.
|
|
947
|
+
"""
|
|
948
|
+
for child in ast.iter_child_nodes(node):
|
|
949
|
+
new_parent_id = parent_entity_id
|
|
950
|
+
match child:
|
|
951
|
+
case ast.FunctionDef() | ast.AsyncFunctionDef():
|
|
952
|
+
if _has_overload_decorator(child):
|
|
953
|
+
continue
|
|
954
|
+
entity, child_id = _build_function_entity(
|
|
955
|
+
child,
|
|
956
|
+
parents,
|
|
957
|
+
dotted_module,
|
|
958
|
+
parent_entity_id,
|
|
959
|
+
state,
|
|
960
|
+
)
|
|
961
|
+
if child_id in state.seen_ids:
|
|
962
|
+
state.duplicate_entities_dropped += 1
|
|
963
|
+
sys.stderr.write(
|
|
964
|
+
f"loomweave-plugin-python: dropping duplicate entity {child_id} "
|
|
965
|
+
f"in {state.file_path} at line {child.lineno} "
|
|
966
|
+
f"(first definition wins)\n",
|
|
967
|
+
)
|
|
968
|
+
continue
|
|
969
|
+
state.seen_ids.add(child_id)
|
|
970
|
+
out_entities.append(entity)
|
|
971
|
+
out_edges.append(_contains_edge(parent_entity_id, child_id))
|
|
972
|
+
out_function_ids.append(child_id)
|
|
973
|
+
new_parent_id = child_id
|
|
974
|
+
case ast.ClassDef():
|
|
975
|
+
entity, child_id = _build_class_entity(
|
|
976
|
+
child,
|
|
977
|
+
parents,
|
|
978
|
+
dotted_module,
|
|
979
|
+
parent_entity_id,
|
|
980
|
+
state,
|
|
981
|
+
)
|
|
982
|
+
if child_id in state.seen_ids:
|
|
983
|
+
state.duplicate_entities_dropped += 1
|
|
984
|
+
sys.stderr.write(
|
|
985
|
+
f"loomweave-plugin-python: dropping duplicate entity {child_id} "
|
|
986
|
+
f"in {state.file_path} at line {child.lineno} "
|
|
987
|
+
f"(first definition wins)\n",
|
|
988
|
+
)
|
|
989
|
+
continue
|
|
990
|
+
state.seen_ids.add(child_id)
|
|
991
|
+
out_entities.append(entity)
|
|
992
|
+
out_edges.append(_contains_edge(parent_entity_id, child_id))
|
|
993
|
+
new_parent_id = child_id
|
|
994
|
+
_walk(
|
|
995
|
+
child,
|
|
996
|
+
[*parents, child],
|
|
997
|
+
dotted_module,
|
|
998
|
+
file_path,
|
|
999
|
+
new_parent_id,
|
|
1000
|
+
out_entities,
|
|
1001
|
+
out_edges,
|
|
1002
|
+
out_function_ids,
|
|
1003
|
+
state,
|
|
1004
|
+
)
|
|
1005
|
+
|
|
1006
|
+
|
|
1007
|
+
def _definition_span(
|
|
1008
|
+
node: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef,
|
|
1009
|
+
) -> tuple[int, int, DefinitionSpan]:
|
|
1010
|
+
"""Return ``(start_line, start_col, definition)`` for a definition node.
|
|
1011
|
+
|
|
1012
|
+
``node.lineno`` is the ``def``/``class`` keyword line (Python 3.8+);
|
|
1013
|
+
decorators sit above it. When the node is decorated the returned
|
|
1014
|
+
``start_line``/``start_col`` extend the entity span up to the topmost
|
|
1015
|
+
decorator so an ``entity_at`` query on a decorator line resolves to this
|
|
1016
|
+
entity (clarion-460def6a51). The ``definition`` map records the
|
|
1017
|
+
sub-ranges that explain why a line matched.
|
|
1018
|
+
"""
|
|
1019
|
+
definition: DefinitionSpan = {"decl_line": node.lineno}
|
|
1020
|
+
if node.body:
|
|
1021
|
+
definition["body_line_start"] = node.body[0].lineno
|
|
1022
|
+
start_line = node.lineno
|
|
1023
|
+
start_col = node.col_offset
|
|
1024
|
+
if node.decorator_list:
|
|
1025
|
+
topmost = min(node.decorator_list, key=lambda d: (d.lineno, d.col_offset))
|
|
1026
|
+
decorator_line_start = topmost.lineno
|
|
1027
|
+
decorator_line_end = max(
|
|
1028
|
+
(d.end_lineno if d.end_lineno is not None else d.lineno) for d in node.decorator_list
|
|
1029
|
+
)
|
|
1030
|
+
definition["decorator_line_start"] = decorator_line_start
|
|
1031
|
+
definition["decorator_line_end"] = decorator_line_end
|
|
1032
|
+
start_line = decorator_line_start
|
|
1033
|
+
start_col = topmost.col_offset
|
|
1034
|
+
return start_line, start_col, definition
|
|
1035
|
+
|
|
1036
|
+
|
|
1037
|
+
def _contains_edge(parent_id: str, child_id: str) -> RawEdge:
|
|
1038
|
+
"""Build a ``contains`` edge per ADR-026 decision 3 (no source range)."""
|
|
1039
|
+
return {
|
|
1040
|
+
"kind": "contains",
|
|
1041
|
+
"from_id": parent_id,
|
|
1042
|
+
"to_id": child_id,
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
_HTTP_ROUTE_DECORATOR_NAMES = {
|
|
1047
|
+
"get",
|
|
1048
|
+
"post",
|
|
1049
|
+
"put",
|
|
1050
|
+
"patch",
|
|
1051
|
+
"delete",
|
|
1052
|
+
"options",
|
|
1053
|
+
"head",
|
|
1054
|
+
"route",
|
|
1055
|
+
"websocket",
|
|
1056
|
+
}
|
|
1057
|
+
_CLI_DECORATOR_NAMES = {"command", "group", "callback"}
|
|
1058
|
+
_DATA_MODEL_BASE_NAMES = {"BaseModel", "Model", "SQLModel", "TypedDict"}
|
|
1059
|
+
|
|
1060
|
+
|
|
1061
|
+
def _attach_optional_entity_metadata(
|
|
1062
|
+
entity: RawEntity,
|
|
1063
|
+
*,
|
|
1064
|
+
docstring: str | None,
|
|
1065
|
+
tags: set[str] | list[str],
|
|
1066
|
+
) -> None:
|
|
1067
|
+
if docstring:
|
|
1068
|
+
entity["docstring"] = docstring
|
|
1069
|
+
if tags:
|
|
1070
|
+
entity["tags"] = sorted(tags)
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
def _attach_wardline_entity_metadata(
|
|
1074
|
+
entity: RawEntity,
|
|
1075
|
+
node: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef,
|
|
1076
|
+
tags: set[str],
|
|
1077
|
+
vocabulary: WardlineVocabulary | None,
|
|
1078
|
+
) -> None:
|
|
1079
|
+
if vocabulary is None:
|
|
1080
|
+
return
|
|
1081
|
+
decorators: list[WardlineDecoratorMetadata] = []
|
|
1082
|
+
for decorator in node.decorator_list:
|
|
1083
|
+
qualified_name = _expr_qualified_name(decorator)
|
|
1084
|
+
if qualified_name is None:
|
|
1085
|
+
continue
|
|
1086
|
+
entry = vocabulary.entry_for_decorator(qualified_name)
|
|
1087
|
+
if entry is None:
|
|
1088
|
+
continue
|
|
1089
|
+
decorators.append(
|
|
1090
|
+
{
|
|
1091
|
+
"canonical_name": entry.canonical_name,
|
|
1092
|
+
"qualified_name": qualified_name,
|
|
1093
|
+
"group": entry.group,
|
|
1094
|
+
"attrs": dict(entry.attrs),
|
|
1095
|
+
"line": decorator.lineno,
|
|
1096
|
+
},
|
|
1097
|
+
)
|
|
1098
|
+
tags.update({"wardline", f"wardline:{entry.canonical_name}"})
|
|
1099
|
+
if decorators:
|
|
1100
|
+
entity["wardline"] = {
|
|
1101
|
+
"descriptor_version": vocabulary.version,
|
|
1102
|
+
"confidence_basis": vocabulary.confidence_basis,
|
|
1103
|
+
"decorators": decorators,
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
|
|
1107
|
+
def _module_export_names(tree: ast.Module) -> set[str]:
|
|
1108
|
+
exported: set[str] = set()
|
|
1109
|
+
for statement in tree.body:
|
|
1110
|
+
if not isinstance(statement, ast.Assign):
|
|
1111
|
+
continue
|
|
1112
|
+
if not any(
|
|
1113
|
+
isinstance(target, ast.Name) and target.id == "__all__" for target in statement.targets
|
|
1114
|
+
):
|
|
1115
|
+
continue
|
|
1116
|
+
match statement.value:
|
|
1117
|
+
case ast.List(elts=elts) | ast.Tuple(elts=elts) | ast.Set(elts=elts):
|
|
1118
|
+
for elt in elts:
|
|
1119
|
+
if isinstance(elt, ast.Constant) and isinstance(elt.value, str):
|
|
1120
|
+
exported.add(elt.value)
|
|
1121
|
+
return exported
|
|
1122
|
+
|
|
1123
|
+
|
|
1124
|
+
def _expr_qualified_name(expr: ast.expr) -> str | None:
|
|
1125
|
+
match expr:
|
|
1126
|
+
case ast.Call(func=func):
|
|
1127
|
+
return _expr_qualified_name(func)
|
|
1128
|
+
case ast.Name(id=name):
|
|
1129
|
+
return name
|
|
1130
|
+
case ast.Attribute(value=value, attr=attr):
|
|
1131
|
+
base = _expr_qualified_name(value)
|
|
1132
|
+
return f"{base}.{attr}" if base else attr
|
|
1133
|
+
case _:
|
|
1134
|
+
return None
|
|
1135
|
+
|
|
1136
|
+
|
|
1137
|
+
def _decorator_names(
|
|
1138
|
+
node: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef,
|
|
1139
|
+
) -> list[str]:
|
|
1140
|
+
return [name for decorator in node.decorator_list if (name := _expr_qualified_name(decorator))]
|
|
1141
|
+
|
|
1142
|
+
|
|
1143
|
+
def _last_name(name: str) -> str:
|
|
1144
|
+
return name.rsplit(".", 1)[-1]
|
|
1145
|
+
|
|
1146
|
+
|
|
1147
|
+
def _is_module_level(parents: list[ast.AST]) -> bool:
|
|
1148
|
+
return len(parents) == 1
|
|
1149
|
+
|
|
1150
|
+
|
|
1151
|
+
def _function_tags(
|
|
1152
|
+
node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
1153
|
+
parents: list[ast.AST],
|
|
1154
|
+
exported_names: set[str],
|
|
1155
|
+
) -> set[str]:
|
|
1156
|
+
tags: set[str] = set()
|
|
1157
|
+
if _is_module_level(parents) and node.name == "main":
|
|
1158
|
+
tags.add("entry-point")
|
|
1159
|
+
if _is_module_level(parents) and node.name in exported_names:
|
|
1160
|
+
tags.add("exported-api")
|
|
1161
|
+
if node.name.startswith("test_") or any(
|
|
1162
|
+
isinstance(parent, ast.ClassDef) and parent.name.startswith("Test") for parent in parents
|
|
1163
|
+
):
|
|
1164
|
+
tags.add("test")
|
|
1165
|
+
decorator_names = _decorator_names(node)
|
|
1166
|
+
if any(_last_name(name) in _HTTP_ROUTE_DECORATOR_NAMES for name in decorator_names):
|
|
1167
|
+
tags.update({"http-route", "framework-handler"})
|
|
1168
|
+
if any(_last_name(name) in _CLI_DECORATOR_NAMES for name in decorator_names):
|
|
1169
|
+
tags.update({"cli-command", "framework-handler"})
|
|
1170
|
+
return tags
|
|
1171
|
+
|
|
1172
|
+
|
|
1173
|
+
def _class_tags(node: ast.ClassDef, parents: list[ast.AST], exported_names: set[str]) -> set[str]:
|
|
1174
|
+
tags: set[str] = set()
|
|
1175
|
+
if _is_module_level(parents) and node.name in exported_names:
|
|
1176
|
+
tags.add("exported-api")
|
|
1177
|
+
if node.name.startswith("Test"):
|
|
1178
|
+
tags.add("test")
|
|
1179
|
+
decorator_names = _decorator_names(node)
|
|
1180
|
+
base_names = [_expr_qualified_name(base) for base in node.bases]
|
|
1181
|
+
if any(_last_name(name) == "dataclass" for name in decorator_names) or any(
|
|
1182
|
+
name is not None and _last_name(name) in _DATA_MODEL_BASE_NAMES for name in base_names
|
|
1183
|
+
):
|
|
1184
|
+
tags.add("data-model")
|
|
1185
|
+
return tags
|
|
1186
|
+
|
|
1187
|
+
|
|
1188
|
+
def _annotation_str(node: ast.expr | None) -> str | None:
|
|
1189
|
+
"""Unparse an annotation/expression node to its canonical source text, or
|
|
1190
|
+
``None`` when absent. ``ast.unparse`` is deterministic for a given AST."""
|
|
1191
|
+
if node is None:
|
|
1192
|
+
return None
|
|
1193
|
+
return ast.unparse(node)
|
|
1194
|
+
|
|
1195
|
+
|
|
1196
|
+
def _format_param(arg: ast.arg, prefix: str = "") -> str:
|
|
1197
|
+
"""Render one parameter as ``name`` or ``name: annotation`` (``prefix`` is
|
|
1198
|
+
``*`` / ``**`` for var-positional / var-keyword params)."""
|
|
1199
|
+
annotation = _annotation_str(arg.annotation)
|
|
1200
|
+
name = f"{prefix}{arg.arg}"
|
|
1201
|
+
return f"{name}: {annotation}" if annotation is not None else name
|
|
1202
|
+
|
|
1203
|
+
|
|
1204
|
+
def _function_signature(node: ast.FunctionDef | ast.AsyncFunctionDef) -> FunctionSignature:
|
|
1205
|
+
"""SEI signature for a function (ADR-038 REQ-C-01). Near-redundant for the
|
|
1206
|
+
v1 deterministic move case (a byte-identical body already implies an
|
|
1207
|
+
identical ``def`` line), carried for spec conformance + the fuzzy future."""
|
|
1208
|
+
args = node.args
|
|
1209
|
+
params: list[str] = [_format_param(arg) for arg in (*args.posonlyargs, *args.args)]
|
|
1210
|
+
if args.vararg is not None:
|
|
1211
|
+
params.append(_format_param(args.vararg, "*"))
|
|
1212
|
+
params.extend(_format_param(arg) for arg in args.kwonlyargs)
|
|
1213
|
+
if args.kwarg is not None:
|
|
1214
|
+
params.append(_format_param(args.kwarg, "**"))
|
|
1215
|
+
return {
|
|
1216
|
+
"v": SIGNATURE_SCHEMA_VERSION,
|
|
1217
|
+
"params": params,
|
|
1218
|
+
"return_ann": _annotation_str(node.returns),
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
|
|
1222
|
+
def _class_signature(node: ast.ClassDef) -> ClassSignature:
|
|
1223
|
+
"""SEI signature for a class (ADR-038 REQ-C-01): unparsed base expressions."""
|
|
1224
|
+
bases = [text for text in (_annotation_str(base) for base in node.bases) if text is not None]
|
|
1225
|
+
return {"v": SIGNATURE_SCHEMA_VERSION, "bases": bases}
|
|
1226
|
+
|
|
1227
|
+
|
|
1228
|
+
def _build_function_entity(
|
|
1229
|
+
node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
1230
|
+
parents: list[ast.AST],
|
|
1231
|
+
dotted_module: str,
|
|
1232
|
+
parent_entity_id: str,
|
|
1233
|
+
state: _WalkState,
|
|
1234
|
+
) -> tuple[RawEntity, str]:
|
|
1235
|
+
python_qualname = reconstruct_qualname(node, parents)
|
|
1236
|
+
qualified_name = f"{dotted_module}.{python_qualname}" if dotted_module else python_qualname
|
|
1237
|
+
end_line = node.end_lineno if node.end_lineno is not None else node.lineno
|
|
1238
|
+
end_col = node.end_col_offset if node.end_col_offset is not None else node.col_offset
|
|
1239
|
+
start_line, start_col, definition = _definition_span(node)
|
|
1240
|
+
child_id = entity_id(_PLUGIN_ID, "function", qualified_name)
|
|
1241
|
+
entity: RawEntity = {
|
|
1242
|
+
"id": child_id,
|
|
1243
|
+
"kind": "function",
|
|
1244
|
+
"qualified_name": qualified_name,
|
|
1245
|
+
"source": {
|
|
1246
|
+
"file_path": state.file_path,
|
|
1247
|
+
"source_range": {
|
|
1248
|
+
"start_line": start_line,
|
|
1249
|
+
"start_col": start_col,
|
|
1250
|
+
"end_line": end_line,
|
|
1251
|
+
"end_col": end_col,
|
|
1252
|
+
},
|
|
1253
|
+
},
|
|
1254
|
+
"parent_id": parent_entity_id,
|
|
1255
|
+
"definition": definition,
|
|
1256
|
+
"signature": _function_signature(node),
|
|
1257
|
+
}
|
|
1258
|
+
tags = _function_tags(node, parents, state.exported_names)
|
|
1259
|
+
_attach_wardline_entity_metadata(entity, node, tags, state.wardline_vocabulary)
|
|
1260
|
+
_attach_optional_entity_metadata(
|
|
1261
|
+
entity,
|
|
1262
|
+
docstring=ast.get_docstring(node),
|
|
1263
|
+
tags=tags,
|
|
1264
|
+
)
|
|
1265
|
+
return entity, child_id
|
|
1266
|
+
|
|
1267
|
+
|
|
1268
|
+
def _build_class_entity(
|
|
1269
|
+
node: ast.ClassDef,
|
|
1270
|
+
parents: list[ast.AST],
|
|
1271
|
+
dotted_module: str,
|
|
1272
|
+
parent_entity_id: str,
|
|
1273
|
+
state: _WalkState,
|
|
1274
|
+
) -> tuple[RawEntity, str]:
|
|
1275
|
+
"""Build a class entity. Uses real ast.end_lineno/end_col_offset (not the module sentinel).
|
|
1276
|
+
|
|
1277
|
+
Class methods continue to emit as ``function`` entities (per
|
|
1278
|
+
detailed-design.md:67); no separate ``method`` kind. Nested classes
|
|
1279
|
+
nest in the qualname per ``reconstruct_qualname`` (no ``<locals>``
|
|
1280
|
+
between class names).
|
|
1281
|
+
"""
|
|
1282
|
+
python_qualname = reconstruct_qualname(node, parents)
|
|
1283
|
+
qualified_name = f"{dotted_module}.{python_qualname}" if dotted_module else python_qualname
|
|
1284
|
+
end_line = node.end_lineno if node.end_lineno is not None else node.lineno
|
|
1285
|
+
end_col = node.end_col_offset if node.end_col_offset is not None else node.col_offset
|
|
1286
|
+
start_line, start_col, definition = _definition_span(node)
|
|
1287
|
+
child_id = entity_id(_PLUGIN_ID, "class", qualified_name)
|
|
1288
|
+
entity: RawEntity = {
|
|
1289
|
+
"id": child_id,
|
|
1290
|
+
"kind": "class",
|
|
1291
|
+
"qualified_name": qualified_name,
|
|
1292
|
+
"source": {
|
|
1293
|
+
"file_path": state.file_path,
|
|
1294
|
+
"source_range": {
|
|
1295
|
+
"start_line": start_line,
|
|
1296
|
+
"start_col": start_col,
|
|
1297
|
+
"end_line": end_line,
|
|
1298
|
+
"end_col": end_col,
|
|
1299
|
+
},
|
|
1300
|
+
},
|
|
1301
|
+
"parent_id": parent_entity_id,
|
|
1302
|
+
"definition": definition,
|
|
1303
|
+
"signature": _class_signature(node),
|
|
1304
|
+
}
|
|
1305
|
+
tags = _class_tags(node, parents, state.exported_names)
|
|
1306
|
+
_attach_wardline_entity_metadata(entity, node, tags, state.wardline_vocabulary)
|
|
1307
|
+
_attach_optional_entity_metadata(
|
|
1308
|
+
entity,
|
|
1309
|
+
docstring=ast.get_docstring(node),
|
|
1310
|
+
tags=tags,
|
|
1311
|
+
)
|
|
1312
|
+
return entity, child_id
|