nativegate 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. nativegate/__init__.py +1 -0
  2. nativegate/__main__.py +4 -0
  3. nativegate/buildinfo.py +344 -0
  4. nativegate/cli.py +2007 -0
  5. nativegate/config.py +991 -0
  6. nativegate/declared_invariants.py +565 -0
  7. nativegate/discovery.py +167 -0
  8. nativegate/driverbuild.py +626 -0
  9. nativegate/drivers/__init__.py +5 -0
  10. nativegate/drivers/cpp.py +616 -0
  11. nativegate/drivers/fortran.py +507 -0
  12. nativegate/generators/__init__.py +0 -0
  13. nativegate/generators/cmake_gen.py +101 -0
  14. nativegate/generators/docker_gen.py +614 -0
  15. nativegate/generators/error_gen.py +104 -0
  16. nativegate/generators/f2py_gen.py +91 -0
  17. nativegate/generators/gateway_gen.py +110 -0
  18. nativegate/generators/golden_gen.py +50 -0
  19. nativegate/generators/k8s_gen.py +212 -0
  20. nativegate/generators/mcp_gen.py +281 -0
  21. nativegate/generators/middleware_gen.py +717 -0
  22. nativegate/generators/pybind_gen.py +406 -0
  23. nativegate/generators/pyproject_gen.py +61 -0
  24. nativegate/generators/python_pkg_gen.py +1164 -0
  25. nativegate/generators/test_gen.py +160 -0
  26. nativegate/golden.py +747 -0
  27. nativegate/invariants.py +532 -0
  28. nativegate/ir.py +789 -0
  29. nativegate/lattice.py +350 -0
  30. nativegate/locking.py +216 -0
  31. nativegate/oracle.py +904 -0
  32. nativegate/parsers/__init__.py +0 -0
  33. nativegate/parsers/cpp.py +105 -0
  34. nativegate/parsers/cpp_ast.py +1652 -0
  35. nativegate/parsers/cpp_regex.py +812 -0
  36. nativegate/parsers/fixed_form.py +868 -0
  37. nativegate/parsers/fortran.py +157 -0
  38. nativegate/parsers/fortran_fparser.py +1116 -0
  39. nativegate/parsers/fortran_regex.py +686 -0
  40. nativegate/preprocess.py +335 -0
  41. nativegate/structural_invariants.py +762 -0
  42. nativegate/suggest.py +208 -0
  43. nativegate/templates/__init__.py +20 -0
  44. nativegate/templates/golden_test_template.py +248 -0
  45. nativegate/wire.py +438 -0
  46. nativegate-0.1.0.dist-info/METADATA +547 -0
  47. nativegate-0.1.0.dist-info/RECORD +50 -0
  48. nativegate-0.1.0.dist-info/WHEEL +5 -0
  49. nativegate-0.1.0.dist-info/entry_points.txt +3 -0
  50. nativegate-0.1.0.dist-info/top_level.txt +1 -0
nativegate/wire.py ADDED
@@ -0,0 +1,438 @@
1
+ """The bit-emission wire protocol shared by the oracle's two sides.
2
+
3
+ Layer 2 (design-verification-layers.md, spec section 2) compares a native
4
+ driver's output against the Python binding's output *bitwise*, in the same
5
+ build, on the same machine, in the same run. Both sides have to agree, byte
6
+ for byte, on how a value becomes text and back — that agreement lives here,
7
+ once, so the Fortran driver generator (T3), the C++ driver generator (T7),
8
+ and the comparator (T5) cannot drift into disagreeing with each other about
9
+ what a line means.
10
+
11
+ This module owns both directions:
12
+
13
+ * **Emitting** — turning a Python value into the wire's `<value>` text
14
+ (`pack_float`, `format_value`) and a whole line (`format_line`).
15
+ * **Parsing** — turning driver stdout back into structured data
16
+ (`parse_line`, `parse_lines`) and turning a `slot` string into a typed
17
+ `Slot` (`Slot.parse`).
18
+ * **The plan** — given one golden.json entry and the IR signature it was
19
+ recorded against, the expected list of slots (`slots_for_entry`). T3/T7
20
+ generate their print statements from this list; T5 compares against it.
21
+ This is the single source of truth other generators read from, so "which
22
+ slots does this call produce" is answered in exactly one place.
23
+
24
+ Wire format (spec section 2.5)
25
+ -------------------------------
26
+
27
+ One line per observable value, tab-separated::
28
+
29
+ <key>\\t<slot>\\t<value>
30
+
31
+ `<value>` is 16 lowercase hex digits for floats (`pack_float`), a decimal
32
+ integer for integers, `0`/`1` for booleans, or a percent-escaped string
33
+ otherwise (`escape_string`).
34
+
35
+ Slot grammar (spec section 2.5's table)
36
+ ----------------------------------------
37
+
38
+ ====================== ===========================================
39
+ ``return`` the function's return value
40
+ ``return[<n>]`` element ``n`` of a tuple return — f2py
41
+ surfaces ``intent(out)`` scalars/arrays as
42
+ return values, not as argument effects
43
+ ``arg:<i>`` scalar argument ``i`` after the call
44
+ (``intent(inout)``)
45
+ ``arg:<i>[<n>]`` element ``n`` of array argument ``i``
46
+ ====================== ===========================================
47
+
48
+ **`<i>` counts over the Python-visible argument list** — the same index
49
+ space as golden.json's `arguments` array and `argument_effects` keys. This
50
+ is deliberately *not* the native call site's parameter index: `plan()`
51
+ (golden.py) drops `intent(out)` parameters from the Python argument list,
52
+ and an f2py call site additionally carries hidden `CHARACTER` length
53
+ arguments the Python signature never sees. The driver generator owns the
54
+ mapping from this Python-visible index to whatever native position the
55
+ value actually lives at; the wire format itself never exposes native
56
+ positions, so a comparator reading the wire never needs to know the native
57
+ calling convention at all.
58
+ """
59
+
60
+ from __future__ import annotations
61
+
62
+ import re
63
+ import struct
64
+ from dataclasses import dataclass
65
+
66
+ from .ir import FunctionDef
67
+
68
+ # --- floats: the bitwise channel (spec section 2.4) ----------------------
69
+
70
+ # "Big-endian, and this is not a preference." Both native sides print the
71
+ # *integer value* of the reinterpreted bits, most-significant nibble first,
72
+ # regardless of host byte order — struct.pack(">d", ...) matches that; the
73
+ # native-endian "<d" produces the byte-reversed string on every little-endian
74
+ # host nativegate runs on, and every comparison would fail. The harness must
75
+ # assert this on a known constant at startup rather than trust it (spec
76
+ # section 2.4's closing paragraph) — this constant and its expected hex are
77
+ # copied verbatim from the spec, which verified them against gfortran's
78
+ # `Z16.16`, clang's `%016llx`, and Python's `struct.pack(">d", ...).hex()`.
79
+ _SELF_CHECK_VALUE = 355.0764805578969
80
+ _SELF_CHECK_HEX = "4076313943ad6f27"
81
+
82
+
83
+ class WireEndiannessError(RuntimeError):
84
+ """The float channel is not big-endian on this host/interpreter.
85
+
86
+ This should be unreachable in practice — struct's ">d" format is
87
+ byte-order-explicit by definition — but the bitwise comparison this
88
+ module exists for is worthless if it is ever wrong, so it is checked
89
+ rather than assumed.
90
+ """
91
+
92
+
93
+ def _self_check() -> None:
94
+ actual = struct.pack(">d", _SELF_CHECK_VALUE).hex()
95
+ if actual != _SELF_CHECK_HEX:
96
+ raise WireEndiannessError(
97
+ f"big-endian float self-check failed: struct.pack('>d', "
98
+ f"{_SELF_CHECK_VALUE!r}).hex() == {actual!r}, expected "
99
+ f"{_SELF_CHECK_HEX!r}. The oracle's bitwise comparison is "
100
+ "meaningless if this does not hold — refusing to proceed."
101
+ )
102
+
103
+
104
+ # Run once per process import, not per call: pack_float is on the hot path
105
+ # for every slot of every entry, and the check result cannot change between
106
+ # calls in the same interpreter.
107
+ _self_check()
108
+
109
+
110
+ def pack_float(value: float) -> str:
111
+ """The wire's `<value>` text for a float: 16 lowercase hex digits.
112
+
113
+ `struct.pack(">d", value).hex()` — big-endian IEEE-754 double, not
114
+ `repr` and not `float.hex()`. A `real(4)`/`float` value must be widened
115
+ to double *before* it reaches here (spec section 2.4: widening is exact,
116
+ so the bitwise claim survives; a 32-bit value transferred directly into
117
+ a 64-bit slot has undefined padding bits). Widening is the driver
118
+ generator's job (T3/T7), not this function's — `pack_float` always packs
119
+ a full 64-bit double.
120
+ """
121
+ return struct.pack(">d", value).hex()
122
+
123
+
124
+ def unpack_float(hex_digits: str) -> float:
125
+ """The inverse of `pack_float`, for tests and for reading driver output
126
+ back into a comparable float where a comparator wants the numeric value
127
+ rather than the hex text (the default and preferred comparison is
128
+ hex-text equality, which sidesteps NaN != NaN)."""
129
+ if len(hex_digits) != 16:
130
+ raise ValueError(
131
+ f"not a 16-hex-digit float slot value: {hex_digits!r}"
132
+ )
133
+ return struct.unpack(">d", bytes.fromhex(hex_digits))[0]
134
+
135
+
136
+ # --- non-float value channel (spec section 2.4/2.5) -----------------------
137
+
138
+ # Escape nothing else: the design is explicit that %09/%0A/%25 are the whole
139
+ # alphabet. Order matters — '%' must be escaped first, or the '%' introduced
140
+ # by escaping tab/newline would itself be re-escaped.
141
+ _ESCAPES = (("%", "%25"), ("\t", "%09"), ("\n", "%0A"))
142
+
143
+
144
+ def escape_string(value: str) -> str:
145
+ """Percent-escape a string for the wire's value channel.
146
+
147
+ Escapes only tab (`%09`), newline (`%0A`) and percent (`%25`) — nothing
148
+ else — so a `CHARACTER` output containing a tab or newline cannot break
149
+ the `<key>\\t<slot>\\t<value>` line protocol (spec section 2.4).
150
+ """
151
+ for raw, escaped in _ESCAPES:
152
+ value = value.replace(raw, escaped)
153
+ return value
154
+
155
+
156
+ _UNESCAPE_RE = re.compile("%09|%0A|%25")
157
+ _UNESCAPE_MAP = {"%09": "\t", "%0A": "\n", "%25": "%"}
158
+
159
+
160
+ def unescape_string(value: str) -> str:
161
+ """The inverse of `escape_string`."""
162
+ return _UNESCAPE_RE.sub(lambda m: _UNESCAPE_MAP[m.group(0)], value)
163
+
164
+
165
+ class WireValueError(ValueError):
166
+ """A Python value has no representation in the wire's value channel."""
167
+
168
+
169
+ def format_value(value) -> str:
170
+ """The wire's `<value>` text for any scalar the protocol carries.
171
+
172
+ Dispatches on Python type: `bool` -> `0`/`1`, `int` -> decimal, `float`
173
+ -> `pack_float` (16 hex digits), `str` -> `escape_string`. `bool` is
174
+ checked before `int` because `bool` is an `int` subclass in Python.
175
+ """
176
+ if isinstance(value, bool):
177
+ return "1" if value else "0"
178
+ if isinstance(value, int):
179
+ return str(value)
180
+ if isinstance(value, float):
181
+ return pack_float(value)
182
+ if isinstance(value, str):
183
+ return escape_string(value)
184
+ raise WireValueError(
185
+ f"no wire representation for {value!r} of type {type(value).__name__}"
186
+ )
187
+
188
+
189
+ # --- slot grammar (spec section 2.5) ---------------------------------------
190
+
191
+
192
+ class SlotParseError(ValueError):
193
+ """A `slot` field could not be parsed as one of the four wire forms."""
194
+
195
+
196
+ _RETURN_RE = re.compile(r"^return(?:\[(\d+)\])?$")
197
+ _ARG_RE = re.compile(r"^arg:(\d+)(?:\[(\d+)\])?$")
198
+
199
+
200
+ @dataclass(frozen=True)
201
+ class Slot:
202
+ """One observable value's address within a call.
203
+
204
+ `role` is `"return"` or `"arg"`. For `"arg"`, `arg_index` is the
205
+ Python-visible argument index (see module docstring — golden.json's
206
+ `arguments`/`argument_effects` index space, not the native parameter
207
+ position). `element` is the sub-index for `return[<n>]`/`arg:<i>[<n>]`
208
+ (a tuple-return position or an array element) and is `None` for the
209
+ scalar forms `return`/`arg:<i>`.
210
+ """
211
+
212
+ role: str # "return" | "arg"
213
+ arg_index: int | None = None
214
+ element: int | None = None
215
+
216
+ def __post_init__(self) -> None:
217
+ if self.role not in ("return", "arg"):
218
+ raise SlotParseError(f"unknown slot role {self.role!r}")
219
+ if self.role == "arg" and self.arg_index is None:
220
+ raise SlotParseError("an 'arg' slot needs arg_index")
221
+ if self.role == "return" and self.arg_index is not None:
222
+ raise SlotParseError("a 'return' slot must not carry arg_index")
223
+
224
+ def __str__(self) -> str:
225
+ base = "return" if self.role == "return" else f"arg:{self.arg_index}"
226
+ return base if self.element is None else f"{base}[{self.element}]"
227
+
228
+ @classmethod
229
+ def parse(cls, text: str) -> "Slot":
230
+ match = _RETURN_RE.match(text)
231
+ if match:
232
+ element = match.group(1)
233
+ return cls("return", element=int(element) if element is not None else None)
234
+ match = _ARG_RE.match(text)
235
+ if match:
236
+ index, element = match.groups()
237
+ return cls(
238
+ "arg",
239
+ arg_index=int(index),
240
+ element=int(element) if element is not None else None,
241
+ )
242
+ raise SlotParseError(
243
+ f"malformed slot {text!r}: expected 'return', 'return[<n>]', "
244
+ "'arg:<i>' or 'arg:<i>[<n>]'"
245
+ )
246
+
247
+
248
+ def return_slot(element: int | None = None) -> Slot:
249
+ return Slot("return", element=element)
250
+
251
+
252
+ def arg_slot(index: int, element: int | None = None) -> Slot:
253
+ return Slot("arg", arg_index=index, element=element)
254
+
255
+
256
+ # --- line format (spec section 2.5) -----------------------------------------
257
+
258
+
259
+ class WireFormatError(ValueError):
260
+ """A line, or a stream of lines, violates the wire protocol.
261
+
262
+ Always carries the offending line's text (spec: "a malformed line,
263
+ duplicate (key, slot), or key out of expected order is an error carrying
264
+ the offending line text") — never just an index or a summary, so the
265
+ failure is legible without re-running the driver.
266
+ """
267
+
268
+
269
+ def format_line(key: str, slot: "Slot | str", value) -> str:
270
+ """One wire line: `<key>\\t<slot>\\t<value>`.
271
+
272
+ `value` is the already-Python-typed value (a float, int, bool or str);
273
+ `format_value` produces its `<value>` text. Pass a pre-formatted string
274
+ only if the caller has already escaped/packed it itself.
275
+ """
276
+ slot_text = str(slot)
277
+ value_text = value if isinstance(value, str) and _looks_preformatted(value) else format_value(value)
278
+ return f"{key}\t{slot_text}\t{value_text}"
279
+
280
+
281
+ def _looks_preformatted(_value: str) -> bool:
282
+ # format_value already handles str via escape_string; there is no case
283
+ # where a caller needs to bypass it. Kept as an explicit hook (rather
284
+ # than silently double-escaping) in case a future caller hands in text
285
+ # it already escaped itself — today, always False.
286
+ return False
287
+
288
+
289
+ def parse_line(line: str) -> tuple[str, Slot, str]:
290
+ """Split one wire line into `(key, slot, raw_value_text)`.
291
+
292
+ `raw_value_text` is returned unparsed (still hex/decimal/escaped) —
293
+ callers that know the expected type call `unpack_float`/`int`/
294
+ `unescape_string` themselves, because this function has no way to know
295
+ which channel a given slot uses without the IR.
296
+
297
+ Raises `WireFormatError` carrying the offending line on anything that
298
+ is not exactly three tab-separated fields, or whose slot field does not
299
+ parse.
300
+ """
301
+ fields = line.split("\t")
302
+ if len(fields) != 3:
303
+ raise WireFormatError(
304
+ f"malformed wire line (expected 3 tab-separated fields, got "
305
+ f"{len(fields)}): {line!r}"
306
+ )
307
+ key, slot_text, value_text = fields
308
+ if not key:
309
+ raise WireFormatError(f"malformed wire line (empty key): {line!r}")
310
+ try:
311
+ slot = Slot.parse(slot_text)
312
+ except SlotParseError as exc:
313
+ raise WireFormatError(f"malformed wire line ({exc}): {line!r}") from exc
314
+ return key, slot, value_text
315
+
316
+
317
+ def parse_lines(lines, expected_key_order: list[str] | None = None) -> dict[str, dict[Slot, str]]:
318
+ """Parse a whole driver output stream into `{key: {slot: raw_value}}`.
319
+
320
+ Enforces, across the whole stream, the three failure modes spec section
321
+ 2.5 names: a malformed line, a duplicate `(key, slot)` pair, or a key
322
+ appearing out of the expected order. Each is a hard `WireFormatError`
323
+ carrying the offending line's text — never a skip, per spec section 2.2
324
+ ("the driver executes exactly the file's entries ... nothing after it is
325
+ compared").
326
+
327
+ `expected_key_order` is the call order the driver was generated from
328
+ (golden.json's entry order, spec section 2.2) — omit it to skip the
329
+ ordering check (e.g. when parsing a partial stream for a unit test).
330
+ """
331
+ result: dict[str, dict[Slot, str]] = {}
332
+ seen_pairs: set[tuple[str, Slot]] = set()
333
+ expected = list(expected_key_order) if expected_key_order is not None else None
334
+ next_expected_position = 0
335
+ key_position: dict[str, int] = {}
336
+
337
+ for raw_line in lines:
338
+ line = raw_line.rstrip("\n")
339
+ if line == "":
340
+ continue
341
+ key, slot, value_text = parse_line(line)
342
+
343
+ if expected is not None:
344
+ if key not in key_position:
345
+ # First time this key appears: it must be the next one due,
346
+ # per the file's (and therefore the driver's) call order.
347
+ if next_expected_position >= len(expected) or expected[next_expected_position] != key:
348
+ raise WireFormatError(
349
+ f"key {key!r} appears out of the expected call order "
350
+ f"(expected {expected[next_expected_position:next_expected_position + 1] or '(end)'} next): "
351
+ f"{line!r}"
352
+ )
353
+ key_position[key] = next_expected_position
354
+ next_expected_position += 1
355
+ # A later line for an already-started key is fine (multiple
356
+ # slots per call); only the key's *first* appearance is ordered.
357
+
358
+ pair = (key, slot)
359
+ if pair in seen_pairs:
360
+ raise WireFormatError(
361
+ f"duplicate slot {slot} for key {key!r}: {line!r}"
362
+ )
363
+ seen_pairs.add(pair)
364
+ result.setdefault(key, {})[slot] = value_text
365
+
366
+ return result
367
+
368
+
369
+ # --- slots_for_entry: the source of truth (spec section 2.5, T3/T7/T5) ----
370
+
371
+
372
+ def slots_for_entry(entry: dict, ir_function: FunctionDef) -> list[Slot]:
373
+ """The expected slot list for one golden.json entry.
374
+
375
+ This is the single source of truth the Fortran/C++ driver generators
376
+ (T3/T7) emit their print statements from, and the comparator (T5)
377
+ compares against — so "which slots does this call produce" is answered
378
+ in exactly one place rather than three generators independently
379
+ reverse-engineering f2py/pybind11's output conventions.
380
+
381
+ `entry` is one value of golden.json's `"entries"` mapping (as `golden.py`
382
+ writes it): `{"arguments": [...], "result": ..., "argument_effects":
383
+ {...}, ...}`. `ir_function` is that entry's `FunctionDef` from the IR
384
+ (used to confirm whether the routine has a return path at all — a
385
+ subroutine with no `intent(out)` parameter and no recorded result has
386
+ no `return` slot).
387
+
388
+ Slot derivation:
389
+
390
+ * **Return.** `ir_function` has a return path when its declared return
391
+ type is not `"void"`, or it declares at least one `intent(out)`
392
+ parameter (f2py folds those into the return tuple — spec section
393
+ 2.5's table). Where there is a return path, the *shape* of the
394
+ recorded `entry["result"]` decides the slot(s): `None` -> no slot (a
395
+ subroutine whose only outputs are `intent(inout)` arguments, which
396
+ golden.py already records as `None` — see golden.py's `invoke`
397
+ docstring); a scalar (`int`/`float`/`bool`/`str`) -> one `return`
398
+ slot; a flat list -> `return[<n>]` for each element, in order (f2py's
399
+ tuple-of-outputs convention). A function declaring a return path whose
400
+ recorded result is `None` is a golden-recording anomaly, not a driver
401
+ bug, and is treated the same as "no return slot" here — the mismatch
402
+ would already have been caught upstream by golden itself.
403
+ * **Arguments.** One `arg:<i>` (or, for a list-valued effect,
404
+ `arg:<i>[<n>]` per element) for every key in the recorded
405
+ `entry["argument_effects"]` — golden.py only records an effect for an
406
+ argument the call actually modified in place, and that is exactly the
407
+ set of argument positions this layer needs to observe. `<i>` is
408
+ already the Python-visible index (golden.py builds
409
+ `argument_effects` keyed that way), so it is used as-is — see the
410
+ module docstring for why this is *not* the native parameter index.
411
+
412
+ Order: `return`/`return[<n>]` slots first (in tuple order), then
413
+ `arg:<i>[...]` slots in ascending argument-index order (and ascending
414
+ element order within one argument) — the order the driver generator
415
+ emits print statements in and the order a comparator walks.
416
+ """
417
+ slots: list[Slot] = []
418
+
419
+ has_return_path = ir_function.returns != "void" or any(
420
+ p.intent == "out" for p in ir_function.parameters
421
+ )
422
+ result = entry.get("result")
423
+ if has_return_path and result is not None:
424
+ if isinstance(result, list):
425
+ slots.extend(return_slot(i) for i in range(len(result)))
426
+ else:
427
+ slots.append(return_slot())
428
+
429
+ effects = entry.get("argument_effects") or {}
430
+ for index_text in sorted(effects, key=int):
431
+ index = int(index_text)
432
+ value = effects[index_text]
433
+ if isinstance(value, list):
434
+ slots.extend(arg_slot(index, i) for i in range(len(value)))
435
+ else:
436
+ slots.append(arg_slot(index))
437
+
438
+ return slots