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
nativegate/lattice.py
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"""The verification lattice: fixed, declared points to check properties over.
|
|
2
|
+
|
|
3
|
+
Layer 3 (design-verification-layers.md section 3) trades "one recorded point"
|
|
4
|
+
for "a swept surface" without reaching for randomness or a full cartesian
|
|
5
|
+
explosion. The whole guarantee of the layer rests on the lattice being the
|
|
6
|
+
*same* points on every machine forever, so this module is deliberately pure
|
|
7
|
+
(no native calls, no `random`, no `numpy.random` global state) and every
|
|
8
|
+
formula it uses is pinned to the bit in the spec:
|
|
9
|
+
|
|
10
|
+
* `sweep()` implements section 3.4's point-generation formula literally,
|
|
11
|
+
in the exact operation association written there. A mathematically
|
|
12
|
+
equivalent reassociation (e.g. precomputing the step and multiplying) is
|
|
13
|
+
*not* interchangeable — see "Why the formula's association matters" below
|
|
14
|
+
and the test that demonstrates it.
|
|
15
|
+
* `scatter()` implements splitmix64 itself (it is ~10 lines and completely
|
|
16
|
+
specified) rather than depending on `random`, whose algorithm and stream
|
|
17
|
+
are not specified to be stable across Python versions.
|
|
18
|
+
* `build_entry_lattice()` is the per-entry assembly described in section
|
|
19
|
+
3.4 item 1: every argument held at the value `golden.json` recorded for
|
|
20
|
+
that entry, one declared-range parameter swept at a time.
|
|
21
|
+
|
|
22
|
+
Why the formula's association matters
|
|
23
|
+
--------------------------------------
|
|
24
|
+
|
|
25
|
+
`x_i = lo + (i * (hi - lo)) / (n - 1)` and the mathematically-equivalent-in-
|
|
26
|
+
real-arithmetic `lo * (1 - t) + hi * t` (with `t = i / (n - 1)`) are *not*
|
|
27
|
+
the same function in float64: multiplication and division do not associate
|
|
28
|
+
or distribute exactly under rounding, so they disagree in the low bits at
|
|
29
|
+
most interior points (see `test_lattice.py::test_naive_association_differs`
|
|
30
|
+
for a hard-coded example). If a reviewer ever "simplified" `sweep()` to the
|
|
31
|
+
naive form, every declared `monotone` check checked against slightly
|
|
32
|
+
different points and the change would pass silently — nothing here would
|
|
33
|
+
tell them it happened, because nothing before this file's test suite pins
|
|
34
|
+
the bits. That is why the formula is copied here character-for-character
|
|
35
|
+
rather than re-derived.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import hashlib
|
|
41
|
+
from dataclasses import dataclass
|
|
42
|
+
from typing import Mapping, Sequence
|
|
43
|
+
|
|
44
|
+
from .config import RangeDeclaration
|
|
45
|
+
|
|
46
|
+
# Spec section 3.4 item 1: "sweep one parameter across N points (default 33)
|
|
47
|
+
# inclusive of both endpoints".
|
|
48
|
+
DEFAULT_SWEEP_POINTS = 33
|
|
49
|
+
|
|
50
|
+
# splitmix64 constants (Vigna, http://prng.di.unimi.it/splitmix64.c) --
|
|
51
|
+
# these are the published constants, not tunables.
|
|
52
|
+
_MASK64 = (1 << 64) - 1
|
|
53
|
+
_GOLDEN_GAMMA = 0x9E3779B97F4A7C15
|
|
54
|
+
_MIX1 = 0xBF58476D1CE4E5B9
|
|
55
|
+
_MIX2 = 0x94D049BB133111EB
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# --- the sweep formula, pinned to the bit --------------------------------
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def sweep(lo: float, hi: float, n: int = DEFAULT_SWEEP_POINTS) -> list[float]:
|
|
62
|
+
"""`n` points across `[lo, hi]`, inclusive, per spec section 3.4.
|
|
63
|
+
|
|
64
|
+
``x_i = lo + (i * (hi - lo)) / (n - 1)``, each operation performed in
|
|
65
|
+
float64 in exactly that association (no precomputed step, no
|
|
66
|
+
reassociation) -- see the module docstring for why that is load-bearing.
|
|
67
|
+
`x_0` and `x_{n-1}` are assigned the literal endpoints `lo`/`hi` rather
|
|
68
|
+
than computed from the formula, so they are exact even where the
|
|
69
|
+
formula's rounding would not reproduce them (e.g. `n - 1` not dividing
|
|
70
|
+
`hi - lo` evenly is irrelevant to the endpoints, but the spec is explicit
|
|
71
|
+
that they are assigned, not computed, so this does not depend on that
|
|
72
|
+
working out).
|
|
73
|
+
"""
|
|
74
|
+
if n < 2:
|
|
75
|
+
raise ValueError(f"sweep() needs n >= 2 (need both endpoints), got n={n}")
|
|
76
|
+
lo = float(lo)
|
|
77
|
+
hi = float(hi)
|
|
78
|
+
points: list[float] = []
|
|
79
|
+
last = n - 1
|
|
80
|
+
for i in range(n):
|
|
81
|
+
if i == 0:
|
|
82
|
+
points.append(lo)
|
|
83
|
+
elif i == last:
|
|
84
|
+
points.append(hi)
|
|
85
|
+
else:
|
|
86
|
+
points.append(lo + (i * (hi - lo)) / last)
|
|
87
|
+
return points
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# --- splitmix64 ------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _splitmix64_next(state: int) -> tuple[int, int]:
|
|
94
|
+
"""One splitmix64 step: advance `state`, return `(new_state, output)`.
|
|
95
|
+
|
|
96
|
+
Reference: http://prng.di.unimi.it/splitmix64.c
|
|
97
|
+
|
|
98
|
+
uint64_t next() {
|
|
99
|
+
uint64_t z = (x += 0x9e3779b97f4a7c15);
|
|
100
|
+
z = (z ^ (z >> 30)) * 0xbf58476d1ce4e5b9;
|
|
101
|
+
z = (z ^ (z >> 27)) * 0x94d049bb133111eb;
|
|
102
|
+
return z ^ (z >> 31);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
Python integers are unbounded, so every arithmetic step is masked to
|
|
106
|
+
64 bits explicitly to reproduce the C implementation's wraparound.
|
|
107
|
+
"""
|
|
108
|
+
state = (state + _GOLDEN_GAMMA) & _MASK64
|
|
109
|
+
z = state
|
|
110
|
+
z = ((z ^ (z >> 30)) * _MIX1) & _MASK64
|
|
111
|
+
z = ((z ^ (z >> 27)) * _MIX2) & _MASK64
|
|
112
|
+
z = z ^ (z >> 31)
|
|
113
|
+
return state, z
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def splitmix64(seed: int, count: int) -> list[int]:
|
|
117
|
+
"""`count` successive splitmix64 outputs (as uint64) seeded with `seed`.
|
|
118
|
+
|
|
119
|
+
Exposed on its own (not just through `scatter()`) so it can be tested
|
|
120
|
+
directly against the algorithm's published reference vectors.
|
|
121
|
+
"""
|
|
122
|
+
if count < 0:
|
|
123
|
+
raise ValueError(f"splitmix64() needs count >= 0, got {count}")
|
|
124
|
+
state = int(seed) & _MASK64
|
|
125
|
+
outputs = []
|
|
126
|
+
for _ in range(count):
|
|
127
|
+
state, z = _splitmix64_next(state)
|
|
128
|
+
outputs.append(z)
|
|
129
|
+
return outputs
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _unit_interval(word: int) -> float:
|
|
133
|
+
"""A uint64 splitmix64 output mapped to `[0.0, 1.0)`.
|
|
134
|
+
|
|
135
|
+
2**53 significant bits is exactly a float64 mantissa's worth of
|
|
136
|
+
precision, which is what `random.Random.random()` also uses -- taking
|
|
137
|
+
the top 53 bits rather than dividing the full 64-bit word keeps the
|
|
138
|
+
mapping exact-in-float64 (a plain `word / 2**64` also works, but loses
|
|
139
|
+
the same low bits anyway, and this is the conventional construction).
|
|
140
|
+
"""
|
|
141
|
+
return (word >> 11) * (1.0 / (1 << 53))
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def scatter(
|
|
145
|
+
seed: int, count: int, ranges: Mapping[str, RangeDeclaration]
|
|
146
|
+
) -> dict[str, tuple[float, ...]]:
|
|
147
|
+
"""`count` deterministic pseudo-random samples in each named range.
|
|
148
|
+
|
|
149
|
+
Spec section 3.4 item 3: "splitmix64 with the seed written into the
|
|
150
|
+
file, mapped to each range, for a fixed count. Reproducible, and honest
|
|
151
|
+
about being a plain pseudo-random sample" -- explicitly *not*
|
|
152
|
+
low-discrepancy.
|
|
153
|
+
|
|
154
|
+
Design decision (not pinned by the spec, flagged here): each range gets
|
|
155
|
+
its own independent sub-stream, seeded by mixing the declared `seed`
|
|
156
|
+
with the range's name, so that adding or removing a range does not
|
|
157
|
+
perturb the samples drawn for every other range (which sharing one
|
|
158
|
+
running generator across ranges, in dict-iteration order, would do).
|
|
159
|
+
The per-range sub-seed is itself derived with `splitmix64`, so nothing
|
|
160
|
+
but splitmix64 and the declared seed is ever used as a source of
|
|
161
|
+
randomness. The mapping from a splitmix64 word to `[lo, hi]` is
|
|
162
|
+
`lo + u * (hi - lo)` with `u` the word's top 53 bits as a `[0, 1)`
|
|
163
|
+
float -- also not pinned by the spec beyond "mapped to each range", and
|
|
164
|
+
called out for the same reason.
|
|
165
|
+
|
|
166
|
+
Returns `{parameter_name: (sample_0, ..., sample_{count-1})}`.
|
|
167
|
+
"""
|
|
168
|
+
if count < 0:
|
|
169
|
+
raise ValueError(f"scatter() needs count >= 0, got {count}")
|
|
170
|
+
seed = int(seed) & _MASK64
|
|
171
|
+
result: dict[str, tuple[float, ...]] = {}
|
|
172
|
+
for name in ranges:
|
|
173
|
+
# Derive a sub-seed for this range's sub-stream from (seed, name),
|
|
174
|
+
# so the sub-stream is a pure function of the declared seed and the
|
|
175
|
+
# parameter name -- not of iteration order or of how many other
|
|
176
|
+
# ranges exist.
|
|
177
|
+
# A raw `int.from_bytes(name) & _MASK64` only keeps the name's low 64
|
|
178
|
+
# bits, so any two names sharing an 8-byte suffix (e.g.
|
|
179
|
+
# "differential_pressure" and "static_pressure", both ending in
|
|
180
|
+
# "pressure") would collide and silently draw identical sub-streams.
|
|
181
|
+
# Hash the full name first so every byte of it affects the sub-seed.
|
|
182
|
+
name_hash = int.from_bytes(
|
|
183
|
+
hashlib.sha256(name.encode("utf-8")).digest()[:8], "big", signed=False
|
|
184
|
+
)
|
|
185
|
+
sub_seed = splitmix64((seed ^ name_hash) & _MASK64, 1)[0]
|
|
186
|
+
words = splitmix64(sub_seed, count)
|
|
187
|
+
lo, hi = ranges[name]
|
|
188
|
+
result[name] = tuple(lo + _unit_interval(w) * (hi - lo) for w in words)
|
|
189
|
+
return result
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# --- per-entry lattice construction (spec section 3.4 item 1) --------------
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@dataclass(frozen=True)
|
|
196
|
+
class LatticePoint:
|
|
197
|
+
"""One point to evaluate: a full positional argument tuple for a call.
|
|
198
|
+
|
|
199
|
+
`origin` and the sweep-specific fields are provenance for the runners
|
|
200
|
+
that consume this (T10's structural checks, T11's declared-property
|
|
201
|
+
checks and its bisection reporting, which needs to know a point's
|
|
202
|
+
position within its sweep to report "index 27" the way section 3.6's
|
|
203
|
+
message format does).
|
|
204
|
+
"""
|
|
205
|
+
|
|
206
|
+
arguments: tuple
|
|
207
|
+
origin: str # "sweep" | "corner" | "scatter"
|
|
208
|
+
parameter: str | None = None # the swept parameter, for origin="sweep"
|
|
209
|
+
index: int | None = None # position within that parameter's sweep
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@dataclass(frozen=True)
|
|
213
|
+
class ParameterSweep:
|
|
214
|
+
"""The lattice for one swept parameter, plus enough to report a
|
|
215
|
+
counterexample against it (spec section 3.6: "lattice: pressure in
|
|
216
|
+
[14.7, 10000], 33 points, index 27")."""
|
|
217
|
+
|
|
218
|
+
parameter: str
|
|
219
|
+
lo: float
|
|
220
|
+
hi: float
|
|
221
|
+
points: tuple[LatticePoint, ...]
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@dataclass(frozen=True)
|
|
225
|
+
class EntryLattice:
|
|
226
|
+
"""Every lattice point declared for one golden.json entry.
|
|
227
|
+
|
|
228
|
+
`base_arguments` is the entry's own recorded `arguments` -- unmodified,
|
|
229
|
+
per spec section 3.4 item 1 ("hold all arguments at the values recorded
|
|
230
|
+
in golden.json for that entry"). Every point in `sweeps`, `corners` and
|
|
231
|
+
`scatter` is a full argument tuple of the same arity, differing from
|
|
232
|
+
`base_arguments` only in the swept parameter's position (sweeps,
|
|
233
|
+
scatter) or not at all (corners, which are passed through verbatim --
|
|
234
|
+
they are whatever the human declared, not derived from the base point).
|
|
235
|
+
"""
|
|
236
|
+
|
|
237
|
+
key: str
|
|
238
|
+
base_arguments: tuple
|
|
239
|
+
sweeps: tuple[ParameterSweep, ...]
|
|
240
|
+
corners: tuple[LatticePoint, ...]
|
|
241
|
+
scatter: tuple[LatticePoint, ...]
|
|
242
|
+
# Parameters that a declared range exists for but that do not appear
|
|
243
|
+
# (by name) in `parameter_names`, or vice versa -- surfaced so a caller
|
|
244
|
+
# can feed them into invariants.json's `uncovered` block (spec section
|
|
245
|
+
# 3.7); this module does not decide what "uncovered" means for the
|
|
246
|
+
# file, it only refuses to silently drop a parameter with no range.
|
|
247
|
+
unswept_parameters: tuple[str, ...]
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def build_entry_lattice(
|
|
251
|
+
entry: Mapping,
|
|
252
|
+
parameter_names: Sequence[str],
|
|
253
|
+
ranges: Mapping[str, RangeDeclaration],
|
|
254
|
+
*,
|
|
255
|
+
n: int = DEFAULT_SWEEP_POINTS,
|
|
256
|
+
corners: Sequence[Sequence] = (),
|
|
257
|
+
scatter_seed: int | None = None,
|
|
258
|
+
scatter_count: int = 0,
|
|
259
|
+
) -> EntryLattice:
|
|
260
|
+
"""The lattice for one golden.json entry.
|
|
261
|
+
|
|
262
|
+
`entry` is one value from a golden document's `"entries"` mapping (i.e.
|
|
263
|
+
`document["entries"][key]`) -- its `"arguments"` are the base point,
|
|
264
|
+
held fixed while one parameter at a time is swept. `parameter_names` is
|
|
265
|
+
the *positional* parameter name for each element of `entry["arguments"]`
|
|
266
|
+
-- golden.json itself is positional (it mirrors the Python-visible call,
|
|
267
|
+
same as the oracle's wire format, spec section 2.5), so the caller (which
|
|
268
|
+
has the parsed IR) supplies the names.
|
|
269
|
+
|
|
270
|
+
Only parameters that are (a) plain numbers in the base arguments (not
|
|
271
|
+
arrays or structs -- sections 3.2-3.4 describe scalar sweeps) and (b)
|
|
272
|
+
named in `ranges` are swept; per spec section 3.4, "there is no default
|
|
273
|
+
range -- a swept parameter with no declared range is an error, not a
|
|
274
|
+
guess". This function does not raise for a rangeless numeric parameter
|
|
275
|
+
-- it simply does not sweep it, and reports it back in
|
|
276
|
+
`unswept_parameters` so a caller can record it as `uncovered`.
|
|
277
|
+
|
|
278
|
+
`corners` are full argument tuples, passed through completely
|
|
279
|
+
unmodified (spec section 3.4 item 2) -- this function does not validate
|
|
280
|
+
their arity or clamp them to any range.
|
|
281
|
+
"""
|
|
282
|
+
base_arguments = tuple(entry["arguments"])
|
|
283
|
+
if len(parameter_names) != len(base_arguments):
|
|
284
|
+
raise ValueError(
|
|
285
|
+
f"entry {entry.get('name', '?')!r} has {len(base_arguments)} "
|
|
286
|
+
f"argument(s) but {len(parameter_names)} parameter name(s) were "
|
|
287
|
+
"given"
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
sweeps: list[ParameterSweep] = []
|
|
291
|
+
unswept: list[str] = []
|
|
292
|
+
for position, name in enumerate(parameter_names):
|
|
293
|
+
value = base_arguments[position]
|
|
294
|
+
if not _is_plain_number(value):
|
|
295
|
+
continue # arrays/structs are not swept by this lattice kind
|
|
296
|
+
range_declaration = ranges.get(name)
|
|
297
|
+
if range_declaration is None:
|
|
298
|
+
unswept.append(name)
|
|
299
|
+
continue
|
|
300
|
+
lo, hi = range_declaration
|
|
301
|
+
swept_values = sweep(lo, hi, n)
|
|
302
|
+
points = tuple(
|
|
303
|
+
LatticePoint(
|
|
304
|
+
arguments=_replaced(base_arguments, position, x),
|
|
305
|
+
origin="sweep",
|
|
306
|
+
parameter=name,
|
|
307
|
+
index=index,
|
|
308
|
+
)
|
|
309
|
+
for index, x in enumerate(swept_values)
|
|
310
|
+
)
|
|
311
|
+
sweeps.append(ParameterSweep(parameter=name, lo=lo, hi=hi, points=points))
|
|
312
|
+
|
|
313
|
+
corner_points = tuple(
|
|
314
|
+
LatticePoint(arguments=tuple(corner), origin="corner") for corner in corners
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
scatter_points: tuple[LatticePoint, ...] = ()
|
|
318
|
+
if scatter_count > 0:
|
|
319
|
+
if scatter_seed is None:
|
|
320
|
+
raise ValueError("scatter_count > 0 requires a scatter_seed")
|
|
321
|
+
swept_names = {s.parameter for s in sweeps}
|
|
322
|
+
scatter_ranges = {
|
|
323
|
+
name: ranges[name] for name in parameter_names if name in swept_names
|
|
324
|
+
}
|
|
325
|
+
samples = scatter(scatter_seed, scatter_count, scatter_ranges)
|
|
326
|
+
name_to_position = {name: i for i, name in enumerate(parameter_names)}
|
|
327
|
+
built = []
|
|
328
|
+
for draw in range(scatter_count):
|
|
329
|
+
arguments = base_arguments
|
|
330
|
+
for name, values in samples.items():
|
|
331
|
+
arguments = _replaced(arguments, name_to_position[name], values[draw])
|
|
332
|
+
built.append(LatticePoint(arguments=arguments, origin="scatter", index=draw))
|
|
333
|
+
scatter_points = tuple(built)
|
|
334
|
+
|
|
335
|
+
return EntryLattice(
|
|
336
|
+
key=entry.get("name", ""),
|
|
337
|
+
base_arguments=base_arguments,
|
|
338
|
+
sweeps=tuple(sweeps),
|
|
339
|
+
corners=corner_points,
|
|
340
|
+
scatter=scatter_points,
|
|
341
|
+
unswept_parameters=tuple(unswept),
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _is_plain_number(value) -> bool:
|
|
346
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _replaced(arguments: tuple, position: int, value) -> tuple:
|
|
350
|
+
return arguments[:position] + (value,) + arguments[position + 1 :]
|
nativegate/locking.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Hash-pinned dependency locks for a generated service.
|
|
2
|
+
|
|
3
|
+
WHAT THIS CLOSES
|
|
4
|
+
|
|
5
|
+
The generated Dockerfile installed the service wheel and let pip resolve
|
|
6
|
+
`fastapi`, `uvicorn`, `gunicorn`, `numpy` and their transitive dependencies
|
|
7
|
+
from PyPI at build time, unpinned. So an image rebuilt a week later shipped
|
|
8
|
+
different Python code with no record of the change anywhere — the same hole
|
|
9
|
+
the base-image digest and the apt pins close on the other two layers.
|
|
10
|
+
|
|
11
|
+
`ngate lock <service>` resolves those dependencies once, records every
|
|
12
|
+
artifact's SHA-256, and the generated Dockerfile installs from the lock with
|
|
13
|
+
`--require-hashes`. After that, a rebuild either produces the same
|
|
14
|
+
dependencies or fails; it cannot quietly produce different ones.
|
|
15
|
+
|
|
16
|
+
WHY IT RESOLVES FOR THE IMAGE, NOT FOR THIS MACHINE
|
|
17
|
+
|
|
18
|
+
The lock is consumed inside `python:3.12-slim` on Linux. Resolving with the
|
|
19
|
+
local interpreter would pin macOS wheels, or wheels for whatever Python the
|
|
20
|
+
developer happens to run, and the build would fail — or worse, succeed with a
|
|
21
|
+
different set. So the resolution is done with pip's cross-environment flags:
|
|
22
|
+
`--python-version 3.12`, `--only-binary=:all:`, and an explicit platform.
|
|
23
|
+
|
|
24
|
+
BOTH ARCHITECTURES, IN ONE FILE
|
|
25
|
+
|
|
26
|
+
BASE_IMAGE is a multi-arch index that resolves on amd64 and arm64 alike, so a
|
|
27
|
+
lock naming only x86_64 wheels would break every arm64 build. Each dependency
|
|
28
|
+
is resolved for both platforms and the hashes are merged — `--require-hashes`
|
|
29
|
+
accepts several hashes for one requirement and matches whichever artifact it
|
|
30
|
+
actually downloads.
|
|
31
|
+
|
|
32
|
+
A package that resolves to *different versions* per architecture is refused
|
|
33
|
+
rather than silently locked to one of them: that is a genuine split in the
|
|
34
|
+
dependency graph, and picking a side for the user would produce an image whose
|
|
35
|
+
contents depend on where it was built.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import json
|
|
41
|
+
import subprocess
|
|
42
|
+
import sys
|
|
43
|
+
import tempfile
|
|
44
|
+
from dataclasses import dataclass, field
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
|
|
47
|
+
LOCK_FILENAME = "requirements.lock"
|
|
48
|
+
|
|
49
|
+
# Matched to docker_gen.BASE_IMAGE. Refresh them together.
|
|
50
|
+
LOCK_PYTHON_VERSION = "3.12"
|
|
51
|
+
|
|
52
|
+
# The manylinux tags the base image can actually run, newest first.
|
|
53
|
+
#
|
|
54
|
+
# `--platform` is an EXACT tag match, not a floor: asking only for
|
|
55
|
+
# manylinux2014 excludes a wheel tagged manylinux_2_28, which is how the first
|
|
56
|
+
# version of this locked numpy 2.2.6 while the image itself resolved 2.5.2 —
|
|
57
|
+
# numpy 2.5.2 publishes manylinux_2_28 only. A lock that pins something the
|
|
58
|
+
# image would not have chosen is worse than no lock: it looks authoritative
|
|
59
|
+
# and is quietly wrong.
|
|
60
|
+
#
|
|
61
|
+
# Measured in the base image: Debian trixie, glibc 2.41 — so manylinux_2_28
|
|
62
|
+
# and everything older is satisfied. Listing several lets pip pick whichever a
|
|
63
|
+
# project actually publishes.
|
|
64
|
+
LOCK_PLATFORMS = (
|
|
65
|
+
"manylinux_2_28_x86_64",
|
|
66
|
+
"manylinux2014_x86_64",
|
|
67
|
+
"manylinux_2_28_aarch64",
|
|
68
|
+
"manylinux2014_aarch64",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class LockError(RuntimeError):
|
|
73
|
+
"""Resolution failed, or produced something that cannot be locked."""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class LockedPackage:
|
|
78
|
+
name: str
|
|
79
|
+
version: str
|
|
80
|
+
# SHA-256 of every artifact that may satisfy this requirement — one per
|
|
81
|
+
# architecture, usually two, sometimes one for a pure-Python wheel.
|
|
82
|
+
hashes: set[str] = field(default_factory=set)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _resolve(
|
|
86
|
+
requirements: list[str], platform: list[str], pip: list[str]
|
|
87
|
+
) -> dict[str, LockedPackage]:
|
|
88
|
+
"""One cross-environment pip resolution, as {normalised name: package}.
|
|
89
|
+
|
|
90
|
+
`platform` is every compatible tag for ONE architecture; pip takes the set
|
|
91
|
+
and picks whichever a project publishes.
|
|
92
|
+
"""
|
|
93
|
+
# pip requires a target when resolving for a foreign platform; nothing is
|
|
94
|
+
# written, because --dry-run. It still has to be a path pip can create, so
|
|
95
|
+
# take one from the OS rather than hardcoding /tmp — that is not a
|
|
96
|
+
# writable location on Windows.
|
|
97
|
+
with tempfile.TemporaryDirectory(prefix="nativegate-lock-") as dry_run_target:
|
|
98
|
+
command = [
|
|
99
|
+
*pip,
|
|
100
|
+
"install",
|
|
101
|
+
"--dry-run",
|
|
102
|
+
"--quiet",
|
|
103
|
+
"--report",
|
|
104
|
+
"-",
|
|
105
|
+
"--only-binary=:all:",
|
|
106
|
+
"--python-version",
|
|
107
|
+
LOCK_PYTHON_VERSION,
|
|
108
|
+
*[arg for tag in platform for arg in ("--platform", tag)],
|
|
109
|
+
"--target",
|
|
110
|
+
dry_run_target,
|
|
111
|
+
*requirements,
|
|
112
|
+
]
|
|
113
|
+
result = subprocess.run(command, capture_output=True, text=True)
|
|
114
|
+
if result.returncode != 0:
|
|
115
|
+
raise LockError(
|
|
116
|
+
f"pip could not resolve {' '.join(requirements)} for "
|
|
117
|
+
f"{', '.join(platform)}:\n"
|
|
118
|
+
f"{result.stderr.strip() or result.stdout.strip()}"
|
|
119
|
+
)
|
|
120
|
+
try:
|
|
121
|
+
report = json.loads(result.stdout)
|
|
122
|
+
except json.JSONDecodeError as exc:
|
|
123
|
+
raise LockError(
|
|
124
|
+
f"pip's --report output for {', '.join(platform)} was not JSON. "
|
|
125
|
+
"pip 22.2+ is "
|
|
126
|
+
f"required for hash locking. Got: {result.stdout[:200]!r}"
|
|
127
|
+
) from exc
|
|
128
|
+
|
|
129
|
+
resolved: dict[str, LockedPackage] = {}
|
|
130
|
+
for item in report.get("install", []):
|
|
131
|
+
metadata = item.get("metadata", {})
|
|
132
|
+
name = _normalise(metadata.get("name", ""))
|
|
133
|
+
version = metadata.get("version", "")
|
|
134
|
+
archive = item.get("download_info", {}).get("archive_info", {})
|
|
135
|
+
digest = (archive.get("hashes") or {}).get("sha256") or ""
|
|
136
|
+
if not digest and str(archive.get("hash", "")).startswith("sha256="):
|
|
137
|
+
digest = archive["hash"].split("=", 1)[1]
|
|
138
|
+
if not (name and version and digest):
|
|
139
|
+
raise LockError(
|
|
140
|
+
f"pip reported {name or '<unnamed>'} without a version or a "
|
|
141
|
+
"sha256. A lock is only worth having if every entry has one; "
|
|
142
|
+
"refusing to write a partial lock."
|
|
143
|
+
)
|
|
144
|
+
resolved[name] = LockedPackage(name=name, version=version, hashes={digest})
|
|
145
|
+
return resolved
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _normalise(name: str) -> str:
|
|
149
|
+
"""PEP 503 normalisation, so `uvicorn-worker` and `uvicorn_worker` agree."""
|
|
150
|
+
return "".join("-" if c in "-_." else c for c in name.lower())
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def resolve_locked(
|
|
154
|
+
requirements: list[str], pip: list[str] | None = None
|
|
155
|
+
) -> list[LockedPackage]:
|
|
156
|
+
"""Resolve `requirements` for every supported image platform, merged."""
|
|
157
|
+
if not requirements:
|
|
158
|
+
return []
|
|
159
|
+
pip = pip or [sys.executable, "-m", "pip"]
|
|
160
|
+
|
|
161
|
+
architectures = [
|
|
162
|
+
[p for p in LOCK_PLATFORMS if p.endswith("x86_64")],
|
|
163
|
+
[p for p in LOCK_PLATFORMS if p.endswith("aarch64")],
|
|
164
|
+
]
|
|
165
|
+
merged: dict[str, LockedPackage] = {}
|
|
166
|
+
for platform in architectures:
|
|
167
|
+
for name, package in _resolve(requirements, platform, pip).items():
|
|
168
|
+
existing = merged.get(name)
|
|
169
|
+
if existing is None:
|
|
170
|
+
merged[name] = package
|
|
171
|
+
continue
|
|
172
|
+
if existing.version != package.version:
|
|
173
|
+
raise LockError(
|
|
174
|
+
f"'{name}' resolves to {existing.version} on one image "
|
|
175
|
+
f"architecture and {package.version} on another. Locking "
|
|
176
|
+
"one of them would make the image's contents depend on "
|
|
177
|
+
"where it was built. Pin it explicitly in the generated "
|
|
178
|
+
"pyproject.toml and re-run the lock."
|
|
179
|
+
)
|
|
180
|
+
existing.hashes |= package.hashes
|
|
181
|
+
|
|
182
|
+
return [merged[name] for name in sorted(merged)]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def render_lock(packages: list[LockedPackage], service_name: str) -> str:
|
|
186
|
+
"""The `requirements.lock` text, in pip's `--require-hashes` format."""
|
|
187
|
+
lines = [
|
|
188
|
+
"# Generated by nativegate. Do not edit by hand — re-run "
|
|
189
|
+
f"`ngate lock {service_name}`.",
|
|
190
|
+
"#",
|
|
191
|
+
"# Every dependency of this service, pinned by version and SHA-256, so",
|
|
192
|
+
"# `docker build` cannot quietly install different code than it did",
|
|
193
|
+
"# last time. Installed with --require-hashes: a mismatch fails the",
|
|
194
|
+
"# build rather than shipping.",
|
|
195
|
+
"#",
|
|
196
|
+
f"# Resolved for the IMAGE, not for the machine that ran the lock:",
|
|
197
|
+
f"# Python {LOCK_PYTHON_VERSION}, {', '.join(LOCK_PLATFORMS)}.",
|
|
198
|
+
"# Several hashes per entry is normal — one artifact per architecture.",
|
|
199
|
+
"",
|
|
200
|
+
]
|
|
201
|
+
for package in packages:
|
|
202
|
+
digests = sorted(package.hashes)
|
|
203
|
+
entry = [f"{package.name}=={package.version}"]
|
|
204
|
+
entry += [f"--hash=sha256:{d}" for d in digests]
|
|
205
|
+
lines.append(" \\\n ".join(entry))
|
|
206
|
+
return "\n".join(lines) + "\n"
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def write_lock(
|
|
210
|
+
service_dir: Path, service_name: str, requirements: list[str], pip: list[str] | None = None
|
|
211
|
+
) -> Path:
|
|
212
|
+
"""Resolve and write `<service_dir>/requirements.lock`. Returns the path."""
|
|
213
|
+
packages = resolve_locked(requirements, pip=pip)
|
|
214
|
+
target = Path(service_dir) / LOCK_FILENAME
|
|
215
|
+
target.write_text(render_lock(packages, service_name))
|
|
216
|
+
return target
|