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.
@@ -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