mirror of
https://github.com/vale981/emacs-jupyter
synced 2025-03-05 07:41:37 -05:00
Jupyter notebook contents API integration with TRAMP
This commit is contained in:
parent
e8edd4d5cc
commit
4a87d35f76
7 changed files with 1323 additions and 13 deletions
|
@ -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)
|
||||
|
|
|
@ -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."
|
||||
|
|
|
@ -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)
|
||||
|
|
936
jupyter-tramp.el
Normal file
936
jupyter-tramp.el
Normal file
|
@ -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 <nathanielnicandro@gmail.com>
|
||||
;; 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
|
|
@ -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
|
||||
|
|
347
test/jupyter-tramp-test.el
Normal file
347
test/jupyter-tramp-test.el
Normal file
|
@ -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 <nathanielnicandro@gmail.com>
|
||||
;; 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
|
|
@ -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)))
|
||||
|
|
Loading…
Add table
Reference in a new issue