@lincyaw/kernel 0.0.0-stage → 1.0.0-rc.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/LICENSE +202 -0
- package/README.md +32 -2
- package/package.json +37 -3
- package/src/anchors.ts +54 -0
- package/src/blocks.ts +27 -0
- package/src/checks.ts +35 -0
- package/src/contracts/agent.ts +153 -0
- package/src/contracts/execution.ts +119 -0
- package/src/contracts/operations.ts +8 -0
- package/src/contracts/preview.ts +391 -0
- package/src/contracts/server.ts +158 -0
- package/src/contracts/service.ts +8 -0
- package/src/contracts/speech.ts +137 -0
- package/src/contracts.ts +285 -0
- package/src/errors.ts +7 -0
- package/src/events.ts +32 -0
- package/src/execution.ts +21 -0
- package/src/fingerprint.ts +31 -0
- package/src/follow.ts +37 -0
- package/src/git.ts +12 -0
- package/src/html.ts +41 -0
- package/src/index.ts +29 -0
- package/src/ir.ts +318 -0
- package/src/kernel.ts +177 -0
- package/src/lock.ts +85 -0
- package/src/logging.ts +11 -0
- package/src/markdown.ts +311 -0
- package/src/node/index.ts +133 -0
- package/src/node/strip-types.mjs +15 -0
- package/src/published.ts +11 -0
- package/src/reading.ts +91 -0
- package/src/requests.ts +28 -0
- package/src/runner.py +454 -0
- package/src/services.ts +146 -0
package/src/runner.py
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
# The Python runner of Presmium (docs/architecture.md 9.2), published by the kernel as
|
|
2
|
+
# `@lincyaw/kernel/runner.py` and shared by the plugins that run Python: the execution plugin
|
|
3
|
+
# loads it into Pyodide in the browser and runs it with the local python3 at compile time, the
|
|
4
|
+
# local environment plugin runs it in the container. The kernel itself never reads it.
|
|
5
|
+
#
|
|
6
|
+
# A run gives the parameters as variables (read back by `_pm_arg`: literals keep their type), the
|
|
7
|
+
# request's stdin to the program and its files in the working directory. Its result is JSON: the
|
|
8
|
+
# text streams `stdout` and `stderr`, the exit code, and the rich outputs in nbformat form
|
|
9
|
+
# (`outputs`: the streams in order, what `display()` shows, matplotlib figures, the value of a
|
|
10
|
+
# last expression as Jupyter shows it, the error), docs/ir.md 6.1.
|
|
11
|
+
#
|
|
12
|
+
# Without a session every run gets fresh globals, and what it changes in the interpreter is undone
|
|
13
|
+
# afterwards (_PmIsolation), so one run in the browser's long-lived interpreter cannot affect the
|
|
14
|
+
# next. The runs of one session share their globals and working directory until `_pm_end`.
|
|
15
|
+
#
|
|
16
|
+
# At compile time the process reads its input from `.presmium/request.json` in the run directory,
|
|
17
|
+
# its working directory, and writes the result of run i to `.presmium/result-<i>.json` there
|
|
18
|
+
# (`_pm_main`): no standard input or extra file descriptor, which a process sandbox does not pass on.
|
|
19
|
+
import ast
|
|
20
|
+
import base64
|
|
21
|
+
import builtins
|
|
22
|
+
import contextlib
|
|
23
|
+
import importlib.util
|
|
24
|
+
import io
|
|
25
|
+
import json
|
|
26
|
+
import linecache
|
|
27
|
+
import os
|
|
28
|
+
import shutil
|
|
29
|
+
import sys
|
|
30
|
+
import tempfile
|
|
31
|
+
import traceback
|
|
32
|
+
import types
|
|
33
|
+
|
|
34
|
+
_PM_FILE = '<presmium>'
|
|
35
|
+
# Session name -> {'scope': the globals, 'dir': the working directory}.
|
|
36
|
+
_PM_SESSIONS = {}
|
|
37
|
+
# The outputs of the run under way, which display() and plt.show() add to.
|
|
38
|
+
_PM_CURRENT = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class _PmStop(BaseException):
|
|
42
|
+
"""Raised by the tracer when the event limit is reached."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class _PmOutputs:
|
|
46
|
+
"""The outputs of one run in order (nbformat), with the text streams also kept apart."""
|
|
47
|
+
|
|
48
|
+
def __init__(self):
|
|
49
|
+
self.items = []
|
|
50
|
+
self.text = {'stdout': [], 'stderr': []}
|
|
51
|
+
|
|
52
|
+
def stream(self, name, text):
|
|
53
|
+
if not text:
|
|
54
|
+
return
|
|
55
|
+
self.text[name].append(text)
|
|
56
|
+
last = self.items[-1] if self.items else None
|
|
57
|
+
if last is not None and last['output_type'] == 'stream' and last['name'] == name:
|
|
58
|
+
last['text'] += text
|
|
59
|
+
else:
|
|
60
|
+
self.items.append({'output_type': 'stream', 'name': name, 'text': text})
|
|
61
|
+
|
|
62
|
+
def display(self, data, kind='display_data'):
|
|
63
|
+
self.items.append({'output_type': kind, 'data': data, 'metadata': {}})
|
|
64
|
+
|
|
65
|
+
def error(self, etype, value, tb):
|
|
66
|
+
text = ''.join(traceback.format_exception(etype, value, tb))
|
|
67
|
+
self.text['stderr'].append(text)
|
|
68
|
+
self.items.append({'output_type': 'error', 'ename': etype.__name__, 'evalue': str(value), 'traceback': text.rstrip('\n').split('\n')})
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class _PmStream(io.TextIOBase):
|
|
72
|
+
"""sys.stdout or sys.stderr of a run: what the program writes becomes a stream output."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, outputs, name):
|
|
75
|
+
super().__init__()
|
|
76
|
+
self._outputs = outputs
|
|
77
|
+
self._name = name
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def encoding(self):
|
|
81
|
+
return 'utf-8'
|
|
82
|
+
|
|
83
|
+
def writable(self):
|
|
84
|
+
return True
|
|
85
|
+
|
|
86
|
+
def write(self, text):
|
|
87
|
+
self._outputs.stream(self._name, str(text))
|
|
88
|
+
return len(text)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
_PM_REPRS = [
|
|
92
|
+
('text/html', '_repr_html_'),
|
|
93
|
+
('text/markdown', '_repr_markdown_'),
|
|
94
|
+
('text/latex', '_repr_latex_'),
|
|
95
|
+
('image/svg+xml', '_repr_svg_'),
|
|
96
|
+
('image/png', '_repr_png_'),
|
|
97
|
+
('image/jpeg', '_repr_jpeg_'),
|
|
98
|
+
('application/json', '_repr_json_'),
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _pm_value(value):
|
|
103
|
+
"""A MIME value as JSON holds it: binary data in base64."""
|
|
104
|
+
if isinstance(value, (bytes, bytearray)):
|
|
105
|
+
return base64.b64encode(value).decode('ascii')
|
|
106
|
+
return value
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _pm_bundle(obj):
|
|
110
|
+
"""The MIME bundle of an object, as Jupyter builds it from its `_repr_*_` methods; always with text/plain."""
|
|
111
|
+
data = {}
|
|
112
|
+
if not isinstance(obj, type):
|
|
113
|
+
method = getattr(obj, '_repr_mimebundle_', None)
|
|
114
|
+
if callable(method):
|
|
115
|
+
try:
|
|
116
|
+
found = method()
|
|
117
|
+
if isinstance(found, tuple):
|
|
118
|
+
found = found[0]
|
|
119
|
+
if isinstance(found, dict):
|
|
120
|
+
data.update({mime: _pm_value(value) for mime, value in found.items()})
|
|
121
|
+
except Exception:
|
|
122
|
+
pass
|
|
123
|
+
for mime, name in _PM_REPRS:
|
|
124
|
+
method = getattr(obj, name, None)
|
|
125
|
+
if mime in data or not callable(method):
|
|
126
|
+
continue
|
|
127
|
+
try:
|
|
128
|
+
value = method()
|
|
129
|
+
except Exception:
|
|
130
|
+
continue
|
|
131
|
+
if isinstance(value, tuple):
|
|
132
|
+
value = value[0]
|
|
133
|
+
if value is not None:
|
|
134
|
+
data[mime] = _pm_value(value)
|
|
135
|
+
data.setdefault('text/plain', repr(obj))
|
|
136
|
+
return data
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def display(*objs, raw=False, **_options):
|
|
140
|
+
"""Shows objects as rich outputs (Jupyter's `display`); `raw=True` takes MIME bundles."""
|
|
141
|
+
for obj in objs:
|
|
142
|
+
data = dict(obj) if raw and isinstance(obj, dict) else _pm_bundle(obj)
|
|
143
|
+
if _PM_CURRENT is not None:
|
|
144
|
+
_PM_CURRENT.display({mime: _pm_value(value) for mime, value in data.items()})
|
|
145
|
+
else:
|
|
146
|
+
print(data.get('text/plain', ''))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class _PmDisplayObject:
|
|
150
|
+
"""The display objects of IPython.display that the runner provides: data of one MIME type."""
|
|
151
|
+
mime = 'text/plain'
|
|
152
|
+
binary = False
|
|
153
|
+
|
|
154
|
+
def __init__(self, data=None, url=None, filename=None, **_options):
|
|
155
|
+
if filename is not None:
|
|
156
|
+
with open(filename, 'rb' if self.binary else 'r') as f:
|
|
157
|
+
data = f.read()
|
|
158
|
+
self.data = data
|
|
159
|
+
self.url = url
|
|
160
|
+
|
|
161
|
+
def _repr_mimebundle_(self, include=None, exclude=None):
|
|
162
|
+
return {self.mime: _pm_value(self.data), 'text/plain': '<%s object>' % type(self).__name__}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class _PmMarkdown(_PmDisplayObject):
|
|
166
|
+
mime = 'text/markdown'
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class _PmHTML(_PmDisplayObject):
|
|
170
|
+
mime = 'text/html'
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class _PmLatex(_PmDisplayObject):
|
|
174
|
+
mime = 'text/latex'
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class _PmSVG(_PmDisplayObject):
|
|
178
|
+
mime = 'image/svg+xml'
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class _PmJSON(_PmDisplayObject):
|
|
182
|
+
mime = 'application/json'
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class _PmImage(_PmDisplayObject):
|
|
186
|
+
binary = True
|
|
187
|
+
|
|
188
|
+
def __init__(self, data=None, url=None, filename=None, format=None, **options):
|
|
189
|
+
super().__init__(data, url, filename, **options)
|
|
190
|
+
kind = (format or os.path.splitext(filename or '')[1].lstrip('.') or 'png').lower()
|
|
191
|
+
self.mime = {'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'gif': 'image/gif', 'svg': 'image/svg+xml'}.get(kind, 'image/png')
|
|
192
|
+
if self.mime == 'image/svg+xml' and isinstance(self.data, (bytes, bytearray)):
|
|
193
|
+
self.data = self.data.decode('utf-8')
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _pm_figures():
|
|
197
|
+
"""The open matplotlib figures as PNG outputs, then closed (plt.show(), and the end of a run)."""
|
|
198
|
+
plt = sys.modules.get('matplotlib.pyplot')
|
|
199
|
+
if plt is None or _PM_CURRENT is None:
|
|
200
|
+
return
|
|
201
|
+
for number in plt.get_fignums():
|
|
202
|
+
figure = plt.figure(number)
|
|
203
|
+
data = io.BytesIO()
|
|
204
|
+
figure.savefig(data, format='png', bbox_inches='tight')
|
|
205
|
+
_PM_CURRENT.display({'image/png': base64.b64encode(data.getvalue()).decode('ascii'), 'text/plain': repr(figure)})
|
|
206
|
+
plt.close('all')
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class _PmModules:
|
|
210
|
+
"""
|
|
211
|
+
The modules the runner provides, made when the code imports them, the same in the browser and
|
|
212
|
+
at compile time (an installed IPython is not used): `IPython` and `IPython.display` with
|
|
213
|
+
`display` and its display objects, for an IPython without a shell; `_presmium_matplotlib`,
|
|
214
|
+
the matplotlib backend of the runs, which draws with Agg and shows the figures as outputs on
|
|
215
|
+
plt.show() and at the end of a run, like Jupyter's inline backend.
|
|
216
|
+
"""
|
|
217
|
+
|
|
218
|
+
@classmethod
|
|
219
|
+
def find_spec(cls, name, _path=None, _target=None):
|
|
220
|
+
if name in ('IPython', 'IPython.display', '_presmium_matplotlib'):
|
|
221
|
+
return importlib.util.spec_from_loader(name, cls, is_package=name == 'IPython')
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
@staticmethod
|
|
225
|
+
def create_module(_spec):
|
|
226
|
+
return None
|
|
227
|
+
|
|
228
|
+
@staticmethod
|
|
229
|
+
def exec_module(module):
|
|
230
|
+
if module.__name__ == '_presmium_matplotlib':
|
|
231
|
+
from matplotlib.backends.backend_agg import FigureCanvasAgg
|
|
232
|
+
module.FigureCanvas = FigureCanvasAgg
|
|
233
|
+
module.show = lambda *_args, **_kwargs: _pm_figures()
|
|
234
|
+
elif module.__name__ == 'IPython':
|
|
235
|
+
module.get_ipython = lambda: None
|
|
236
|
+
# Newer than the versions whose shell matplotlib adjusts.
|
|
237
|
+
module.version_info = (8, 24, 0, '')
|
|
238
|
+
else:
|
|
239
|
+
for name, value in [('display', display), ('Markdown', _PmMarkdown), ('HTML', _PmHTML), ('Latex', _PmLatex),
|
|
240
|
+
('SVG', _PmSVG), ('JSON', _PmJSON), ('Image', _PmImage)]:
|
|
241
|
+
setattr(module, name, value)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
if _PmModules not in sys.meta_path:
|
|
245
|
+
sys.meta_path.insert(0, _PmModules)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class _PmIsolation:
|
|
249
|
+
"""
|
|
250
|
+
The working directory of a run and what it changes in the interpreter. Without a session the
|
|
251
|
+
run gets a fresh directory (`dir`, or a new temporary one, removed afterwards) and the state it
|
|
252
|
+
may change is restored; in a session the directory and the state persist.
|
|
253
|
+
"""
|
|
254
|
+
|
|
255
|
+
def __init__(self, dir=None, session=None):
|
|
256
|
+
self.given = dir
|
|
257
|
+
self.session = session
|
|
258
|
+
|
|
259
|
+
def __enter__(self):
|
|
260
|
+
self.limit = sys.getrecursionlimit()
|
|
261
|
+
self.path = list(sys.path)
|
|
262
|
+
self.modules = set(sys.modules)
|
|
263
|
+
self.builtins = dict(builtins.__dict__)
|
|
264
|
+
self.stdin = sys.stdin
|
|
265
|
+
self.cwd = os.getcwd()
|
|
266
|
+
self.backend = os.environ.get('MPLBACKEND')
|
|
267
|
+
if self.given is not None:
|
|
268
|
+
self.dir = self.given
|
|
269
|
+
elif self.session is not None:
|
|
270
|
+
self.dir = self.session['dir']
|
|
271
|
+
else:
|
|
272
|
+
self.dir = tempfile.mkdtemp(prefix='presmium-run-')
|
|
273
|
+
os.chdir(self.dir)
|
|
274
|
+
os.environ['MPLBACKEND'] = 'module://_presmium_matplotlib'
|
|
275
|
+
return self
|
|
276
|
+
|
|
277
|
+
def __exit__(self, *_exc):
|
|
278
|
+
sys.setrecursionlimit(self.limit)
|
|
279
|
+
os.chdir(self.cwd)
|
|
280
|
+
sys.stdin = self.stdin
|
|
281
|
+
if self.backend is None:
|
|
282
|
+
os.environ.pop('MPLBACKEND', None)
|
|
283
|
+
else:
|
|
284
|
+
os.environ['MPLBACKEND'] = self.backend
|
|
285
|
+
if self.session is not None:
|
|
286
|
+
return False
|
|
287
|
+
if self.given is None:
|
|
288
|
+
shutil.rmtree(self.dir, ignore_errors=True)
|
|
289
|
+
sys.path[:] = self.path
|
|
290
|
+
for name in set(sys.modules) - self.modules:
|
|
291
|
+
del sys.modules[name]
|
|
292
|
+
for name in set(builtins.__dict__) - set(self.builtins):
|
|
293
|
+
del builtins.__dict__[name]
|
|
294
|
+
builtins.__dict__.update(self.builtins)
|
|
295
|
+
return False
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _pm_arg(raw):
|
|
299
|
+
"""
|
|
300
|
+
A parameter as the program gets it (`RunRequest.args` are strings, docs/architecture.md 4.6): a
|
|
301
|
+
Python literal (`10`, `0.5`, `'hi'`, `[1, 2]`, `False`), else JSON (`true`, `false`, `null`, a
|
|
302
|
+
mapping written as JSON), else the string itself. The Jupyter environments read parameters back
|
|
303
|
+
with the same rule (addon-exec-jupyter's `argumentsCode`).
|
|
304
|
+
"""
|
|
305
|
+
try:
|
|
306
|
+
return ast.literal_eval(raw)
|
|
307
|
+
except (ValueError, SyntaxError, TypeError, MemoryError, RecursionError):
|
|
308
|
+
pass
|
|
309
|
+
try:
|
|
310
|
+
return json.loads(raw)
|
|
311
|
+
except (ValueError, TypeError, RecursionError):
|
|
312
|
+
return raw
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _pm_prepare(code, args_json, stdin, files_json, scope):
|
|
316
|
+
for path, content in json.loads(files_json).items():
|
|
317
|
+
folder = os.path.dirname(path)
|
|
318
|
+
if folder:
|
|
319
|
+
os.makedirs(folder, exist_ok=True)
|
|
320
|
+
with open(path, 'w') as f:
|
|
321
|
+
f.write(content)
|
|
322
|
+
sys.stdin = io.StringIO(stdin)
|
|
323
|
+
linecache.cache[_PM_FILE] = (len(code), None, code.splitlines(True), _PM_FILE)
|
|
324
|
+
scope.setdefault('__name__', '__main__')
|
|
325
|
+
scope.setdefault('display', display)
|
|
326
|
+
for name, raw in json.loads(args_json).items():
|
|
327
|
+
scope[name] = _pm_arg(raw)
|
|
328
|
+
return compile(code, _PM_FILE, 'exec')
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _pm_split(code, flags=0):
|
|
332
|
+
"""
|
|
333
|
+
The code as Jupyter runs it (docs/ir.md 6.1): its statements, and apart the last one when it is
|
|
334
|
+
an expression, whose value is the run's `execute_result` (`_pm_result`); `(program, None)`
|
|
335
|
+
without one, or when a `;` ends the code, which hides the value as in Jupyter.
|
|
336
|
+
"""
|
|
337
|
+
tree = ast.parse(code, _PM_FILE)
|
|
338
|
+
if not tree.body or not isinstance(tree.body[-1], ast.Expr) or code.rstrip().endswith(';'):
|
|
339
|
+
return compile(tree, _PM_FILE, 'exec', flags=flags), None
|
|
340
|
+
last = ast.Expression(tree.body.pop().value)
|
|
341
|
+
return compile(tree, _PM_FILE, 'exec', flags=flags), compile(last, _PM_FILE, 'eval', flags=flags)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _pm_result(outputs, value):
|
|
345
|
+
"""The value of the last expression as an `execute_result`, with the MIME bundle `display` shows; None shows nothing."""
|
|
346
|
+
if value is not None:
|
|
347
|
+
outputs.display({mime: _pm_value(item) for mime, item in _pm_bundle(value).items()}, 'execute_result')
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _pm_user_traceback():
|
|
351
|
+
"""The current exception, its traceback starting at the user's code."""
|
|
352
|
+
etype, value, tb = sys.exc_info()
|
|
353
|
+
while tb is not None and tb.tb_frame.f_code.co_filename != _PM_FILE:
|
|
354
|
+
tb = tb.tb_next
|
|
355
|
+
return etype, value, tb
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _pm_session(name, dir=None):
|
|
359
|
+
"""A session's state; its working directory is `dir` when given (the run directory), else one of its own."""
|
|
360
|
+
if name not in _PM_SESSIONS:
|
|
361
|
+
own = dir is None
|
|
362
|
+
_PM_SESSIONS[name] = {'scope': {}, 'dir': tempfile.mkdtemp(prefix='presmium-session-') if own else dir, 'own': own}
|
|
363
|
+
return _PM_SESSIONS[name]
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _pm_end(name):
|
|
367
|
+
"""Drops a session: its globals and the working directory it made."""
|
|
368
|
+
session = _PM_SESSIONS.pop(name, None)
|
|
369
|
+
if session is not None and session['own']:
|
|
370
|
+
shutil.rmtree(session['dir'], ignore_errors=True)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _pm_run(code, args_json, stdin, files_json, _max_events, session_name=None, dir=None):
|
|
374
|
+
global _PM_CURRENT
|
|
375
|
+
outputs = _PmOutputs()
|
|
376
|
+
exit_code = 0
|
|
377
|
+
session = _pm_session(session_name, dir) if session_name else None
|
|
378
|
+
scope = session['scope'] if session is not None else {}
|
|
379
|
+
_PM_CURRENT = outputs
|
|
380
|
+
try:
|
|
381
|
+
with _PmIsolation(dir, session), contextlib.redirect_stdout(_PmStream(outputs, 'stdout')), contextlib.redirect_stderr(_PmStream(outputs, 'stderr')):
|
|
382
|
+
try:
|
|
383
|
+
_pm_prepare(code, args_json, stdin, files_json, scope)
|
|
384
|
+
program, last = _pm_split(code)
|
|
385
|
+
exec(program, scope)
|
|
386
|
+
if last is not None:
|
|
387
|
+
_pm_result(outputs, eval(last, scope))
|
|
388
|
+
_pm_figures()
|
|
389
|
+
except SystemExit as e:
|
|
390
|
+
exit_code = e.code if isinstance(e.code, int) else (0 if e.code is None else 1)
|
|
391
|
+
except BaseException:
|
|
392
|
+
outputs.error(*_pm_user_traceback())
|
|
393
|
+
exit_code = 1
|
|
394
|
+
finally:
|
|
395
|
+
_PM_CURRENT = None
|
|
396
|
+
return json.dumps({'stdout': ''.join(outputs.text['stdout']), 'stderr': ''.join(outputs.text['stderr']), 'exitCode': exit_code, 'outputs': outputs.items})
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _pm_trace(code, args_json, stdin, files_json, max_events, _session_name=None):
|
|
400
|
+
"""Runs the code once, recording each executed line of it with the output accumulated after the line ran."""
|
|
401
|
+
out = io.StringIO()
|
|
402
|
+
events = []
|
|
403
|
+
truncated = False
|
|
404
|
+
|
|
405
|
+
def tracer(frame, event, _arg):
|
|
406
|
+
nonlocal truncated
|
|
407
|
+
if frame.f_code.co_filename != _PM_FILE:
|
|
408
|
+
return None
|
|
409
|
+
if event == 'line':
|
|
410
|
+
if events:
|
|
411
|
+
events[-1]['output'] = out.getvalue()
|
|
412
|
+
if len(events) >= max_events:
|
|
413
|
+
truncated = True
|
|
414
|
+
raise _PmStop()
|
|
415
|
+
events.append({'line': frame.f_lineno, 'output': ''})
|
|
416
|
+
return tracer
|
|
417
|
+
|
|
418
|
+
with _PmIsolation(), contextlib.redirect_stdout(out), contextlib.redirect_stderr(out):
|
|
419
|
+
try:
|
|
420
|
+
scope = {}
|
|
421
|
+
program = _pm_prepare(code, args_json, stdin, files_json, scope)
|
|
422
|
+
sys.settrace(tracer)
|
|
423
|
+
exec(program, scope)
|
|
424
|
+
except (_PmStop, SystemExit):
|
|
425
|
+
pass
|
|
426
|
+
except BaseException:
|
|
427
|
+
out.write(''.join(traceback.format_exception(*_pm_user_traceback())))
|
|
428
|
+
finally:
|
|
429
|
+
sys.settrace(None)
|
|
430
|
+
if events:
|
|
431
|
+
events[-1]['output'] = out.getvalue()
|
|
432
|
+
return json.dumps({'events': events, 'truncated': truncated, 'output': out.getvalue()})
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _pm_main():
|
|
436
|
+
"""
|
|
437
|
+
A compile-time process: runs the requests of `.presmium/request.json` in order in the run
|
|
438
|
+
directory, its working directory (one session when the request names one), and writes the
|
|
439
|
+
result of each as soon as it is known, so a run stopped at its timeout keeps the earlier ones.
|
|
440
|
+
"""
|
|
441
|
+
here = os.getcwd()
|
|
442
|
+
channel = os.path.join(here, '.presmium')
|
|
443
|
+
with open(os.path.join(channel, 'request.json'), encoding='utf-8') as f:
|
|
444
|
+
request = json.load(f)
|
|
445
|
+
# E.g. the workspace in a container, whose modules stay importable.
|
|
446
|
+
for path in reversed(request.get('path') or []):
|
|
447
|
+
sys.path.insert(1, path)
|
|
448
|
+
session = request.get('session')
|
|
449
|
+
for i, run in enumerate(request['runs']):
|
|
450
|
+
result = _pm_run(run['code'], json.dumps(run.get('args') or {}), run.get('stdin') or '', json.dumps(run.get('files') or {}), 0, session, here)
|
|
451
|
+
partial = os.path.join(channel, 'result-%d.part' % i)
|
|
452
|
+
with open(partial, 'w', encoding='utf-8') as f:
|
|
453
|
+
f.write(result)
|
|
454
|
+
os.replace(partial, os.path.join(channel, 'result-%d.json' % i))
|
package/src/services.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import satisfies from 'semver/functions/satisfies.js'
|
|
2
|
+
import validRange from 'semver/ranges/valid.js'
|
|
3
|
+
import { KERNEL_VERSION } from './contracts.ts'
|
|
4
|
+
import type { PluginManifest, ServiceToken } from './contracts.ts'
|
|
5
|
+
|
|
6
|
+
export const HOST_OWNER = 'host'
|
|
7
|
+
|
|
8
|
+
export interface StartPlan {
|
|
9
|
+
order: string[]
|
|
10
|
+
excluded: Map<string, string>
|
|
11
|
+
cycles: string[][]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface Entry {
|
|
15
|
+
impl: unknown
|
|
16
|
+
owner: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class ServiceRegistry {
|
|
20
|
+
#entries = new Map<string, Entry>()
|
|
21
|
+
|
|
22
|
+
provide<T>(token: ServiceToken<T>, impl: T, owner: string = HOST_OWNER): void {
|
|
23
|
+
const existing = this.#entries.get(token.id)
|
|
24
|
+
if (existing) throw new Error(`service ${token.id} is already provided by ${existing.owner}`)
|
|
25
|
+
this.#entries.set(token.id, { impl, owner })
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
has(token: ServiceToken<unknown>): boolean {
|
|
29
|
+
return this.#entries.has(token.id)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
get<T>(token: ServiceToken<T>): T {
|
|
33
|
+
const entry = this.#entries.get(token.id)
|
|
34
|
+
if (!entry) throw new Error(`service ${token.id} is not available`)
|
|
35
|
+
return entry.impl as T
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
tryGet<T>(token: ServiceToken<T>): T | undefined {
|
|
39
|
+
return this.#entries.get(token.id)?.impl as T | undefined
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
owner(token: ServiceToken<unknown>): string | undefined {
|
|
43
|
+
return this.#entries.get(token.id)?.owner
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
revoke(owner: string): void {
|
|
47
|
+
for (const [id, entry] of this.#entries) {
|
|
48
|
+
if (entry.owner === owner) this.#entries.delete(id)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
ids(): string[] {
|
|
53
|
+
return [...this.#entries.keys()]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
plan(manifests: PluginManifest[], kernelVersion: string = KERNEL_VERSION): StartPlan {
|
|
57
|
+
const excluded = new Map<string, string>()
|
|
58
|
+
for (const m of manifests) {
|
|
59
|
+
const reason = incompatibility(m.kernel, kernelVersion)
|
|
60
|
+
if (reason) excluded.set(m.id, reason)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const providers = new Map<string, string>([...this.#entries].map(([id, entry]) => [id, entry.owner]))
|
|
64
|
+
for (const m of manifests) {
|
|
65
|
+
if (excluded.has(m.id)) continue
|
|
66
|
+
const taken = m.provides?.find(t => providers.has(t.id))
|
|
67
|
+
if (taken) {
|
|
68
|
+
excluded.set(m.id, `service ${taken.id} is already provided by ${providers.get(taken.id)}`)
|
|
69
|
+
continue
|
|
70
|
+
}
|
|
71
|
+
for (const t of m.provides ?? []) providers.set(t.id, m.id)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const ids = manifests.map(m => m.id).filter(id => !excluded.has(id))
|
|
75
|
+
const plugins = new Set(ids)
|
|
76
|
+
const deps = new Map<string, string[]>()
|
|
77
|
+
for (const m of manifests) {
|
|
78
|
+
if (!plugins.has(m.id)) continue
|
|
79
|
+
const on = (m.requires ?? [])
|
|
80
|
+
.map(r => providers.get(r.token.id))
|
|
81
|
+
.filter((p): p is string => p !== undefined && plugins.has(p))
|
|
82
|
+
deps.set(m.id, [...new Set(on)])
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const cycles = findCycles(ids, deps)
|
|
86
|
+
for (const cycle of cycles) {
|
|
87
|
+
const path = [...cycle, cycle[0]].join(' -> ')
|
|
88
|
+
for (const id of cycle) excluded.set(id, `dependency cycle: ${path}`)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { order: topologicalOrder(ids.filter(id => !excluded.has(id)), deps), excluded, cycles }
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function kernelIncompatibility(range: string, version: string = KERNEL_VERSION): string | undefined {
|
|
96
|
+
return incompatibility(range, version)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function incompatibility(range: string, version: string): string | undefined {
|
|
100
|
+
if (validRange(range) === null) return `invalid kernel range "${range}"`
|
|
101
|
+
if (!satisfies(version, range, { includePrerelease: true })) return `kernel ${version} does not satisfy "${range}"`
|
|
102
|
+
return undefined
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function reachable(start: string, deps: Map<string, string[]>): Set<string> {
|
|
106
|
+
const seen = new Set<string>()
|
|
107
|
+
const stack = [...(deps.get(start) ?? [])]
|
|
108
|
+
while (stack.length > 0) {
|
|
109
|
+
const id = stack.pop()!
|
|
110
|
+
if (seen.has(id)) continue
|
|
111
|
+
seen.add(id)
|
|
112
|
+
stack.push(...(deps.get(id) ?? []))
|
|
113
|
+
}
|
|
114
|
+
return seen
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function findCycles(ids: string[], deps: Map<string, string[]>): string[][] {
|
|
118
|
+
const reach = new Map(ids.map(id => [id, reachable(id, deps)]))
|
|
119
|
+
const onCycle = ids.filter(id => reach.get(id)!.has(id))
|
|
120
|
+
const cycles: string[][] = []
|
|
121
|
+
const grouped = new Set<string>()
|
|
122
|
+
for (const id of onCycle) {
|
|
123
|
+
if (grouped.has(id)) continue
|
|
124
|
+
const members = new Set(onCycle.filter(o => reach.get(id)!.has(o) && reach.get(o)!.has(id)))
|
|
125
|
+
const path = [id]
|
|
126
|
+
for (;;) {
|
|
127
|
+
const next = deps.get(path.at(-1)!)!.find(d => members.has(d) && !path.includes(d))
|
|
128
|
+
if (next === undefined) break
|
|
129
|
+
path.push(next)
|
|
130
|
+
}
|
|
131
|
+
for (const m of members) if (!path.includes(m)) path.push(m)
|
|
132
|
+
path.forEach(m => grouped.add(m))
|
|
133
|
+
cycles.push(path)
|
|
134
|
+
}
|
|
135
|
+
return cycles
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function topologicalOrder(ids: string[], deps: Map<string, string[]>): string[] {
|
|
139
|
+
const pending = [...ids]
|
|
140
|
+
const order: string[] = []
|
|
141
|
+
while (pending.length > 0) {
|
|
142
|
+
const index = pending.findIndex(id => deps.get(id)!.every(d => !pending.includes(d)))
|
|
143
|
+
order.push(...pending.splice(index, 1))
|
|
144
|
+
}
|
|
145
|
+
return order
|
|
146
|
+
}
|