pythonparts-mcp 0.2.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.
- pythonparts_mcp-0.2.0.dist-info/METADATA +179 -0
- pythonparts_mcp-0.2.0.dist-info/RECORD +20 -0
- pythonparts_mcp-0.2.0.dist-info/WHEEL +4 -0
- pythonparts_mcp-0.2.0.dist-info/entry_points.txt +2 -0
- server/__init__.py +0 -0
- server/__main__.py +101 -0
- server/config.py +58 -0
- server/paths.py +87 -0
- server/pyright_config.py +67 -0
- server/registry.py +59 -0
- server/tools/__init__.py +23 -0
- server/tools/_introspect_worker.py +537 -0
- server/tools/_sandbox_worker.py +196 -0
- server/tools/connector.py +81 -0
- server/tools/inspect_symbol.py +75 -0
- server/tools/run_snippet_in_allplan.py +85 -0
- server/tools/run_snippet_in_sandbox.py +91 -0
- server/tools/runtime_context.py +157 -0
- server/tools/typecheck.py +140 -0
- server/worker.py +184 -0
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Self-contained worker script that introspects Python symbols and prints JSON
|
|
3
|
+
results to stdout.
|
|
4
|
+
|
|
5
|
+
This script is executed by the ALLPLAN-embedded interpreter (not the MCP server's
|
|
6
|
+
interpreter). It must not import anything from the server package.
|
|
7
|
+
|
|
8
|
+
Native (pybind11) modules such as ``NemAll_Python_IFW_ElementAdapter`` carry no
|
|
9
|
+
usable signatures at runtime, so results are enriched from the matching ``.pyi``
|
|
10
|
+
stub in ``InterfaceStubs`` whenever one is available.
|
|
11
|
+
|
|
12
|
+
Protocol (persistent mode):
|
|
13
|
+
1. Caller writes one JSON init line to stdin:
|
|
14
|
+
{"sys_path": [...], "prg_plugins": "...", "allplan_paths_mock": "...",
|
|
15
|
+
"etc_pythonparts": "...", "stub_dirs": [...]}
|
|
16
|
+
Worker sets up the ALLPLAN runtime, then writes one JSON ack to stdout:
|
|
17
|
+
{"ok": true} or {"ok": false, "error": "..."}
|
|
18
|
+
2. For each symbol query the caller writes one JSON line:
|
|
19
|
+
{"symbol": "NemAll_Python_Geometry.Point3D"}
|
|
20
|
+
Worker writes one JSON result to stdout, then loops.
|
|
21
|
+
3. Caller closes stdin (EOF) to signal shutdown; the worker exits cleanly.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import ast
|
|
27
|
+
import inspect
|
|
28
|
+
import importlib
|
|
29
|
+
import importlib.util
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
import re
|
|
33
|
+
import sys
|
|
34
|
+
import warnings
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
_DLL_DIR_HANDLE: Any | None = None
|
|
38
|
+
|
|
39
|
+
_STUB_DIRS: list[str] = []
|
|
40
|
+
_STUB_CACHE: dict[str, dict | None] = {}
|
|
41
|
+
|
|
42
|
+
# The generated stubs contain malformed defs such as ``def __init__(: object):``.
|
|
43
|
+
_BROKEN_SELF_ARG = re.compile(r"(def\s+\w+\s*\()\s*:")
|
|
44
|
+
|
|
45
|
+
# Boost-style cast parameters such as ``(Point2D)index`` inside a def line.
|
|
46
|
+
_BROKEN_CAST_PARAM = re.compile(r"\(\s*([A-Za-z_][\w.]*)\s*\)\s*([A-Za-z_]\w*)")
|
|
47
|
+
|
|
48
|
+
_DEF_LINE = re.compile(r"^(\s*)(?:async\s+)?def\s+(\w+)")
|
|
49
|
+
|
|
50
|
+
_MAX_PARSE_REPAIRS = 25
|
|
51
|
+
|
|
52
|
+
_OVERLOAD_PLACEHOLDER = "Overloaded function"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _read_line() -> dict | None:
|
|
58
|
+
"""Return the next JSON object from stdin, or None on EOF."""
|
|
59
|
+
line = sys.stdin.readline()
|
|
60
|
+
if not line:
|
|
61
|
+
return None
|
|
62
|
+
return json.loads(line)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _setup_environment(params: dict) -> None:
|
|
66
|
+
"""Inject DLL search path and sys.path, then patch AllplanPaths if possible."""
|
|
67
|
+
global _DLL_DIR_HANDLE
|
|
68
|
+
|
|
69
|
+
_DLL_DIR_HANDLE = os.add_dll_directory(params["prg_plugins"])
|
|
70
|
+
|
|
71
|
+
_STUB_DIRS.extend(params.get("stub_dirs") or [])
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
for path in reversed(params["sys_path"]):
|
|
75
|
+
if path not in sys.path:
|
|
76
|
+
sys.path.insert(0, path)
|
|
77
|
+
|
|
78
|
+
# Patch AllplanSettings.AllplanPaths so framework modules that call
|
|
79
|
+
# GetPathOfApplication() at import-time receive the real Prg path rather
|
|
80
|
+
# than the interpreter directory (Prg\Python\).
|
|
81
|
+
try:
|
|
82
|
+
mock_spec = importlib.util.spec_from_file_location(
|
|
83
|
+
"AllplanPathsMock", params["allplan_paths_mock"]
|
|
84
|
+
)
|
|
85
|
+
if mock_spec is None or mock_spec.loader is None:
|
|
86
|
+
raise ImportError("Could not load AllplanPathsMock spec")
|
|
87
|
+
mock_mod = importlib.util.module_from_spec(mock_spec)
|
|
88
|
+
mock_spec.loader.exec_module(mock_mod) # type: ignore[union-attr]
|
|
89
|
+
|
|
90
|
+
import NemAll_Python_AllplanSettings as _AllplanSettings # noqa: PLC0415
|
|
91
|
+
_AllplanSettings.AllplanPaths = mock_mod.AllplanPathsMock(
|
|
92
|
+
etc_path=params["etc_pythonparts"]
|
|
93
|
+
)
|
|
94
|
+
except Exception: # noqa: BLE001 — AllplanPaths mock is optional; real errors surface below
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _determine_category(obj: Any) -> str:
|
|
99
|
+
if inspect.ismodule(obj):
|
|
100
|
+
return "module"
|
|
101
|
+
if inspect.isclass(obj):
|
|
102
|
+
return "class"
|
|
103
|
+
if inspect.isfunction(obj):
|
|
104
|
+
return "function"
|
|
105
|
+
if inspect.isbuiltin(obj):
|
|
106
|
+
return "builtin_function"
|
|
107
|
+
if inspect.ismethod(obj):
|
|
108
|
+
return "method"
|
|
109
|
+
if isinstance(obj, property):
|
|
110
|
+
return "property"
|
|
111
|
+
return "attribute"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _safe_signature(obj: Any) -> str | None:
|
|
115
|
+
try:
|
|
116
|
+
return str(inspect.signature(obj))
|
|
117
|
+
except (ValueError, TypeError):
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# --------------------------------------------------------------------------- #
|
|
122
|
+
# .pyi stub support #
|
|
123
|
+
# --------------------------------------------------------------------------- #
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _find_stub_file(module_name: str) -> str | None:
|
|
127
|
+
for directory in _STUB_DIRS:
|
|
128
|
+
candidate = os.path.join(directory, module_name.replace(".", os.sep) + ".pyi")
|
|
129
|
+
if os.path.isfile(candidate):
|
|
130
|
+
return candidate
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _repair_def_line(line: str) -> str:
|
|
135
|
+
line = _BROKEN_SELF_ARG.sub(r"\1__arg:", line)
|
|
136
|
+
counter = [0]
|
|
137
|
+
|
|
138
|
+
def rename_cast(match: re.Match) -> str:
|
|
139
|
+
counter[0] += 1
|
|
140
|
+
return f"{match.group(2)}_{counter[0]}: {match.group(1)}"
|
|
141
|
+
|
|
142
|
+
return _BROKEN_CAST_PARAM.sub(rename_cast, line)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _degrade_def_line(lines: list[str], lineno: int | None) -> bool:
|
|
146
|
+
"""Replace the offending def line with ``def name(*args, **kwargs):``."""
|
|
147
|
+
if not lineno or lineno > len(lines):
|
|
148
|
+
return False
|
|
149
|
+
match = _DEF_LINE.match(lines[lineno - 1])
|
|
150
|
+
if match is None:
|
|
151
|
+
return False
|
|
152
|
+
indent, name = match.groups()
|
|
153
|
+
degraded = f"{indent}def {name}(*args, **kwargs):\n"
|
|
154
|
+
if lines[lineno - 1] == degraded:
|
|
155
|
+
return False
|
|
156
|
+
lines[lineno - 1] = degraded
|
|
157
|
+
return True
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _parse_stub_source(source: str) -> ast.Module | None:
|
|
161
|
+
with warnings.catch_warnings():
|
|
162
|
+
warnings.simplefilter("ignore", SyntaxWarning)
|
|
163
|
+
try:
|
|
164
|
+
return ast.parse(source)
|
|
165
|
+
except SyntaxError:
|
|
166
|
+
pass
|
|
167
|
+
|
|
168
|
+
# Repair known malformed patterns on def lines only, so docstrings
|
|
169
|
+
# containing similar text stay untouched.
|
|
170
|
+
lines = [
|
|
171
|
+
_repair_def_line(line) if _DEF_LINE.match(line) else line
|
|
172
|
+
for line in source.splitlines(keepends=True)
|
|
173
|
+
]
|
|
174
|
+
for _ in range(_MAX_PARSE_REPAIRS):
|
|
175
|
+
try:
|
|
176
|
+
return ast.parse("".join(lines))
|
|
177
|
+
except SyntaxError as exc:
|
|
178
|
+
if not _degrade_def_line(lines, exc.lineno):
|
|
179
|
+
return None
|
|
180
|
+
return None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _render_signature(node: ast.FunctionDef | ast.AsyncFunctionDef) -> str:
|
|
184
|
+
try:
|
|
185
|
+
args = ast.unparse(node.args)
|
|
186
|
+
except Exception: # noqa: BLE001 — unparse is best effort
|
|
187
|
+
args = "..."
|
|
188
|
+
returns = ""
|
|
189
|
+
if node.returns is not None:
|
|
190
|
+
try:
|
|
191
|
+
returns = f" -> {ast.unparse(node.returns)}"
|
|
192
|
+
except Exception: # noqa: BLE001
|
|
193
|
+
returns = ""
|
|
194
|
+
return f"({args}){returns}"
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _decorator_names(node: ast.FunctionDef | ast.AsyncFunctionDef) -> set[str]:
|
|
198
|
+
names = set()
|
|
199
|
+
for dec in node.decorator_list:
|
|
200
|
+
try:
|
|
201
|
+
names.add(ast.unparse(dec))
|
|
202
|
+
except Exception: # noqa: BLE001
|
|
203
|
+
pass
|
|
204
|
+
return names
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _stub_function_entry(nodes: list[ast.FunctionDef | ast.AsyncFunctionDef]) -> dict:
|
|
208
|
+
"""Merge the definitions sharing one name into a single entry with overloads."""
|
|
209
|
+
overloads = [
|
|
210
|
+
{"signature": _render_signature(n), "docstring": ast.get_docstring(n)}
|
|
211
|
+
for n in nodes
|
|
212
|
+
]
|
|
213
|
+
# pybind11 stubs append a dispatcher def whose docstring only points to the
|
|
214
|
+
# overloads; drop it when real overloads are present.
|
|
215
|
+
if len(overloads) > 1:
|
|
216
|
+
overloads = [
|
|
217
|
+
o for o in overloads
|
|
218
|
+
if not (o["docstring"] or "").strip().startswith(_OVERLOAD_PLACEHOLDER)
|
|
219
|
+
] or overloads[:1]
|
|
220
|
+
|
|
221
|
+
decorators = set().union(*(_decorator_names(n) for n in nodes))
|
|
222
|
+
if "property" in decorators:
|
|
223
|
+
category = "property"
|
|
224
|
+
elif "staticmethod" in decorators:
|
|
225
|
+
category = "staticmethod"
|
|
226
|
+
elif "classmethod" in decorators:
|
|
227
|
+
category = "classmethod"
|
|
228
|
+
else:
|
|
229
|
+
category = "function"
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
"category": category,
|
|
233
|
+
"docstring": next((o["docstring"] for o in overloads if o["docstring"]), None),
|
|
234
|
+
"signature": overloads[0]["signature"],
|
|
235
|
+
"overloads": overloads if len(overloads) > 1 else [],
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _index_body(body: list[ast.stmt]) -> dict[str, dict]:
|
|
240
|
+
"""Index the functions, classes and annotated attributes of one scope."""
|
|
241
|
+
functions: dict[str, list] = {}
|
|
242
|
+
entries: dict[str, dict] = {}
|
|
243
|
+
|
|
244
|
+
for node in body:
|
|
245
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
246
|
+
functions.setdefault(node.name, []).append(node)
|
|
247
|
+
elif isinstance(node, ast.ClassDef):
|
|
248
|
+
entries[node.name] = {
|
|
249
|
+
"category": "class",
|
|
250
|
+
"docstring": ast.get_docstring(node),
|
|
251
|
+
"signature": None,
|
|
252
|
+
"overloads": [],
|
|
253
|
+
"bases": [_safe_unparse(b) for b in node.bases],
|
|
254
|
+
"members": _index_body(node.body),
|
|
255
|
+
}
|
|
256
|
+
elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
|
|
257
|
+
entries[node.target.id] = {
|
|
258
|
+
"category": "attribute",
|
|
259
|
+
"docstring": None,
|
|
260
|
+
"signature": _safe_unparse(node.annotation),
|
|
261
|
+
"overloads": [],
|
|
262
|
+
}
|
|
263
|
+
elif isinstance(node, ast.Assign):
|
|
264
|
+
for target in node.targets:
|
|
265
|
+
if isinstance(target, ast.Name):
|
|
266
|
+
entries[target.id] = {
|
|
267
|
+
"category": "attribute",
|
|
268
|
+
"docstring": None,
|
|
269
|
+
"signature": None,
|
|
270
|
+
"overloads": [],
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
for name, nodes in functions.items():
|
|
274
|
+
entries[name] = _stub_function_entry(nodes)
|
|
275
|
+
return entries
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _safe_unparse(node: ast.AST) -> str | None:
|
|
279
|
+
try:
|
|
280
|
+
return ast.unparse(node)
|
|
281
|
+
except Exception: # noqa: BLE001
|
|
282
|
+
return None
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _load_stub(module_name: str) -> dict | None:
|
|
286
|
+
"""Return the parsed index of ``<module_name>.pyi``, or None when unavailable."""
|
|
287
|
+
if module_name in _STUB_CACHE:
|
|
288
|
+
return _STUB_CACHE[module_name]
|
|
289
|
+
|
|
290
|
+
index: dict | None = None
|
|
291
|
+
path = _find_stub_file(module_name)
|
|
292
|
+
if path is not None:
|
|
293
|
+
try:
|
|
294
|
+
with open(path, "r", encoding="utf-8", errors="replace") as handle:
|
|
295
|
+
tree = _parse_stub_source(handle.read())
|
|
296
|
+
except OSError:
|
|
297
|
+
tree = None
|
|
298
|
+
if tree is not None:
|
|
299
|
+
index = {
|
|
300
|
+
"path": path,
|
|
301
|
+
"docstring": ast.get_docstring(tree),
|
|
302
|
+
"members": _index_body(tree.body),
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
_STUB_CACHE[module_name] = index
|
|
306
|
+
return index
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _stub_lookup(module_name: str, attr_chain: list[str]) -> tuple[dict | None, dict | None]:
|
|
310
|
+
"""Return (stub index of the module, entry for the attribute chain)."""
|
|
311
|
+
index = _load_stub(module_name)
|
|
312
|
+
if index is None:
|
|
313
|
+
return None, None
|
|
314
|
+
|
|
315
|
+
entry: dict | None = None
|
|
316
|
+
members: dict[str, dict] = index["members"]
|
|
317
|
+
for attr in attr_chain:
|
|
318
|
+
entry = members.get(attr)
|
|
319
|
+
if entry is None:
|
|
320
|
+
return index, None
|
|
321
|
+
members = entry.get("members", {})
|
|
322
|
+
return index, entry
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _format_stub_members(members: dict[str, dict], magic: bool) -> list[str]:
|
|
326
|
+
"""Render member names with their stub signatures, one entry per overload."""
|
|
327
|
+
rendered: list[str] = []
|
|
328
|
+
for name in sorted(members):
|
|
329
|
+
if name.startswith("__") and name.endswith("__") and not magic:
|
|
330
|
+
continue
|
|
331
|
+
if magic and not (name.startswith("__") and name.endswith("__")):
|
|
332
|
+
continue
|
|
333
|
+
if not magic and name.startswith("_"):
|
|
334
|
+
continue
|
|
335
|
+
entry = members[name]
|
|
336
|
+
if magic and entry["category"] == "attribute":
|
|
337
|
+
continue
|
|
338
|
+
if entry["category"] == "attribute":
|
|
339
|
+
rendered.append(f"{name}: {entry['signature']}" if entry["signature"] else name)
|
|
340
|
+
continue
|
|
341
|
+
if entry["category"] == "class":
|
|
342
|
+
rendered.append(f"{name} (class)")
|
|
343
|
+
continue
|
|
344
|
+
signatures = [o["signature"] for o in entry["overloads"]] or [entry["signature"]]
|
|
345
|
+
for signature in signatures:
|
|
346
|
+
rendered.append(f"{name}{signature}" if signature else name)
|
|
347
|
+
return rendered
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _split_docstring(docstring: str | None) -> list[str] | None:
|
|
351
|
+
"""Split a docstring into a list of lines for the JSON output."""
|
|
352
|
+
if docstring is None:
|
|
353
|
+
return None
|
|
354
|
+
return docstring.split("\n")
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _split_overload_docstrings(overloads: list[dict]) -> list[dict]:
|
|
358
|
+
"""Return copies of overload entries with docstrings split into lines."""
|
|
359
|
+
return [
|
|
360
|
+
{"signature": o["signature"], "docstring": _split_docstring(o["docstring"])}
|
|
361
|
+
for o in overloads
|
|
362
|
+
]
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _collect_module_member_names(mod: Any) -> list[str]:
|
|
366
|
+
if hasattr(mod, "__all__"):
|
|
367
|
+
return list(mod.__all__)
|
|
368
|
+
return [n for n in dir(mod) if not n.startswith("_")]
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _collect_class_member_names(cls: Any) -> list[str]:
|
|
372
|
+
return [name for name in cls.__dict__ if not name.startswith("_")]
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _collect_class_magic_methods(cls: Any) -> list[str]:
|
|
376
|
+
return sorted(
|
|
377
|
+
name for name in cls.__dict__
|
|
378
|
+
if name.startswith("__") and name.endswith("__")
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _introspect(symbol: str) -> dict:
|
|
383
|
+
parts = symbol.split(".")
|
|
384
|
+
|
|
385
|
+
# Progressive import: try longest prefix first, fall back to shorter ones.
|
|
386
|
+
module = None
|
|
387
|
+
attr_chain: list[str] = []
|
|
388
|
+
last_errors: dict[str, str] = {}
|
|
389
|
+
for i in range(len(parts), 0, -1):
|
|
390
|
+
mod_name = ".".join(parts[:i])
|
|
391
|
+
try:
|
|
392
|
+
module = importlib.import_module(mod_name)
|
|
393
|
+
attr_chain = parts[i:]
|
|
394
|
+
break
|
|
395
|
+
except ImportError as exc:
|
|
396
|
+
last_errors[mod_name] = str(exc)
|
|
397
|
+
except Exception as exc: # noqa: BLE001 — surface import errors as structured JSON
|
|
398
|
+
last_errors[mod_name] = f"{type(exc).__name__}: {exc}"
|
|
399
|
+
|
|
400
|
+
if module is None:
|
|
401
|
+
return _introspect_from_stub_only(symbol, parts, last_errors)
|
|
402
|
+
|
|
403
|
+
# Walk the attribute chain.
|
|
404
|
+
obj: Any = module
|
|
405
|
+
resolved_module = ".".join(parts[: len(parts) - len(attr_chain)])
|
|
406
|
+
for attr in attr_chain:
|
|
407
|
+
try:
|
|
408
|
+
obj = getattr(obj, attr)
|
|
409
|
+
except AttributeError:
|
|
410
|
+
return {
|
|
411
|
+
"ok": False,
|
|
412
|
+
"error": f"'{resolved_module}' has no attribute '{attr}'",
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
category = _determine_category(obj)
|
|
416
|
+
stub_index, stub_entry = _stub_lookup(resolved_module, attr_chain)
|
|
417
|
+
|
|
418
|
+
if category == "module":
|
|
419
|
+
members = _collect_module_member_names(obj)
|
|
420
|
+
magic_methods: list[str] = []
|
|
421
|
+
elif category == "class":
|
|
422
|
+
members = _collect_class_member_names(obj)
|
|
423
|
+
magic_methods = _collect_class_magic_methods(obj)
|
|
424
|
+
else:
|
|
425
|
+
members = []
|
|
426
|
+
magic_methods = []
|
|
427
|
+
|
|
428
|
+
docstring = inspect.getdoc(obj)
|
|
429
|
+
signature = _safe_signature(obj) if callable(obj) else None
|
|
430
|
+
overloads: list[dict] = []
|
|
431
|
+
|
|
432
|
+
if stub_entry is not None:
|
|
433
|
+
category = stub_entry["category"] if category == "attribute" else category
|
|
434
|
+
# The stub text is cleaner than the pybind/Boost-generated runtime docstring.
|
|
435
|
+
docstring = stub_entry["docstring"] or docstring
|
|
436
|
+
signature = stub_entry["signature"] or signature
|
|
437
|
+
overloads = stub_entry["overloads"]
|
|
438
|
+
if "members" in stub_entry:
|
|
439
|
+
members = _format_stub_members(stub_entry["members"], magic=False)
|
|
440
|
+
magic_methods = _format_stub_members(stub_entry["members"], magic=True)
|
|
441
|
+
elif stub_index is not None and not attr_chain:
|
|
442
|
+
docstring = stub_index["docstring"] or docstring
|
|
443
|
+
members = _format_stub_members(stub_index["members"], magic=False) or members
|
|
444
|
+
|
|
445
|
+
return {
|
|
446
|
+
"ok": True,
|
|
447
|
+
"data": {
|
|
448
|
+
"symbol": symbol,
|
|
449
|
+
"category": category,
|
|
450
|
+
"module": resolved_module,
|
|
451
|
+
"docstring": _split_docstring(docstring),
|
|
452
|
+
"signature": signature,
|
|
453
|
+
"overloads": _split_overload_docstrings(overloads),
|
|
454
|
+
"members": members,
|
|
455
|
+
"magic_methods": magic_methods,
|
|
456
|
+
"stub_file": (stub_index or {}).get("path"),
|
|
457
|
+
},
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _introspect_from_stub_only(symbol: str, parts: list[str], import_errors: dict) -> dict:
|
|
462
|
+
"""Resolve a symbol purely from its ``.pyi`` stub when the module cannot be imported."""
|
|
463
|
+
for i in range(len(parts), 0, -1):
|
|
464
|
+
module_name = ".".join(parts[:i])
|
|
465
|
+
index, entry = _stub_lookup(module_name, parts[i:])
|
|
466
|
+
if index is None:
|
|
467
|
+
continue
|
|
468
|
+
if parts[i:] and entry is None:
|
|
469
|
+
return {
|
|
470
|
+
"ok": False,
|
|
471
|
+
"error": f"'{module_name}' stub has no attribute '{'.'.join(parts[i:])}'",
|
|
472
|
+
}
|
|
473
|
+
source = entry or {
|
|
474
|
+
"category": "module",
|
|
475
|
+
"docstring": index["docstring"],
|
|
476
|
+
"signature": None,
|
|
477
|
+
"overloads": [],
|
|
478
|
+
"members": index["members"],
|
|
479
|
+
}
|
|
480
|
+
return {
|
|
481
|
+
"ok": True,
|
|
482
|
+
"data": {
|
|
483
|
+
"symbol": symbol,
|
|
484
|
+
"category": source["category"],
|
|
485
|
+
"module": module_name,
|
|
486
|
+
"docstring": _split_docstring(source["docstring"]),
|
|
487
|
+
"signature": source["signature"],
|
|
488
|
+
"overloads": _split_overload_docstrings(source["overloads"]),
|
|
489
|
+
"members": _format_stub_members(source.get("members", {}), magic=False),
|
|
490
|
+
"magic_methods": _format_stub_members(source.get("members", {}), magic=True),
|
|
491
|
+
"stub_file": index["path"],
|
|
492
|
+
},
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
return {
|
|
496
|
+
"ok": False,
|
|
497
|
+
"error": f"Cannot import any module from '{symbol}'",
|
|
498
|
+
"import_errors": import_errors,
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _emit(obj: dict) -> None:
|
|
503
|
+
"""Write one JSON line to stdout and flush immediately."""
|
|
504
|
+
sys.stdout.write(json.dumps(obj) + "\n")
|
|
505
|
+
sys.stdout.flush()
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def main() -> None:
|
|
509
|
+
# Stage 1: one-time environment setup.
|
|
510
|
+
init_params = _read_line()
|
|
511
|
+
if init_params is None:
|
|
512
|
+
return
|
|
513
|
+
try:
|
|
514
|
+
_setup_environment(init_params)
|
|
515
|
+
_emit({"ok": True})
|
|
516
|
+
except Exception as exc: # noqa: BLE001
|
|
517
|
+
_emit({"ok": False, "error": str(exc)})
|
|
518
|
+
return
|
|
519
|
+
|
|
520
|
+
# Stage 2: query loop — one symbol per line until stdin closes.
|
|
521
|
+
while True:
|
|
522
|
+
query = _read_line()
|
|
523
|
+
if query is None:
|
|
524
|
+
break
|
|
525
|
+
try:
|
|
526
|
+
_emit(_introspect(query["symbol"]))
|
|
527
|
+
except Exception as exc: # noqa: BLE001
|
|
528
|
+
_emit({"ok": False, "error": str(exc)})
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
try:
|
|
532
|
+
main()
|
|
533
|
+
except Exception as exc: # noqa: BLE001 — top-level guard; must not crash the subprocess
|
|
534
|
+
try:
|
|
535
|
+
_emit({"ok": False, "error": str(exc)})
|
|
536
|
+
except Exception: # noqa: BLE001
|
|
537
|
+
pass
|