@nm-lang/cli 0.4.0 → 0.6.0

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.
@@ -0,0 +1,524 @@
1
+ """Bounded local N/M JSON/CLI bridge (Python 3.10+, standard library only).
2
+
3
+ Not a Python evaluator, checkpoint, hardware connector or permission-bearing IR.
4
+ The npm CLI must be built/installed separately. No network or shell is used here.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import copy
9
+ import html
10
+ import importlib.metadata
11
+ import json
12
+ import math
13
+ import os
14
+ import subprocess
15
+ import sys
16
+ import time
17
+ from pathlib import Path
18
+ from collections.abc import Mapping, Sequence
19
+
20
+ FORMAT = "nm-python-bridge"
21
+ VERSION = 1
22
+ MAX_REQUEST_BYTES = 262_144
23
+ MAX_RESPONSE_BYTES = 4_194_304
24
+ MAX_STDERR_BYTES = 65_536
25
+ MAX_REQUEST_DEPTH = 32
26
+ MAX_RESPONSE_DEPTH = 64
27
+
28
+ _COPY = {
29
+ "en": {
30
+ "input": "Invalid or oversized JSON bridge input.",
31
+ "transport": "The local CLI did not return a valid complete bridge response.",
32
+ "timeout": "Bridge deadline exceeded; the process was killed. No completed precision evidence is available.",
33
+ "cancel": "Bridge cancelled; the process was killed. No completed result is available.",
34
+ "output": "Bridge output exceeded its byte limit; no partial result was accepted.",
35
+ "node": "Node.js was not found in an absolute PATH directory outside the working directory. Install Node.js or supply a trusted command sequence.",
36
+ "cli": "A trusted absolute N/M CLI file is required. Install the adjacent npm CLI or supply cli_path= or a trusted command sequence.",
37
+ "title": "N/M local bridge", "status": "Execution succeeded", "failed": "Execution failed",
38
+ "rejected": "Request rejected", "shots": "Total shots used / budget", "remaining": "Unallocated shots",
39
+ "pilot": "Pilot shots (allocation only)", "held_out": "Held-out estimation shots",
40
+ "cost": "Final sampled cost", "observed": "Latest observed cost (not a final-parameter certificate)",
41
+ "interval": "Simultaneous 95% confidence interval", "se": "Standard error",
42
+ "precision": "Final-cost precision target", "bound": "Absolute error bound", "floor": "Numeric resolution floor",
43
+ "required_shots": "Estimated shots needed for final cost", "above_limit": "Above the 4096 limit",
44
+ "estimate_assumptions": "Estimate assumptions",
45
+ "estimate_large": "Estimate exceeds the safe integer range", "estimate_resolution": "Target cannot be certified at the current numeric resolution",
46
+ "estimate_note": "Assumes final estimation-group proportions and the current confidence calculation; final-cost shots only, not a total training budget or a guarantee. No extra shots are taken.",
47
+ "met": "met", "not-met": "not met", "incomplete": "incomplete", "source": "Original source",
48
+ "notice": "Local record, not a signature. Confidence applies to the recorded sampling assumptions; it is not a convergence, optimum, hardware or numerical-model certificate. Replaying source requires independent caller permission.",
49
+ "field": "Field", "value": "Value",
50
+ "unavailable": "unavailable",
51
+ },
52
+ "tr": {
53
+ "input": "JSON köprü girdisi geçersiz veya boyut sınırını aşıyor.",
54
+ "transport": "Yerel CLI geçerli ve tam bir köprü yanıtı döndürmedi.",
55
+ "timeout": "Köprü süre sınırı aşıldı; işlem sonlandırıldı. Tamamlanmış hassasiyet kanıtı yoktur.",
56
+ "cancel": "Köprü iptal edildi; işlem sonlandırıldı. Tamamlanmış sonuç yoktur.",
57
+ "output": "Köprü çıktısı bayt sınırını aştı; kısmi sonuç kabul edilmedi.",
58
+ "node": "Çalışma dizini dışındaki mutlak PATH dizinlerinde Node.js bulunamadı. Node.js kurun veya güvenilir bir komut dizisi verin.",
59
+ "cli": "Güvenilir mutlak N/M CLI dosyası gerekir. Komşu npm CLI'yi kurun veya cli_path= ya da güvenilir bir komut dizisi verin.",
60
+ "title": "N/M yerel köprüsü", "status": "Yürütme başarılı", "failed": "Yürütme başarısız",
61
+ "rejected": "İstek reddedildi", "shots": "Kullanılan toplam ölçüm / bütçe", "remaining": "Dağıtılmamış ölçüm",
62
+ "pilot": "Pilot ölçümler (yalnız dağılım için)", "held_out": "Ayrı değerlendirme ölçümleri",
63
+ "cost": "Son örnek maliyet", "observed": "Son gözlenen maliyet (son parametrelerin belgesi değildir)",
64
+ "interval": "Eşzamanlı %95 güven aralığı", "se": "Standart hata",
65
+ "precision": "Son maliyet hassasiyet hedefi", "bound": "Mutlak hata sınırı", "floor": "Sayısal çözünürlük alt sınırı",
66
+ "required_shots": "Son maliyet için tahmini ölçüm ihtiyacı", "above_limit": "4096 sınırının üzerinde",
67
+ "estimate_assumptions": "Tahmin varsayımları",
68
+ "estimate_large": "Tahmin güvenli tamsayı aralığının üzerinde", "estimate_resolution": "Hedef mevcut sayısal çözünürlükle doğrulanamaz",
69
+ "estimate_note": "Son değerlendirmedeki grup oranları ve mevcut güven hesabı varsayılır; yalnız son maliyet ölçümleridir, toplam eğitim bütçesi veya garanti değildir. Ek ölçüm alınmaz.",
70
+ "met": "karşılandı", "not-met": "karşılanmadı", "incomplete": "tamamlanmadı", "source": "Özgün kaynak",
71
+ "notice": "Yerel kayıttır; imza değildir. Güven aralığı kayıtlı örnekleme varsayımları için geçerlidir; yakınsama, optimum, donanım veya sayısal model belgesi değildir. Yeniden yürütme bağımsız çağıran izni gerektirir.",
72
+ "field": "Alan", "value": "Değer",
73
+ "unavailable": "belirlenmedi",
74
+ },
75
+ }
76
+
77
+
78
+ class NMBridgeError(RuntimeError):
79
+ """Transport/validation failure. It never contains a partial success result."""
80
+ def __init__(self, code: str, key: str, locale: str = "en"):
81
+ self.code = code
82
+ super().__init__(f"{code}: {_COPY[locale][key]}")
83
+
84
+
85
+ def _resolve_node(locale: str) -> str:
86
+ """Search explicit absolute PATH entries, never Windows' implicit cwd search."""
87
+ try:
88
+ working_directory = Path.cwd().resolve(strict=True)
89
+ except (OSError, RuntimeError):
90
+ raise NMBridgeError("NM-PY-011", "node", locale) from None
91
+ for entry in os.environ.get("PATH", "").split(os.pathsep):
92
+ if not entry or not Path(entry).is_absolute():
93
+ continue
94
+ try:
95
+ directory = Path(entry).resolve(strict=True)
96
+ if directory == working_directory:
97
+ continue
98
+ executable = (directory / ("node.exe" if os.name == "nt" else "node")).resolve(strict=True)
99
+ if executable.parent != working_directory and executable.is_file() and os.access(executable, os.X_OK):
100
+ return str(executable)
101
+ except (OSError, RuntimeError, ValueError):
102
+ continue
103
+ raise NMBridgeError("NM-PY-011", "node", locale)
104
+
105
+
106
+ def _strict_json(text: str, limit: int, *, safe_integers: bool = False, depth_limit: int = MAX_REQUEST_DEPTH):
107
+ if not isinstance(text, str) or len(text.encode("utf-8")) > limit:
108
+ raise ValueError("JSON byte limit")
109
+ depth, quoted, escaped = 0, False, False
110
+ for char in text:
111
+ if quoted:
112
+ if escaped:
113
+ escaped = False
114
+ elif char == "\\":
115
+ escaped = True
116
+ elif char == '"':
117
+ quoted = False
118
+ elif char == '"':
119
+ quoted = True
120
+ elif char in "[{":
121
+ depth += 1
122
+ if depth > depth_limit:
123
+ raise ValueError("JSON depth limit")
124
+ elif char in "]}":
125
+ depth -= 1
126
+
127
+ def finite(value):
128
+ number = float(value)
129
+ if not math.isfinite(number) or safe_integers and number.is_integer() and abs(number) > 9007199254740991:
130
+ raise ValueError("Non-finite JSON number")
131
+ return number
132
+
133
+ def integer(value):
134
+ number = int(value)
135
+ if abs(number) > 9007199254740991:
136
+ if safe_integers:
137
+ raise ValueError("Unsafe integer JSON number")
138
+ # Runtime numbers are IEEE-754, not arbitrary-precision Python ints.
139
+ # JS may serialize an integral double as its shortest decimal,
140
+ # e.g. 1000000000000000100 denotes the double 1000000000000000128.
141
+ return finite(value)
142
+ return number
143
+
144
+ def pairs(items):
145
+ result = {}
146
+ for key, value in items:
147
+ if key in result:
148
+ raise ValueError("Duplicate JSON key")
149
+ result[key] = value
150
+ return result
151
+
152
+ def invalid(_value):
153
+ raise ValueError("Non-JSON constant")
154
+
155
+ return json.loads(text, parse_float=finite, parse_int=integer, parse_constant=invalid, object_pairs_hook=pairs)
156
+
157
+
158
+ def _compact_json_text(text: str) -> str:
159
+ """Compact validated JSON without rewriting number or string representations."""
160
+ characters, quoted, escaped = [], False, False
161
+ for char in text:
162
+ if quoted:
163
+ characters.append(char)
164
+ if escaped:
165
+ escaped = False
166
+ elif char == "\\":
167
+ escaped = True
168
+ elif char == '"':
169
+ quoted = False
170
+ elif char == '"':
171
+ characters.append(char)
172
+ quoted = True
173
+ elif char not in " \t\r\n":
174
+ characters.append(char)
175
+ return "".join(characters)
176
+
177
+
178
+ def _set_nonblocking(stream):
179
+ if os.name != "nt" or sys.version_info >= (3, 12):
180
+ os.set_blocking(stream.fileno(), False)
181
+ return stream.read, stream.write
182
+ # os.set_blocking gained Windows pipe support in 3.12. Keep the adapter's
183
+ # Python 3.10+ contract using the same anonymous-pipe mode on older Windows.
184
+ import ctypes
185
+ import msvcrt
186
+ from ctypes import wintypes
187
+ kernel = ctypes.WinDLL("kernel32", use_last_error=True)
188
+ set_state = kernel.SetNamedPipeHandleState
189
+ set_state.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD),
190
+ ctypes.POINTER(wintypes.DWORD), ctypes.POINTER(wintypes.DWORD)]
191
+ set_state.restype = wintypes.BOOL
192
+ mode = wintypes.DWORD(1) # PIPE_NOWAIT, byte mode
193
+ handle = msvcrt.get_osfhandle(stream.fileno())
194
+ if not set_state(handle, ctypes.byref(mode), None, None):
195
+ raise ctypes.WinError(ctypes.get_last_error())
196
+ kernel.ReadFile.argtypes = kernel.WriteFile.argtypes = [wintypes.HANDLE, ctypes.c_void_p,
197
+ wintypes.DWORD, ctypes.POINTER(wintypes.DWORD), ctypes.c_void_p]
198
+ kernel.ReadFile.restype = kernel.WriteFile.restype = wintypes.BOOL
199
+
200
+ # Older Python CRT reads map ERROR_NO_DATA to EINVAL rather than EAGAIN.
201
+ # Interpret the actual nonblocking pipe result instead of hiding all EINVALs.
202
+ def read(size):
203
+ buffer, count = ctypes.create_string_buffer(size), wintypes.DWORD()
204
+ if kernel.ReadFile(handle, buffer, size, ctypes.byref(count), None):
205
+ return buffer.raw[:count.value]
206
+ error = ctypes.get_last_error()
207
+ if error == 232: # ERROR_NO_DATA: pipe still open, no bytes ready
208
+ return None
209
+ if error == 109: # ERROR_BROKEN_PIPE: EOF
210
+ return b""
211
+ raise ctypes.WinError(error)
212
+
213
+ def write(data):
214
+ raw, count = bytes(data), wintypes.DWORD()
215
+ buffer = ctypes.create_string_buffer(raw)
216
+ if kernel.WriteFile(handle, buffer, len(raw), ctypes.byref(count), None):
217
+ return count.value
218
+ error = ctypes.get_last_error()
219
+ if error in (109, 232):
220
+ raise BrokenPipeError("Bridge input pipe closed")
221
+ raise ctypes.WinError(error)
222
+
223
+ return read, write
224
+
225
+
226
+ class NMResult:
227
+ """Detached, round-trippable record; construction/deserialization never runs N/M."""
228
+ def __init__(self, record: dict):
229
+ self._load_record(json.dumps(record, ensure_ascii=False, allow_nan=False, separators=(",", ":")))
230
+
231
+ def _load_record(self, text: str):
232
+ record = _strict_json(text, MAX_RESPONSE_BYTES, depth_limit=MAX_RESPONSE_DEPTH)
233
+ if (not isinstance(record, dict) or record.get("format") != FORMAT
234
+ or type(record.get("version")) is not int or record["version"] != VERSION
235
+ or type(record.get("success")) is not bool or record.get("locale") not in _COPY
236
+ or record.get("status") not in ("completed", "rejected")):
237
+ raise ValueError("Invalid bridge response")
238
+ if record["status"] == "completed":
239
+ if (not isinstance(record.get("request"), dict) or not isinstance(record.get("permissions"), dict)
240
+ or not isinstance(record.get("result"), dict) or record["result"].get("success") is not record["success"]):
241
+ raise ValueError("Incomplete bridge response")
242
+ elif record["success"] or "result" in record or not isinstance(record.get("error"), dict):
243
+ raise ValueError("Invalid rejection")
244
+ self._record = record
245
+ # Python may expand valid JS numbers (1e-7 -> 1e-07). Preserve their wire
246
+ # representation so a bounded response remains bounded when saved/reloaded.
247
+ self._compact_json = _compact_json_text(text)
248
+
249
+ @classmethod
250
+ def from_json(cls, text: str) -> NMResult:
251
+ result = cls.__new__(cls)
252
+ result._load_record(text)
253
+ return result
254
+
255
+ def to_json(self, *, indent: int | None = None) -> str:
256
+ if indent is None:
257
+ return self._compact_json
258
+ text = json.dumps(self._record, ensure_ascii=False, allow_nan=False, indent=indent)
259
+ if len(text.encode("utf-8")) > MAX_RESPONSE_BYTES:
260
+ raise ValueError("Formatted record exceeds the JSON byte limit; use compact output")
261
+ return text
262
+
263
+ @property
264
+ def record(self) -> dict:
265
+ return copy.deepcopy(self._record)
266
+
267
+ @property
268
+ def request(self) -> dict | None:
269
+ return copy.deepcopy(self._record.get("request"))
270
+
271
+ @property
272
+ def result(self) -> dict | None:
273
+ return copy.deepcopy(self._record.get("result"))
274
+
275
+ @property
276
+ def success(self) -> bool:
277
+ return self._record["success"]
278
+
279
+ def __repr__(self) -> str:
280
+ return f"NMResult(status={self._record['status']!r}, success={self.success!r})"
281
+
282
+ def _repr_html_(self) -> str:
283
+ """Jupyter rich display: text is escaped, never evaluated or injected as HTML."""
284
+ record = self._record
285
+ labels = _COPY[record["locale"]]
286
+ def mapping(value):
287
+ return value if isinstance(value, dict) else {}
288
+
289
+ result = mapping(record.get("result"))
290
+ evidence = mapping(result.get("groupedMeasurements"))
291
+ rows = [(labels["field"], labels["value"])]
292
+ rows.append((labels["title"], labels["rejected"] if record["status"] == "rejected" else labels["status" if self.success else "failed"]))
293
+ error = mapping(record.get("error"))
294
+ if error:
295
+ rows.append((str(error.get("code", "")), str(error.get("message", ""))))
296
+ if result.get("error"):
297
+ rows.append((labels["failed"], result["error"]))
298
+ if evidence:
299
+ rows.extend([(labels["shots"], f"{evidence.get('usedShots')} / {evidence.get('requestedShots')}"),
300
+ (labels["remaining"], evidence.get("unallocatedShots"))])
301
+ adaptive = mapping(evidence.get("adaptive"))
302
+ if adaptive:
303
+ rows.extend([(labels["pilot"], adaptive.get("pilotUsedShots")), (labels["held_out"], adaptive.get("estimationUsedShots"))])
304
+ costs = evidence.get("costs") if isinstance(evidence.get("costs"), list) else []
305
+ if costs:
306
+ cost = mapping(costs[-1])
307
+ rows.extend([(labels["cost" if self.success else "observed"], cost.get("value")),
308
+ (labels["interval"], f"[{cost.get('lower')}, {cost.get('upper')}]"), (labels["se"], cost.get("standardError"))])
309
+ accuracy = mapping(evidence.get("targetAccuracy"))
310
+ if accuracy:
311
+ state = str(accuracy.get("status", "incomplete"))
312
+ rows.extend([(labels["precision"], f"{accuracy.get('absoluteError')}: {labels.get(state, state)}"),
313
+ (labels["bound"], accuracy.get("absoluteErrorBound")), (labels["floor"], accuracy.get("numericResolutionFloor"))])
314
+ estimate = mapping(accuracy.get("requiredShotsEstimate"))
315
+ if estimate:
316
+ estimated = estimate.get("shots") if estimate.get("status") == "estimated" else labels[
317
+ "estimate_resolution" if estimate.get("status") == "numeric-resolution-limit" else "estimate_large"]
318
+ if estimate.get("exceedsShotLimit") is True:
319
+ estimated = f"{estimated} — {labels['above_limit']}"
320
+ rows.extend([(labels["required_shots"], estimated), (labels["estimate_assumptions"], labels["estimate_note"])])
321
+ header, *body = rows
322
+ table = "<table><caption>" + html.escape(labels["title"]) + "</caption><thead><tr>"
323
+ table += "".join("<th scope=\"col\">" + html.escape(str(value)) + "</th>" for value in header)
324
+ table += "</tr></thead><tbody>"
325
+ table += "".join("<tr><th scope=\"row\">" + html.escape(str(key)) + "</th><td>" + html.escape(labels["unavailable"] if value is None else str(value)) + "</td></tr>" for key, value in body)
326
+ table += "</tbody></table><p>" + html.escape(labels["notice"]) + "</p>"
327
+ source = mapping(record.get("request")).get("source", "")
328
+ return table + "<details><summary>" + html.escape(labels["source"]) + "</summary><pre>" + html.escape(str(source)) + "</pre></details>"
329
+
330
+
331
+ def _npm_adjacent_cli(module_path: Path) -> Path:
332
+ """Use the npm default only when the physical module has unambiguous origin.
333
+
334
+ A wheel may be installed with --target into a directory named ``python``.
335
+ Its local distribution identity/RECORD must take precedence over any sibling
336
+ npm-looking layout. Metadata elsewhere on sys.path does not own this module.
337
+ """
338
+ module_path = module_path.resolve(strict=True)
339
+ directory = module_path.parent
340
+ if module_path.name != "nm_bridge.py" or directory.name != "python":
341
+ raise ValueError("The standalone adapter requires explicit CLI selection")
342
+ package = directory.parent
343
+ marker = package / "package.json"
344
+ if marker.resolve(strict=True).parent != package:
345
+ raise ValueError("Ambiguous npm package marker")
346
+ with marker.open("rb") as stream:
347
+ manifest = _strict_json(stream.read(65537).decode("utf-8"), 65536, safe_integers=True)
348
+ if (not isinstance(manifest, dict) or manifest.get("name") != "@nm-lang/cli"
349
+ or not isinstance(manifest.get("bin"), dict) or manifest["bin"].get("nm") != "./bin/nm.mjs"):
350
+ raise ValueError("The module is not in the original N/M npm package")
351
+ markers = [entry for entry in directory.iterdir() if entry.name.endswith((".dist-info", ".egg-info"))]
352
+ if any(entry.is_symlink() for entry in markers):
353
+ raise ValueError("Ambiguous local Python distribution marker")
354
+ if any(entry.name.lower().replace("_", "-").replace(".", "-").startswith("nm-jupyter-adapter-") for entry in markers):
355
+ raise ValueError("Standalone adapter installation marker")
356
+ distributions = list(importlib.metadata.distributions(path=[str(directory)]))
357
+ if len(distributions) != len(markers):
358
+ raise ValueError("Unrecognized local Python distribution metadata")
359
+ for distribution in distributions:
360
+ names = distribution.metadata.get_all("Name", [])
361
+ name = names[0] if len(names) == 1 else None
362
+ files = distribution.files
363
+ if not isinstance(name, str) or not name or files is None:
364
+ raise ValueError("Incomplete local Python distribution metadata")
365
+ normalized = name.lower().replace("_", "-").replace(".", "-")
366
+ if normalized == "nm-jupyter-adapter" or any(
367
+ Path(distribution.locate_file(file)).resolve() == module_path for file in files):
368
+ raise ValueError("An installed Python distribution owns the bridge")
369
+ cli = (package / "bin/nm.mjs").resolve(strict=True)
370
+ if not cli.is_relative_to(package):
371
+ raise ValueError("The adjacent CLI resolves outside its npm package")
372
+ return cli
373
+
374
+
375
+ class NMClient:
376
+ """Each call launches one CLI process, with independently chosen permissions.
377
+
378
+ command is a trusted argument sequence, never a shell string. The default uses
379
+ an absolute Node executable from PATH and the npm package's adjacent nm.mjs.
380
+ Empty/relative PATH entries and the working directory are never searched.
381
+ Standalone wheel users select an absolute trusted script with cli_path=.
382
+ That option and the existing trusted command= sequence are mutually exclusive.
383
+ """
384
+ def __init__(self, *, experimental: Sequence[str] = (), locale: str = "en", timeout: float = 30,
385
+ command: Sequence[str] | None = None, cli_path: str | os.PathLike[str] | None = None):
386
+ if locale not in _COPY:
387
+ raise ValueError("locale must be en or tr")
388
+ self.locale = locale
389
+ if (type(timeout) not in (int, float) or not math.isfinite(timeout) or not 0.1 <= timeout <= 60
390
+ or isinstance(experimental, (str, bytes)) or not isinstance(experimental, Sequence)
391
+ or any(not isinstance(name, str) or not name.isascii() or not name.isalpha() for name in experimental)
392
+ or len(set(experimental)) != len(experimental)):
393
+ raise NMBridgeError("NM-PY-001", "input", locale)
394
+ if cli_path is not None and command is not None:
395
+ raise NMBridgeError("NM-PY-001", "input", locale)
396
+ if command is None:
397
+ try:
398
+ cli = Path(cli_path) if cli_path is not None else _npm_adjacent_cli(Path(__file__))
399
+ if not cli.is_absolute():
400
+ raise ValueError("CLI path must be absolute")
401
+ cli = cli.resolve(strict=True)
402
+ if not cli.is_file():
403
+ raise ValueError("CLI path must be a file")
404
+ except (OSError, RuntimeError, TypeError, ValueError, UnicodeError, KeyError, IndexError):
405
+ raise NMBridgeError("NM-PY-012", "cli", locale) from None
406
+ command = (_resolve_node(locale), str(cli))
407
+ if (isinstance(command, (str, bytes)) or not isinstance(command, Sequence) or not command
408
+ or any(not isinstance(arg, str) or not arg or "\0" in arg for arg in command)):
409
+ raise NMBridgeError("NM-PY-001", "input", locale)
410
+ self.command, self.experimental, self.timeout = tuple(command), tuple(experimental), timeout
411
+
412
+ def check(self, source: str, *, context: Mapping | None = None) -> NMResult:
413
+ return self._invoke("check", source, context=context)
414
+
415
+ def run(self, source: str, *, context: Mapping | None = None) -> NMResult:
416
+ return self._invoke("run", source, context=context)
417
+
418
+ def train(self, source: str, *, options: Mapping | None = None, context: Mapping | None = None) -> NMResult:
419
+ return self._invoke("train", source, context=context, options=options)
420
+
421
+ def _invoke(self, operation, source, *, context, options=None):
422
+ if context is not None and not isinstance(context, Mapping) or options is not None and not isinstance(options, Mapping):
423
+ raise NMBridgeError("NM-PY-001", "input", self.locale)
424
+ request = {"format": FORMAT, "version": VERSION, "operation": operation, "source": source}
425
+ if context is None:
426
+ context = {"experimental": {name: True for name in self.experimental}}
427
+ request["context"] = dict(context)
428
+ if options is not None:
429
+ request["training"] = dict(options)
430
+ return self.execute(request)
431
+
432
+ def execute(self, request: Mapping) -> NMResult:
433
+ """Replay only request data. The client's permissions are never read from it."""
434
+ try:
435
+ if not isinstance(request, Mapping):
436
+ raise ValueError("Request must be a JSON object")
437
+ payload = json.dumps(request, ensure_ascii=False, allow_nan=False, separators=(",", ":"))
438
+ snapshot = _strict_json(payload, MAX_REQUEST_BYTES, safe_integers=True)
439
+ encoded = payload.encode("utf-8")
440
+ except (ValueError, TypeError, RecursionError, UnicodeError) as error:
441
+ raise NMBridgeError("NM-PY-001", "input", self.locale) from error
442
+ args = [*self.command, "bridge", "--locale", self.locale, "--timeout-ms", str(int(self.timeout * 1000))]
443
+ for name in self.experimental:
444
+ args.extend(("--allow-feature", name))
445
+ stdout, returncode = self._exchange(args, encoded)
446
+ try:
447
+ response = NMResult.from_json(stdout.decode("utf-8", errors="strict").removesuffix("\n"))
448
+ if returncode != (0 if response.success else 1):
449
+ raise ValueError("Exit/result mismatch")
450
+ if response.record["locale"] != self.locale:
451
+ raise ValueError("Locale mismatch")
452
+ if response.record["status"] == "completed" and (response.request != snapshot
453
+ or response.record["permissions"].get("authorized") != {name: True for name in self.experimental}):
454
+ raise ValueError("Source/options/authority mismatch")
455
+ return response
456
+ except (ValueError, TypeError, RecursionError, UnicodeError) as error:
457
+ raise NMBridgeError("NM-PY-010", "transport", self.locale) from error
458
+
459
+ def _exchange(self, args, payload):
460
+ try:
461
+ process = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
462
+ shell=False, bufsize=0, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
463
+ except OSError as error:
464
+ raise NMBridgeError("NM-PY-010", "transport", self.locale) from error
465
+ output = bytearray()
466
+ # Reserve one of the two cleanup seconds for reaping the direct process.
467
+ # EOF is part of the deadline too: a custom launcher's child may inherit
468
+ # the pipes even after that launcher exits. No reader/writer threads or
469
+ # buffered stream locks may outlive this call.
470
+ deadline = time.monotonic() + self.timeout + 1
471
+ readers = {process.stdout: [MAX_RESPONSE_BYTES + 1, 0], process.stderr: [MAX_STDERR_BYTES, 0]}
472
+ position = 0
473
+ try:
474
+ _, write = _set_nonblocking(process.stdin)
475
+ read = {stream: _set_nonblocking(stream)[0] for stream in readers}
476
+ while readers or process.poll() is None:
477
+ if time.monotonic() >= deadline:
478
+ raise NMBridgeError("NM-PY-008", "timeout", self.locale)
479
+ progressed = False
480
+ if not process.stdin.closed:
481
+ try:
482
+ written = write(memoryview(payload)[position:position + 65536])
483
+ position += written or 0
484
+ progressed = bool(written)
485
+ if position == len(payload):
486
+ process.stdin.close()
487
+ except BrokenPipeError:
488
+ process.stdin.close()
489
+ for stream, state in list(readers.items()):
490
+ chunk = read[stream](65536)
491
+ if chunk is None:
492
+ continue
493
+ progressed = True
494
+ if not chunk:
495
+ del readers[stream]
496
+ stream.close()
497
+ continue
498
+ state[1] += len(chunk)
499
+ if state[1] > state[0]:
500
+ raise NMBridgeError("NM-PY-006", "output", self.locale)
501
+ if stream is process.stdout:
502
+ output.extend(chunk)
503
+ if not progressed:
504
+ time.sleep(min(0.005, max(0, deadline - time.monotonic())))
505
+ except KeyboardInterrupt as error:
506
+ raise NMBridgeError("NM-PY-009", "cancel", self.locale) from error
507
+ except OSError as error:
508
+ raise NMBridgeError("NM-PY-010", "transport", self.locale) from error
509
+ finally:
510
+ if process.poll() is None:
511
+ try:
512
+ process.kill()
513
+ except OSError:
514
+ pass
515
+ try:
516
+ process.wait(timeout=1)
517
+ except subprocess.TimeoutExpired:
518
+ pass
519
+ for stream in (process.stdin, process.stdout, process.stderr):
520
+ try:
521
+ stream.close()
522
+ except OSError:
523
+ pass
524
+ return bytes(output), process.returncode
@@ -0,0 +1,20 @@
1
+ """Optional, explicitly invoked IPython cells over the stdlib N/M bridge.
2
+
3
+ Importing this package neither imports IPython nor launches the CLI. The legacy
4
+ ``from nm_bridge import NMClient, NMResult`` remains supported.
5
+ """
6
+ from nm_bridge import NMBridgeError, NMClient, NMResult
7
+
8
+ __all__ = ["NMBridgeError", "NMClient", "NMResult"]
9
+
10
+
11
+ def load_ipython_extension(ipython):
12
+ """Register %%nm only when IPython explicitly loads this extension."""
13
+ from .extension import load_ipython_extension as load
14
+ load(ipython)
15
+
16
+
17
+ def unload_ipython_extension(ipython):
18
+ """Remove this extension's magic without changing other shell magics."""
19
+ from .extension import unload_ipython_extension as unload
20
+ unload(ipython)