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.
Files changed (50) hide show
  1. nativegate/__init__.py +1 -0
  2. nativegate/__main__.py +4 -0
  3. nativegate/buildinfo.py +344 -0
  4. nativegate/cli.py +2007 -0
  5. nativegate/config.py +991 -0
  6. nativegate/declared_invariants.py +565 -0
  7. nativegate/discovery.py +167 -0
  8. nativegate/driverbuild.py +626 -0
  9. nativegate/drivers/__init__.py +5 -0
  10. nativegate/drivers/cpp.py +616 -0
  11. nativegate/drivers/fortran.py +507 -0
  12. nativegate/generators/__init__.py +0 -0
  13. nativegate/generators/cmake_gen.py +101 -0
  14. nativegate/generators/docker_gen.py +614 -0
  15. nativegate/generators/error_gen.py +104 -0
  16. nativegate/generators/f2py_gen.py +91 -0
  17. nativegate/generators/gateway_gen.py +110 -0
  18. nativegate/generators/golden_gen.py +50 -0
  19. nativegate/generators/k8s_gen.py +212 -0
  20. nativegate/generators/mcp_gen.py +281 -0
  21. nativegate/generators/middleware_gen.py +717 -0
  22. nativegate/generators/pybind_gen.py +406 -0
  23. nativegate/generators/pyproject_gen.py +61 -0
  24. nativegate/generators/python_pkg_gen.py +1164 -0
  25. nativegate/generators/test_gen.py +160 -0
  26. nativegate/golden.py +747 -0
  27. nativegate/invariants.py +532 -0
  28. nativegate/ir.py +789 -0
  29. nativegate/lattice.py +350 -0
  30. nativegate/locking.py +216 -0
  31. nativegate/oracle.py +904 -0
  32. nativegate/parsers/__init__.py +0 -0
  33. nativegate/parsers/cpp.py +105 -0
  34. nativegate/parsers/cpp_ast.py +1652 -0
  35. nativegate/parsers/cpp_regex.py +812 -0
  36. nativegate/parsers/fixed_form.py +868 -0
  37. nativegate/parsers/fortran.py +157 -0
  38. nativegate/parsers/fortran_fparser.py +1116 -0
  39. nativegate/parsers/fortran_regex.py +686 -0
  40. nativegate/preprocess.py +335 -0
  41. nativegate/structural_invariants.py +762 -0
  42. nativegate/suggest.py +208 -0
  43. nativegate/templates/__init__.py +20 -0
  44. nativegate/templates/golden_test_template.py +248 -0
  45. nativegate/wire.py +438 -0
  46. nativegate-0.1.0.dist-info/METADATA +547 -0
  47. nativegate-0.1.0.dist-info/RECORD +50 -0
  48. nativegate-0.1.0.dist-info/WHEEL +5 -0
  49. nativegate-0.1.0.dist-info/entry_points.txt +3 -0
  50. 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 []