nativegate 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nativegate/__init__.py +1 -0
- nativegate/__main__.py +4 -0
- nativegate/buildinfo.py +344 -0
- nativegate/cli.py +2007 -0
- nativegate/config.py +991 -0
- nativegate/declared_invariants.py +565 -0
- nativegate/discovery.py +167 -0
- nativegate/driverbuild.py +626 -0
- nativegate/drivers/__init__.py +5 -0
- nativegate/drivers/cpp.py +616 -0
- nativegate/drivers/fortran.py +507 -0
- nativegate/generators/__init__.py +0 -0
- nativegate/generators/cmake_gen.py +101 -0
- nativegate/generators/docker_gen.py +614 -0
- nativegate/generators/error_gen.py +104 -0
- nativegate/generators/f2py_gen.py +91 -0
- nativegate/generators/gateway_gen.py +110 -0
- nativegate/generators/golden_gen.py +50 -0
- nativegate/generators/k8s_gen.py +212 -0
- nativegate/generators/mcp_gen.py +281 -0
- nativegate/generators/middleware_gen.py +717 -0
- nativegate/generators/pybind_gen.py +406 -0
- nativegate/generators/pyproject_gen.py +61 -0
- nativegate/generators/python_pkg_gen.py +1164 -0
- nativegate/generators/test_gen.py +160 -0
- nativegate/golden.py +747 -0
- nativegate/invariants.py +532 -0
- nativegate/ir.py +789 -0
- nativegate/lattice.py +350 -0
- nativegate/locking.py +216 -0
- nativegate/oracle.py +904 -0
- nativegate/parsers/__init__.py +0 -0
- nativegate/parsers/cpp.py +105 -0
- nativegate/parsers/cpp_ast.py +1652 -0
- nativegate/parsers/cpp_regex.py +812 -0
- nativegate/parsers/fixed_form.py +868 -0
- nativegate/parsers/fortran.py +157 -0
- nativegate/parsers/fortran_fparser.py +1116 -0
- nativegate/parsers/fortran_regex.py +686 -0
- nativegate/preprocess.py +335 -0
- nativegate/structural_invariants.py +762 -0
- nativegate/suggest.py +208 -0
- nativegate/templates/__init__.py +20 -0
- nativegate/templates/golden_test_template.py +248 -0
- nativegate/wire.py +438 -0
- nativegate-0.1.0.dist-info/METADATA +547 -0
- nativegate-0.1.0.dist-info/RECORD +50 -0
- nativegate-0.1.0.dist-info/WHEEL +5 -0
- nativegate-0.1.0.dist-info/entry_points.txt +3 -0
- nativegate-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,626 @@
|
|
|
1
|
+
"""T4 — turn a generated oracle driver source into a runnable binary.
|
|
2
|
+
|
|
3
|
+
Spec `design-verification-layers.md` section 2.3 ("one set of object code,
|
|
4
|
+
not two compilations of one source"), section 2.8 (hard preconditions),
|
|
5
|
+
section 4 rules 2-3, 5, 8. Read those, plus `buildinfo.py` (T1) and
|
|
6
|
+
`drivers/fortran.py` (T3), before changing anything here.
|
|
7
|
+
|
|
8
|
+
What this module does NOT do, on purpose:
|
|
9
|
+
|
|
10
|
+
* It never recompiles a library source. The whole bitwise claim in section
|
|
11
|
+
2.3 rests on the driver and the extension executing the *identical*
|
|
12
|
+
machine code for the library routines — a second compilation of the same
|
|
13
|
+
`.f90`/`.f` files, even with identical flags, is a second body of object
|
|
14
|
+
code the compiler is free to have inlined or contracted differently.
|
|
15
|
+
`link_objects_for_sources` only ever *reads* a `compile_commands.json`
|
|
16
|
+
entry's already-recorded `output` path; it does not invoke a compiler.
|
|
17
|
+
* It never widens the safety gate into a warning. `buildinfo.refuse_unsafe`
|
|
18
|
+
is called before anything is compiled, and a mismatch between the driver's
|
|
19
|
+
own codegen-affecting flags and the extension's is `DriverFlagMismatchError`
|
|
20
|
+
— both hard errors (spec section 2.8, section 4 rule 2).
|
|
21
|
+
* It never guesses at threading. Every subprocess this module runs — the
|
|
22
|
+
driver TU compile is a single-threaded compiler invocation, so that one is
|
|
23
|
+
unaffected, but the *driver executable* — runs under
|
|
24
|
+
`buildinfo.pinned_environment()` (spec section 4 rule 3).
|
|
25
|
+
|
|
26
|
+
What "the built library objects" means here, concretely: for the Fortran/
|
|
27
|
+
f2py-via-meson path this repository actually uses (`services/petro_api`),
|
|
28
|
+
`compile_commands.json`'s per-source `output` field is the `.o` file meson's
|
|
29
|
+
ninja backend wrote for that source — already `-fPIC` (meson builds every
|
|
30
|
+
source of a shared-library/extension target position-independent), and
|
|
31
|
+
linking `-fPIC` objects into a plain executable is legal everywhere this
|
|
32
|
+
runs (spec section 2.3's stated consequence). This module links exactly
|
|
33
|
+
those files, in the order the caller names their sources, plus the freshly
|
|
34
|
+
compiled driver object — nothing else.
|
|
35
|
+
|
|
36
|
+
**A build-pipeline gap this module used to work around, now fixed for the
|
|
37
|
+
Fortran path:** `ngate build <name>` runs `pip wheel .`, which drives
|
|
38
|
+
scikit-build-core → CMake → the generated `add_custom_command` that shells
|
|
39
|
+
out to `python -m numpy.f2py -c --backend meson ...` (see
|
|
40
|
+
`generators/f2py_gen.py`). That command now passes an explicit
|
|
41
|
+
`--build-dir "${CMAKE_CURRENT_SOURCE_DIR}/.nativegate/build"`, so f2py no
|
|
42
|
+
longer falls back to `tempfile.mkdtemp()` for its meson build directory (see
|
|
43
|
+
`numpy/f2py/f2py2e.py:run_compile`) and the build tree survives at a fixed,
|
|
44
|
+
documented location: `services/<name>/.nativegate/build/bbdir/compile_commands.json`,
|
|
45
|
+
with the corresponding `.o` files alongside it. After a real
|
|
46
|
+
`ngate build petro_api`, that `compile_commands.json` is directly
|
|
47
|
+
loadable by `buildinfo.load_compile_commands` and usable by this module's
|
|
48
|
+
`link_objects_for_sources`/`build_and_run_driver` without any workaround.
|
|
49
|
+
The analogous C++/`cmake_gen.py` gap (`CMAKE_EXPORT_COMPILE_COMMANDS`
|
|
50
|
+
pointing into a throwaway CMake build tree rather than a pinned one) is a
|
|
51
|
+
separate, still-open gap — out of scope here.
|
|
52
|
+
`build_extension_with_compile_commands` below is kept as a
|
|
53
|
+
test/CI-isolation helper: it reproduces the same f2py invocation
|
|
54
|
+
`CMakeLists.txt` now generates (including the `--build-dir` flag) but lets a
|
|
55
|
+
test hand it its own throwaway `build_dir` instead of writing into a real
|
|
56
|
+
service's `.nativegate/` tree, so tests don't mutate checked-out fixture
|
|
57
|
+
directories. It performs the library's one and only compilation — the
|
|
58
|
+
driver then links its outputs — so it does not violate "never recompile the
|
|
59
|
+
library sources"; it exists purely for test isolation now, not to route
|
|
60
|
+
around a missing location.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
from __future__ import annotations
|
|
64
|
+
|
|
65
|
+
import hashlib
|
|
66
|
+
import os
|
|
67
|
+
import subprocess
|
|
68
|
+
import sys
|
|
69
|
+
from dataclasses import dataclass, field
|
|
70
|
+
from pathlib import Path
|
|
71
|
+
from typing import Sequence
|
|
72
|
+
|
|
73
|
+
from . import buildinfo
|
|
74
|
+
|
|
75
|
+
__all__ = [
|
|
76
|
+
"DriverBuildResult",
|
|
77
|
+
"DriverFlagMismatchError",
|
|
78
|
+
"DriverRunError",
|
|
79
|
+
"link_objects_for_sources",
|
|
80
|
+
"compile_driver_tu",
|
|
81
|
+
"link_driver",
|
|
82
|
+
"run_driver",
|
|
83
|
+
"build_and_run_driver",
|
|
84
|
+
"build_extension_with_compile_commands",
|
|
85
|
+
"build_cxx_extension_with_compile_commands",
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class DriverFlagMismatchError(RuntimeError):
|
|
90
|
+
"""The driver TU's codegen-affecting flags differ from the extension's.
|
|
91
|
+
|
|
92
|
+
Spec section 2.8: "The driver translation unit's flags differ from the
|
|
93
|
+
extension's extracted flags in any way that affects code generation." A
|
|
94
|
+
hard refusal, not a warning — a divergence here means a subsequent
|
|
95
|
+
bitwise mismatch would measure the flags, not the binding.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class DriverRunError(RuntimeError):
|
|
100
|
+
"""The compiled driver exited non-zero. Message includes stderr (T4 §3)."""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass
|
|
104
|
+
class DriverBuildResult:
|
|
105
|
+
"""Provenance and output of one driver build+run (T4 §4)."""
|
|
106
|
+
|
|
107
|
+
driver_sha256: str
|
|
108
|
+
link_target_sha256: str
|
|
109
|
+
extracted_flags: list[str]
|
|
110
|
+
codegen_flags: list[str]
|
|
111
|
+
compile_argv: list[str]
|
|
112
|
+
link_argv: list[str]
|
|
113
|
+
stdout: str
|
|
114
|
+
stderr: str
|
|
115
|
+
returncode: int
|
|
116
|
+
executable: Path
|
|
117
|
+
driver_object: Path
|
|
118
|
+
linked_objects: list[Path] = field(default_factory=list)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# --- locating the extension's own built objects (never recompiled) --------
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def link_objects_for_sources(
|
|
125
|
+
compile_commands_path: Path, source_names: Sequence[str]
|
|
126
|
+
) -> list[Path]:
|
|
127
|
+
"""The extension's own built object files for `source_names`, in order.
|
|
128
|
+
|
|
129
|
+
Reads each source's `compile_commands.json` entry and resolves its
|
|
130
|
+
recorded `output` path against `directory` — the object file the
|
|
131
|
+
extension's *own* build already produced. This function never invokes a
|
|
132
|
+
compiler; if an entry has no `output` field (a `command`-form entry that
|
|
133
|
+
used `-o` instead — `buildinfo.flags_for_source` strips that pair rather
|
|
134
|
+
than exposing it, so this reads the raw entry directly), that source's
|
|
135
|
+
object cannot be located and a `KeyError` says so by name rather than
|
|
136
|
+
silently skipping it out of the link line.
|
|
137
|
+
"""
|
|
138
|
+
commands = buildinfo.load_compile_commands(Path(compile_commands_path))
|
|
139
|
+
objects: list[Path] = []
|
|
140
|
+
for name in source_names:
|
|
141
|
+
entry = buildinfo.find_entry(commands, name)
|
|
142
|
+
directory = Path(entry.get("directory") or Path(compile_commands_path).parent)
|
|
143
|
+
output = entry.get("output")
|
|
144
|
+
if not output:
|
|
145
|
+
output = _output_from_argv(entry)
|
|
146
|
+
if not output:
|
|
147
|
+
raise KeyError(
|
|
148
|
+
f"compile command for {name!r} has no 'output' field and no "
|
|
149
|
+
"'-o' argument — cannot locate its built object file"
|
|
150
|
+
)
|
|
151
|
+
objects.append((directory / output).resolve())
|
|
152
|
+
return objects
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _output_from_argv(entry: dict) -> str | None:
|
|
156
|
+
# buildinfo.entry_argv is the single shared implementation of "turn a
|
|
157
|
+
# compile_commands.json entry's arguments/command into an argv list" —
|
|
158
|
+
# this module used to keep its own private copy; see buildinfo.py's
|
|
159
|
+
# entry_argv docstring.
|
|
160
|
+
argv = buildinfo.entry_argv(entry)
|
|
161
|
+
for i, token in enumerate(argv):
|
|
162
|
+
if token == "-o" and i + 1 < len(argv):
|
|
163
|
+
return argv[i + 1]
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _compiler_executable(entry: dict) -> str:
|
|
168
|
+
"""argv[0] of the entry's own compile command — the same compiler, not
|
|
169
|
+
whatever happens to be first on PATH."""
|
|
170
|
+
return buildinfo.entry_argv(entry)[0]
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _module_search_flags(flags: Sequence[str], base_dir: Path) -> list[str]:
|
|
174
|
+
"""`-I`/`-J` tokens from the extension's flags, as search-only, absolute `-I`s.
|
|
175
|
+
|
|
176
|
+
The driver's `use <module>` needs to find the `.mod` file the
|
|
177
|
+
extension's own compile wrote (gfortran's `-J<dir>` sets both the write
|
|
178
|
+
location and a search location; `-Idir` is already search-only). This is
|
|
179
|
+
a *build-mechanics* necessity, not a codegen-affecting flag — it does
|
|
180
|
+
not change what machine code the compiler emits for a given AST, only
|
|
181
|
+
where it looks up module interfaces — so it is deliberately excluded
|
|
182
|
+
from `buildinfo.codegen_flags` and from the driver/extension divergence
|
|
183
|
+
check, and is added on top of the codegen subset instead.
|
|
184
|
+
|
|
185
|
+
`compile_commands.json` entries record paths relative to their own
|
|
186
|
+
`directory` field, and this module compiles the driver in a different
|
|
187
|
+
working directory (its own `work_dir`), so every path is resolved
|
|
188
|
+
against `base_dir` (the extension entry's `directory`) before use —
|
|
189
|
+
otherwise a relative `-Ifoo.so.p` silently resolves against the wrong
|
|
190
|
+
cwd and the `.mod` file is "not found" even though it exists.
|
|
191
|
+
"""
|
|
192
|
+
out: list[str] = []
|
|
193
|
+
for flag in flags:
|
|
194
|
+
if flag.startswith("-J"):
|
|
195
|
+
path = flag[2:]
|
|
196
|
+
out.append("-I" + str((base_dir / path).resolve()))
|
|
197
|
+
elif flag.startswith("-I"):
|
|
198
|
+
path = flag[2:]
|
|
199
|
+
out.append("-I" + str((base_dir / path).resolve()))
|
|
200
|
+
return out
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# --- compiling the driver TU only ------------------------------------------
|
|
204
|
+
|
|
205
|
+
# Source suffix for the driver TU, by language — the only thing that used to
|
|
206
|
+
# be hardcoded to Fortran here. Everything else (which compiler to invoke,
|
|
207
|
+
# which flags to use) already came from the extension's own
|
|
208
|
+
# compile_commands.json entry, so it was already language-agnostic.
|
|
209
|
+
_DRIVER_SUFFIX = {"fortran": ".f90", "cpp": ".cpp"}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def compile_driver_tu(
|
|
213
|
+
driver_source: str,
|
|
214
|
+
compile_commands_path: Path,
|
|
215
|
+
extension_source_name: str,
|
|
216
|
+
work_dir: Path,
|
|
217
|
+
*,
|
|
218
|
+
driver_flags: Sequence[str] | None = None,
|
|
219
|
+
language: str = "fortran",
|
|
220
|
+
) -> tuple[Path, list[str], list[str], list[str]]:
|
|
221
|
+
"""Compile ONLY the driver translation unit.
|
|
222
|
+
|
|
223
|
+
Never touches a library source. Returns
|
|
224
|
+
`(driver_object_path, compile_argv, extracted_flags, codegen_flags)`.
|
|
225
|
+
|
|
226
|
+
`language` selects the driver TU's source suffix/filename —
|
|
227
|
+
`"fortran"` (default, `.f90`, unchanged behavior) or `"cpp"` (`.cpp`).
|
|
228
|
+
It does NOT select which compiler runs: the compiler is always
|
|
229
|
+
`argv[0]` of the extension's own compile_commands.json entry for
|
|
230
|
+
`extension_source_name` — the same compiler (and, via
|
|
231
|
+
`driver_flags`/`use_codegen` below, the same codegen-affecting flags)
|
|
232
|
+
that built the extension, whether that happens to be gfortran, clang++,
|
|
233
|
+
or g++. This is what keeps the driver and the extension's objects one
|
|
234
|
+
body of machine code (spec section 2.3) regardless of source language.
|
|
235
|
+
|
|
236
|
+
Preconditions enforced here (spec section 2.8), both hard errors:
|
|
237
|
+
|
|
238
|
+
* `buildinfo.refuse_unsafe` on the extension's full extracted flags.
|
|
239
|
+
* if `driver_flags` is given, its codegen-affecting subset must equal
|
|
240
|
+
the extension's codegen-affecting subset exactly (order-sensitive) —
|
|
241
|
+
otherwise `DriverFlagMismatchError`. When omitted, the extension's own
|
|
242
|
+
codegen flags are used for the driver, so this can never diverge by
|
|
243
|
+
construction; `driver_flags` exists so a caller (and this module's own
|
|
244
|
+
negative test) can demonstrate the refusal.
|
|
245
|
+
"""
|
|
246
|
+
work_dir = Path(work_dir)
|
|
247
|
+
work_dir.mkdir(parents=True, exist_ok=True)
|
|
248
|
+
|
|
249
|
+
suffix = _DRIVER_SUFFIX.get(language)
|
|
250
|
+
if suffix is None:
|
|
251
|
+
raise ValueError(
|
|
252
|
+
f"unknown driver language {language!r} — expected one of "
|
|
253
|
+
f"{sorted(_DRIVER_SUFFIX)}"
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
commands = buildinfo.load_compile_commands(Path(compile_commands_path))
|
|
257
|
+
ext_entry = buildinfo.find_entry(commands, extension_source_name)
|
|
258
|
+
extracted_flags = buildinfo.flags_for_source(commands, extension_source_name)
|
|
259
|
+
|
|
260
|
+
buildinfo.refuse_unsafe(extracted_flags)
|
|
261
|
+
|
|
262
|
+
ext_codegen = buildinfo.codegen_flags(extracted_flags)
|
|
263
|
+
|
|
264
|
+
if driver_flags is not None:
|
|
265
|
+
given_codegen = buildinfo.codegen_flags(list(driver_flags))
|
|
266
|
+
if given_codegen != ext_codegen:
|
|
267
|
+
raise DriverFlagMismatchError(
|
|
268
|
+
"refusing to build the oracle driver: its codegen-affecting "
|
|
269
|
+
f"flags {given_codegen} differ from the extension's own "
|
|
270
|
+
f"{ext_codegen} (design-verification-layers.md section 2.8) "
|
|
271
|
+
"— a bitwise comparison built from mismatched flags would "
|
|
272
|
+
"measure the flags, not the binding"
|
|
273
|
+
)
|
|
274
|
+
use_codegen = given_codegen
|
|
275
|
+
else:
|
|
276
|
+
use_codegen = ext_codegen
|
|
277
|
+
|
|
278
|
+
compiler = _compiler_executable(ext_entry)
|
|
279
|
+
ext_directory = Path(ext_entry.get("directory") or Path(compile_commands_path).parent)
|
|
280
|
+
search_flags = _module_search_flags(extracted_flags, ext_directory)
|
|
281
|
+
|
|
282
|
+
driver_path = work_dir / f"n2p_oracle_driver{suffix}"
|
|
283
|
+
driver_path.write_text(driver_source)
|
|
284
|
+
|
|
285
|
+
obj_path = work_dir / "n2p_oracle_driver.o"
|
|
286
|
+
argv = [compiler, *use_codegen, *search_flags, "-c", str(driver_path), "-o", str(obj_path)]
|
|
287
|
+
|
|
288
|
+
completed = subprocess.run(argv, cwd=work_dir, capture_output=True, text=True, check=False)
|
|
289
|
+
if completed.returncode != 0:
|
|
290
|
+
raise DriverRunError(
|
|
291
|
+
f"compiling the oracle driver TU failed ({completed.returncode}):\n"
|
|
292
|
+
f" $ {' '.join(argv)}\n{completed.stdout}\n{completed.stderr}"
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
return obj_path, argv, extracted_flags, use_codegen
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
# --- linking ----------------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def link_driver(
|
|
302
|
+
driver_object: Path,
|
|
303
|
+
link_objects: Sequence[Path],
|
|
304
|
+
compiler: str,
|
|
305
|
+
work_dir: Path,
|
|
306
|
+
*,
|
|
307
|
+
extra_link_args: Sequence[str] = (),
|
|
308
|
+
) -> tuple[Path, list[str]]:
|
|
309
|
+
"""Link the driver object against the extension's own built objects.
|
|
310
|
+
|
|
311
|
+
Nothing here compiles anything — every path in `link_objects` must
|
|
312
|
+
already exist (`link_objects_for_sources`'s job) — so this function
|
|
313
|
+
cannot accidentally become a second compilation of library sources.
|
|
314
|
+
"""
|
|
315
|
+
work_dir = Path(work_dir)
|
|
316
|
+
for obj in link_objects:
|
|
317
|
+
if not Path(obj).exists():
|
|
318
|
+
raise FileNotFoundError(
|
|
319
|
+
f"link object {obj} does not exist — it must be the extension's "
|
|
320
|
+
"own build output, never something this module compiles"
|
|
321
|
+
)
|
|
322
|
+
exe_path = work_dir / ("n2p_oracle_driver.exe" if os.name == "nt" else "n2p_oracle_driver")
|
|
323
|
+
argv = [
|
|
324
|
+
compiler,
|
|
325
|
+
str(driver_object),
|
|
326
|
+
*[str(p) for p in link_objects],
|
|
327
|
+
*extra_link_args,
|
|
328
|
+
"-o",
|
|
329
|
+
str(exe_path),
|
|
330
|
+
]
|
|
331
|
+
completed = subprocess.run(argv, cwd=work_dir, capture_output=True, text=True, check=False)
|
|
332
|
+
if completed.returncode != 0:
|
|
333
|
+
raise DriverRunError(
|
|
334
|
+
f"linking the oracle driver failed ({completed.returncode}):\n"
|
|
335
|
+
f" $ {' '.join(argv)}\n{completed.stdout}\n{completed.stderr}"
|
|
336
|
+
)
|
|
337
|
+
return exe_path, argv
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
# --- running -----------------------------------------------------------------
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def run_driver(executable: Path, work_dir: Path) -> subprocess.CompletedProcess:
|
|
344
|
+
"""Run the driver under `buildinfo.pinned_environment()`, capture both
|
|
345
|
+
streams. Non-zero exit is a hard failure whose message includes stderr
|
|
346
|
+
(T4 §3) — raised by the caller, not here, so callers that want the raw
|
|
347
|
+
`CompletedProcess` (e.g. to also assert stdout parses) can still get it.
|
|
348
|
+
"""
|
|
349
|
+
env = dict(os.environ)
|
|
350
|
+
env.update(buildinfo.pinned_environment())
|
|
351
|
+
return subprocess.run(
|
|
352
|
+
[str(executable)], cwd=work_dir, capture_output=True, text=True, check=False, env=env
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
# --- the whole T4 pipeline in one call --------------------------------------
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def build_and_run_driver(
|
|
360
|
+
driver_source: str,
|
|
361
|
+
compile_commands_path: Path,
|
|
362
|
+
extension_source_name: str,
|
|
363
|
+
link_source_names: Sequence[str],
|
|
364
|
+
work_dir: Path,
|
|
365
|
+
*,
|
|
366
|
+
driver_flags: Sequence[str] | None = None,
|
|
367
|
+
extra_link_args: Sequence[str] = (),
|
|
368
|
+
language: str = "fortran",
|
|
369
|
+
) -> DriverBuildResult:
|
|
370
|
+
"""Compile the driver TU, link it against the extension's own built
|
|
371
|
+
objects, run it under the pinned environment, and report provenance.
|
|
372
|
+
|
|
373
|
+
`language` — `"fortran"` (default) or `"cpp"` — selects the driver TU's
|
|
374
|
+
source suffix; see `compile_driver_tu` for what it does and does not
|
|
375
|
+
control.
|
|
376
|
+
|
|
377
|
+
Raises `buildinfo.UnsafeFlagError` (fast-math in the extension's flags),
|
|
378
|
+
`DriverFlagMismatchError` (driver/extension codegen flags diverge), or
|
|
379
|
+
`DriverRunError` (compile, link, or a non-zero run — every message
|
|
380
|
+
includes the captured stderr).
|
|
381
|
+
"""
|
|
382
|
+
work_dir = Path(work_dir)
|
|
383
|
+
work_dir.mkdir(parents=True, exist_ok=True)
|
|
384
|
+
|
|
385
|
+
driver_object, compile_argv, extracted_flags, used_codegen = compile_driver_tu(
|
|
386
|
+
driver_source,
|
|
387
|
+
compile_commands_path,
|
|
388
|
+
extension_source_name,
|
|
389
|
+
work_dir,
|
|
390
|
+
driver_flags=driver_flags,
|
|
391
|
+
language=language,
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
link_objects = link_objects_for_sources(compile_commands_path, link_source_names)
|
|
395
|
+
|
|
396
|
+
commands = buildinfo.load_compile_commands(Path(compile_commands_path))
|
|
397
|
+
ext_entry = buildinfo.find_entry(commands, extension_source_name)
|
|
398
|
+
compiler = _compiler_executable(ext_entry)
|
|
399
|
+
|
|
400
|
+
executable, link_argv = link_driver(
|
|
401
|
+
driver_object, link_objects, compiler, work_dir, extra_link_args=extra_link_args
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
completed = run_driver(executable, work_dir)
|
|
405
|
+
if completed.returncode != 0:
|
|
406
|
+
raise DriverRunError(
|
|
407
|
+
f"the oracle driver exited {completed.returncode}:\n"
|
|
408
|
+
f"stdout:\n{completed.stdout}\nstderr:\n{completed.stderr}"
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
driver_sha256 = hashlib.sha256(driver_source.encode("utf-8")).hexdigest()
|
|
412
|
+
link_target_sha256 = buildinfo.link_target_hash(link_objects)
|
|
413
|
+
|
|
414
|
+
return DriverBuildResult(
|
|
415
|
+
driver_sha256=driver_sha256,
|
|
416
|
+
link_target_sha256=link_target_sha256,
|
|
417
|
+
extracted_flags=extracted_flags,
|
|
418
|
+
codegen_flags=used_codegen,
|
|
419
|
+
compile_argv=compile_argv,
|
|
420
|
+
link_argv=link_argv,
|
|
421
|
+
stdout=completed.stdout,
|
|
422
|
+
stderr=completed.stderr,
|
|
423
|
+
returncode=completed.returncode,
|
|
424
|
+
executable=executable,
|
|
425
|
+
driver_object=driver_object,
|
|
426
|
+
linked_objects=link_objects,
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
# --- test/CI fixture helper: produce a *locatable* build ---------------------
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def build_extension_with_compile_commands(
|
|
434
|
+
sources: Sequence[Path],
|
|
435
|
+
module_name: str,
|
|
436
|
+
build_dir: Path,
|
|
437
|
+
*,
|
|
438
|
+
only: Sequence[str] | None = None,
|
|
439
|
+
) -> Path:
|
|
440
|
+
"""Build the f2py/meson extension the way `CMakeLists.txt` does, but with
|
|
441
|
+
an explicit `--build-dir` so `compile_commands.json` and the object files
|
|
442
|
+
persist afterwards (see the module docstring's "build-pipeline gap").
|
|
443
|
+
|
|
444
|
+
This performs the library sources' ONE compilation — the same one a real
|
|
445
|
+
`ngate build` would have performed, had its generated CMake pinned a
|
|
446
|
+
location — so a driver subsequently linking the resulting objects is not
|
|
447
|
+
looking at a second body of machine code. It is not part of T4's own
|
|
448
|
+
compile/link/run contract (`build_and_run_driver` above never calls
|
|
449
|
+
this); it exists so tests and CI can hand `build_and_run_driver` a real,
|
|
450
|
+
locatable extension build without reaching into a throwaway temp
|
|
451
|
+
directory numpy.f2py orphaned.
|
|
452
|
+
|
|
453
|
+
Returns the path to `<build_dir>/bbdir/compile_commands.json`.
|
|
454
|
+
"""
|
|
455
|
+
build_dir = Path(build_dir)
|
|
456
|
+
build_dir.mkdir(parents=True, exist_ok=True)
|
|
457
|
+
argv = [
|
|
458
|
+
sys.executable,
|
|
459
|
+
"-m",
|
|
460
|
+
"numpy.f2py",
|
|
461
|
+
"-c",
|
|
462
|
+
"--backend",
|
|
463
|
+
"meson",
|
|
464
|
+
"--build-dir",
|
|
465
|
+
str(build_dir),
|
|
466
|
+
"-m",
|
|
467
|
+
module_name,
|
|
468
|
+
*[str(s) for s in sources],
|
|
469
|
+
]
|
|
470
|
+
if only:
|
|
471
|
+
argv.extend(["only:", *only, ":"])
|
|
472
|
+
# f2py's meson backend both (a) writes its generated wrapper sources
|
|
473
|
+
# into the current working directory before copying them into
|
|
474
|
+
# build_dir (so cwd cannot BE build_dir — that copy collides with
|
|
475
|
+
# itself, "are the same file") and (b) moves the finished extension to
|
|
476
|
+
# cwd when the build completes ("move exec to root"). Use a cwd that is
|
|
477
|
+
# a sibling of build_dir, still under this function's caller-supplied
|
|
478
|
+
# directory, so nothing lands in the repository root.
|
|
479
|
+
run_cwd = build_dir.parent / f"{build_dir.name}-f2py-cwd"
|
|
480
|
+
run_cwd.mkdir(parents=True, exist_ok=True)
|
|
481
|
+
completed = subprocess.run(argv, cwd=run_cwd, capture_output=True, text=True, check=False)
|
|
482
|
+
if completed.returncode != 0:
|
|
483
|
+
raise DriverRunError(
|
|
484
|
+
f"building the f2py extension failed ({completed.returncode}):\n"
|
|
485
|
+
f" $ {' '.join(argv)}\n{completed.stdout}\n{completed.stderr}"
|
|
486
|
+
)
|
|
487
|
+
compile_commands = build_dir / "bbdir" / "compile_commands.json"
|
|
488
|
+
if not compile_commands.exists():
|
|
489
|
+
raise FileNotFoundError(
|
|
490
|
+
f"expected {compile_commands} after building the extension, found nothing "
|
|
491
|
+
"(meson's build directory layout may have changed)"
|
|
492
|
+
)
|
|
493
|
+
return compile_commands
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def build_cxx_extension_with_compile_commands(
|
|
497
|
+
module,
|
|
498
|
+
header_names: str | list[str],
|
|
499
|
+
native_sources: Sequence[Path],
|
|
500
|
+
module_name: str,
|
|
501
|
+
build_dir: Path,
|
|
502
|
+
) -> Path:
|
|
503
|
+
"""The C++/pybind11 analogue of `build_extension_with_compile_commands`.
|
|
504
|
+
|
|
505
|
+
Generates a pybind11 bindings TU (`generators.pybind_gen`) and a
|
|
506
|
+
`CMakeLists.txt` (`generators.cmake_gen` — already emits
|
|
507
|
+
`CMAKE_EXPORT_COMPILE_COMMANDS ON`, see that module's docstring) for
|
|
508
|
+
`module`/`native_sources`, then configures and builds them with an
|
|
509
|
+
explicit `-B build_dir`, so — unlike a real `ngate build`'s `pip
|
|
510
|
+
wheel .` (which drives scikit-build-core into a throwaway, pip-managed
|
|
511
|
+
build tree; see this module's docstring for the analogous, still-open
|
|
512
|
+
f2py-era gap on the CMake side) — `compile_commands.json` and the
|
|
513
|
+
compiled `.o`/extension files persist at a known location afterwards.
|
|
514
|
+
|
|
515
|
+
This performs `native_sources`' ONE compilation; a driver subsequently
|
|
516
|
+
linking the resulting objects is not looking at a second body of
|
|
517
|
+
machine code. Like its Fortran counterpart, it exists purely for
|
|
518
|
+
test/CI isolation — `build_and_run_driver` never calls this itself.
|
|
519
|
+
|
|
520
|
+
Returns the path to `<build_dir>/compile_commands.json`. The built
|
|
521
|
+
extension module itself lands directly under `build_dir` (CMake's
|
|
522
|
+
default `pybind11_add_module` output location for a single-config
|
|
523
|
+
generator) — see `_cxx_extension_search_dir`.
|
|
524
|
+
"""
|
|
525
|
+
from .generators import cmake_gen, pybind_gen
|
|
526
|
+
|
|
527
|
+
try:
|
|
528
|
+
import pybind11
|
|
529
|
+
except ImportError as exc: # pragma: no cover - environment-dependent
|
|
530
|
+
raise DriverRunError(
|
|
531
|
+
"building the C++/pybind11 extension requires the 'pybind11' "
|
|
532
|
+
"package to be importable (for its CMake package config dir)"
|
|
533
|
+
) from exc
|
|
534
|
+
|
|
535
|
+
build_dir = Path(build_dir)
|
|
536
|
+
build_dir.mkdir(parents=True, exist_ok=True)
|
|
537
|
+
|
|
538
|
+
src_dir = build_dir.parent / f"{build_dir.name}-src"
|
|
539
|
+
native_dir = src_dir / "native"
|
|
540
|
+
native_dir.mkdir(parents=True, exist_ok=True)
|
|
541
|
+
|
|
542
|
+
for src in native_sources:
|
|
543
|
+
src = Path(src)
|
|
544
|
+
(native_dir / src.name).write_bytes(src.read_bytes())
|
|
545
|
+
|
|
546
|
+
# The implementation sources #include their own header(s) by name, so
|
|
547
|
+
# those headers must live alongside them in native_dir too — copied from
|
|
548
|
+
# wherever the caller's sources actually are (the same directory as
|
|
549
|
+
# native_sources, since that is the service's own native/ tree).
|
|
550
|
+
if native_sources:
|
|
551
|
+
source_dir = Path(native_sources[0]).parent
|
|
552
|
+
for header_name in ([header_names] if isinstance(header_names, str) else header_names):
|
|
553
|
+
header_src = source_dir / header_name
|
|
554
|
+
if header_src.exists():
|
|
555
|
+
(native_dir / header_name).write_bytes(header_src.read_bytes())
|
|
556
|
+
|
|
557
|
+
bindings_source = pybind_gen.generate_bindings(module, header_names)
|
|
558
|
+
bindings_name = f"{module.name}_bindings.cpp"
|
|
559
|
+
(native_dir / bindings_name).write_text(bindings_source)
|
|
560
|
+
|
|
561
|
+
cmake_sources = [f"native/{Path(s).name}" for s in native_sources] + [
|
|
562
|
+
f"native/{bindings_name}"
|
|
563
|
+
]
|
|
564
|
+
cmake_text = cmake_gen.generate_cmake(module, module_name, cmake_sources)
|
|
565
|
+
(src_dir / "CMakeLists.txt").write_text(cmake_text)
|
|
566
|
+
|
|
567
|
+
configure_argv = [
|
|
568
|
+
"cmake",
|
|
569
|
+
"-S",
|
|
570
|
+
str(src_dir),
|
|
571
|
+
"-B",
|
|
572
|
+
str(build_dir),
|
|
573
|
+
f"-Dpybind11_DIR={pybind11.get_cmake_dir()}",
|
|
574
|
+
# pybind11 still defaults to its "classic" Python search (CMake's
|
|
575
|
+
# deprecated FindPythonInterp/FindPythonLibs) for projects whose
|
|
576
|
+
# `cmake_minimum_required` predates 3.27 — which cmake_gen's
|
|
577
|
+
# generated CMakeLists deliberately is (3.18, for older distros).
|
|
578
|
+
# Classic mode reads the ALL-CAPS `PYTHON_EXECUTABLE` and ignores
|
|
579
|
+
# `Python_EXECUTABLE` entirely, so without this flag the interpreter
|
|
580
|
+
# below is silently discarded ("Manually-specified variables were not
|
|
581
|
+
# used by the project") and pybind11 picks whatever `python3.9`-ish
|
|
582
|
+
# binary happens to sit first on PATH — a pyenv shim, say. The
|
|
583
|
+
# extension then gets built with THAT interpreter's ABI tag and
|
|
584
|
+
# `oracle._import_extension` fails with a bare ModuleNotFoundError,
|
|
585
|
+
# because a `.cpython-39-darwin.so` is invisible to a 3.11 run.
|
|
586
|
+
# Forcing FindPython mode makes `Python_EXECUTABLE` authoritative, so
|
|
587
|
+
# the oracle's extension is always built for the interpreter that is
|
|
588
|
+
# about to import it — the whole point of a same-process comparison.
|
|
589
|
+
# (A real `ngate build` never hits this: scikit-build-core sets this
|
|
590
|
+
# same variable itself before configuring.)
|
|
591
|
+
"-DPYBIND11_FINDPYTHON=ON",
|
|
592
|
+
f"-DPython_EXECUTABLE={sys.executable}",
|
|
593
|
+
]
|
|
594
|
+
completed = subprocess.run(configure_argv, capture_output=True, text=True, check=False)
|
|
595
|
+
if completed.returncode != 0:
|
|
596
|
+
raise DriverRunError(
|
|
597
|
+
f"configuring the C++/pybind11 extension failed ({completed.returncode}):\n"
|
|
598
|
+
f" $ {' '.join(configure_argv)}\n{completed.stdout}\n{completed.stderr}"
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
build_argv = ["cmake", "--build", str(build_dir)]
|
|
602
|
+
completed = subprocess.run(build_argv, capture_output=True, text=True, check=False)
|
|
603
|
+
if completed.returncode != 0:
|
|
604
|
+
raise DriverRunError(
|
|
605
|
+
f"building the C++/pybind11 extension failed ({completed.returncode}):\n"
|
|
606
|
+
f" $ {' '.join(build_argv)}\n{completed.stdout}\n{completed.stderr}"
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
compile_commands = build_dir / "compile_commands.json"
|
|
610
|
+
if not compile_commands.exists():
|
|
611
|
+
raise FileNotFoundError(
|
|
612
|
+
f"expected {compile_commands} after building the extension, found nothing "
|
|
613
|
+
"(CMake's build directory layout may have changed)"
|
|
614
|
+
)
|
|
615
|
+
return compile_commands
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def _cxx_extension_search_dir(build_dir: Path) -> Path:
|
|
619
|
+
"""Where the built pybind11 extension `.so`/`.pyd` lands.
|
|
620
|
+
|
|
621
|
+
CMake's default `LIBRARY_OUTPUT_DIRECTORY` for a `pybind11_add_module`
|
|
622
|
+
target, with no `install()` step run and a single-config generator
|
|
623
|
+
(Unix Makefiles/Ninja — what `cmake`'s platform default resolves to on
|
|
624
|
+
the platforms this runs on), is the build directory itself.
|
|
625
|
+
"""
|
|
626
|
+
return Path(build_dir)
|