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,812 @@
|
|
|
1
|
+
"""C++ header parser — regex/brace fallback reader.
|
|
2
|
+
|
|
3
|
+
The Clang AST parser in `cpp_ast.py` is the primary front end (design.md
|
|
4
|
+
section 7) and is used whenever libclang is importable. This module is the
|
|
5
|
+
fallback for machines without it: it works on the token and brace structure
|
|
6
|
+
of the header rather than on a full AST, which covers the declaration subset
|
|
7
|
+
nativegate binds (classes, structs, free functions, `extern "C"` blocks) as
|
|
8
|
+
long as the header is self-contained.
|
|
9
|
+
|
|
10
|
+
What it structurally cannot do, and `cpp_ast.py` does:
|
|
11
|
+
|
|
12
|
+
* run the preprocessor — `#include`d types, `#define`d names and `#ifdef`
|
|
13
|
+
branches are invisible here, and macro-mangled declarations are reported as
|
|
14
|
+
unreadable rather than expanded;
|
|
15
|
+
* templates, `= delete`/`= default`, abstract classes, copy/move constructor
|
|
16
|
+
detection, typedef and `using`-alias resolution, nested namespaces.
|
|
17
|
+
|
|
18
|
+
`ngate inspect --parser regex` selects it explicitly; otherwise see
|
|
19
|
+
`cpp.py` for how a backend is chosen.
|
|
20
|
+
|
|
21
|
+
What it is careful about, because the previous line-regex version was not:
|
|
22
|
+
|
|
23
|
+
* **Comments.** Prose and commented-out code were parsed as declarations.
|
|
24
|
+
* **Access specifiers.** `public:` was matched as a *return type*, so any
|
|
25
|
+
class whose access label sat on its own line failed the whole header.
|
|
26
|
+
Worse, when it didn't fail, private methods were bound as public.
|
|
27
|
+
* **Constructors.** They have no return type and never matched, so
|
|
28
|
+
`has_default_constructor` was a guess and constructors taking arguments
|
|
29
|
+
were unreachable from Python.
|
|
30
|
+
* **cv-qualifiers and pointers.** `const char*`, `const double&` and
|
|
31
|
+
`double*` all raised and took the rest of the header down with them.
|
|
32
|
+
|
|
33
|
+
Unbindable symbols are now *skipped and reported* (see ModuleIR.skipped)
|
|
34
|
+
rather than aborting the file. One method taking a `std::vector` should not
|
|
35
|
+
cost you the other thirty in the same class.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import re
|
|
41
|
+
from dataclasses import dataclass
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
|
|
44
|
+
from ..config import ExposeConfig
|
|
45
|
+
from ..ir import (
|
|
46
|
+
ClassDef,
|
|
47
|
+
FunctionDef,
|
|
48
|
+
Method,
|
|
49
|
+
ModuleIR,
|
|
50
|
+
NativeTypeError,
|
|
51
|
+
Parameter,
|
|
52
|
+
SkippedSymbol,
|
|
53
|
+
StructDef,
|
|
54
|
+
map_type,
|
|
55
|
+
size_pairings,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
_EXPOSE_ATTR = "[[nativegate::expose]]"
|
|
59
|
+
|
|
60
|
+
_NAMESPACE_RE = re.compile(r"\bnamespace\s+(\w+)\s*\{")
|
|
61
|
+
|
|
62
|
+
# `class Foo final : public Bar {` / `struct Foo {`
|
|
63
|
+
_RECORD_RE = re.compile(
|
|
64
|
+
r"\b(?P<kind>class|struct)\s+(?P<name>\w+)\s*"
|
|
65
|
+
r"(?:final\s*)?(?::(?P<bases>[^{;]*))?\{",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# One entry of a base-clause: `public Bar`, `virtual public ns::Bar`.
|
|
69
|
+
_BASE_RE = re.compile(
|
|
70
|
+
r"^(?:virtual\s+)?(?:(?P<access>public|private|protected)\s+)?"
|
|
71
|
+
r"(?:virtual\s+)?(?P<name>[\w:]+)$"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
_ACCESS_RE = re.compile(r"^\s*(public|private|protected)\s*:", re.IGNORECASE)
|
|
75
|
+
|
|
76
|
+
# A declarator: optional specifier tokens, a name, a parenthesised argument
|
|
77
|
+
# list, optional trailing qualifiers.
|
|
78
|
+
_DECLARATOR_RE = re.compile(
|
|
79
|
+
r"^(?P<spec>[\w\s:<>,&*~\[\]]*?)"
|
|
80
|
+
r"(?P<name>~?\w+)\s*"
|
|
81
|
+
r"\((?P<params>[^()]*)\)"
|
|
82
|
+
r"(?P<trailing>[\w\s:=&*]*)$",
|
|
83
|
+
re.DOTALL,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# Specifier keywords that carry no type information.
|
|
87
|
+
_DROPPED_SPECIFIERS = frozenset(
|
|
88
|
+
{"static", "virtual", "inline", "explicit", "friend", "constexpr", "extern"}
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# --- lexical cleanup ----------------------------------------------------
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _strip_comments(source: str) -> str:
|
|
96
|
+
"""Remove // and /* */ comments, preserving newlines so brace matching and
|
|
97
|
+
any future line reporting stay aligned with the original file."""
|
|
98
|
+
out = []
|
|
99
|
+
i, n = 0, len(source)
|
|
100
|
+
while i < n:
|
|
101
|
+
if source.startswith("//", i):
|
|
102
|
+
while i < n and source[i] != "\n":
|
|
103
|
+
i += 1
|
|
104
|
+
elif source.startswith("/*", i):
|
|
105
|
+
end = source.find("*/", i + 2)
|
|
106
|
+
chunk = source[i : end + 2 if end != -1 else n]
|
|
107
|
+
out.append("\n" * chunk.count("\n"))
|
|
108
|
+
i = end + 2 if end != -1 else n
|
|
109
|
+
elif source[i] in "\"'":
|
|
110
|
+
quote = source[i]
|
|
111
|
+
out.append(source[i])
|
|
112
|
+
i += 1
|
|
113
|
+
while i < n and source[i] != quote:
|
|
114
|
+
if source[i] == "\\":
|
|
115
|
+
out.append(source[i])
|
|
116
|
+
i += 1
|
|
117
|
+
if i < n:
|
|
118
|
+
out.append(source[i])
|
|
119
|
+
i += 1
|
|
120
|
+
if i < n:
|
|
121
|
+
out.append(source[i])
|
|
122
|
+
i += 1
|
|
123
|
+
else:
|
|
124
|
+
out.append(source[i])
|
|
125
|
+
i += 1
|
|
126
|
+
return "".join(out)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _strip_preprocessor(source: str) -> str:
|
|
130
|
+
"""Drop #include/#define/#if lines.
|
|
131
|
+
|
|
132
|
+
nativegate does not run a preprocessor, so a declaration whose name comes
|
|
133
|
+
from a macro (the F77_NAME mangling pattern used in Fortran bridge
|
|
134
|
+
headers) cannot be resolved here. Those declarations survive this step
|
|
135
|
+
and are reported as skipped by the declarator parser rather than
|
|
136
|
+
silently dropped.
|
|
137
|
+
"""
|
|
138
|
+
lines = []
|
|
139
|
+
continued = False
|
|
140
|
+
for line in source.splitlines():
|
|
141
|
+
stripped = line.lstrip()
|
|
142
|
+
if continued or stripped.startswith("#"):
|
|
143
|
+
continued = line.rstrip().endswith("\\")
|
|
144
|
+
lines.append("")
|
|
145
|
+
continue
|
|
146
|
+
lines.append(line)
|
|
147
|
+
return "\n".join(lines)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _matching_brace(source: str, open_index: int) -> int:
|
|
151
|
+
"""Index of the '}' matching the '{' at open_index, or -1."""
|
|
152
|
+
depth = 0
|
|
153
|
+
for i in range(open_index, len(source)):
|
|
154
|
+
if source[i] == "{":
|
|
155
|
+
depth += 1
|
|
156
|
+
elif source[i] == "}":
|
|
157
|
+
depth -= 1
|
|
158
|
+
if depth == 0:
|
|
159
|
+
return i
|
|
160
|
+
return -1
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _split_top_level(text: str, separator: str = ",") -> list[str]:
|
|
164
|
+
"""Split on `separator` at paren/angle/bracket depth zero."""
|
|
165
|
+
parts, depth, current = [], 0, ""
|
|
166
|
+
for ch in text:
|
|
167
|
+
if ch in "(<[":
|
|
168
|
+
depth += 1
|
|
169
|
+
elif ch in ")>]":
|
|
170
|
+
depth -= 1
|
|
171
|
+
if ch == separator and depth == 0:
|
|
172
|
+
parts.append(current.strip())
|
|
173
|
+
current = ""
|
|
174
|
+
else:
|
|
175
|
+
current += ch
|
|
176
|
+
if current.strip():
|
|
177
|
+
parts.append(current.strip())
|
|
178
|
+
return parts
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _strip_inline_bodies(text: str) -> str:
|
|
182
|
+
"""Replace `{ ... }` blocks with ';' so an inline method body can't be
|
|
183
|
+
mistaken for a declaration, and its internal ';' don't split statements.
|
|
184
|
+
|
|
185
|
+
The replacement has to be a ';' rather than nothing: `double a() { ... }`
|
|
186
|
+
carries no trailing semicolon of its own, so deleting the body outright
|
|
187
|
+
would run `double a()` into whatever declaration follows it and lose both.
|
|
188
|
+
"""
|
|
189
|
+
out, depth = [], 0
|
|
190
|
+
for ch in text:
|
|
191
|
+
if ch == "{":
|
|
192
|
+
depth += 1
|
|
193
|
+
elif ch == "}":
|
|
194
|
+
depth = max(0, depth - 1)
|
|
195
|
+
if depth == 0:
|
|
196
|
+
out.append(";")
|
|
197
|
+
elif depth == 0:
|
|
198
|
+
out.append(ch)
|
|
199
|
+
return "".join(out)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# --- type mapping -------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class PointerLengthUnknown(NativeTypeError):
|
|
206
|
+
"""A pointer to a scalar whose length is not knowable from the type alone.
|
|
207
|
+
|
|
208
|
+
Mirrors cpp_ast.PointerLengthUnknown so both C++ readers defer the same
|
|
209
|
+
decision to the same place. Still a NativeTypeError, so any caller that
|
|
210
|
+
does not know about the deferral keeps refusing it.
|
|
211
|
+
"""
|
|
212
|
+
|
|
213
|
+
def __init__(self, message: str, *, element: str, is_mutable: bool) -> None:
|
|
214
|
+
super().__init__(message)
|
|
215
|
+
self.element = element
|
|
216
|
+
self.is_mutable = is_mutable
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# Pointee spellings that map to a scalar nativegate can bind as a buffer.
|
|
220
|
+
_SCALAR_POINTER_ELEMENTS = {
|
|
221
|
+
"double": "float", "float": "float", "long double": "float",
|
|
222
|
+
"int": "int", "short": "int", "long": "int", "long long": "int",
|
|
223
|
+
"unsigned": "int", "unsigned int": "int", "unsigned short": "int",
|
|
224
|
+
"unsigned long": "int", "size_t": "int", "std::size_t": "int",
|
|
225
|
+
"int8_t": "int", "int16_t": "int", "int32_t": "int", "int64_t": "int",
|
|
226
|
+
"uint8_t": "int", "uint16_t": "int", "uint32_t": "int", "uint64_t": "int",
|
|
227
|
+
"bool": "bool",
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# `operator+`, `operator[]`, `operator()`, `operator<=`, `operator bool` ...
|
|
232
|
+
_OPERATOR_SPELLING_RE = re.compile(r"\boperator\s*(?:\[\]|\(\)|[^\s(\w]+|\w+)")
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
# `std::vector<T>` in the spellings a header actually uses, tolerating the
|
|
236
|
+
# spaces a formatter may leave inside the angle brackets.
|
|
237
|
+
_VECTOR_RE = re.compile(r"\bstd::vector\s*<\s*(.+)\s*>\s*$")
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _normalize_type(
|
|
241
|
+
raw: str, known_records: frozenset[str] = frozenset(), *, is_return: bool = False
|
|
242
|
+
) -> tuple[str, bool]:
|
|
243
|
+
"""Map a C++ type to (python_type, is_array).
|
|
244
|
+
|
|
245
|
+
Strips cv-qualifiers and reference markers. `char*`/`const char*` map to
|
|
246
|
+
str. Class and struct types declared in the same header map to themselves,
|
|
247
|
+
so a method returning a `PvtState` is bindable instead of being dropped.
|
|
248
|
+
|
|
249
|
+
A pointer to a *known record* is fine as a parameter — pybind11 passes the
|
|
250
|
+
held pointer and no ownership changes hands. A pointer *return* is not:
|
|
251
|
+
the default return-value policy would take ownership of a pointer the C++
|
|
252
|
+
side still owns, and the second free is a crash in someone else's stack
|
|
253
|
+
frame. A pointer to anything else has no length information and is
|
|
254
|
+
likewise refused.
|
|
255
|
+
"""
|
|
256
|
+
# Checked on the RAW spelling, before cv-qualifiers and `&` are tokenised
|
|
257
|
+
# away, because for std::vector the reference and its constness are the
|
|
258
|
+
# whole question. pybind11 converts a Python list into a TEMPORARY vector,
|
|
259
|
+
# so a non-const `std::vector<T>&` — which in C++ means "I will write to
|
|
260
|
+
# your vector" — has its writes discarded when the temporary dies. Silently:
|
|
261
|
+
# it compiles, runs, and hands back unmodified data. Same refusal, and the
|
|
262
|
+
# same wording, as cpp_ast._refuse_mutable_vector_ref.
|
|
263
|
+
raw_text = raw.strip()
|
|
264
|
+
if "vector" in raw_text and "&" in raw_text and not is_return:
|
|
265
|
+
before_ref = raw_text.split("&")[0]
|
|
266
|
+
if _VECTOR_RE.search(before_ref) and "const" not in before_ref.split():
|
|
267
|
+
raise NativeTypeError(
|
|
268
|
+
f"'{raw_text}' is a mutable reference to a std::vector. pybind11 "
|
|
269
|
+
"converts the caller's list into a temporary vector, so anything "
|
|
270
|
+
"the function writes through this parameter is discarded when the "
|
|
271
|
+
"call returns — silently, with no error. Take "
|
|
272
|
+
"'const std::vector<T>&' if the data is input, or RETURN the "
|
|
273
|
+
"modified vector, or exclude the symbol in nativegate.yaml."
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
text = " ".join(raw.replace("*", " * ").replace("&", " & ").split())
|
|
277
|
+
tokens = [t for t in text.split(" ") if t not in ("const", "volatile", "&")]
|
|
278
|
+
|
|
279
|
+
pointer_depth = tokens.count("*")
|
|
280
|
+
tokens = [t for t in tokens if t != "*"]
|
|
281
|
+
base = " ".join(tokens).strip()
|
|
282
|
+
|
|
283
|
+
if not base:
|
|
284
|
+
raise NativeTypeError("empty type")
|
|
285
|
+
|
|
286
|
+
if pointer_depth == 1 and base in ("char", "signed char", "unsigned char"):
|
|
287
|
+
# Only `const char*` is a C string. A non-const `char*` is an output
|
|
288
|
+
# buffer with no length convention: bound as a Python str, pybind11
|
|
289
|
+
# materialises a temporary that C++ then writes through, and the write
|
|
290
|
+
# is lost or corrupts freed memory. (Same refusal as cpp_ast.)
|
|
291
|
+
if "const" in text.split("*")[0].split():
|
|
292
|
+
return "str", False
|
|
293
|
+
raise NativeTypeError(
|
|
294
|
+
f"'{raw.strip()}' is a non-const 'char*': an output buffer with no "
|
|
295
|
+
"length convention. Binding it as a Python str would have C++ write "
|
|
296
|
+
"through a temporary. Return std::string (or take 'const char*' for "
|
|
297
|
+
"input), or exclude the symbol in nativegate.yaml."
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
vector_match = _VECTOR_RE.match(base)
|
|
301
|
+
if vector_match:
|
|
302
|
+
# pybind11's <pybind11/stl.h> converts std::vector<T> <-> list, by copy.
|
|
303
|
+
inner, inner_is_array = _normalize_type(
|
|
304
|
+
vector_match.group(1), known_records, is_return=is_return
|
|
305
|
+
)
|
|
306
|
+
if inner_is_array:
|
|
307
|
+
raise NativeTypeError(
|
|
308
|
+
f"'{raw.strip()}' is a nested container. pybind11 will convert "
|
|
309
|
+
"it, but nativegate has no IR shape for a list of lists, so the "
|
|
310
|
+
"generated Pydantic model would be wrong. Flatten it (pass the "
|
|
311
|
+
"data and its shape separately), or exclude the symbol."
|
|
312
|
+
)
|
|
313
|
+
if inner not in ("float", "int", "bool", "str"):
|
|
314
|
+
raise NativeTypeError(
|
|
315
|
+
f"'{raw.strip()}' has element type '{inner}', which is not a "
|
|
316
|
+
"scalar pybind11 converts element-wise. Use a vector of a "
|
|
317
|
+
"numeric type or std::string, or exclude the symbol."
|
|
318
|
+
)
|
|
319
|
+
return inner, True
|
|
320
|
+
|
|
321
|
+
if base in known_records:
|
|
322
|
+
if pointer_depth == 0:
|
|
323
|
+
return base, False
|
|
324
|
+
if pointer_depth == 1 and not is_return:
|
|
325
|
+
return base, False
|
|
326
|
+
raise NativeTypeError(
|
|
327
|
+
f"'{raw.strip()}' returns a raw pointer to '{base}'; binding it would "
|
|
328
|
+
"hand ownership to Python while C++ still owns the object. Return by "
|
|
329
|
+
"value or by reference instead."
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
if pointer_depth:
|
|
333
|
+
# A capitalised unknown base is almost always a forward-declared class
|
|
334
|
+
# rather than a numeric buffer; saying "cannot infer its length" there
|
|
335
|
+
# would send you looking for the wrong fix.
|
|
336
|
+
if base[:1].isupper():
|
|
337
|
+
raise NativeTypeError(
|
|
338
|
+
f"'{raw.strip()}' refers to '{base}', which is only forward-declared "
|
|
339
|
+
"(an incomplete type). pybind11 needs the full definition to bind it. "
|
|
340
|
+
f"Add the header that defines '{base}' to this service's native/ "
|
|
341
|
+
"directory, or exclude the symbol in nativegate.yaml."
|
|
342
|
+
)
|
|
343
|
+
# A pointer to a scalar may still be bindable as an array, if another
|
|
344
|
+
# parameter of the same function carries its length. That needs the
|
|
345
|
+
# whole signature, so the decision is deferred to _parse_params — the
|
|
346
|
+
# same shape as cpp_ast.PointerLengthUnknown, and for the same reason.
|
|
347
|
+
if base in _SCALAR_POINTER_ELEMENTS:
|
|
348
|
+
raise PointerLengthUnknown(
|
|
349
|
+
f"'{raw.strip()}' is a pointer; nativegate cannot infer its "
|
|
350
|
+
"length. Wrap it in a type that carries its own size "
|
|
351
|
+
"(std::vector, a span, or a struct), pass its length in an "
|
|
352
|
+
"argument named after it (`n`, `n_values`, `values_len`, ...), "
|
|
353
|
+
"or exclude the symbol in nativegate.yaml.",
|
|
354
|
+
element=_SCALAR_POINTER_ELEMENTS[base],
|
|
355
|
+
is_mutable="const" not in raw.split("*")[0].split(),
|
|
356
|
+
)
|
|
357
|
+
raise NativeTypeError(
|
|
358
|
+
f"'{raw.strip()}' is a pointer; nativegate cannot infer its length. "
|
|
359
|
+
"Wrap it in a type that carries its own size (std::vector, a span, "
|
|
360
|
+
"or a struct) or exclude the symbol in nativegate.yaml."
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
return map_type(base), False
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _parse_params(raw: str, known_records: frozenset[str] = frozenset()) -> list[Parameter]:
|
|
367
|
+
raw = raw.strip()
|
|
368
|
+
if not raw or raw == "void":
|
|
369
|
+
return []
|
|
370
|
+
|
|
371
|
+
params: list[Parameter] = []
|
|
372
|
+
deferred: dict[str, PointerLengthUnknown] = {}
|
|
373
|
+
for index, part in enumerate(_split_top_level(raw)):
|
|
374
|
+
# Drop a default argument: `int n = 4` -> `int n`.
|
|
375
|
+
part = part.split("=", 1)[0].strip()
|
|
376
|
+
if not part:
|
|
377
|
+
continue
|
|
378
|
+
|
|
379
|
+
# Array parameter spelled `double values[]`.
|
|
380
|
+
is_array = bool(re.search(r"\[\s*\d*\s*\]$", part))
|
|
381
|
+
part = re.sub(r"\[\s*\d*\s*\]$", "", part).strip()
|
|
382
|
+
|
|
383
|
+
match = re.match(r"^(?P<type>.*?)(?P<name>\w+)$", part, re.DOTALL)
|
|
384
|
+
if match and match.group("type").strip():
|
|
385
|
+
type_text, name = match.group("type"), match.group("name")
|
|
386
|
+
else:
|
|
387
|
+
# Unnamed parameter: `double` / `const char*`.
|
|
388
|
+
type_text, name = part, f"arg{index}"
|
|
389
|
+
|
|
390
|
+
try:
|
|
391
|
+
py_type, pointer_array = _normalize_type(type_text, known_records)
|
|
392
|
+
except PointerLengthUnknown as exc:
|
|
393
|
+
deferred[name] = exc
|
|
394
|
+
params.append(Parameter(name=name, type=exc.element, is_array=True))
|
|
395
|
+
continue
|
|
396
|
+
params.append(
|
|
397
|
+
Parameter(name=name, type=py_type, is_array=is_array or pointer_array)
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
if deferred:
|
|
401
|
+
by_name = {p.name: p for p in params}
|
|
402
|
+
paired = {array: length for length, array in size_pairings(params)}
|
|
403
|
+
for name, exc in deferred.items():
|
|
404
|
+
length = paired.get(name)
|
|
405
|
+
if length is None:
|
|
406
|
+
# Nothing names this pointer's length; refuse it as before.
|
|
407
|
+
raise exc
|
|
408
|
+
by_name[name].length_param = length
|
|
409
|
+
by_name[name].is_mutable_buffer = exc.is_mutable
|
|
410
|
+
|
|
411
|
+
return params
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
# --- declaration scanning -----------------------------------------------
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _statements(body: str):
|
|
418
|
+
"""Yield (statement_text, access_after) for each ';'-terminated statement,
|
|
419
|
+
tracking access specifiers as they are encountered."""
|
|
420
|
+
access = None
|
|
421
|
+
for chunk in _split_top_level(_strip_inline_bodies(body), ";"):
|
|
422
|
+
text = chunk
|
|
423
|
+
while True:
|
|
424
|
+
match = _ACCESS_RE.match(text)
|
|
425
|
+
if not match:
|
|
426
|
+
break
|
|
427
|
+
access = match.group(1).lower()
|
|
428
|
+
text = text[match.end() :]
|
|
429
|
+
text = text.strip()
|
|
430
|
+
if text:
|
|
431
|
+
yield text, access
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
@dataclass
|
|
435
|
+
class _Decl:
|
|
436
|
+
"""One classified declaration.
|
|
437
|
+
|
|
438
|
+
kind is "method", "ctor", "field", or "operator" — the last meaning a
|
|
439
|
+
recognised operator this reader will not attempt to bind, so the caller
|
|
440
|
+
can report it rather than drop it.
|
|
441
|
+
"""
|
|
442
|
+
|
|
443
|
+
kind: str
|
|
444
|
+
name: str = ""
|
|
445
|
+
returns: str = ""
|
|
446
|
+
params: str = ""
|
|
447
|
+
exposed: bool = False
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _parse_declarator(statement: str, record_name: str | None) -> _Decl | None:
|
|
451
|
+
"""Classify one ';'-terminated declaration, or None if it isn't one."""
|
|
452
|
+
exposed = _EXPOSE_ATTR in statement
|
|
453
|
+
text = statement.replace(_EXPOSE_ATTR, " ").strip()
|
|
454
|
+
|
|
455
|
+
# Checked FIRST, because a symbolic operator never reaches the branches
|
|
456
|
+
# below: _DECLARATOR_RE's name group is `~?\w+`, which cannot match the
|
|
457
|
+
# `+` in `operator+`, so `Vec2 operator+(const Vec2&) const` fell through
|
|
458
|
+
# to the data-member branch and was reported as a field called "const".
|
|
459
|
+
# cpp_ast binds these as Python special methods; this reader cannot parse
|
|
460
|
+
# them reliably, so it names them honestly and says so.
|
|
461
|
+
operator = _OPERATOR_SPELLING_RE.search(text)
|
|
462
|
+
if operator and "(" in text:
|
|
463
|
+
return _Decl(kind="operator", name=operator.group(0).strip(), exposed=exposed)
|
|
464
|
+
|
|
465
|
+
match = _DECLARATOR_RE.match(text)
|
|
466
|
+
if not match:
|
|
467
|
+
# No argument list: a data member, or something we don't model.
|
|
468
|
+
field = re.match(r"^(?P<type>.*?)(?P<name>\w+)$", text, re.DOTALL)
|
|
469
|
+
if field and field.group("type").strip():
|
|
470
|
+
return _Decl(
|
|
471
|
+
kind="field",
|
|
472
|
+
name=field.group("name"),
|
|
473
|
+
returns=field.group("type"),
|
|
474
|
+
exposed=exposed,
|
|
475
|
+
)
|
|
476
|
+
return None
|
|
477
|
+
|
|
478
|
+
name = match.group("name")
|
|
479
|
+
spec_tokens = [
|
|
480
|
+
t for t in match.group("spec").split() if t not in _DROPPED_SPECIFIERS
|
|
481
|
+
]
|
|
482
|
+
|
|
483
|
+
# A destructor is genuinely nothing to bind: pybind11 destroys the held
|
|
484
|
+
# object itself, and Python has no `__del__` contract worth wiring to it.
|
|
485
|
+
if name.startswith("~"):
|
|
486
|
+
return None
|
|
487
|
+
# Operators ARE bindable — cpp_ast binds them as Python special methods —
|
|
488
|
+
# but this reader's declarator pattern cannot match `operator[]`,
|
|
489
|
+
# `operator()` or `operator<` reliably, and half-parsing one is worse than
|
|
490
|
+
# not parsing it. Reported rather than silently dropped, with the reason,
|
|
491
|
+
# so the difference between the two backends is visible instead of being
|
|
492
|
+
# discovered as a missing method.
|
|
493
|
+
if "operator" in name:
|
|
494
|
+
return _Decl(kind="operator", name=name, exposed=exposed)
|
|
495
|
+
if record_name and name == record_name and not spec_tokens:
|
|
496
|
+
return _Decl(kind="ctor", name=name, params=match.group("params"), exposed=exposed)
|
|
497
|
+
if not spec_tokens:
|
|
498
|
+
# No return type and not a constructor — a macro-generated name, or a
|
|
499
|
+
# declaration this parser does not model.
|
|
500
|
+
return None
|
|
501
|
+
|
|
502
|
+
return _Decl(
|
|
503
|
+
kind="method",
|
|
504
|
+
name=name,
|
|
505
|
+
returns=" ".join(spec_tokens),
|
|
506
|
+
params=match.group("params"),
|
|
507
|
+
exposed=exposed,
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _unparsed_name(statement: str) -> str | None:
|
|
512
|
+
"""Best-effort identifier for a declaration that failed to parse, so the
|
|
513
|
+
skip report names something a human can search the header for."""
|
|
514
|
+
match = re.search(r"(\w+)\s*\(", statement)
|
|
515
|
+
return match.group(1) if match else None
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _parse_bases(
|
|
519
|
+
clause: str | None, kind: str, known_records: frozenset[str]
|
|
520
|
+
) -> list[str]:
|
|
521
|
+
"""Public base classes bound by this service, from a `: public Bar` clause.
|
|
522
|
+
|
|
523
|
+
Only public bases are reported: with private or protected inheritance the
|
|
524
|
+
Derived -> Base conversion is inaccessible, so naming it in
|
|
525
|
+
`py::class_<Derived, Base>` would not compile. Bases the service does not
|
|
526
|
+
bind are dropped for the same reason — pybind11 needs the base registered.
|
|
527
|
+
"""
|
|
528
|
+
if not clause:
|
|
529
|
+
return []
|
|
530
|
+
default_access = "public" if kind == "struct" else "private"
|
|
531
|
+
|
|
532
|
+
bases = []
|
|
533
|
+
for part in _split_top_level(clause):
|
|
534
|
+
match = _BASE_RE.match(part.strip())
|
|
535
|
+
if not match:
|
|
536
|
+
continue
|
|
537
|
+
access = (match.group("access") or default_access).lower()
|
|
538
|
+
base = match.group("name").split("::")[-1]
|
|
539
|
+
if access == "public" and base in known_records:
|
|
540
|
+
bases.append(base)
|
|
541
|
+
return bases
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _parse_record(
|
|
545
|
+
kind: str,
|
|
546
|
+
name: str,
|
|
547
|
+
body: str,
|
|
548
|
+
namespace: str | None,
|
|
549
|
+
module: ModuleIR,
|
|
550
|
+
known_records: frozenset[str] = frozenset(),
|
|
551
|
+
base_clause: str | None = None,
|
|
552
|
+
):
|
|
553
|
+
"""Build a ClassDef or StructDef from one class/struct body."""
|
|
554
|
+
default_access = "public" if kind == "struct" else "private"
|
|
555
|
+
bases = _parse_bases(base_clause, kind, known_records)
|
|
556
|
+
|
|
557
|
+
methods: list[Method] = []
|
|
558
|
+
fields: list[Parameter] = []
|
|
559
|
+
constructors: list[list[Parameter]] = []
|
|
560
|
+
saw_any_constructor = False
|
|
561
|
+
|
|
562
|
+
for statement, access in _statements(body):
|
|
563
|
+
effective = access or default_access
|
|
564
|
+
decl = _parse_declarator(statement, name)
|
|
565
|
+
if decl is not None and decl.kind == "operator":
|
|
566
|
+
# Reported, not dropped. cpp_ast binds operators as Python special
|
|
567
|
+
# methods; this reader cannot parse their declarations reliably, so
|
|
568
|
+
# the difference is made visible instead of being discovered later
|
|
569
|
+
# as a method that is simply missing.
|
|
570
|
+
module.skipped.append(
|
|
571
|
+
SkippedSymbol(
|
|
572
|
+
f"{name}::{decl.name}",
|
|
573
|
+
f"'{decl.name}' is an operator. The Clang AST parser binds "
|
|
574
|
+
"operators as Python special methods; this regex reader "
|
|
575
|
+
"cannot parse their declarations reliably. Install libclang "
|
|
576
|
+
'(pip install "nativegate[clang]") to bind it, or expose a '
|
|
577
|
+
"named method instead.",
|
|
578
|
+
)
|
|
579
|
+
)
|
|
580
|
+
continue
|
|
581
|
+
if decl is not None and decl.kind == "operator":
|
|
582
|
+
module.skipped.append(
|
|
583
|
+
SkippedSymbol(
|
|
584
|
+
f"{name}::{decl.name}",
|
|
585
|
+
f"'{decl.name}' is an operator. The Clang AST parser binds "
|
|
586
|
+
"operators as Python special methods; this regex reader "
|
|
587
|
+
"cannot parse their declarations reliably. Install libclang "
|
|
588
|
+
'(pip install "nativegate[clang]") to bind it, or expose a '
|
|
589
|
+
"named method instead.",
|
|
590
|
+
)
|
|
591
|
+
)
|
|
592
|
+
continue
|
|
593
|
+
if decl is None:
|
|
594
|
+
continue
|
|
595
|
+
|
|
596
|
+
if decl.kind == "ctor":
|
|
597
|
+
saw_any_constructor = True
|
|
598
|
+
if effective != "public":
|
|
599
|
+
continue
|
|
600
|
+
try:
|
|
601
|
+
constructors.append(_parse_params(decl.params, known_records))
|
|
602
|
+
except NativeTypeError as exc:
|
|
603
|
+
module.skipped.append(
|
|
604
|
+
SkippedSymbol(f"{name}::{name}", str(exc))
|
|
605
|
+
)
|
|
606
|
+
continue
|
|
607
|
+
|
|
608
|
+
if effective != "public":
|
|
609
|
+
continue
|
|
610
|
+
|
|
611
|
+
if decl.kind == "field":
|
|
612
|
+
try:
|
|
613
|
+
py_type, is_array = _normalize_type(decl.returns, known_records)
|
|
614
|
+
except NativeTypeError as exc:
|
|
615
|
+
module.skipped.append(SkippedSymbol(f"{name}.{decl.name}", str(exc)))
|
|
616
|
+
continue
|
|
617
|
+
fields.append(Parameter(name=decl.name, type=py_type, is_array=is_array))
|
|
618
|
+
continue
|
|
619
|
+
|
|
620
|
+
try:
|
|
621
|
+
returns, _ = _normalize_type(decl.returns, known_records, is_return=True)
|
|
622
|
+
parameters = _parse_params(decl.params, known_records)
|
|
623
|
+
except NativeTypeError as exc:
|
|
624
|
+
module.skipped.append(SkippedSymbol(f"{name}::{decl.name}", str(exc)))
|
|
625
|
+
continue
|
|
626
|
+
methods.append(
|
|
627
|
+
Method(
|
|
628
|
+
name=decl.name,
|
|
629
|
+
parameters=parameters,
|
|
630
|
+
returns=returns,
|
|
631
|
+
is_static="static" in statement.split("(")[0],
|
|
632
|
+
)
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
if kind == "struct" and not methods and not bases and fields:
|
|
636
|
+
return StructDef(name=name, namespace=namespace, fields=fields)
|
|
637
|
+
|
|
638
|
+
# A class with only non-default constructors cannot be `py::init<>()`.
|
|
639
|
+
has_default = (not saw_any_constructor) or any(not p for p in constructors)
|
|
640
|
+
return ClassDef(
|
|
641
|
+
name=name,
|
|
642
|
+
namespace=namespace,
|
|
643
|
+
methods=methods,
|
|
644
|
+
has_default_constructor=has_default,
|
|
645
|
+
constructors=[c for c in constructors if c],
|
|
646
|
+
fields=fields,
|
|
647
|
+
bases=bases,
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def defined_record_names(path: Path) -> frozenset[str]:
|
|
652
|
+
"""Records *defined* (not merely forward-declared) in this header.
|
|
653
|
+
|
|
654
|
+
A service's headers all compile into one extension, so a type
|
|
655
|
+
forward-declared in one and defined in another is complete by the time
|
|
656
|
+
pybind11 sees it. Callers union these across every header and feed the
|
|
657
|
+
result back as `extra_known_records`.
|
|
658
|
+
"""
|
|
659
|
+
return frozenset(m.group("name") for m in _RECORD_RE.finditer(path.read_text()))
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def parse_header(
|
|
663
|
+
path: Path,
|
|
664
|
+
expose: ExposeConfig,
|
|
665
|
+
extra_known_records: frozenset[str] = frozenset(),
|
|
666
|
+
) -> ModuleIR:
|
|
667
|
+
"""Parse a C++ header into a language-neutral ModuleIR.
|
|
668
|
+
|
|
669
|
+
A symbol is included if it carries `[[nativegate::expose]]` OR is named in
|
|
670
|
+
nativegate.yaml's `expose:` block (config takes precedence for legacy code
|
|
671
|
+
that can't be annotated in place — see design.md section 9).
|
|
672
|
+
|
|
673
|
+
`extra_known_records` names types defined in sibling headers of the same
|
|
674
|
+
service; without it, a type only forward-declared here is treated as
|
|
675
|
+
incomplete and any symbol using it is skipped.
|
|
676
|
+
"""
|
|
677
|
+
source = _strip_preprocessor(_strip_comments(path.read_text()))
|
|
678
|
+
|
|
679
|
+
namespace_match = _NAMESPACE_RE.search(source)
|
|
680
|
+
namespace = namespace_match.group(1) if namespace_match else None
|
|
681
|
+
|
|
682
|
+
module = ModuleIR(name=path.stem, language="cpp", source_file=str(path))
|
|
683
|
+
|
|
684
|
+
# Pass 1: locate every record and blank its text out, so the free-function
|
|
685
|
+
# scan cannot see member declarations. Collecting the names before any
|
|
686
|
+
# body is parsed is what makes a method returning a type declared later in
|
|
687
|
+
# the same header (`PvtState properties_at(...)`) resolvable — a single
|
|
688
|
+
# forward pass would have to give up on it.
|
|
689
|
+
records = []
|
|
690
|
+
remainder = list(source)
|
|
691
|
+
for match in _RECORD_RE.finditer(source):
|
|
692
|
+
open_index = source.index("{", match.end() - 1)
|
|
693
|
+
close_index = _matching_brace(source, open_index)
|
|
694
|
+
if close_index == -1:
|
|
695
|
+
continue
|
|
696
|
+
|
|
697
|
+
records.append(
|
|
698
|
+
(
|
|
699
|
+
match.group("kind"),
|
|
700
|
+
match.group("name"),
|
|
701
|
+
source[open_index + 1 : close_index],
|
|
702
|
+
match.group("bases"),
|
|
703
|
+
)
|
|
704
|
+
)
|
|
705
|
+
for i in range(match.start(), close_index + 1):
|
|
706
|
+
if remainder[i] != "\n":
|
|
707
|
+
remainder[i] = " "
|
|
708
|
+
|
|
709
|
+
# A forward declaration (`class FluidModel;`) names an INCOMPLETE type.
|
|
710
|
+
# pybind11 needs a complete type to build a caster, so binding a method
|
|
711
|
+
# that takes one fails at compile time with
|
|
712
|
+
# "incomplete type 'FluidModel' used in type trait expression" — a wall
|
|
713
|
+
# of template errors pointing into pybind11 internals, not at your header.
|
|
714
|
+
# Treating forward declarations as bindable is what used to produce that.
|
|
715
|
+
#
|
|
716
|
+
# The type may still be completed by a sibling header in the same service
|
|
717
|
+
# (all of a service's headers compile into one extension), so the caller
|
|
718
|
+
# passes those names in via `extra_known_records`.
|
|
719
|
+
defined_here = frozenset(name for _kind, name, _body, _bases in records)
|
|
720
|
+
known_records = defined_here | extra_known_records
|
|
721
|
+
|
|
722
|
+
# Pass 2: parse the bodies.
|
|
723
|
+
for kind, record_name, body, base_clause in records:
|
|
724
|
+
if not expose.is_exposed(record_name):
|
|
725
|
+
continue
|
|
726
|
+
parsed = _parse_record(
|
|
727
|
+
kind, record_name, body, namespace, module, known_records, base_clause
|
|
728
|
+
)
|
|
729
|
+
if isinstance(parsed, StructDef):
|
|
730
|
+
module.structs.append(parsed)
|
|
731
|
+
else:
|
|
732
|
+
module.classes.append(parsed)
|
|
733
|
+
|
|
734
|
+
# Free functions, including everything inside `extern "C"` and `namespace`
|
|
735
|
+
# blocks — those braces group declarations, they don't scope them away.
|
|
736
|
+
outside = "".join(remainder)
|
|
737
|
+
outside = re.sub(r'\bextern\s+"C"\s*\{', " ", outside)
|
|
738
|
+
outside = re.sub(r"\bnamespace\s+\w*\s*\{", " ", outside)
|
|
739
|
+
outside = outside.replace("}", " ")
|
|
740
|
+
|
|
741
|
+
for statement, _access in _statements(outside):
|
|
742
|
+
decl = _parse_declarator(statement, None)
|
|
743
|
+
if decl is not None and decl.kind == "operator":
|
|
744
|
+
# Reported, not dropped. cpp_ast binds operators as Python special
|
|
745
|
+
# methods; this reader cannot parse their declarations reliably, so
|
|
746
|
+
# the difference is made visible instead of being discovered later
|
|
747
|
+
# as a method that is simply missing.
|
|
748
|
+
module.skipped.append(
|
|
749
|
+
SkippedSymbol(
|
|
750
|
+
decl.name,
|
|
751
|
+
f"'{decl.name}' is an operator. The Clang AST parser binds "
|
|
752
|
+
"operators as Python special methods; this regex reader "
|
|
753
|
+
"cannot parse their declarations reliably. Install libclang "
|
|
754
|
+
'(pip install "nativegate[clang]") to bind it, or expose a '
|
|
755
|
+
"named method instead.",
|
|
756
|
+
)
|
|
757
|
+
)
|
|
758
|
+
continue
|
|
759
|
+
if decl is not None and decl.kind == "operator":
|
|
760
|
+
module.skipped.append(
|
|
761
|
+
SkippedSymbol(
|
|
762
|
+
decl.name,
|
|
763
|
+
f"'{decl.name}' is an operator. The Clang AST parser binds "
|
|
764
|
+
"operators as Python special methods; this regex reader "
|
|
765
|
+
"cannot parse their declarations reliably. Install libclang "
|
|
766
|
+
'(pip install "nativegate[clang]") to bind it, or expose a '
|
|
767
|
+
"named method instead.",
|
|
768
|
+
)
|
|
769
|
+
)
|
|
770
|
+
continue
|
|
771
|
+
if decl is None or decl.kind != "method":
|
|
772
|
+
# A statement that clearly wants to be a function declaration but
|
|
773
|
+
# didn't parse is reported, not dropped. Returning an empty module
|
|
774
|
+
# for a header full of declarations reads as "nothing to bind here"
|
|
775
|
+
# when the truth is "this parser could not read it" — which is what
|
|
776
|
+
# macro-mangled extern "C" bridge headers look like.
|
|
777
|
+
if decl is None and "(" in statement and not statement.startswith("typedef"):
|
|
778
|
+
name = _unparsed_name(statement)
|
|
779
|
+
if name:
|
|
780
|
+
module.skipped.append(
|
|
781
|
+
SkippedSymbol(
|
|
782
|
+
name,
|
|
783
|
+
"could not parse this declaration; if the name comes from a "
|
|
784
|
+
"macro, nativegate does not run a preprocessor — declare the "
|
|
785
|
+
"symbol with its expanded name to bind it.",
|
|
786
|
+
)
|
|
787
|
+
)
|
|
788
|
+
continue
|
|
789
|
+
if not expose.is_exposed(decl.name):
|
|
790
|
+
continue
|
|
791
|
+
try:
|
|
792
|
+
returns, _ = _normalize_type(decl.returns, known_records, is_return=True)
|
|
793
|
+
parameters = _parse_params(decl.params, known_records)
|
|
794
|
+
except NativeTypeError as exc:
|
|
795
|
+
module.skipped.append(SkippedSymbol(decl.name, str(exc)))
|
|
796
|
+
continue
|
|
797
|
+
module.functions.append(
|
|
798
|
+
FunctionDef(name=decl.name, parameters=parameters, returns=returns)
|
|
799
|
+
)
|
|
800
|
+
|
|
801
|
+
# One macro spelled 28 times is one problem, not 28. Collapse identical
|
|
802
|
+
# (name, reason) pairs so the report stays readable.
|
|
803
|
+
seen: set[tuple[str, str]] = set()
|
|
804
|
+
deduped = []
|
|
805
|
+
for entry in module.skipped:
|
|
806
|
+
key = (entry.name, entry.reason)
|
|
807
|
+
if key not in seen:
|
|
808
|
+
seen.add(key)
|
|
809
|
+
deduped.append(entry)
|
|
810
|
+
module.skipped = deduped
|
|
811
|
+
|
|
812
|
+
return module
|