diff --git a/jupyter-base.el b/jupyter-base.el index a541eba..d6a7129 100644 --- a/jupyter-base.el +++ b/jupyter-base.el @@ -129,6 +129,9 @@ from the kernel.") A longer timeout is needed, for example, when retrieving the `jupyter-kernel-info' to allow for the kernel to startup.") +(defconst jupyter-version "0.8.1-dev" + "Current version of Jupyter.") + ;;; Macros (defmacro jupyter-with-timeout (spec &rest wait-forms) diff --git a/jupyter-rest-api.el b/jupyter-rest-api.el index e0eecdf..09906cf 100644 --- a/jupyter-rest-api.el +++ b/jupyter-rest-api.el @@ -678,13 +678,13 @@ A file model is a plist that contains the following keys: "?content=" (if no-content "0" "1") (and type (concat "&type=" type))))) -(defun jupyter-api-delete-file (client file) - "Send a request using CLIENT to delete FILE from the server. +(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))) + (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. @@ -696,8 +696,10 @@ considered." (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." diff --git a/jupyter-server.el b/jupyter-server.el index 07639c2..01ab6d0 100644 --- a/jupyter-server.el +++ b/jupyter-server.el @@ -693,7 +693,10 @@ the same meaning as in `jupyter-connect-repl'." (revert-buffer) (setq tabulated-list-format (jupyter-server--kernel-list-format) tabulated-list-entries #'jupyter-server--kernel-list-entries) - (jupyter-server-kernel-list-mode)) + (jupyter-server-kernel-list-mode) + ;; So that `dired-jump' will visit the directory of the kernel server. + (setq default-directory + (jupyter-tramp-file-name-from-url (oref server url)))) (jupyter-display-current-buffer-reuse-window)))) (provide 'jupyter-server) diff --git a/jupyter-tramp.el b/jupyter-tramp.el new file mode 100644 index 0000000..b3a541e --- /dev/null +++ b/jupyter-tramp.el @@ -0,0 +1,936 @@ +;;; 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) + (if (jupyter-api-binary-content-p model) + (base64-decode-string content) + 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 diff --git a/test/jupyter-test.el b/test/jupyter-test.el index cc769a3..123a55c 100644 --- a/test/jupyter-test.el +++ b/test/jupyter-test.el @@ -48,15 +48,6 @@ (setq jupyter-long-timeout 120 jupyter-default-timeout 60)) -(message "system-configuration %s" system-configuration) - -(when noninteractive - (message "Starting up notebook process for tests") - (start-process "jupyter-notebook" nil "jupyter" "notebook" - "--no-browser" - "--NotebookApp.token=''" - "--NotebookApp.password=''")) - (declare-function org-babel-python-table-or-string "ob-python" (results)) ;; TODO: Required tests diff --git a/test/jupyter-tramp-test.el b/test/jupyter-tramp-test.el new file mode 100644 index 0000000..410503d --- /dev/null +++ b/test/jupyter-tramp-test.el @@ -0,0 +1,347 @@ +;;; jupyter-tramp-test.el --- Tests for the contents REST API integration with TRAMP -*- lexical-binding: t -*- + +;; Copyright (C) 2019 Nathaniel Nicandro + +;; Author: Nathaniel Nicandro +;; Created: 28 May 2019 +;; Version: 0.0.1 +;; 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 2, 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: + +;; Test integration of Jupyter REST contents API with TRAMP. + +;;; Code: + +(require 'jupyter-tramp) + +(let ((port (jupyter-test-ensure-notebook-server))) + (dolist (method '("jpys" "jpy")) + (setf + (alist-get 'tramp-default-port + (alist-get method tramp-methods nil nil #'equal)) + (list port)))) + +(ert-deftest jupyter-tramp-file-directory-p () + :tags '(tramp) + (jupyter-test-at-temporary-directory + (let* ((tfile (make-temp-file "file-directory-p")) + (tdir (make-temp-file "file-directory-p" 'directory)) + (jpyfile (expand-file-name (file-name-nondirectory tfile) "/jpy::/")) + (jpydir (expand-file-name (file-name-nondirectory tdir) "/jpy::/"))) + (unwind-protect + (progn + (should (file-exists-p jpyfile)) + (should (file-exists-p jpydir)) + (should-not (file-directory-p jpyfile)) + (should (file-directory-p jpydir))) + (delete-directory tdir) + (delete-file tfile))))) + +(ert-deftest jupyter-tramp-file-writable-p () + :tags '(tramp) + (jupyter-test-at-temporary-directory + (let* ((tname (make-temp-name "file-writable-p")) + (jpyfile (expand-file-name tname "/jpy::/"))) + (should-not (file-exists-p tname)) + ;; TODO: To test this fully we would have to start the Jupyter server in + ;; a less privileged state than the current user. + (should (file-writable-p jpyfile))))) + +(ert-deftest jupyter-tramp-make-directory () + :tags '(tramp) + (jupyter-test-at-temporary-directory + (let* ((tdir (make-temp-name "make-directory")) + (jpydir (expand-file-name tdir "/jpy::/"))) + (should-not (file-exists-p tdir)) + (should-not (file-directory-p tdir)) + (unwind-protect + (progn + (make-directory jpydir) + (should (file-exists-p tdir)) + (should (file-directory-p tdir))) + (when (file-directory-p tdir) + (delete-directory tdir)))))) + +(ert-deftest jupyter-tramp-file-local-copy () + :tags '(tramp) + (jupyter-test-at-temporary-directory + (let* ((tfile (make-temp-file "file-local-copy")) + (jpyfile (expand-file-name (file-name-nondirectory tfile) "/jpy::/"))) + (unwind-protect + (let ((contents (jupyter-new-uuid))) + (with-temp-file tfile + (insert contents)) + (let ((lfile (file-local-copy jpyfile))) + (unwind-protect + (with-temp-buffer + (should-not (file-remote-p lfile)) + (should-not (equal lfile tfile)) + (insert-file-contents lfile) + (should (equal (buffer-string) contents))) + (delete-file lfile)))) + (delete-file tfile))))) + +(ert-deftest jupyter-tramp-rename-file () + :tags '(tramp) + (jupyter-test-at-temporary-directory + (let* ((tfile (make-temp-file "rename-file")) + (tnewname (jupyter-new-uuid)) + (jpyfile (expand-file-name (file-name-nondirectory tfile) "/jpy::/")) + (jpynewname (expand-file-name tnewname "/jpy::/"))) + (ert-info ("Remote to same remote") + (should-not (file-exists-p tnewname)) + (unwind-protect + (let ((contents (jupyter-new-uuid))) + (with-temp-file tfile + (insert contents)) + (rename-file jpyfile jpynewname) + (should (file-exists-p tnewname)) + (unwind-protect + (with-temp-buffer + (insert-file-contents tnewname) + (should (equal (buffer-string) contents))) + (ignore-errors (delete-file tnewname)))) + (ignore-errors (delete-file tfile)))) + (ert-info ("Local to remote") + (unwind-protect + (let ((contents (jupyter-new-uuid))) + (should-not (file-exists-p tfile)) + (should-not (file-exists-p jpyfile)) + (with-temp-file tfile + (insert contents)) + (should (file-exists-p tfile)) + (rename-file tfile jpynewname) + (should-not (file-exists-p tfile)) + (should (file-exists-p tnewname))) + (ignore-errors (delete-file tnewname)) + (ignore-errors (delete-file tfile))))))) + +(ert-deftest jupyter-tramp-copy-file () + :tags '(tramp) + (jupyter-test-at-temporary-directory + (cl-macrolet + ((file-contents + (f) `(with-temp-buffer + (insert-file-contents ,f) + (buffer-string))) + (check-copy + (f1 f2 c) + `(progn + (should-not (file-exists-p ,f1)) + (write-region ,c nil ,f1) + (should (file-exists-p ,f1)) + (unwind-protect + (unwind-protect + (progn + (copy-file ,f1 ,f2) + (should (file-exists-p ,f2)) + (should (equal ,c (file-contents ,f2)))) + (ignore-errors (delete-file (file-name-nondirectory ,f2)))) + (ignore-errors (delete-file (file-name-nondirectory ,f1))))))) + (ert-info ("Local to remote") + (let ((tf1 (make-temp-name "copy-file")) + (jpy1 (expand-file-name (make-temp-name "copy-file") "/jpy::/")) + (c1 (jupyter-new-uuid))) + (check-copy tf1 jpy1 c1))) + (ert-info ("Remote to local") + (let ((tf1 (make-temp-name "copy-file")) + (jpy1 (expand-file-name (make-temp-name "copy-file") "/jpy::/")) + (c1 (jupyter-new-uuid))) + (check-copy jpy1 tf1 c1))) + (ert-info ("Remote to remote") + (let ((jpy1 (expand-file-name (make-temp-name "copy-file") "/jpy::/")) + (jpy2 (expand-file-name (make-temp-name "copy-file") "/jpy::/")) + (c1 (jupyter-new-uuid))) + (check-copy jpy1 jpy2 c1)))))) + +(ert-deftest jupyter-tramp-delete-file () + :tags '(tramp) + (jupyter-test-at-temporary-directory + (let* ((tfile (make-temp-file "delete-file")) + (tdir (make-temp-file "delete-file" 'directory)) + (jpyfile (expand-file-name (file-name-nondirectory tfile) "/jpy::/")) + (jpydir (expand-file-name (file-name-nondirectory tdir) "/jpy::/"))) + (should (file-exists-p tfile)) + (should (file-exists-p jpyfile)) + (should (file-exists-p tdir)) + (should (file-exists-p jpydir)) + (unwind-protect + (progn + (ert-info ("Error when attempting to delete a directory") + (should-error (delete-file jpydir))) + (ert-info ("Delete a file") + (delete-file jpyfile) + (should-not (file-exists-p tfile)) + (ert-info ("Ensure cache is cleared") + (should-not (file-exists-p jpyfile))))) + (when (file-exists-p tfile) + (delete-file tfile)) + (when (file-exists-p tdir) + (delete-directory tdir)))))) + +(ert-deftest jupyter-delete-directory () + :tags '(tramp) + (jupyter-test-at-temporary-directory + (let* ((tfile (make-temp-file "delete-directory")) + (tdir (make-temp-file "delete-directory" 'directory)) + (jpyfile (expand-file-name (file-name-nondirectory tfile) "/jpy::/")) + (jpydir (expand-file-name (file-name-nondirectory tdir) "/jpy::/"))) + (should (file-exists-p tfile)) + (should (file-exists-p jpyfile)) + (should (file-exists-p tdir)) + (should (file-exists-p jpydir)) + (unwind-protect + (progn + (ert-info ("Error when attempting to delete a file") + (should-error (delete-directory jpyfile))) + (ert-info ("Delete a directory") + (let ((tfile2 (expand-file-name "foobar" jpydir))) + (write-region "xxx" nil tfile2) + (unwind-protect + (progn + (ert-info ("Error when directory contains files") + (should-error (delete-directory jpydir))) + (ert-info ("Unless recusrive is specifed") + (delete-directory jpydir t) + (should-not (file-exists-p tfile2)) + (should-not (file-directory-p tdir)))) + (when (file-exists-p tfile2) + (delete-file tfile2)))) + (should-not (file-exists-p tdir)) + (ert-info ("Ensure cache is cleared") + (should-not (file-exists-p jpydir))))) + (when (file-exists-p tfile) + (delete-file tfile)) + (when (file-exists-p tdir) + (delete-directory tdir)))))) + +(ert-deftest jupyter-tramp-file-attributes () + :tags '(tramp) + (jupyter-test-at-temporary-directory + (let* ((file (make-temp-file "file-attributes")) + (jpyfile (expand-file-name + (file-name-nondirectory file) "/jpy::/"))) + (set-file-modes file (string-to-number "600" 8)) + (write-region (make-string (1+ (random 100)) ?x) nil file) + (unwind-protect + (let ((attrs (file-attributes file 'string)) + (jpyattrs (file-attributes jpyfile 'string))) + (should-not (or (null (file-attribute-size attrs)) + (zerop (file-attribute-size attrs)))) + ;; Remove the usec and psec resolution + (dolist (s '(2 3)) + (setf (nth s (file-attribute-modification-time attrs)) 0) + (setf (nth s (file-attribute-status-change-time attrs)) 0) + (setf (nth s (file-attribute-modification-time jpyattrs)) 0) + (setf (nth s (file-attribute-status-change-time jpyattrs)) 0)) + (should (equal (nth 0 attrs) (nth 0 jpyattrs))) + (dolist (item '(file-attribute-modification-time + file-attribute-status-change-time + ;; We always use the mode 600 since the file modes + ;; are not accessible by a user. The file should + ;; always be writable when testing since the server + ;; is started by the current Emacs process. + ;; file-attribute-modes + file-attribute-size)) + (should (equal (funcall item attrs) + (funcall item jpyattrs))))) + (delete-file file))))) + +(ert-deftest jupyter-tramp-expand-file-name () + :tags '(tramp) + (should (equal "/foo" (expand-file-name "/foo" "/jpy:h:/foo"))) + (should (equal "~/foo" (abbreviate-file-name (expand-file-name "~/foo" "/jpy:h:/foo")))) + (should (equal "/jpy:h:/foo/bar" (expand-file-name "bar" "/jpy:h:/foo"))) + (should (equal "/jpy:h:/foo/bar" (expand-file-name "bar" "/jpy:h:/foo/"))) + (should (equal "/jpy:h:/foo/bar" (expand-file-name "/jpy:h:/foo/bar"))) + (should (equal "/jpy:h:/foo/bar" (expand-file-name "/jpy:h:foo/bar"))) + (let ((default-directory "/jpy:h:/")) + (should (equal "/jpy:h:/foo" (expand-file-name "foo")))) + (let ((default-directory nil)) + (should (equal "/foo" (jupyter-tramp-expand-file-name "foo"))))) + +;; TODO +(ert-deftest jupyter-tramp-file-name-all-completions () + :tags '(tramp)) + +;; TODO +(ert-deftest jupyter-tramp-file-remote-p () + :tags '(tramp)) + +(ert-deftest jupyter-tramp-write-region () + :tags '(tramp) + (jupyter-test-at-temporary-directory + (let* ((file (make-temp-file "write-region")) + (jpyfile (expand-file-name + (file-name-nondirectory file) "/jpy::/"))) + (unwind-protect + (cl-macrolet ((file-contents + () `(with-temp-buffer + (insert-file-contents file) + (buffer-string)))) + (should-error (write-region "foo" nil jpyfile nil nil nil 'excl)) + (ert-info ("Basic write") + (write-region "foo" nil jpyfile) + (should (equal (file-contents) "foo")) + (with-temp-buffer + (insert "foo") + (write-region nil nil jpyfile) + (should (buffer-modified-p)) + (should (equal (file-contents) "foo")) + (insert "bar") + (write-region nil "" jpyfile) + (should (buffer-modified-p)) + (should (equal (file-contents) "foobar")) + (should-error (write-region 1 nil jpyfile)) + (should-error (write-region (list 1) 1 jpyfile)) + (write-region 2 4 jpyfile) + (should (buffer-modified-p)) + (should (equal (file-contents) "oo")))) + (ert-info ("Base64 encode binary") + (let ((coding-system-for-write 'binary)) + (write-region "\0\1\2\3\4\5\6" nil jpyfile) + (should (equal (file-contents) "\0\1\2\3\4\5\6")))) + (ert-info ("Append") + (write-region "x" nil jpyfile) + (should (equal (file-contents) "x")) + (write-region "y" nil jpyfile t) + (should (equal (file-contents) "xy")) + (write-region "z" nil jpyfile t) + (should (equal (file-contents) "xyz")) + (write-region "a" nil jpyfile 1) + (should (equal (file-contents) "xaz")) + (write-region "b" nil jpyfile 6) + (should (equal (file-contents) "xaz\0\0\0b"))) + (ert-info ("File visiting") + (with-temp-buffer + (insert "foo") + (write-region nil nil jpyfile nil t) + (should-not (buffer-modified-p)) + (should (equal jpyfile (buffer-file-name))) + (insert "bar") + (write-region nil nil jpyfile nil "foo") + (should-not (buffer-modified-p)) + (should (equal "foo" (file-name-nondirectory + (buffer-file-name))))))) + (delete-file file))))) + +;; Local Variables: +;; byte-compile-warnings: (not free-vars) +;; End: +;;; jupyter-tramp-test.el ends here diff --git a/test/test-helper.el b/test/test-helper.el index 0872736..9a052a4 100644 --- a/test/test-helper.el +++ b/test/test-helper.el @@ -43,6 +43,27 @@ Let bind to a non-nil value around a call to `jupyter-test-with-kernel-client' or `jupyter-test-with-kernel-repl' to start a new kernel REPL instead of re-using one.") +(defvar jupyter-test-temporary-directory-name "jupyter") + +(defvar jupyter-test-temporary-directory + (make-temp-file jupyter-test-temporary-directory-name 'directory) + "The directory where temporary processes/files will start or be written to.") + +(message "system-configuration %s" system-configuration) + +(when noninteractive + (message "Starting up notebook process for tests") + (let ((default-directory jupyter-test-temporary-directory)) + (start-process "jupyter-notebook" nil "jupyter" "notebook" + "--no-browser" + "--NotebookApp.token=''" + "--NotebookApp.password=''"))) + +(add-hook + 'kill-emacs-hook + (lambda () + (ignore-errors (delete-directory jupyter-test-temporary-directory)))) + ;;; `jupyter-echo-client' (defclass jupyter-echo-client (jupyter-kernel-client) @@ -149,6 +170,13 @@ If the `current-buffer' is not a REPL, this is identical to (jupyter-test-repl-ret-sync))) ,@body)) +(defmacro jupyter-test-at-temporary-directory (&rest body) + (declare (debug (&rest form))) + `(let ((default-directory jupyter-test-temporary-directory) + (temporary-file-directory jupyter-test-temporary-directory) + (tramp-cache-data (make-hash-table :test #'equal))) + ,@body)) + (defmacro jupyter-with-echo-client (client &rest body) (declare (indent 1) (debug (symbolp &rest form))) `(let ((,client (jupyter-echo-client)))