@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.
- package/README.md +229 -166
- package/bin/bridge-host.mjs +78 -0
- package/bin/bridge-worker.mjs +27 -0
- package/bin/nm.mjs +5 -1
- package/bin/project-create.d.mts +27 -0
- package/bin/project-create.mjs +122 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +120 -25
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +7 -2
- package/python/LICENSE.txt +57 -0
- package/python/NOTICE.txt +11 -0
- package/python/README.md +174 -0
- package/python/examples/magic.ipynb +98 -0
- package/python/examples/training.ipynb +78 -0
- package/python/nm_bridge.py +524 -0
- package/python/nm_jupyter/__init__.py +20 -0
- package/python/nm_jupyter/extension.py +135 -0
- package/python/pyproject.toml +21 -0
|
@@ -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)
|