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
nativegate/ir.py
ADDED
|
@@ -0,0 +1,789 @@
|
|
|
1
|
+
"""Intermediate representation shared by all language parsers and binding generators.
|
|
2
|
+
|
|
3
|
+
Parsers (cpp.py, fortran.py, ...) normalize source into these dataclasses.
|
|
4
|
+
Generators (pybind_gen.py, f2py_gen.py, ...) consume them without ever
|
|
5
|
+
knowing which language the API came from.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
import warnings
|
|
12
|
+
from dataclasses import asdict, dataclass, field
|
|
13
|
+
|
|
14
|
+
# Deterministic native -> Python type mapping (design.md section 15).
|
|
15
|
+
# Unsupported types raise NativeTypeError rather than silently binding unsafely.
|
|
16
|
+
TYPE_MAP = {
|
|
17
|
+
"int": "int",
|
|
18
|
+
"int32_t": "int",
|
|
19
|
+
"int64_t": "int",
|
|
20
|
+
"float": "float",
|
|
21
|
+
"double": "float",
|
|
22
|
+
"bool": "bool",
|
|
23
|
+
"void": "None",
|
|
24
|
+
"std::string": "str",
|
|
25
|
+
# Integer spellings that are ordinary in pre-standard C++ and were
|
|
26
|
+
# previously fatal to the whole header.
|
|
27
|
+
"short": "int",
|
|
28
|
+
"long": "int",
|
|
29
|
+
"long long": "int",
|
|
30
|
+
"unsigned": "int",
|
|
31
|
+
"unsigned int": "int",
|
|
32
|
+
"unsigned short": "int",
|
|
33
|
+
"unsigned long": "int",
|
|
34
|
+
"size_t": "int",
|
|
35
|
+
"std::size_t": "int",
|
|
36
|
+
"int8_t": "int",
|
|
37
|
+
"int16_t": "int",
|
|
38
|
+
"uint8_t": "int",
|
|
39
|
+
"uint16_t": "int",
|
|
40
|
+
"uint32_t": "int",
|
|
41
|
+
"uint64_t": "int",
|
|
42
|
+
"long double": "float",
|
|
43
|
+
"char": "str",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class NativeTypeError(ValueError):
|
|
48
|
+
"""Raised when a native type has no deterministic Python/pybind11 mapping."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def map_type(native_type: str) -> str:
|
|
52
|
+
normalized = native_type.strip().rstrip("&").strip()
|
|
53
|
+
if normalized not in TYPE_MAP:
|
|
54
|
+
raise NativeTypeError(
|
|
55
|
+
f"Unsupported native type '{native_type}': add a mapping in ir.TYPE_MAP "
|
|
56
|
+
"or exclude this symbol from nativegate.yaml."
|
|
57
|
+
)
|
|
58
|
+
return TYPE_MAP[normalized]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# Fortran -> Python type mapping (design.md section 15). Kind parameters
|
|
62
|
+
# (real(8), integer(4), ...) are normalized to the base type; arrays are
|
|
63
|
+
# handled separately via Parameter.is_array, not encoded in the type name.
|
|
64
|
+
FORTRAN_TYPE_MAP = {
|
|
65
|
+
"real": "float",
|
|
66
|
+
"double precision": "float",
|
|
67
|
+
"integer": "int",
|
|
68
|
+
"logical": "bool",
|
|
69
|
+
"character": "str",
|
|
70
|
+
"complex": "complex",
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def map_fortran_type(native_type: str) -> str:
|
|
75
|
+
normalized = native_type.strip().lower()
|
|
76
|
+
normalized = re.sub(r"\(.*\)", "", normalized).strip()
|
|
77
|
+
if normalized not in FORTRAN_TYPE_MAP:
|
|
78
|
+
raise NativeTypeError(
|
|
79
|
+
f"Unsupported Fortran type '{native_type}': add a mapping in ir.FORTRAN_TYPE_MAP "
|
|
80
|
+
"or exclude this symbol from nativegate.yaml."
|
|
81
|
+
)
|
|
82
|
+
return FORTRAN_TYPE_MAP[normalized]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class Parameter:
|
|
87
|
+
name: str
|
|
88
|
+
type: str
|
|
89
|
+
is_array: bool = False
|
|
90
|
+
intent: str = "in" # "in" | "out" | "inout" — Fortran calling convention
|
|
91
|
+
# The type as the native source spells it ("std::uint64_t", "Correlation",
|
|
92
|
+
# "type(pvt_state)"). `type` above is the *Python* mapping and is lossy by
|
|
93
|
+
# construction: uint64_t, int and an unscoped enum all collapse to "int".
|
|
94
|
+
# Generators that must emit native code — pybind11's `py::init<...>`, which
|
|
95
|
+
# cannot recover the type from a function pointer — need the real spelling
|
|
96
|
+
# or they guess, truncate a 64-bit id, and select the wrong overload.
|
|
97
|
+
native_type: str | None = None
|
|
98
|
+
# `const` in C++. A const data member bound with def_readwrite is a compile
|
|
99
|
+
# error inside pybind11's templates.
|
|
100
|
+
is_const: bool = False
|
|
101
|
+
# Fortran `optional` attribute. Without it an optional argument is
|
|
102
|
+
# generated as required and the caller cannot express "absent".
|
|
103
|
+
is_optional: bool = False
|
|
104
|
+
# For a C++ parameter bound from a bare `T*`: the name of the integer
|
|
105
|
+
# parameter of the same function that carries this array's length. The
|
|
106
|
+
# generated binding derives the length from the buffer the caller passed
|
|
107
|
+
# and supplies it positionally, so that argument disappears from the Python
|
|
108
|
+
# signature — a length the caller cannot state is a length that cannot
|
|
109
|
+
# disagree with the data.
|
|
110
|
+
length_param: str | None = None
|
|
111
|
+
# True when the callee may WRITE through this pointer (a non-const `T*`).
|
|
112
|
+
# The binding then has to refuse a read-only or wrong-dtype buffer rather
|
|
113
|
+
# than let pybind11 convert it, because a converted buffer is a temporary
|
|
114
|
+
# and the writes are discarded with it.
|
|
115
|
+
is_mutable_buffer: bool = False
|
|
116
|
+
# A C `T*` that is really ONE value passed by reference — the Fortran-
|
|
117
|
+
# linkage convention (`extern "C" void pvtini(double* api, ...)`, "EVERY
|
|
118
|
+
# argument is by reference"). The binding takes the value, passes its
|
|
119
|
+
# address, and, when the pointer is non-const, returns the final value —
|
|
120
|
+
# so an output scalar cannot be silently dropped. Set only under the
|
|
121
|
+
# guarded extern "C" heuristic in parsers/cpp_ast.py.
|
|
122
|
+
is_scalar_ref: bool = False
|
|
123
|
+
|
|
124
|
+
def cpp_spelling(self, fallback: str) -> str:
|
|
125
|
+
"""The C++ type to emit, preferring the recorded native spelling."""
|
|
126
|
+
return self.native_type or fallback
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass
|
|
130
|
+
class Method:
|
|
131
|
+
name: str
|
|
132
|
+
parameters: list[Parameter] = field(default_factory=list)
|
|
133
|
+
returns: str = "void"
|
|
134
|
+
is_static: bool = False
|
|
135
|
+
# `double f() const` — required to disambiguate an overload, because
|
|
136
|
+
# py::overload_cast needs py::const_ for the const member of a const/
|
|
137
|
+
# non-const pair.
|
|
138
|
+
is_const: bool = False
|
|
139
|
+
# Set when this class declares more than one method of this name. Taking
|
|
140
|
+
# `&Cls::name` is then ambiguous and does not compile; the binding has to
|
|
141
|
+
# go through py::overload_cast<...> with the argument types spelled out.
|
|
142
|
+
is_overloaded: bool = False
|
|
143
|
+
# True when the return is a sequence (std::vector<T>) rather than a scalar.
|
|
144
|
+
# Without this a `std::vector<double>` return is indistinguishable from a
|
|
145
|
+
# `double` in the IR, and the generated endpoint annotates it `float` while
|
|
146
|
+
# pybind11 hands back a list — a wrong type on every response.
|
|
147
|
+
returns_array: bool = False
|
|
148
|
+
# The C++ member to take the address of, when it differs from the name the
|
|
149
|
+
# binding is published under. An operator is bound as a Python special
|
|
150
|
+
# method — `operator+` becomes `__add__` — so the two names genuinely
|
|
151
|
+
# differ, and `&Cls::__add__` names nothing.
|
|
152
|
+
cpp_name: str | None = None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@dataclass
|
|
156
|
+
class ClassDef:
|
|
157
|
+
name: str
|
|
158
|
+
namespace: str | None = None
|
|
159
|
+
methods: list[Method] = field(default_factory=list)
|
|
160
|
+
has_default_constructor: bool = True
|
|
161
|
+
# Parameter lists of the public constructors that take arguments. A class
|
|
162
|
+
# whose only constructors take arguments has has_default_constructor=False
|
|
163
|
+
# and would otherwise be unconstructible from Python.
|
|
164
|
+
constructors: list[list[Parameter]] = field(default_factory=list)
|
|
165
|
+
# Public data members, bound read/write.
|
|
166
|
+
fields: list[Parameter] = field(default_factory=list)
|
|
167
|
+
# Public base classes, in declaration order, that are themselves bound by
|
|
168
|
+
# this service. pybind11 needs them named in the class_ declaration
|
|
169
|
+
# (`py::class_<Derived, Base>`) or a Python caller holding a Derived
|
|
170
|
+
# cannot call anything it inherits — and a C++ API that hands back a
|
|
171
|
+
# Base& gets a Python object with none of the derived methods.
|
|
172
|
+
bases: list[str] = field(default_factory=list)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@dataclass
|
|
176
|
+
class StructDef:
|
|
177
|
+
"""A plain data struct — no methods, just public fields.
|
|
178
|
+
|
|
179
|
+
Numerical APIs return these constantly (a PVT state, a solver result) and
|
|
180
|
+
without an IR node for them the methods that return one are unbindable.
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
name: str
|
|
184
|
+
namespace: str | None = None
|
|
185
|
+
fields: list[Parameter] = field(default_factory=list)
|
|
186
|
+
# False when the struct has a const or reference member, or otherwise
|
|
187
|
+
# declares no default constructor. Emitting py::init<>() for one is a
|
|
188
|
+
# compile error, and it was emitted unconditionally.
|
|
189
|
+
has_default_constructor: bool = True
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@dataclass
|
|
193
|
+
class SkippedSymbol:
|
|
194
|
+
"""A declaration the parser recognised but cannot bind, and why.
|
|
195
|
+
|
|
196
|
+
Reported rather than raised: one unsupported signature should not cost
|
|
197
|
+
you every other symbol in the same header.
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
name: str
|
|
201
|
+
reason: str
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@dataclass
|
|
205
|
+
class FunctionDef:
|
|
206
|
+
name: str
|
|
207
|
+
parameters: list[Parameter] = field(default_factory=list)
|
|
208
|
+
returns: str = "void"
|
|
209
|
+
is_subroutine: bool = False # Fortran subroutines have no return value; results come back via intent(out/inout)
|
|
210
|
+
# Enclosing C++ namespace. Classes and structs have always carried one;
|
|
211
|
+
# free functions did not, so `m.def("f", &f)` was emitted unqualified and
|
|
212
|
+
# any function outside the global namespace failed to compile with
|
|
213
|
+
# "use of undeclared identifier".
|
|
214
|
+
namespace: str | None = None
|
|
215
|
+
# Set when the translation unit declares more than one function of this
|
|
216
|
+
# name — same ambiguity as Method.is_overloaded.
|
|
217
|
+
is_overloaded: bool = False
|
|
218
|
+
# True when the return is a sequence (std::vector<T>) rather than a scalar.
|
|
219
|
+
# Without this a `std::vector<double>` return is indistinguishable from a
|
|
220
|
+
# `double` in the IR, and the generated endpoint annotates it `float` while
|
|
221
|
+
# pybind11 hands back a list — a wrong type on every response.
|
|
222
|
+
returns_array: bool = False
|
|
223
|
+
# The native symbol to import when it differs from the published name —
|
|
224
|
+
# same meaning as Method.cpp_name. A Fortran routine taking a derived type
|
|
225
|
+
# is published under its own name but reached through a generated
|
|
226
|
+
# flattening shim (`density` -> `density_n2p`), because f2py cannot pass
|
|
227
|
+
# the type itself.
|
|
228
|
+
cpp_name: str | None = None
|
|
229
|
+
# Enclosing `module X ... end module X`, if any. f2py nests those routines
|
|
230
|
+
# under `<ext>.X.<name>` while bare F77 routines land at the top level, so
|
|
231
|
+
# this has to be tracked per routine: a modern F90 facade sitting on top of
|
|
232
|
+
# fixed-form decks legitimately has both in one service.
|
|
233
|
+
fortran_module: str | None = None
|
|
234
|
+
|
|
235
|
+
# Documentation attached to this declaration in its OWN source: a Doxygen
|
|
236
|
+
# or `//`/`/* */` block above a C++ declaration, the comment header above a
|
|
237
|
+
# Fortran routine. None means the parser found none; "" is not used.
|
|
238
|
+
#
|
|
239
|
+
# Carried because the generated FastAPI endpoint's docstring becomes the
|
|
240
|
+
# route's OpenAPI `description`, which is in turn the MCP tool description
|
|
241
|
+
# handed to a model before it calls native code (generators/mcp_gen.py).
|
|
242
|
+
# That made this the difference between a model calling a routine correctly
|
|
243
|
+
# and guessing, so it stopped being cosmetic. Verbatim from the source and
|
|
244
|
+
# never synthesised: a wrong description is worse than none, and legacy
|
|
245
|
+
# headers do contain stale comments — surfacing them unchanged at least
|
|
246
|
+
# keeps the staleness attributable to the source rather than to nativegate.
|
|
247
|
+
doc: str | None = None
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@dataclass
|
|
251
|
+
class ModuleIR:
|
|
252
|
+
"""Everything exposed from one source file, in language-neutral form."""
|
|
253
|
+
|
|
254
|
+
name: str
|
|
255
|
+
language: str # "cpp" | "fortran"
|
|
256
|
+
source_file: str
|
|
257
|
+
classes: list[ClassDef] = field(default_factory=list)
|
|
258
|
+
functions: list[FunctionDef] = field(default_factory=list)
|
|
259
|
+
structs: list[StructDef] = field(default_factory=list)
|
|
260
|
+
fortran_module: str | None = None # name of the enclosing `module X` block, if any (f2py nests under it)
|
|
261
|
+
# Declarations recognised but not bindable, with the reason. Surfaced by
|
|
262
|
+
# the CLI so "nothing was generated" is never silent.
|
|
263
|
+
skipped: list[SkippedSymbol] = field(default_factory=list)
|
|
264
|
+
# Generated Fortran shim subroutines, one per routine that takes a derived
|
|
265
|
+
# type. The CLI appends them to the module's `_expanded` copy before the
|
|
266
|
+
# f2py build; kept in the IR so ir.json records exactly what extra native
|
|
267
|
+
# code the service compiled.
|
|
268
|
+
fortran_shims: list[str] = field(default_factory=list)
|
|
269
|
+
# Compiler errors from the parse itself ("'foo.hpp' file not found"), when
|
|
270
|
+
# the parser is a real compiler front end. A header that doesn't compile
|
|
271
|
+
# still yields a partial AST, and binding half a header silently is worse
|
|
272
|
+
# than saying so. Always empty for the regex-based parsers.
|
|
273
|
+
diagnostics: list[str] = field(default_factory=list)
|
|
274
|
+
|
|
275
|
+
def is_empty(self) -> bool:
|
|
276
|
+
return not self.classes and not self.functions and not self.structs
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
# --- Python identifier safety -------------------------------------------
|
|
280
|
+
#
|
|
281
|
+
# Native names become Python identifiers in the generated package, the router
|
|
282
|
+
# and the smoke tests. Neither source language reserves Python's keywords, and
|
|
283
|
+
# `lambda` in particular is near-universal in engineering C++ — so a perfectly
|
|
284
|
+
# ordinary header produced `def attenuate(lambda: float)`, a SyntaxError that
|
|
285
|
+
# surfaced when the container started rather than when the code was generated.
|
|
286
|
+
#
|
|
287
|
+
# Note this is NOT solved by generating through `ast`: ast.unparse happily
|
|
288
|
+
# emits `lambda` for ast.Name(id="lambda"). It has to be an explicit check.
|
|
289
|
+
|
|
290
|
+
import keyword
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def python_identifier(name: str) -> str:
|
|
294
|
+
"""A safe Python spelling of `name`, per PEP 8's trailing underscore.
|
|
295
|
+
|
|
296
|
+
Only the Python-side name changes. Callers keep the original for the native
|
|
297
|
+
symbol lookup and for the HTTP route, so the wire contract is unaffected.
|
|
298
|
+
"""
|
|
299
|
+
# Only TRUE keywords. Soft keywords (`match`, `case`, `type`, `_`) are not
|
|
300
|
+
# reserved — `def f(match: float)` is valid Python — so escaping them buys
|
|
301
|
+
# nothing and costs something real: the escaped name becomes the FastAPI
|
|
302
|
+
# request-body field, so a Fortran argument named `match` in a service that
|
|
303
|
+
# already worked would silently have its wire field renamed to `match_` on
|
|
304
|
+
# the next regenerate. True keywords carry no such risk, because a service
|
|
305
|
+
# exposing one never generated importable Python in the first place.
|
|
306
|
+
if keyword.iskeyword(name):
|
|
307
|
+
return f"{name}_"
|
|
308
|
+
return name
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def is_valid_python_name(name: str) -> bool:
|
|
312
|
+
return bool(name) and name.isidentifier() and not keyword.iskeyword(name)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
@dataclass
|
|
316
|
+
class Problem:
|
|
317
|
+
"""A reason this IR cannot be turned into a working service."""
|
|
318
|
+
|
|
319
|
+
symbol: str
|
|
320
|
+
message: str
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def validate(module: "ModuleIR") -> list[Problem]:
|
|
324
|
+
"""Structural problems that would produce broken generated code.
|
|
325
|
+
|
|
326
|
+
Run by the CLI after parsing and before generating, so a name that cannot
|
|
327
|
+
be spelled in Python fails the build instead of the container.
|
|
328
|
+
"""
|
|
329
|
+
problems: list[Problem] = []
|
|
330
|
+
|
|
331
|
+
def check_params(owner: str, params: list[Parameter]) -> None:
|
|
332
|
+
for param in params:
|
|
333
|
+
# Escape first, then check — the generator emits the escaped
|
|
334
|
+
# spelling, so a parameter named `lambda` is fine and one named
|
|
335
|
+
# `2x` is not. Checking `param.name.isidentifier()` directly is
|
|
336
|
+
# the trap here: str.isidentifier() returns True for every Python
|
|
337
|
+
# keyword ("lambda".isidentifier() is True), so it passes exactly
|
|
338
|
+
# the names that produce a SyntaxError downstream.
|
|
339
|
+
if not is_valid_python_name(python_identifier(param.name)):
|
|
340
|
+
problems.append(
|
|
341
|
+
Problem(
|
|
342
|
+
f"{owner}({param.name})",
|
|
343
|
+
f"parameter name '{param.name}' cannot be spelled as a "
|
|
344
|
+
"Python identifier, even after escaping; exclude this "
|
|
345
|
+
"symbol in nativegate.yaml.",
|
|
346
|
+
)
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
exported: dict[str, tuple[str, str]] = {}
|
|
350
|
+
for kind, items in (
|
|
351
|
+
("struct", module.structs),
|
|
352
|
+
("class", module.classes),
|
|
353
|
+
("function", module.functions),
|
|
354
|
+
):
|
|
355
|
+
for item in items:
|
|
356
|
+
if not is_valid_python_name(python_identifier(item.name)):
|
|
357
|
+
problems.append(
|
|
358
|
+
Problem(
|
|
359
|
+
item.name,
|
|
360
|
+
f"{kind} name '{item.name}' cannot be spelled as a Python "
|
|
361
|
+
"identifier; exclude it in nativegate.yaml.",
|
|
362
|
+
)
|
|
363
|
+
)
|
|
364
|
+
# Keyed on the ESCAPED spelling, not the native one: the
|
|
365
|
+
# generators emit python_identifier(name), so a class `lambda` and
|
|
366
|
+
# a struct `lambda_` both land on `lambda_` in the generated
|
|
367
|
+
# package and one silently shadows the other. Comparing raw names
|
|
368
|
+
# would see two distinct keys and miss it.
|
|
369
|
+
emitted = python_identifier(item.name)
|
|
370
|
+
previous = exported.get(emitted)
|
|
371
|
+
if previous is not None:
|
|
372
|
+
previous_kind, previous_name = previous
|
|
373
|
+
detail = (
|
|
374
|
+
f"'{item.name}' and '{previous_name}' both become '{emitted}'"
|
|
375
|
+
if previous_name != item.name
|
|
376
|
+
else f"'{item.name}'"
|
|
377
|
+
)
|
|
378
|
+
problems.append(
|
|
379
|
+
Problem(
|
|
380
|
+
item.name,
|
|
381
|
+
f"{kind} {detail} collides with a {previous_kind} in the "
|
|
382
|
+
"generated package namespace; one would silently shadow "
|
|
383
|
+
"the other.",
|
|
384
|
+
)
|
|
385
|
+
)
|
|
386
|
+
exported[emitted] = (kind, item.name)
|
|
387
|
+
|
|
388
|
+
for fn in module.functions:
|
|
389
|
+
check_params(fn.name, fn.parameters)
|
|
390
|
+
for cls in module.classes:
|
|
391
|
+
for method in cls.methods:
|
|
392
|
+
check_params(f"{cls.name}.{method.name}", method.parameters)
|
|
393
|
+
|
|
394
|
+
return problems
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
# --- serialisation ------------------------------------------------------
|
|
398
|
+
#
|
|
399
|
+
# `generate` writes the IR next to the service so later tooling can answer
|
|
400
|
+
# "what does this service expose?" without re-parsing the native sources —
|
|
401
|
+
# which would need the compiler, the include paths and the same libclang all
|
|
402
|
+
# over again, on a machine that may only have the built wheel.
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
# --- schema version -----------------------------------------------------
|
|
407
|
+
#
|
|
408
|
+
# `ir.json` is a *committed artifact*: services/<name>/.nativegate/ir.json is
|
|
409
|
+
# checked in, is read by `golden record` on machines that have neither the
|
|
410
|
+
# headers nor libclang, and outlives the nativegate that wrote it. Without a
|
|
411
|
+
# version stamp, a document written by a future nativegate deserialises into a
|
|
412
|
+
# subtly different ModuleIR — every field this reader has never heard of takes
|
|
413
|
+
# its "unknown, behave as before" default and nothing says so.
|
|
414
|
+
#
|
|
415
|
+
# The version is MAJOR.MINOR and describes the *document*, not the tool:
|
|
416
|
+
#
|
|
417
|
+
# MAJOR changes when the meaning of existing fields changes, or a field is
|
|
418
|
+
# removed or renamed. A reader cannot honestly interpret a document
|
|
419
|
+
# whose major it does not know, so it refuses it.
|
|
420
|
+
# MINOR changes when fields are added. Additive-only, so:
|
|
421
|
+
# - an OLDER minor loads: the fields it lacks take the documented
|
|
422
|
+
# defaults below (this is exactly what a version-less pre-1.0
|
|
423
|
+
# file gets, and why the already-committed ir.json keeps working);
|
|
424
|
+
# - a NEWER minor loads too, but the keys this reader does not know
|
|
425
|
+
# are reported rather than dropped in silence.
|
|
426
|
+
#
|
|
427
|
+
# Unknown keys at a version we *do* fully understand are an error, not a
|
|
428
|
+
# forward-compatibility question: they mean the document is corrupt or
|
|
429
|
+
# hand-edited, and quietly ignoring them is how a typo'd "is_cosnt: true"
|
|
430
|
+
# becomes a wrong binding.
|
|
431
|
+
SCHEMA_VERSION_MAJOR = 1
|
|
432
|
+
SCHEMA_VERSION_MINOR = 6
|
|
433
|
+
SCHEMA_VERSION = f"{SCHEMA_VERSION_MAJOR}.{SCHEMA_VERSION_MINOR}"
|
|
434
|
+
|
|
435
|
+
# What a document with no `schema_version` at all is treated as: everything
|
|
436
|
+
# nativegate wrote before versioning existed is, by definition, 1.0 minus the
|
|
437
|
+
# stamp.
|
|
438
|
+
LEGACY_SCHEMA_VERSION = "1.0"
|
|
439
|
+
|
|
440
|
+
SCHEMA_VERSION_KEY = "schema_version"
|
|
441
|
+
WRITER_VERSION_KEY = "nativegate_version"
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
class IRSchemaError(ValueError):
|
|
445
|
+
"""Raised when an ir.json cannot be honestly read by this nativegate."""
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
class IRSchemaWarning(UserWarning):
|
|
449
|
+
"""Warned when an ir.json carries fields this nativegate does not know."""
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def nativegate_version() -> str:
|
|
453
|
+
"""The installed nativegate version, recorded in every ir.json we write."""
|
|
454
|
+
try:
|
|
455
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
456
|
+
|
|
457
|
+
return version("nativegate")
|
|
458
|
+
except Exception: # pragma: no cover - metadata is present in any install
|
|
459
|
+
return "unknown"
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def parse_schema_version(raw: object) -> tuple[int, int]:
|
|
463
|
+
if raw is None:
|
|
464
|
+
raw = LEGACY_SCHEMA_VERSION
|
|
465
|
+
text = str(raw).strip()
|
|
466
|
+
match = re.fullmatch(r"(\d+)\.(\d+)", text)
|
|
467
|
+
if not match:
|
|
468
|
+
raise IRSchemaError(
|
|
469
|
+
f"Unreadable {SCHEMA_VERSION_KEY} {raw!r} in this ir.json: expected "
|
|
470
|
+
f'"MAJOR.MINOR" (this nativegate writes "{SCHEMA_VERSION}"). '
|
|
471
|
+
"Regenerate the service with `ngate generate <name>`."
|
|
472
|
+
)
|
|
473
|
+
return int(match.group(1)), int(match.group(2))
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _check_version(data: dict) -> tuple[int, int]:
|
|
477
|
+
major, minor = parse_schema_version(data.get(SCHEMA_VERSION_KEY))
|
|
478
|
+
if major != SCHEMA_VERSION_MAJOR:
|
|
479
|
+
writer = data.get(WRITER_VERSION_KEY)
|
|
480
|
+
written_by = (
|
|
481
|
+
f"nativegate {writer}" if writer else "an unknown nativegate version"
|
|
482
|
+
)
|
|
483
|
+
direction = "a newer" if major > SCHEMA_VERSION_MAJOR else "an older"
|
|
484
|
+
raise IRSchemaError(
|
|
485
|
+
f"This ir.json uses IR schema {major}.{minor}, written by {written_by}; "
|
|
486
|
+
f"nativegate {nativegate_version()} reads schema {SCHEMA_VERSION} and "
|
|
487
|
+
f"cannot interpret {direction} major version — the meaning of the "
|
|
488
|
+
"fields it contains has changed, so loading it would silently produce "
|
|
489
|
+
"a different API than the one that was recorded.\n"
|
|
490
|
+
+ (
|
|
491
|
+
f"Upgrade nativegate (>= {writer}), or regenerate this service "
|
|
492
|
+
"from source with `ngate generate <name>`."
|
|
493
|
+
if major > SCHEMA_VERSION_MAJOR
|
|
494
|
+
else "Regenerate this service from source with "
|
|
495
|
+
"`ngate generate <name>`."
|
|
496
|
+
)
|
|
497
|
+
)
|
|
498
|
+
return major, minor
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
# Every key each node type understands at SCHEMA_VERSION. Kept beside the
|
|
502
|
+
# dataclasses deliberately: adding a field without listing it here makes the
|
|
503
|
+
# round-trip test fail, which is the reminder to bump SCHEMA_VERSION_MINOR.
|
|
504
|
+
_PARAMETER_KEYS = frozenset(
|
|
505
|
+
{
|
|
506
|
+
"name",
|
|
507
|
+
"type",
|
|
508
|
+
"is_array",
|
|
509
|
+
"intent",
|
|
510
|
+
"native_type",
|
|
511
|
+
"is_const",
|
|
512
|
+
"is_optional",
|
|
513
|
+
"length_param",
|
|
514
|
+
"is_mutable_buffer",
|
|
515
|
+
"is_scalar_ref",
|
|
516
|
+
}
|
|
517
|
+
)
|
|
518
|
+
_METHOD_KEYS = frozenset(
|
|
519
|
+
{
|
|
520
|
+
"name",
|
|
521
|
+
"parameters",
|
|
522
|
+
"returns",
|
|
523
|
+
"is_static",
|
|
524
|
+
"is_const",
|
|
525
|
+
"is_overloaded",
|
|
526
|
+
"returns_array",
|
|
527
|
+
"cpp_name",
|
|
528
|
+
}
|
|
529
|
+
)
|
|
530
|
+
_CLASS_KEYS = frozenset(
|
|
531
|
+
{
|
|
532
|
+
"name",
|
|
533
|
+
"namespace",
|
|
534
|
+
"methods",
|
|
535
|
+
"has_default_constructor",
|
|
536
|
+
"constructors",
|
|
537
|
+
"fields",
|
|
538
|
+
"bases",
|
|
539
|
+
}
|
|
540
|
+
)
|
|
541
|
+
_STRUCT_KEYS = frozenset({"name", "namespace", "fields", "has_default_constructor"})
|
|
542
|
+
_FUNCTION_KEYS = frozenset(
|
|
543
|
+
{
|
|
544
|
+
"name",
|
|
545
|
+
"parameters",
|
|
546
|
+
"returns",
|
|
547
|
+
"is_subroutine",
|
|
548
|
+
"namespace",
|
|
549
|
+
"is_overloaded",
|
|
550
|
+
"returns_array",
|
|
551
|
+
"cpp_name",
|
|
552
|
+
"fortran_module",
|
|
553
|
+
"doc",
|
|
554
|
+
}
|
|
555
|
+
)
|
|
556
|
+
_SKIPPED_KEYS = frozenset({"name", "reason"})
|
|
557
|
+
_MODULE_KEYS = frozenset(
|
|
558
|
+
{
|
|
559
|
+
"name",
|
|
560
|
+
"language",
|
|
561
|
+
"source_file",
|
|
562
|
+
"classes",
|
|
563
|
+
"functions",
|
|
564
|
+
"structs",
|
|
565
|
+
"fortran_module",
|
|
566
|
+
"skipped",
|
|
567
|
+
"fortran_shims",
|
|
568
|
+
"diagnostics",
|
|
569
|
+
SCHEMA_VERSION_KEY,
|
|
570
|
+
WRITER_VERSION_KEY,
|
|
571
|
+
}
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def module_to_dict(module: ModuleIR) -> dict:
|
|
576
|
+
"""Serialise, stamping the schema version and the writer that produced it."""
|
|
577
|
+
data = {
|
|
578
|
+
SCHEMA_VERSION_KEY: SCHEMA_VERSION,
|
|
579
|
+
WRITER_VERSION_KEY: nativegate_version(),
|
|
580
|
+
}
|
|
581
|
+
data.update(asdict(module))
|
|
582
|
+
return data
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def module_from_dict(data: dict) -> ModuleIR:
|
|
586
|
+
major, minor = _check_version(data)
|
|
587
|
+
# Only a document at or below the minor we implement can have its unknown
|
|
588
|
+
# keys called corrupt; above it, an unknown key is just a field added by a
|
|
589
|
+
# later nativegate, so it is reported and skipped rather than fatal.
|
|
590
|
+
strict = minor <= SCHEMA_VERSION_MINOR
|
|
591
|
+
ignored: set[str] = set()
|
|
592
|
+
|
|
593
|
+
def check_keys(item: dict, allowed: frozenset[str], where: str) -> dict:
|
|
594
|
+
unknown = sorted(set(item) - allowed)
|
|
595
|
+
if not unknown:
|
|
596
|
+
return item
|
|
597
|
+
if strict:
|
|
598
|
+
raise IRSchemaError(
|
|
599
|
+
f"Unknown key(s) {', '.join(repr(k) for k in unknown)} in {where} of "
|
|
600
|
+
f"this ir.json, which declares IR schema {major}.{minor} — a version "
|
|
601
|
+
"this nativegate fully implements, so these fields are not from a "
|
|
602
|
+
"newer writer. The file is corrupt or hand-edited; regenerate it "
|
|
603
|
+
"with `ngate generate <name>`."
|
|
604
|
+
)
|
|
605
|
+
ignored.update(f"{where}.{key}" for key in unknown)
|
|
606
|
+
return item
|
|
607
|
+
|
|
608
|
+
def parameters(items) -> list[Parameter]:
|
|
609
|
+
# Explicit rather than Parameter(**item): an ir.json written by an
|
|
610
|
+
# older nativegate has none of the fields added since, and **item would
|
|
611
|
+
# also crash on a field removed later. Defaults here are the
|
|
612
|
+
# "unknown, behave as before" values.
|
|
613
|
+
return [
|
|
614
|
+
Parameter(
|
|
615
|
+
name=check_keys(item, _PARAMETER_KEYS, "a parameter")["name"],
|
|
616
|
+
type=item["type"],
|
|
617
|
+
is_array=item.get("is_array", False),
|
|
618
|
+
length_param=item.get("length_param"),
|
|
619
|
+
is_mutable_buffer=item.get("is_mutable_buffer", False),
|
|
620
|
+
is_scalar_ref=item.get("is_scalar_ref", False),
|
|
621
|
+
intent=item.get("intent", "in"),
|
|
622
|
+
native_type=item.get("native_type"),
|
|
623
|
+
is_const=item.get("is_const", False),
|
|
624
|
+
is_optional=item.get("is_optional", False),
|
|
625
|
+
)
|
|
626
|
+
for item in items
|
|
627
|
+
]
|
|
628
|
+
|
|
629
|
+
check_keys(data, _MODULE_KEYS, "the module")
|
|
630
|
+
module = ModuleIR(
|
|
631
|
+
name=data["name"],
|
|
632
|
+
language=data["language"],
|
|
633
|
+
source_file=data["source_file"],
|
|
634
|
+
classes=[
|
|
635
|
+
ClassDef(
|
|
636
|
+
name=check_keys(cls, _CLASS_KEYS, f"class {cls.get('name')!r}")["name"],
|
|
637
|
+
namespace=cls.get("namespace"),
|
|
638
|
+
methods=[
|
|
639
|
+
Method(
|
|
640
|
+
name=check_keys(m, _METHOD_KEYS, f"method {m.get('name')!r}")["name"],
|
|
641
|
+
parameters=parameters(m.get("parameters", [])),
|
|
642
|
+
returns=m.get("returns", "void"),
|
|
643
|
+
is_static=m.get("is_static", False),
|
|
644
|
+
is_const=m.get("is_const", False),
|
|
645
|
+
is_overloaded=m.get("is_overloaded", False),
|
|
646
|
+
returns_array=m.get("returns_array", False),
|
|
647
|
+
cpp_name=m.get("cpp_name"),
|
|
648
|
+
)
|
|
649
|
+
for m in cls.get("methods", [])
|
|
650
|
+
],
|
|
651
|
+
has_default_constructor=cls.get("has_default_constructor", True),
|
|
652
|
+
constructors=[parameters(c) for c in cls.get("constructors", [])],
|
|
653
|
+
fields=parameters(cls.get("fields", [])),
|
|
654
|
+
bases=list(cls.get("bases", [])),
|
|
655
|
+
)
|
|
656
|
+
for cls in data.get("classes", [])
|
|
657
|
+
],
|
|
658
|
+
functions=[
|
|
659
|
+
FunctionDef(
|
|
660
|
+
name=check_keys(fn, _FUNCTION_KEYS, f"function {fn.get('name')!r}")["name"],
|
|
661
|
+
parameters=parameters(fn.get("parameters", [])),
|
|
662
|
+
returns=fn.get("returns", "void"),
|
|
663
|
+
is_subroutine=fn.get("is_subroutine", False),
|
|
664
|
+
fortran_module=fn.get("fortran_module"),
|
|
665
|
+
namespace=fn.get("namespace"),
|
|
666
|
+
is_overloaded=fn.get("is_overloaded", False),
|
|
667
|
+
returns_array=fn.get("returns_array", False),
|
|
668
|
+
cpp_name=fn.get("cpp_name"),
|
|
669
|
+
doc=fn.get("doc"),
|
|
670
|
+
)
|
|
671
|
+
for fn in data.get("functions", [])
|
|
672
|
+
],
|
|
673
|
+
structs=[
|
|
674
|
+
StructDef(
|
|
675
|
+
name=check_keys(struct, _STRUCT_KEYS, f"struct {struct.get('name')!r}")["name"],
|
|
676
|
+
namespace=struct.get("namespace"),
|
|
677
|
+
fields=parameters(struct.get("fields", [])),
|
|
678
|
+
has_default_constructor=struct.get("has_default_constructor", True),
|
|
679
|
+
)
|
|
680
|
+
for struct in data.get("structs", [])
|
|
681
|
+
],
|
|
682
|
+
fortran_module=data.get("fortran_module"),
|
|
683
|
+
fortran_shims=list(data.get("fortran_shims", [])),
|
|
684
|
+
skipped=[
|
|
685
|
+
SkippedSymbol(**check_keys(item, _SKIPPED_KEYS, "a skipped symbol"))
|
|
686
|
+
for item in data.get("skipped", [])
|
|
687
|
+
],
|
|
688
|
+
diagnostics=list(data.get("diagnostics", [])),
|
|
689
|
+
)
|
|
690
|
+
|
|
691
|
+
if ignored:
|
|
692
|
+
# A newer minor: additive by contract, so the module above is still a
|
|
693
|
+
# faithful subset — but say which fields were dropped, because "the
|
|
694
|
+
# binding lost an attribute" is otherwise indistinguishable from "the
|
|
695
|
+
# parser never found it".
|
|
696
|
+
writer = data.get(WRITER_VERSION_KEY) or "an unknown nativegate version"
|
|
697
|
+
warnings.warn(
|
|
698
|
+
f"This ir.json declares IR schema {major}.{minor} (written by {writer}); "
|
|
699
|
+
f"nativegate {nativegate_version()} implements {SCHEMA_VERSION} and ignored "
|
|
700
|
+
f"{len(ignored)} unknown field(s): {', '.join(sorted(ignored))}. "
|
|
701
|
+
"Upgrade nativegate, or regenerate the service, if any of them matter.",
|
|
702
|
+
IRSchemaWarning,
|
|
703
|
+
stacklevel=2,
|
|
704
|
+
)
|
|
705
|
+
return module
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
# --- inferring which integer argument is an array's length ----------------
|
|
709
|
+
#
|
|
710
|
+
# Shared by the Fortran router (which validates `n == len(props)` before the
|
|
711
|
+
# call) and the C++ parser (which needs the length to bind a bare `double*`).
|
|
712
|
+
# One implementation on purpose: two heuristics that drift would mean the same
|
|
713
|
+
# signature is read differently depending on which language it came from.
|
|
714
|
+
|
|
715
|
+
_SIZE_PREFIXES = ("n", "n_", "num", "num_", "len", "len_", "size", "size_", "count", "count_")
|
|
716
|
+
_SIZE_SUFFIXES = ("_n", "_len", "_size", "_count", "_dim", "_num")
|
|
717
|
+
|
|
718
|
+
# Bare integer names that mean "a length" in Fortran by near-universal
|
|
719
|
+
# convention. The fallback below pairs one of these with a lone array; an
|
|
720
|
+
# integer named anything else is left alone, because `PRZFAC(IPHASE, X)` takes
|
|
721
|
+
# a *phase selector* and an array, and pairing those rejects every valid call.
|
|
722
|
+
_SIZE_WORDS = frozenset(
|
|
723
|
+
{"n", "m", "num", "len", "length", "size", "count", "npts", "ndim", "nelem"}
|
|
724
|
+
)
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def names_a_size_of(int_name: str, array_name: str) -> bool:
|
|
728
|
+
int_name, array_name = int_name.lower(), array_name.lower()
|
|
729
|
+
for prefix in _SIZE_PREFIXES:
|
|
730
|
+
if int_name == f"{prefix}{array_name}":
|
|
731
|
+
return True
|
|
732
|
+
for suffix in _SIZE_SUFFIXES:
|
|
733
|
+
if int_name == f"{array_name}{suffix}":
|
|
734
|
+
return True
|
|
735
|
+
return False
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def size_pairings(inputs: list) -> list[tuple[str, str]]:
|
|
739
|
+
"""(integer parameter, array parameter) pairs where the integer is the length.
|
|
740
|
+
|
|
741
|
+
A Fortran routine takes its extents explicitly — `PVTSTATE(P, PROPS, N)` —
|
|
742
|
+
and f2py passes whatever `n` the request supplied straight through. When
|
|
743
|
+
`n > len(props)` the routine indexes past the end of the buffer: an
|
|
744
|
+
out-of-bounds read or write, not merely a wrong answer.
|
|
745
|
+
|
|
746
|
+
The IR has no link between a size argument and the array it sizes, so the
|
|
747
|
+
pairing is inferred, and only where it is unambiguous:
|
|
748
|
+
|
|
749
|
+
1. by name — `n`, `nprops`, `n_props`, `num_props`, `len_props`,
|
|
750
|
+
`props_len`, ... matched case-insensitively against exactly one array;
|
|
751
|
+
2. failing that, structurally — exactly one array input and exactly one
|
|
752
|
+
integer whose bare name is a conventional length (`N`, `LEN`, `SIZE`,
|
|
753
|
+
...). Both halves are required. "One array, one integer" alone is not
|
|
754
|
+
enough: `PRZFAC(IPHASE, X)` takes a phase selector and an array, and
|
|
755
|
+
pairing those would reject every valid call to it.
|
|
756
|
+
|
|
757
|
+
Anything else — several arrays sharing one `N`, two integers with unhelpful
|
|
758
|
+
names, an integer matching two arrays — yields NO pairing. A false pairing
|
|
759
|
+
rejects valid calls; a missed one leaves that endpoint exactly as unsafe as
|
|
760
|
+
it was before.
|
|
761
|
+
"""
|
|
762
|
+
arrays = [p for p in inputs if p.is_array]
|
|
763
|
+
integers = [p for p in inputs if not p.is_array and p.type == "int"]
|
|
764
|
+
if not arrays or not integers:
|
|
765
|
+
return []
|
|
766
|
+
|
|
767
|
+
by_int: dict[str, list[str]] = {}
|
|
768
|
+
for i in integers:
|
|
769
|
+
matches = [a.name for a in arrays if names_a_size_of(i.name, a.name)]
|
|
770
|
+
if matches:
|
|
771
|
+
by_int[i.name] = matches
|
|
772
|
+
|
|
773
|
+
named = [
|
|
774
|
+
(int_name, matches[0])
|
|
775
|
+
for int_name, matches in by_int.items()
|
|
776
|
+
if len(matches) == 1
|
|
777
|
+
]
|
|
778
|
+
# An array claimed by two integers is ambiguous in the other direction.
|
|
779
|
+
claimed: dict[str, int] = {}
|
|
780
|
+
for _, array_name in named:
|
|
781
|
+
claimed[array_name] = claimed.get(array_name, 0) + 1
|
|
782
|
+
named = [pair for pair in named if claimed[pair[1]] == 1]
|
|
783
|
+
if named:
|
|
784
|
+
return named
|
|
785
|
+
|
|
786
|
+
size_named = [i for i in integers if i.name.lower() in _SIZE_WORDS]
|
|
787
|
+
if len(arrays) == 1 and len(size_named) == 1:
|
|
788
|
+
return [(size_named[0].name, arrays[0].name)]
|
|
789
|
+
return []
|