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,1164 @@
|
|
|
1
|
+
"""Generated Python package + FastAPI service layer (design.md sections 10, 12)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
|
|
7
|
+
from ..ir import (
|
|
8
|
+
FunctionDef,
|
|
9
|
+
ModuleIR,
|
|
10
|
+
Parameter,
|
|
11
|
+
python_identifier,
|
|
12
|
+
size_pairings as _size_pairings,
|
|
13
|
+
)
|
|
14
|
+
from . import mcp_gen
|
|
15
|
+
from .error_gen import ERROR_HANDLER_IMPORTS, generate_error_handler
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _py(name: str) -> str:
|
|
19
|
+
"""The Python spelling of a native name (B4).
|
|
20
|
+
|
|
21
|
+
Only the *Python* identifier changes. The HTTP route and the attribute
|
|
22
|
+
looked up on the native extension keep the original spelling, so escaping
|
|
23
|
+
a name never moves the wire contract or breaks the symbol lookup.
|
|
24
|
+
"""
|
|
25
|
+
return python_identifier(name)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _attr(target: str, native_name: str) -> str:
|
|
29
|
+
"""`target.native_name`, falling back to getattr for a Python keyword.
|
|
30
|
+
|
|
31
|
+
`native.lambda` is a SyntaxError even though the pybind11/f2py attribute is
|
|
32
|
+
genuinely called `lambda`, so the *access* has to be spelled differently
|
|
33
|
+
while still naming the original attribute.
|
|
34
|
+
"""
|
|
35
|
+
if _py(native_name) == native_name:
|
|
36
|
+
return f"{target}.{native_name}"
|
|
37
|
+
return f'getattr({target}, "{native_name}")'
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _EndpointNames:
|
|
41
|
+
"""Unique (route, function name) pairs for the endpoints of one router (A5).
|
|
42
|
+
|
|
43
|
+
C++ overloads share a name, so two `viscosity` overloads previously emitted
|
|
44
|
+
two `def viscosity(...)` and two `@router.post("/viscosity")` in one module:
|
|
45
|
+
the second def shadowed the first and FastAPI dispatched the first route, so
|
|
46
|
+
one overload was unreachable with no warning.
|
|
47
|
+
|
|
48
|
+
Chosen fix: keep one endpoint per overload and disambiguate the later ones
|
|
49
|
+
with a numeric suffix (`/viscosity_2`, `def viscosity_2`). Merging the
|
|
50
|
+
overloads into a single endpoint with optional parameters was the
|
|
51
|
+
alternative, but it cannot express two overloads that differ only in
|
|
52
|
+
argument *type*, and it would silently pick an overload at request time
|
|
53
|
+
based on which fields happened to be present — every overload stays
|
|
54
|
+
individually addressable this way instead.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(self, service_name: str = "") -> None:
|
|
58
|
+
# `_unexposed` is taken by the generated introspection route, so a
|
|
59
|
+
# native symbol of that name gets the suffix instead of shadowing it.
|
|
60
|
+
self._used: set[str] = {"_unexposed"}
|
|
61
|
+
self._service = service_name
|
|
62
|
+
# Issued operationIds, and the route each was issued for: the id is a
|
|
63
|
+
# lossy function of the route (see operation_id), so uniqueness has to
|
|
64
|
+
# be tracked rather than derived, and a repeat call for the same route
|
|
65
|
+
# must return the same id rather than allocating a new suffix.
|
|
66
|
+
self._used_operation_ids: set[str] = set()
|
|
67
|
+
self._operation_ids: dict[str, str] = {}
|
|
68
|
+
|
|
69
|
+
def take(self, native_name: str) -> tuple[str, str]:
|
|
70
|
+
"""Return (route path segment, Python function base name)."""
|
|
71
|
+
base = _py(native_name)
|
|
72
|
+
route, name = native_name, base
|
|
73
|
+
counter = 1
|
|
74
|
+
while name in self._used:
|
|
75
|
+
counter += 1
|
|
76
|
+
route, name = f"{native_name}_{counter}", f"{base}_{counter}"
|
|
77
|
+
self._used.add(name)
|
|
78
|
+
return route, name
|
|
79
|
+
|
|
80
|
+
def operation_id(self, route: str) -> str:
|
|
81
|
+
"""The OpenAPI operationId for a route, qualified by service name.
|
|
82
|
+
|
|
83
|
+
Two separate constraints shape this, both measured rather than assumed.
|
|
84
|
+
|
|
85
|
+
**Uniqueness.** OperationIds must be unique across an OpenAPI
|
|
86
|
+
*document*, and the same router is served by two documents: the
|
|
87
|
+
service's own app, and a gateway that mounts several services at once.
|
|
88
|
+
Unqualified ids collide in the gateway — certainly on `_unexposed`,
|
|
89
|
+
which every service has, and on any symbol name two services happen to
|
|
90
|
+
share. FastAPI answers a duplicate with a warning and an OpenAPI
|
|
91
|
+
document that client generators and FastMCP both mis-handle, so the id
|
|
92
|
+
carries the service name. It is also the MCP tool name, where the
|
|
93
|
+
prefix earns its keep a second time: `pvt_solution_gor` tells the model
|
|
94
|
+
which service it is calling, which a bare `solution_gor` does not.
|
|
95
|
+
|
|
96
|
+
**No double underscores.** FastMCP TRUNCATES a tool name at `__` and
|
|
97
|
+
strips leading underscores: measured against fastmcp 3.4.7,
|
|
98
|
+
`svc__unexposed` becomes the tool `svc`, and `svc_a__b` becomes
|
|
99
|
+
`svc_a`. So `<service>_` + `_unexposed` would name the introspection
|
|
100
|
+
tool after the service itself, and any two native symbols differing
|
|
101
|
+
only after a `__` would collapse onto one tool name — silently making
|
|
102
|
+
one of them uncallable. Underscore runs are therefore collapsed before
|
|
103
|
+
the id is issued, and the result is de-duplicated with a numeric
|
|
104
|
+
suffix so collapsing can never merge two distinct routes.
|
|
105
|
+
"""
|
|
106
|
+
if route in self._operation_ids:
|
|
107
|
+
return self._operation_ids[route]
|
|
108
|
+
base = f"{self._service}_{route}" if self._service else route
|
|
109
|
+
# Collapse runs of underscores and trim the ends: what survives here is
|
|
110
|
+
# exactly what FastMCP will use, so the id and the tool name agree.
|
|
111
|
+
candidate = "_".join(part for part in base.split("_") if part) or "endpoint"
|
|
112
|
+
unique, counter = candidate, 1
|
|
113
|
+
while unique in self._used_operation_ids:
|
|
114
|
+
counter += 1
|
|
115
|
+
unique = f"{candidate}_{counter}"
|
|
116
|
+
self._used_operation_ids.add(unique)
|
|
117
|
+
self._operation_ids[route] = unique
|
|
118
|
+
return unique
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _route(route: str, names: "_EndpointNames") -> str:
|
|
122
|
+
"""A POST decorator carrying an explicit `operation_id` (generators/mcp_gen.py).
|
|
123
|
+
|
|
124
|
+
The operation_id is the service name plus the NATIVE symbol name — the
|
|
125
|
+
route's own spelling, not the escaped Python one, so it matches the rest of
|
|
126
|
+
the wire contract. See _EndpointNames.operation_id for why it is qualified.
|
|
127
|
+
|
|
128
|
+
It is not cosmetic. FastMCP names each generated MCP tool after the route's
|
|
129
|
+
operationId, and FastAPI's default is `<function>_<path>_<method>`: without
|
|
130
|
+
this, `solution_gor` reaches the model as
|
|
131
|
+
`solution_gor_endpoint_solution_gor_post`. It also gives the OpenAPI schema
|
|
132
|
+
stable operation ids, which is what client generators key off.
|
|
133
|
+
"""
|
|
134
|
+
return f'@router.post("/{route}", operation_id="{names.operation_id(route)}")'
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# A docstring body we are willing to paste into a `"""..."""` literal as-is.
|
|
138
|
+
#
|
|
139
|
+
# Deliberately strict, because the text on the other side comes from a native
|
|
140
|
+
# source file nobody vetted: a `"""` closes the literal early and turns the
|
|
141
|
+
# rest of a C++ comment into executable Python, and a trailing backslash
|
|
142
|
+
# escapes the closing quotes so the literal swallows the code after it. Both
|
|
143
|
+
# are ordinary things to find in a real header (`\` ends a line in a macro, and
|
|
144
|
+
# Doxygen examples quote Python), so this is a real injection channel and not a
|
|
145
|
+
# hypothetical one. Anything failing this test is emitted via repr() instead.
|
|
146
|
+
_DOCSTRING_CONTROL = ("\r", "\x00")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _breaks_a_docstring(text: str) -> bool:
|
|
150
|
+
"""True when `text` cannot be pasted between triple double-quotes as-is.
|
|
151
|
+
|
|
152
|
+
Precise rather than merely conservative, because the fallback is a
|
|
153
|
+
single-line repr() and a 20-line Fortran header rendered that way is
|
|
154
|
+
genuinely hard to read in a file people debug even though they may not
|
|
155
|
+
edit it.
|
|
156
|
+
|
|
157
|
+
What actually breaks the literal, and nothing else:
|
|
158
|
+
|
|
159
|
+
* `\"\"\"` anywhere — closes it early, and what follows becomes code;
|
|
160
|
+
* a TRAILING `\"` — it abuts the closing quotes to make four, ending the
|
|
161
|
+
literal one character early and opening another string. A quote
|
|
162
|
+
anywhere else is harmless: `\"pressure\"` mid-prose is ordinary text to
|
|
163
|
+
Python, and rejecting it cost readability for nothing;
|
|
164
|
+
* any backslash — `\\N{`, a truncated `\\x`, or a trailing one escaping
|
|
165
|
+
the closing quotes. Real sources are full of `\\param` and `C:\\logs\\`;
|
|
166
|
+
* NUL and carriage return, less breakage than a way to make generated
|
|
167
|
+
files render and diff badly.
|
|
168
|
+
"""
|
|
169
|
+
return (
|
|
170
|
+
'"""' in text
|
|
171
|
+
or text.endswith('"')
|
|
172
|
+
or "\\" in text
|
|
173
|
+
or any(c in text for c in _DOCSTRING_CONTROL)
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _docstring_literal(text: str) -> str:
|
|
178
|
+
"""`text` as a Python string literal that is safe to use as a docstring.
|
|
179
|
+
|
|
180
|
+
Two shapes, chosen per text rather than one compromise:
|
|
181
|
+
|
|
182
|
+
* the common case — plain prose — becomes a readable `\"\"\"...\"\"\"` block,
|
|
183
|
+
because generated code nobody may hand-edit should still be legible to
|
|
184
|
+
whoever has to debug it;
|
|
185
|
+
* anything `_breaks_a_docstring` rejects becomes `repr(text)`, a
|
|
186
|
+
single-line literal Python itself guarantees is correctly escaped. It is
|
|
187
|
+
uglier, and that is the right trade: the alternative is hand-rolled
|
|
188
|
+
escaping of adversarial input, which is how a generator starts emitting
|
|
189
|
+
code that does not parse — or worse, parses.
|
|
190
|
+
"""
|
|
191
|
+
if _breaks_a_docstring(text):
|
|
192
|
+
return repr(text)
|
|
193
|
+
return f'"""{text}"""'
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _docstring(summary: str, doc: str | None = None) -> list[str]:
|
|
197
|
+
"""The endpoint's docstring: the native source's own words, then ours.
|
|
198
|
+
|
|
199
|
+
FastAPI publishes this as the route's OpenAPI `description`, which is what
|
|
200
|
+
FastMCP hands the model as the MCP tool description (generators/mcp_gen.py).
|
|
201
|
+
With no docstring FastMCP falls back to a title-cased function name
|
|
202
|
+
("Solution Gor Endpoint"), which tells the model nothing about the routine —
|
|
203
|
+
so how well a model calls native code depends directly on this text.
|
|
204
|
+
|
|
205
|
+
`doc` is the documentation the parser recovered from the declaration itself
|
|
206
|
+
(ROADMAP 3.4): a Doxygen block over a C++ prototype, the comment header on a
|
|
207
|
+
Fortran subroutine. It is used VERBATIM. nativegate does not paraphrase it,
|
|
208
|
+
because a plausible-sounding paraphrase of a numerical routine's contract is
|
|
209
|
+
worse than no description at all, and legacy headers do carry stale
|
|
210
|
+
comments — passing them through unchanged at least keeps the staleness
|
|
211
|
+
attributable to the source.
|
|
212
|
+
|
|
213
|
+
`summary` is nativegate's own one-line statement of what the endpoint does,
|
|
214
|
+
and is always emitted. It survives even when `doc` is present because the
|
|
215
|
+
native comment rarely says which symbol it belongs to, and the model needs
|
|
216
|
+
that to reason about a call it is about to make.
|
|
217
|
+
"""
|
|
218
|
+
if not doc or not doc.strip():
|
|
219
|
+
return [f" {_docstring_literal(summary)}"]
|
|
220
|
+
|
|
221
|
+
# Indented to sit inside the endpoint function. The native text is
|
|
222
|
+
# re-indented line by line rather than pasted as one blob, so a multi-line
|
|
223
|
+
# comment does not emit a docstring whose continuation lines sit at column
|
|
224
|
+
# zero — that parses, but it reads as though the function body ended.
|
|
225
|
+
body = "\n".join(
|
|
226
|
+
f" {line}".rstrip() for line in f"{doc.strip()}\n\n{summary}".splitlines()
|
|
227
|
+
).strip()
|
|
228
|
+
literal = _docstring_literal(body)
|
|
229
|
+
if literal.startswith('"""'):
|
|
230
|
+
# Closing quotes on their own line, PEP 257's shape for a multi-line
|
|
231
|
+
# docstring, and the reason the body was re-indented above.
|
|
232
|
+
return [f' """{body}', ' """'] if "\n" in body else [f" {literal}"]
|
|
233
|
+
return [f" {literal}"]
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _native_module_symbol(module: ModuleIR) -> str:
|
|
237
|
+
# cpp compiles one pybind11 extension named "<module>_cpp" (section 7);
|
|
238
|
+
# f2py names the extension after the Fortran module directly (section 8).
|
|
239
|
+
return f"{module.name}_cpp" if module.language == "cpp" else module.name
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def generate_init_py(module: ModuleIR) -> str:
|
|
243
|
+
module_symbol = _native_module_symbol(module)
|
|
244
|
+
# Structs are re-exported alongside classes: a method returning one is
|
|
245
|
+
# useless if the caller can't name the type to annotate or construct it.
|
|
246
|
+
names = (
|
|
247
|
+
[struct.name for struct in module.structs]
|
|
248
|
+
+ [cls.name for cls in module.classes]
|
|
249
|
+
+ [fn.name for fn in module.functions]
|
|
250
|
+
)
|
|
251
|
+
# __all__ lists the names this package actually binds, which are the escaped
|
|
252
|
+
# Python spellings — `__all__ = ["lambda"]` would name something unimportable.
|
|
253
|
+
all_list = ", ".join(f'"{_py(n)}"' for n in names)
|
|
254
|
+
|
|
255
|
+
if module.language == "fortran" and any(fn.fortran_module for fn in module.functions):
|
|
256
|
+
# Routines declared inside `module X ... end module X` come back from
|
|
257
|
+
# f2py as attributes of a nested `<module_symbol>.<X>` fortran object,
|
|
258
|
+
# not top-level names — re-export them so callers still get the flat
|
|
259
|
+
# `from physics import calculate_pressure` design.md promises.
|
|
260
|
+
#
|
|
261
|
+
# Resolved per routine, not per service: a modern F90 facade over
|
|
262
|
+
# fixed-form F77 decks has module-scoped and top-level routines in the
|
|
263
|
+
# same build, and that is the normal shape of a migration in progress.
|
|
264
|
+
# The attribute path uses the name f2py actually created (lower case,
|
|
265
|
+
# original spelling); only the local binding is escaped for Python.
|
|
266
|
+
rebindings = "\n".join(
|
|
267
|
+
f"{_py(fn.name)} = "
|
|
268
|
+
+ (
|
|
269
|
+
_attr(
|
|
270
|
+
_attr("_native", fn.fortran_module),
|
|
271
|
+
(fn.cpp_name or fn.name).lower(),
|
|
272
|
+
)
|
|
273
|
+
if fn.fortran_module
|
|
274
|
+
else _attr("_native", (fn.cpp_name or fn.name).lower())
|
|
275
|
+
)
|
|
276
|
+
for fn in module.functions
|
|
277
|
+
)
|
|
278
|
+
return (
|
|
279
|
+
"# Generated by nativegate. Do not edit by hand — re-run `ngate generate`.\n"
|
|
280
|
+
f"from ._native import {module_symbol} as _native\n\n"
|
|
281
|
+
f"{rebindings}\n\n"
|
|
282
|
+
f"__all__ = [{all_list}]\n"
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
if module.language == "fortran":
|
|
286
|
+
# Fortran is case-insensitive and f2py always emits lowercase symbols,
|
|
287
|
+
# but legacy sources (and therefore nativegate.yaml) spell routines in
|
|
288
|
+
# upper case. Alias so `PVTINI` in config keeps working while the
|
|
289
|
+
# import targets the symbol that actually exists.
|
|
290
|
+
native_names = {name: name.lower() for name in names}
|
|
291
|
+
else:
|
|
292
|
+
native_names = {name: name for name in names}
|
|
293
|
+
|
|
294
|
+
# A native symbol whose own spelling is a Python keyword cannot appear in an
|
|
295
|
+
# import statement at all (`from m import lambda as lambda_` is a
|
|
296
|
+
# SyntaxError), so it is fetched by getattr from the module object instead.
|
|
297
|
+
importable = [
|
|
298
|
+
(native, _py(name))
|
|
299
|
+
for name, native in native_names.items()
|
|
300
|
+
if _py(native) == native
|
|
301
|
+
]
|
|
302
|
+
by_getattr = [
|
|
303
|
+
(native, _py(name))
|
|
304
|
+
for name, native in native_names.items()
|
|
305
|
+
if _py(native) != native
|
|
306
|
+
]
|
|
307
|
+
|
|
308
|
+
lines = ["# Generated by nativegate. Do not edit by hand — re-run `ngate generate`."]
|
|
309
|
+
if not names:
|
|
310
|
+
# `from ._native.svc import` with nothing after it is a SyntaxError.
|
|
311
|
+
# The CLI already refuses an empty module, but a generator must not be
|
|
312
|
+
# able to emit an unparsable file for any IR it is handed.
|
|
313
|
+
lines.append("# Nothing exposed — check `expose:` in nativegate.yaml.")
|
|
314
|
+
elif by_getattr:
|
|
315
|
+
lines.append(f"from ._native import {module_symbol} as _native")
|
|
316
|
+
if importable:
|
|
317
|
+
imports = ", ".join(
|
|
318
|
+
f"{native} as {alias}" if native != alias else native
|
|
319
|
+
for native, alias in importable
|
|
320
|
+
)
|
|
321
|
+
lines.append(f"from ._native.{module_symbol} import {imports}")
|
|
322
|
+
for native, alias in by_getattr:
|
|
323
|
+
lines.append(f'{alias} = getattr(_native, "{native}")')
|
|
324
|
+
else:
|
|
325
|
+
imports = ", ".join(
|
|
326
|
+
f"{native} as {alias}" if native != alias else native
|
|
327
|
+
for native, alias in importable
|
|
328
|
+
)
|
|
329
|
+
lines.append(f"from ._native.{module_symbol} import {imports}")
|
|
330
|
+
|
|
331
|
+
lines.append("")
|
|
332
|
+
lines.append(f"__all__ = [{all_list}]")
|
|
333
|
+
return "\n".join(lines) + "\n"
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
# --- serialising native calls (COMMON-block safety) ----------------------
|
|
337
|
+
|
|
338
|
+
# Why this exists, in the generated file itself: a maintainer who deletes the
|
|
339
|
+
# lock to "fix throughput" reintroduces silently wrong numbers, so the reason
|
|
340
|
+
# has to travel with the code and not only with the docs.
|
|
341
|
+
NATIVE_LOCK_COMMENT = [
|
|
342
|
+
"# Every native call below is serialised through this one process-wide lock.",
|
|
343
|
+
"#",
|
|
344
|
+
"# Fortran COMMON blocks are process-global storage, not per-call state. A",
|
|
345
|
+
"# routine like PVTINI writes COMMON /FLUID/ and a later call to PVTRS,",
|
|
346
|
+
"# PVTBO or PVTVIS reads it back — the header's own contract is \"CALL",
|
|
347
|
+
"# PVTSET(P) THEN USE THE COMMON\". FastAPI runs synchronous `def` endpoints",
|
|
348
|
+
"# in a threadpool, so without this lock two requests configuring different",
|
|
349
|
+
"# fluids interleave their writes and reads in the SAME COMMON block and",
|
|
350
|
+
"# each caller gets numbers computed from the other caller's fluid. Nothing",
|
|
351
|
+
"# raises, nothing logs; the answers are just wrong.",
|
|
352
|
+
"#",
|
|
353
|
+
"# Per-request objects cannot fix this the way they fix a stateful C++ class:",
|
|
354
|
+
"# COMMON is reachable from every instance because it belongs to the process.",
|
|
355
|
+
"#",
|
|
356
|
+
"# DO NOT REMOVE THIS TO IMPROVE THROUGHPUT. It caps native execution at one",
|
|
357
|
+
"# concurrent call per process on purpose. The supported ways to go faster are",
|
|
358
|
+
"# more processes, or an API reshaped so a configure+read pair is one call.",
|
|
359
|
+
"#",
|
|
360
|
+
"# RLock, not Lock: if one generated endpoint is ever changed to call another,",
|
|
361
|
+
"# a plain Lock would deadlock the request against itself.",
|
|
362
|
+
]
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
# --- input bounds (ROADMAP 2.3) -----------------------------------------
|
|
366
|
+
|
|
367
|
+
# The placeholder extent. `ir.Parameter` records *that* an argument is an array
|
|
368
|
+
# but not how long it may be — real extents need the fparser2 front end (1.4).
|
|
369
|
+
# Until then every generated array endpoint shares one cap, overridable per
|
|
370
|
+
# deployment through the environment without regenerating.
|
|
371
|
+
DEFAULT_MAX_ARRAY_ITEMS = 65536
|
|
372
|
+
|
|
373
|
+
ARRAY_CAP_COMMENT = [
|
|
374
|
+
"# Maximum number of elements accepted in any array argument of any endpoint.",
|
|
375
|
+
"#",
|
|
376
|
+
"# PLACEHOLDER. nativegate does not yet know the real extent of a native array",
|
|
377
|
+
"# argument — the IR records that a parameter IS an array, not how long the",
|
|
378
|
+
"# routine expects it to be. Recovering the declared extent needs the fparser2",
|
|
379
|
+
"# front end (ROADMAP 1.4); until then one configurable cap stands in for all",
|
|
380
|
+
"# of them, so an unbounded request body cannot exhaust this worker's memory.",
|
|
381
|
+
"# Pydantic rejects an oversized list during request validation, so the caller",
|
|
382
|
+
"# gets a 422 and the array is never materialised for the native call.",
|
|
383
|
+
"#",
|
|
384
|
+
"# Raise it for a service with genuinely large inputs; do not remove it.",
|
|
385
|
+
f'MAX_ARRAY_ITEMS = int(os.environ.get("NATIVEGATE_MAX_ARRAY_ITEMS", "{DEFAULT_MAX_ARRAY_ITEMS}"))',
|
|
386
|
+
]
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
# Integer-name shapes that unambiguously describe an array `x`: n, nx, n_x,
|
|
390
|
+
# num_x, len_x, size_x, count_x, x_n, x_len, x_size, x_count. Anything else is
|
|
391
|
+
# not paired — a wrong pairing rejects valid calls, which is worse than the
|
|
392
|
+
# status quo for that routine.
|
|
393
|
+
def _size_guard_lines(pairings: list[tuple[str, str]]) -> list[str]:
|
|
394
|
+
"""Reject a size/array mismatch before the value reaches native code."""
|
|
395
|
+
lines: list[str] = []
|
|
396
|
+
for int_name, array_name in pairings:
|
|
397
|
+
lines.append(f" if {int_name} != len({array_name}):")
|
|
398
|
+
lines.append(" raise HTTPException(")
|
|
399
|
+
lines.append(" status_code=422,")
|
|
400
|
+
lines.append(
|
|
401
|
+
f' detail=f"{int_name} ({{{int_name}}}) must equal '
|
|
402
|
+
f'len({array_name}) ({{len({array_name})}}): the native routine '
|
|
403
|
+
f'uses {int_name} as the extent of {array_name} and would index '
|
|
404
|
+
'past the end of the buffer.",'
|
|
405
|
+
)
|
|
406
|
+
lines.append(" )")
|
|
407
|
+
return lines
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _array_annotation(inner: str) -> str:
|
|
411
|
+
return f"Annotated[list[{inner}], Field(max_length=MAX_ARRAY_ITEMS)]"
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _serialised(body_lines: list[str]) -> list[str]:
|
|
415
|
+
"""Indent `body_lines` under `with _NATIVE_LOCK:`.
|
|
416
|
+
|
|
417
|
+
Only the native call itself belongs inside the lock. Argument marshalling
|
|
418
|
+
and JSON shaping touch no COMMON storage, and holding the lock across them
|
|
419
|
+
would lengthen the critical section for no correctness gain.
|
|
420
|
+
"""
|
|
421
|
+
return [" with _NATIVE_LOCK:"] + [f" {line}" for line in body_lines]
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _endpoint_for_function(
|
|
425
|
+
fn: FunctionDef,
|
|
426
|
+
call_expr: str,
|
|
427
|
+
names: "_EndpointNames | None" = None,
|
|
428
|
+
serialise: bool = False,
|
|
429
|
+
) -> list[str]:
|
|
430
|
+
"""One FastAPI endpoint for one native routine.
|
|
431
|
+
|
|
432
|
+
Intent decides both halves of the signature. f2py drops `intent(out)`
|
|
433
|
+
arguments from the call and returns them instead, so an endpoint that
|
|
434
|
+
still accepted them would pass too many arguments; and `intent(in,out)`
|
|
435
|
+
arguments are both passed and returned. Getting this wrong is how a
|
|
436
|
+
Fortran routine's results end up discarded — the call succeeds and the
|
|
437
|
+
answer goes nowhere.
|
|
438
|
+
"""
|
|
439
|
+
names = names or _EndpointNames()
|
|
440
|
+
route, py_name = names.take(fn.name)
|
|
441
|
+
|
|
442
|
+
# Every parameter that becomes a Python identifier is escaped first (B4);
|
|
443
|
+
# the call into the native routine is positional, so the values still land
|
|
444
|
+
# on the right arguments.
|
|
445
|
+
parameters = [_renamed(p, _py(p.name)) for p in fn.parameters]
|
|
446
|
+
# intent(out) is supplied by Fortran, not by the caller.
|
|
447
|
+
inputs = [p for p in parameters if p.intent != "out"]
|
|
448
|
+
outputs = [p for p in parameters if p.intent != "in"]
|
|
449
|
+
|
|
450
|
+
array_inputs = [p for p in inputs if p.is_array]
|
|
451
|
+
scalar_inputs = [p for p in inputs if not p.is_array]
|
|
452
|
+
|
|
453
|
+
sig_parts = [f"{p.name}: {p.type}" for p in scalar_inputs] + [
|
|
454
|
+
f"{p.name}: {_array_annotation(p.type)}" for p in array_inputs
|
|
455
|
+
]
|
|
456
|
+
# The endpoint function shares fn.name with the imported native symbol
|
|
457
|
+
# (see call_expr's alias in generate_service_py) — using fn.name here as
|
|
458
|
+
# well would shadow the import and turn every call into infinite self-recursion.
|
|
459
|
+
lines = [_route(route, names), f"def {py_name}_endpoint({', '.join(sig_parts)}):"]
|
|
460
|
+
call_args = [p.name for p in inputs]
|
|
461
|
+
|
|
462
|
+
kind = "subroutine" if fn.is_subroutine else "routine"
|
|
463
|
+
body_lines = _docstring(f"Call the native {kind} `{fn.name}`.", fn.doc)
|
|
464
|
+
|
|
465
|
+
# Checked against the *list* the request supplied, before it is turned into
|
|
466
|
+
# a numpy buffer and before the native call.
|
|
467
|
+
body_lines += _size_guard_lines(_size_pairings(inputs))
|
|
468
|
+
for p in array_inputs:
|
|
469
|
+
body_lines.append(f" {p.name} = np.array({p.name}, dtype=np.float64)")
|
|
470
|
+
|
|
471
|
+
call = f"{call_expr}({', '.join(call_args)})"
|
|
472
|
+
|
|
473
|
+
def _as_json(param) -> str:
|
|
474
|
+
if param.is_array:
|
|
475
|
+
return f"{param.name}.tolist()"
|
|
476
|
+
if param.type == "str":
|
|
477
|
+
# f2py hands CHARACTER outputs back as BYTES, blank-padded to the
|
|
478
|
+
# declared length. Bytes are not JSON, and the padding is Fortran
|
|
479
|
+
# bookkeeping, not data.
|
|
480
|
+
return f"_n2p_text({param.name})"
|
|
481
|
+
return param.name
|
|
482
|
+
|
|
483
|
+
# The call is kept separate from the return so it — and only it — can be
|
|
484
|
+
# wrapped in the COMMON-block lock below.
|
|
485
|
+
if fn.is_subroutine:
|
|
486
|
+
if not outputs:
|
|
487
|
+
call_lines = [f" {call}"]
|
|
488
|
+
return_line = ' return {"ok": True}'
|
|
489
|
+
elif len(outputs) == 1:
|
|
490
|
+
call_lines = [f" {outputs[0].name} = {call}"]
|
|
491
|
+
return_line = f' return {{"{outputs[0].name}": {_as_json(outputs[0])}}}'
|
|
492
|
+
else:
|
|
493
|
+
targets = ", ".join(p.name for p in outputs)
|
|
494
|
+
call_lines = [f" {targets} = {call}"]
|
|
495
|
+
result_expr = ", ".join(f'"{p.name}": {_as_json(p)}' for p in outputs)
|
|
496
|
+
return_line = f" return {{{result_expr}}}"
|
|
497
|
+
elif outputs:
|
|
498
|
+
# A FUNCTION with output arguments: f2py returns the result first,
|
|
499
|
+
# then each output argument, in declaration order.
|
|
500
|
+
targets = ", ".join(["result"] + [p.name for p in outputs])
|
|
501
|
+
call_lines = [f" {targets} = {call}"]
|
|
502
|
+
result_expr = ", ".join(
|
|
503
|
+
['"result": result'] + [f'"{p.name}": {_as_json(p)}' for p in outputs]
|
|
504
|
+
)
|
|
505
|
+
return_line = f" return {{{result_expr}}}"
|
|
506
|
+
elif serialise:
|
|
507
|
+
# Inlining the call into the return would put JSON shaping inside the
|
|
508
|
+
# lock, so the result is named first.
|
|
509
|
+
call_lines = [f" result = {call}"]
|
|
510
|
+
return_line = ' return {"result": result}'
|
|
511
|
+
else:
|
|
512
|
+
call_lines = []
|
|
513
|
+
return_line = f' return {{"result": {call}}}'
|
|
514
|
+
|
|
515
|
+
if serialise and call_lines:
|
|
516
|
+
call_lines = _serialised(call_lines)
|
|
517
|
+
body_lines.extend(call_lines)
|
|
518
|
+
body_lines.append(return_line)
|
|
519
|
+
|
|
520
|
+
return [lines[0], lines[1], *body_lines, ""]
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def generate_router_py(module: ModuleIR, service_name: str) -> str:
|
|
524
|
+
"""The endpoints as a mountable APIRouter, deliberately WITHOUT a prefix.
|
|
525
|
+
|
|
526
|
+
Keeping the prefix out of the router is what lets one generated service
|
|
527
|
+
serve two deployment topologies from identical code: standalone (its own
|
|
528
|
+
image, its own FastAPI app, paths at `/circle_area`) and composed (many
|
|
529
|
+
services mounted into one gateway app under `/demo/circle_area`). The
|
|
530
|
+
mounting side chooses the prefix — see generators/gateway_gen.py.
|
|
531
|
+
|
|
532
|
+
Three rules here exist because breaking them produced services that
|
|
533
|
+
*imported* wrongly rather than failing a build:
|
|
534
|
+
|
|
535
|
+
* an instance is created **inside** each endpoint, not at module scope.
|
|
536
|
+
A module-scope `sim = Simulator()` is one shared mutable object for
|
|
537
|
+
every concurrent request, and for a class with no default constructor
|
|
538
|
+
it is a TypeError at import — the service never starts;
|
|
539
|
+
* a struct crossing the boundary is converted through a Pydantic model.
|
|
540
|
+
Returning the pybind11 object gets a 500 from the JSON encoder at
|
|
541
|
+
request time, with the class bound perfectly well underneath;
|
|
542
|
+
* a signature nativegate cannot serialise (a bound *class* as a parameter
|
|
543
|
+
or return) gets no endpoint and a comment saying why, because FastAPI
|
|
544
|
+
raises on an unknown annotation when the router is registered — again,
|
|
545
|
+
at import.
|
|
546
|
+
"""
|
|
547
|
+
structs = {struct.name: struct for struct in module.structs}
|
|
548
|
+
class_names = {cls.name for cls in module.classes}
|
|
549
|
+
|
|
550
|
+
# Does any endpoint accept an array from the request? Includes class
|
|
551
|
+
# constructor and method arguments, not only free functions.
|
|
552
|
+
endpoint_params = [p for fn in module.functions for p in fn.parameters]
|
|
553
|
+
for cls in module.classes:
|
|
554
|
+
for ctor in cls.constructors:
|
|
555
|
+
endpoint_params.extend(ctor)
|
|
556
|
+
for method in cls.methods:
|
|
557
|
+
endpoint_params.extend(method.parameters)
|
|
558
|
+
needs_bounds = any(p.is_array for p in endpoint_params)
|
|
559
|
+
|
|
560
|
+
# Fortran only. A C++ service already builds a fresh instance per request
|
|
561
|
+
# (see below), and a C++ free function has no equivalent of COMMON unless
|
|
562
|
+
# it uses file-scope statics — which the parser cannot see, so a lock there
|
|
563
|
+
# would be a guess that costs every C++ service its concurrency.
|
|
564
|
+
serialise = module.language == "fortran"
|
|
565
|
+
|
|
566
|
+
# The endpoints are built first so the header can import exactly what they
|
|
567
|
+
# turned out to need — the size guards are emitted only where a pairing is
|
|
568
|
+
# unambiguous, and an unused `HTTPException` import (or a missing one) would
|
|
569
|
+
# otherwise depend on re-deriving that decision a second time.
|
|
570
|
+
endpoint_lines: list[str] = []
|
|
571
|
+
# One registry for the whole file: a class method and a free function can
|
|
572
|
+
# collide just as two overloads of one method can (A5).
|
|
573
|
+
names = _EndpointNames(service_name)
|
|
574
|
+
|
|
575
|
+
for cls in module.classes:
|
|
576
|
+
endpoint_lines.extend(
|
|
577
|
+
_endpoints_for_class(cls, structs, class_names, names, serialise=serialise)
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
for fn in module.functions:
|
|
581
|
+
if module.language == "cpp":
|
|
582
|
+
endpoint_lines.extend(
|
|
583
|
+
_endpoint_for_cpp_function(fn, structs, class_names, names)
|
|
584
|
+
)
|
|
585
|
+
else:
|
|
586
|
+
endpoint_lines.extend(
|
|
587
|
+
_endpoint_for_function(fn, _py(fn.name), names, serialise=serialise)
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
needs_http_exception = any("HTTPException(" in line for line in endpoint_lines)
|
|
591
|
+
# Derived from what was actually EMITTED, not from the IR. Only the Fortran
|
|
592
|
+
# path marshals arrays through numpy (f2py wants a contiguous buffer);
|
|
593
|
+
# pybind11 takes a plain list and converts it to std::vector itself. Keying
|
|
594
|
+
# this off `p.is_array` instead put `import numpy` in every C++ router the
|
|
595
|
+
# moment std::vector became bindable — and numpy is not a declared
|
|
596
|
+
# dependency of a C++ service, so the router would fail at import inside
|
|
597
|
+
# the container while passing every test on a dev machine that has numpy.
|
|
598
|
+
needs_numpy = any("np." in line for line in endpoint_lines)
|
|
599
|
+
needs_text_helper = any("_n2p_text(" in line for line in endpoint_lines)
|
|
600
|
+
|
|
601
|
+
lines = [
|
|
602
|
+
"# Generated by nativegate. Do not edit by hand — re-run `ngate generate`.",
|
|
603
|
+
]
|
|
604
|
+
if needs_bounds:
|
|
605
|
+
lines.append("import os")
|
|
606
|
+
if serialise:
|
|
607
|
+
lines.append("import threading")
|
|
608
|
+
if needs_bounds:
|
|
609
|
+
lines.append("from typing import Annotated")
|
|
610
|
+
if needs_numpy:
|
|
611
|
+
lines.append("import numpy as np")
|
|
612
|
+
lines.append(
|
|
613
|
+
"from fastapi import APIRouter, HTTPException"
|
|
614
|
+
if needs_http_exception
|
|
615
|
+
else "from fastapi import APIRouter"
|
|
616
|
+
)
|
|
617
|
+
pydantic_names = (["BaseModel"] if structs else []) + (
|
|
618
|
+
["Field"] if needs_bounds else []
|
|
619
|
+
)
|
|
620
|
+
if pydantic_names:
|
|
621
|
+
lines.append(f"from pydantic import {', '.join(pydantic_names)}")
|
|
622
|
+
lines.append("")
|
|
623
|
+
|
|
624
|
+
# The package re-exports every symbol under its escaped Python name (B4),
|
|
625
|
+
# so that is what the router imports.
|
|
626
|
+
imported_names = [
|
|
627
|
+
_py(name)
|
|
628
|
+
for name in (
|
|
629
|
+
[struct.name for struct in module.structs]
|
|
630
|
+
+ [cls.name for cls in module.classes]
|
|
631
|
+
+ [fn.name for fn in module.functions]
|
|
632
|
+
)
|
|
633
|
+
]
|
|
634
|
+
if imported_names:
|
|
635
|
+
lines.append(f"from . import {', '.join(imported_names)}")
|
|
636
|
+
lines.append("")
|
|
637
|
+
|
|
638
|
+
if needs_bounds:
|
|
639
|
+
lines.extend(ARRAY_CAP_COMMENT)
|
|
640
|
+
lines.append("")
|
|
641
|
+
|
|
642
|
+
if needs_text_helper:
|
|
643
|
+
lines.extend(
|
|
644
|
+
[
|
|
645
|
+
"def _n2p_text(value) -> str:",
|
|
646
|
+
' """A CHARACTER output as a Python str.',
|
|
647
|
+
"",
|
|
648
|
+
" f2py returns bytes, blank-padded to the declared length —",
|
|
649
|
+
" Fortran bookkeeping, not data. Latin-1 because it cannot",
|
|
650
|
+
" fail: every byte maps, so a deck that stuffed binary into",
|
|
651
|
+
" a CHARACTER still round-trips instead of raising mid-request.",
|
|
652
|
+
' """',
|
|
653
|
+
" if isinstance(value, (bytes, bytearray)):",
|
|
654
|
+
' return value.decode("latin-1").rstrip()',
|
|
655
|
+
" return str(value).rstrip()",
|
|
656
|
+
"",
|
|
657
|
+
"",
|
|
658
|
+
]
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
lines.extend(_struct_models(module, structs))
|
|
662
|
+
|
|
663
|
+
if serialise:
|
|
664
|
+
lines.extend(NATIVE_LOCK_COMMENT)
|
|
665
|
+
lines.append("_NATIVE_LOCK = threading.RLock()")
|
|
666
|
+
lines.append("")
|
|
667
|
+
|
|
668
|
+
lines.append(f'router = APIRouter(tags=["{service_name}"])')
|
|
669
|
+
lines.append("")
|
|
670
|
+
|
|
671
|
+
lines.extend(_unexposed_route(module, names))
|
|
672
|
+
|
|
673
|
+
lines.extend(endpoint_lines)
|
|
674
|
+
|
|
675
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
# --- what the parser refused to bind (ROADMAP 2.4) ----------------------
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def _unexposed_route(module: ModuleIR, names: "_EndpointNames") -> list[str]:
|
|
682
|
+
"""`GET /_unexposed` — every symbol the parser recognised and refused.
|
|
683
|
+
|
|
684
|
+
`ModuleIR.skipped` is how nativegate promises never to mis-bind silently,
|
|
685
|
+
but until now it only survived as comments in a file inside a container.
|
|
686
|
+
Serving it makes the refusals part of the running service's contract: a
|
|
687
|
+
caller who cannot find an endpoint can ask why it is missing.
|
|
688
|
+
|
|
689
|
+
The route exists even when nothing was skipped, and returns `{}`. That is
|
|
690
|
+
the point — an empty mapping means "this build refused nothing", while a
|
|
691
|
+
404 means "this service was generated before nativegate could tell you".
|
|
692
|
+
"""
|
|
693
|
+
# Duplicate names are merged rather than silently overwritten: two reasons
|
|
694
|
+
# for one overloaded symbol are both true and both worth reporting.
|
|
695
|
+
merged: dict[str, str] = {}
|
|
696
|
+
for entry in module.skipped:
|
|
697
|
+
if entry.name in merged:
|
|
698
|
+
merged[entry.name] = f"{merged[entry.name]}; {entry.reason}"
|
|
699
|
+
else:
|
|
700
|
+
merged[entry.name] = entry.reason
|
|
701
|
+
|
|
702
|
+
lines = [
|
|
703
|
+
"# Symbols this build recognised and refused to bind, with the reason.",
|
|
704
|
+
"#",
|
|
705
|
+
"# Refusal is nativegate's safety mechanism (a non-const char* output buffer",
|
|
706
|
+
"# has no length; a pointer return has no ownership; a derived-type Fortran",
|
|
707
|
+
"# result has no mapping), so the refusals are part of this service's",
|
|
708
|
+
"# contract and not an implementation detail. An EMPTY mapping means this",
|
|
709
|
+
"# build refused nothing; a 404 on this route means the service predates",
|
|
710
|
+
"# the feature and cannot tell you either way.",
|
|
711
|
+
"_UNEXPOSED: dict[str, str] = {",
|
|
712
|
+
]
|
|
713
|
+
for name, reason in merged.items():
|
|
714
|
+
lines.append(f" {name!r}: {reason!r},")
|
|
715
|
+
lines.append("}")
|
|
716
|
+
lines.append("")
|
|
717
|
+
lines.append(
|
|
718
|
+
'@router.get("/_unexposed", tags=["introspection"], '
|
|
719
|
+
f'operation_id="{names.operation_id("_unexposed")}")'
|
|
720
|
+
)
|
|
721
|
+
# Deliberately NOT `def _unexposed`: the router imports every bound symbol
|
|
722
|
+
# into this module namespace, so a native routine called `_unexposed` and
|
|
723
|
+
# the route handler would be the same name and one would silently shadow
|
|
724
|
+
# the other — the endpoint for that routine would then return this mapping.
|
|
725
|
+
lines.append("def _nativegate_unexposed() -> dict[str, str]:")
|
|
726
|
+
lines.append(' """Symbols the parser recognised but refused to bind, and why."""')
|
|
727
|
+
lines.append(" return _UNEXPOSED")
|
|
728
|
+
lines.append("")
|
|
729
|
+
return lines
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
# --- structs over HTTP ---------------------------------------------------
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def _model_name(struct_name: str) -> str:
|
|
736
|
+
return f"{_py(struct_name)}Model"
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def _struct_models(module: ModuleIR, structs: dict) -> list[str]:
|
|
740
|
+
"""A Pydantic model, a parser and a serialiser for each bound struct.
|
|
741
|
+
|
|
742
|
+
pybind11 hands Python a real C++ object; FastAPI needs something it can
|
|
743
|
+
encode. The conversions are explicit rather than a generic `__dict__`
|
|
744
|
+
walk so a field the IR never bound cannot silently appear in an API
|
|
745
|
+
response.
|
|
746
|
+
"""
|
|
747
|
+
lines: list[str] = []
|
|
748
|
+
for struct in module.structs:
|
|
749
|
+
lines.append(f"class {_model_name(struct.name)}(BaseModel):")
|
|
750
|
+
if not struct.fields:
|
|
751
|
+
lines.append(" pass")
|
|
752
|
+
for field in struct.fields:
|
|
753
|
+
# A field whose native name is a Python keyword is escaped on both
|
|
754
|
+
# sides of the boundary — the model attribute AND the JSON key — so
|
|
755
|
+
# a request body round-trips through the response unchanged. The
|
|
756
|
+
# native member is still reached under its original spelling below.
|
|
757
|
+
lines.append(f" {_py(field.name)}: {_annotation(field, structs)}")
|
|
758
|
+
lines.append("")
|
|
759
|
+
lines.append("")
|
|
760
|
+
|
|
761
|
+
for struct in module.structs:
|
|
762
|
+
# native -> JSON
|
|
763
|
+
lines.append(f"def _from_{_py(struct.name)}(native) -> dict:")
|
|
764
|
+
if not struct.fields:
|
|
765
|
+
lines.append(" return {}")
|
|
766
|
+
else:
|
|
767
|
+
items = ", ".join(
|
|
768
|
+
f'"{_py(f.name)}": {_field_to_json(f, structs)}' for f in struct.fields
|
|
769
|
+
)
|
|
770
|
+
lines.append(f" return {{{items}}}")
|
|
771
|
+
lines.append("")
|
|
772
|
+
lines.append("")
|
|
773
|
+
|
|
774
|
+
# JSON -> native
|
|
775
|
+
lines.append(f"def _to_{_py(struct.name)}(model: {_model_name(struct.name)}):")
|
|
776
|
+
lines.append(f" native = {_py(struct.name)}()")
|
|
777
|
+
for field in struct.fields:
|
|
778
|
+
value = _field_to_native(field, structs)
|
|
779
|
+
if _py(field.name) == field.name:
|
|
780
|
+
lines.append(f" native.{field.name} = {value}")
|
|
781
|
+
else:
|
|
782
|
+
lines.append(f' setattr(native, "{field.name}", {value})')
|
|
783
|
+
lines.append(" return native")
|
|
784
|
+
lines.append("")
|
|
785
|
+
lines.append("")
|
|
786
|
+
|
|
787
|
+
return lines
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def _annotation(param, structs: dict, bounded: bool = False) -> str:
|
|
791
|
+
"""The Python type annotation FastAPI should see for one parameter/field.
|
|
792
|
+
|
|
793
|
+
`bounded` is set for endpoint *parameters*, where an array arriving from an
|
|
794
|
+
untrusted request body carries the MAX_ARRAY_ITEMS cap. Struct model fields
|
|
795
|
+
are left plain: they are reached through a parameter that already carries
|
|
796
|
+
the cap on the outer list.
|
|
797
|
+
"""
|
|
798
|
+
base = _model_name(param.type) if param.type in structs else param.type
|
|
799
|
+
if not param.is_array:
|
|
800
|
+
return base
|
|
801
|
+
return _array_annotation(base) if bounded else f"list[{base}]"
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def _field_to_json(field, structs: dict) -> str:
|
|
805
|
+
access = _attr("native", field.name)
|
|
806
|
+
if field.type in structs:
|
|
807
|
+
return (
|
|
808
|
+
f"[_from_{_py(field.type)}(v) for v in {access}]"
|
|
809
|
+
if field.is_array
|
|
810
|
+
else f"_from_{_py(field.type)}({access})"
|
|
811
|
+
)
|
|
812
|
+
return f"list({access})" if field.is_array else access
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def _field_to_native(field, structs: dict) -> str:
|
|
816
|
+
access = f"model.{_py(field.name)}"
|
|
817
|
+
if field.type in structs:
|
|
818
|
+
return (
|
|
819
|
+
f"[_to_{_py(field.type)}(v) for v in {access}]"
|
|
820
|
+
if field.is_array
|
|
821
|
+
else f"_to_{_py(field.type)}({access})"
|
|
822
|
+
)
|
|
823
|
+
return access
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
def _unserialisable(params, returns: str, structs: dict, class_names: set) -> str | None:
|
|
827
|
+
"""Why this signature cannot be exposed over HTTP, or None if it can.
|
|
828
|
+
|
|
829
|
+
A bound class is a live C++ object: there is no JSON that constructs one
|
|
830
|
+
and none that represents one. It stays available in the Python package —
|
|
831
|
+
only the HTTP endpoint is impossible.
|
|
832
|
+
"""
|
|
833
|
+
for param in params:
|
|
834
|
+
if param.type in class_names and param.type not in structs:
|
|
835
|
+
return f"parameter '{param.name}' is a bound class ({param.type})"
|
|
836
|
+
if returns in class_names and returns not in structs:
|
|
837
|
+
return f"it returns a bound class ({returns})"
|
|
838
|
+
return None
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
def _call_arguments(params, structs: dict) -> list[str]:
|
|
842
|
+
return [
|
|
843
|
+
f"_to_{_py(p.type)}({p.name})"
|
|
844
|
+
if p.type in structs and not p.is_array
|
|
845
|
+
else p.name
|
|
846
|
+
for p in params
|
|
847
|
+
]
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def _wrap_result(expression: str, returns: str, structs: dict) -> str:
|
|
851
|
+
if returns in structs:
|
|
852
|
+
return f"_from_{_py(returns)}({expression})"
|
|
853
|
+
return expression
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
# --- class endpoints -----------------------------------------------------
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def _constructor_parameters(cls) -> list | None:
|
|
860
|
+
"""The constructor arguments an endpoint must accept, or None if the class
|
|
861
|
+
cannot be constructed from a request at all.
|
|
862
|
+
|
|
863
|
+
A default constructor needs nothing. Otherwise the shortest public
|
|
864
|
+
constructor is used — it asks the caller for the least, and every
|
|
865
|
+
constructor of a class builds the same type.
|
|
866
|
+
"""
|
|
867
|
+
if cls.has_default_constructor:
|
|
868
|
+
return []
|
|
869
|
+
if not cls.constructors:
|
|
870
|
+
return None
|
|
871
|
+
return min(cls.constructors, key=len)
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def _endpoints_for_class(
|
|
875
|
+
cls,
|
|
876
|
+
structs: dict,
|
|
877
|
+
class_names: set,
|
|
878
|
+
names: "_EndpointNames | None" = None,
|
|
879
|
+
serialise: bool = False,
|
|
880
|
+
) -> list[str]:
|
|
881
|
+
names = names or _EndpointNames()
|
|
882
|
+
lines: list[str] = []
|
|
883
|
+
ctor_params = _constructor_parameters(cls)
|
|
884
|
+
|
|
885
|
+
if ctor_params is None and any(not m.is_static for m in cls.methods):
|
|
886
|
+
lines.append(
|
|
887
|
+
f"# {cls.name}: no public constructor callable from Python "
|
|
888
|
+
"(abstract, or all constructors are non-public), so its instance "
|
|
889
|
+
"methods have no endpoint. It is still exported by the package and "
|
|
890
|
+
"usable as a base class."
|
|
891
|
+
)
|
|
892
|
+
lines.append("")
|
|
893
|
+
|
|
894
|
+
for method in cls.methods:
|
|
895
|
+
if method.is_static:
|
|
896
|
+
needed_ctor = []
|
|
897
|
+
elif ctor_params is None:
|
|
898
|
+
continue
|
|
899
|
+
else:
|
|
900
|
+
needed_ctor = ctor_params
|
|
901
|
+
|
|
902
|
+
reason = _unserialisable(
|
|
903
|
+
[*needed_ctor, *method.parameters], method.returns, structs, class_names
|
|
904
|
+
)
|
|
905
|
+
if reason is not None:
|
|
906
|
+
lines.append(f"# {cls.name}.{method.name}: no endpoint — {reason}.")
|
|
907
|
+
lines.append("")
|
|
908
|
+
continue
|
|
909
|
+
|
|
910
|
+
# A constructor argument sharing a name with a method argument would
|
|
911
|
+
# be one parameter in the signature and the wrong value in one of the
|
|
912
|
+
# two calls, so the constructor's copy is renamed. Every name is also
|
|
913
|
+
# escaped against Python's keywords (B4).
|
|
914
|
+
method_params = [_renamed(p, _py(p.name)) for p in method.parameters]
|
|
915
|
+
# Same treatment buffer arguments get on free functions: the length is
|
|
916
|
+
# read off the array the caller sent, so it is not asked over HTTP,
|
|
917
|
+
# and a buffer the native code writes through comes back in the
|
|
918
|
+
# response — the array it wrote into does not outlive the request.
|
|
919
|
+
buffers = [p for p in method_params if getattr(p, "length_param", None)]
|
|
920
|
+
length_names = {p.length_param for p in buffers}
|
|
921
|
+
method_params = [p for p in method_params if p.name not in length_names]
|
|
922
|
+
mutable_buffers = [p for p in buffers if getattr(p, "is_mutable_buffer", False)]
|
|
923
|
+
method_names = {p.name for p in method_params}
|
|
924
|
+
ctor_names = [
|
|
925
|
+
f"{_py(p.name)}_init" if _py(p.name) in method_names else _py(p.name)
|
|
926
|
+
for p in needed_ctor
|
|
927
|
+
]
|
|
928
|
+
|
|
929
|
+
signature = [
|
|
930
|
+
f"{name}: {_annotation(p, structs, bounded=True)}"
|
|
931
|
+
for name, p in zip(ctor_names, needed_ctor)
|
|
932
|
+
] + [
|
|
933
|
+
f"{p.name}: {_annotation(p, structs, bounded=True)}" for p in method_params
|
|
934
|
+
]
|
|
935
|
+
|
|
936
|
+
ctor_args = _call_arguments(
|
|
937
|
+
[_renamed(p, name) for name, p in zip(ctor_names, needed_ctor)], structs
|
|
938
|
+
)
|
|
939
|
+
call_args = _call_arguments(method_params, structs)
|
|
940
|
+
|
|
941
|
+
route, py_name = names.take(method.name)
|
|
942
|
+
lines.append(_route(route, names))
|
|
943
|
+
lines.append(f"def {py_name}({', '.join(signature)}):")
|
|
944
|
+
if method.is_static:
|
|
945
|
+
summary = f"Call the static method `{cls.name}::{method.name}`."
|
|
946
|
+
else:
|
|
947
|
+
# Worth stating: the instance is per-request, so an LLM cannot
|
|
948
|
+
# treat two calls as operating on the same object.
|
|
949
|
+
summary = (
|
|
950
|
+
f"Construct a `{cls.name}` and call its `{method.name}` method. "
|
|
951
|
+
"The instance does not outlive this request."
|
|
952
|
+
)
|
|
953
|
+
lines.extend(_docstring(summary))
|
|
954
|
+
guard = _size_guard_lines(
|
|
955
|
+
_size_pairings(
|
|
956
|
+
[_renamed(p, name) for name, p in zip(ctor_names, needed_ctor)]
|
|
957
|
+
+ method_params
|
|
958
|
+
)
|
|
959
|
+
)
|
|
960
|
+
lines.extend(guard)
|
|
961
|
+
body: list[str] = []
|
|
962
|
+
for buf in buffers:
|
|
963
|
+
body.append(
|
|
964
|
+
f" {buf.name} = np.array({buf.name}, dtype=np.{_BUFFER_DTYPE[buf.type]})"
|
|
965
|
+
)
|
|
966
|
+
if method.is_static:
|
|
967
|
+
call = f"{_attr(_py(cls.name), method.name)}({', '.join(call_args)})"
|
|
968
|
+
else:
|
|
969
|
+
# Per request, not per process: a native class with internal state
|
|
970
|
+
# shared across concurrent requests is a correctness bug, and a
|
|
971
|
+
# simulation object is stateful by definition.
|
|
972
|
+
body.append(f" instance = {_py(cls.name)}({', '.join(ctor_args)})")
|
|
973
|
+
call = f"{_attr('instance', method.name)}({', '.join(call_args)})"
|
|
974
|
+
if mutable_buffers:
|
|
975
|
+
written = ", ".join(
|
|
976
|
+
f'"{p.name}": {p.name}.tolist()' for p in mutable_buffers
|
|
977
|
+
)
|
|
978
|
+
if method.returns == "None":
|
|
979
|
+
body.append(f" {call}")
|
|
980
|
+
lines.extend(_serialised(body) if serialise else body)
|
|
981
|
+
lines.append(f" return {{{written}}}")
|
|
982
|
+
else:
|
|
983
|
+
body.append(f" result = {call}")
|
|
984
|
+
lines.extend(_serialised(body) if serialise else body)
|
|
985
|
+
lines.append(
|
|
986
|
+
f' return {{"result": '
|
|
987
|
+
f'{_wrap_result("result", method.returns, structs)}, {written}}}'
|
|
988
|
+
)
|
|
989
|
+
elif method.returns == "None":
|
|
990
|
+
body.append(f" {call}")
|
|
991
|
+
lines.extend(_serialised(body) if serialise else body)
|
|
992
|
+
lines.append(' return {"ok": True}')
|
|
993
|
+
elif serialise:
|
|
994
|
+
body.append(f" result = {call}")
|
|
995
|
+
lines.extend(_serialised(body))
|
|
996
|
+
lines.append(f' return {{"result": {_wrap_result("result", method.returns, structs)}}}')
|
|
997
|
+
else:
|
|
998
|
+
lines.extend(body)
|
|
999
|
+
lines.append(
|
|
1000
|
+
f' return {{"result": {_wrap_result(call, method.returns, structs)}}}'
|
|
1001
|
+
)
|
|
1002
|
+
lines.append("")
|
|
1003
|
+
|
|
1004
|
+
return lines
|
|
1005
|
+
|
|
1006
|
+
|
|
1007
|
+
def _renamed(param, name: str):
|
|
1008
|
+
"""Copy of a Parameter under a different endpoint-level name."""
|
|
1009
|
+
if not isinstance(param, Parameter): # pragma: no cover - defensive
|
|
1010
|
+
return Parameter(
|
|
1011
|
+
name=name, type=param.type, is_array=param.is_array, intent=param.intent
|
|
1012
|
+
)
|
|
1013
|
+
return dataclasses.replace(param, name=name)
|
|
1014
|
+
|
|
1015
|
+
|
|
1016
|
+
# numpy dtype for a buffer parameter, matching what pybind_gen's lambda
|
|
1017
|
+
# demands. A mutable buffer of the wrong dtype is REFUSED by the binding
|
|
1018
|
+
# rather than converted, so these two tables have to agree.
|
|
1019
|
+
_BUFFER_DTYPE = {"float": "float64", "int": "intc", "bool": "bool_"}
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
def _endpoint_for_cpp_function(
|
|
1023
|
+
fn, structs: dict, class_names: set, names: "_EndpointNames | None" = None
|
|
1024
|
+
) -> list[str]:
|
|
1025
|
+
names = names or _EndpointNames()
|
|
1026
|
+
reason = _unserialisable(fn.parameters, fn.returns, structs, class_names)
|
|
1027
|
+
if reason is not None:
|
|
1028
|
+
return [f"# {fn.name}: no endpoint — {reason}.", ""]
|
|
1029
|
+
|
|
1030
|
+
parameters = [_renamed(p, _py(p.name)) for p in fn.parameters]
|
|
1031
|
+
|
|
1032
|
+
# A `T*` bound as a buffer takes its length from the array itself, so the
|
|
1033
|
+
# length argument is not part of the binding's signature and must not be
|
|
1034
|
+
# asked of an HTTP caller either.
|
|
1035
|
+
buffers = [p for p in parameters if getattr(p, "length_param", None)]
|
|
1036
|
+
length_names = {p.length_param for p in buffers}
|
|
1037
|
+
exposed = [p for p in parameters if p.name not in length_names]
|
|
1038
|
+
mutable_buffers = [p for p in buffers if getattr(p, "is_mutable_buffer", False)]
|
|
1039
|
+
|
|
1040
|
+
signature = ", ".join(
|
|
1041
|
+
f"{p.name}: {_annotation(p, structs, bounded=True)}" for p in exposed
|
|
1042
|
+
)
|
|
1043
|
+
call_args = [p.name for p in exposed]
|
|
1044
|
+
call = f"{_py(fn.name)}({', '.join(call_args)})"
|
|
1045
|
+
|
|
1046
|
+
# extern "C" scalars passed by reference: non-const ones are OUTPUTS, and
|
|
1047
|
+
# the binding returns their final values f2py-style — result first, then
|
|
1048
|
+
# each output in declaration order, a bare value when there is only one.
|
|
1049
|
+
# The endpoint unpacks that shape so an output like an error code reaches
|
|
1050
|
+
# the caller instead of dying with the lambda's locals.
|
|
1051
|
+
scalar_outputs = [
|
|
1052
|
+
p.name
|
|
1053
|
+
for p in exposed
|
|
1054
|
+
if getattr(p, "is_scalar_ref", False) and p.intent != "in"
|
|
1055
|
+
]
|
|
1056
|
+
|
|
1057
|
+
route, py_name = names.take(fn.name)
|
|
1058
|
+
lines = [_route(route, names), f"def {py_name}_endpoint({signature}):"]
|
|
1059
|
+
lines.extend(_docstring(f"Call the native function `{fn.name}`.", fn.doc))
|
|
1060
|
+
if not buffers:
|
|
1061
|
+
lines.extend(_size_guard_lines(_size_pairings(parameters)))
|
|
1062
|
+
|
|
1063
|
+
# The binding wants a real ndarray, and for a mutable buffer specifically a
|
|
1064
|
+
# writable one of the exact dtype — it refuses to convert, because a
|
|
1065
|
+
# converted array is a temporary whose writes are discarded. JSON gives us
|
|
1066
|
+
# a list, so the array is built here with the dtype the binding demands.
|
|
1067
|
+
for buf in buffers:
|
|
1068
|
+
lines.append(
|
|
1069
|
+
f" {buf.name} = np.array({buf.name}, dtype=np.{_BUFFER_DTYPE[buf.type]})"
|
|
1070
|
+
)
|
|
1071
|
+
|
|
1072
|
+
if scalar_outputs:
|
|
1073
|
+
named = ", ".join(f'"{n}": {n}' for n in scalar_outputs)
|
|
1074
|
+
if fn.returns == "None" and len(scalar_outputs) == 1:
|
|
1075
|
+
lines.append(f" {scalar_outputs[0]} = {call}")
|
|
1076
|
+
lines.append(f" return {{{named}}}")
|
|
1077
|
+
elif fn.returns == "None":
|
|
1078
|
+
lines.append(f" {', '.join(scalar_outputs)} = {call}")
|
|
1079
|
+
lines.append(f" return {{{named}}}")
|
|
1080
|
+
else:
|
|
1081
|
+
lines.append(f" result, {', '.join(scalar_outputs)} = {call}")
|
|
1082
|
+
lines.append(f' return {{"result": result, {named}}}')
|
|
1083
|
+
lines.append("")
|
|
1084
|
+
return lines
|
|
1085
|
+
|
|
1086
|
+
if not mutable_buffers:
|
|
1087
|
+
# Inline, exactly as before: naming an intermediate would churn every
|
|
1088
|
+
# generated router for the benefit of the few that write back.
|
|
1089
|
+
if fn.returns == "None":
|
|
1090
|
+
lines.append(f" {call}")
|
|
1091
|
+
lines.append(' return {"ok": True}')
|
|
1092
|
+
else:
|
|
1093
|
+
lines.append(
|
|
1094
|
+
f' return {{"result": {_wrap_result(call, fn.returns, structs)}}}'
|
|
1095
|
+
)
|
|
1096
|
+
lines.append("")
|
|
1097
|
+
return lines
|
|
1098
|
+
|
|
1099
|
+
if fn.returns == "None":
|
|
1100
|
+
lines.append(f" {call}")
|
|
1101
|
+
else:
|
|
1102
|
+
lines.append(f" result = {_wrap_result(call, fn.returns, structs)}")
|
|
1103
|
+
|
|
1104
|
+
if mutable_buffers:
|
|
1105
|
+
# Whatever the native call wrote goes back to the caller. Without this
|
|
1106
|
+
# the endpoint would run, return 200, and silently discard the entire
|
|
1107
|
+
# point of an in-place routine — the HTTP-layer version of the
|
|
1108
|
+
# std::vector& trap this codebase refuses at the binding layer.
|
|
1109
|
+
written = ", ".join(f'"{p.name}": {p.name}.tolist()' for p in mutable_buffers)
|
|
1110
|
+
if fn.returns == "None":
|
|
1111
|
+
lines.append(f" return {{{written}}}")
|
|
1112
|
+
else:
|
|
1113
|
+
lines.append(f' return {{"result": result, {written}}}')
|
|
1114
|
+
elif fn.returns == "None":
|
|
1115
|
+
lines.append(' return {"ok": True}')
|
|
1116
|
+
else:
|
|
1117
|
+
lines.append(' return {"result": result}')
|
|
1118
|
+
lines.append("")
|
|
1119
|
+
return lines
|
|
1120
|
+
|
|
1121
|
+
|
|
1122
|
+
def generate_service_py(service_name: str) -> str:
|
|
1123
|
+
"""Standalone app: this service alone, in its own container.
|
|
1124
|
+
|
|
1125
|
+
Thin by design — all the endpoints live in router.py so the exact same
|
|
1126
|
+
code can instead be mounted into a shared gateway.
|
|
1127
|
+
|
|
1128
|
+
The error handler is emitted here AND in the gateway app, from the same
|
|
1129
|
+
generator, because it attaches to an app rather than to a router — see
|
|
1130
|
+
generators/error_gen.py.
|
|
1131
|
+
"""
|
|
1132
|
+
error_imports = "\n".join(ERROR_HANDLER_IMPORTS)
|
|
1133
|
+
return f'''# Generated by nativegate. Do not edit by hand — re-run `ngate generate`.
|
|
1134
|
+
{error_imports}
|
|
1135
|
+
from fastapi import FastAPI, Request
|
|
1136
|
+
|
|
1137
|
+
from .middleware import install_middleware, readiness
|
|
1138
|
+
from .router import router
|
|
1139
|
+
{mcp_gen.MCP_APP_IMPORT}
|
|
1140
|
+
|
|
1141
|
+
# `lifespan=` is not optional: FastMCP's streamable-HTTP session manager starts
|
|
1142
|
+
# in mcp_app's lifespan, and mounting the app without adopting it fails every
|
|
1143
|
+
# tool call at runtime with "task group was not initialized" — at request time,
|
|
1144
|
+
# not at startup. See generators/mcp_gen.py.
|
|
1145
|
+
app = FastAPI(title="{service_name}", lifespan=mcp_app.lifespan)
|
|
1146
|
+
app.include_router(router)
|
|
1147
|
+
|
|
1148
|
+
# Auth, rate limiting, body-size limits, request ids and access logging. Called
|
|
1149
|
+
# here AND in the gateway app: middleware attaches to an app, and a router
|
|
1150
|
+
# mounted into a gateway runs under the gateway's app.
|
|
1151
|
+
install_middleware(app, "{service_name}")
|
|
1152
|
+
readiness(app, "{service_name}")
|
|
1153
|
+
{mcp_gen.mcp_mount_lines()}
|
|
1154
|
+
|
|
1155
|
+
|
|
1156
|
+
@app.get("/healthz", tags=["health"])
|
|
1157
|
+
def healthz():
|
|
1158
|
+
"""Liveness: the process is up and the native extension imported.
|
|
1159
|
+
|
|
1160
|
+
Deliberately NOT readiness — see /readyz. A liveness probe that fails
|
|
1161
|
+
during startup gets the container killed and restarted forever.
|
|
1162
|
+
"""
|
|
1163
|
+
return {{"status": "ok", "service": "{service_name}"}}
|
|
1164
|
+
{generate_error_handler(service_name)}'''
|