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/suggest.py ADDED
@@ -0,0 +1,208 @@
1
+ """Rank the native sources in a tree by how cleanly they would bind.
2
+
3
+ The information needed to pick a starting file already exists — `inspect`
4
+ prints it — but only one file at a time, for someone who already knows which
5
+ files are candidates. Someone meeting a C++ codebase for the first time does
6
+ not, and the cost of guessing wrong is a service that half-binds and fails
7
+ later. This module runs the parse across a whole tree and sorts the results,
8
+ so the answer to "which file do I point at" is the first row of a table.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+
17
+ from .config import ExposeConfig
18
+ from .discovery import find_implementation_files, find_native_sources
19
+ from .ir import ModuleIR
20
+ from .parsers import cpp as cpp_parser
21
+ from .parsers.cpp_ast import ClangOptions
22
+ from .parsers import fortran as fortran_parser
23
+
24
+ # Verdicts, in ranking order. READY files bind everything they declare;
25
+ # WORKABLE ones bind more than they skip; POOR ones bind nothing useful.
26
+ READY = "ready"
27
+ WORKABLE = "workable"
28
+ POOR = "poor"
29
+
30
+ _QUOTED_INCLUDE = re.compile(r'^\s*#\s*include\s*"([^"]+)"', re.MULTILINE)
31
+
32
+ # Headers-only extensions: these are what you point nativegate at. A bare .cpp
33
+ # is an implementation file, not an interface, so it is not offered as a
34
+ # candidate unless nothing else exists.
35
+ _HEADER_SUFFIXES = {".hpp", ".hh", ".h"}
36
+
37
+
38
+ @dataclass
39
+ class Candidate:
40
+ """One native source, and how well it would bind."""
41
+
42
+ path: Path
43
+ language: str
44
+ verdict: str
45
+ bound: int = 0
46
+ skipped: int = 0
47
+ classes: int = 0
48
+ structs: int = 0
49
+ functions: int = 0
50
+ methods: int = 0
51
+ impl_files: list[Path] = field(default_factory=list)
52
+ local_includes: list[str] = field(default_factory=list)
53
+ error: str | None = None
54
+
55
+ @property
56
+ def sort_key(self) -> tuple:
57
+ # Dependencies dominate, ahead of skip count: one skipped array method
58
+ # out of twenty costs you that method, while one #include of another
59
+ # subsystem's header costs you that whole subsystem's build. Only
60
+ # outright unusable files (POOR) sort below a dependency-heavy one.
61
+ return (
62
+ 1 if self.verdict == POOR else 0,
63
+ len(self.local_includes),
64
+ self.skipped,
65
+ -self.bound,
66
+ str(self.path),
67
+ )
68
+
69
+ def binds_summary(self) -> str:
70
+ parts = []
71
+ if self.functions:
72
+ parts.append(f"{self.functions} fn")
73
+ if self.classes:
74
+ parts.append(f"{self.classes} class")
75
+ if self.methods:
76
+ parts.append(f"{self.methods} method")
77
+ if self.structs:
78
+ parts.append(f"{self.structs} struct")
79
+ return ", ".join(parts) or "nothing"
80
+
81
+ def notes(self) -> str:
82
+ if self.error:
83
+ return self.error
84
+ notes = []
85
+ if self.impl_files:
86
+ notes.append(", ".join(f.name for f in self.impl_files))
87
+ elif self.language == "cpp" and (self.methods or self.functions):
88
+ notes.append("no .cpp found")
89
+ if self.local_includes:
90
+ shown = sorted(self.local_includes)
91
+ extra = ""
92
+ if len(shown) > 2:
93
+ shown, extra = shown[:2], f" +{len(self.local_includes) - 2}"
94
+ notes.append("needs " + ", ".join(shown) + extra)
95
+ return "; ".join(notes)
96
+
97
+
98
+ def _local_includes(source: Path, impl_files: list[Path]) -> list[str]:
99
+ """Quoted #includes that resolve to a real neighbouring header.
100
+
101
+ Scans the implementation files too: a header can look self-contained
102
+ while its .cpp pulls in a bridge to another subsystem, and that
103
+ dependency is exactly what makes a file a poor first choice.
104
+ """
105
+ found: set[str] = set()
106
+ for path in [source, *impl_files]:
107
+ try:
108
+ text = path.read_text(errors="replace")
109
+ except OSError:
110
+ continue
111
+ for name in _QUOTED_INCLUDE.findall(text):
112
+ included = Path(name).name
113
+ if Path(included).stem == source.stem:
114
+ continue # the .cpp including its own header
115
+ # Only count it if it actually exists nearby — a missing include
116
+ # is a build problem, not a dependency worth ranking on.
117
+ if (path.parent / name).exists() or (source.parent / included).exists():
118
+ found.add(included)
119
+ return sorted(found)
120
+
121
+
122
+ def _classify(module: ModuleIR, local_includes: list[str]) -> tuple[str, dict]:
123
+ methods = sum(len(cls.methods) for cls in module.classes)
124
+ counts = {
125
+ "classes": len(module.classes),
126
+ "structs": len(module.structs),
127
+ "functions": len(module.functions),
128
+ "methods": methods,
129
+ }
130
+ bound = methods + len(module.functions) + len(module.structs)
131
+ skipped = len(module.skipped)
132
+
133
+ if bound == 0 or skipped >= bound:
134
+ verdict = POOR
135
+ elif skipped == 0 and not local_includes:
136
+ # Turnkey: everything it declares binds, and it drags nothing else in.
137
+ verdict = READY
138
+ else:
139
+ verdict = WORKABLE
140
+ return verdict, {"bound": bound, "skipped": skipped, **counts}
141
+
142
+
143
+ def _analyse_cpp(source: Path, backend: str, options: ClangOptions) -> Candidate:
144
+ impl_files = find_implementation_files(source)
145
+ try:
146
+ module = cpp_parser.parse_header(
147
+ source, ExposeConfig(), backend=backend, options=options
148
+ )
149
+ except Exception as exc: # a header that will not parse is a real answer
150
+ return Candidate(
151
+ path=source,
152
+ language="cpp",
153
+ verdict=POOR,
154
+ impl_files=impl_files,
155
+ error=f"parse failed: {type(exc).__name__}",
156
+ )
157
+
158
+ local_includes = _local_includes(source, impl_files)
159
+ verdict, counts = _classify(module, local_includes)
160
+ return Candidate(
161
+ path=source,
162
+ language="cpp",
163
+ verdict=verdict,
164
+ impl_files=impl_files,
165
+ local_includes=local_includes,
166
+ **counts,
167
+ )
168
+
169
+
170
+ def _analyse_fortran(source: Path) -> Candidate:
171
+ try:
172
+ routines = fortran_parser.list_routine_names(source)
173
+ except Exception as exc:
174
+ return Candidate(
175
+ path=source,
176
+ language="fortran",
177
+ verdict=POOR,
178
+ error=f"parse failed: {type(exc).__name__}",
179
+ )
180
+ return Candidate(
181
+ path=source,
182
+ language="fortran",
183
+ verdict=READY if routines else POOR,
184
+ bound=len(routines),
185
+ functions=len(routines),
186
+ )
187
+
188
+
189
+ def analyse_tree(
190
+ root: Path,
191
+ backend: str = "auto",
192
+ options: ClangOptions | None = None,
193
+ ) -> list[Candidate]:
194
+ """Parse every native source under `root`, best starting point first."""
195
+ options = options or ClangOptions()
196
+ candidates: list[Candidate] = []
197
+
198
+ cpp_sources = find_native_sources(root, "cpp")
199
+ headers = [p for p in cpp_sources if p.suffix.lower() in _HEADER_SUFFIXES]
200
+ # Prefer headers; fall back to .cpp only for projects that have no headers
201
+ # at all, where the .cpp really is the interface.
202
+ for source in headers or cpp_sources:
203
+ candidates.append(_analyse_cpp(source, backend, options))
204
+
205
+ for source in find_native_sources(root, "fortran"):
206
+ candidates.append(_analyse_fortran(source))
207
+
208
+ return sorted(candidates, key=lambda c: c.sort_key)
@@ -0,0 +1,20 @@
1
+ """Files that are shipped verbatim and rendered into a generated service.
2
+
3
+ A template here is a REAL `.py` file, not a string literal inside a
4
+ generator. That is the whole point: as a string it was invisible to this
5
+ project's own linting, type checking and test suite, so a syntax error or a
6
+ stale reference inside it only surfaced when somebody ran the generated
7
+ service. As a module it is parsed, compiled and checked like any other file.
8
+
9
+ Rendering is plain textual substitution of the sentinel names below — not
10
+ `str.format`, which would require every literal brace in the template to be
11
+ doubled and so make the file invalid Python again.
12
+
13
+ The sentinels are chosen to be syntactically valid where they appear
14
+ (`NATIVEGATE_PACKAGE` stands in for a module name, the other two live inside
15
+ string literals), which is why the file compiles on its own. It is not
16
+ *runnable* on its own: `import NATIVEGATE_PACKAGE` resolves only once the
17
+ name has been substituted for a real service package.
18
+ """
19
+
20
+ from __future__ import annotations
@@ -0,0 +1,248 @@
1
+ # Generated by nativegate. Do not edit by hand — re-run `ngate generate`.
2
+ """Numerical regression: the answers must not change.
3
+
4
+ `golden.json` records what every bound entry point returned for a fixed set
5
+ of inputs, the tolerance each entry is held to, and the toolchain the values
6
+ were recorded on. This replays those calls against the currently built
7
+ extension and compares.
8
+
9
+ A failure here does not necessarily mean a bug — it means the numbers moved.
10
+ Two things move them: the code changed, or the toolchain did. The failure
11
+ message reports the recorded toolchain against this one so you can tell which
12
+ before investigating. Then re-record deliberately with:
13
+
14
+ ngate golden record NATIVEGATE_SERVICE_NAME
15
+
16
+ and review the diff in golden.json like any other change to behaviour.
17
+ """
18
+
19
+ import json
20
+ import math
21
+ import platform
22
+ import shutil
23
+ import subprocess
24
+ from pathlib import Path
25
+
26
+ import pytest
27
+
28
+ import NATIVEGATE_PACKAGE
29
+
30
+ GOLDEN_PATH = Path(__file__).resolve().parent.parent / "NATIVEGATE_GOLDEN_FILENAME"
31
+
32
+
33
+ def _numpy():
34
+ try:
35
+ import numpy
36
+ except ImportError:
37
+ return None
38
+ return numpy
39
+
40
+
41
+ def _is_number(value):
42
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
43
+
44
+
45
+ def _materialise(value):
46
+ if isinstance(value, dict) and "__struct__" in value:
47
+ native = getattr(NATIVEGATE_PACKAGE, value["__struct__"])()
48
+ for name, field in value["fields"].items():
49
+ setattr(native, name, _materialise(field))
50
+ return native
51
+ if isinstance(value, list) and value and all(_is_number(v) for v in value):
52
+ numpy = _numpy()
53
+ if numpy is not None:
54
+ dtype = "int64" if all(isinstance(v, int) for v in value) else "float64"
55
+ return numpy.array(value, dtype=dtype)
56
+ return value
57
+
58
+
59
+ def _jsonable(value):
60
+ if value is None or isinstance(value, (bool, int, float, str)):
61
+ return value
62
+ if isinstance(value, (list, tuple)):
63
+ return [_jsonable(v) for v in value]
64
+ if hasattr(value, "tolist"):
65
+ return _jsonable(value.tolist())
66
+ return {
67
+ name: _jsonable(getattr(value, name))
68
+ for name in dir(value)
69
+ if not name.startswith("_") and not callable(getattr(value, name))
70
+ }
71
+
72
+
73
+ def _invoke(entry):
74
+ """Return (result, {argument index: value after the call}).
75
+
76
+ A Fortran routine with an `intent(inout)` array argument returns None and
77
+ writes its answer into the caller's array; comparing only the return value
78
+ would compare None against None and prove nothing.
79
+ """
80
+ arguments = [_materialise(a) for a in entry["arguments"]]
81
+ if entry["kind"] == "function":
82
+ target = getattr(NATIVEGATE_PACKAGE, entry["name"])
83
+ elif entry["kind"] == "static":
84
+ target = getattr(getattr(NATIVEGATE_PACKAGE, entry["class"]), entry["name"])
85
+ else:
86
+ cls = getattr(NATIVEGATE_PACKAGE, entry["class"])
87
+ instance = cls(*[_materialise(a) for a in entry["constructor_arguments"]])
88
+ target = getattr(instance, entry["name"])
89
+ result = _jsonable(target(*arguments))
90
+ effects = {}
91
+ for index, (before, after) in enumerate(zip(entry["arguments"], arguments)):
92
+ if isinstance(before, list):
93
+ now = _jsonable(after)
94
+ if now != before:
95
+ effects[str(index)] = now
96
+ return result, effects
97
+
98
+
99
+ def _tool_version(command):
100
+ executable = shutil.which(command)
101
+ if executable is None:
102
+ return None
103
+ try:
104
+ done = subprocess.run(
105
+ [executable, "--version"], capture_output=True, text=True,
106
+ timeout=30, check=False,
107
+ )
108
+ except OSError:
109
+ return None
110
+ lines = (done.stdout or done.stderr or "").strip().splitlines()
111
+ return lines[0].strip() if lines else None
112
+
113
+
114
+ def _environment():
115
+ """This machine, in the same shape golden.json recorded.
116
+
117
+ Source digests are deliberately absent: a deployed service does not ship
118
+ the native sources, so re-hashing them here is not possible. The recorded
119
+ digests still say which sources the values came from.
120
+ """
121
+ numpy = _numpy()
122
+ return {
123
+ "platform": "%s %s" % (platform.system(), platform.release()),
124
+ "machine": platform.machine(),
125
+ "python": platform.python_version(),
126
+ "python_implementation": platform.python_implementation(),
127
+ "numpy": None if numpy is None else numpy.__version__,
128
+ "fortran_compiler": _tool_version("gfortran"),
129
+ "cxx_compiler": _tool_version("clang++") or _tool_version("g++"),
130
+ }
131
+
132
+
133
+ def _toolchain_report(recorded):
134
+ if not recorded:
135
+ return "golden.json records no provenance, so a toolchain change cannot be ruled out."
136
+ current = _environment()
137
+ moved = [
138
+ " %s: recorded %r, now %r" % (key, recorded.get(key), value)
139
+ for key, value in current.items()
140
+ if key in recorded and recorded[key] != value
141
+ ]
142
+ if not moved:
143
+ return (
144
+ "The toolchain matches what golden.json recorded, so this is a change "
145
+ "in behaviour, not a platform difference."
146
+ )
147
+ return (
148
+ "The toolchain ALSO changed since these values were recorded. A platform "
149
+ "difference looks exactly like a regression — rule it out first:\n"
150
+ + "\n".join(moved)
151
+ )
152
+
153
+
154
+ def _close(expected, actual, rtol, atol):
155
+ if isinstance(expected, bool) or isinstance(actual, bool):
156
+ return expected == actual
157
+ if isinstance(expected, (int, float)) and isinstance(actual, (int, float)):
158
+ if math.isnan(expected) and math.isnan(actual):
159
+ return True
160
+ return math.isclose(expected, actual, rel_tol=rtol, abs_tol=atol)
161
+ if isinstance(expected, list) and isinstance(actual, list):
162
+ return len(expected) == len(actual) and all(
163
+ _close(e, a, rtol, atol) for e, a in zip(expected, actual)
164
+ )
165
+ if isinstance(expected, dict) and isinstance(actual, dict):
166
+ return expected.keys() == actual.keys() and all(
167
+ _close(expected[k], actual[k], rtol, atol) for k in expected
168
+ )
169
+ return expected == actual
170
+
171
+
172
+ def _document():
173
+ if not GOLDEN_PATH.exists():
174
+ pytest.skip(
175
+ f"no {GOLDEN_PATH.name} recorded yet — run `ngate golden record NATIVEGATE_SERVICE_NAME`"
176
+ )
177
+ return json.loads(GOLDEN_PATH.read_text())
178
+
179
+
180
+ def test_golden_values_are_unchanged():
181
+ document = _document()
182
+ fallback = document.get("tolerance") or {}
183
+
184
+ differences = []
185
+ for key, entry in (document.get("entries") or {}).items():
186
+ # Per entry, not one hidden global: a routine that terminates on its
187
+ # own convergence tolerance cannot be held to the same number as a
188
+ # closed-form correlation, and saying so in the file is honest where
189
+ # loosening every entry to match the worst one is not.
190
+ own = entry.get("tolerance") or {}
191
+ rtol = own.get("rtol", fallback.get("rtol", 1e-9))
192
+ atol = own.get("atol", fallback.get("atol", 1e-12))
193
+ try:
194
+ actual, effects = _invoke(entry)
195
+ except Exception as exc: # noqa: BLE001
196
+ differences.append(f"{key}: recorded, but this build cannot call it — {exc}")
197
+ continue
198
+ expected = entry.get("result")
199
+ if not _close(expected, actual, rtol, atol):
200
+ differences.append(
201
+ f"{key}: expected {expected!r}, got {actual!r} (rtol={rtol}, atol={atol})"
202
+ )
203
+ for index, was in (entry.get("argument_effects") or {}).items():
204
+ if index not in effects:
205
+ differences.append(
206
+ f"{key}: argument {index} was written in place when recorded, "
207
+ "and this build does not write it"
208
+ )
209
+ elif not _close(was, effects[index], rtol, atol):
210
+ differences.append(
211
+ f"{key}: argument {index} after the call: expected {was!r}, "
212
+ f"got {effects[index]!r}"
213
+ )
214
+
215
+ assert not differences, (
216
+ "numerical regression:\n "
217
+ + "\n ".join(differences)
218
+ + "\n\n"
219
+ + _toolchain_report(document.get("provenance"))
220
+ )
221
+
222
+
223
+ def test_golden_file_covers_the_api():
224
+ # A golden file that records two of forty entry points passes every run
225
+ # and proves almost nothing. This asserts only that *something* is
226
+ # covered; the skip reasons in the file say what is not.
227
+ document = _document()
228
+ assert document.get("entries"), (
229
+ "golden.json records no entry points at all — see its 'skipped' block "
230
+ "for why, then re-record with `ngate golden record NATIVEGATE_SERVICE_NAME`"
231
+ )
232
+
233
+
234
+ def test_golden_file_records_the_toolchain_it_was_recorded_on():
235
+ # Without this, a failure above cannot distinguish a regression from a
236
+ # different compiler — and the documented remedy (re-record) would erase
237
+ # the only evidence of which it was.
238
+ document = _document()
239
+ recorded = document.get("provenance") or {}
240
+ missing = [
241
+ field
242
+ for field in ("platform", "machine", "python", "sources")
243
+ if not recorded.get(field)
244
+ ]
245
+ assert not missing, (
246
+ "golden.json is missing provenance %r — re-record with "
247
+ "`ngate golden record NATIVEGATE_SERVICE_NAME` to capture it" % missing
248
+ )