nativegate 0.1.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.
- nativegate/__init__.py +1 -0
- nativegate/__main__.py +4 -0
- nativegate/buildinfo.py +344 -0
- nativegate/cli.py +2007 -0
- nativegate/config.py +991 -0
- nativegate/declared_invariants.py +565 -0
- nativegate/discovery.py +167 -0
- nativegate/driverbuild.py +626 -0
- nativegate/drivers/__init__.py +5 -0
- nativegate/drivers/cpp.py +616 -0
- nativegate/drivers/fortran.py +507 -0
- nativegate/generators/__init__.py +0 -0
- nativegate/generators/cmake_gen.py +101 -0
- nativegate/generators/docker_gen.py +614 -0
- nativegate/generators/error_gen.py +104 -0
- nativegate/generators/f2py_gen.py +91 -0
- nativegate/generators/gateway_gen.py +110 -0
- nativegate/generators/golden_gen.py +50 -0
- nativegate/generators/k8s_gen.py +212 -0
- nativegate/generators/mcp_gen.py +281 -0
- nativegate/generators/middleware_gen.py +717 -0
- nativegate/generators/pybind_gen.py +406 -0
- nativegate/generators/pyproject_gen.py +61 -0
- nativegate/generators/python_pkg_gen.py +1164 -0
- nativegate/generators/test_gen.py +160 -0
- nativegate/golden.py +747 -0
- nativegate/invariants.py +532 -0
- nativegate/ir.py +789 -0
- nativegate/lattice.py +350 -0
- nativegate/locking.py +216 -0
- nativegate/oracle.py +904 -0
- nativegate/parsers/__init__.py +0 -0
- nativegate/parsers/cpp.py +105 -0
- nativegate/parsers/cpp_ast.py +1652 -0
- nativegate/parsers/cpp_regex.py +812 -0
- nativegate/parsers/fixed_form.py +868 -0
- nativegate/parsers/fortran.py +157 -0
- nativegate/parsers/fortran_fparser.py +1116 -0
- nativegate/parsers/fortran_regex.py +686 -0
- nativegate/preprocess.py +335 -0
- nativegate/structural_invariants.py +762 -0
- nativegate/suggest.py +208 -0
- nativegate/templates/__init__.py +20 -0
- nativegate/templates/golden_test_template.py +248 -0
- nativegate/wire.py +438 -0
- nativegate-0.1.0.dist-info/METADATA +547 -0
- nativegate-0.1.0.dist-info/RECORD +50 -0
- nativegate-0.1.0.dist-info/WHEEL +5 -0
- nativegate-0.1.0.dist-info/entry_points.txt +3 -0
- nativegate-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1652 @@
|
|
|
1
|
+
"""C++ header parser built on the Clang AST (design.md section 7).
|
|
2
|
+
|
|
3
|
+
This is the parser design.md always specified; `cpp_regex.py` was the interim
|
|
4
|
+
token/brace reader. Both produce the same `ir.ModuleIR`, so generators and the
|
|
5
|
+
CLI do not care which one ran — see `cpp.py` for the selection logic.
|
|
6
|
+
|
|
7
|
+
What moving to a real AST buys, concretely — every one of these is a case the
|
|
8
|
+
regex reader could not see:
|
|
9
|
+
|
|
10
|
+
* **The preprocessor runs.** `#include`d types, `#define`d names, `#ifdef`ed
|
|
11
|
+
declarations and macro-mangled `extern "C"` bridge headers (the F77_NAME
|
|
12
|
+
pattern) are expanded before parsing instead of being skipped and reported.
|
|
13
|
+
* **Templates.** Class and function templates are recognised as templates and
|
|
14
|
+
reported as unbindable-without-instantiation, rather than mis-parsed into a
|
|
15
|
+
bogus declaration or dropped silently.
|
|
16
|
+
* **Typedefs and aliases.** `typedef double Pressure;`, `using Depth = double;`
|
|
17
|
+
and `typedef struct {...} Point;` resolve to their canonical types.
|
|
18
|
+
* **Real name lookup.** A type is a known record because the AST says a record
|
|
19
|
+
with that name is declared, not because the same header happened to spell
|
|
20
|
+
`class X {` somewhere. Incompleteness is read off the declaration
|
|
21
|
+
(`is_definition()`), not guessed from capitalisation.
|
|
22
|
+
* **Overloads, `= delete`, `= default`, copy/move constructors, abstract
|
|
23
|
+
classes, access via inheritance, nested types, enums, references vs
|
|
24
|
+
pointers, cv-qualification** — all are distinguished by the AST rather than
|
|
25
|
+
by token shape.
|
|
26
|
+
|
|
27
|
+
What it still refuses to bind, deliberately (same rules as the interim
|
|
28
|
+
parser, because they are pybind11 constraints and not parsing limits):
|
|
29
|
+
|
|
30
|
+
* raw pointers to non-char, non-record types (no length information),
|
|
31
|
+
* raw pointer *returns* of record types (ownership would be handed to Python
|
|
32
|
+
while C++ still owns the object),
|
|
33
|
+
* class/function templates, which have no single instantiation to bind,
|
|
34
|
+
* STL and user types with no entry in `ir.TYPE_MAP` and no definition in the
|
|
35
|
+
service's headers.
|
|
36
|
+
|
|
37
|
+
All of those are reported through `ModuleIR.skipped` — one unbindable
|
|
38
|
+
signature never costs you the rest of the header.
|
|
39
|
+
|
|
40
|
+
Requires the `libclang` Python package (``pip install "nativegate[clang]"``).
|
|
41
|
+
`is_available()` reports whether it loaded; `cpp.py` falls back to the regex
|
|
42
|
+
reader when it did not.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
import os
|
|
48
|
+
import subprocess
|
|
49
|
+
import sys
|
|
50
|
+
from dataclasses import dataclass
|
|
51
|
+
from pathlib import Path
|
|
52
|
+
|
|
53
|
+
from ..config import ExposeConfig
|
|
54
|
+
from ..ir import (
|
|
55
|
+
ClassDef,
|
|
56
|
+
FunctionDef,
|
|
57
|
+
Method,
|
|
58
|
+
ModuleIR,
|
|
59
|
+
NativeTypeError,
|
|
60
|
+
Parameter,
|
|
61
|
+
SkippedSymbol,
|
|
62
|
+
StructDef,
|
|
63
|
+
size_pairings,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# --- libclang loading ---------------------------------------------------
|
|
67
|
+
|
|
68
|
+
# Import failure must not be fatal at import time: nativegate's Fortran half,
|
|
69
|
+
# and the regex C++ reader, work fine without libclang installed.
|
|
70
|
+
try: # pragma: no cover - exercised by whichever branch the machine has
|
|
71
|
+
from clang import cindex as _cindex
|
|
72
|
+
except Exception: # ImportError, or a broken wheel
|
|
73
|
+
_cindex = None
|
|
74
|
+
|
|
75
|
+
_LOAD_ERROR: str | None = None
|
|
76
|
+
_LOADED = False
|
|
77
|
+
|
|
78
|
+
# Where a libclang shared library usually lives when the pip wheel didn't
|
|
79
|
+
# bring its own. NATIVEGATE_LIBCLANG overrides all of it.
|
|
80
|
+
_LIBCLANG_CANDIDATES = (
|
|
81
|
+
"/Library/Developer/CommandLineTools/usr/lib/libclang.dylib",
|
|
82
|
+
"/opt/homebrew/opt/llvm/lib/libclang.dylib",
|
|
83
|
+
"/usr/local/opt/llvm/lib/libclang.dylib",
|
|
84
|
+
"/usr/lib/llvm-18/lib/libclang.so.1",
|
|
85
|
+
"/usr/lib/llvm-17/lib/libclang.so.1",
|
|
86
|
+
"/usr/lib/llvm-16/lib/libclang.so.1",
|
|
87
|
+
"/usr/lib/x86_64-linux-gnu/libclang-18.so.1",
|
|
88
|
+
"/usr/lib/x86_64-linux-gnu/libclang.so.1",
|
|
89
|
+
# Windows: LLVM's own installer and the two package managers that ship it.
|
|
90
|
+
# A DLL is loaded by name, so these are absolute paths on purpose —
|
|
91
|
+
# set_library_file with a bare name would search PATH and could pick up an
|
|
92
|
+
# unrelated libclang.dll.
|
|
93
|
+
r"C:\Program Files\LLVM\bin\libclang.dll",
|
|
94
|
+
r"C:\Program Files (x86)\LLVM\bin\libclang.dll",
|
|
95
|
+
str(
|
|
96
|
+
Path(os.environ.get("ProgramData", r"C:\ProgramData"))
|
|
97
|
+
/ "chocolatey/lib/llvm/tools/LLVM/bin/libclang.dll"
|
|
98
|
+
),
|
|
99
|
+
str(
|
|
100
|
+
Path(os.environ.get("VCPKG_ROOT", r"C:\vcpkg"))
|
|
101
|
+
/ "installed/x64-windows/bin/libclang.dll"
|
|
102
|
+
),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _load() -> bool:
|
|
107
|
+
"""Import libclang and point it at a usable shared library. Idempotent."""
|
|
108
|
+
global _LOADED, _LOAD_ERROR
|
|
109
|
+
if _LOADED:
|
|
110
|
+
return True
|
|
111
|
+
if _cindex is None:
|
|
112
|
+
_LOAD_ERROR = (
|
|
113
|
+
"the 'libclang' Python package is not installed "
|
|
114
|
+
'(pip install "nativegate[clang]")'
|
|
115
|
+
)
|
|
116
|
+
return False
|
|
117
|
+
|
|
118
|
+
override = os.environ.get("NATIVEGATE_LIBCLANG")
|
|
119
|
+
candidates = [override] if override else list(_LIBCLANG_CANDIDATES)
|
|
120
|
+
|
|
121
|
+
# The pip `libclang` wheel ships its own native library and configures
|
|
122
|
+
# itself on import, so try a bare Index first and only go hunting if that
|
|
123
|
+
# fails.
|
|
124
|
+
try:
|
|
125
|
+
_cindex.Index.create()
|
|
126
|
+
_LOADED = True
|
|
127
|
+
return True
|
|
128
|
+
except Exception as exc: # library not found / not configured
|
|
129
|
+
_LOAD_ERROR = str(exc)
|
|
130
|
+
|
|
131
|
+
for candidate in candidates:
|
|
132
|
+
if not candidate or not Path(candidate).exists():
|
|
133
|
+
continue
|
|
134
|
+
try:
|
|
135
|
+
_cindex.Config.set_library_file(candidate)
|
|
136
|
+
_cindex.Index.create()
|
|
137
|
+
_LOADED = True
|
|
138
|
+
return True
|
|
139
|
+
except Exception as exc:
|
|
140
|
+
_LOAD_ERROR = f"{candidate}: {exc}"
|
|
141
|
+
|
|
142
|
+
if override and not Path(override).exists():
|
|
143
|
+
_LOAD_ERROR = f"NATIVEGATE_LIBCLANG={override} does not exist"
|
|
144
|
+
return False
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def is_available() -> bool:
|
|
148
|
+
"""True if a Clang AST parse can actually run on this machine."""
|
|
149
|
+
return _load()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def unavailable_reason() -> str:
|
|
153
|
+
return _LOAD_ERROR or "unknown"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def clang_version() -> str:
|
|
157
|
+
"""A human-readable identifier for the libclang in use.
|
|
158
|
+
|
|
159
|
+
clang_getClangVersion() returns a CXString whose conversion is not exposed
|
|
160
|
+
consistently across libclang builds, so report the packaged version and the
|
|
161
|
+
library file instead — both are what you need when a parse behaves oddly.
|
|
162
|
+
"""
|
|
163
|
+
if not _load():
|
|
164
|
+
return "unavailable"
|
|
165
|
+
try:
|
|
166
|
+
from importlib.metadata import version
|
|
167
|
+
|
|
168
|
+
return f"libclang {version('libclang')}"
|
|
169
|
+
except Exception:
|
|
170
|
+
pass
|
|
171
|
+
try:
|
|
172
|
+
return Path(_cindex.conf.get_filename()).name
|
|
173
|
+
except Exception:
|
|
174
|
+
return "unknown version"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# --- parse configuration ------------------------------------------------
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
DEFAULT_STD = "c++17"
|
|
181
|
+
|
|
182
|
+
_PLATFORM_ARGS: list[str] | None = None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _platform_args() -> list[str]:
|
|
186
|
+
"""Flags that let libclang find the standard library and its own builtins.
|
|
187
|
+
|
|
188
|
+
The pip `libclang` wheel is a bare front end: it has no notion of where
|
|
189
|
+
the SDK or its own `stdarg.h` live, so `#include <string>` fails and every
|
|
190
|
+
type that came from it silently degrades to `int` under error recovery.
|
|
191
|
+
That degradation is precisely the class of bug an AST parser is supposed
|
|
192
|
+
to eliminate, so the search happens up front and is cached.
|
|
193
|
+
"""
|
|
194
|
+
global _PLATFORM_ARGS
|
|
195
|
+
if _PLATFORM_ARGS is not None:
|
|
196
|
+
return _PLATFORM_ARGS
|
|
197
|
+
|
|
198
|
+
args: list[str] = []
|
|
199
|
+
|
|
200
|
+
if sys.platform == "darwin":
|
|
201
|
+
sdk = _run(["xcrun", "--show-sdk-path"])
|
|
202
|
+
if sdk:
|
|
203
|
+
args += ["-isysroot", sdk]
|
|
204
|
+
|
|
205
|
+
resource_dir = _run(["clang", "-print-resource-dir"]) or _run(
|
|
206
|
+
["xcrun", "clang", "-print-resource-dir"]
|
|
207
|
+
)
|
|
208
|
+
builtin_includes = []
|
|
209
|
+
if resource_dir:
|
|
210
|
+
builtin_includes.append(Path(resource_dir) / "include")
|
|
211
|
+
builtin_includes += sorted(
|
|
212
|
+
Path("/Library/Developer/CommandLineTools/usr/lib/clang").glob("*/include"),
|
|
213
|
+
reverse=True,
|
|
214
|
+
)
|
|
215
|
+
for candidate in builtin_includes:
|
|
216
|
+
if (candidate / "stdarg.h").exists():
|
|
217
|
+
# -idirafter, not -isystem: clang's builtin headers must come
|
|
218
|
+
# *after* the C++ standard library's, or libc++'s <cstddef> finds
|
|
219
|
+
# the C <stddef.h> first and the whole standard library falls over
|
|
220
|
+
# with "your header search paths are not configured properly".
|
|
221
|
+
args += ["-idirafter", str(candidate)]
|
|
222
|
+
break
|
|
223
|
+
|
|
224
|
+
_PLATFORM_ARGS = args
|
|
225
|
+
return args
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _run(command: list[str]) -> str | None:
|
|
229
|
+
try:
|
|
230
|
+
out = subprocess.run(
|
|
231
|
+
command, capture_output=True, text=True, timeout=20, check=False
|
|
232
|
+
)
|
|
233
|
+
except (OSError, subprocess.SubprocessError):
|
|
234
|
+
return None
|
|
235
|
+
value = out.stdout.strip()
|
|
236
|
+
return value if out.returncode == 0 and value else None
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@dataclass(frozen=True)
|
|
240
|
+
class ClangOptions:
|
|
241
|
+
"""Compiler flags for the parse. Mirrors nativegate.yaml's `clang:` block."""
|
|
242
|
+
|
|
243
|
+
std: str = DEFAULT_STD
|
|
244
|
+
include_paths: tuple[str, ...] = ()
|
|
245
|
+
defines: tuple[str, ...] = ()
|
|
246
|
+
extra_args: tuple[str, ...] = ()
|
|
247
|
+
# `-x <language>`. `.h` is ambiguous: a C header using `restrict` or a K&R
|
|
248
|
+
# declaration is not valid C++, and forcing `-x c++` reports the failure as
|
|
249
|
+
# a C++ diagnostic. Defaults to C++ so existing callers — config.ClangConfig
|
|
250
|
+
# among them, which does not know about this field — are unchanged.
|
|
251
|
+
language: str = "c++"
|
|
252
|
+
# extern "C" functions whose bare `T*` arguments are scalars passed by
|
|
253
|
+
# reference. See config.ClangConfig.scalar_ref_functions for why this is
|
|
254
|
+
# opt-in per function rather than inferred.
|
|
255
|
+
scalar_ref_functions: tuple[str, ...] = ()
|
|
256
|
+
|
|
257
|
+
def command_line(self, header: Path) -> list[str]:
|
|
258
|
+
args = ["-x", self.language, "-fsyntax-only"]
|
|
259
|
+
# A C++ -std with -x c (or the reverse) is a hard clang error, so the
|
|
260
|
+
# standard is only passed when it belongs to the selected language.
|
|
261
|
+
is_cpp_language = "c++" in self.language
|
|
262
|
+
is_cpp_std = self.std.startswith("c++") or self.std.startswith("gnu++")
|
|
263
|
+
if is_cpp_language == is_cpp_std:
|
|
264
|
+
args.append(f"-std={self.std}")
|
|
265
|
+
args += _platform_args()
|
|
266
|
+
# A header almost always includes its siblings by bare name.
|
|
267
|
+
args.append(f"-I{header.parent}")
|
|
268
|
+
args.extend(f"-I{p}" for p in self.include_paths)
|
|
269
|
+
args.extend(f"-D{d}" for d in self.defines)
|
|
270
|
+
args.extend(self.extra_args)
|
|
271
|
+
return args
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# --- type mapping -------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _kinds(*names):
|
|
278
|
+
"""TypeKind members by name — the set varies across libclang versions."""
|
|
279
|
+
return {
|
|
280
|
+
getattr(_cindex.TypeKind, n) for n in names if hasattr(_cindex.TypeKind, n)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _builtin_map() -> dict:
|
|
285
|
+
return {
|
|
286
|
+
**{k: "None" for k in _kinds("VOID")},
|
|
287
|
+
**{k: "bool" for k in _kinds("BOOL")},
|
|
288
|
+
**{
|
|
289
|
+
k: "int"
|
|
290
|
+
for k in _kinds(
|
|
291
|
+
"CHAR_U", "USHORT", "UINT", "ULONG", "ULONGLONG", "UINT128",
|
|
292
|
+
"SHORT", "INT", "LONG", "LONGLONG", "INT128", "WCHAR",
|
|
293
|
+
"CHAR16", "CHAR32",
|
|
294
|
+
)
|
|
295
|
+
},
|
|
296
|
+
**{k: "float" for k in _kinds("FLOAT", "DOUBLE", "LONGDOUBLE", "FLOAT128")},
|
|
297
|
+
# A lone `char` is a one-character string on the Python side, matching
|
|
298
|
+
# ir.TYPE_MAP. `char*` is handled separately, as a C string.
|
|
299
|
+
**{k: "str" for k in _kinds("CHAR_S", "SCHAR", "UCHAR")},
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
_CHAR_KIND_NAMES = ("CHAR_S", "SCHAR", "UCHAR", "CHAR_U")
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _strip_refs(t):
|
|
307
|
+
while t.kind in _kinds("LVALUEREFERENCE", "RVALUEREFERENCE"):
|
|
308
|
+
t = t.get_pointee()
|
|
309
|
+
return t
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _record_short_name(t) -> str | None:
|
|
313
|
+
"""Unqualified name of the record/enum a type refers to, if any."""
|
|
314
|
+
decl = t.get_declaration()
|
|
315
|
+
if decl is None or decl.kind == _cindex.CursorKind.NO_DECL_FOUND:
|
|
316
|
+
return None
|
|
317
|
+
if decl.spelling:
|
|
318
|
+
return decl.spelling.split("::")[-1].split("<")[0]
|
|
319
|
+
return None
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _qualified_decl_name(decl) -> str | None:
|
|
323
|
+
"""`petro::Correlation` for a declaration, walking namespaces and records.
|
|
324
|
+
|
|
325
|
+
The Python type name is lossy by construction (every enum is `int`), so a
|
|
326
|
+
generator that must emit C++ needs the spelling as written *and* qualified
|
|
327
|
+
enough to be valid at namespace scope in the generated binding file.
|
|
328
|
+
"""
|
|
329
|
+
if decl is None or not decl.spelling:
|
|
330
|
+
return None
|
|
331
|
+
parts = [decl.spelling.split("<")[0]]
|
|
332
|
+
parent = decl.semantic_parent
|
|
333
|
+
while parent is not None and parent.kind != _cindex.CursorKind.TRANSLATION_UNIT:
|
|
334
|
+
if parent.spelling and parent.kind in (
|
|
335
|
+
_cindex.CursorKind.NAMESPACE,
|
|
336
|
+
_cindex.CursorKind.STRUCT_DECL,
|
|
337
|
+
_cindex.CursorKind.CLASS_DECL,
|
|
338
|
+
_cindex.CursorKind.UNION_DECL,
|
|
339
|
+
_cindex.CursorKind.CLASS_TEMPLATE,
|
|
340
|
+
):
|
|
341
|
+
parts.append(parent.spelling.split("<")[0])
|
|
342
|
+
parent = parent.semantic_parent
|
|
343
|
+
return "::".join(reversed(parts))
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
_NAMED_TYPE_DECL_KINDS = lambda: ( # noqa: E731 - needs _cindex loaded first
|
|
347
|
+
_cindex.CursorKind.STRUCT_DECL,
|
|
348
|
+
_cindex.CursorKind.CLASS_DECL,
|
|
349
|
+
_cindex.CursorKind.UNION_DECL,
|
|
350
|
+
_cindex.CursorKind.ENUM_DECL,
|
|
351
|
+
_cindex.CursorKind.TYPEDEF_DECL,
|
|
352
|
+
_cindex.CursorKind.TYPE_ALIAS_DECL,
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _native_spelling(t) -> str | None:
|
|
357
|
+
"""The C++ type as written, qualified — `std::uint64_t`, `petro::Correlation`.
|
|
358
|
+
|
|
359
|
+
Top-level `const` and reference decoration are dropped: constness travels
|
|
360
|
+
separately on `ir.Parameter.is_const`, and what a generator needs here is
|
|
361
|
+
the type to name inside `py::init<...>`.
|
|
362
|
+
"""
|
|
363
|
+
base = _strip_refs(t)
|
|
364
|
+
decl = base.get_declaration()
|
|
365
|
+
if (
|
|
366
|
+
decl is not None
|
|
367
|
+
and decl.kind != _cindex.CursorKind.NO_DECL_FOUND
|
|
368
|
+
and decl.kind in _NAMED_TYPE_DECL_KINDS()
|
|
369
|
+
):
|
|
370
|
+
qualified = _qualified_decl_name(decl)
|
|
371
|
+
if qualified:
|
|
372
|
+
return qualified
|
|
373
|
+
spelling = base.spelling.strip()
|
|
374
|
+
if spelling.startswith("const "):
|
|
375
|
+
spelling = spelling[len("const ") :].strip()
|
|
376
|
+
return spelling or None
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
_STD_STRING_SPELLINGS = frozenset(
|
|
380
|
+
{"std::string", "std::__1::string", "std::__cxx11::string", "string"}
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _is_std_string(t) -> bool:
|
|
385
|
+
"""True for std::string in any of its spellings.
|
|
386
|
+
|
|
387
|
+
libclang keeps the sugar: the canonical type of `std::string` is spelled
|
|
388
|
+
`std::string` on libc++ and `std::__cxx11::basic_string<char, ...>` on
|
|
389
|
+
libstdc++, and a user typedef adds another layer on top. All of them are
|
|
390
|
+
the same std::string that pybind11 converts to a Python str.
|
|
391
|
+
"""
|
|
392
|
+
canonical = t.get_canonical()
|
|
393
|
+
spelling = canonical.spelling.replace("const ", "").strip()
|
|
394
|
+
if "basic_string<char" in spelling or spelling in _STD_STRING_SPELLINGS:
|
|
395
|
+
return True
|
|
396
|
+
declaration = canonical.get_declaration()
|
|
397
|
+
return bool(
|
|
398
|
+
declaration
|
|
399
|
+
and declaration.spelling == "basic_string"
|
|
400
|
+
and "char" in declaration.type.spelling
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _is_incomplete_record(t) -> bool:
|
|
405
|
+
decl = t.get_declaration()
|
|
406
|
+
if decl is None:
|
|
407
|
+
return False
|
|
408
|
+
if decl.kind not in (
|
|
409
|
+
_cindex.CursorKind.STRUCT_DECL,
|
|
410
|
+
_cindex.CursorKind.CLASS_DECL,
|
|
411
|
+
_cindex.CursorKind.UNION_DECL,
|
|
412
|
+
):
|
|
413
|
+
return False
|
|
414
|
+
return not decl.is_definition()
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
# C++ operator -> the Python special method pybind11 should bind it as, keyed
|
|
418
|
+
# by (spelling, argument count). The count matters: a member `operator-` with
|
|
419
|
+
# one argument is subtraction, with none it is negation, and binding one as the
|
|
420
|
+
# other would compile and then compute something else entirely.
|
|
421
|
+
_OPERATOR_DUNDERS = {
|
|
422
|
+
("operator+", 1): "__add__",
|
|
423
|
+
("operator-", 1): "__sub__",
|
|
424
|
+
("operator*", 1): "__mul__",
|
|
425
|
+
("operator/", 1): "__truediv__",
|
|
426
|
+
("operator%", 1): "__mod__",
|
|
427
|
+
("operator+", 0): "__pos__",
|
|
428
|
+
("operator-", 0): "__neg__",
|
|
429
|
+
("operator==", 1): "__eq__",
|
|
430
|
+
("operator!=", 1): "__ne__",
|
|
431
|
+
("operator<", 1): "__lt__",
|
|
432
|
+
("operator<=", 1): "__le__",
|
|
433
|
+
("operator>", 1): "__gt__",
|
|
434
|
+
("operator>=", 1): "__ge__",
|
|
435
|
+
("operator[]", 1): "__getitem__",
|
|
436
|
+
("operator()", 1): "__call__",
|
|
437
|
+
("operator!", 0): "__invert__",
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
# Why the rest are refused rather than approximated.
|
|
441
|
+
_OPERATOR_REFUSALS = {
|
|
442
|
+
"operator=": "assignment has no Python equivalent — Python rebinds names, it does not assign through them",
|
|
443
|
+
"operator+=": "a compound assignment must return the mutated object for Python's `__iadd__` protocol, and a C++ `T&` return needs a return-value policy nativegate cannot infer",
|
|
444
|
+
"operator-=": "same as operator+=",
|
|
445
|
+
"operator*=": "same as operator+=",
|
|
446
|
+
"operator/=": "same as operator+=",
|
|
447
|
+
"operator++": "Python has no increment operator",
|
|
448
|
+
"operator--": "Python has no decrement operator",
|
|
449
|
+
"operator->": "member access cannot be forwarded through pybind11",
|
|
450
|
+
"operator new": "allocation is not something Python may call",
|
|
451
|
+
"operator delete": "deallocation is managed by pybind11's holder, not by Python",
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _operator_dunder(spelling: str, arg_count: int) -> str | None:
|
|
456
|
+
return _OPERATOR_DUNDERS.get((spelling, arg_count))
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _operator_refusal(spelling: str, arg_count: int) -> str:
|
|
460
|
+
"""Why this operator is not bound — never just 'unsupported'."""
|
|
461
|
+
base = spelling.split("(")[0].strip()
|
|
462
|
+
if base in _OPERATOR_REFUSALS:
|
|
463
|
+
return (
|
|
464
|
+
f"'{spelling}' is not bound: {_OPERATOR_REFUSALS[base]}. "
|
|
465
|
+
"Expose a named method instead, or exclude the symbol in "
|
|
466
|
+
"nativegate.yaml."
|
|
467
|
+
)
|
|
468
|
+
return (
|
|
469
|
+
f"'{spelling}' with {arg_count} argument(s) has no Python special "
|
|
470
|
+
"method nativegate maps it to. Expose a named method instead, or "
|
|
471
|
+
"exclude the symbol in nativegate.yaml."
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
class PointerLengthUnknown(NativeTypeError):
|
|
476
|
+
"""A pointer to a scalar, whose length is not knowable from the type alone.
|
|
477
|
+
|
|
478
|
+
Still a NativeTypeError, so every existing caller keeps refusing it. What
|
|
479
|
+
the subclass adds is the information `_parameters` needs to reconsider:
|
|
480
|
+
the element type, and whether the callee may write through it. If another
|
|
481
|
+
argument of the same function turns out to be this pointer's length, the
|
|
482
|
+
pair becomes a bindable array; otherwise this propagates unchanged.
|
|
483
|
+
"""
|
|
484
|
+
|
|
485
|
+
def __init__(self, message: str, *, element: str, is_mutable: bool) -> None:
|
|
486
|
+
super().__init__(message)
|
|
487
|
+
self.element = element
|
|
488
|
+
self.is_mutable = is_mutable
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _refuse_mutable_vector_ref(t, *, is_return: bool) -> None:
|
|
492
|
+
"""Refuse `std::vector<T>&` — pybind11 discards writes through it, silently.
|
|
493
|
+
|
|
494
|
+
THIS IS THE REASON std::vector SUPPORT IS SAFE TO HAVE AT ALL.
|
|
495
|
+
|
|
496
|
+
`<pybind11/stl.h>` converts a Python list to a *temporary* std::vector for
|
|
497
|
+
the duration of the call. For an input parameter — by value or by
|
|
498
|
+
`const&` — that is exactly right. For a non-const `std::vector<T>&`, which
|
|
499
|
+
in C++ means "I am going to write to your vector", the callee writes into
|
|
500
|
+
the temporary and the temporary is destroyed on return. The caller's list
|
|
501
|
+
is unchanged.
|
|
502
|
+
|
|
503
|
+
Nothing raises. Nothing warns. It compiles, it runs, it returns. Measured
|
|
504
|
+
with a real pybind11 module:
|
|
505
|
+
|
|
506
|
+
void scale_in_place(std::vector<double>& v, double k);
|
|
507
|
+
|
|
508
|
+
>>> data = [1.0, 2.0, 3.0]
|
|
509
|
+
>>> probe.scale_in_place(data, 10.0)
|
|
510
|
+
>>> data
|
|
511
|
+
[1.0, 2.0, 3.0] # writes discarded
|
|
512
|
+
|
|
513
|
+
A service that binds that builds, imports, passes a smoke test, and returns
|
|
514
|
+
unmodified data to whoever asked. That is the exact failure class this
|
|
515
|
+
project treats as the worst kind, so the binding is refused instead.
|
|
516
|
+
|
|
517
|
+
Returning `std::vector<T>&` is fine to bind — pybind11 copies it out, and a
|
|
518
|
+
copy of a returned value is what Python wants anyway.
|
|
519
|
+
"""
|
|
520
|
+
if is_return or t.kind not in _kinds("LVALUEREFERENCE"):
|
|
521
|
+
return
|
|
522
|
+
pointee = t.get_pointee()
|
|
523
|
+
if not _is_std_vector(pointee):
|
|
524
|
+
return
|
|
525
|
+
if pointee.is_const_qualified() or pointee.get_canonical().is_const_qualified():
|
|
526
|
+
return
|
|
527
|
+
raise NativeTypeError(
|
|
528
|
+
f"'{t.spelling}' is a mutable reference to a std::vector. pybind11 "
|
|
529
|
+
"converts the caller's list into a temporary vector, so anything the "
|
|
530
|
+
"function writes through this parameter is discarded when the call "
|
|
531
|
+
"returns — silently, with no error. Take 'const std::vector<T>&' if "
|
|
532
|
+
"the data is input, or RETURN the modified vector, or exclude the "
|
|
533
|
+
"symbol in nativegate.yaml."
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _is_std_vector(t) -> bool:
|
|
538
|
+
"""True for std::vector<T> in any spelling, through typedefs."""
|
|
539
|
+
canonical = t.get_canonical()
|
|
540
|
+
spelling = canonical.spelling.replace("const ", "").strip()
|
|
541
|
+
return spelling.startswith("std::vector<") or spelling.startswith(
|
|
542
|
+
"std::__1::vector<"
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _vector_element(t):
|
|
547
|
+
"""The T of a std::vector<T>, or None if it cannot be read off the AST."""
|
|
548
|
+
canonical = t.get_canonical()
|
|
549
|
+
try:
|
|
550
|
+
if canonical.get_num_template_arguments() > 0:
|
|
551
|
+
return canonical.get_template_argument_type(0)
|
|
552
|
+
except (AttributeError, TypeError): # older libclang on some platforms
|
|
553
|
+
pass
|
|
554
|
+
return None
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _map_type(t, known_records: frozenset[str], *, is_return: bool) -> tuple[str, bool]:
|
|
558
|
+
"""Map a clang Type to (python_type, is_array), or raise NativeTypeError.
|
|
559
|
+
|
|
560
|
+
The refusals mirror cpp_regex._normalize_type so that switching parsers
|
|
561
|
+
never changes which signatures are considered safe — only how accurately
|
|
562
|
+
they are recognised.
|
|
563
|
+
"""
|
|
564
|
+
# Checked BEFORE _strip_refs, because for std::vector the reference and its
|
|
565
|
+
# constness are the whole question — see _refuse_mutable_vector_ref.
|
|
566
|
+
_refuse_mutable_vector_ref(t, is_return=is_return)
|
|
567
|
+
|
|
568
|
+
t = _strip_refs(t)
|
|
569
|
+
spelling = t.spelling
|
|
570
|
+
|
|
571
|
+
if _is_std_vector(t):
|
|
572
|
+
element = _vector_element(t)
|
|
573
|
+
if element is None:
|
|
574
|
+
raise NativeTypeError(
|
|
575
|
+
f"'{spelling}' is a std::vector whose element type could not be "
|
|
576
|
+
"read from the AST. Expose a function taking a concrete vector "
|
|
577
|
+
"(std::vector<double>, std::vector<int>, ...) instead, or "
|
|
578
|
+
"exclude the symbol in nativegate.yaml."
|
|
579
|
+
)
|
|
580
|
+
inner, inner_is_array = _map_type(element, known_records, is_return=is_return)
|
|
581
|
+
if inner_is_array:
|
|
582
|
+
raise NativeTypeError(
|
|
583
|
+
f"'{spelling}' is a nested container. pybind11 will convert it, "
|
|
584
|
+
"but nativegate has no IR shape for a list of lists, so the "
|
|
585
|
+
"generated Pydantic model would be wrong. Flatten it (pass the "
|
|
586
|
+
"data and its shape separately), or exclude the symbol."
|
|
587
|
+
)
|
|
588
|
+
if inner not in ("float", "int", "bool", "str"):
|
|
589
|
+
raise NativeTypeError(
|
|
590
|
+
f"'{spelling}' has element type '{inner}', which is not a scalar "
|
|
591
|
+
"pybind11 converts element-wise. Use a vector of a numeric type "
|
|
592
|
+
"or std::string, or exclude the symbol in nativegate.yaml."
|
|
593
|
+
)
|
|
594
|
+
return inner, True
|
|
595
|
+
|
|
596
|
+
# `double values[4]` / `double values[]` as a *field*; parameters decay to
|
|
597
|
+
# pointers and are recovered from the source tokens by the caller.
|
|
598
|
+
array_kinds = _kinds("CONSTANTARRAY", "INCOMPLETEARRAY", "VARIABLEARRAY", "DEPENDENTSIZEDARRAY")
|
|
599
|
+
if t.kind in array_kinds:
|
|
600
|
+
element, _ = _map_type(t.element_type, known_records, is_return=is_return)
|
|
601
|
+
return element, True
|
|
602
|
+
|
|
603
|
+
if t.kind in _kinds("POINTER"):
|
|
604
|
+
pointee = _strip_refs(t.get_pointee())
|
|
605
|
+
canonical_pointee = pointee.get_canonical()
|
|
606
|
+
|
|
607
|
+
if any(canonical_pointee.kind in _kinds(n) for n in _CHAR_KIND_NAMES):
|
|
608
|
+
# `const char*` is a C string, the one pointer with an unambiguous
|
|
609
|
+
# Python meaning. A *non-const* `char*` is an output buffer with no
|
|
610
|
+
# length convention: bound as a Python str, pybind11 materialises a
|
|
611
|
+
# temporary that C++ then writes through, so the write is lost or
|
|
612
|
+
# lands in freed memory.
|
|
613
|
+
if pointee.is_const_qualified() or canonical_pointee.is_const_qualified():
|
|
614
|
+
return "str", False
|
|
615
|
+
raise NativeTypeError(
|
|
616
|
+
f"'{spelling}' is a non-const 'char*': an output buffer with no "
|
|
617
|
+
"length convention. Binding it as a Python str would have C++ "
|
|
618
|
+
"write through a temporary. Return std::string (or take "
|
|
619
|
+
"'const char*' for input), or exclude the symbol in nativegate.yaml."
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
name = _record_short_name(pointee)
|
|
623
|
+
if name and name in known_records:
|
|
624
|
+
if is_return:
|
|
625
|
+
raise NativeTypeError(
|
|
626
|
+
f"'{spelling}' returns a raw pointer to '{name}'; binding it would "
|
|
627
|
+
"hand ownership to Python while C++ still owns the object. Return by "
|
|
628
|
+
"value or by reference instead."
|
|
629
|
+
)
|
|
630
|
+
return name, False
|
|
631
|
+
|
|
632
|
+
if name and _is_incomplete_record(pointee):
|
|
633
|
+
raise NativeTypeError(
|
|
634
|
+
f"'{spelling}' refers to '{name}', which is only forward-declared "
|
|
635
|
+
"(an incomplete type). pybind11 needs the full definition to bind it. "
|
|
636
|
+
f"Add the header that defines '{name}' to this service's native/ "
|
|
637
|
+
"directory, or exclude the symbol in nativegate.yaml."
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
# A pointer to a scalar MIGHT still be bindable — as an array, if some
|
|
641
|
+
# other parameter of the same function carries its length. That cannot
|
|
642
|
+
# be decided here, one type at a time, so the decision is deferred to
|
|
643
|
+
# _parameters, which can see the whole signature. Raising a distinct
|
|
644
|
+
# exception (carrying what was learned about the pointee) is how the
|
|
645
|
+
# information gets there without _map_type growing a second job.
|
|
646
|
+
element = _builtin_map().get(canonical_pointee.kind)
|
|
647
|
+
if element in ("float", "int", "bool"):
|
|
648
|
+
raise PointerLengthUnknown(
|
|
649
|
+
f"'{spelling}' is a pointer; nativegate cannot infer its length. "
|
|
650
|
+
"Wrap it in a type that carries its own size (std::vector, a "
|
|
651
|
+
"span, or a struct), pass its length in an argument named after "
|
|
652
|
+
"it (`n`, `n_values`, `values_len`, ...), or exclude the symbol "
|
|
653
|
+
"in nativegate.yaml.",
|
|
654
|
+
element=element,
|
|
655
|
+
is_mutable=not (
|
|
656
|
+
pointee.is_const_qualified()
|
|
657
|
+
or canonical_pointee.is_const_qualified()
|
|
658
|
+
),
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
raise NativeTypeError(
|
|
662
|
+
f"'{spelling}' is a pointer; nativegate cannot infer its length. "
|
|
663
|
+
"Wrap it in a type that carries its own size (std::vector, a span, "
|
|
664
|
+
"or a struct) or exclude the symbol in nativegate.yaml."
|
|
665
|
+
)
|
|
666
|
+
|
|
667
|
+
if t.kind in _kinds("ENUM") or t.get_canonical().kind in _kinds("ENUM"):
|
|
668
|
+
# A scoped or unscoped enum crosses the boundary as its integer value.
|
|
669
|
+
return "int", False
|
|
670
|
+
|
|
671
|
+
if _is_std_string(t):
|
|
672
|
+
return "str", False
|
|
673
|
+
|
|
674
|
+
canonical = t.get_canonical()
|
|
675
|
+
builtin = _builtin_map().get(canonical.kind)
|
|
676
|
+
if builtin is not None:
|
|
677
|
+
return builtin, False
|
|
678
|
+
|
|
679
|
+
name = _record_short_name(t)
|
|
680
|
+
if name and name in known_records:
|
|
681
|
+
return name, False
|
|
682
|
+
|
|
683
|
+
if name and _is_incomplete_record(t):
|
|
684
|
+
raise NativeTypeError(
|
|
685
|
+
f"'{spelling}' refers to '{name}', which is only forward-declared "
|
|
686
|
+
"(an incomplete type). pybind11 needs the full definition to bind it. "
|
|
687
|
+
f"Add the header that defines '{name}' to this service's native/ "
|
|
688
|
+
"directory, or exclude the symbol in nativegate.yaml."
|
|
689
|
+
)
|
|
690
|
+
|
|
691
|
+
raise NativeTypeError(
|
|
692
|
+
f"Unsupported native type '{spelling}': add a mapping in ir.TYPE_MAP, "
|
|
693
|
+
"define the type in this service's headers, or exclude this symbol "
|
|
694
|
+
"from nativegate.yaml."
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def _declared_as_array(cursor) -> bool:
|
|
699
|
+
"""True if a parameter was *written* as an array (`double v[]`).
|
|
700
|
+
|
|
701
|
+
C decays an array parameter to a pointer before the AST sees it, so the
|
|
702
|
+
only surviving evidence is the source spelling. `is_array` on the IR is
|
|
703
|
+
what tells the generated service to accept a sequence there.
|
|
704
|
+
"""
|
|
705
|
+
try:
|
|
706
|
+
tokens = [tok.spelling for tok in cursor.get_tokens()]
|
|
707
|
+
except Exception:
|
|
708
|
+
return False
|
|
709
|
+
return "[" in tokens
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def _is_extern_c(cursor) -> bool:
|
|
713
|
+
"""True when this declaration sits inside an `extern "C"` block."""
|
|
714
|
+
parent = cursor.lexical_parent
|
|
715
|
+
while parent is not None and parent.kind != _cindex.CursorKind.TRANSLATION_UNIT:
|
|
716
|
+
if parent.kind == _cindex.CursorKind.LINKAGE_SPEC:
|
|
717
|
+
return True
|
|
718
|
+
parent = parent.lexical_parent
|
|
719
|
+
return False
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def _parameters(
|
|
723
|
+
cursor,
|
|
724
|
+
known_records: frozenset[str],
|
|
725
|
+
*,
|
|
726
|
+
scalar_ref_ok: bool = False,
|
|
727
|
+
extern_c: bool = False,
|
|
728
|
+
) -> list[Parameter]:
|
|
729
|
+
"""Map every argument, resolving bare pointers against the whole signature.
|
|
730
|
+
|
|
731
|
+
A `double*` carries no length, so it cannot be bound from its type alone.
|
|
732
|
+
But `void scale(double* data, int n, double k)` does say how long `data`
|
|
733
|
+
is — in another argument. That is only visible with the full parameter
|
|
734
|
+
list in hand, so `_map_type` defers the decision by raising
|
|
735
|
+
PointerLengthUnknown and it is settled here.
|
|
736
|
+
|
|
737
|
+
The pairing uses `ir.size_pairings`, the same inference the Fortran router
|
|
738
|
+
uses to validate `n == len(props)`. Deliberately shared: two heuristics
|
|
739
|
+
that drifted would mean the same signature read differently depending on
|
|
740
|
+
which language it came from. It is conservative by design — an ambiguous
|
|
741
|
+
case yields no pairing and the pointer stays refused, because a false
|
|
742
|
+
pairing silently binds the wrong argument as a length.
|
|
743
|
+
"""
|
|
744
|
+
params: list[Parameter] = []
|
|
745
|
+
deferred: dict[str, PointerLengthUnknown] = {}
|
|
746
|
+
|
|
747
|
+
for index, arg in enumerate(cursor.get_arguments()):
|
|
748
|
+
name = arg.spelling or f"arg{index}"
|
|
749
|
+
try:
|
|
750
|
+
py_type, is_array = _map_type(arg.type, known_records, is_return=False)
|
|
751
|
+
except PointerLengthUnknown as exc:
|
|
752
|
+
# Provisional: typed from the pointee so the pairing below can see
|
|
753
|
+
# it as an array candidate. Replaced or re-raised before returning.
|
|
754
|
+
deferred[name] = exc
|
|
755
|
+
params.append(
|
|
756
|
+
Parameter(
|
|
757
|
+
name=name,
|
|
758
|
+
type=exc.element,
|
|
759
|
+
is_array=True,
|
|
760
|
+
native_type=_native_spelling(arg.type),
|
|
761
|
+
is_const=_is_const_type(arg.type),
|
|
762
|
+
)
|
|
763
|
+
)
|
|
764
|
+
continue
|
|
765
|
+
params.append(
|
|
766
|
+
Parameter(
|
|
767
|
+
name=name,
|
|
768
|
+
type=py_type,
|
|
769
|
+
is_array=is_array or _declared_as_array(arg),
|
|
770
|
+
native_type=_native_spelling(arg.type),
|
|
771
|
+
is_const=_is_const_type(arg.type),
|
|
772
|
+
)
|
|
773
|
+
)
|
|
774
|
+
|
|
775
|
+
if not deferred:
|
|
776
|
+
return params
|
|
777
|
+
|
|
778
|
+
by_name = {p.name: p for p in params}
|
|
779
|
+
paired = {array: length for length, array in size_pairings(params)}
|
|
780
|
+
|
|
781
|
+
# THE SCALAR-BY-REFERENCE FALLBACK, and why it is gated twice.
|
|
782
|
+
#
|
|
783
|
+
# In an `extern "C"` bridge to Fortran, EVERY argument is a pointer,
|
|
784
|
+
# because Fortran has no pass-by-value — `void pvtini(double* api, ...)`
|
|
785
|
+
# takes four scalars. Refusing those as "pointer with no length" refused
|
|
786
|
+
# the single most common C-linkage signature there is.
|
|
787
|
+
#
|
|
788
|
+
# But `T*` is also how C passes an ARRAY whose length travels some other
|
|
789
|
+
# way, and binding an array as one scalar hands the callee a pointer to a
|
|
790
|
+
# single stack double it will read past — the silent-wrong-answer class.
|
|
791
|
+
# Hence two gates:
|
|
792
|
+
# 1. extern "C" only. In C++ linkage a scalar in/out is spelled `T&`,
|
|
793
|
+
# which already binds; a bare `T*` there usually IS an array.
|
|
794
|
+
# 2. no size-word integer anywhere in the signature. `krload(double* sw,
|
|
795
|
+
# double* krw, double* kro, double* pcw, int* n)` has four arrays
|
|
796
|
+
# sharing one `n` — the pairing above is ambiguous, and the presence
|
|
797
|
+
# of `n` says "these are arrays", so everything stays refused rather
|
|
798
|
+
# than becoming five scalars.
|
|
799
|
+
#
|
|
800
|
+
# Non-const scalars are marked intent="inout": the binding returns their
|
|
801
|
+
# final value, so an output like PVTERR's `int* icode` cannot be silently
|
|
802
|
+
# dropped into a discarded temporary.
|
|
803
|
+
from ..ir import _SIZE_WORDS, names_a_size_of
|
|
804
|
+
|
|
805
|
+
def _signature_has_size_word() -> bool:
|
|
806
|
+
for candidate in params:
|
|
807
|
+
if candidate.type != "int":
|
|
808
|
+
continue
|
|
809
|
+
lowered = candidate.name.lower()
|
|
810
|
+
if lowered in _SIZE_WORDS:
|
|
811
|
+
return True
|
|
812
|
+
if any(names_a_size_of(candidate.name, other.name) for other in params):
|
|
813
|
+
return True
|
|
814
|
+
return False
|
|
815
|
+
|
|
816
|
+
scalar_ref = scalar_ref_ok and not _signature_has_size_word()
|
|
817
|
+
|
|
818
|
+
for name, exc in deferred.items():
|
|
819
|
+
length = paired.get(name)
|
|
820
|
+
if length is None:
|
|
821
|
+
if scalar_ref:
|
|
822
|
+
target = by_name[name]
|
|
823
|
+
target.is_array = False
|
|
824
|
+
target.length_param = None
|
|
825
|
+
target.is_scalar_ref = True
|
|
826
|
+
target.intent = "inout" if exc.is_mutable else "in"
|
|
827
|
+
continue
|
|
828
|
+
# Nothing in the signature names this pointer's length. Refuse it
|
|
829
|
+
# exactly as before — guessing here is how a service binds the
|
|
830
|
+
# wrong argument as an extent and reads past the end of a buffer.
|
|
831
|
+
if extern_c:
|
|
832
|
+
raise NativeTypeError(
|
|
833
|
+
str(exc)
|
|
834
|
+
+ f" If every `T*` argument of '{cursor.spelling}' is a "
|
|
835
|
+
"SCALAR passed by reference (the Fortran-linkage "
|
|
836
|
+
"convention), list it under `clang.scalar_ref_functions` "
|
|
837
|
+
"in nativegate.yaml — but check the Fortran first: an "
|
|
838
|
+
"array whose extent lives in a PARAMETER or COMMON block "
|
|
839
|
+
"looks identical in the C prototype, and binding it as a "
|
|
840
|
+
"scalar corrupts memory."
|
|
841
|
+
) from exc
|
|
842
|
+
raise exc
|
|
843
|
+
by_name[name].length_param = length
|
|
844
|
+
by_name[name].is_mutable_buffer = exc.is_mutable
|
|
845
|
+
|
|
846
|
+
return params
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def _is_const_type(t) -> bool:
|
|
850
|
+
"""True for a `const`-qualified declaration (`const double x`).
|
|
851
|
+
|
|
852
|
+
A const data member bound with def_readwrite is a compile error inside
|
|
853
|
+
pybind11's templates; the generator emits def_readonly instead.
|
|
854
|
+
"""
|
|
855
|
+
try:
|
|
856
|
+
if t.is_const_qualified():
|
|
857
|
+
return True
|
|
858
|
+
stripped = _strip_refs(t)
|
|
859
|
+
return bool(stripped is not t and stripped.is_const_qualified())
|
|
860
|
+
except Exception:
|
|
861
|
+
return False
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
def _mark_overloads(entries) -> None:
|
|
865
|
+
"""Flag every entry whose name is declared more than once.
|
|
866
|
+
|
|
867
|
+
`&Cls::viscosity` is ambiguous the moment a second `viscosity` exists, so
|
|
868
|
+
the binding has to go through py::overload_cast<...>; on the HTTP side two
|
|
869
|
+
same-named routes would silently shadow each other.
|
|
870
|
+
"""
|
|
871
|
+
counts: dict[str, int] = {}
|
|
872
|
+
for entry in entries:
|
|
873
|
+
counts[entry.name] = counts.get(entry.name, 0) + 1
|
|
874
|
+
for entry in entries:
|
|
875
|
+
if counts[entry.name] > 1:
|
|
876
|
+
entry.is_overloaded = True
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
# --- declaration walking ------------------------------------------------
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def _in_file(cursor, path: Path) -> bool:
|
|
883
|
+
"""True if the cursor was written in this header rather than an include.
|
|
884
|
+
|
|
885
|
+
Everything reachable through `#include` belongs to the header that
|
|
886
|
+
declared it; binding it here would emit it twice when both headers are in
|
|
887
|
+
the same service.
|
|
888
|
+
"""
|
|
889
|
+
location = cursor.location
|
|
890
|
+
if location is None or location.file is None:
|
|
891
|
+
return False
|
|
892
|
+
try:
|
|
893
|
+
return Path(location.file.name).resolve() == path
|
|
894
|
+
except OSError:
|
|
895
|
+
return str(location.file.name) == str(path)
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
_RECORD_KINDS = lambda: ( # noqa: E731 - needs _cindex loaded first
|
|
899
|
+
_cindex.CursorKind.CLASS_DECL,
|
|
900
|
+
_cindex.CursorKind.STRUCT_DECL,
|
|
901
|
+
)
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
def _namespace_of(cursor) -> str | None:
|
|
905
|
+
"""`a::b` for a declaration nested in namespaces, else None.
|
|
906
|
+
|
|
907
|
+
Anonymous namespaces and `extern "C"` blocks are not name scopes we can
|
|
908
|
+
spell in generated code, so they are skipped in the chain.
|
|
909
|
+
"""
|
|
910
|
+
parts = []
|
|
911
|
+
parent = cursor.semantic_parent
|
|
912
|
+
while parent is not None and parent.kind != _cindex.CursorKind.TRANSLATION_UNIT:
|
|
913
|
+
if parent.kind == _cindex.CursorKind.NAMESPACE and parent.spelling:
|
|
914
|
+
parts.append(parent.spelling)
|
|
915
|
+
parent = parent.semantic_parent
|
|
916
|
+
return "::".join(reversed(parts)) or None
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
_COMMENT_OPENERS = ("/**", "/*!", "/*")
|
|
920
|
+
_COMMENT_LINE_MARKERS = ("///", "//!", "//<", "//")
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
def _doc_of(cursor) -> str | None:
|
|
924
|
+
"""The declaration's own documentation, cleaned into one line of prose.
|
|
925
|
+
|
|
926
|
+
Why `raw_comment` and not `brief_comment`: `brief_comment` is Clang's
|
|
927
|
+
*parsed* comment model, and it returns only the \\brief paragraph. For the
|
|
928
|
+
kind of header this tool is pointed at — legacy engineering headers whose
|
|
929
|
+
Doxygen blocks are one unlabelled paragraph followed by @param lines, or
|
|
930
|
+
just a bare `///` line — that paragraph is very often absent, so
|
|
931
|
+
`brief_comment` silently yields nothing where there is real documentation
|
|
932
|
+
sitting right above the declaration. It also depends on libclang having
|
|
933
|
+
built the comment AST, which is not uniform across builds. `raw_comment` is
|
|
934
|
+
the verbatim source text of the attached comment and is stable on every
|
|
935
|
+
libclang we support; the price is that the comment syntax has to be
|
|
936
|
+
stripped here, which is the rest of this function.
|
|
937
|
+
|
|
938
|
+
Doxygen *structure* is deliberately not parsed: @param/@return stay in the
|
|
939
|
+
prose. The consumer is an MCP tool description handed to a model, and a
|
|
940
|
+
model reads "@param api oil gravity" perfectly well; inventing IR fields to
|
|
941
|
+
hold it would be a schema change no generator asked for (ROADMAP 3.4).
|
|
942
|
+
|
|
943
|
+
The result is a single line by construction — see `_sanitise_doc` for why
|
|
944
|
+
that is a safety property and not a formatting preference.
|
|
945
|
+
"""
|
|
946
|
+
raw = getattr(cursor, "raw_comment", None)
|
|
947
|
+
if not raw:
|
|
948
|
+
return None
|
|
949
|
+
|
|
950
|
+
body = raw.strip()
|
|
951
|
+
for opener in _COMMENT_OPENERS:
|
|
952
|
+
if body.startswith(opener):
|
|
953
|
+
body = body[len(opener):]
|
|
954
|
+
break
|
|
955
|
+
if body.endswith("*/"):
|
|
956
|
+
body = body[: -len("*/")]
|
|
957
|
+
|
|
958
|
+
lines = []
|
|
959
|
+
for line in body.splitlines():
|
|
960
|
+
line = line.strip()
|
|
961
|
+
# A block comment's continuation stars, and the marker on every line of
|
|
962
|
+
# a `///` run — libclang hands the whole run back as one raw comment,
|
|
963
|
+
# so the markers after the first are still in the text.
|
|
964
|
+
if line.startswith("*") and not line.startswith("*/"):
|
|
965
|
+
line = line[1:].strip()
|
|
966
|
+
else:
|
|
967
|
+
for marker in _COMMENT_LINE_MARKERS:
|
|
968
|
+
if line.startswith(marker):
|
|
969
|
+
line = line[len(marker):].strip()
|
|
970
|
+
break
|
|
971
|
+
lines.append(line)
|
|
972
|
+
|
|
973
|
+
return _sanitise_doc(" ".join(lines))
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
def _sanitise_doc(text: str) -> str | None:
|
|
977
|
+
"""Collapse to one line of prose, and make it safe to *paste* into source.
|
|
978
|
+
|
|
979
|
+
This text does not stay data. `python_pkg_gen._docstring` interpolates it
|
|
980
|
+
straight into generated Python between triple double-quotes, so three
|
|
981
|
+
characters of a comment nobody wrote for that purpose can close the
|
|
982
|
+
docstring and turn the generated module into a syntax error — or, with a
|
|
983
|
+
little effort, into code.
|
|
984
|
+
|
|
985
|
+
Exactly ONE transformation happens here: all whitespace — newlines and
|
|
986
|
+
control characters included — collapses to single spaces. A legacy header
|
|
987
|
+
wraps its prose to 72 columns and pads with continuation stars, none of
|
|
988
|
+
which is content, so this loses nothing and gives the field a contract that
|
|
989
|
+
is narrow and checkable: `doc` is one line.
|
|
990
|
+
|
|
991
|
+
Everything else is left ALONE, and that is a deliberate reversal of the
|
|
992
|
+
obvious design. It is tempting to also neutralise a `\"\"\"` here (it would
|
|
993
|
+
close the docstring this text is interpolated into) or to drop a trailing
|
|
994
|
+
backslash (it would escape the closing quote). Both work, and both are
|
|
995
|
+
LOSSY: they silently rewrite a routine's documentation to suit one
|
|
996
|
+
consumer's quoting rules. The IR promises the native source's own words,
|
|
997
|
+
and a parser that quietly edits them breaks that promise for every other
|
|
998
|
+
consumer too — JSON, an OpenAPI description, a future doc site.
|
|
999
|
+
|
|
1000
|
+
So escaping lives entirely in the layer that owns the quoting context:
|
|
1001
|
+
`python_pkg_gen._docstring_literal` emits `repr(doc)` for anything carrying
|
|
1002
|
+
a quote, a backslash or a control character, which Python itself guarantees
|
|
1003
|
+
is correctly encoded, and a readable triple-quoted block otherwise. That is
|
|
1004
|
+
lossless, it is tested against hostile text in
|
|
1005
|
+
tests/test_endpoint_docstrings.py, and it means this function does not need
|
|
1006
|
+
to guess what its output will be pasted into.
|
|
1007
|
+
"""
|
|
1008
|
+
return " ".join(text.split()) or None
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
def _is_public(cursor) -> bool:
|
|
1012
|
+
return cursor.access_specifier == _cindex.AccessSpecifier.PUBLIC
|
|
1013
|
+
|
|
1014
|
+
|
|
1015
|
+
def _is_deleted(cursor) -> bool:
|
|
1016
|
+
# is_deleted_method() is missing on some libclang builds; fall back to the
|
|
1017
|
+
# source tokens, where `= delete` is unmistakable.
|
|
1018
|
+
getter = getattr(cursor, "is_deleted_method", None)
|
|
1019
|
+
if callable(getter):
|
|
1020
|
+
try:
|
|
1021
|
+
return bool(getter())
|
|
1022
|
+
except Exception:
|
|
1023
|
+
pass
|
|
1024
|
+
try:
|
|
1025
|
+
tokens = [t.spelling for t in cursor.get_tokens()]
|
|
1026
|
+
except Exception:
|
|
1027
|
+
return False
|
|
1028
|
+
return "delete" in tokens and "=" in tokens
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
def _bool_method(cursor, name: str, default: bool = False) -> bool:
|
|
1032
|
+
getter = getattr(cursor, name, None)
|
|
1033
|
+
if not callable(getter):
|
|
1034
|
+
return default
|
|
1035
|
+
try:
|
|
1036
|
+
return bool(getter())
|
|
1037
|
+
except Exception:
|
|
1038
|
+
return default
|
|
1039
|
+
|
|
1040
|
+
|
|
1041
|
+
def _error_at(cursor, errors: dict[int, str]) -> str | None:
|
|
1042
|
+
"""The compiler error covering this declaration, if any.
|
|
1043
|
+
|
|
1044
|
+
Clang recovers from an unknown type by pretending it was `int` — so a
|
|
1045
|
+
method returning an unincluded `std::vector<double>` parses cleanly as
|
|
1046
|
+
`int b()` and would be bound as returning an integer. Any declaration the
|
|
1047
|
+
compiler complained about is therefore refused rather than trusted; the
|
|
1048
|
+
diagnostic itself is the reason reported to the user.
|
|
1049
|
+
"""
|
|
1050
|
+
if not errors:
|
|
1051
|
+
return None
|
|
1052
|
+
extent = cursor.extent
|
|
1053
|
+
if extent is None or extent.start.file is None:
|
|
1054
|
+
return None
|
|
1055
|
+
for line in range(extent.start.line, extent.end.line + 1):
|
|
1056
|
+
if line in errors:
|
|
1057
|
+
return errors[line]
|
|
1058
|
+
return None
|
|
1059
|
+
|
|
1060
|
+
|
|
1061
|
+
def _parse_record(
|
|
1062
|
+
cursor,
|
|
1063
|
+
module: ModuleIR,
|
|
1064
|
+
known_records: frozenset[str],
|
|
1065
|
+
errors: dict[int, str] | None = None,
|
|
1066
|
+
) -> ClassDef | StructDef:
|
|
1067
|
+
errors = errors or {}
|
|
1068
|
+
name = cursor.spelling
|
|
1069
|
+
namespace = _namespace_of(cursor)
|
|
1070
|
+
is_struct = cursor.kind == _cindex.CursorKind.STRUCT_DECL
|
|
1071
|
+
|
|
1072
|
+
methods: list[Method] = []
|
|
1073
|
+
fields: list[Parameter] = []
|
|
1074
|
+
constructors: list[list[Parameter]] = []
|
|
1075
|
+
bases: list[str] = []
|
|
1076
|
+
saw_constructor = False
|
|
1077
|
+
saw_public_default_ctor = False
|
|
1078
|
+
blocks_default_ctor = False
|
|
1079
|
+
|
|
1080
|
+
for child in cursor.get_children():
|
|
1081
|
+
kind = child.kind
|
|
1082
|
+
|
|
1083
|
+
if kind == _cindex.CursorKind.CXX_BASE_SPECIFIER:
|
|
1084
|
+
base_name = _record_short_name(child.type)
|
|
1085
|
+
if not base_name:
|
|
1086
|
+
continue
|
|
1087
|
+
if not _is_public(child):
|
|
1088
|
+
# Private/protected inheritance is an implementation detail:
|
|
1089
|
+
# the conversion Derived* -> Base* is inaccessible, so telling
|
|
1090
|
+
# pybind11 about it would not compile.
|
|
1091
|
+
continue
|
|
1092
|
+
if base_name not in known_records:
|
|
1093
|
+
module.skipped.append(
|
|
1094
|
+
SkippedSymbol(
|
|
1095
|
+
f"{name} : {base_name}",
|
|
1096
|
+
f"inherits from '{base_name}', which this service does not "
|
|
1097
|
+
"bind. Python callers will not see anything inherited from "
|
|
1098
|
+
f"it — expose '{base_name}' too, or ignore this if its "
|
|
1099
|
+
"members are not needed.",
|
|
1100
|
+
)
|
|
1101
|
+
)
|
|
1102
|
+
continue
|
|
1103
|
+
bases.append(base_name)
|
|
1104
|
+
continue
|
|
1105
|
+
|
|
1106
|
+
broken = _error_at(child, errors)
|
|
1107
|
+
|
|
1108
|
+
if kind == _cindex.CursorKind.CONSTRUCTOR:
|
|
1109
|
+
saw_constructor = True
|
|
1110
|
+
if not _is_public(child) or _is_deleted(child):
|
|
1111
|
+
continue
|
|
1112
|
+
if broken:
|
|
1113
|
+
module.skipped.append(
|
|
1114
|
+
SkippedSymbol(f"{name}::{name}", _unparsed_reason(broken))
|
|
1115
|
+
)
|
|
1116
|
+
continue
|
|
1117
|
+
# A copy/move constructor is not a Python-facing way to build the
|
|
1118
|
+
# object; pybind11 provides copying separately.
|
|
1119
|
+
if _bool_method(child, "is_copy_constructor") or _bool_method(
|
|
1120
|
+
child, "is_move_constructor"
|
|
1121
|
+
):
|
|
1122
|
+
continue
|
|
1123
|
+
if _bool_method(child, "is_default_constructor") or not list(
|
|
1124
|
+
child.get_arguments()
|
|
1125
|
+
):
|
|
1126
|
+
saw_public_default_ctor = True
|
|
1127
|
+
continue
|
|
1128
|
+
try:
|
|
1129
|
+
constructors.append(_parameters(child, known_records))
|
|
1130
|
+
except NativeTypeError as exc:
|
|
1131
|
+
module.skipped.append(SkippedSymbol(f"{name}::{name}", str(exc)))
|
|
1132
|
+
continue
|
|
1133
|
+
|
|
1134
|
+
if kind == _cindex.CursorKind.CXX_METHOD:
|
|
1135
|
+
if not _is_public(child) or _is_deleted(child):
|
|
1136
|
+
continue
|
|
1137
|
+
# Operators used to be dropped here, silently and without a
|
|
1138
|
+
# `skipped` entry — the one thing this parser is not allowed to do.
|
|
1139
|
+
# pybind11 binds them perfectly well as Python special methods, so
|
|
1140
|
+
# the ones with an honest mapping are bound and the rest are
|
|
1141
|
+
# reported with the reason they cannot be.
|
|
1142
|
+
operator_dunder = None
|
|
1143
|
+
if child.spelling.startswith("operator"):
|
|
1144
|
+
arg_count = len(list(child.get_arguments()))
|
|
1145
|
+
operator_dunder = _operator_dunder(child.spelling, arg_count)
|
|
1146
|
+
if operator_dunder is None:
|
|
1147
|
+
module.skipped.append(
|
|
1148
|
+
SkippedSymbol(
|
|
1149
|
+
f"{name}::{child.spelling}",
|
|
1150
|
+
_operator_refusal(child.spelling, arg_count),
|
|
1151
|
+
)
|
|
1152
|
+
)
|
|
1153
|
+
continue
|
|
1154
|
+
if broken:
|
|
1155
|
+
module.skipped.append(
|
|
1156
|
+
SkippedSymbol(
|
|
1157
|
+
f"{name}::{child.spelling}", _unparsed_reason(broken)
|
|
1158
|
+
)
|
|
1159
|
+
)
|
|
1160
|
+
continue
|
|
1161
|
+
try:
|
|
1162
|
+
returns, returns_array = _map_type(
|
|
1163
|
+
child.result_type, known_records, is_return=True
|
|
1164
|
+
)
|
|
1165
|
+
parameters = _parameters(child, known_records)
|
|
1166
|
+
except NativeTypeError as exc:
|
|
1167
|
+
module.skipped.append(
|
|
1168
|
+
SkippedSymbol(f"{name}::{child.spelling}", str(exc))
|
|
1169
|
+
)
|
|
1170
|
+
continue
|
|
1171
|
+
methods.append(
|
|
1172
|
+
Method(
|
|
1173
|
+
name=operator_dunder or child.spelling,
|
|
1174
|
+
parameters=parameters,
|
|
1175
|
+
returns=returns,
|
|
1176
|
+
is_static=child.is_static_method(),
|
|
1177
|
+
is_const=_bool_method(child, "is_const_method"),
|
|
1178
|
+
returns_array=returns_array,
|
|
1179
|
+
# Only set for operators: the published name is the dunder,
|
|
1180
|
+
# but the address to take is still `Cls::operator+`.
|
|
1181
|
+
cpp_name=child.spelling if operator_dunder else None,
|
|
1182
|
+
)
|
|
1183
|
+
)
|
|
1184
|
+
continue
|
|
1185
|
+
|
|
1186
|
+
if kind == _cindex.CursorKind.FIELD_DECL:
|
|
1187
|
+
field_is_const = _is_const_type(child.type)
|
|
1188
|
+
if field_is_const or child.type.kind in _kinds(
|
|
1189
|
+
"LVALUEREFERENCE", "RVALUEREFERENCE"
|
|
1190
|
+
):
|
|
1191
|
+
# A const or reference member — public or not — suppresses the
|
|
1192
|
+
# implicit default constructor, so py::init<>() for this record
|
|
1193
|
+
# would not compile.
|
|
1194
|
+
blocks_default_ctor = True
|
|
1195
|
+
if not _is_public(child):
|
|
1196
|
+
continue
|
|
1197
|
+
if broken:
|
|
1198
|
+
module.skipped.append(
|
|
1199
|
+
SkippedSymbol(f"{name}.{child.spelling}", _unparsed_reason(broken))
|
|
1200
|
+
)
|
|
1201
|
+
continue
|
|
1202
|
+
try:
|
|
1203
|
+
py_type, is_array = _map_type(
|
|
1204
|
+
child.type, known_records, is_return=False
|
|
1205
|
+
)
|
|
1206
|
+
except NativeTypeError as exc:
|
|
1207
|
+
module.skipped.append(
|
|
1208
|
+
SkippedSymbol(f"{name}.{child.spelling}", str(exc))
|
|
1209
|
+
)
|
|
1210
|
+
continue
|
|
1211
|
+
fields.append(
|
|
1212
|
+
Parameter(
|
|
1213
|
+
name=child.spelling,
|
|
1214
|
+
type=py_type,
|
|
1215
|
+
is_array=is_array,
|
|
1216
|
+
native_type=_native_spelling(child.type),
|
|
1217
|
+
is_const=field_is_const,
|
|
1218
|
+
)
|
|
1219
|
+
)
|
|
1220
|
+
continue
|
|
1221
|
+
|
|
1222
|
+
if kind in (
|
|
1223
|
+
_cindex.CursorKind.FUNCTION_TEMPLATE,
|
|
1224
|
+
_cindex.CursorKind.CLASS_TEMPLATE,
|
|
1225
|
+
):
|
|
1226
|
+
module.skipped.append(
|
|
1227
|
+
SkippedSymbol(
|
|
1228
|
+
f"{name}::{child.spelling}",
|
|
1229
|
+
"is a template; pybind11 binds instantiations, not templates. "
|
|
1230
|
+
"Add a non-template wrapper for the instantiation you need, "
|
|
1231
|
+
"or exclude the symbol in nativegate.yaml.",
|
|
1232
|
+
)
|
|
1233
|
+
)
|
|
1234
|
+
continue
|
|
1235
|
+
|
|
1236
|
+
# An abstract class cannot be instantiated from Python at all — emitting
|
|
1237
|
+
# py::init<> for one is a compile error inside pybind11's templates.
|
|
1238
|
+
abstract = _bool_method(cursor, "is_abstract_record")
|
|
1239
|
+
if abstract:
|
|
1240
|
+
constructors = []
|
|
1241
|
+
|
|
1242
|
+
_mark_overloads(methods)
|
|
1243
|
+
|
|
1244
|
+
# libclang exposes no reliable "has a default constructor" query on the
|
|
1245
|
+
# record cursor across builds, so it is derived: a user-declared non-default
|
|
1246
|
+
# constructor, or a const/reference member, removes the implicit one.
|
|
1247
|
+
has_default = (
|
|
1248
|
+
not abstract
|
|
1249
|
+
and (saw_public_default_ctor or not saw_constructor)
|
|
1250
|
+
and (saw_public_default_ctor or not blocks_default_ctor)
|
|
1251
|
+
)
|
|
1252
|
+
|
|
1253
|
+
if is_struct and not methods and not bases and fields:
|
|
1254
|
+
return StructDef(
|
|
1255
|
+
name=name,
|
|
1256
|
+
namespace=namespace,
|
|
1257
|
+
fields=fields,
|
|
1258
|
+
has_default_constructor=has_default,
|
|
1259
|
+
)
|
|
1260
|
+
|
|
1261
|
+
return ClassDef(
|
|
1262
|
+
name=name,
|
|
1263
|
+
namespace=namespace,
|
|
1264
|
+
methods=methods,
|
|
1265
|
+
has_default_constructor=has_default,
|
|
1266
|
+
constructors=constructors,
|
|
1267
|
+
fields=fields,
|
|
1268
|
+
bases=bases,
|
|
1269
|
+
)
|
|
1270
|
+
|
|
1271
|
+
|
|
1272
|
+
def _unparsed_reason(diagnostic: str) -> str:
|
|
1273
|
+
return (
|
|
1274
|
+
f"the compiler rejected this declaration ({diagnostic}); it cannot be bound "
|
|
1275
|
+
"until it compiles. Usual causes: a header the parser could not find (add "
|
|
1276
|
+
"its directory to `clang.include_paths` in nativegate.yaml), or a name that "
|
|
1277
|
+
"comes from a macro whose definition is not reachable from this header "
|
|
1278
|
+
"(add the header that defines the macro, or -D it via `clang.defines`)."
|
|
1279
|
+
)
|
|
1280
|
+
|
|
1281
|
+
|
|
1282
|
+
def _parse_function(
|
|
1283
|
+
cursor,
|
|
1284
|
+
module: ModuleIR,
|
|
1285
|
+
known_records: frozenset[str],
|
|
1286
|
+
errors: dict[int, str] | None = None,
|
|
1287
|
+
scalar_ref_functions: frozenset[str] = frozenset(),
|
|
1288
|
+
) -> None:
|
|
1289
|
+
broken = _error_at(cursor, errors or {})
|
|
1290
|
+
if broken:
|
|
1291
|
+
module.skipped.append(
|
|
1292
|
+
SkippedSymbol(cursor.spelling, _unparsed_reason(broken))
|
|
1293
|
+
)
|
|
1294
|
+
return
|
|
1295
|
+
try:
|
|
1296
|
+
returns, returns_array = _map_type(
|
|
1297
|
+
cursor.result_type, known_records, is_return=True
|
|
1298
|
+
)
|
|
1299
|
+
parameters = _parameters(
|
|
1300
|
+
cursor,
|
|
1301
|
+
known_records,
|
|
1302
|
+
# Both conditions, and the second is the load-bearing one: the
|
|
1303
|
+
# extern "C" check narrows WHERE the convention can apply, and the
|
|
1304
|
+
# per-function opt-in is the user asserting it DOES apply — see
|
|
1305
|
+
# config.ClangConfig.scalar_ref_functions and the FLASH2 case.
|
|
1306
|
+
scalar_ref_ok=_is_extern_c(cursor)
|
|
1307
|
+
and (
|
|
1308
|
+
cursor.spelling in scalar_ref_functions
|
|
1309
|
+
or "*" in scalar_ref_functions
|
|
1310
|
+
),
|
|
1311
|
+
extern_c=_is_extern_c(cursor),
|
|
1312
|
+
)
|
|
1313
|
+
except NativeTypeError as exc:
|
|
1314
|
+
module.skipped.append(SkippedSymbol(cursor.spelling, str(exc)))
|
|
1315
|
+
return
|
|
1316
|
+
module.functions.append(
|
|
1317
|
+
FunctionDef(
|
|
1318
|
+
name=cursor.spelling,
|
|
1319
|
+
parameters=parameters,
|
|
1320
|
+
returns=returns,
|
|
1321
|
+
# Without this a function in `namespace petro` is emitted as
|
|
1322
|
+
# `&bubble_point` — "use of undeclared identifier".
|
|
1323
|
+
namespace=_namespace_of(cursor),
|
|
1324
|
+
returns_array=returns_array,
|
|
1325
|
+
# The routine's own documentation, when it has any, becomes the
|
|
1326
|
+
# generated endpoint's docstring and from there the MCP tool
|
|
1327
|
+
# description a model reads before deciding how to call it
|
|
1328
|
+
# (ROADMAP 3.4). None when the declaration carries no comment: a
|
|
1329
|
+
# skeleton sentence is the generator's business, and synthesising
|
|
1330
|
+
# prose here would put text in the IR that appears nowhere in the
|
|
1331
|
+
# native source.
|
|
1332
|
+
doc=_doc_of(cursor),
|
|
1333
|
+
)
|
|
1334
|
+
)
|
|
1335
|
+
|
|
1336
|
+
|
|
1337
|
+
def _walk(cursor, path: Path, handler, *, depth: int = 0) -> None:
|
|
1338
|
+
"""Visit declarations in this file, descending through namespaces and
|
|
1339
|
+
`extern "C"` blocks — those group declarations without changing what is
|
|
1340
|
+
declared."""
|
|
1341
|
+
for child in cursor.get_children():
|
|
1342
|
+
if child.kind in (
|
|
1343
|
+
_cindex.CursorKind.NAMESPACE,
|
|
1344
|
+
_cindex.CursorKind.LINKAGE_SPEC,
|
|
1345
|
+
_cindex.CursorKind.UNEXPOSED_DECL,
|
|
1346
|
+
):
|
|
1347
|
+
_walk(child, path, handler, depth=depth + 1)
|
|
1348
|
+
continue
|
|
1349
|
+
if not _in_file(child, path):
|
|
1350
|
+
continue
|
|
1351
|
+
handler(child)
|
|
1352
|
+
|
|
1353
|
+
|
|
1354
|
+
# --- translation units --------------------------------------------------
|
|
1355
|
+
|
|
1356
|
+
|
|
1357
|
+
_TU_CACHE: dict[tuple, object] = {}
|
|
1358
|
+
|
|
1359
|
+
|
|
1360
|
+
def _translation_unit(path: Path, options: ClangOptions):
|
|
1361
|
+
if not _load():
|
|
1362
|
+
raise NativeTypeError(
|
|
1363
|
+
f"the Clang parser is unavailable: {unavailable_reason()}"
|
|
1364
|
+
)
|
|
1365
|
+
|
|
1366
|
+
resolved = path.resolve()
|
|
1367
|
+
try:
|
|
1368
|
+
stamp = resolved.stat().st_mtime_ns
|
|
1369
|
+
except OSError:
|
|
1370
|
+
stamp = 0
|
|
1371
|
+
key = (str(resolved), stamp, options)
|
|
1372
|
+
cached = _TU_CACHE.get(key)
|
|
1373
|
+
if cached is not None:
|
|
1374
|
+
return cached
|
|
1375
|
+
|
|
1376
|
+
index = _cindex.Index.create()
|
|
1377
|
+
flags = (
|
|
1378
|
+
_cindex.TranslationUnit.PARSE_SKIP_FUNCTION_BODIES
|
|
1379
|
+
| _cindex.TranslationUnit.PARSE_INCOMPLETE
|
|
1380
|
+
)
|
|
1381
|
+
tu = index.parse(str(resolved), args=options.command_line(resolved), options=flags)
|
|
1382
|
+
_TU_CACHE[key] = tu
|
|
1383
|
+
return tu
|
|
1384
|
+
|
|
1385
|
+
|
|
1386
|
+
def _diagnostics(tu) -> list[str]:
|
|
1387
|
+
"""Errors worth telling the user about.
|
|
1388
|
+
|
|
1389
|
+
A header that doesn't compile still yields a partial AST, and nativegate
|
|
1390
|
+
binds whatever parsed — but silently binding half a header because an
|
|
1391
|
+
`#include` was unreachable is exactly the failure the regex parser was
|
|
1392
|
+
criticised for. Surface it.
|
|
1393
|
+
"""
|
|
1394
|
+
messages = []
|
|
1395
|
+
for diagnostic in tu.diagnostics:
|
|
1396
|
+
if diagnostic.severity < _cindex.Diagnostic.Error:
|
|
1397
|
+
continue
|
|
1398
|
+
location = diagnostic.location
|
|
1399
|
+
where = (
|
|
1400
|
+
f"{Path(location.file.name).name}:{location.line}"
|
|
1401
|
+
if location and location.file
|
|
1402
|
+
else "?"
|
|
1403
|
+
)
|
|
1404
|
+
messages.append(f"{where}: {diagnostic.spelling}")
|
|
1405
|
+
return messages
|
|
1406
|
+
|
|
1407
|
+
|
|
1408
|
+
def _fatal_diagnostics(tu) -> list[str]:
|
|
1409
|
+
"""Errors that make the rest of the parse untrustworthy (a missing include)."""
|
|
1410
|
+
return [
|
|
1411
|
+
d.spelling
|
|
1412
|
+
for d in tu.diagnostics
|
|
1413
|
+
if d.severity >= _cindex.Diagnostic.Fatal
|
|
1414
|
+
]
|
|
1415
|
+
|
|
1416
|
+
|
|
1417
|
+
def _error_lines(tu, path: Path) -> dict[int, str]:
|
|
1418
|
+
"""Line -> message for compiler errors raised *in this header*."""
|
|
1419
|
+
resolved = path.resolve()
|
|
1420
|
+
lines: dict[int, str] = {}
|
|
1421
|
+
for diagnostic in tu.diagnostics:
|
|
1422
|
+
if diagnostic.severity < _cindex.Diagnostic.Error:
|
|
1423
|
+
continue
|
|
1424
|
+
location = diagnostic.location
|
|
1425
|
+
if location is None or location.file is None:
|
|
1426
|
+
continue
|
|
1427
|
+
try:
|
|
1428
|
+
same_file = Path(location.file.name).resolve() == resolved
|
|
1429
|
+
except OSError:
|
|
1430
|
+
same_file = str(location.file.name) == str(resolved)
|
|
1431
|
+
if same_file:
|
|
1432
|
+
lines.setdefault(location.line, diagnostic.spelling)
|
|
1433
|
+
return lines
|
|
1434
|
+
|
|
1435
|
+
|
|
1436
|
+
def _user_roots(path: Path, options: ClangOptions) -> tuple[Path, ...]:
|
|
1437
|
+
"""Directories whose headers belong to this project rather than the system.
|
|
1438
|
+
|
|
1439
|
+
Records defined under these count as bindable types; records defined under
|
|
1440
|
+
the SDK or the standard library do not — `std::vector` is "defined" in the
|
|
1441
|
+
translation unit too, and treating it as a known record would emit a
|
|
1442
|
+
py::class_ for it.
|
|
1443
|
+
"""
|
|
1444
|
+
roots = [path.parent]
|
|
1445
|
+
for include in options.include_paths:
|
|
1446
|
+
try:
|
|
1447
|
+
roots.append(Path(include).resolve())
|
|
1448
|
+
except OSError:
|
|
1449
|
+
continue
|
|
1450
|
+
return tuple(roots)
|
|
1451
|
+
|
|
1452
|
+
|
|
1453
|
+
def _under(candidate: Path, roots: tuple[Path, ...]) -> bool:
|
|
1454
|
+
for root in roots:
|
|
1455
|
+
try:
|
|
1456
|
+
candidate.relative_to(root)
|
|
1457
|
+
return True
|
|
1458
|
+
except ValueError:
|
|
1459
|
+
continue
|
|
1460
|
+
return False
|
|
1461
|
+
|
|
1462
|
+
|
|
1463
|
+
def _defined_records(tu, path: Path, options: ClangOptions) -> set[str]:
|
|
1464
|
+
"""Names of records defined in this header or in a project header it includes."""
|
|
1465
|
+
roots = _user_roots(path, options)
|
|
1466
|
+
names: set[str] = set()
|
|
1467
|
+
|
|
1468
|
+
def visit(cursor):
|
|
1469
|
+
for child in cursor.get_children():
|
|
1470
|
+
if (
|
|
1471
|
+
child.kind in _RECORD_KINDS()
|
|
1472
|
+
and child.is_definition()
|
|
1473
|
+
and child.spelling
|
|
1474
|
+
):
|
|
1475
|
+
location = child.location
|
|
1476
|
+
if location is not None and location.file is not None:
|
|
1477
|
+
try:
|
|
1478
|
+
origin = Path(location.file.name).resolve()
|
|
1479
|
+
except OSError:
|
|
1480
|
+
origin = Path(location.file.name)
|
|
1481
|
+
if origin == path or _under(origin, roots):
|
|
1482
|
+
names.add(child.spelling)
|
|
1483
|
+
visit(child)
|
|
1484
|
+
|
|
1485
|
+
visit(tu.cursor)
|
|
1486
|
+
return names
|
|
1487
|
+
|
|
1488
|
+
|
|
1489
|
+
def defined_record_names(
|
|
1490
|
+
path: Path, options: ClangOptions | None = None
|
|
1491
|
+
) -> frozenset[str]:
|
|
1492
|
+
"""Records *defined* (not merely forward-declared) in this header.
|
|
1493
|
+
|
|
1494
|
+
Unlike the regex reader's textual scan, a class only counts here if the
|
|
1495
|
+
AST says its definition is in this file — a forward declaration or a
|
|
1496
|
+
mention inside a comment or a disabled `#ifdef` branch does not.
|
|
1497
|
+
"""
|
|
1498
|
+
options = options or ClangOptions()
|
|
1499
|
+
if not is_available():
|
|
1500
|
+
return frozenset()
|
|
1501
|
+
tu = _translation_unit(path, options)
|
|
1502
|
+
resolved = path.resolve()
|
|
1503
|
+
|
|
1504
|
+
names: set[str] = set()
|
|
1505
|
+
|
|
1506
|
+
def visit(cursor):
|
|
1507
|
+
if cursor.kind in _RECORD_KINDS() and cursor.is_definition() and cursor.spelling:
|
|
1508
|
+
names.add(cursor.spelling)
|
|
1509
|
+
|
|
1510
|
+
_walk(tu.cursor, resolved, visit)
|
|
1511
|
+
return frozenset(names)
|
|
1512
|
+
|
|
1513
|
+
|
|
1514
|
+
def parse_header(
|
|
1515
|
+
path: Path,
|
|
1516
|
+
expose: ExposeConfig,
|
|
1517
|
+
extra_known_records: frozenset[str] = frozenset(),
|
|
1518
|
+
options: ClangOptions | None = None,
|
|
1519
|
+
) -> ModuleIR:
|
|
1520
|
+
"""Parse a C++ header into a language-neutral ModuleIR via the Clang AST.
|
|
1521
|
+
|
|
1522
|
+
Signature-compatible with `cpp_regex.parse_header`, plus `options` for the
|
|
1523
|
+
compiler flags the AST parse needs (standard, include paths, defines).
|
|
1524
|
+
|
|
1525
|
+
A symbol is included if it carries `[[nativegate::expose]]` OR is named in
|
|
1526
|
+
nativegate.yaml's `expose:` block. `extra_known_records` names types defined
|
|
1527
|
+
in sibling headers of the same service — all of a service's headers compile
|
|
1528
|
+
into one extension, so such a type is complete by the time pybind11 sees it
|
|
1529
|
+
even though it is incomplete here.
|
|
1530
|
+
"""
|
|
1531
|
+
options = options or ClangOptions()
|
|
1532
|
+
resolved = path.resolve()
|
|
1533
|
+
tu = _translation_unit(path, options)
|
|
1534
|
+
|
|
1535
|
+
module = ModuleIR(name=path.stem, language="cpp", source_file=str(path))
|
|
1536
|
+
module.diagnostics = _diagnostics(tu)
|
|
1537
|
+
|
|
1538
|
+
fatal = _fatal_diagnostics(tu)
|
|
1539
|
+
if fatal:
|
|
1540
|
+
# A missing #include stops clang from reporting anything further, and
|
|
1541
|
+
# every unresolved name silently becomes `int` under error recovery.
|
|
1542
|
+
# The AST past that point is fiction — binding it would generate a
|
|
1543
|
+
# service whose signatures disagree with the C++ it calls — so the
|
|
1544
|
+
# whole header is refused, loudly.
|
|
1545
|
+
module.skipped.append(
|
|
1546
|
+
SkippedSymbol(path.name, _unparsed_reason("; ".join(fatal)))
|
|
1547
|
+
)
|
|
1548
|
+
return module
|
|
1549
|
+
|
|
1550
|
+
# Pass 1: every record the service could bind — those defined in this file
|
|
1551
|
+
# (so a method returning a type declared later still resolves) plus those
|
|
1552
|
+
# defined in headers this one #includes from the service's own directories.
|
|
1553
|
+
# An included type is complete and belongs to whichever header declared it;
|
|
1554
|
+
# refusing it here would drop every method that uses one.
|
|
1555
|
+
defined_here = _defined_records(tu, resolved, options)
|
|
1556
|
+
errors = _error_lines(tu, path)
|
|
1557
|
+
known_records = frozenset(defined_here) | extra_known_records
|
|
1558
|
+
|
|
1559
|
+
# Pass 2: build the IR.
|
|
1560
|
+
scalar_refs = frozenset(getattr(options, "scalar_ref_functions", ()) or ())
|
|
1561
|
+
|
|
1562
|
+
def build(cursor):
|
|
1563
|
+
kind = cursor.kind
|
|
1564
|
+
|
|
1565
|
+
if kind in _RECORD_KINDS():
|
|
1566
|
+
if not cursor.is_definition() or not cursor.spelling:
|
|
1567
|
+
return # forward declaration, or an anonymous record
|
|
1568
|
+
if not expose.is_exposed(cursor.spelling):
|
|
1569
|
+
return
|
|
1570
|
+
parsed = _parse_record(cursor, module, known_records, errors)
|
|
1571
|
+
if isinstance(parsed, StructDef):
|
|
1572
|
+
module.structs.append(parsed)
|
|
1573
|
+
else:
|
|
1574
|
+
module.classes.append(parsed)
|
|
1575
|
+
return
|
|
1576
|
+
|
|
1577
|
+
if kind == _cindex.CursorKind.CLASS_TEMPLATE:
|
|
1578
|
+
if expose.is_exposed(cursor.spelling):
|
|
1579
|
+
module.skipped.append(
|
|
1580
|
+
SkippedSymbol(
|
|
1581
|
+
cursor.spelling,
|
|
1582
|
+
"is a class template; pybind11 binds instantiations, "
|
|
1583
|
+
"not templates. A typedef does NOT help — measured: "
|
|
1584
|
+
"libclang reports Buffer<double> with no members, both "
|
|
1585
|
+
"with and without an explicit instantiation, so there "
|
|
1586
|
+
"is nothing to bind. Write a non-template class or free "
|
|
1587
|
+
"functions over the instantiation you need and expose "
|
|
1588
|
+
"those, or exclude it in nativegate.yaml.",
|
|
1589
|
+
)
|
|
1590
|
+
)
|
|
1591
|
+
return
|
|
1592
|
+
|
|
1593
|
+
if kind == _cindex.CursorKind.FUNCTION_TEMPLATE:
|
|
1594
|
+
if expose.is_exposed(cursor.spelling):
|
|
1595
|
+
module.skipped.append(
|
|
1596
|
+
SkippedSymbol(
|
|
1597
|
+
cursor.spelling,
|
|
1598
|
+
"is a function template; pybind11 binds instantiations, not "
|
|
1599
|
+
"templates. Add a non-template overload for the types you "
|
|
1600
|
+
"need, or exclude it in nativegate.yaml.",
|
|
1601
|
+
)
|
|
1602
|
+
)
|
|
1603
|
+
return
|
|
1604
|
+
|
|
1605
|
+
if kind == _cindex.CursorKind.FUNCTION_DECL:
|
|
1606
|
+
if not expose.is_exposed(cursor.spelling):
|
|
1607
|
+
return
|
|
1608
|
+
_parse_function(
|
|
1609
|
+
cursor,
|
|
1610
|
+
module,
|
|
1611
|
+
known_records,
|
|
1612
|
+
errors,
|
|
1613
|
+
scalar_ref_functions=scalar_refs,
|
|
1614
|
+
)
|
|
1615
|
+
return
|
|
1616
|
+
|
|
1617
|
+
if kind == _cindex.CursorKind.TYPEDEF_DECL:
|
|
1618
|
+
# `typedef struct { double x; } Point;` — the record itself is
|
|
1619
|
+
# anonymous, so bind it under the typedef name.
|
|
1620
|
+
underlying = cursor.underlying_typedef_type
|
|
1621
|
+
decl = underlying.get_declaration()
|
|
1622
|
+
if (
|
|
1623
|
+
decl is not None
|
|
1624
|
+
and decl.kind in _RECORD_KINDS()
|
|
1625
|
+
and decl.is_definition()
|
|
1626
|
+
and not decl.spelling
|
|
1627
|
+
and expose.is_exposed(cursor.spelling)
|
|
1628
|
+
):
|
|
1629
|
+
parsed = _parse_record(decl, module, known_records, errors)
|
|
1630
|
+
parsed.name = cursor.spelling
|
|
1631
|
+
if isinstance(parsed, StructDef):
|
|
1632
|
+
module.structs.append(parsed)
|
|
1633
|
+
else:
|
|
1634
|
+
module.classes.append(parsed)
|
|
1635
|
+
|
|
1636
|
+
_walk(tu.cursor, resolved, build)
|
|
1637
|
+
|
|
1638
|
+
# Free functions overload just as members do: the same &name ambiguity
|
|
1639
|
+
# applies, and two same-named HTTP routes shadow each other.
|
|
1640
|
+
_mark_overloads(module.functions)
|
|
1641
|
+
|
|
1642
|
+
# One macro spelled 28 times is one problem, not 28.
|
|
1643
|
+
seen: set[tuple[str, str]] = set()
|
|
1644
|
+
deduped = []
|
|
1645
|
+
for entry in module.skipped:
|
|
1646
|
+
key = (entry.name, entry.reason)
|
|
1647
|
+
if key not in seen:
|
|
1648
|
+
seen.add(key)
|
|
1649
|
+
deduped.append(entry)
|
|
1650
|
+
module.skipped = deduped
|
|
1651
|
+
|
|
1652
|
+
return module
|