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
@@ -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)}'''