jsonyter 1.0.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.
jsonyter/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ """jsonyter: a JSON-first Python interface to a Jupyter server.
2
+
3
+ Every public method returns plain Python objects (dicts, lists, strings,
4
+ numbers, booleans, None) that serialize directly with ``json.dumps``, so the
5
+ library can sit behind any editor front end — the original target being an
6
+ Emacs REPL driven over a JSON pipe (see ``jsonyter.cli``).
7
+ """
8
+
9
+ from .client import Client, JupyterError
10
+ from .kernel import KernelConnection
11
+ from .notebook import NotebookConflict, file_hash, read_notebook, write_notebook
12
+
13
+ __version__ = "1.0.0"
14
+
15
+ __all__ = ["Client", "KernelConnection", "JupyterError", "NotebookConflict",
16
+ "read_notebook", "write_notebook", "file_hash", "__version__"]
jsonyter/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
jsonyter/cli.py ADDED
@@ -0,0 +1,429 @@
1
+ """JSON-over-stdio interface, designed to be driven by Emacs.
2
+
3
+ Run ``jsonyter --url http://localhost:8888`` and write one JSON request per
4
+ line to stdin; one JSON response per line comes back on stdout. Requests look
5
+ like::
6
+
7
+ {"id": 1, "method": "execute", "params": {"kernel_id": "...", "code": "1+1"}}
8
+
9
+ and responses like::
10
+
11
+ {"id": 1, "result": {...}}
12
+ {"id": 1, "error": {"error": "JupyterError", "message": "...", ...}}
13
+
14
+ Requests are handled concurrently: calls that don't touch a kernel's socket
15
+ run immediately, and each kernel gets its own worker so a long ``execute``
16
+ never blocks the reader. That means an ``interrupt_kernel`` sent while code
17
+ is running is acted on right away, and responses may arrive out of request
18
+ order — match them by ``id``.
19
+
20
+ Lines that are not final responses are tagged by an extra key instead of
21
+ ``result``/``error``, so a client can dispatch on which key is present:
22
+
23
+ - ``{"id": N, "output": {...}}`` — incremental output from a running
24
+ ``execute`` (only when that request passed ``"stream": true``).
25
+ - ``{"id": N, "input_request": {"prompt": ...}}`` — the kernel wants stdin;
26
+ reply with ``{"id": N, "input": "..."}`` (a bare ``{"input": "..."}`` also
27
+ works when only one request is waiting).
28
+ - ``{"event": {...}, "kernel_id": ...}`` — async kernel state, after
29
+ ``subscribe``.
30
+
31
+ Available methods (params in parentheses):
32
+
33
+ - ``status``, ``version``, ``list_kernelspecs``, ``list_kernels``,
34
+ ``list_sessions``
35
+ - ``start_kernel`` (``name``), ``get_kernel``/``shutdown_kernel``/
36
+ ``restart_kernel``/``interrupt_kernel`` (``kernel_id``)
37
+ - ``create_session`` (``path``, ``kernel_name``), ``delete_session`` (``session_id``)
38
+ - ``execute`` (``kernel_id``, ``code``, ``timeout``, ``silent``, ``stream``)
39
+ - ``complete``/``inspect`` (``kernel_id``, ``code``, ``cursor_pos``),
40
+ ``is_complete`` (``kernel_id``, ``code``), ``kernel_info`` (``kernel_id``)
41
+ - ``subscribe``/``unsubscribe`` (``kernel_id``) — async kernel status events
42
+ - ``disconnect`` (``kernel_id``) — close the websocket but leave the kernel up
43
+ - ``read_notebook`` (``path``), ``write_notebook`` (``path``, ``cells``,
44
+ ``expect_hash``, ``include_outputs``), ``notebook_hash`` (``path``) — local
45
+ ``.ipynb`` files; these need no server and no kernel, so the bridge is
46
+ usable offline
47
+ """
48
+
49
+ import argparse
50
+ import json
51
+ import os
52
+ import queue
53
+ import sys
54
+ import threading
55
+ import time
56
+
57
+ from .client import Client, JupyterError
58
+
59
+ # Client methods invocable directly, mapped to accepted params.
60
+ _CLIENT_METHODS = {
61
+ "status": (), "version": (), "list_kernelspecs": (), "list_kernels": (),
62
+ "list_sessions": (),
63
+ "start_kernel": ("name",),
64
+ "get_kernel": ("kernel_id",),
65
+ "shutdown_kernel": ("kernel_id",),
66
+ "restart_kernel": ("kernel_id",),
67
+ "interrupt_kernel": ("kernel_id",),
68
+ "create_session": ("path", "kernel_name", "session_type", "name"),
69
+ "get_session": ("session_id",),
70
+ "delete_session": ("session_id",),
71
+ "get_contents": ("path", "content"),
72
+ # Local filesystem, no server contact — and not on a kernel worker, so a
73
+ # save never queues behind a running execute.
74
+ "read_notebook": ("path",),
75
+ "write_notebook": ("path", "cells", "expect_hash", "include_outputs"),
76
+ "notebook_hash": ("path",),
77
+ }
78
+
79
+ _KERNEL_METHODS = {
80
+ "execute": ("code", "timeout", "silent", "store_history"),
81
+ "complete": ("code", "cursor_pos", "timeout"),
82
+ "inspect": ("code", "cursor_pos", "detail_level", "timeout"),
83
+ "is_complete": ("code", "timeout"),
84
+ "kernel_info": ("timeout",),
85
+ "history": ("n", "timeout"),
86
+ }
87
+
88
+ _LOCAL_METHODS = ("subscribe", "unsubscribe", "disconnect", "methods")
89
+
90
+
91
+ class Dispatcher:
92
+ """Routes JSON requests to the client/kernels, concurrently."""
93
+
94
+ def __init__(self, client, stdin=None, stdout=None, pretty=False,
95
+ stream=False):
96
+ self.client = client
97
+ self.connections = {}
98
+ self.stdin = stdin or sys.stdin
99
+ self.stdout = stdout or sys.stdout
100
+ self.pretty = pretty
101
+ self.stream = stream
102
+ self._write_lock = threading.Lock()
103
+ self._state_lock = threading.Lock()
104
+ self._workers = {} # kernel_id -> (Queue, Thread)
105
+ self._rest_workers = []
106
+ self._rest_queue = queue.Queue()
107
+ self._pending_input = {} # request id -> Queue
108
+ self._subscribed = {} # kernel_id -> listener callable
109
+ self._stopping = False
110
+
111
+ # ----------------------------------------------------------------- output
112
+
113
+ def _emit(self, obj):
114
+ line = json.dumps(obj, indent=2 if self.pretty else None) + "\n"
115
+ with self._write_lock: # keep concurrent replies from interleaving
116
+ self.stdout.write(line)
117
+ self.stdout.flush()
118
+
119
+ # ------------------------------------------------------------ connections
120
+
121
+ def _connection(self, kernel_id):
122
+ with self._state_lock:
123
+ if kernel_id not in self.connections:
124
+ self.connections[kernel_id] = self.client.kernel(kernel_id)
125
+ return self.connections[kernel_id]
126
+
127
+ def _drop_connection(self, kernel_id):
128
+ with self._state_lock:
129
+ conn = self.connections.pop(kernel_id, None)
130
+ self._subscribed.pop(kernel_id, None)
131
+ if conn is not None:
132
+ try:
133
+ conn.close()
134
+ except Exception:
135
+ pass
136
+ return conn
137
+
138
+ # ---------------------------------------------------------------- workers
139
+
140
+ def _kernel_queue(self, kernel_id):
141
+ """Per-kernel work queue: one socket, so one request at a time."""
142
+ with self._state_lock:
143
+ entry = self._workers.get(kernel_id)
144
+ if entry is None:
145
+ work = queue.Queue()
146
+ thread = threading.Thread(
147
+ target=self._worker_loop, args=(work,),
148
+ name="jsonyter-kernel-" + kernel_id[:8], daemon=True)
149
+ entry = (work, thread)
150
+ self._workers[kernel_id] = entry
151
+ thread.start()
152
+ return entry[0]
153
+
154
+ def _worker_loop(self, work):
155
+ while True:
156
+ request = work.get()
157
+ if request is None:
158
+ return
159
+ self._run(request)
160
+
161
+ def _rest_worker_loop(self):
162
+ while True:
163
+ request = self._rest_queue.get()
164
+ if request is None:
165
+ return
166
+ self._run(request)
167
+
168
+ def _start_rest_workers(self, count=4):
169
+ for i in range(count):
170
+ thread = threading.Thread(
171
+ target=self._rest_worker_loop,
172
+ name="jsonyter-rest-{}".format(i), daemon=True)
173
+ thread.start()
174
+ self._rest_workers.append(thread)
175
+
176
+ def _run(self, request):
177
+ try:
178
+ self._emit(self.dispatch(request))
179
+ except JupyterError as exc:
180
+ self._emit({"id": request.get("id"), "error": exc.to_json()})
181
+ except Exception as exc: # keep the pipe alive on bugs
182
+ self._emit({"id": request.get("id"), "error": {
183
+ "error": type(exc).__name__, "message": str(exc)}})
184
+
185
+ # ------------------------------------------------------------------ stdin
186
+
187
+ def _stdin_callback(self, request_id):
188
+ """Ask the front end for input; the reader thread routes the reply."""
189
+ def ask(content):
190
+ answer = queue.Queue()
191
+ with self._state_lock:
192
+ self._pending_input[request_id] = answer
193
+ try:
194
+ self._emit({"id": request_id, "input_request": content})
195
+ return answer.get()
196
+ finally:
197
+ with self._state_lock:
198
+ self._pending_input.pop(request_id, None)
199
+ return ask
200
+
201
+ def _route_input(self, obj):
202
+ value = obj.get("input", "")
203
+ request_id = obj.get("id")
204
+ with self._state_lock:
205
+ if request_id is not None:
206
+ answer = self._pending_input.get(request_id)
207
+ elif len(self._pending_input) == 1:
208
+ answer = next(iter(self._pending_input.values()))
209
+ else:
210
+ answer = None
211
+ if answer is not None:
212
+ answer.put(value)
213
+
214
+ # -------------------------------------------------------------- dispatch
215
+
216
+ def dispatch(self, request):
217
+ request_id = request.get("id")
218
+ method = request.get("method")
219
+ params = request.get("params") or {}
220
+
221
+ if method in _CLIENT_METHODS:
222
+ allowed = _CLIENT_METHODS[method]
223
+ kwargs = {k: v for k, v in params.items() if k in allowed}
224
+ args = []
225
+ if "kernel_id" in kwargs:
226
+ args = [kwargs.pop("kernel_id")]
227
+ if "session_id" in kwargs:
228
+ args = [kwargs.pop("session_id")]
229
+ if method == "shutdown_kernel" and args:
230
+ self._drop_connection(args[0])
231
+ result = getattr(self.client, method)(*args, **kwargs)
232
+ elif method in _KERNEL_METHODS:
233
+ kernel_id = params.get("kernel_id")
234
+ if not kernel_id:
235
+ raise JupyterError("missing required param: kernel_id")
236
+ conn = self._connection(kernel_id)
237
+ kwargs = {k: v for k, v in params.items()
238
+ if k in _KERNEL_METHODS[method]}
239
+ if method == "execute":
240
+ kwargs["stdin_callback"] = self._stdin_callback(request_id)
241
+ if params.get("stream", self.stream):
242
+ kwargs["on_output"] = lambda output: self._emit(
243
+ {"id": request_id, "output": output})
244
+ result = getattr(conn, method)(**kwargs)
245
+ elif method == "subscribe":
246
+ result = self._subscribe(params.get("kernel_id"))
247
+ elif method == "unsubscribe":
248
+ result = self._unsubscribe(params.get("kernel_id"))
249
+ elif method == "disconnect":
250
+ conn = self._drop_connection(params.get("kernel_id"))
251
+ result = {"id": params.get("kernel_id"), "closed": conn is not None}
252
+ elif method == "methods":
253
+ result = (sorted(_CLIENT_METHODS) + sorted(_KERNEL_METHODS)
254
+ + sorted(_LOCAL_METHODS))
255
+ else:
256
+ raise JupyterError("unknown method: {!r}".format(method))
257
+ return {"id": request_id, "result": result}
258
+
259
+ # ----------------------------------------------------------- subscriptions
260
+
261
+ def _subscribe(self, kernel_id):
262
+ if not kernel_id:
263
+ raise JupyterError("missing required param: kernel_id")
264
+ with self._state_lock:
265
+ already = kernel_id in self._subscribed
266
+ if already:
267
+ return {"kernel_id": kernel_id, "subscribed": True}
268
+ conn = self._connection(kernel_id)
269
+ conn.connect()
270
+
271
+ def listener(event):
272
+ self._emit({"kernel_id": kernel_id, "event": event})
273
+
274
+ conn.add_listener(listener)
275
+ with self._state_lock:
276
+ self._subscribed[kernel_id] = listener
277
+ return {"kernel_id": kernel_id, "subscribed": True,
278
+ "execution_state": conn.execution_state}
279
+
280
+ def _unsubscribe(self, kernel_id):
281
+ with self._state_lock:
282
+ listener = self._subscribed.pop(kernel_id, None)
283
+ conn = self.connections.get(kernel_id)
284
+ if listener is not None and conn is not None:
285
+ conn.remove_listener(listener)
286
+ return {"kernel_id": kernel_id, "subscribed": False}
287
+
288
+ # -------------------------------------------------------------- main loop
289
+
290
+ def run(self):
291
+ self._start_rest_workers()
292
+ for line in self.stdin:
293
+ line = line.strip()
294
+ if not line:
295
+ continue
296
+ try:
297
+ request = json.loads(line)
298
+ except ValueError as exc:
299
+ self._emit({"id": None, "error": {
300
+ "error": "ParseError", "message": str(exc)}})
301
+ continue
302
+ if not isinstance(request, dict):
303
+ self._emit({"id": None, "error": {
304
+ "error": "ParseError",
305
+ "message": "request must be a JSON object"}})
306
+ continue
307
+ # An input reply, not a request.
308
+ if "input" in request and "method" not in request:
309
+ self._route_input(request)
310
+ continue
311
+ method = request.get("method")
312
+ if method in _KERNEL_METHODS:
313
+ kernel_id = request.get("params", {}).get("kernel_id")
314
+ if kernel_id:
315
+ self._kernel_queue(kernel_id).put(request)
316
+ continue
317
+ self._rest_queue.put(request)
318
+
319
+ def shutdown(self, drain_timeout=10.0):
320
+ """Finish queued work, then tear down.
321
+
322
+ Requests already accepted must still be answered: stdin reaching EOF
323
+ (a one-shot invocation, or the editor quitting) would otherwise kill
324
+ the worker threads mid-flight and silently drop their responses. The
325
+ sentinels queue behind the outstanding work, so joining the workers
326
+ drains it; the deadline keeps a long-running execute from blocking
327
+ exit forever.
328
+ """
329
+ self._stopping = True
330
+ with self._state_lock:
331
+ workers = list(self._workers.values())
332
+ for work, _thread in workers:
333
+ work.put(None)
334
+ for _ in self._rest_workers:
335
+ self._rest_queue.put(None)
336
+
337
+ deadline = time.monotonic() + drain_timeout
338
+ for _work, thread in workers:
339
+ thread.join(timeout=max(0.0, deadline - time.monotonic()))
340
+ for thread in self._rest_workers:
341
+ thread.join(timeout=max(0.0, deadline - time.monotonic()))
342
+
343
+ with self._state_lock:
344
+ connections = list(self.connections.values())
345
+ self.connections.clear()
346
+ for conn in connections:
347
+ try:
348
+ conn.close()
349
+ except Exception:
350
+ pass
351
+
352
+
353
+ def resolve_token(args):
354
+ """Token from the least-exposed source available.
355
+
356
+ ``--token`` is accepted for convenience but is visible to any local user
357
+ via ``ps``; ``JUPYTER_TOKEN`` or ``--token-file`` (including ``-`` to read
358
+ the first stdin line, which pairs with ``gpg -d | jsonyter``) keep it off
359
+ the command line.
360
+ """
361
+ if args.token_file:
362
+ if args.token_file == "-":
363
+ return sys.stdin.readline().strip() or None
364
+ with open(os.path.expanduser(args.token_file)) as handle:
365
+ return handle.read().strip() or None
366
+ if args.token:
367
+ return args.token
368
+ return os.environ.get("JUPYTER_TOKEN") or None
369
+
370
+
371
+ def main(argv=None):
372
+ parser = argparse.ArgumentParser(
373
+ prog="jsonyter",
374
+ description="JSON-over-stdio bridge to a Jupyter server.")
375
+ parser.add_argument("--url", default="http://localhost:8888",
376
+ help="Jupyter server base URL")
377
+ parser.add_argument("--token", default=None,
378
+ help="Jupyter auth token (INSECURE: visible to other "
379
+ "local users via ps; prefer JUPYTER_TOKEN or "
380
+ "--token-file)")
381
+ parser.add_argument("--token-file", default=None, metavar="PATH",
382
+ help="read the token from PATH, or from the first "
383
+ "line of stdin if PATH is '-' (e.g. "
384
+ "gpg -d token.gpg | jsonyter --token-file -)")
385
+ parser.add_argument("--timeout", type=float, default=30.0,
386
+ help="timeout in seconds for REST calls and the "
387
+ "WebSocket handshake (not kernel execution)")
388
+ parser.add_argument("--exec-timeout", type=float, default=None,
389
+ help="default timeout in seconds to wait for a "
390
+ "kernel reply on execute, measured as silence "
391
+ "since the last message (not total run time); "
392
+ "omit for no timeout (wait indefinitely — the "
393
+ "default, since user code may legitimately run "
394
+ "for any length of time; use interrupt_kernel "
395
+ "to stop it)")
396
+ parser.add_argument("--control-timeout", type=float, default=30.0,
397
+ help="same, for the introspection calls (complete, "
398
+ "inspect, is_complete, kernel_info, history), "
399
+ "which are bounded operations; default 30. Pass "
400
+ "0 to wait indefinitely — not advised, since a "
401
+ "kernel that never answers one of these (SAS "
402
+ "never answers history) would wedge its worker")
403
+ parser.add_argument("--stream", action="store_true",
404
+ help="emit incremental {\"id\": N, \"output\": {...}} "
405
+ "lines for every execute, without each request "
406
+ "having to ask for \"stream\": true")
407
+ parser.add_argument("--insecure", action="store_true",
408
+ help="skip TLS certificate verification")
409
+ parser.add_argument("--pretty", action="store_true",
410
+ help="indent JSON responses (for humans; breaks the "
411
+ "one-line-per-response protocol editors rely on)")
412
+ args = parser.parse_args(argv)
413
+
414
+ client = Client(args.url, token=resolve_token(args) or False,
415
+ timeout=args.timeout, exec_timeout=args.exec_timeout,
416
+ control_timeout=args.control_timeout or None,
417
+ verify_tls=not args.insecure)
418
+ dispatcher = Dispatcher(client, pretty=args.pretty, stream=args.stream)
419
+ try:
420
+ dispatcher.run()
421
+ except KeyboardInterrupt:
422
+ pass
423
+ finally:
424
+ dispatcher.shutdown()
425
+ return 0
426
+
427
+
428
+ if __name__ == "__main__":
429
+ sys.exit(main())