-
-
Notifications
You must be signed in to change notification settings - Fork 93
/
jupyter-rest-api.el
1108 lines (945 loc) · 45.4 KB
/
jupyter-rest-api.el
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
;;; jupyter-rest-api.el --- Jupyter REST API -*- lexical-binding: t -*-
;; Copyright (C) 2019-2024 Nathaniel Nicandro
;; Author: Nathaniel Nicandro <[email protected]>
;; Created: 03 Apr 2019
;; This program is free software; you can redistribute it and/or
;; modify it under the terms of the GNU General Public License as
;; published by the Free Software Foundation; either version 3, or (at
;; your option) any later version.
;; This program is distributed in the hope that it will be useful, but
;; WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
;; General Public License for more details.
;; You should have received a copy of the GNU General Public License
;; along with GNU Emacs; see the file COPYING. If not, write to the
;; Free Software Foundation, Inc., 59 Temple Place - Suite 330,
;; Boston, MA 02111-1307, USA.
;;; Commentary:
;; Routines for working with the Jupyter REST API. Currently only the kernels,
;; kernelspecs, contents, and config endpoints are implemented. Functions that
;; get information from the server take the form `jupyter-api-get-*'. The lower
;; level functions that make requests have the form `jupyter-api/<endpoint>'.
;; Functions that alter the state of a kernel look like
;; `jupyter-api-interrupt-kernel'. Those that modify files have the appropriate
;; name familiar to Emacs-Lisp, e.g. to create a directory on the server there
;; is the function `jupyter-api-make-directory'. The exception are those that
;; actually read and write files, `jupyter-api-read-file-content' and
;; `jupyter-api-write-file-content' respectively.
;;; Code:
(eval-when-compile (require 'subr-x))
(require 'jupyter-base)
(require 'websocket)
(require 'url)
(require 'url-http)
(declare-function jupyter-decode-time "jupyter-messages")
(defgroup jupyter-rest-api nil
"Jupyter REST API"
:group 'jupyter)
(defcustom jupyter-api-authentication-method 'ask
"Authentication method to use for new connections."
:type '(choice (const :tag "None" none)
(const :tag "Token based" token)
(const :tag "Password" password)
(const :tag "Ask" ask))
:group 'jupyter-rest-api)
(defvar jupyter-api-max-authentication-attempts 3
"Maximum number of retries used for authentication.
When attempting to authenticate a request, try this many times
before raising an error.")
;;; Jupyter REST API
(defmacro jupyter-api-with-subprocess-setup (&rest body)
"Return a form to load cookies, load `jupyter-rest-api', then evaluate BODY.
The paths added to `load-path' are those necessary for proper
operation of a `jupyter-rest-client'."
`(progn
(require 'url)
(setq url-cookie-file
;; Value set by `url-do-setup'
,(or url-cookie-file
(expand-file-name "cookies" url-configuration-directory)))
(url-do-setup)
;; Don't save any cookies or history in a subprocess
(ignore-errors (cancel-timer url-history-timer))
(ignore-errors (cancel-timer url-cookie-timer))
(push ,(file-name-directory (locate-library "jupyter-base")) load-path)
(push ,(file-name-directory (locate-library "websocket")) load-path)
(require 'jupyter-rest-api)
,@body))
(defclass jupyter-rest-client ()
;; convert to a url field to avoid parsing this every time
((url
:type string
:initform "http://localhost:8888"
:initarg :url)
(ws-url
:type string
:initarg :ws-url
:documentation "The WebSocket url to use.
If this slot is not bound when initializing an instance of this
class, it defaults to the value of the URL slot with the \"http\"
prefix replaced by \"ws\". ")
(auth
:initarg :auth
:documentation "Indicator for authentication.
If the symbol ask, ask the user how to authenticate requests to
URL.
If a list, then its assumed to be a list of cons cells that are
the necessary HTTP headers used to authenticate requests and will
be passed along with every request made.
If the symbol password, ask for a login password to use.
If the symbol token, ask for a token to use.
If any other value, assume no steps are necessary to authenticate
requests.")))
(cl-defmethod initialize-instance ((client jupyter-rest-client) &optional _slots)
"Set CLIENT's WS-URL slot if it isn't bound.
WS-URL will be a copy of URL with the url type equal to either ws
or wss depending on if URL has a type of http or https,
respectively."
(cl-call-next-method)
(unless (slot-boundp client 'auth)
(oset client auth jupyter-api-authentication-method))
(unless (slot-boundp client 'ws-url)
(let ((url (url-generic-parse-url (oref client url))))
(setf (url-type url) (if (equal (url-type url) "https") "wss" "ws"))
(oset client ws-url (url-recreate-url url))))
(unless (gnutls-available-p)
(let ((url (url-generic-parse-url (oref client url)))
(ws-url (url-generic-parse-url (oref client ws-url))))
(when (or (equal (url-type url) "https") (equal (url-type ws-url) "wss"))
(user-error "GnuTLS not available for HTTPS (SSL/TSL) connections")))))
;;; Making HTTP requests
(define-error 'jupyter-api-http-error
"Jupyter REST API error")
(define-error 'jupyter-api-http-redirect-limit
"Redirect limit reached" 'jupyter-api-http-error)
;; Same as their corresponding `url-request' counterparts. We define our own
;; variables here so that it will be easier to transition away from
;; `url-retrieve' if necessary.
(defvar jupyter-api-request-headers nil)
(defvar jupyter-api-request-method nil)
(defvar jupyter-api-request-data nil)
(defvar url-http-codes)
(defvar url-http-content-type)
(defvar url-http-end-of-headers)
(defvar url-http-response-status)
(defvar url-callback-arguments)
(defvar gnutls-verify-error)
(defun jupyter-api-url-parse-response (buffer)
"Given a URL BUFFER parse and return its response.
BUFFER should be a URL buffer as returned by, e.g.
`url-retrieve'. Return a plist representation of its JSON
content.
If the response indicates an error, signal a
`jupyter-api-http-error' otherwise return the parsed JSON or nil
if the content is not JSON.
If the maximum number of redirects are reached a
`jupyter-api-http-redirect-limit' error is raised instead."
(with-current-buffer buffer
(goto-char url-http-end-of-headers)
(skip-syntax-forward "->")
(let* ((json-object-type 'plist)
(json-false nil)
(resp (when (and (equal url-http-content-type "application/json")
(not (eobp)))
(json-read))))
(cond
((>= url-http-response-status 400)
(cl-destructuring-bind
(&key reason message traceback &allow-other-keys) resp
(when traceback
(setq traceback
(format "%s (%s): %s" reason message
(car (last (split-string traceback "\n" t))))))
(signal 'jupyter-api-http-error
(list url-http-response-status
(or traceback
(and (or reason message)
(concat reason
(and reason ": ")
message))
(nth 2 (assoc url-http-response-status
url-http-codes)))))))
;; Handle other kinds of errors, e.g. max redirects
((and (boundp 'url-callback-arguments)
(plist-get (car url-callback-arguments) :error))
(let ((err (plist-get (car url-callback-arguments) :error)))
(if (eq (nth 1 err) 'http-redirect-limit)
(signal 'jupyter-api-http-redirect-limit
(cons url-http-response-status
(cddr err)))
(signal (car err) (cdr err)))))
(t resp)))))
(defun jupyter-api-url-request (url &optional async &rest async-args)
"Retrieve URL and return its JSON response.
ASYNC and ASYNC-ARGS have the same meaning as CALLBACK and CBARGS
of `url-retrieve'.
If ASYNC is nil, retrieve URL synchronously and return its JSON
response or signal an error when something went wrong with the
request. On success, if the response obtained by URL is not JSON,
return nil otherwise the parsed JSON is returned as a plist. On
error, either an `jupyter-api-http-error' (when
`url-http-response-status' >= 400),
`jupyter-api-http-redirect-limit' (when `url-max-redirections' is
reached), or `error' (on any other kind of URL error) is signaled.
When ASYNC is a callback function, this function does the same
thing as `url-retrieve' with its SILENT argument set to t and
INHIBIT-COOKIES set to nil."
(let ((url-package-name "jupyter")
(url-package-version jupyter-version)
(url-request-method jupyter-api-request-method)
(url-request-data jupyter-api-request-data)
(url-request-extra-headers jupyter-api-request-headers)
;; Avoid errors when `default-directory' is a remote
;; directory path. `url' seems to not be able to handle it.
(default-directory user-emacs-directory))
(if async (url-retrieve url async async-args t)
(let ((buffer (url-retrieve-synchronously url t nil jupyter-long-timeout)))
(unwind-protect
(jupyter-api-url-parse-response buffer)
(url-mark-buffer-as-dead buffer))))))
;; See jupyter/notebook/services/api/api.yaml for HTTP
;; response codes.
(defun jupyter-api-http-request (url endpoint method &rest data)
"Send request to URL/ENDPOINT using HTTP METHOD.
DATA is encoded into a JSON string using `json-encode-plist' and
sent as the HTTP request data. If DATA is nil, don't send any
request data."
(declare (indent 3))
(when data
(setq data (json-encode-plist data))
(when (multibyte-string-p data)
(setq data (encode-coding-string data 'utf-8 'nocopy))))
(let ((jupyter-api-request-method method)
(jupyter-api-request-data (or data jupyter-api-request-data))
(jupyter-api-request-headers
(append (when data (list (cons "Content-Type" "application/json")))
jupyter-api-request-headers)))
(jupyter-api-url-request (concat url "/" endpoint))))
(cl-defmethod jupyter-api-server-exists-p ((client jupyter-rest-client))
"Return non-nil when the server at CLIENT's URL exists."
(condition-case nil
(prog1 t (jupyter-api-url-request (oref client url)))
;; A `file-error' is raised when a server no longer exists
(file-error nil)))
;;; Cookies and headers
(defmacro jupyter-api--ensure-unibyte (place)
"Ensure PLACE does not hold a multibyte string.
If the value of PLACE is a multibyte string, encode it using the
us-ascii coding system.
This is necessary when the contents of an API request contains
unicode characters. The HTTP request constructed in
`url-http-create-request' concatenates various string components
to make up the full request. If the contents are encoded, but
some other component is multibyte, the resulting string after
concatenating all elements will contain multibyte characters and
this will cause errors in the URL library."
(gv-letplace (getter setter) place
(macroexp-let2 nil old getter
`(if (multibyte-string-p ,old)
,(funcall setter `(encode-coding-string ,old 'us-ascii))
,old))))
;; For more info on the XSRF header see
;; https://blog.jupyter.org/security-release-jupyter-notebook-4-3-1-808e1f3bb5e2
;; and
;; http://www.tornadoweb.org/en/stable/guide/security.html#cross-site-request-forgery-protection
(defun jupyter-api-request-xsrf-cookie (client)
"Send a request using CLIENT to retrieve the _xsrf cookie."
;; Don't use `jupyter-api-request' here to avoid an infinite authentication
;; loop since this function is used during authentication.
(let (jupyter-api-request-headers jupyter-api-request-data)
(or
;; Already have the xsrf cookie, no need to request the login
;; page to receive it as a side effect.
(jupyter-api-xsrf-header-from-cookies (oref client url))
;; FIXME: It is not reliable to attempt to get the xsrf cookie as
;; a side effect of requesting the login page since it may not
;; always exist.
(jupyter-api-http-request (oref client url) "login" "GET"))))
(defun jupyter-api-url-cookies (url)
"Return the list of cookies for URL."
(or (url-p url) (setq url (url-generic-parse-url url)))
(url-cookie-retrieve
(url-host url) (concat (url-filename url) "/")
(equal (url-type url) "https")))
(defun jupyter-api-xsrf-header-from-cookies (url)
"Return an alist containing an X-XSRFTOKEN header or nil.
Searches the cookies of URL for an _xsrf token, if found, sets
the value of the cookie as the value of the X-XSRFTOKEN header
returned."
(cl-loop
for cookie in (jupyter-api-url-cookies url)
if (equal (url-cookie-name cookie) "_xsrf")
return `(("X-XSRFTOKEN" .
,(jupyter-api--ensure-unibyte
(url-cookie-value cookie))))))
(defun jupyter-api-copy-cookies-for-websocket (url)
"Copy URL cookies so that those under HOST are accessible under HOST:PORT.
`url-retrieve-synchronously' will store cookies under HOST
whereas `websocket-open' will expect those cookies to be under
HOST:PORT when PORT is a nonstandard port for the type of URL.
The behavior of `url-retrieve-synchronously' (cookies being
stored without considering a PORT) appears to be the standard,
see RFC 6265."
(when-let* ((url (url-generic-parse-url url))
(host (url-host url))
(port (url-port-if-non-default url))
(host-port (format "%s:%s" host port))
(cookies (jupyter-api-url-cookies url)))
(setq url-cookies-changed-since-last-save t)
(cl-loop
for cookie in cookies
do (pcase-let (((cl-struct url-cookie name value expires
localpart secure)
cookie))
;; Set the expiration date if it does not have one already since
;; `url-cookie-clean-up' (called by `url-cookie-write-file') will
;; correctly drop any cookies that don't have an expiration date
;; since cookies are required to have them.
;;
;; FIXME: This is mainly for the _xsrf cookie which does not have an
;; expiration date. I believe this is to be interpreted as meaning
;; the cookie should only be valid for the current session. We go
;; through `url-cookie-write-file' so that the subprocess which
;; starts websockets can read the required cookies. An alternative
;; solution would be to pass the cookies directly to the subprocess.
(unless expires
(setq expires (setf (url-cookie-expires cookie)
(format-time-string "%a, %d %b %Y %T %z"
(time-add (current-time)
(days-to-time 1))))))
(url-cookie-store name value expires host-port localpart secure)))))
;; Adapted from `url-cookie-delete'
(defun jupyter-api--delete-cookie (cookie)
(let* ((storage (if (url-cookie-secure cookie)
'url-cookie-secure-storage
'url-cookie-storage))
(cookies (symbol-value storage))
(elem (assoc (url-cookie-domain cookie) cookies)))
(cl-callf2 delq cookie elem)
(when (zerop (length (cdr elem)))
(cl-callf2 delq elem cookies))
(set storage cookies)))
(defun jupyter-api-delete-cookies (url)
"Delete all cookies for URL.
All cookies associated with the HOST of URL are deleted. If URL
has a non-standard port for the type of URL, all cookies
associated with HOST:PORT are deleted as well."
(let* ((url (if (url-p url) url
(url-generic-parse-url url)))
(host (url-host url)))
(dolist (u (cons url
;; Also delete cookies that were duplicated by
;; `jupyter-api-copy-cookies-for-websocket'.
(when-let* ((port (url-port-if-non-default url))
(u (copy-sequence url)))
(prog1 (list u)
(setf (url-host u) (format "%s:%s" host port))))))
(cl-loop
for cookie in (jupyter-api-url-cookies u)
do (jupyter-api--delete-cookie cookie)))
(setq url-cookies-changed-since-last-save t)
(url-cookie-write-file)))
(defun jupyter-api-add-websocket-headers (plist)
"Destructively modify PLIST to add a `:custom-header-alist' key.
Appends the value of `jupyter-api-request-headers' to the
`:custom-header-alist' key of PLIST, creating the key if
necessary. Before doing so, move past any non-keyword elements of
PLIST so as to only modify what looks like a property list.
Return the modified PLIST."
(or plist (setq plist (list :custom-header-alist nil)))
(let ((head plist))
(while (and head (not (keywordp (car head))))
(pop head))
(setq head (or (plist-member head :custom-header-alist)
(setcdr (last plist)
(list :custom-header-alist nil))))
(prog1 plist
(plist-put head :custom-header-alist
(append
(plist-get head :custom-header-alist)
jupyter-api-request-headers)))))
;;; Authentication
(defvar jupyter-api-authentication-in-progress-p nil)
(define-error 'jupyter-api-login-failed
"Login attempt failed")
;; FIXME: Make the DATA this error signals consistent.
(define-error 'jupyter-api-authentication-failed
"Authentication failed")
;; Signaled when `jupyter-api-request' receives a 403 response from the server.
;; The DATA of the signaled error will be the arguments of the
;; `jupyter-api-request' call.
(define-error 'jupyter-api-unauthenticated
"An API request returned an \"Access Forbidden\" response")
;;;; Logging in
(defmacro jupyter-api--without-url-http-authentication-handler (&rest body)
(declare (indent 0))
;; Workaround to suppress the authentication handling of `url-retrieve'.
;; Jupyter notebook return a 401 response without a www-authenticate header
;; and `url-http-handle-authentication' handles this by defaulting to
;; "basic" authentication which we don't want happening.
(let ((orig (make-symbol "orig")))
`(let ((,orig (symbol-function #'url-http-handle-authentication)))
(cl-letf (((symbol-function #'url-http-handle-authentication)
(lambda (proxy &rest args)
;; If there is an authenticate header, let the default
;; `url-http-handle-authentication' handle it.
(if (mail-fetch-field
(if proxy "proxy-authenticate" "www-authenticate")
nil nil t)
(apply ,orig proxy args)
;; Otherwise assume we are authenticated to suppress the
;; "basic" authentication handling.
t))))
,@body))))
(defun jupyter-api-login (client)
"Attempt to login to the server using CLIENT.
Login is attempted by sending a GET request to CLIENT's login
endpoint using `url-retrieve'. To change the login information,
set `jupyter-api-request-method', `jupyter-api-request-data', and
`jupyter-api-request-headers'.
On success, write the URL cookies to file so that they can be
used by other Emacs processes and return non-nil.
If a response isn't received in `jupyter-long-timeout' seconds,
raise an error.
If the login attempt failed, raise a `jupyter-api-login-failed'
error with the data being the error received by `url-retrieve'."
(let (status done)
(jupyter-api--without-url-http-authentication-handler
(jupyter-api-url-request
(concat (oref client url) "/login")
(lambda (s &rest _)
(url-mark-buffer-as-dead (current-buffer))
(setq status s done t)))
(jupyter-with-timeout
(nil jupyter-long-timeout
(error "Login [%s]: Timeout reached" (oref client url)))
done))
;; Verify login.
(let ((err (plist-get status :error)))
(unless
(or (not err)
;; jupyter_server can sometimes not have a login page,
;; for example when there is no password or token.
;; Therefore no need to login.
(= (nth 2 err) 404)
;; Handle HTTP 1.0. When given a POST request, 302 redirection
;; doesn't change the method to GET dynamically. On the Jupyter
;; notebook, the redirected page expects a GET and will return
;; 405 (invalid method).
(and (plist-get status :redirect)
(= (nth 2 err) 405)))
(signal 'jupyter-api-login-failed err)))
;; Handle cookies.
(jupyter-api-copy-cookies-for-websocket (oref client url))
(url-cookie-write-file)))
;;;; Authenticators
(cl-defmethod jupyter-api-server-accessible-p ((client jupyter-rest-client))
"Return non-nil if CLIENT can access the Jupyter notebook server."
(ignore-errors
(prog1 t
(let ((jupyter-api-authentication-in-progress-p t)
jupyter-api-request-data
jupyter-api-request-headers)
(jupyter-api-get-kernelspec client)))))
(cl-defgeneric jupyter-api-authenticate (client &rest args)
(declare (indent 1)))
(cl-defmethod jupyter-api-authenticate ((client jupyter-rest-client) (authenticator function))
"Call AUTHENTICATOR then check if CLIENT can access the REST server.
Repeat up to `jupyter-api-max-authentication-attempts' before
signaling a `jupyter-api-authentication-failed' error if CLIENT
cannot access the server.
AUTHENTICATOR is called with zero arguments.
Before attempting to authenticate, save the value of the AUTH
slot of CLIENT and restore the AUTH slot on failure."
(let ((jupyter-api-authentication-in-progress-p t)
(max-tries jupyter-api-max-authentication-attempts))
(let ((auth (oref client auth)))
(jupyter-api-request-xsrf-cookie client)
(let ((jupyter-api-request-headers
(nconc (jupyter-api-xsrf-header-from-cookies (oref client url))
(jupyter-api-auth-headers client))))
(while (and (not (progn
(funcall authenticator)
(jupyter-api-server-accessible-p client)))
(not (zerop (cl-decf max-tries))))))
(when (zerop max-tries)
(oset client auth auth)
(signal 'jupyter-api-authentication-failed
(list client))))))
(cl-defmethod jupyter-api-authenticate ((client jupyter-rest-client) (_auth (eql password))
&optional passwd)
"Authenticate CLIENT by asking for a password.
If PASSWD is provided it must be a function that takes zero
arguments and returns a password, it defaults to a call to
`read-passwd'. It will be called before each authentication
attempt. If CLIENT could not be authenticated raise an error."
(or (functionp passwd)
(setq passwd (lambda () (read-passwd (format "Password [%s]: "
(oref client url))))))
(jupyter-api-authenticate client
;; FIXME: Workaround due to the function generalizer in the base
;; `jupyter-api-authenticate' method only recognizing function symbols or
;; compiled functions since it currently uses `type-of' instead of
;; `cl-typep'. This wouldn't be needed for the compiled sources, but seems
;; to cause issues on Windows even when the sources are compiled.
(apply-partially
(lambda ()
(let ((jupyter-api-request-method "POST")
(jupyter-api-request-headers
(nconc (list (cons "Content-Type"
"application/x-www-form-urlencoded"))
jupyter-api-request-headers))
(jupyter-api-request-data
(concat "password=" (url-hexify-string (funcall passwd)))))
(jupyter-api-login client)))))
(oset client auth t))
(cl-defmethod jupyter-api-authenticate ((client jupyter-rest-client) (_auth (eql token)))
"Authenticate CLIENT by asking for a token.
Access to server will be checked by setting the token in the
Authorization header.
Raise an error on failure."
(jupyter-api-authenticate client
(apply-partially
(lambda ()
(let ((token (read-string (format "Token [%s]: " (oref client url)))))
(oset client auth
`(("Authorization" .
,(concat "token " (jupyter-api--ensure-unibyte token))))))))))
;;; `jupyter-rest-client' methods
(cl-defmethod jupyter-api-ensure-authenticated :around ((_client jupyter-rest-client))
(unless jupyter-api-authentication-in-progress-p
(let ((jupyter-api-authentication-in-progress-p t))
(cl-call-next-method))))
(cl-defmethod jupyter-api-ensure-authenticated ((client jupyter-rest-client))
(with-slots (auth url) client
(when (eq auth 'ask)
(jupyter-api-request-xsrf-cookie client)
(when (jupyter-api-server-accessible-p client)
(oset client auth t)))
(unless (or (listp auth)
(not (memq auth '(ask token password))))
(when (eq auth 'ask)
(when noninteractive
(signal 'jupyter-api-authentication-failed
(list "Can't authenticate non-interactively")))
(cond
((y-or-n-p (format "Token authenticated [%s]? " url))
(oset client auth 'token))
((y-or-n-p (format "Password needed [%s]? " url))
(oset client auth 'password))
(t
(signal 'jupyter-api-authentication-failed
(list "Can only authenticate with password or token")))))
(jupyter-api-authenticate client (oref client auth)))))
(cl-defmethod jupyter-api-auth-headers ((client jupyter-rest-client))
"Return the HTTP headers CLIENT is using for authentication or nil."
(jupyter-api-ensure-authenticated client)
(with-slots (auth) client
(when (listp auth)
auth)))
;;;; Calling the REST API
(defun jupyter-api-construct-endpoint (plist)
"Return a cons cell (ENDPOINT . REST) based on PLIST.
ENDPOINT is the API endpoint constructed from the elements at the
beginning of PLIST that are strings. REST will contain the
remainder of PLIST.
So if PLIST looks like
\='(\"api\" \"kernels\" :k1 ...)
ENDPOINT will be \"api/kernels\" and REST will be \='(:k1 ...).
If there is an alist after the strings of PLIST that make up the
ENDPOINT, the alist is interpreted as the query component of
ENDPOINT. So if PLIST looks like
\='(\"api\" \"contents\" ((\"content\" . \"1\")) :k1 ...)
The returned ENDPOINT will be \"api/contents?content=1\" and REST
will be \='(:k1 ...)."
(let (endpoint)
(while (and plist (or (null (car plist))
(stringp (car plist))))
;; Remove any trailing empty strings or nil values so that something like
;; ("contents?content=0" "") doesn't get turned into
;; "api/contents?contents=0/" below.
(if (member (car plist) '(nil "")) (pop plist)
(cl-check-type (car plist) string
"Endpoint can only be constructed from strings")
(push (pop plist) endpoint)))
(setq endpoint (mapconcat #'identity
(or (nreverse endpoint) (list "")) "/"))
(when (consp (car plist))
(setq endpoint (concat endpoint "?"
(mapconcat
(lambda (x)
(cl-check-type x cons)
(cl-check-type (car x) string)
(cl-check-type (cdr x) string)
(concat (car x) "=" (cdr x)))
(pop plist)
"&"))))
(cons endpoint plist)))
(cl-defgeneric jupyter-api-request (client method &rest plist)
(declare (indent 2)))
(cl-defmethod jupyter-api-request ((client jupyter-rest-client) method &rest plist)
"Send an HTTP request using CLIENT.
METHOD is the HTTP request method and PLIST contains the request.
The elements of PLIST before the first non-string form the REST
API endpoint and the rest of the PLIST after will be encoded into
a JSON object and sent as the request data. So a call like
\(jupyter-api-request client \"POST\" \"api\" \"kernels\" :name \"python\")
where the url slot of client is http://localhost:8888 will create
an http POST request to the url http://localhost:8888/api/kernels
using the JSON encoded from the plist (:name \"python\") as the
POST data.
Note an empty plist (after forming the endpoint) is interpreted
as no request data at all and NOT as an empty JSON dictionary.
A call to this method can also look like
\(jupyter-api-request client \"GET\"
\"api\" \"contents\" \='((\"content\" . \"1\"))
In this case, the alist after the strings that make up the base
endpoint, but before the rest of the non-strings elements of
PLIST, will be interpreted as the query component of the
resulting endpoint. So for the above example, the resulting url
will be http://localhost:8888/api/contents?content=1.
If METHOD is \"WS\", a websocket will be opened using the REST api
url and PLIST will be used in a call to `websocket-open'.
If the request receives a 403 \"Access Forbidden\" response,
signal a `jupyter-api-unauthenticated' error with the error data
being the arguments passed to this method. Otherwise for any
other kind of HTTP error, signal a `jupyter-api-http-error' with
error data being a list of two elements, the first being the HTTP
response code and the second being a error message returned from
the server."
(jupyter-api-ensure-authenticated client)
(let ((jupyter-api-request-headers
(append (jupyter-api-auth-headers client)
(jupyter-api-xsrf-header-from-cookies (oref client url))
jupyter-api-request-headers)))
(cl-destructuring-bind (endpoint . rest)
(jupyter-api-construct-endpoint plist)
(pcase method
("WS"
(jupyter-api-copy-cookies-for-websocket (oref client url))
(apply #'websocket-open
(concat (oref client ws-url) "/" endpoint)
(jupyter-api-add-websocket-headers rest)))
(_
(condition-case err
(apply #'jupyter-api-http-request
(oref client url) endpoint method
rest)
(jupyter-api-http-error
(if (or jupyter-api-authentication-in-progress-p
;; Access Forbidden
(not (= (nth 1 err) 403)))
(signal (car err) (cdr err))
(signal 'jupyter-api-unauthenticated
(cons client (cons method plist)))))))))))
;;;; Endpoints
(cl-defgeneric jupyter-api/kernels (client method &rest plist)
(declare (indent 2)))
(cl-defmethod jupyter-api/kernels ((client jupyter-rest-client) method &rest plist)
"Send an HTTP request to the api/kernels endpoint to CLIENT's url.
METHOD is the HTTP method to use. PLIST has the same meaning as
in `jupyter-api-request'."
(apply #'jupyter-api-request client method "api" "kernels" plist))
(cl-defgeneric jupyter-api/kernelspecs (client method &rest plist)
(declare (indent 2)))
(cl-defmethod jupyter-api/kernelspecs ((client jupyter-rest-client) method &rest plist)
"Send an HTTP request to the api/kernelspecs endpoint of CLIENT.
METHOD is the HTTP method to use. PLIST has the same meaning as
in `jupyter-api-request'."
(apply #'jupyter-api-request client method "api" "kernelspecs" plist))
(cl-defgeneric jupyter-api/contents (client method &rest plist)
(declare (indent 2)))
(cl-defmethod jupyter-api/contents ((client jupyter-rest-client) method &rest plist)
"Send an HTTP request to the api/contents endpoint of CLIENT.
METHOD is the HTTP method to use. PLIST has the same meaning as
in `jupyter-api-request'."
(apply #'jupyter-api-request client method "api" "contents" plist))
(cl-defgeneric jupyter-api/config (client method &rest plist)
(declare (indent 2)))
(cl-defmethod jupyter-api/config ((client jupyter-rest-client) method &rest plist)
"Send an HTTP request to the api/config endpoint of CLIENT.
METHOD is the HTTP method to use. PLIST has the same meaning as
in `jupyter-api-request'."
(apply #'jupyter-api-request client method "api" "config" plist))
;;; Config
(defun jupyter-api-get-config (client section)
"Send an HTTP request using CLIENT to get the configuration for SECTION."
(jupyter-api/config client "GET" section))
(defun jupyter-api-update-config (client section &rest plist)
"Send a request using CLIENT to update configuration SECTION.
PLIST is a property list that will be encoded into JSON with the
requested changes."
(apply #'jupyter-api/config client "PATCH" section plist))
;;; Kernels API
(defun jupyter-api-get-kernel (client &optional id)
"Send an HTTP request using CLIENT to return a plist of the kernel with ID.
If ID is nil, return models for all kernels accessible via CLIENT."
(jupyter-api/kernels client "GET" id))
(defun jupyter-api-start-kernel (client &optional name)
"Send an HTTP request using CLIENT to start a kernel with kernelspec NAME.
If NAME is not provided use the default kernelspec."
(apply #'jupyter-api/kernels client "POST"
(when name (list :name name))))
(defun jupyter-api-shutdown-kernel (client id)
"Send the HTTP request using CLIENT to shutdown a kernel with ID."
(jupyter-api/kernels client "DELETE" id))
(defun jupyter-api-restart-kernel (client id)
"Send an HTTP request using CLIENT to restart a kernel with ID."
(jupyter-api/kernels client "POST" id "restart"))
(defun jupyter-api-interrupt-kernel (client id)
"Send an HTTP request using CLIENT to interrupt a kernel with ID."
(jupyter-api/kernels client "POST" id "interrupt"))
;;;; Shutdown/interrupt kernel
(cl-defmethod jupyter-shutdown-kernel ((client jupyter-rest-client) kernel-id
&optional restart timeout)
"Send an HTTP request using CLIENT to shutdown the kernel with KERNEL-ID.
Optionally RESTART the kernel. If TIMEOUT is provided, it is the
timeout used for the HTTP request."
(let ((jupyter-long-timeout (or timeout jupyter-long-timeout)))
(if restart (jupyter-api-restart-kernel client kernel-id)
(jupyter-api-shutdown-kernel client kernel-id))))
(cl-defmethod jupyter-interrupt-kernel ((client jupyter-rest-client) kernel-id
&optional timeout)
"Send an HTTP request using CLIENT to interrupt the kernel with KERNEL-ID.
If TIMEOUT is provided, it is the timeout used for the HTTP
request."
(let ((jupyter-long-timeout (or timeout jupyter-long-timeout)))
(jupyter-api-interrupt-kernel client kernel-id)))
;;;; Kernel websocket
(defun jupyter-api-kernel-websocket (client id &rest plist)
"Return a websocket using CLIENT's ws-url slot.
ID identifies the kernel to connect to, PLIST will be passed to
the call to `websocket-open' to initialize the websocket.
The `websocket-client-data' of the websocket will be a plist like
(:id ID :session SESSION)
where SESSION is a `jupyter-session' with a `jupyter-session-id'
equal to the one associated with the kernel on the server CLIENT
is communicating with."
(let* ((session (jupyter-session))
(ws (apply #'jupyter-api/kernels client "WS" id "channels"
`(("session_id" . ,(jupyter-session-id session)))
plist)))
(prog1 ws
(setf (websocket-client-data ws)
(list :id id :session session)))))
;;; Kernelspec API
(defun jupyter-api-get-kernelspec (client &optional name)
"Send an HTTP request using CLIENT to get the kernelspec with NAME.
If NAME is not provided, return a plist of all kernelspecs
available via CLIENT."
(apply #'jupyter-api/kernelspecs client "GET"
(when name (list :name name))))
;;; Contents API
;; TODO: Actually consider encoding/decoding
;; https://jupyter-notebook.readthedocs.io/en/stable/extending/contents.html#filesystem-entities
(defun jupyter-api--strip-slashes (path)
(thread-last path
(replace-regexp-in-string "^/+" "")
(replace-regexp-in-string "/+$" "")))
(autoload 'tramp-drop-volume-letter "tramp")
;; See https://jupyter-notebook.readthedocs.io/en/stable/extending/contents.html#api-paths
(defsubst jupyter-api-content-path (file)
"Return a sanitized path for locating FILE on a Jupyter REST server.
Note, if FILE is not an absolute file name, it is expanded into
one as if the `default-directory' where /."
(jupyter-api--strip-slashes
(tramp-drop-volume-letter
(expand-file-name (file-local-name file) "/"))))
(defun jupyter-api-get-file-model (client file &optional no-content type)
"Send a request using CLIENT to get a model of FILE.
If NO-CONTENT is non-nil, tell the server to return a model
excluding the FILE's contents. Otherwise a model with contents is
returned.
If TYPE is non-nil, signal an error if FILE is not of the
specified type.
Note, only the `file-local-name' of FILE is considered.
A file model is a plist that contains the following keys:
:name - The name of the file relative to its directory
:path - The filesystem path of the file
:last_modified - The last time the file was modified
:created - The time the file was created
:content - The file's contents or, if a file is directory, a
vector of models representing the files contained
in the directory
:format - The format of the file, either \"text\", \"base64\", or nil
:mimetype - The guessed mimetype or nil
:size - The size of the file in bytes or nil
:writable - If the file can be written to
:type - Either \"directory\" or \"file\""
(declare (indent 1))
(jupyter-api/contents client "GET"
(jupyter-api-content-path file)
(nconc (list (cons "content" (if no-content "0" "1")))
(when type (cons "type" type)))))
(defun jupyter-api-delete-file (client file-or-dir)
"Send a request using CLIENT to delete FILE-OR-DIR from the server.
Note, only the `file-local-name' of FILE-OR-DIR is considered."
(declare (indent 1))
(jupyter-api/contents client "DELETE"
(jupyter-api-content-path file-or-dir)))
(defun jupyter-api-rename-file (client file newname)
"Send a request using CLIENT to rename FILE to NEWNAME.
Note, only the `file-local-name' of FILE and NEWNAME are
considered."
(declare (indent 1))
(jupyter-api/contents client "PATCH"
(jupyter-api-content-path file)
:path (jupyter-api-content-path newname)))
;; NOTE: The Jupyter REST API doesn't allow copying directories in an easy way
(defun jupyter-api-copy-file (client file newname)
"Send a request using CLIENT to copy FILE to NEWNAME.
NEWNAME must not be an existing file.
Note, only the `file-local-name' of FILE and NEWNAME are
considered."
(declare (indent 1))
(cl-destructuring-bind (&key path &allow-other-keys)
(jupyter-api/contents client "POST"
(jupyter-api-content-path (file-name-directory newname))
:copy_from (jupyter-api-content-path file))
(jupyter-api-rename-file client path newname)))
(defun jupyter-api-write-file-content (client filename content &optional binary)
"Send a request using CLIENT to write CONTENT to FILENAME.
If BINARY is non-nil, as a final step encode CONTENT as a base64
string and set the file's format to \"base64\". Otherwise CONTENT
is encoded as UTF-8 and file's format is set to \"text\".
Note, only the `file-local-name' of FILENAME is considered."
(declare (indent 1))
(cl-check-type content string)
(jupyter-api/contents client "PUT"
(jupyter-api-content-path filename)
:content (if binary (thread-first content
(encode-coding-string 'no-conversion 'nocopy)
(base64-encode-string))
;; Encoded in `jupyter-api-http-request'
content)
:type "file"
:format (if binary "base64" "text")))
(defun jupyter-api-read-file-content (client file)
"Send a request using CLIENT to read the content of FILE.
If FILE's contents are encoded, decode it first. This currently
only applies to the case where FILE's format is \"base64\".
Note, only the `file-local-name' of FILENAME is considered."
(declare (indent 1))
(let* ((model (jupyter-api-get-file-model client file nil "file"))
(content (plist-get model :content)))
(if (jupyter-api-binary-content-p model)
(base64-decode-string content)
(decode-coding-string content 'utf-8 'nocopy))))
(defun jupyter-api-make-directory (client directory)
"Send a request using CLIENT to create DIRECTORY.
Note, only the `file-local-name' of DIRECTORY is considered."
(declare (indent 1))
(jupyter-api/contents client "PUT"
(jupyter-api-content-path directory)
:type "directory"))
;;;; Checkpoints
(defun jupyter-api-get-checkpoints (client file)
"Send a request using CLIENT to get all checkpoints available for FILE.
Return a list of checkpoints of the form
(:id ID :last_modified TIME)
where ID is the ID of the checkpoint and TIME is the last time
FILE was modified when the checkpoint was created."
(declare (indent 1))
(append
(jupyter-api/contents client "GET"
(jupyter-api-content-path file) "checkpoints")
nil))
(defun jupyter-api-get-ordered-checkpoints (client file)
"Send a request using CLIENT to get all checkpoints available for FILE.
Return a list of the checkpoints ordered most recently created first."
(declare (indent 1))
(sort