diff --git a/jupyter_server/serverapp.py b/jupyter_server/serverapp.py index a5f9b99d10..2cf3e89033 100644 --- a/jupyter_server/serverapp.py +++ b/jupyter_server/serverapp.py @@ -62,7 +62,6 @@ from jupyter_client.session import Session from jupyter_core.application import JupyterApp, base_aliases, base_flags from jupyter_core.paths import jupyter_runtime_dir -from nbformat.sign import NotebookNotary from traitlets import ( Any, Bool, @@ -769,7 +768,6 @@ class ServerApp(JupyterApp): FileContentsManager, AsyncContentsManager, AsyncFileContentsManager, - NotebookNotary, GatewayMappingKernelManager, GatewayKernelSpecManager, GatewaySessionManager, diff --git a/jupyter_server/services/contents/fileio.py b/jupyter_server/services/contents/fileio.py index e1d6ae66dc..de123f8d96 100644 --- a/jupyter_server/services/contents/fileio.py +++ b/jupyter_server/services/contents/fileio.py @@ -4,13 +4,12 @@ # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. import errno +import json import os import shutil from base64 import decodebytes, encodebytes from contextlib import contextmanager -from functools import partial -import nbformat from anyio.to_thread import run_sync from tornado.web import HTTPError from traitlets import Bool @@ -259,47 +258,8 @@ def _get_os_path(self, path): raise HTTPError(404, "%s is outside root contents directory" % path) return os_path - def _read_notebook(self, os_path, as_version=4, capture_validation_error=None): - """Read a notebook from an os path.""" - with self.open(os_path, "r", encoding="utf-8") as f: - try: - return nbformat.read( - f, as_version=as_version, capture_validation_error=capture_validation_error - ) - except Exception as e: - e_orig = e - - # If use_atomic_writing is enabled, we'll guess that it was also - # enabled when this notebook was written and look for a valid - # atomic intermediate. - tmp_path = path_to_intermediate(os_path) - - if not self.use_atomic_writing or not os.path.exists(tmp_path): - raise HTTPError( - 400, - f"Unreadable Notebook: {os_path} {e_orig!r}", - ) - - # Move the bad file aside, restore the intermediate, and try again. - invalid_file = path_to_invalid(os_path) - replace_file(os_path, invalid_file) - replace_file(tmp_path, os_path) - return self._read_notebook( - os_path, as_version, capture_validation_error=capture_validation_error - ) - - def _save_notebook(self, os_path, nb, capture_validation_error=None): - """Save a notebook to an os_path.""" - with self.atomic_writing(os_path, encoding="utf-8") as f: - nbformat.write( - nb, - f, - version=nbformat.NO_CONVERT, - capture_validation_error=capture_validation_error, - ) - def _read_file(self, os_path, format): - """Read a non-notebook file. + """Read a file. os_path: The path to be read. format: @@ -329,13 +289,16 @@ def _read_file(self, os_path, format): def _save_file(self, os_path, content, format): """Save content of a generic file.""" - if format not in {"text", "base64"}: + format = format or "text" + if format not in {"json", "text", "base64"}: raise HTTPError( 400, - "Must specify format of file contents as 'text' or 'base64'", + "Must specify format of file contents as 'json', 'text' or 'base64'", ) try: - if format == "text": + if format == "json": + bcontent = json.dumps(content).encode("utf8") + elif format == "text": bcontent = content.encode("utf8") else: b64_bytes = content.encode("ascii") @@ -359,55 +322,8 @@ async def _copy(self, src, dest): """ await async_copy2_safe(src, dest, log=self.log) - async def _read_notebook(self, os_path, as_version=4, capture_validation_error=None): - """Read a notebook from an os path.""" - with self.open(os_path, "r", encoding="utf-8") as f: - try: - return await run_sync( - partial( - nbformat.read, - as_version=as_version, - capture_validation_error=capture_validation_error, - ), - f, - ) - except Exception as e: - e_orig = e - - # If use_atomic_writing is enabled, we'll guess that it was also - # enabled when this notebook was written and look for a valid - # atomic intermediate. - tmp_path = path_to_intermediate(os_path) - - if not self.use_atomic_writing or not os.path.exists(tmp_path): - raise HTTPError( - 400, - f"Unreadable Notebook: {os_path} {e_orig!r}", - ) - - # Move the bad file aside, restore the intermediate, and try again. - invalid_file = path_to_invalid(os_path) - await async_replace_file(os_path, invalid_file) - await async_replace_file(tmp_path, os_path) - return await self._read_notebook( - os_path, as_version, capture_validation_error=capture_validation_error - ) - - async def _save_notebook(self, os_path, nb, capture_validation_error=None): - """Save a notebook to an os_path.""" - with self.atomic_writing(os_path, encoding="utf-8") as f: - await run_sync( - partial( - nbformat.write, - version=nbformat.NO_CONVERT, - capture_validation_error=capture_validation_error, - ), - nb, - f, - ) - async def _read_file(self, os_path, format): - """Read a non-notebook file. + """Read a file. os_path: The path to be read. format: @@ -437,13 +353,16 @@ async def _read_file(self, os_path, format): async def _save_file(self, os_path, content, format): """Save content of a generic file.""" - if format not in {"text", "base64"}: + format = format or "text" + if format not in {"json", "text", "base64"}: raise HTTPError( 400, - "Must specify format of file contents as 'text' or 'base64'", + "Must specify format of file contents as 'json', 'text' or 'base64'", ) try: - if format == "text": + if format == "json": + bcontent = json.dumps(content).encode("utf8") + elif format == "text": bcontent = content.encode("utf8") else: b64_bytes = content.encode("ascii") diff --git a/jupyter_server/services/contents/filemanager.py b/jupyter_server/services/contents/filemanager.py index b04ab4a3dc..c67ebcabdf 100644 --- a/jupyter_server/services/contents/filemanager.py +++ b/jupyter_server/services/contents/filemanager.py @@ -9,7 +9,6 @@ import sys from datetime import datetime -import nbformat from anyio.to_thread import run_sync from jupyter_core.paths import exists, is_file_hidden, is_hidden from send2trash import send2trash @@ -326,28 +325,6 @@ def _file_model(self, path, content=True, format=None): return model - def _notebook_model(self, path, content=True): - """Build a notebook model - - if content is requested, the notebook content will be populated - as a JSON structure (not double-serialized) - """ - model = self._base_model(path) - model["type"] = "notebook" - os_path = self._get_os_path(path) - - if content: - validation_error: dict = {} - nb = self._read_notebook( - os_path, as_version=4, capture_validation_error=validation_error - ) - self.mark_trusted_cells(nb, path) - model["content"] = nb - model["format"] = "json" - self.validate_notebook_model(model, validation_error) - - return model - def get(self, path, content=True, type=None, format=None): """Takes a path for an entity and returns its model @@ -358,11 +335,11 @@ def get(self, path, content=True, type=None, format=None): content : bool Whether to include the contents in the reply type : str, optional - The requested type - 'file', 'notebook', or 'directory'. + The requested type - 'file' or 'directory'. Will raise HTTPError 400 if the content doesn't match. format : str, optional The requested format for file contents. 'text' or 'base64'. - Ignored if this returns a notebook or directory model. + Ignored if this returns a directory model. Returns ------- @@ -389,8 +366,6 @@ def get(self, path, content=True, type=None, format=None): reason="bad type", ) model = self._dir_model(path, content=content) - elif type == "notebook" or (type is None and path.endswith(".ipynb")): - model = self._notebook_model(path, content=content) else: if type == "directory": raise web.HTTPError(400, "%s is not a directory" % path, reason="bad type") @@ -427,20 +402,12 @@ def save(self, model, path=""): self.log.debug("Saving %s", os_path) - validation_error: dict = {} try: - if model["type"] == "notebook": - nb = nbformat.from_dict(model["content"]) - self.check_and_sign(nb, path) - self._save_notebook(os_path, nb, capture_validation_error=validation_error) - # One checkpoint should always exist for notebooks. - if not self.checkpoints.list_checkpoints(path): - self.create_checkpoint(path) - elif model["type"] == "file": + if model["type"] == "directory": + self._save_directory(os_path, model, path) + elif model["type"] in ("file", "notebook"): # keep notebook for backwards compatibility # Missing format will be handled internally by _save_file. self._save_file(os_path, model["content"], model.get("format")) - elif model["type"] == "directory": - self._save_directory(os_path, model, path) else: raise web.HTTPError(400, "Unhandled contents type: %s" % model["type"]) except web.HTTPError: @@ -449,14 +416,7 @@ def save(self, model, path=""): self.log.error("Error while saving file: %s %s", path, e, exc_info=True) raise web.HTTPError(500, f"Unexpected error while saving file: {path} {e}") from e - validation_message = None - if model["type"] == "notebook": - self.validate_notebook_model(model, validation_error=validation_error) - validation_message = model.get("message", None) - model = self.get(path, content=False) - if validation_message: - model["message"] = validation_message self.run_post_save_hooks(model=model, os_path=os_path) @@ -671,28 +631,6 @@ async def _file_model(self, path, content=True, format=None): return model - async def _notebook_model(self, path, content=True): - """Build a notebook model - - if content is requested, the notebook content will be populated - as a JSON structure (not double-serialized) - """ - model = self._base_model(path) - model["type"] = "notebook" - os_path = self._get_os_path(path) - - if content: - validation_error: dict = {} - nb = await self._read_notebook( - os_path, as_version=4, capture_validation_error=validation_error - ) - self.mark_trusted_cells(nb, path) - model["content"] = nb - model["format"] = "json" - self.validate_notebook_model(model, validation_error) - - return model - async def get(self, path, content=True, type=None, format=None): """Takes a path for an entity and returns its model @@ -703,11 +641,11 @@ async def get(self, path, content=True, type=None, format=None): content : bool Whether to include the contents in the reply type : str, optional - The requested type - 'file', 'notebook', or 'directory'. + The requested type - 'file' or 'directory'. Will raise HTTPError 400 if the content doesn't match. format : str, optional The requested format for file contents. 'text' or 'base64'. - Ignored if this returns a notebook or directory model. + Ignored if this returns directory model. Returns ------- @@ -729,8 +667,6 @@ async def get(self, path, content=True, type=None, format=None): reason="bad type", ) model = await self._dir_model(path, content=content) - elif type == "notebook" or (type is None and path.endswith(".ipynb")): - model = await self._notebook_model(path, content=content) else: if type == "directory": raise web.HTTPError(400, "%s is not a directory" % path, reason="bad type") @@ -763,20 +699,12 @@ async def save(self, model, path=""): os_path = self._get_os_path(path) self.log.debug("Saving %s", os_path) - validation_error: dict = {} try: - if model["type"] == "notebook": - nb = nbformat.from_dict(model["content"]) - self.check_and_sign(nb, path) - await self._save_notebook(os_path, nb, capture_validation_error=validation_error) - # One checkpoint should always exist for notebooks. - if not (await self.checkpoints.list_checkpoints(path)): - await self.create_checkpoint(path) - elif model["type"] == "file": + if model["type"] == "directory": + await self._save_directory(os_path, model, path) + elif model["type"] in ("file", "notebook"): # keep notebook for backwards compatibility # Missing format will be handled internally by _save_file. await self._save_file(os_path, model["content"], model.get("format")) - elif model["type"] == "directory": - await self._save_directory(os_path, model, path) else: raise web.HTTPError(400, "Unhandled contents type: %s" % model["type"]) except web.HTTPError: @@ -785,14 +713,7 @@ async def save(self, model, path=""): self.log.error("Error while saving file: %s %s", path, e, exc_info=True) raise web.HTTPError(500, f"Unexpected error while saving file: {path} {e}") from e - validation_message = None - if model["type"] == "notebook": - self.validate_notebook_model(model, validation_error=validation_error) - validation_message = model.get("message", None) - model = await self.get(path, content=False) - if validation_message: - model["message"] = validation_message self.run_post_save_hooks(model=model, os_path=os_path) diff --git a/jupyter_server/services/contents/handlers.py b/jupyter_server/services/contents/handlers.py index 462cbff35e..f4ae0bbe0d 100644 --- a/jupyter_server/services/contents/handlers.py +++ b/jupyter_server/services/contents/handlers.py @@ -98,7 +98,9 @@ async def get(self, path=""): cm = self.contents_manager type = self.get_query_argument("type", default=None) - if type not in {None, "directory", "file", "notebook"}: + if type == "notebook": + type = "file" + if type not in {None, "directory", "file"}: raise web.HTTPError(400, "Type %r is invalid" % type) format = self.get_query_argument("format", default=None) diff --git a/jupyter_server/services/contents/manager.py b/jupyter_server/services/contents/manager.py index 7bd6450803..6eb34096bc 100644 --- a/jupyter_server/services/contents/manager.py +++ b/jupyter_server/services/contents/manager.py @@ -2,13 +2,10 @@ # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. import itertools -import json import re import warnings from fnmatch import fnmatch -from nbformat import ValidationError, sign -from nbformat import validate as validate_nb from nbformat.v4 import new_notebook from tornado.web import HTTPError, RequestHandler from traitlets import ( @@ -37,9 +34,8 @@ class ContentsManager(LoggingConfigurable): """Base class for serving files and directories. - This serves any text or binary file, - as well as directories, - with special handling for JSON notebook documents. + This serves any JSON, text or binary file, + as well as directories. Most APIs take a path argument, which is always an API-style unicode path, @@ -57,11 +53,6 @@ class ContentsManager(LoggingConfigurable): allow_hidden = Bool(False, config=True, help="Allow access to hidden files") - notary = Instance(sign.NotebookNotary) - - def _notary_default(self): - return sign.NotebookNotary(parent=self) - hide_globs = List( Unicode(), [ @@ -487,27 +478,6 @@ def increment_filename(self, filename, path="", insert=""): break return name - def validate_notebook_model(self, model, validation_error=None): - """Add failed-validation message to model""" - try: - # If we're given a validation_error dictionary, extract the exception - # from it and raise the exception, else call nbformat's validate method - # to determine if the notebook is valid. This 'else' condition may - # pertain to server extension not using the server's notebook read/write - # functions. - if validation_error is not None: - e = validation_error.get("ValidationError") - if isinstance(e, ValidationError): - raise e - else: - validate_nb(model["content"]) - except ValidationError as e: - model["message"] = "Notebook validation failed: {}:\n{}".format( - str(e), - json.dumps(e.instance, indent=1, default=lambda obj: ""), - ) - return model - def new_untitled(self, path="", type="", ext=""): """Create a new untitled file or directory in path @@ -555,10 +525,7 @@ def new(self, model=None, path=""): if model is None: model = {} - if path.endswith(".ipynb"): - model.setdefault("type", "notebook") - else: - model.setdefault("type", "file") + model.setdefault("type", "file") # no content, not a directory, so fill out new-file model if "content" not in model and model["type"] != "directory": @@ -621,54 +588,6 @@ def copy(self, from_path, to_path=None): def log_info(self): self.log.info(self.info_string()) - def trust_notebook(self, path): - """Explicitly trust a notebook - - Parameters - ---------- - path : string - The path of a notebook - """ - model = self.get(path) - nb = model["content"] - self.log.warning("Trusting notebook %s", path) - self.notary.mark_cells(nb, True) - self.check_and_sign(nb, path) - - def check_and_sign(self, nb, path=""): - """Check for trusted cells, and sign the notebook. - - Called as a part of saving notebooks. - - Parameters - ---------- - nb : dict - The notebook dict - path : string - The notebook's path (for logging) - """ - if self.notary.check_cells(nb): - self.notary.sign(nb) - else: - self.log.warning("Notebook %s is not trusted", path) - - def mark_trusted_cells(self, nb, path=""): - """Mark cells as trusted if the notebook signature matches. - - Called as a part of loading notebooks. - - Parameters - ---------- - nb : dict - The notebook object (in current nbformat) - path : string - The notebook's path (for logging) - """ - trusted = self.notary.check_signature(nb) - if not trusted: - self.log.warning("Notebook %s is not trusted", path) - self.notary.mark_cells(nb, trusted) - def should_list(self, name): """Should this file/directory name be displayed in a listing?""" return not any(fnmatch(name, glob) for glob in self.hide_globs) @@ -987,20 +906,6 @@ async def copy(self, from_path, to_path=None): model = await self.save(model, to_path) return model - async def trust_notebook(self, path): - """Explicitly trust a notebook - - Parameters - ---------- - path : string - The path of a notebook - """ - model = await self.get(path) - nb = model["content"] - self.log.warning("Trusting notebook %s", path) - self.notary.mark_cells(nb, True) - self.check_and_sign(nb, path) - # Part 3: Checkpoints API async def create_checkpoint(self, path): """Create a checkpoint.""" diff --git a/tests/services/contents/test_api.py b/tests/services/contents/test_api.py index c1eefe74eb..33377edb6c 100644 --- a/tests/services/contents/test_api.py +++ b/tests/services/contents/test_api.py @@ -2,12 +2,11 @@ import pathlib import sys from base64 import decodebytes, encodebytes -from unicodedata import normalize import pytest import tornado -from nbformat import from_dict, writes -from nbformat.v4 import new_markdown_cell, new_notebook +from nbformat import writes +from nbformat.v4 import new_notebook from jupyter_server.utils import url_path_join @@ -89,21 +88,6 @@ def folders(): return list({item[0] for item in dirs}) -@pytest.mark.parametrize("path,name", dirs) -async def test_list_notebooks(jp_fetch, contents, path, name): - response = await jp_fetch( - "api", - "contents", - path, - method="GET", - ) - data = json.loads(response.body.decode()) - nbs = notebooks_only(data) - assert len(nbs) > 0 - assert name + ".ipynb" in [normalize("NFC", n["name"]) for n in nbs] - assert url_path_join(path, name + ".ipynb") in [normalize("NFC", n["path"]) for n in nbs] - - @pytest.mark.parametrize("path,name", dirs) async def test_get_dir_no_contents(jp_fetch, contents, path, name): response = await jp_fetch( @@ -132,61 +116,6 @@ async def test_list_nonexistant_dir(jp_fetch, contents): ) -@pytest.mark.parametrize("path,name", dirs) -async def test_get_nb_contents(jp_fetch, contents, path, name): - nbname = name + ".ipynb" - nbpath = (path + "/" + nbname).lstrip("/") - r = await jp_fetch("api", "contents", nbpath, method="GET", params=dict(content="1")) - model = json.loads(r.body.decode()) - assert model["name"] == nbname - assert model["path"] == nbpath - assert model["type"] == "notebook" - assert "content" in model - assert model["format"] == "json" - assert "metadata" in model["content"] - assert isinstance(model["content"]["metadata"], dict) - - -@pytest.mark.parametrize("path,name", dirs) -async def test_get_nb_no_contents(jp_fetch, contents, path, name): - nbname = name + ".ipynb" - nbpath = (path + "/" + nbname).lstrip("/") - r = await jp_fetch("api", "contents", nbpath, method="GET", params=dict(content="0")) - model = json.loads(r.body.decode()) - assert model["name"] == nbname - assert model["path"] == nbpath - assert model["type"] == "notebook" - assert "content" in model - assert model["content"] is None - - -async def test_get_nb_invalid(contents_dir, jp_fetch, contents): - nb = { - "nbformat": 4, - "metadata": {}, - "cells": [ - { - "cell_type": "wrong", - "metadata": {}, - } - ], - } - nbpath = "å b/Validate tést.ipynb" - (contents_dir / nbpath).write_text(json.dumps(nb)) - r = await jp_fetch( - "api", - "contents", - nbpath, - method="GET", - ) - model = json.loads(r.body.decode()) - assert model["path"] == nbpath - assert model["type"] == "notebook" - assert "content" in model - assert "message" in model - assert "validation failed" in model["message"].lower() - - async def test_get_contents_no_such_file(jp_fetch): with pytest.raises(tornado.httpclient.HTTPClientError) as e: await jp_fetch( @@ -316,7 +245,7 @@ async def test_get_bad_type(jp_fetch, contents): @pytest.fixture def _check_created(jp_base_url): - def _inner(r, contents_dir, path, name, type="notebook"): + def _inner(r, contents_dir, path, name, type="file"): fpath = path + "/" + name assert r.code == 201 location = jp_base_url + "api/contents/" + tornado.escape.url_escape(fpath, plus=False) @@ -338,16 +267,16 @@ async def test_create_untitled(jp_fetch, contents, contents_dir, _check_created) path = "å b" name = "Untitled.ipynb" r = await jp_fetch("api", "contents", path, method="POST", body=json.dumps({"ext": ".ipynb"})) - _check_created(r, str(contents_dir), path, name, type="notebook") + _check_created(r, str(contents_dir), path, name, type="file") name = "Untitled1.ipynb" r = await jp_fetch("api", "contents", path, method="POST", body=json.dumps({"ext": ".ipynb"})) - _check_created(r, str(contents_dir), path, name, type="notebook") + _check_created(r, str(contents_dir), path, name, type="file") path = "foo/bar" name = "Untitled.ipynb" r = await jp_fetch("api", "contents", path, method="POST", body=json.dumps({"ext": ".ipynb"})) - _check_created(r, str(contents_dir), path, name, type="notebook") + _check_created(r, str(contents_dir), path, name, type="file") async def test_create_untitled_txt(jp_fetch, contents, contents_dir, _check_created): @@ -364,8 +293,8 @@ async def test_create_untitled_txt(jp_fetch, contents, contents_dir, _check_crea async def test_upload(jp_fetch, contents, contents_dir, _check_created): - nb = new_notebook() - nbmodel = {"content": nb, "type": "notebook"} + nb = json.dumps(new_notebook()) + nbmodel = {"content": nb, "type": "file", "format": "text"} path = "å b" name = "Upload tést.ipynb" r = await jp_fetch("api", "contents", path, name, method="PUT", body=json.dumps(nbmodel)) @@ -511,7 +440,7 @@ async def test_copy(jp_fetch, contents, contents_dir, _check_created): method="POST", body=json.dumps({"copy_from": path + "/" + name}), ) - _check_created(r, str(contents_dir), path, copy, type="notebook") + _check_created(r, str(contents_dir), path, copy) # Copy the same file name copy2 = "ç d-Copy2.ipynb" @@ -522,7 +451,7 @@ async def test_copy(jp_fetch, contents, contents_dir, _check_created): method="POST", body=json.dumps({"copy_from": path + "/" + name}), ) - _check_created(r, str(contents_dir), path, copy2, type="notebook") + _check_created(r, str(contents_dir), path, copy2) # copy a copy. copy3 = "ç d-Copy3.ipynb" @@ -533,7 +462,7 @@ async def test_copy(jp_fetch, contents, contents_dir, _check_created): method="POST", body=json.dumps({"copy_from": path + "/" + copy2}), ) - _check_created(r, str(contents_dir), path, copy3, type="notebook") + _check_created(r, str(contents_dir), path, copy3) async def test_copy_path(jp_fetch, contents, contents_dir, _check_created): @@ -548,7 +477,7 @@ async def test_copy_path(jp_fetch, contents, contents_dir, _check_created): method="POST", body=json.dumps({"copy_from": path1 + "/" + name}), ) - _check_created(r, str(contents_dir), path2, name, type="notebook") + _check_created(r, str(contents_dir), path2, name) r = await jp_fetch( "api", @@ -557,7 +486,7 @@ async def test_copy_path(jp_fetch, contents, contents_dir, _check_created): method="POST", body=json.dumps({"copy_from": path1 + "/" + name}), ) - _check_created(r, str(contents_dir), path2, copy, type="notebook") + _check_created(r, str(contents_dir), path2, copy) async def test_copy_put_400(jp_fetch, contents, contents_dir, _check_created): @@ -836,53 +765,6 @@ async def test_rename_400_hidden(jp_fetch, jp_base_url, contents, contents_dir): assert expected_http_error(e, 400) -async def test_checkpoints_follow_file(jp_fetch, contents): - path = "foo" - name = "a.ipynb" - - # Read initial file. - r = await jp_fetch("api", "contents", path, name, method="GET") - model = json.loads(r.body.decode()) - - # Create a checkpoint of initial state - r = await jp_fetch( - "api", - "contents", - path, - name, - "checkpoints", - method="POST", - allow_nonstandard_methods=True, - ) - cp1 = json.loads(r.body.decode()) - - # Modify file and save. - nbcontent = model["content"] - nb = from_dict(nbcontent) - hcell = new_markdown_cell("Created by test") - nb.cells.append(hcell) - nbmodel = {"content": nb, "type": "notebook"} - await jp_fetch("api", "contents", path, name, method="PUT", body=json.dumps(nbmodel)) - - # List checkpoints - r = await jp_fetch( - "api", - "contents", - path, - name, - "checkpoints", - method="GET", - ) - cps = json.loads(r.body.decode()) - assert cps == [cp1] - - r = await jp_fetch("api", "contents", path, name, method="GET") - model = json.loads(r.body.decode()) - nbcontent = model["content"] - nb = from_dict(nbcontent) - assert nb.cells[0].source == "Created by test" - - async def test_rename_existing(jp_fetch, contents): with pytest.raises(tornado.httpclient.HTTPClientError) as e: path = "foo" @@ -903,152 +785,12 @@ async def test_rename_existing(jp_fetch, contents): async def test_save(jp_fetch, contents): r = await jp_fetch("api", "contents", "foo/a.ipynb", method="GET") model = json.loads(r.body.decode()) - nbmodel = model["content"] - nb = from_dict(nbmodel) - nb.cells.append(new_markdown_cell("Created by test ³")) - nbmodel = {"content": nb, "type": "notebook"} + nb = model["content"] + nb += "\nCreated by test ³" + nbmodel = {"content": nb, "type": "file"} await jp_fetch("api", "contents", "foo/a.ipynb", method="PUT", body=json.dumps(nbmodel)) # Round trip. r = await jp_fetch("api", "contents", "foo/a.ipynb", method="GET") model = json.loads(r.body.decode()) - newnb = from_dict(model["content"]) - assert newnb.cells[0].source == "Created by test ³" - - -async def test_checkpoints(jp_fetch, contents): - path = "foo/a.ipynb" - resp = await jp_fetch("api", "contents", path, method="GET") - model = json.loads(resp.body.decode()) - r = await jp_fetch( - "api", - "contents", - path, - "checkpoints", - method="POST", - allow_nonstandard_methods=True, - ) - assert r.code == 201 - cp1 = json.loads(r.body.decode()) - assert set(cp1) == {"id", "last_modified"} - assert r.headers["Location"].split("/")[-1] == cp1["id"] - - # Modify it. - nbcontent = model["content"] - nb = from_dict(nbcontent) - hcell = new_markdown_cell("Created by test") - nb.cells.append(hcell) - - # Save it. - nbmodel = {"content": nb, "type": "notebook"} - await jp_fetch("api", "contents", path, method="PUT", body=json.dumps(nbmodel)) - - # List checkpoints - r = await jp_fetch("api", "contents", path, "checkpoints", method="GET") - cps = json.loads(r.body.decode()) - assert cps == [cp1] - - r = await jp_fetch("api", "contents", path, method="GET") - nbcontent = json.loads(r.body.decode())["content"] - nb = from_dict(nbcontent) - assert nb.cells[0].source == "Created by test" - - # Restore Checkpoint cp1 - r = await jp_fetch( - "api", - "contents", - path, - "checkpoints", - cp1["id"], - method="POST", - allow_nonstandard_methods=True, - ) - assert r.code == 204 - - r = await jp_fetch("api", "contents", path, method="GET") - nbcontent = json.loads(r.body.decode())["content"] - nb = from_dict(nbcontent) - assert nb.cells == [] - - # Delete cp1 - r = await jp_fetch("api", "contents", path, "checkpoints", cp1["id"], method="DELETE") - assert r.code == 204 - - r = await jp_fetch("api", "contents", path, "checkpoints", method="GET") - cps = json.loads(r.body.decode()) - assert cps == [] - - -async def test_file_checkpoints(jp_fetch, contents): - path = "foo/a.txt" - resp = await jp_fetch("api", "contents", path, method="GET") - orig_content = json.loads(resp.body.decode())["content"] - r = await jp_fetch( - "api", - "contents", - path, - "checkpoints", - method="POST", - allow_nonstandard_methods=True, - ) - assert r.code == 201 - cp1 = json.loads(r.body.decode()) - assert set(cp1) == {"id", "last_modified"} - assert r.headers["Location"].split("/")[-1] == cp1["id"] - - # Modify it. - new_content = orig_content + "\nsecond line" - model = { - "content": new_content, - "type": "file", - "format": "text", - } - - # Save it. - await jp_fetch("api", "contents", path, method="PUT", body=json.dumps(model)) - - # List checkpoints - r = await jp_fetch("api", "contents", path, "checkpoints", method="GET") - cps = json.loads(r.body.decode()) - assert cps == [cp1] - - r = await jp_fetch("api", "contents", path, method="GET") - content = json.loads(r.body.decode())["content"] - assert content == new_content - - # Restore Checkpoint cp1 - r = await jp_fetch( - "api", - "contents", - path, - "checkpoints", - cp1["id"], - method="POST", - allow_nonstandard_methods=True, - ) - assert r.code == 204 - - r = await jp_fetch("api", "contents", path, method="GET") - restored_content = json.loads(r.body.decode())["content"] - assert restored_content == orig_content - - # Delete cp1 - r = await jp_fetch("api", "contents", path, "checkpoints", cp1["id"], method="DELETE") - assert r.code == 204 - - r = await jp_fetch("api", "contents", path, "checkpoints", method="GET") - cps = json.loads(r.body.decode()) - assert cps == [] - - -async def test_trust(jp_fetch, contents): - # It should be able to trust a notebook that exists - for path in contents["notebooks"]: - r = await jp_fetch( - "api", - "contents", - str(path), - "trust", - method="POST", - allow_nonstandard_methods=True, - ) - assert r.code == 201 + newnb = model["content"] + assert newnb.splitlines()[-1] == "Created by test ³" diff --git a/tests/services/contents/test_manager.py b/tests/services/contents/test_manager.py index d2d06a2513..8d3d7f47de 100644 --- a/tests/services/contents/test_manager.py +++ b/tests/services/contents/test_manager.py @@ -1,12 +1,11 @@ +import json import os import sys import time from itertools import combinations from typing import Dict, Optional, Tuple -from unittest.mock import patch import pytest -from nbformat import ValidationError from nbformat import v4 as nbformat from tornado.web import HTTPError from traitlets import TraitError @@ -66,7 +65,7 @@ def symlink(jp_contents_manager, src, dst): def add_code_cell(notebook): output = nbformat.new_output("display_data", {"application/javascript": "alert('hi');"}) cell = nbformat.new_code_cell("print('hi')", outputs=[output]) - notebook.cells.append(cell) + notebook["cells"].append(cell) def add_invalid_cell(notebook): @@ -85,7 +84,7 @@ async def prepare_notebook( path = model["path"] full_model = await ensure_async(cm.get(path)) - nb = full_model["content"] + nb = json.loads(full_model["content"]) nb["metadata"]["counter"] = int(1e6 * time.time()) if make_invalid: add_invalid_cell(nb) @@ -120,13 +119,9 @@ async def check_populated_dir_files(jp_contents_manager, api_path): for entry in dir_model["content"]: if entry["type"] == "directory": continue - elif entry["type"] == "file": - assert entry["name"] == "file.txt" - complete_path = "/".join([api_path, "file.txt"]) - assert entry["path"] == complete_path - elif entry["type"] == "notebook": - assert entry["name"] == "nb.ipynb" - complete_path = "/".join([api_path, "nb.ipynb"]) + elif entry["type"] in ("file", "notebook"): + name = entry["name"] + complete_path = "/".join([api_path, name]) assert entry["path"] == complete_path @@ -470,7 +465,7 @@ async def test_new_untitled(jp_contents_manager): assert "name" in model assert "path" in model assert "type" in model - assert model["type"] == "notebook" + assert model["type"] == "file" assert model["name"] == "Untitled.ipynb" assert model["path"] == "Untitled.ipynb" @@ -508,7 +503,9 @@ async def test_modified_date(jp_contents_manager): model = await ensure_async(cm.get(path)) # Add a cell and save. - add_code_cell(model["content"]) + nbmodel = json.loads(model["content"]) + add_code_cell(nbmodel) + model["content"] = json.dumps(nbmodel) await ensure_async(cm.save(model, path)) # Reload notebook and verify that last_modified incremented. @@ -834,114 +831,3 @@ async def test_copy(jp_contents_manager): copy3 = await ensure_async(cm.copy(path, "/copy 3.ipynb")) assert copy3["name"] == "copy 3.ipynb" assert copy3["path"] == "copy 3.ipynb" - - -async def test_mark_trusted_cells(jp_contents_manager): - cm = jp_contents_manager - nb, name, path = await new_notebook(cm) - - cm.mark_trusted_cells(nb, path) - for cell in nb.cells: - if cell.cell_type == "code": - assert not cell.metadata.trusted - - await ensure_async(cm.trust_notebook(path)) - nb = (await ensure_async(cm.get(path)))["content"] - for cell in nb.cells: - if cell.cell_type == "code": - assert cell.metadata.trusted - - -async def test_check_and_sign(jp_contents_manager): - cm = jp_contents_manager - nb, name, path = await new_notebook(cm) - - cm.mark_trusted_cells(nb, path) - cm.check_and_sign(nb, path) - assert not cm.notary.check_signature(nb) - - await ensure_async(cm.trust_notebook(path)) - nb = (await ensure_async(cm.get(path)))["content"] - cm.mark_trusted_cells(nb, path) - cm.check_and_sign(nb, path) - assert cm.notary.check_signature(nb) - - -async def test_nb_validation(jp_contents_manager): - # Test that validation is performed once when a notebook is read or written - - model, path = await prepare_notebook(jp_contents_manager, make_invalid=False) - cm = jp_contents_manager - - # We'll use a patch to capture the call count on "nbformat.validate" for the - # successful methods and ensure that calls to the aliased "validate_nb" are - # zero. Note that since patching side-effects the validation error case, we'll - # skip call-count assertions for that portion of the test. - with patch("nbformat.validate") as mock_validate, patch( - "jupyter_server.services.contents.manager.validate_nb" - ) as mock_validate_nb: - # Valid notebook, save, then get - model = await ensure_async(cm.save(model, path)) - assert "message" not in model - assert mock_validate.call_count == 1 - assert mock_validate_nb.call_count == 0 - mock_validate.reset_mock() - mock_validate_nb.reset_mock() - - # Get the notebook and ensure there are no messages - model = await ensure_async(cm.get(path)) - assert "message" not in model - assert mock_validate.call_count == 1 - assert mock_validate_nb.call_count == 0 - mock_validate.reset_mock() - mock_validate_nb.reset_mock() - - # Add invalid cell, save, then get - add_invalid_cell(model["content"]) - - model = await ensure_async(cm.save(model, path)) - assert "message" in model - assert "Notebook validation failed:" in model["message"] - - model = await ensure_async(cm.get(path)) - assert "message" in model - assert "Notebook validation failed:" in model["message"] - - -async def test_validate_notebook_model(jp_contents_manager): - # Test the validation_notebook_model method to ensure that validation is not - # performed when a validation_error dictionary is provided and is performed - # when that parameter is None. - - model, path = await prepare_notebook(jp_contents_manager, make_invalid=False) - cm = jp_contents_manager - - with patch("jupyter_server.services.contents.manager.validate_nb") as mock_validate_nb: - # Valid notebook and a non-None dictionary, no validate call expected - - validation_error: dict = {} - cm.validate_notebook_model(model, validation_error) - assert mock_validate_nb.call_count == 0 - mock_validate_nb.reset_mock() - - # And without the extra parameter, validate call expected - cm.validate_notebook_model(model) - assert mock_validate_nb.call_count == 1 - mock_validate_nb.reset_mock() - - # Now do the same with an invalid model - # invalidate the model... - add_invalid_cell(model["content"]) - - validation_error["ValidationError"] = ValidationError("not a real validation error") - cm.validate_notebook_model(model, validation_error) - assert "Notebook validation failed" in model["message"] - assert mock_validate_nb.call_count == 0 - mock_validate_nb.reset_mock() - model.pop("message") - - # And without the extra parameter, validate call expected. Since patch side-effects - # the patched method, we won't attempt to access the message field. - cm.validate_notebook_model(model) - assert mock_validate_nb.call_count == 1 - mock_validate_nb.reset_mock()