;;; jupyter-tramp.el --- TRAMP interface to the Jupyter REST API -*- lexical-binding: t -*- ;; Copyright (C) 2019 Nathaniel Nicandro ;; Author: Nathaniel Nicandro ;; Created: 25 May 2019 ;; Version: 0.0.1 ;; Keywords: jupyter notebook ;; X-URL: https://github.com/dzop/emacs-jupyter ;; 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: ;; Integrate the Jupyter REST API contents endpoint with Emacs' file handling ;; facilities for remote files. Adds two new remote file methods, /jpy: and ;; /jpys:, the former being HTTP connections and the latter being HTTPS ;; connections. ;; ;; If you run a local notebook server on port 8888 then reading and writing ;; files to the server is as easy as ;; ;; (write-region "xxxx" nil "/jpy:localhost:happy.txt") ;; ;; or ;; ;; (find-file "/jpy:localhost:serious.py") ;; ;; To open a `dired' listing to the base directory of the notebook server ;; ;; (dired "/jpy:localhost:/") ;; ;; You can change the default port by changing the `tramp-default-port' entry ;; of the jpy or jpys method in `tramp-methods' or you can specify a port ;; inline using something like /jpy:localhost#8888:/. ;; ;; You can also set an entry in `tramp-default-host-alist' like ;; ;; (add-to-list 'tramp-default-host-alist (list "jpy" nil "HOST")) ;; ;; Then specifying filenames like /jpy::/foo is equivalent to /jpy:HOST: ;; ;; TODO: Same messages for implemented file operations that TRAMP and Emacs ;; give. ;; ;; TODO: How can checkpoints be used with: `auto-save-mode', ;; `diff-latest-backup-file', ... ;;; Code: (eval-when-compile (require 'subr-x) (require 'tramp-compat)) (require 'jupyter-rest-api) (require 'jupyter-server) (require 'tramp) (require 'tramp-cache) (defgroup jupyter-tramp nil "TRAMP integration with the Jupyter Contents REST API" :group 'jupyter) (declare-function dired-check-switches "dired" (switches short &optional long)) (declare-function jupyter-decode-time "jupyter-messages" (str)) (defmacro jupyter-tramp-with-api-connection (file &rest body) "Set `jupyter-current-server' based on FILE, evaluate BODY. FILE must be a remote file name recognized as corresponding to a file on a server that can be communicated with using the Jupyter notebook REST API. Note, BODY is wrapped with a call to `with-parsed-tramp-file-name' so that the variables method, user, host, localname, ..., are all bound to values parsed from FILE." (declare (indent 1) (debug ([&or stringp symbolp] body))) `(with-parsed-tramp-file-name ,file nil ;; FIXME: There is a dilemma here, a `jupyter-server' is a more particular ;; object than what we need. There is really no reason to have it here, we ;; just need a `jupyter-rest-client'. Is there a reason this needs to be ;; here? Also we should really rename `jupyter-server' to something like ;; `jupyter-server-client' since it isn't a representation of a server, ;; but a communication channel with one. (let ((jupyter-current-server (jupyter-tramp-server-from-file-name ,file))) ,@body))) ;;; File name handler setup ;; Actual functions implemented by `jupyter-tramp' all the others are either ;; ignored or handled by the TRAMP handlers. ;; ;; jupyter-tramp-copy-file ;; jupyter-tramp-delete-directory ;; jupyter-tramp-delete-file ;; jupyter-tramp-expand-file-name ;; jupyter-tramp-file-attributes ;; jupyter-tramp-file-directory-p ;; jupyter-tramp-file-local-copy ;; jupyter-tramp-file-name-all-completions ;; jupyter-tramp-file-remote-p ;; jupyter-tramp-file-symlink-p ;; jupyter-tramp-file-writable-p ;; jupyter-tramp-make-directory-internal ;; jupyter-tramp-rename-file ;; jupyter-tramp-write-region ;;;###autoload (defconst jupyter-tramp-file-name-handler-alist '(;; `access-file' performed by default handler. (add-name-to-file . tramp-handle-add-name-to-file) ;; `byte-compiler-base-file-name' performed by default handler. ;; `copy-directory' performed by default handler. (copy-file . jupyter-tramp-copy-file) (delete-directory . jupyter-tramp-delete-directory) (delete-file . jupyter-tramp-delete-file) ;; TODO: Use the `checkpoint' file? I think we can only create a checkpoint ;; or restore a file from a checkpoint so maybe we can do something with ;; auto-save and checkpoints? ;; `diff-latest-backup-file' performed by default handler. (directory-file-name . tramp-handle-directory-file-name) (directory-files . tramp-handle-directory-files) (directory-files-and-attributes . tramp-handle-directory-files-and-attributes) (dired-compress-file . ignore) (dired-uncache . tramp-handle-dired-uncache) (expand-file-name . jupyter-tramp-expand-file-name) (file-accessible-directory-p . tramp-handle-file-accessible-directory-p) (file-acl . ignore) (file-attributes . jupyter-tramp-file-attributes) (file-directory-p . jupyter-tramp-file-directory-p) (file-equal-p . tramp-handle-file-equal-p) (file-executable-p . tramp-handle-file-exists-p) (file-exists-p . tramp-handle-file-exists-p) (file-in-directory-p . tramp-handle-file-in-directory-p) (file-local-copy . jupyter-tramp-file-local-copy) (file-modes . tramp-handle-file-modes) (file-name-all-completions . jupyter-tramp-file-name-all-completions) (file-name-as-directory . tramp-handle-file-name-as-directory) (file-name-case-insensitive-p . tramp-handle-file-name-case-insensitive-p) (file-name-completion . tramp-handle-file-name-completion) (file-name-directory . tramp-handle-file-name-directory) (file-name-nondirectory . tramp-handle-file-name-nondirectory) ;; `file-name-sans-versions' performed by default handler. (file-newer-than-file-p . tramp-handle-file-newer-than-file-p) (file-notify-add-watch . tramp-handle-file-notify-add-watch) (file-notify-rm-watch . tramp-handle-file-notify-rm-watch) (file-notify-valid-p . tramp-handle-file-notify-valid-p) (file-ownership-preserved-p . ignore) (file-readable-p . tramp-handle-file-exists-p) (file-regular-p . tramp-handle-file-regular-p) ;; NOTE: We can't use `tramp-handle-file-remote-p' since it expects a ;; process to check for the connected argument whereas we are using an HTTP ;; connection which may or may not be as long lived as something like an ;; SSH connection as the liveness depends on the Keep-Alive header of an ;; HTTP request. (file-remote-p . jupyter-tramp-file-remote-p) (file-selinux-context . tramp-handle-file-selinux-context) (file-symlink-p . jupyter-tramp-file-symlink-p) (file-system-info . ignore) (file-truename . tramp-handle-file-truename) (file-writable-p . jupyter-tramp-file-writable-p) ;; TODO: Can we do something here with checkpoints on the remote? (find-backup-file-name . ignore) ;; `find-file-noselect' performed by default handler. ;; `get-file-buffer' performed by default handler. (insert-directory . tramp-handle-insert-directory) ;; Uses `file-local-copy' to get the contents so be sure thats implemented (insert-file-contents . tramp-handle-insert-file-contents) (load . tramp-handle-load) (make-auto-save-file-name . tramp-handle-make-auto-save-file-name) ;; `make-directory' performed by default handler. (make-directory-internal . jupyter-tramp-make-directory-internal) (make-nearby-temp-file . tramp-handle-make-nearby-temp-file) (make-symbolic-link . tramp-handle-make-symbolic-link) ;; `process-file' performed by default handler. (rename-file . jupyter-tramp-rename-file) (set-file-acl . ignore) (set-file-modes . ignore) (set-file-selinux-context . ignore) (set-file-times . ignore) (set-visited-file-modtime . tramp-handle-set-visited-file-modtime) ;; `shell-command' performed by default handler. ;; `start-file-process' performed by default handler. (substitute-in-file-name . tramp-handle-substitute-in-file-name) (temporary-file-directory . tramp-handle-temporary-file-directory) ;; Important that we have this so that `call-process' and friends don't try ;; to set a Jupyter notebook directory as a directory in which a process ;; should run. (unhandled-file-name-directory . ignore) (vc-registered . ignore) (verify-visited-file-modtime . tramp-handle-verify-visited-file-modtime) (write-region . jupyter-tramp-write-region)) "Alist of handler functions for Tramp Jupyter method. Operations not mentioned here will be handled by the default Emacs primitives.") ;;;###autoload (defconst jupyter-tramp-methods '("jpy" "jpys") "Methods to connect Jupyter kernel servers.") ;;;###autoload (with-eval-after-load 'tramp (mapc (lambda (method) (add-to-list 'tramp-methods (list method (list 'tramp-default-port 8888) (list 'tramp-tmpdir "/tmp")))) jupyter-tramp-methods) (tramp-register-foreign-file-name-handler 'jupyter-tramp-file-name-p 'jupyter-tramp-file-name-handler) (add-to-list 'tramp-default-host-alist '("\\`jpys?\\'" nil "localhost"))) ;;;###autoload (defsubst jupyter-tramp-file-name-method-p (method) "Return METHOD if it corresponds to a Jupyter filename method or nil." (string-match-p "\\`jpys?\\'" method)) ;;;###autoload (defun jupyter-tramp-file-name-p (filename) "If FILENAME is a Jupyter filename, return its method otherwise nil." (jupyter-tramp-file-name-method-p (tramp-file-name-method (tramp-dissect-file-name filename)))) ;;;###autoload (defun jupyter-tramp-file-name-handler (operation &rest args) (let ((handler (assq operation jupyter-tramp-file-name-handler-alist))) (if (not handler) (tramp-run-real-handler operation args) (apply (cdr handler) args)))) ;;;; Converting file names to authenticated `jupyter-rest-client' instances (defvar tramp-current-method) (defvar tramp-current-user) (defvar tramp-current-domain) (defvar tramp-current-host) (defvar tramp-current-port) (defun jupyter-tramp-read-passwd (filename &optional prompt) "Read a password based off of FILENAME's TRAMP filename components. Use PROMPT to prompt the user for the password if needed, PROMPT defaults to \"Password:\"." (unless (jupyter-tramp-file-name-p filename) (error "Not a Jupyter filename")) (with-parsed-tramp-file-name filename nil (let ((tramp-current-method method) (tramp-current-user (or user user-login-name)) (tramp-current-domain nil) (tramp-current-host host) (tramp-current-port port)) (tramp-read-passwd nil (or prompt "Password: "))))) ;;;###autoload (defun jupyter-tramp-file-name-from-url (url) "Return a Jupyter TRAMP filename for the root directory of a kernel server. The filename is based off of URL's host and port if any." (let ((url (if (url-p url) url (url-generic-parse-url url)))) (format "/jpy%s:%s%s:/" (if (equal (url-type url) "https") "s" "") (url-host url) (let ((port (url-port-if-non-default url))) (if port (format "#%d" port) ""))))) ;;;###autoload (defun jupyter-tramp-url-from-file-name (filename) "Return a URL string based off the method, host, and port of FILENAME." (with-parsed-tramp-file-name filename nil (unless port (setq port (when (functionp 'tramp-file-name-port-or-default) ;; This function was introduced in Emacs 26.1 (tramp-file-name-port-or-default v)))) (format "%s://%s%s" (if (equal method "jpys") "https" "http") host (if port (format ":%s" port) "")))) ;;;###autoload (defun jupyter-tramp-server-from-file-name (filename) "Return a `jupyter-server' instance based off of FILENAME's remote components. If the connection has not been authenticated by the server, attempt to authenticate the connection. Raise an error if that fails." (unless (jupyter-tramp-file-name-p filename) (error "Not a Jupyter filename")) (with-parsed-tramp-file-name filename nil (with-tramp-connection-property v "server" (let* ((url (jupyter-tramp-url-from-file-name filename)) (client (or (jupyter-find-server url) (jupyter-server :url url)))) (prog1 client (unless (jupyter-api-server-accessible-p client) (cond ((y-or-n-p (format "Login to %s using a token? " url)) (jupyter-api-token-authenticator client)) (t ;; This is here so that reading a password using ;; `tramp-read-passwd' via `jupyter-api-read-passwd' will check ;; auth sources. (tramp-set-connection-property v "first-password-request" t) (jupyter-api-password-authenticator client (let ((remote (file-remote-p filename))) (lambda (try) (jupyter-tramp-read-passwd filename (unless (zerop try) (format "Password for %s " remote)))))))))))))) ;;; Getting information about file models (defalias 'jupyter-tramp-flush-file-properties (if (functionp 'tramp-flush-file-properties) ;; New in Emacs 27 'tramp-flush-file-properties 'tramp-flush-file-property)) (defun jupyter-tramp-get-file-model (file &optional no-content) "Return a model of FILE or raise an error. For non-existent files the model (:path PATH :name nil :writable WRITABLE) is returned, where PATH is a local path name to FILE on the server, i.e. excludes the remote part of FILE. WRITABLE will be t if FILE can be created on the server or nil if PATH is outside the base directory the server was started in. When NO-CONTENT is non-nil, return a model for file that excludes :content if an actual request needs to be made. The :content key may or may not be present in this case. If NO-CONTENT is nil, guarantee that we request FILE's content as well. See `jupyter-tramp-get-file-model' for details on what a file model is." (setq file (expand-file-name file)) (jupyter-tramp-with-api-connection file (let ((value (or (tramp-get-file-property v localname "model" nil) (when no-content (tramp-get-file-property v localname "nc-model" nil))))) (unless value (let ((path (jupyter-api-content-path localname)) model) (setq model (cond (no-content (jupyter-tramp-get-file-model (file-name-directory file))) (t (condition-case err (jupyter-api-get-file-model jupyter-current-server localname) (jupyter-api-http-error (cl-destructuring-bind (_ code msg) err (if (and (eq code 404) (string-match-p "No such file or directory" msg)) (list :path path :name nil ;; If a file doesn't exist we need to check if the ;; containing directory is writable to determine if ;; FILE is. :writable (plist-get (jupyter-tramp-get-file-model (file-name-directory (directory-file-name file)) 'no-content) :writable)) (signal (car err) (cdr err))))))))) (setq value (or (jupyter-api-find-model path model) ;; We reach here when MODEL is a directory that does ;; not contain PATH. PATH is writable if the ;; directory is. (list :path path :name nil :writable (plist-get model :writable))))) (tramp-set-file-property v localname (if no-content "nc-model" "model") value)) value))) (defun jupyter-tramp-flush-file-and-directory-properties (filename) (with-parsed-tramp-file-name filename nil (jupyter-tramp-flush-file-properties v localname) (jupyter-tramp-flush-file-properties v (file-name-directory localname)))) ;;; Predicates (defun jupyter-tramp--barf-if-not-file (file) (unless (file-exists-p file) (error "No such file or directory: %s" file))) (defun jupyter-tramp--barf-if-not-regular-file (file) (jupyter-tramp--barf-if-not-file file) (unless (file-regular-p file) (error "Not a file: %s" file))) (defun jupyter-tramp--barf-if-not-directory (directory) (jupyter-tramp--barf-if-not-file directory) (unless (file-directory-p directory) (error "Not a directory: %s" (expand-file-name directory)))) (defun jupyter-tramp-file-writable-p (filename) (jupyter-tramp-with-api-connection filename (plist-get (jupyter-tramp-get-file-model filename 'no-content) :writable))) ;; Actually this may not be true, but there is no way to tell if a file is a ;; symlink or not (defun jupyter-tramp-file-symlink-p (_filename) nil) (defun jupyter-tramp-file-directory-p (filename) (jupyter-tramp-with-api-connection filename (equal (plist-get (jupyter-tramp-get-file-model filename 'no-content) :type) "directory"))) (defvar url-http-open-connections) (defun jupyter-tramp-file-remote-p (file &optional identification connected) (when (file-name-absolute-p file) (with-parsed-tramp-file-name file nil (when (or (null connected) (let* ((port (or port (tramp-file-name-port-or-default v))) (key (cons host (if (numberp port) port (string-to-number port))))) (catch 'connected (dolist (conn (gethash key url-http-open-connections)) (when (memq (process-status conn) '(run open connect)) (throw 'connected t)))))) (cl-case identification (method method) (host host) (user user) (localname localname) (t (tramp-make-tramp-file-name method user domain host port ""))))))) ;;; File name manipulation (defun jupyter-tramp-expand-file-name (name &optional directory) ;; From `tramp-sh-handle-expand-file-name' (setq directory (or directory default-directory "/")) (unless (file-name-absolute-p name) (setq name (concat (file-name-as-directory directory) name))) (if (tramp-tramp-file-p name) (let ((v (tramp-dissect-file-name name))) (if (jupyter-tramp-file-name-method-p (tramp-file-name-method v)) (tramp-make-tramp-file-name (tramp-file-name-method v) (tramp-file-name-user v) (tramp-file-name-domain v) (tramp-file-name-host v) (tramp-file-name-port v) (tramp-drop-volume-letter (tramp-run-real-handler 'expand-file-name (list (tramp-file-name-localname v) "/"))) (tramp-file-name-hop v)) (let ((tramp-foreign-file-name-handler-alist (remove (cons 'jupyter-tramp-file-name-p 'jupyter-tramp-file-name-handler) tramp-foreign-file-name-handler-alist))) (expand-file-name name)))) (tramp-run-real-handler 'expand-file-name (list name)))) ;;; File operations ;; Adapted from `tramp-smb-handle-rename-file' (defun jupyter-tramp-rename-file (filename newname &optional ok-if-already-exists) (setq filename (expand-file-name filename) newname (expand-file-name newname)) (when (and (not ok-if-already-exists) (file-exists-p newname)) (tramp-error (tramp-dissect-file-name (if (tramp-tramp-file-p filename) filename newname)) 'file-already-exists newname)) (with-tramp-progress-reporter (tramp-dissect-file-name (if (tramp-tramp-file-p filename) filename newname)) 0 (format "Renaming %s to %s" filename newname) (if (and (not (file-exists-p newname)) (tramp-equal-remote filename newname)) ;; We can rename directly. (jupyter-tramp-with-api-connection filename ;; We must also flush the cache of the directory, because ;; `file-attributes' reads the values from there. (jupyter-tramp-flush-file-and-directory-properties filename) (jupyter-tramp-flush-file-and-directory-properties newname) (jupyter-api-rename-file jupyter-current-server filename newname)) ;; We must rename via copy. (copy-file filename newname ok-if-already-exists) (if (file-directory-p filename) (delete-directory filename 'recursive) (delete-file filename))))) ;; NOTE: Deleting to trash is configured on the server. (defun jupyter-tramp-delete-directory (directory &optional recursive _trash) (jupyter-tramp--barf-if-not-directory directory) (jupyter-tramp-with-api-connection directory (jupyter-tramp-flush-file-properties v localname) (let ((files (cl-remove-if (lambda (x) (member x '("." ".."))) (directory-files directory nil nil t)))) (unless (or recursive (not files)) (error "Directory %s not empty" directory)) (let ((deleted ;; Try to delete the directory, if we get an error because its not ;; empty, manually delete all files below and then try again. (condition-case err (prog1 t (jupyter-api-delete-file jupyter-current-server directory)) (jupyter-api-http-error (unless (and (= (cadr err) 400) (string-match-p "not empty" (caddr err))) (signal (car err) (cdr err))))))) (unless deleted ;; Recursive delete, we need to do this manually since we can get a 400 ;; error on Windows when deleting to trash and also in general when not ;; deleting to trash if the directory isn't empty, see ;; jupyter/notebook/notebook/services/contents/filemanager.py (while files (let ((file (expand-file-name (pop files) directory))) (if (file-directory-p file) (delete-directory file recursive) (delete-file file)))) (jupyter-api-delete-file jupyter-current-server directory)))) ;; Need to uncache both the file and its directory (jupyter-tramp-flush-file-and-directory-properties directory))) (defun jupyter-tramp-delete-file (filename &optional _trash) (jupyter-tramp--barf-if-not-regular-file filename) (jupyter-tramp-with-api-connection filename (jupyter-api-delete-file jupyter-current-server filename) ;; Need to uncache both the file and its directory (jupyter-tramp-flush-file-and-directory-properties filename))) ;; Adapted from `tramp-smb-handle-copy-file' (defun jupyter-tramp-copy-file (filename newname &optional ok-if-already-exists keep-date _preserve-uid-gid _preserve-permissions) (setq filename (expand-file-name filename) newname (expand-file-name newname)) (with-tramp-progress-reporter (tramp-dissect-file-name (if (tramp-tramp-file-p filename) filename newname)) 0 (format "Copying %s to %s" filename newname) (if (file-directory-p filename) (copy-directory filename newname keep-date 'parents 'copy-contents) (cond ((tramp-equal-remote filename newname) (jupyter-tramp-with-api-connection newname (when (and (not ok-if-already-exists) (file-exists-p newname)) (tramp-error v 'file-already-exists newname)) (jupyter-api-copy-file jupyter-current-server filename newname))) (t (let ((tmpfile (file-local-copy filename))) (if tmpfile ;; Remote filename. (condition-case err (rename-file tmpfile newname ok-if-already-exists) ((error quit) (delete-file tmpfile) (signal (car err) (cdr err)))) ;; Remote newname. (when (and (file-directory-p newname) (directory-name-p newname)) (setq newname (expand-file-name (file-name-nondirectory filename) newname))) (with-parsed-tramp-file-name newname nil (when (and (not ok-if-already-exists) (file-exists-p newname)) (tramp-error v 'file-already-exists newname)) (with-temp-file newname (insert-file-contents-literally filename))))))) (when (tramp-tramp-file-p newname) ;; We must also flush the cache of the directory, because ;; `file-attributes' reads the values from there. (jupyter-tramp-flush-file-and-directory-properties newname))))) (defun jupyter-tramp-make-directory-internal (dir) (jupyter-tramp-with-api-connection dir (jupyter-api-make-directory jupyter-current-server dir) (jupyter-tramp-flush-file-and-directory-properties dir))) ;;; File name completion (defun jupyter-tramp-file-name-all-completions (filename directory) (when (jupyter-tramp-file-name-p directory) (all-completions filename (mapcar #'car (jupyter-tramp-directory-file-models directory)) (lambda (f) (let ((ext (file-name-extension f t))) (and (or (null ext) (not (member ext completion-ignored-extensions))) (or (null completion-regexp-list) (not (cl-loop for re in completion-regexp-list thereis (not (string-match-p re f))))))))))) ;;; Insert file contents ;; XXX: WIP (defun jupyter-tramp--recover-this-file (orig) "If the `current-buffer' is Jupyter file, revert back to a checkpoint. If no checkpoints exist, revert back to the file that exists on the server. For any other file, call ORIG, which is the function `recover-this-file'" (interactive) (let ((file (buffer-file-name))) (if (not (jupyter-tramp-file-name-p file)) (funcall orig) (jupyter-tramp-with-api-connection file (let ((checkpoint (jupyter-api-get-latest-checkpoint jupyter-current-server file))) (when checkpoint (jupyter-api-restore-checkpoint jupyter-current-server file checkpoint)) (let ((tmpfile (file-local-copy file))) (unwind-protect (save-restriction (widen) (insert-file-contents tmpfile nil nil nil 'replace) ;; TODO: What else needs to be done here (set-buffer-modified-p nil)) (delete-file tmpfile)))))))) ;; TODO: Something that doesn't use advise ;; (advice-add 'recover-this-file :around 'jupyter-tramp--recover-this-file) ;; TODO: What to do about reading and writing large files? See ;; `jupyter-api-upload-large-file'. Also the out of band functions of TRAMP. ;; ;; TODO Consider encoding ;; ;; Adapted from `tramp-sh-handle-write-region' (defun jupyter-tramp-write-region (start end filename &optional append visit lockname mustbenew) (setq filename (expand-file-name filename)) (when (and mustbenew (file-exists-p filename) (or (eq mustbenew 'excl) (not (y-or-n-p (format "File %s exists; overwrite anyway? " filename))))) (signal 'file-already-exists (list filename))) (jupyter-tramp-with-api-connection filename ;; Ensure we don't use stale model contents (jupyter-tramp-flush-file-and-directory-properties filename) (if (and append (file-exists-p filename)) (let ((tmpfile (file-local-copy filename))) (condition-case err (tramp-run-real-handler 'write-region (list start end tmpfile append 'no-message lockname mustbenew)) (error (delete-file tmpfile) (signal (car err) (cdr err)))) (unwind-protect (jupyter-api-write-file-content jupyter-current-server ;; TODO: How should we handle binary vs text? filename (with-temp-buffer (insert-file-contents-literally tmpfile) (buffer-string)) 'binary) (delete-file tmpfile))) (let* ((source (if (stringp start) start (if (null start) (buffer-string) (buffer-substring-no-properties start end)))) (binary (multibyte-string-p source))) (when binary (setq source (encode-coding-string source 'utf-8))) ;; TODO: Think more about this. See ;; https://github.com/ipython/ipython/pull/3158 ;; (when (file-exists-p filename) ;; (jupyter-api-make-checkpoint jupyter-current-server ;; filename)) (jupyter-api-write-file-content jupyter-current-server filename source binary) ;; Adapted from `tramp-sh-handle-write-region' (when (or (eq visit t) (stringp visit)) (let ((file-attr (file-attributes filename))) (when (stringp visit) (setq buffer-file-name visit)) (set-buffer-modified-p nil) (set-visited-file-modtime ;; We must pass modtime explicitly, because FILENAME can ;; be different from (buffer-file-name), f.e. if ;; `file-precious-flag' is set. (tramp-compat-file-attribute-modification-time file-attr)))) (when (and (null noninteractive) (or (eq visit t) (null visit) (stringp visit))) (tramp-message v 0 "Wrote %s" filename)))) ;; Another flush after writing for consistency ;; TODO: Figure out more exactly where these should go (jupyter-tramp-flush-file-and-directory-properties filename))) ;; TODO: Set `jupyter-current-server' in every buffer that visits a file, this ;; way `jupyter-current-server' will always use the right server for file ;; operations if there happen to be more than one server. ;; ;; NOTE: Not currently used since `file-local-copy' is used as a way to get ;; files from the server and then `write-region' is used to write them back. (defun jupyter-tramp-insert-file-contents (filename &optional visit beg end replace) (setq filename (expand-file-name filename)) (let ((do-visit (lambda () (setq buffer-file-name filename) (set-buffer-modified-p nil)))) (condition-case err (jupyter-tramp--barf-if-not-file filename) (error (and visit (funcall do-visit)) (signal (car err) (cdr err)))) (jupyter-tramp-with-api-connection filename ;; Ensure we grab a fresh model since the cached version may be out of ;; sync with the server. (jupyter-tramp-flush-file-properties v localname) (let ((model (jupyter-tramp-get-file-model filename))) (when (and visit (jupyter-api-binary-content-p model)) (set-buffer-multibyte nil)) (let ((pos (point))) (jupyter-api-insert-model-content model replace beg end) (and visit (funcall do-visit)) (list filename (- (point) pos))))))) (defun jupyter-tramp-file-local-copy (filename) (jupyter-tramp-with-api-connection filename (unless (file-exists-p filename) (tramp-error v tramp-file-missing "Cannot make local copy of non-existing file `%s'" filename)) ;; Ensure we grab a fresh model since the cached version may be out of ;; sync with the server. (jupyter-tramp-flush-file-properties v localname) (let* ((model (jupyter-tramp-get-file-model filename)) (content (plist-get model :content))) (tramp-run-real-handler 'make-temp-file (list "jupyter-tramp." nil (file-name-extension filename t) (cond ((jupyter-api-binary-content-p model) (base64-decode-string content)) ((jupyter-api-notebook-p model) (error "Notebooks not supported yet")) (t content))))))) ;;; File/directory attributes (defun jupyter-tramp-file-attributes-from-model (model &optional id-format) ;; :name is nil if the corresponding file of MODEL doesn't exist, see ;; `jupyter-tramp-get-file-model'. (when (plist-get model :name) (let* ((dirp (equal (plist-get model :type) "directory")) (last-modified (plist-get model :last_modified)) (created (plist-get model :created)) (mtime (or (and last-modified (jupyter-decode-time last-modified)) (current-time))) (ctime (or (and created (jupyter-decode-time created)) (current-time))) ;; Sometimes the model doesn't contain a size (size (or (plist-get model :size) 64)) ;; FIXME: What to use for these two? (ugid (if (eq id-format 'string) "jupyter" 100)) (mbits (format "%sr%s%s-------" (if dirp "d" "-") (if (plist-get model :writable) "w" "") (if dirp "x" "")))) (list dirp 1 user-login-name ugid mtime mtime ctime size mbits nil -1 -1)))) (defun jupyter-tramp-file-attributes (filename &optional id-format) (jupyter-tramp-file-attributes-from-model (jupyter-tramp-with-api-connection filename (jupyter-tramp-get-file-model filename 'no-content)) id-format)) (defun jupyter-tramp-directory-file-models (directory &optional full match) "Return the files contained in DIRECTORY as Jupyter file models. The returned files have the form (PATH . MODEL) where PATH is relative to DIRECTORY unless FULL is non-nil. In that case PATH is an absolute file name. PATH will have an ending / character if MODEL corresponds to a directory. If MATCH is non-nil, it should be a regular expression. Only return files that match it. If DIRECTORY does not correspond to a directory on the server, return nil." (when (file-directory-p directory) (jupyter-tramp-with-api-connection directory (let ((dir-model (jupyter-tramp-get-file-model directory))) (cl-loop for model across (plist-get dir-model :content) for dirp = (equal (plist-get model :type) "directory") for name = (concat (plist-get model :name) (and dirp "/")) for path = (if full (expand-file-name name directory) name) if match when (string-match-p match name) collect (cons path model) into files end else collect (cons path model) into files finally return (let ((pdir-model (jupyter-tramp-get-file-model (file-name-directory (directory-file-name directory))))) (dolist (d (list (cons "../" pdir-model) (cons "./" dir-model))) (when (or (null match) (string-match-p match (car d))) (when full (setcar d (expand-file-name (car d) directory))) (push d files))) files)))))) (defun jupyter-tramp-directory-files-and-attributes (directory &optional full match nosort id-format) (jupyter-tramp--barf-if-not-directory directory) (let ((files (cl-loop for (file . model) in (jupyter-tramp-directory-file-models directory full match) for attrs = (jupyter-tramp-file-attributes-from-model model id-format) collect (cons file attrs)))) (if nosort files (sort files (lambda (a b) (string-lessp (car a) (car b))))))) ;;; Inserting directory listings ;; NOTE: None of the directory listing functions are actually used, since ;; ls-lisp actually handles most dired switches. (defun jupyter-tramp--insert-aligned-columns (rows &optional right-align-cols) (let ((max-lens (make-list (length (car rows)) 0))) ;; Compute max lengths for each column (let ((head rows)) (while head (let ((x max-lens) (y (pop head))) (while x (setcar x (max (pop x) (length (pop y)))))))) (cl-loop for row in rows do (cl-loop for col being the elements of row using (index i) for pad-len = (- (nth i max-lens) (length col)) for pad = (when (and (> pad-len 0) ;; No need to pad the last column (not (= i (1- (length row))))) (make-string pad-len ?\s)) collect (if (member i right-align-cols) (concat pad col) (concat col pad)) into cols finally (insert (mapconcat #'identity cols " ") "\n"))))) (defun jupyter-tramp--byte-size-string (size) (let* ((B size) (KB (/ B 1000.0)) (MB (/ KB 1000.0)) (GB (/ MB 1000.0)) (TB (/ GB 1000.0)) (PB (/ TB 1000.0)) (suf "") (size (cond ((< KB 1.0) (setq suf "B") B) ((< MB 1.0) (setq suf "K") KB) ((< GB 1.0) (setq suf "M") MB) ((< TB 1.0) (setq suf "G") GB) ((< PB 1.0) (setq suf "T") TB) (t (setq suf "P") PB)))) (concat (format (if (< size 100) (if (integerp size) "%d" "%.1f") (setq size (round size)) "%d") size) suf))) (defun jupyter-tramp--directory-listing-rows (file switches &optional full-directory-p) (jupyter-tramp--barf-if-not-file file) (cl-loop with total = 0 with year-fmt = (format-time-string "%Y-01-01 00:00") with year = (apply #'encode-time (parse-time-string year-fmt)) for attrs in (or (and (file-directory-p file) (or full-directory-p (not (dired-check-switches switches "d" "directory"))) (directory-files-and-attributes file nil nil t)) (list (cons (file-local-name file) (file-attributes file)))) for file = (pop attrs) for links = (number-to-string (or (file-attribute-link-number attrs) 1)) for user = (file-attribute-user-id attrs) for group = (file-attribute-group-id attrs) for mtime = (let ((time (file-attribute-modification-time attrs))) (format-time-string (concat "%b %d " (if (time-less-p time year) " %Y" "%H:%M")) time)) for size = (jupyter-tramp--byte-size-string (let ((s (or (file-attribute-size attrs) 64))) (prog1 s (cl-incf total s)))) for mbits = (file-attribute-modes attrs) collect (list mbits links user group size mtime file) into rows finally return (cons (jupyter-tramp--byte-size-string total) rows))) (defun jupyter-tramp-insert-directory (file switches &optional wildcard full-directory-p) (if wildcard (let* ((files (directory-files (file-name-directory file) 'abs (when wildcard ;; TODO: Directory name wildcards (wildcard-to-regexp (file-name-nondirectory file))))) (dirs (cl-remove-if-not #'file-directory-p files))) (mapc (lambda (x) (insert-directory x switches)) (cl-set-difference files dirs)) (when dirs (insert "\n")) (while dirs (let ((dir (pop dirs))) (insert dir ":\n") (insert-directory dir switches nil full-directory-p) (when dirs (insert "\n"))))) (cl-destructuring-bind (total-size . rows) (jupyter-tramp--directory-listing-rows file switches full-directory-p) (when (or full-directory-p (> (length rows) 1)) (insert (format "total %s\n" total-size))) ;; Right align the byte size column (jupyter-tramp--insert-aligned-columns rows '(4))))) (provide 'jupyter-tramp) ;;; jupyter-tramp.el ends here