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/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