You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
	
	
		
			445 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			Python
		
	
			
		
		
	
	
			445 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			Python
		
	
"""Tornado handlers for the contents web service.
 | 
						|
 | 
						|
Preliminary documentation at https://github.com/ipython/ipython/wiki/IPEP-27%3A-Contents-Service
 | 
						|
"""
 | 
						|
 | 
						|
# Copyright (c) Jupyter Development Team.
 | 
						|
# Distributed under the terms of the Modified BSD License.
 | 
						|
import json
 | 
						|
from http import HTTPStatus
 | 
						|
from typing import Any
 | 
						|
 | 
						|
try:
 | 
						|
    from jupyter_client.jsonutil import json_default
 | 
						|
except ImportError:
 | 
						|
    from jupyter_client.jsonutil import date_default as json_default
 | 
						|
 | 
						|
from jupyter_core.utils import ensure_async
 | 
						|
from tornado import web
 | 
						|
 | 
						|
from jupyter_server.auth.decorator import allow_unauthenticated, authorized
 | 
						|
from jupyter_server.base.handlers import APIHandler, JupyterHandler, path_regex
 | 
						|
from jupyter_server.utils import url_escape, url_path_join
 | 
						|
 | 
						|
AUTH_RESOURCE = "contents"
 | 
						|
 | 
						|
 | 
						|
def _validate_keys(expect_defined: bool, model: dict[str, Any], keys: list[str]):
 | 
						|
    """
 | 
						|
    Validate that the keys are defined (i.e. not None) or not (i.e. None)
 | 
						|
    """
 | 
						|
 | 
						|
    if expect_defined:
 | 
						|
        errors = [key for key in keys if model[key] is None]
 | 
						|
        if errors:
 | 
						|
            raise web.HTTPError(
 | 
						|
                500,
 | 
						|
                f"Keys unexpectedly None: {errors}",
 | 
						|
            )
 | 
						|
    else:
 | 
						|
        errors = {key: model[key] for key in keys if model[key] is not None}  # type: ignore[assignment]
 | 
						|
        if errors:
 | 
						|
            raise web.HTTPError(
 | 
						|
                500,
 | 
						|
                f"Keys unexpectedly not None: {errors}",
 | 
						|
            )
 | 
						|
 | 
						|
 | 
						|
def validate_model(model, expect_content=False, expect_hash=False):
 | 
						|
    """
 | 
						|
    Validate a model returned by a ContentsManager method.
 | 
						|
 | 
						|
    If expect_content is True, then we expect non-null entries for 'content'
 | 
						|
    and 'format'.
 | 
						|
 | 
						|
    If expect_hash is True, then we expect non-null entries for 'hash' and 'hash_algorithm'.
 | 
						|
    """
 | 
						|
    required_keys = {
 | 
						|
        "name",
 | 
						|
        "path",
 | 
						|
        "type",
 | 
						|
        "writable",
 | 
						|
        "created",
 | 
						|
        "last_modified",
 | 
						|
        "mimetype",
 | 
						|
        "content",
 | 
						|
        "format",
 | 
						|
    }
 | 
						|
    if expect_hash:
 | 
						|
        required_keys.update(["hash", "hash_algorithm"])
 | 
						|
    missing = required_keys - set(model.keys())
 | 
						|
    if missing:
 | 
						|
        raise web.HTTPError(
 | 
						|
            500,
 | 
						|
            f"Missing Model Keys: {missing}",
 | 
						|
        )
 | 
						|
 | 
						|
    content_keys = ["content", "format"]
 | 
						|
    _validate_keys(expect_content, model, content_keys)
 | 
						|
    if expect_hash:
 | 
						|
        _validate_keys(expect_hash, model, ["hash", "hash_algorithm"])
 | 
						|
 | 
						|
 | 
						|
class ContentsAPIHandler(APIHandler):
 | 
						|
    """A contents API handler."""
 | 
						|
 | 
						|
    auth_resource = AUTH_RESOURCE
 | 
						|
 | 
						|
 | 
						|
class ContentsHandler(ContentsAPIHandler):
 | 
						|
    """A contents handler."""
 | 
						|
 | 
						|
    def location_url(self, path):
 | 
						|
        """Return the full URL location of a file.
 | 
						|
 | 
						|
        Parameters
 | 
						|
        ----------
 | 
						|
        path : unicode
 | 
						|
            The API path of the file, such as "foo/bar.txt".
 | 
						|
        """
 | 
						|
        return url_path_join(self.base_url, "api", "contents", url_escape(path))
 | 
						|
 | 
						|
    def _finish_model(self, model, location=True):
 | 
						|
        """Finish a JSON request with a model, setting relevant headers, etc."""
 | 
						|
        if location:
 | 
						|
            location = self.location_url(model["path"])
 | 
						|
            self.set_header("Location", location)
 | 
						|
        self.set_header("Last-Modified", model["last_modified"])
 | 
						|
        self.set_header("Content-Type", "application/json")
 | 
						|
        self.finish(json.dumps(model, default=json_default))
 | 
						|
 | 
						|
    async def _finish_error(self, code, message):
 | 
						|
        """Finish a JSON request with an error code and descriptive message"""
 | 
						|
        self.set_status(code)
 | 
						|
        self.write(message)
 | 
						|
        await self.finish()
 | 
						|
 | 
						|
    @web.authenticated
 | 
						|
    @authorized
 | 
						|
    async def get(self, path=""):
 | 
						|
        """Return a model for a file or directory.
 | 
						|
 | 
						|
        A directory model contains a list of models (without content)
 | 
						|
        of the files and directories it contains.
 | 
						|
        """
 | 
						|
        path = path or ""
 | 
						|
        cm = self.contents_manager
 | 
						|
 | 
						|
        type = self.get_query_argument("type", default=None)
 | 
						|
        if type not in {None, "directory", "file", "notebook"}:
 | 
						|
            # fall back to file if unknown type
 | 
						|
            type = "file"
 | 
						|
 | 
						|
        format = self.get_query_argument("format", default=None)
 | 
						|
        if format not in {None, "text", "base64"}:
 | 
						|
            raise web.HTTPError(400, "Format %r is invalid" % format)
 | 
						|
        content_str = self.get_query_argument("content", default="1")
 | 
						|
        if content_str not in {"0", "1"}:
 | 
						|
            raise web.HTTPError(400, "Content %r is invalid" % content_str)
 | 
						|
        content = int(content_str or "")
 | 
						|
 | 
						|
        hash_str = self.get_query_argument("hash", default="0")
 | 
						|
        if hash_str not in {"0", "1"}:
 | 
						|
            raise web.HTTPError(
 | 
						|
                400, f"Hash argument {hash_str!r} is invalid. It must be '0' or '1'."
 | 
						|
            )
 | 
						|
        require_hash = int(hash_str)
 | 
						|
 | 
						|
        if not cm.allow_hidden and await ensure_async(cm.is_hidden(path)):
 | 
						|
            await self._finish_error(
 | 
						|
                HTTPStatus.NOT_FOUND, f"file or directory {path!r} does not exist"
 | 
						|
            )
 | 
						|
 | 
						|
        try:
 | 
						|
            expect_hash = require_hash
 | 
						|
            try:
 | 
						|
                model = await ensure_async(
 | 
						|
                    self.contents_manager.get(
 | 
						|
                        path=path,
 | 
						|
                        type=type,
 | 
						|
                        format=format,
 | 
						|
                        content=content,
 | 
						|
                        require_hash=require_hash,
 | 
						|
                    )
 | 
						|
                )
 | 
						|
            except TypeError:
 | 
						|
                # Fallback for ContentsManager not handling the require_hash argument
 | 
						|
                # introduced in 2.11
 | 
						|
                expect_hash = False
 | 
						|
                model = await ensure_async(
 | 
						|
                    self.contents_manager.get(
 | 
						|
                        path=path,
 | 
						|
                        type=type,
 | 
						|
                        format=format,
 | 
						|
                        content=content,
 | 
						|
                    )
 | 
						|
                )
 | 
						|
            validate_model(model, expect_content=content, expect_hash=expect_hash)
 | 
						|
            self._finish_model(model, location=False)
 | 
						|
        except web.HTTPError as exc:
 | 
						|
            # 404 is okay in this context, catch exception and return 404 code to prevent stack trace on client
 | 
						|
            if exc.status_code == HTTPStatus.NOT_FOUND:
 | 
						|
                await self._finish_error(
 | 
						|
                    HTTPStatus.NOT_FOUND, f"file or directory {path!r} does not exist"
 | 
						|
                )
 | 
						|
            raise
 | 
						|
 | 
						|
    @web.authenticated
 | 
						|
    @authorized
 | 
						|
    async def patch(self, path=""):
 | 
						|
        """PATCH renames a file or directory without re-uploading content."""
 | 
						|
        cm = self.contents_manager
 | 
						|
        model = self.get_json_body()
 | 
						|
        if model is None:
 | 
						|
            raise web.HTTPError(400, "JSON body missing")
 | 
						|
 | 
						|
        old_path = model.get("path")
 | 
						|
        if (
 | 
						|
            old_path
 | 
						|
            and not cm.allow_hidden
 | 
						|
            and (
 | 
						|
                await ensure_async(cm.is_hidden(path)) or await ensure_async(cm.is_hidden(old_path))
 | 
						|
            )
 | 
						|
        ):
 | 
						|
            raise web.HTTPError(400, f"Cannot rename file or directory {path!r}")
 | 
						|
 | 
						|
        model = await ensure_async(cm.update(model, path))
 | 
						|
        validate_model(model)
 | 
						|
        self._finish_model(model)
 | 
						|
 | 
						|
    async def _copy(self, copy_from, copy_to=None):
 | 
						|
        """Copy a file, optionally specifying a target directory."""
 | 
						|
        self.log.info(
 | 
						|
            "Copying %r to %r",
 | 
						|
            copy_from,
 | 
						|
            copy_to or "",
 | 
						|
        )
 | 
						|
        model = await ensure_async(self.contents_manager.copy(copy_from, copy_to))
 | 
						|
        self.set_status(201)
 | 
						|
        validate_model(model)
 | 
						|
        self._finish_model(model)
 | 
						|
 | 
						|
    async def _upload(self, model, path):
 | 
						|
        """Handle upload of a new file to path"""
 | 
						|
        self.log.info("Uploading file to %s", path)
 | 
						|
        model = await ensure_async(self.contents_manager.new(model, path))
 | 
						|
        self.set_status(201)
 | 
						|
        validate_model(model)
 | 
						|
        self._finish_model(model)
 | 
						|
 | 
						|
    async def _new_untitled(self, path, type="", ext=""):
 | 
						|
        """Create a new, empty untitled entity"""
 | 
						|
        self.log.info("Creating new %s in %s", type or "file", path)
 | 
						|
        model = await ensure_async(
 | 
						|
            self.contents_manager.new_untitled(path=path, type=type, ext=ext)
 | 
						|
        )
 | 
						|
        self.set_status(201)
 | 
						|
        validate_model(model)
 | 
						|
        self._finish_model(model)
 | 
						|
 | 
						|
    async def _save(self, model, path):
 | 
						|
        """Save an existing file."""
 | 
						|
        chunk = model.get("chunk", None)
 | 
						|
        if not chunk or chunk == -1:  # Avoid tedious log information
 | 
						|
            self.log.info("Saving file at %s", path)
 | 
						|
        model = await ensure_async(self.contents_manager.save(model, path))
 | 
						|
        validate_model(model)
 | 
						|
        self._finish_model(model)
 | 
						|
 | 
						|
    @web.authenticated
 | 
						|
    @authorized
 | 
						|
    async def post(self, path=""):
 | 
						|
        """Create a new file in the specified path.
 | 
						|
 | 
						|
        POST creates new files. The server always decides on the name.
 | 
						|
 | 
						|
        POST /api/contents/path
 | 
						|
          New untitled, empty file or directory.
 | 
						|
        POST /api/contents/path
 | 
						|
          with body {"copy_from" : "/path/to/OtherNotebook.ipynb"}
 | 
						|
          New copy of OtherNotebook in path
 | 
						|
        """
 | 
						|
 | 
						|
        cm = self.contents_manager
 | 
						|
 | 
						|
        file_exists = await ensure_async(cm.file_exists(path))
 | 
						|
        if file_exists:
 | 
						|
            raise web.HTTPError(400, "Cannot POST to files, use PUT instead.")
 | 
						|
 | 
						|
        model = self.get_json_body()
 | 
						|
        if model:
 | 
						|
            copy_from = model.get("copy_from")
 | 
						|
            if copy_from:
 | 
						|
                if not cm.allow_hidden and (
 | 
						|
                    await ensure_async(cm.is_hidden(path))
 | 
						|
                    or await ensure_async(cm.is_hidden(copy_from))
 | 
						|
                ):
 | 
						|
                    raise web.HTTPError(400, f"Cannot copy file or directory {path!r}")
 | 
						|
                else:
 | 
						|
                    await self._copy(copy_from, path)
 | 
						|
            else:
 | 
						|
                ext = model.get("ext", "")
 | 
						|
                type = model.get("type", "")
 | 
						|
                if type not in {None, "", "directory", "file", "notebook"}:
 | 
						|
                    # fall back to file if unknown type
 | 
						|
                    type = "file"
 | 
						|
                await self._new_untitled(path, type=type, ext=ext)
 | 
						|
        else:
 | 
						|
            await self._new_untitled(path)
 | 
						|
 | 
						|
    @web.authenticated
 | 
						|
    @authorized
 | 
						|
    async def put(self, path=""):
 | 
						|
        """Saves the file in the location specified by name and path.
 | 
						|
 | 
						|
        PUT is very similar to POST, but the requester specifies the name,
 | 
						|
        whereas with POST, the server picks the name.
 | 
						|
 | 
						|
        PUT /api/contents/path/Name.ipynb
 | 
						|
          Save notebook at ``path/Name.ipynb``. Notebook structure is specified
 | 
						|
          in `content` key of JSON request body. If content is not specified,
 | 
						|
          create a new empty notebook.
 | 
						|
        """
 | 
						|
        model = self.get_json_body()
 | 
						|
        cm = self.contents_manager
 | 
						|
 | 
						|
        if model:
 | 
						|
            if model.get("copy_from"):
 | 
						|
                raise web.HTTPError(400, "Cannot copy with PUT, only POST")
 | 
						|
            if not cm.allow_hidden and (
 | 
						|
                (model.get("path") and await ensure_async(cm.is_hidden(model.get("path"))))
 | 
						|
                or await ensure_async(cm.is_hidden(path))
 | 
						|
            ):
 | 
						|
                raise web.HTTPError(400, f"Cannot create file or directory {path!r}")
 | 
						|
 | 
						|
            exists = await ensure_async(self.contents_manager.file_exists(path))
 | 
						|
            if model.get("type", "") not in {None, "", "directory", "file", "notebook"}:
 | 
						|
                # fall back to file if unknown type
 | 
						|
                model["type"] = "file"
 | 
						|
            if exists:
 | 
						|
                await self._save(model, path)
 | 
						|
            else:
 | 
						|
                await self._upload(model, path)
 | 
						|
        else:
 | 
						|
            await self._new_untitled(path)
 | 
						|
 | 
						|
    @web.authenticated
 | 
						|
    @authorized
 | 
						|
    async def delete(self, path=""):
 | 
						|
        """delete a file in the given path"""
 | 
						|
        cm = self.contents_manager
 | 
						|
 | 
						|
        if not cm.allow_hidden and await ensure_async(cm.is_hidden(path)):
 | 
						|
            raise web.HTTPError(400, f"Cannot delete file or directory {path!r}")
 | 
						|
 | 
						|
        self.log.warning("delete %s", path)
 | 
						|
        await ensure_async(cm.delete(path))
 | 
						|
        self.set_status(204)
 | 
						|
        self.finish()
 | 
						|
 | 
						|
 | 
						|
class CheckpointsHandler(ContentsAPIHandler):
 | 
						|
    """A checkpoints API handler."""
 | 
						|
 | 
						|
    @web.authenticated
 | 
						|
    @authorized
 | 
						|
    async def get(self, path=""):
 | 
						|
        """get lists checkpoints for a file"""
 | 
						|
        cm = self.contents_manager
 | 
						|
        checkpoints = await ensure_async(cm.list_checkpoints(path))
 | 
						|
        data = json.dumps(checkpoints, default=json_default)
 | 
						|
        self.finish(data)
 | 
						|
 | 
						|
    @web.authenticated
 | 
						|
    @authorized
 | 
						|
    async def post(self, path=""):
 | 
						|
        """post creates a new checkpoint"""
 | 
						|
        cm = self.contents_manager
 | 
						|
        checkpoint = await ensure_async(cm.create_checkpoint(path))
 | 
						|
        data = json.dumps(checkpoint, default=json_default)
 | 
						|
        location = url_path_join(
 | 
						|
            self.base_url,
 | 
						|
            "api/contents",
 | 
						|
            url_escape(path),
 | 
						|
            "checkpoints",
 | 
						|
            url_escape(checkpoint["id"]),
 | 
						|
        )
 | 
						|
        self.set_header("Location", location)
 | 
						|
        self.set_status(201)
 | 
						|
        self.finish(data)
 | 
						|
 | 
						|
 | 
						|
class ModifyCheckpointsHandler(ContentsAPIHandler):
 | 
						|
    """A checkpoints modification handler."""
 | 
						|
 | 
						|
    @web.authenticated
 | 
						|
    @authorized
 | 
						|
    async def post(self, path, checkpoint_id):
 | 
						|
        """post restores a file from a checkpoint"""
 | 
						|
        cm = self.contents_manager
 | 
						|
        await ensure_async(cm.restore_checkpoint(checkpoint_id, path))
 | 
						|
        self.set_status(204)
 | 
						|
        self.finish()
 | 
						|
 | 
						|
    @web.authenticated
 | 
						|
    @authorized
 | 
						|
    async def delete(self, path, checkpoint_id):
 | 
						|
        """delete clears a checkpoint for a given file"""
 | 
						|
        cm = self.contents_manager
 | 
						|
        await ensure_async(cm.delete_checkpoint(checkpoint_id, path))
 | 
						|
        self.set_status(204)
 | 
						|
        self.finish()
 | 
						|
 | 
						|
 | 
						|
class NotebooksRedirectHandler(JupyterHandler):
 | 
						|
    """Redirect /api/notebooks to /api/contents"""
 | 
						|
 | 
						|
    SUPPORTED_METHODS = (
 | 
						|
        "GET",
 | 
						|
        "PUT",
 | 
						|
        "PATCH",
 | 
						|
        "POST",
 | 
						|
        "DELETE",
 | 
						|
    )
 | 
						|
 | 
						|
    @allow_unauthenticated
 | 
						|
    def get(self, path):
 | 
						|
        """Handle a notebooks redirect."""
 | 
						|
        self.log.warning("/api/notebooks is deprecated, use /api/contents")
 | 
						|
        self.redirect(url_path_join(self.base_url, "api/contents", url_escape(path)))
 | 
						|
 | 
						|
    put = patch = post = delete = get
 | 
						|
 | 
						|
 | 
						|
class TrustNotebooksHandler(JupyterHandler):
 | 
						|
    """Handles trust/signing of notebooks"""
 | 
						|
 | 
						|
    @web.authenticated  # type:ignore[misc]
 | 
						|
    @authorized(resource=AUTH_RESOURCE)
 | 
						|
    async def post(self, path=""):
 | 
						|
        """Trust a notebook by path."""
 | 
						|
        cm = self.contents_manager
 | 
						|
        await ensure_async(cm.trust_notebook(path))
 | 
						|
        self.set_status(201)
 | 
						|
        self.finish()
 | 
						|
 | 
						|
 | 
						|
# -----------------------------------------------------------------------------
 | 
						|
# URL to handler mappings
 | 
						|
# -----------------------------------------------------------------------------
 | 
						|
 | 
						|
 | 
						|
_checkpoint_id_regex = r"(?P<checkpoint_id>[\w-]+)"
 | 
						|
 | 
						|
 | 
						|
default_handlers = [
 | 
						|
    (r"/api/contents%s/checkpoints" % path_regex, CheckpointsHandler),
 | 
						|
    (
 | 
						|
        rf"/api/contents{path_regex}/checkpoints/{_checkpoint_id_regex}",
 | 
						|
        ModifyCheckpointsHandler,
 | 
						|
    ),
 | 
						|
    (r"/api/contents%s/trust" % path_regex, TrustNotebooksHandler),
 | 
						|
    (r"/api/contents%s" % path_regex, ContentsHandler),
 | 
						|
    (r"/api/notebooks/?(.*)", NotebooksRedirectHandler),
 | 
						|
]
 |