Uploaded Test files

This commit is contained in:
Batuhan Berk Başoğlu 2020-11-12 11:05:57 -05:00
parent f584ad9d97
commit 2e81cb7d99
16627 changed files with 2065359 additions and 102444 deletions

View file

@ -0,0 +1,857 @@
swagger: '2.0'
info:
title: Jupyter Notebook API
description: Notebook API
version: "5"
contact:
name: Jupyter Project
url: https://jupyter.org
# will be prefixed to all paths
basePath: /
produces:
- application/json
consumes:
- application/json
parameters:
kernel:
name: kernel_id
required: true
in: path
description: kernel uuid
type: string
format: uuid
session:
name: session
required: true
in: path
description: session uuid
type: string
format: uuid
path:
name: path
required: true
in: path
description: file path
type: string
checkpoint_id:
name: checkpoint_id
required: true
in: path
description: Checkpoint id for a file
type: string
section_name:
name: section_name
required: true
in: path
description: Name of config section
type: string
terminal_id:
name: terminal_id
required: true
in: path
description: ID of terminal session
type: string
paths:
/api/contents/{path}:
parameters:
- $ref: '#/parameters/path'
get:
summary: Get contents of file or directory
description: "A client can optionally specify a type and/or format argument via URL parameter. When given, the Contents service shall return a model in the requested type and/or format. If the request cannot be satisfied, e.g. type=text is requested, but the file is binary, then the request shall fail with 400 and have a JSON response containing a 'reason' field, with the value 'bad format' or 'bad type', depending on what was requested."
tags:
- contents
parameters:
- name: type
in: query
description: File type ('file', 'directory')
type: string
enum:
- file
- directory
- name: format
in: query
description: "How file content should be returned ('text', 'base64')"
type: string
enum:
- text
- base64
- name: content
in: query
description: "Return content (0 for no content, 1 for return content)"
type: integer
responses:
404:
description: No item found
400:
description: Bad request
schema:
type: object
properties:
error:
type: string
description: Error condition
reason:
type: string
description: Explanation of error reason
200:
description: Contents of file or directory
headers:
Last-Modified:
description: Last modified date for file
type: string
format: dateTime
schema:
$ref: '#/definitions/Contents'
500:
description: Model key error
post:
summary: Create a new file in the specified path
description: "A POST to /api/contents/path creates a New untitled, empty file or directory. A POST to /api/contents/path with body {'copy_from': '/path/to/OtherNotebook.ipynb'} creates a new copy of OtherNotebook in path."
tags:
- contents
parameters:
- name: model
in: body
description: Path of file to copy
schema:
type: object
properties:
copy_from:
type: string
ext:
type: string
type:
type: string
responses:
201:
description: File created
headers:
Location:
description: URL for the new file
type: string
format: url
schema:
$ref: '#/definitions/Contents'
404:
description: No item found
400:
description: Bad request
schema:
type: object
properties:
error:
type: string
description: Error condition
reason:
type: string
description: Explanation of error reason
patch:
summary: Rename a file or directory without re-uploading content
tags:
- contents
parameters:
- name: path
in: body
required: true
description: New path for file or directory.
schema:
type: object
properties:
path:
type: string
format: path
description: New path for file or directory
responses:
200:
description: Path updated
headers:
Location:
description: Updated URL for the file or directory
type: string
format: url
schema:
$ref: '#/definitions/Contents'
400:
description: No data provided
schema:
type: object
properties:
error:
type: string
description: Error condition
reason:
type: string
description: Explanation of error reason
put:
summary: Save or upload file.
description: "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."
tags:
- contents
parameters:
- name: model
in: body
description: New path for file or directory
schema:
type: object
properties:
name:
type: string
description: The new filename if changed
path:
type: string
description: New path for file or directory
type:
type: string
description: Path dtype ('notebook', 'file', 'directory')
format:
type: string
description: File format ('json', 'text', 'base64')
content:
type: string
description: The actual body of the document excluding directory type
responses:
200:
description: File saved
headers:
Location:
description: Updated URL for the file or directory
type: string
format: url
schema:
$ref: '#/definitions/Contents'
201:
description: Path created
headers:
Location:
description: URL for the file or directory
type: string
format: url
schema:
$ref: '#/definitions/Contents'
400:
description: No data provided
schema:
type: object
properties:
error:
type: string
description: Error condition
reason:
type: string
description: Explanation of error reason
delete:
summary: Delete a file in the given path
tags:
- contents
responses:
204:
description: File deleted
headers:
Location:
description: URL for the removed file
type: string
format: url
/api/contents/{path}/checkpoints:
parameters:
- $ref: '#/parameters/path'
get:
summary: Get a list of checkpoints for a file
description: List checkpoints for a given file. There will typically be zero or one results.
tags:
- contents
responses:
404:
description: No item found
400:
description: Bad request
schema:
type: object
properties:
error:
type: string
description: Error condition
reason:
type: string
description: Explanation of error reason
200:
description: List of checkpoints for a file
schema:
type: array
items:
$ref: '#/definitions/Checkpoints'
500:
description: Model key error
post:
summary: Create a new checkpoint for a file
description: "Create a new checkpoint with the current state of a file. With the default FileContentsManager, only one checkpoint is supported, so creating new checkpoints clobbers existing ones."
tags:
- contents
responses:
201:
description: Checkpoint created
headers:
Location:
description: URL for the checkpoint
type: string
format: url
schema:
$ref: '#/definitions/Checkpoints'
404:
description: No item found
400:
description: Bad request
schema:
type: object
properties:
error:
type: string
description: Error condition
reason:
type: string
description: Explanation of error reason
/api/contents/{path}/checkpoints/{checkpoint_id}:
post:
summary: Restore a file to a particular checkpointed state
parameters:
- $ref: "#/parameters/path"
- $ref: "#/parameters/checkpoint_id"
tags:
- contents
responses:
204:
description: Checkpoint restored
400:
description: Bad request
schema:
type: object
properties:
error:
type: string
description: Error condition
reason:
type: string
description: Explanation of error reason
delete:
summary: Delete a checkpoint
parameters:
- $ref: "#/parameters/path"
- $ref: "#/parameters/checkpoint_id"
tags:
- contents
responses:
204:
description: Checkpoint deleted
/api/sessions/{session}:
parameters:
- $ref: '#/parameters/session'
get:
summary: Get session
tags:
- sessions
responses:
200:
description: Session
schema:
$ref: '#/definitions/Session'
patch:
summary: "This can be used to rename the session."
tags:
- sessions
parameters:
- name: model
in: body
required: true
schema:
$ref: '#/definitions/Session'
responses:
200:
description: Session
schema:
$ref: '#/definitions/Session'
400:
description: No data provided
delete:
summary: Delete a session
tags:
- sessions
responses:
204:
description: Session (and kernel) were deleted
410:
description: "Kernel was deleted before the session, and the session was *not* deleted (TODO - check to make sure session wasn't deleted)"
/api/sessions:
get:
summary: List available sessions
tags:
- sessions
responses:
200:
description: List of current sessions
schema:
type: array
items:
$ref: '#/definitions/Session'
post:
summary: "Create a new session, or return an existing session if a session of the same name already exists"
tags:
- sessions
parameters:
- name: session
in: body
schema:
$ref: '#/definitions/Session'
responses:
201:
description: Session created or returned
schema:
$ref: '#/definitions/Session'
headers:
Location:
description: URL for session commands
type: string
format: url
501:
description: Session not available
schema:
type: object
description: error message
properties:
message:
type: string
short_message:
type: string
/api/kernels:
get:
summary: List the JSON data for all kernels that are currently running
tags:
- kernels
responses:
200:
description: List of currently-running kernel uuids
schema:
type: array
items:
$ref: '#/definitions/Kernel'
post:
summary: Start a kernel and return the uuid
tags:
- kernels
parameters:
- name: name
in: body
description: Kernel spec name (defaults to default kernel spec for server)
schema:
type: object
properties:
name:
type: string
responses:
201:
description: Kernel started
schema:
$ref: '#/definitions/Kernel'
headers:
Location:
description: Model for started kernel
type: string
format: url
/api/kernels/{kernel_id}:
parameters:
- $ref: '#/parameters/kernel'
get:
summary: Get kernel information
tags:
- kernels
responses:
200:
description: Kernel information
schema:
$ref: '#/definitions/Kernel'
delete:
summary: Kill a kernel and delete the kernel id
tags:
- kernels
responses:
204:
description: Kernel deleted
/api/kernels/{kernel_id}/interrupt:
parameters:
- $ref: '#/parameters/kernel'
post:
summary: Interrupt a kernel
tags:
- kernels
responses:
204:
description: Kernel interrupted
/api/kernels/{kernel_id}/restart:
parameters:
- $ref: '#/parameters/kernel'
post:
summary: Restart a kernel
tags:
- kernels
responses:
200:
description: Kernel interrupted
headers:
Location:
description: URL for kernel commands
type: string
format: url
schema:
$ref: '#/definitions/Kernel'
/api/kernelspecs:
get:
summary: Get kernel specs
tags:
- kernelspecs
responses:
200:
description: Kernel specs
schema:
type: object
properties:
default:
type: string
description: Default kernel name
kernelspecs:
type: object
additionalProperties:
$ref: '#/definitions/KernelSpec'
/api/config/{section_name}:
get:
summary: Get a configuration section by name
parameters:
- $ref: "#/parameters/section_name"
tags:
- config
responses:
200:
description: Configuration object
schema:
type: object
patch:
summary: Update a configuration section by name
tags:
- config
parameters:
- $ref: "#/parameters/section_name"
- name: configuration
in: body
schema:
type: object
responses:
200:
description: Configuration object
schema:
type: object
/api/terminals:
get:
summary: Get available terminals
tags:
- terminals
responses:
200:
description: A list of all available terminal ids.
schema:
type: array
items:
$ref: '#/definitions/Terminal'
403:
description: Forbidden to access
404:
description: Not found
post:
summary: Create a new terminal
tags:
- terminals
responses:
200:
description: Succesfully created a new terminal
schema:
$ref: '#/definitions/Terminal'
403:
description: Forbidden to access
404:
description: Not found
/api/terminals/{terminal_id}:
get:
summary: Get a terminal session corresponding to an id.
tags:
- terminals
parameters:
- $ref: '#/parameters/terminal_id'
responses:
200:
description: Terminal session with given id
schema:
$ref: '#/definitions/Terminal'
403:
description: Forbidden to access
404:
description: Not found
delete:
summary: Delete a terminal session corresponding to an id.
tags:
- terminals
parameters:
- $ref: '#/parameters/terminal_id'
responses:
204:
description: Succesfully deleted terminal session
403:
description: Forbidden to access
404:
description: Not found
/api/status:
get:
summary: Get the current status/activity of the server.
tags:
- status
responses:
200:
description: The current status of the server
schema:
$ref: '#/definitions/APIStatus'
/api/spec.yaml:
get:
summary: Get the current spec for the notebook server's APIs.
tags:
- api-spec
produces:
- text/x-yaml
responses:
200:
description: The current spec for the notebook server's APIs.
schema:
type: file
definitions:
APIStatus:
description: |
Notebook server API status.
Added in notebook 5.0.
properties:
started:
type: string
description: |
ISO8601 timestamp indicating when the notebook server started.
last_activity:
type: string
description: |
ISO8601 timestamp indicating the last activity on the server,
either on the REST API or kernel activity.
connections:
type: number
description: |
The total number of currently open connections to kernels.
kernels:
type: number
description: |
The total number of running kernels.
KernelSpec:
description: Kernel spec (contents of kernel.json)
properties:
name:
type: string
description: Unique name for kernel
KernelSpecFile:
$ref: '#/definitions/KernelSpecFile'
resources:
type: object
properties:
kernel.js:
type: string
format: filename
description: path for kernel.js file
kernel.css:
type: string
format: filename
description: path for kernel.css file
logo-*:
type: string
format: filename
description: path for logo file. Logo filenames are of the form `logo-widthxheight`
KernelSpecFile:
description: Kernel spec json file
required:
- argv
- display_name
- language
properties:
language:
type: string
description: The programming language which this kernel runs. This will be stored in notebook metadata.
argv:
type: array
description: "A list of command line arguments used to start the kernel. The text `{connection_file}` in any argument will be replaced with the path to the connection file."
items:
type: string
display_name:
type: string
description: "The kernel's name as it should be displayed in the UI. Unlike the kernel name used in the API, this can contain arbitrary unicode characters."
codemirror_mode:
type: string
description: Codemirror mode. Can be a string *or* an valid Codemirror mode object. This defaults to the string from the `language` property.
env:
type: object
description: A dictionary of environment variables to set for the kernel. These will be added to the current environment variables.
additionalProperties:
type: string
help_links:
type: array
description: Help items to be displayed in the help menu in the notebook UI.
items:
type: object
required:
- text
- url
properties:
text:
type: string
description: menu item link text
url:
type: string
format: URL
description: menu item link url
Kernel:
description: Kernel information
required:
- id
- name
properties:
id:
type: string
format: uuid
description: uuid of kernel
name:
type: string
description: kernel spec name
last_activity:
type: string
description: |
ISO 8601 timestamp for the last-seen activity on this kernel.
Use this in combination with execution_state == 'idle' to identify
which kernels have been idle since a given time.
Timestamps will be UTC, indicated 'Z' suffix.
Added in notebook server 5.0.
connections:
type: number
description: |
The number of active connections to this kernel.
execution_state:
type: string
description: |
Current execution state of the kernel (typically 'idle' or 'busy', but may be other values, such as 'starting').
Added in notebook server 5.0.
Session:
description: A session
type: object
properties:
id:
type: string
format: uuid
path:
type: string
description: path to the session
name:
type: string
description: name of the session
type:
type: string
description: session type
kernel:
$ref: '#/definitions/Kernel'
Contents:
description: "A contents object. The content and format keys may be null if content is not contained. If type is 'file', then the mimetype will be null."
type: object
required:
- type
- name
- path
- writable
- created
- last_modified
- mimetype
- format
- content
properties:
name:
type: string
description: "Name of file or directory, equivalent to the last part of the path"
path:
type: string
description: Full path for file or directory
type:
type: string
description: Type of content
enum:
- directory
- file
- notebook
writable:
type: boolean
description: indicates whether the requester has permission to edit the file
created:
type: string
description: Creation timestamp
format: dateTime
last_modified:
type: string
description: Last modified timestamp
format: dateTime
size:
type: integer
description: "The size of the file or notebook in bytes. If no size is provided, defaults to null."
mimetype:
type: string
description: "The mimetype of a file. If content is not null, and type is 'file', this will contain the mimetype of the file, otherwise this will be null."
content:
type: string
description: "The content, if requested (otherwise null). Will be an array if type is 'directory'"
format:
type: string
description: Format of content (one of null, 'text', 'base64', 'json')
Checkpoints:
description: A checkpoint object.
type: object
required:
- id
- last_modified
properties:
id:
type: string
description: Unique id for the checkpoint.
last_modified:
type: string
description: Last modified timestamp
format: dateTime
Terminal:
description: A Terminal object
type: object
required:
- name
properties:
name:
type: string
description: name of terminal
last_activity:
type: string
description: |
ISO 8601 timestamp for the last-seen activity on this terminal. Use
this to identify which terminals have been inactive since a given time.
Timestamps will be UTC, indicated 'Z' suffix.

View file

@ -0,0 +1,56 @@
"""Tornado handlers for api specifications."""
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
import json
import os
from tornado import gen, web
from ...base.handlers import IPythonHandler, APIHandler
from notebook._tz import utcfromtimestamp, isoformat
from notebook.utils import maybe_future
class APISpecHandler(web.StaticFileHandler, IPythonHandler):
def initialize(self):
web.StaticFileHandler.initialize(self, path=os.path.dirname(__file__))
@web.authenticated
def get(self):
self.log.warning("Serving api spec (experimental, incomplete)")
return web.StaticFileHandler.get(self, 'api.yaml')
def get_content_type(self):
return 'text/x-yaml'
class APIStatusHandler(APIHandler):
_track_activity = False
@web.authenticated
@gen.coroutine
def get(self):
# if started was missing, use unix epoch
started = self.settings.get('started', utcfromtimestamp(0))
started = isoformat(started)
kernels = yield maybe_future(self.kernel_manager.list_kernels())
total_connections = sum(k['connections'] for k in kernels)
last_activity = isoformat(self.application.last_activity())
model = {
'started': started,
'last_activity': last_activity,
'kernels': len(kernels),
'connections': total_connections,
}
self.finish(json.dumps(model, sort_keys=True))
default_handlers = [
(r"/api/spec.yaml", APISpecHandler),
(r"/api/status", APIStatusHandler),
]

View file

@ -0,0 +1,47 @@
"""Test the basic /api endpoints"""
from datetime import timedelta
from notebook._tz import isoformat, utcnow
from notebook.utils import url_path_join
from notebook.tests.launchnotebook import NotebookTestBase
class APITest(NotebookTestBase):
"""Test the kernels web service API"""
def _req(self, verb, path, **kwargs):
r = self.request(verb, url_path_join('api', path))
r.raise_for_status()
return r
def get(self, path, **kwargs):
return self._req('GET', path)
def test_get_spec(self):
r = self.get('spec.yaml')
assert r.text
def test_get_status(self):
r = self.get('status')
data = r.json()
assert data['connections'] == 0
assert data['kernels'] == 0
assert data['last_activity'].endswith('Z')
assert data['started'].endswith('Z')
assert data['started'] == isoformat(self.notebook.web_app.settings['started'])
def test_no_track_activity(self):
# initialize with old last api activity
old = utcnow() - timedelta(days=1)
settings = self.notebook.web_app.settings
settings['api_last_activity'] = old
# accessing status doesn't update activity
self.get('status')
assert settings['api_last_activity'] == old
# accessing with ?no_track_activity doesn't update activity
self.get('contents?no_track_activity=1')
assert settings['api_last_activity'] == old
# accessing without ?no_track_activity does update activity
self.get('contents')
assert settings['api_last_activity'] > old

View file

@ -0,0 +1 @@
from .manager import ConfigManager

View file

@ -0,0 +1,39 @@
"""Tornado handlers for frontend config storage."""
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
import json
import os
import io
import errno
from tornado import web
from ...base.handlers import APIHandler
class ConfigHandler(APIHandler):
@web.authenticated
def get(self, section_name):
self.set_header("Content-Type", 'application/json')
self.finish(json.dumps(self.config_manager.get(section_name)))
@web.authenticated
def put(self, section_name):
data = self.get_json_body() # Will raise 400 if content is not valid JSON
self.config_manager.set(section_name, data)
self.set_status(204)
@web.authenticated
def patch(self, section_name):
new_data = self.get_json_body()
section = self.config_manager.update(section_name, new_data)
self.finish(json.dumps(section))
# URL to handler mappings
section_name_regex = r"(?P<section_name>\w+)"
default_handlers = [
(r"/api/config/%s" % section_name_regex, ConfigHandler),
]

View file

@ -0,0 +1,58 @@
"""Manager to read and modify frontend config data in JSON files.
"""
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
import os.path
from notebook.config_manager import BaseJSONConfigManager, recursive_update
from jupyter_core.paths import jupyter_config_dir, jupyter_config_path
from traitlets import Unicode, Instance, List, observe, default
from traitlets.config import LoggingConfigurable
class ConfigManager(LoggingConfigurable):
"""Config Manager used for storing notebook frontend config"""
# Public API
def get(self, section_name):
"""Get the config from all config sections."""
config = {}
# step through back to front, to ensure front of the list is top priority
for p in self.read_config_path[::-1]:
cm = BaseJSONConfigManager(config_dir=p)
recursive_update(config, cm.get(section_name))
return config
def set(self, section_name, data):
"""Set the config only to the user's config."""
return self.write_config_manager.set(section_name, data)
def update(self, section_name, new_data):
"""Update the config only to the user's config."""
return self.write_config_manager.update(section_name, new_data)
# Private API
read_config_path = List(Unicode())
@default('read_config_path')
def _default_read_config_path(self):
return [os.path.join(p, 'nbconfig') for p in jupyter_config_path()]
write_config_dir = Unicode()
@default('write_config_dir')
def _default_write_config_dir(self):
return os.path.join(jupyter_config_dir(), 'nbconfig')
write_config_manager = Instance(BaseJSONConfigManager)
@default('write_config_manager')
def _default_write_config_manager(self):
return BaseJSONConfigManager(config_dir=self.write_config_dir)
@observe('write_config_dir')
def _update_write_config_dir(self, change):
self.write_config_manager = BaseJSONConfigManager(config_dir=self.write_config_dir)

View file

@ -0,0 +1,68 @@
# coding: utf-8
"""Test the config webservice API."""
import json
import requests
from notebook.utils import url_path_join
from notebook.tests.launchnotebook import NotebookTestBase
class ConfigAPI(object):
"""Wrapper for notebook API calls."""
def __init__(self, request):
self.request = request
def _req(self, verb, section, body=None):
response = self.request(verb,
url_path_join('api/config', section),
data=body,
)
response.raise_for_status()
return response
def get(self, section):
return self._req('GET', section)
def set(self, section, values):
return self._req('PUT', section, json.dumps(values))
def modify(self, section, values):
return self._req('PATCH', section, json.dumps(values))
class APITest(NotebookTestBase):
"""Test the config web service API"""
def setUp(self):
self.config_api = ConfigAPI(self.request)
def test_create_retrieve_config(self):
sample = {'foo': 'bar', 'baz': 73}
r = self.config_api.set('example', sample)
self.assertEqual(r.status_code, 204)
r = self.config_api.get('example')
self.assertEqual(r.status_code, 200)
self.assertEqual(r.json(), sample)
def test_modify(self):
sample = {'foo': 'bar', 'baz': 73,
'sub': {'a': 6, 'b': 7}, 'sub2': {'c': 8}}
self.config_api.set('example', sample)
r = self.config_api.modify('example', {'foo': None, # should delete foo
'baz': 75,
'wib': [1,2,3],
'sub': {'a': 8, 'b': None, 'd': 9},
'sub2': {'c': None} # should delete sub2
})
self.assertEqual(r.status_code, 200)
self.assertEqual(r.json(), {'baz': 75, 'wib': [1,2,3],
'sub': {'a': 8, 'd': 9}})
def test_get_unknown(self):
# We should get an empty config dictionary instead of a 404
r = self.config_api.get('nonexistant')
self.assertEqual(r.status_code, 200)
self.assertEqual(r.json(), {})

View file

@ -0,0 +1,142 @@
"""
Classes for managing Checkpoints.
"""
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
from tornado.web import HTTPError
from traitlets.config.configurable import LoggingConfigurable
class Checkpoints(LoggingConfigurable):
"""
Base class for managing checkpoints for a ContentsManager.
Subclasses are required to implement:
create_checkpoint(self, contents_mgr, path)
restore_checkpoint(self, contents_mgr, checkpoint_id, path)
rename_checkpoint(self, checkpoint_id, old_path, new_path)
delete_checkpoint(self, checkpoint_id, path)
list_checkpoints(self, path)
"""
def create_checkpoint(self, contents_mgr, path):
"""Create a checkpoint."""
raise NotImplementedError("must be implemented in a subclass")
def restore_checkpoint(self, contents_mgr, checkpoint_id, path):
"""Restore a checkpoint"""
raise NotImplementedError("must be implemented in a subclass")
def rename_checkpoint(self, checkpoint_id, old_path, new_path):
"""Rename a single checkpoint from old_path to new_path."""
raise NotImplementedError("must be implemented in a subclass")
def delete_checkpoint(self, checkpoint_id, path):
"""delete a checkpoint for a file"""
raise NotImplementedError("must be implemented in a subclass")
def list_checkpoints(self, path):
"""Return a list of checkpoints for a given file"""
raise NotImplementedError("must be implemented in a subclass")
def rename_all_checkpoints(self, old_path, new_path):
"""Rename all checkpoints for old_path to new_path."""
for cp in self.list_checkpoints(old_path):
self.rename_checkpoint(cp['id'], old_path, new_path)
def delete_all_checkpoints(self, path):
"""Delete all checkpoints for the given path."""
for checkpoint in self.list_checkpoints(path):
self.delete_checkpoint(checkpoint['id'], path)
class GenericCheckpointsMixin(object):
"""
Helper for creating Checkpoints subclasses that can be used with any
ContentsManager.
Provides a ContentsManager-agnostic implementation of `create_checkpoint`
and `restore_checkpoint` in terms of the following operations:
- create_file_checkpoint(self, content, format, path)
- create_notebook_checkpoint(self, nb, path)
- get_file_checkpoint(self, checkpoint_id, path)
- get_notebook_checkpoint(self, checkpoint_id, path)
To create a generic CheckpointManager, add this mixin to a class that
implement the above four methods plus the remaining Checkpoints API
methods:
- delete_checkpoint(self, checkpoint_id, path)
- list_checkpoints(self, path)
- rename_checkpoint(self, checkpoint_id, old_path, new_path)
"""
def create_checkpoint(self, contents_mgr, path):
model = contents_mgr.get(path, content=True)
type = model['type']
if type == 'notebook':
return self.create_notebook_checkpoint(
model['content'],
path,
)
elif type == 'file':
return self.create_file_checkpoint(
model['content'],
model['format'],
path,
)
else:
raise HTTPError(500, u'Unexpected type %s' % type)
def restore_checkpoint(self, contents_mgr, checkpoint_id, path):
"""Restore a checkpoint."""
type = contents_mgr.get(path, content=False)['type']
if type == 'notebook':
model = self.get_notebook_checkpoint(checkpoint_id, path)
elif type == 'file':
model = self.get_file_checkpoint(checkpoint_id, path)
else:
raise HTTPError(500, u'Unexpected type %s' % type)
contents_mgr.save(model, path)
# Required Methods
def create_file_checkpoint(self, content, format, path):
"""Create a checkpoint of the current state of a file
Returns a checkpoint model for the new checkpoint.
"""
raise NotImplementedError("must be implemented in a subclass")
def create_notebook_checkpoint(self, nb, path):
"""Create a checkpoint of the current state of a file
Returns a checkpoint model for the new checkpoint.
"""
raise NotImplementedError("must be implemented in a subclass")
def get_file_checkpoint(self, checkpoint_id, path):
"""Get the content of a checkpoint for a non-notebook file.
Returns a dict of the form:
{
'type': 'file',
'content': <str>,
'format': {'text','base64'},
}
"""
raise NotImplementedError("must be implemented in a subclass")
def get_notebook_checkpoint(self, checkpoint_id, path):
"""Get the content of a checkpoint for a notebook.
Returns a dict of the form:
{
'type': 'notebook',
'content': <output of nbformat.read>,
}
"""
raise NotImplementedError("must be implemented in a subclass")

View file

@ -0,0 +1,202 @@
"""
File-based Checkpoints implementations.
"""
import os
import shutil
from tornado.web import HTTPError
from .checkpoints import (
Checkpoints,
GenericCheckpointsMixin,
)
from .fileio import FileManagerMixin
from jupyter_core.utils import ensure_dir_exists
from ipython_genutils.py3compat import getcwd
from traitlets import Unicode
from notebook import _tz as tz
class FileCheckpoints(FileManagerMixin, Checkpoints):
"""
A Checkpoints that caches checkpoints for files in adjacent
directories.
Only works with FileContentsManager. Use GenericFileCheckpoints if
you want file-based checkpoints with another ContentsManager.
"""
checkpoint_dir = Unicode(
'.ipynb_checkpoints',
config=True,
help="""The directory name in which to keep file checkpoints
This is a path relative to the file's own directory.
By default, it is .ipynb_checkpoints
""",
)
root_dir = Unicode(config=True)
def _root_dir_default(self):
try:
return self.parent.root_dir
except AttributeError:
return getcwd()
# ContentsManager-dependent checkpoint API
def create_checkpoint(self, contents_mgr, path):
"""Create a checkpoint."""
checkpoint_id = u'checkpoint'
src_path = contents_mgr._get_os_path(path)
dest_path = self.checkpoint_path(checkpoint_id, path)
self._copy(src_path, dest_path)
return self.checkpoint_model(checkpoint_id, dest_path)
def restore_checkpoint(self, contents_mgr, checkpoint_id, path):
"""Restore a checkpoint."""
src_path = self.checkpoint_path(checkpoint_id, path)
dest_path = contents_mgr._get_os_path(path)
self._copy(src_path, dest_path)
# ContentsManager-independent checkpoint API
def rename_checkpoint(self, checkpoint_id, old_path, new_path):
"""Rename a checkpoint from old_path to new_path."""
old_cp_path = self.checkpoint_path(checkpoint_id, old_path)
new_cp_path = self.checkpoint_path(checkpoint_id, new_path)
if os.path.isfile(old_cp_path):
self.log.debug(
"Renaming checkpoint %s -> %s",
old_cp_path,
new_cp_path,
)
with self.perm_to_403():
shutil.move(old_cp_path, new_cp_path)
def delete_checkpoint(self, checkpoint_id, path):
"""delete a file's checkpoint"""
path = path.strip('/')
cp_path = self.checkpoint_path(checkpoint_id, path)
if not os.path.isfile(cp_path):
self.no_such_checkpoint(path, checkpoint_id)
self.log.debug("unlinking %s", cp_path)
with self.perm_to_403():
os.unlink(cp_path)
def list_checkpoints(self, path):
"""list the checkpoints for a given file
This contents manager currently only supports one checkpoint per file.
"""
path = path.strip('/')
checkpoint_id = "checkpoint"
os_path = self.checkpoint_path(checkpoint_id, path)
if not os.path.isfile(os_path):
return []
else:
return [self.checkpoint_model(checkpoint_id, os_path)]
# Checkpoint-related utilities
def checkpoint_path(self, checkpoint_id, path):
"""find the path to a checkpoint"""
path = path.strip('/')
parent, name = ('/' + path).rsplit('/', 1)
parent = parent.strip('/')
basename, ext = os.path.splitext(name)
filename = u"{name}-{checkpoint_id}{ext}".format(
name=basename,
checkpoint_id=checkpoint_id,
ext=ext,
)
os_path = self._get_os_path(path=parent)
cp_dir = os.path.join(os_path, self.checkpoint_dir)
with self.perm_to_403():
ensure_dir_exists(cp_dir)
cp_path = os.path.join(cp_dir, filename)
return cp_path
def checkpoint_model(self, checkpoint_id, os_path):
"""construct the info dict for a given checkpoint"""
stats = os.stat(os_path)
last_modified = tz.utcfromtimestamp(stats.st_mtime)
info = dict(
id=checkpoint_id,
last_modified=last_modified,
)
return info
# Error Handling
def no_such_checkpoint(self, path, checkpoint_id):
raise HTTPError(
404,
u'Checkpoint does not exist: %s@%s' % (path, checkpoint_id)
)
class GenericFileCheckpoints(GenericCheckpointsMixin, FileCheckpoints):
"""
Local filesystem Checkpoints that works with any conforming
ContentsManager.
"""
def create_file_checkpoint(self, content, format, path):
"""Create a checkpoint from the current content of a file."""
path = path.strip('/')
# only the one checkpoint ID:
checkpoint_id = u"checkpoint"
os_checkpoint_path = self.checkpoint_path(checkpoint_id, path)
self.log.debug("creating checkpoint for %s", path)
with self.perm_to_403():
self._save_file(os_checkpoint_path, content, format=format)
# return the checkpoint info
return self.checkpoint_model(checkpoint_id, os_checkpoint_path)
def create_notebook_checkpoint(self, nb, path):
"""Create a checkpoint from the current content of a notebook."""
path = path.strip('/')
# only the one checkpoint ID:
checkpoint_id = u"checkpoint"
os_checkpoint_path = self.checkpoint_path(checkpoint_id, path)
self.log.debug("creating checkpoint for %s", path)
with self.perm_to_403():
self._save_notebook(os_checkpoint_path, nb)
# return the checkpoint info
return self.checkpoint_model(checkpoint_id, os_checkpoint_path)
def get_notebook_checkpoint(self, checkpoint_id, path):
"""Get a checkpoint for a notebook."""
path = path.strip('/')
self.log.info("restoring %s from checkpoint %s", path, checkpoint_id)
os_checkpoint_path = self.checkpoint_path(checkpoint_id, path)
if not os.path.isfile(os_checkpoint_path):
self.no_such_checkpoint(path, checkpoint_id)
return {
'type': 'notebook',
'content': self._read_notebook(
os_checkpoint_path,
as_version=4,
),
}
def get_file_checkpoint(self, checkpoint_id, path):
"""Get a checkpoint for a file."""
path = path.strip('/')
self.log.info("restoring %s from checkpoint %s", path, checkpoint_id)
os_checkpoint_path = self.checkpoint_path(checkpoint_id, path)
if not os.path.isfile(os_checkpoint_path):
self.no_such_checkpoint(path, checkpoint_id)
content, format = self._read_file(os_checkpoint_path, format=None)
return {
'type': 'file',
'content': content,
'format': format,
}

View file

@ -0,0 +1,341 @@
"""
Utilities for file-based Contents/Checkpoints managers.
"""
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
from contextlib import contextmanager
import errno
import io
import os
import shutil
from tornado.web import HTTPError
from notebook.utils import (
to_api_path,
to_os_path,
)
import nbformat
from ipython_genutils.py3compat import str_to_unicode
from traitlets.config import Configurable
from traitlets import Bool
from base64 import encodebytes, decodebytes
def replace_file(src, dst):
""" replace dst with src
switches between os.replace or os.rename based on python 2.7 or python 3
"""
if hasattr(os, 'replace'): # PY3
os.replace(src, dst)
else:
if os.name == 'nt' and os.path.exists(dst):
# Rename over existing file doesn't work on Windows
os.remove(dst)
os.rename(src, dst)
def copy2_safe(src, dst, log=None):
"""copy src to dst
like shutil.copy2, but log errors in copystat instead of raising
"""
shutil.copyfile(src, dst)
try:
shutil.copystat(src, dst)
except OSError:
if log:
log.debug("copystat on %s failed", dst, exc_info=True)
def path_to_intermediate(path):
'''Name of the intermediate file used in atomic writes.
The .~ prefix will make Dropbox ignore the temporary file.'''
dirname, basename = os.path.split(path)
return os.path.join(dirname, '.~'+basename)
def path_to_invalid(path):
'''Name of invalid file after a failed atomic write and subsequent read.'''
dirname, basename = os.path.split(path)
return os.path.join(dirname, basename+'.invalid')
@contextmanager
def atomic_writing(path, text=True, encoding='utf-8', log=None, **kwargs):
"""Context manager to write to a file only if the entire write is successful.
This works by copying the previous file contents to a temporary file in the
same directory, and renaming that file back to the target if the context
exits with an error. If the context is successful, the new data is synced to
disk and the temporary file is removed.
Parameters
----------
path : str
The target file to write to.
text : bool, optional
Whether to open the file in text mode (i.e. to write unicode). Default is
True.
encoding : str, optional
The encoding to use for files opened in text mode. Default is UTF-8.
**kwargs
Passed to :func:`io.open`.
"""
# realpath doesn't work on Windows: https://bugs.python.org/issue9949
# Luckily, we only need to resolve the file itself being a symlink, not
# any of its directories, so this will suffice:
if os.path.islink(path):
path = os.path.join(os.path.dirname(path), os.readlink(path))
tmp_path = path_to_intermediate(path)
if os.path.isfile(path):
copy2_safe(path, tmp_path, log=log)
if text:
# Make sure that text files have Unix linefeeds by default
kwargs.setdefault('newline', '\n')
fileobj = io.open(path, 'w', encoding=encoding, **kwargs)
else:
fileobj = io.open(path, 'wb', **kwargs)
try:
yield fileobj
except:
# Failed! Move the backup file back to the real path to avoid corruption
fileobj.close()
replace_file(tmp_path, path)
raise
# Flush to disk
fileobj.flush()
os.fsync(fileobj.fileno())
fileobj.close()
# Written successfully, now remove the backup copy
if os.path.isfile(tmp_path):
os.remove(tmp_path)
@contextmanager
def _simple_writing(path, text=True, encoding='utf-8', log=None, **kwargs):
"""Context manager to write file without doing atomic writing
( for weird filesystem eg: nfs).
Parameters
----------
path : str
The target file to write to.
text : bool, optional
Whether to open the file in text mode (i.e. to write unicode). Default is
True.
encoding : str, optional
The encoding to use for files opened in text mode. Default is UTF-8.
**kwargs
Passed to :func:`io.open`.
"""
# realpath doesn't work on Windows: https://bugs.python.org/issue9949
# Luckily, we only need to resolve the file itself being a symlink, not
# any of its directories, so this will suffice:
if os.path.islink(path):
path = os.path.join(os.path.dirname(path), os.readlink(path))
if text:
# Make sure that text files have Unix linefeeds by default
kwargs.setdefault('newline', '\n')
fileobj = io.open(path, 'w', encoding=encoding, **kwargs)
else:
fileobj = io.open(path, 'wb', **kwargs)
try:
yield fileobj
except:
fileobj.close()
raise
fileobj.close()
class FileManagerMixin(Configurable):
"""
Mixin for ContentsAPI classes that interact with the filesystem.
Provides facilities for reading, writing, and copying both notebooks and
generic files.
Shared by FileContentsManager and FileCheckpoints.
Note
----
Classes using this mixin must provide the following attributes:
root_dir : unicode
A directory against against which API-style paths are to be resolved.
log : logging.Logger
"""
use_atomic_writing = Bool(True, config=True, help=
"""By default notebooks are saved on disk on a temporary file and then if successfully written, it replaces the old ones.
This procedure, namely 'atomic_writing', causes some bugs on file system without operation order enforcement (like some networked fs).
If set to False, the new notebook is written directly on the old one which could fail (eg: full filesystem or quota )""")
@contextmanager
def open(self, os_path, *args, **kwargs):
"""wrapper around io.open that turns permission errors into 403"""
with self.perm_to_403(os_path):
with io.open(os_path, *args, **kwargs) as f:
yield f
@contextmanager
def atomic_writing(self, os_path, *args, **kwargs):
"""wrapper around atomic_writing that turns permission errors to 403.
Depending on flag 'use_atomic_writing', the wrapper perform an actual atomic writing or
simply writes the file (whatever an old exists or not)"""
with self.perm_to_403(os_path):
if self.use_atomic_writing:
with atomic_writing(os_path, *args, log=self.log, **kwargs) as f:
yield f
else:
with _simple_writing(os_path, *args, log=self.log, **kwargs) as f:
yield f
@contextmanager
def perm_to_403(self, os_path=''):
"""context manager for turning permission errors into 403."""
try:
yield
except (OSError, IOError) as e:
if e.errno in {errno.EPERM, errno.EACCES}:
# make 403 error message without root prefix
# this may not work perfectly on unicode paths on Python 2,
# but nobody should be doing that anyway.
if not os_path:
os_path = str_to_unicode(e.filename or 'unknown file')
path = to_api_path(os_path, root=self.root_dir)
raise HTTPError(403, u'Permission denied: %s' % path) from e
else:
raise
def _copy(self, src, dest):
"""copy src to dest
like shutil.copy2, but log errors in copystat
"""
copy2_safe(src, dest, log=self.log)
def _get_os_path(self, path):
"""Given an API path, return its file system path.
Parameters
----------
path : string
The relative API path to the named file.
Returns
-------
path : string
Native, absolute OS path to for a file.
Raises
------
404: if path is outside root
"""
root = os.path.abspath(self.root_dir)
os_path = to_os_path(path, root)
if not (os.path.abspath(os_path) + os.path.sep).startswith(root):
raise HTTPError(404, "%s is outside root contents directory" % path)
return os_path
def _read_notebook(self, os_path, as_version=4):
"""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)
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,
u"Unreadable Notebook: %s %r" % (os_path, e_orig),
)
# 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)
def _save_notebook(self, os_path, nb):
"""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)
def _read_file(self, os_path, format):
"""Read a non-notebook file.
os_path: The path to be read.
format:
If 'text', the contents will be decoded as UTF-8.
If 'base64', the raw bytes contents will be encoded as base64.
If not specified, try to decode as UTF-8, and fall back to base64
"""
if not os.path.isfile(os_path):
raise HTTPError(400, "Cannot read non-file %s" % os_path)
with self.open(os_path, 'rb') as f:
bcontent = f.read()
if format is None or format == 'text':
# Try to interpret as unicode if format is unknown or if unicode
# was explicitly requested.
try:
return bcontent.decode('utf8'), 'text'
except UnicodeError as e:
if format == 'text':
raise HTTPError(
400,
"%s is not UTF-8 encoded" % os_path,
reason='bad format',
) from e
return encodebytes(bcontent).decode('ascii'), 'base64'
def _save_file(self, os_path, content, format):
"""Save content of a generic file."""
if format not in {'text', 'base64'}:
raise HTTPError(
400,
"Must specify format of file contents as 'text' or 'base64'",
)
try:
if format == 'text':
bcontent = content.encode('utf8')
else:
b64_bytes = content.encode('ascii')
bcontent = decodebytes(b64_bytes)
except Exception as e:
raise HTTPError(
400, u'Encoding error saving %s: %s' % (os_path, e)
) from e
with self.atomic_writing(os_path, text=False) as f:
f.write(bcontent)

View file

@ -0,0 +1,623 @@
"""A contents manager that uses the local file system for storage."""
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
from datetime import datetime
import errno
import io
import os
import shutil
import stat
import sys
import warnings
import mimetypes
import nbformat
from send2trash import send2trash
from tornado import web
from .filecheckpoints import FileCheckpoints
from .fileio import FileManagerMixin
from .manager import ContentsManager
from ...utils import exists
from ipython_genutils.importstring import import_item
from traitlets import Any, Unicode, Bool, TraitError, observe, default, validate
from ipython_genutils.py3compat import getcwd, string_types
from notebook import _tz as tz
from notebook.utils import (
is_hidden, is_file_hidden,
to_api_path,
)
from notebook.base.handlers import AuthenticatedFileHandler
from notebook.transutils import _
from os.path import samefile
_script_exporter = None
def _post_save_script(model, os_path, contents_manager, **kwargs):
"""convert notebooks to Python script after save with nbconvert
replaces `jupyter notebook --script`
"""
from nbconvert.exporters.script import ScriptExporter
warnings.warn("`_post_save_script` is deprecated and will be removed in Notebook 5.0", DeprecationWarning)
if model['type'] != 'notebook':
return
global _script_exporter
if _script_exporter is None:
_script_exporter = ScriptExporter(parent=contents_manager)
log = contents_manager.log
base, ext = os.path.splitext(os_path)
script, resources = _script_exporter.from_filename(os_path)
script_fname = base + resources.get('output_extension', '.txt')
log.info("Saving script /%s", to_api_path(script_fname, contents_manager.root_dir))
with io.open(script_fname, 'w', encoding='utf-8') as f:
f.write(script)
class FileContentsManager(FileManagerMixin, ContentsManager):
root_dir = Unicode(config=True)
@default('root_dir')
def _default_root_dir(self):
try:
return self.parent.notebook_dir
except AttributeError:
return getcwd()
save_script = Bool(False, config=True, help='DEPRECATED, use post_save_hook. Will be removed in Notebook 5.0')
@observe('save_script')
def _update_save_script(self, change):
if not change['new']:
return
self.log.warning("""
`--script` is deprecated and will be removed in notebook 5.0.
You can trigger nbconvert via pre- or post-save hooks:
ContentsManager.pre_save_hook
FileContentsManager.post_save_hook
A post-save hook has been registered that calls:
jupyter nbconvert --to script [notebook]
which behaves similarly to `--script`.
""")
self.post_save_hook = _post_save_script
post_save_hook = Any(None, config=True, allow_none=True,
help="""Python callable or importstring thereof
to be called on the path of a file just saved.
This can be used to process the file on disk,
such as converting the notebook to a script or HTML via nbconvert.
It will be called as (all arguments passed by keyword)::
hook(os_path=os_path, model=model, contents_manager=instance)
- path: the filesystem path to the file just written
- model: the model representing the file
- contents_manager: this ContentsManager instance
"""
)
@validate('post_save_hook')
def _validate_post_save_hook(self, proposal):
value = proposal['value']
if isinstance(value, string_types):
value = import_item(value)
if not callable(value):
raise TraitError("post_save_hook must be callable")
return value
def run_post_save_hook(self, model, os_path):
"""Run the post-save hook if defined, and log errors"""
if self.post_save_hook:
try:
self.log.debug("Running post-save hook on %s", os_path)
self.post_save_hook(os_path=os_path, model=model, contents_manager=self)
except Exception as e:
self.log.error("Post-save hook failed o-n %s", os_path, exc_info=True)
raise web.HTTPError(500, u'Unexpected error while running post hook save: %s'
% e) from e
@validate('root_dir')
def _validate_root_dir(self, proposal):
"""Do a bit of validation of the root_dir."""
value = proposal['value']
if not os.path.isabs(value):
# If we receive a non-absolute path, make it absolute.
value = os.path.abspath(value)
if not os.path.isdir(value):
raise TraitError("%r is not a directory" % value)
return value
@default('checkpoints_class')
def _checkpoints_class_default(self):
return FileCheckpoints
delete_to_trash = Bool(True, config=True,
help="""If True (default), deleting files will send them to the
platform's trash/recycle bin, where they can be recovered. If False,
deleting files really deletes them.""")
@default('files_handler_class')
def _files_handler_class_default(self):
return AuthenticatedFileHandler
@default('files_handler_params')
def _files_handler_params_default(self):
return {'path': self.root_dir}
def is_hidden(self, path):
"""Does the API style path correspond to a hidden directory or file?
Parameters
----------
path : string
The path to check. This is an API path (`/` separated,
relative to root_dir).
Returns
-------
hidden : bool
Whether the path exists and is hidden.
"""
path = path.strip('/')
os_path = self._get_os_path(path=path)
return is_hidden(os_path, self.root_dir)
def file_exists(self, path):
"""Returns True if the file exists, else returns False.
API-style wrapper for os.path.isfile
Parameters
----------
path : string
The relative path to the file (with '/' as separator)
Returns
-------
exists : bool
Whether the file exists.
"""
path = path.strip('/')
os_path = self._get_os_path(path)
return os.path.isfile(os_path)
def dir_exists(self, path):
"""Does the API-style path refer to an extant directory?
API-style wrapper for os.path.isdir
Parameters
----------
path : string
The path to check. This is an API path (`/` separated,
relative to root_dir).
Returns
-------
exists : bool
Whether the path is indeed a directory.
"""
path = path.strip('/')
os_path = self._get_os_path(path=path)
return os.path.isdir(os_path)
def exists(self, path):
"""Returns True if the path exists, else returns False.
API-style wrapper for os.path.exists
Parameters
----------
path : string
The API path to the file (with '/' as separator)
Returns
-------
exists : bool
Whether the target exists.
"""
path = path.strip('/')
os_path = self._get_os_path(path=path)
return exists(os_path)
def _base_model(self, path):
"""Build the common base of a contents model"""
os_path = self._get_os_path(path)
info = os.lstat(os_path)
try:
# size of file
size = info.st_size
except (ValueError, OSError):
self.log.warning('Unable to get size.')
size = None
try:
last_modified = tz.utcfromtimestamp(info.st_mtime)
except (ValueError, OSError):
# Files can rarely have an invalid timestamp
# https://github.com/jupyter/notebook/issues/2539
# https://github.com/jupyter/notebook/issues/2757
# Use the Unix epoch as a fallback so we don't crash.
self.log.warning('Invalid mtime %s for %s', info.st_mtime, os_path)
last_modified = datetime(1970, 1, 1, 0, 0, tzinfo=tz.UTC)
try:
created = tz.utcfromtimestamp(info.st_ctime)
except (ValueError, OSError): # See above
self.log.warning('Invalid ctime %s for %s', info.st_ctime, os_path)
created = datetime(1970, 1, 1, 0, 0, tzinfo=tz.UTC)
# Create the base model.
model = {}
model['name'] = path.rsplit('/', 1)[-1]
model['path'] = path
model['last_modified'] = last_modified
model['created'] = created
model['content'] = None
model['format'] = None
model['mimetype'] = None
model['size'] = size
try:
model['writable'] = os.access(os_path, os.W_OK)
except OSError:
self.log.error("Failed to check write permissions on %s", os_path)
model['writable'] = False
return model
def _dir_model(self, path, content=True):
"""Build a model for a directory
if content is requested, will include a listing of the directory
"""
os_path = self._get_os_path(path)
four_o_four = u'directory does not exist: %r' % path
if not os.path.isdir(os_path):
raise web.HTTPError(404, four_o_four)
elif is_hidden(os_path, self.root_dir) and not self.allow_hidden:
self.log.info("Refusing to serve hidden directory %r, via 404 Error",
os_path
)
raise web.HTTPError(404, four_o_four)
model = self._base_model(path)
model['type'] = 'directory'
model['size'] = None
if content:
model['content'] = contents = []
os_dir = self._get_os_path(path)
for name in os.listdir(os_dir):
try:
os_path = os.path.join(os_dir, name)
except UnicodeDecodeError as e:
self.log.warning(
"failed to decode filename '%s': %s", name, e)
continue
try:
st = os.lstat(os_path)
except OSError as e:
# skip over broken symlinks in listing
if e.errno == errno.ENOENT:
self.log.warning("%s doesn't exist", os_path)
else:
self.log.warning("Error stat-ing %s: %s", os_path, e)
continue
if (not stat.S_ISLNK(st.st_mode)
and not stat.S_ISREG(st.st_mode)
and not stat.S_ISDIR(st.st_mode)):
self.log.debug("%s not a regular file", os_path)
continue
try:
if self.should_list(name):
if self.allow_hidden or not is_file_hidden(os_path, stat_res=st):
contents.append(
self.get(path='%s/%s' % (path, name), content=False)
)
except OSError as e:
# ELOOP: recursive symlink
if e.errno != errno.ELOOP:
self.log.warning(
"Unknown error checking if file %r is hidden",
os_path,
exc_info=True,
)
model['format'] = 'json'
return model
def _file_model(self, path, content=True, format=None):
"""Build a model for a file
if content is requested, include the file contents.
format:
If 'text', the contents will be decoded as UTF-8.
If 'base64', the raw bytes contents will be encoded as base64.
If not specified, try to decode as UTF-8, and fall back to base64
"""
model = self._base_model(path)
model['type'] = 'file'
os_path = self._get_os_path(path)
model['mimetype'] = mimetypes.guess_type(os_path)[0]
if content:
content, format = self._read_file(os_path, format)
if model['mimetype'] is None:
default_mime = {
'text': 'text/plain',
'base64': 'application/octet-stream'
}[format]
model['mimetype'] = default_mime
model.update(
content=content,
format=format,
)
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:
nb = self._read_notebook(os_path, as_version=4)
self.mark_trusted_cells(nb, path)
model['content'] = nb
model['format'] = 'json'
self.validate_notebook_model(model)
return model
def get(self, path, content=True, type=None, format=None):
""" Takes a path for an entity and returns its model
Parameters
----------
path : str
the API path that describes the relative path for the target
content : bool
Whether to include the contents in the reply
type : str, optional
The requested type - 'file', 'notebook', 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.
Returns
-------
model : dict
the contents model. If content=True, returns the contents
of the file or directory as well.
"""
path = path.strip('/')
if not self.exists(path):
raise web.HTTPError(404, u'No such file or directory: %s' % path)
os_path = self._get_os_path(path)
if os.path.isdir(os_path):
if type not in (None, 'directory'):
raise web.HTTPError(400,
u'%s is a directory, not a %s' % (path, type), 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,
u'%s is not a directory' % path, reason='bad type')
model = self._file_model(path, content=content, format=format)
return model
def _save_directory(self, os_path, model, path=''):
"""create a directory"""
if is_hidden(os_path, self.root_dir) and not self.allow_hidden:
raise web.HTTPError(400, u'Cannot create hidden directory %r' % os_path)
if not os.path.exists(os_path):
with self.perm_to_403():
os.mkdir(os_path)
elif not os.path.isdir(os_path):
raise web.HTTPError(400, u'Not a directory: %s' % (os_path))
else:
self.log.debug("Directory %r already exists", os_path)
def save(self, model, path=''):
"""Save the file model and return the model with no content."""
path = path.strip('/')
if 'type' not in model:
raise web.HTTPError(400, u'No file type provided')
if 'content' not in model and model['type'] != 'directory':
raise web.HTTPError(400, u'No file content provided')
os_path = self._get_os_path(path)
self.log.debug("Saving %s", os_path)
self.run_pre_save_hook(model=model, path=path)
try:
if model['type'] == 'notebook':
nb = nbformat.from_dict(model['content'])
self.check_and_sign(nb, path)
self._save_notebook(os_path, nb)
# One checkpoint should always exist for notebooks.
if not self.checkpoints.list_checkpoints(path):
self.create_checkpoint(path)
elif model['type'] == 'file':
# 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:
raise
except Exception as e:
self.log.error(u'Error while saving file: %s %s', path, e, exc_info=True)
raise web.HTTPError(500, u'Unexpected error while saving file: %s %s' %
(path, e)) from e
validation_message = None
if model['type'] == 'notebook':
self.validate_notebook_model(model)
validation_message = model.get('message', None)
model = self.get(path, content=False)
if validation_message:
model['message'] = validation_message
self.run_post_save_hook(model=model, os_path=os_path)
return model
def delete_file(self, path):
"""Delete file at path."""
path = path.strip('/')
os_path = self._get_os_path(path)
rm = os.unlink
if not os.path.exists(os_path):
raise web.HTTPError(404, u'File or directory does not exist: %s' % os_path)
def _check_trash(os_path):
if sys.platform in {'win32', 'darwin'}:
return True
# It's a bit more nuanced than this, but until we can better
# distinguish errors from send2trash, assume that we can only trash
# files on the same partition as the home directory.
file_dev = os.stat(os_path).st_dev
home_dev = os.stat(os.path.expanduser('~')).st_dev
return file_dev == home_dev
def is_non_empty_dir(os_path):
if os.path.isdir(os_path):
# A directory containing only leftover checkpoints is
# considered empty.
cp_dir = getattr(self.checkpoints, 'checkpoint_dir', None)
if set(os.listdir(os_path)) - {cp_dir}:
return True
return False
if self.delete_to_trash:
if sys.platform == 'win32' and is_non_empty_dir(os_path):
# send2trash can really delete files on Windows, so disallow
# deleting non-empty files. See Github issue 3631.
raise web.HTTPError(400, u'Directory %s not empty' % os_path)
if _check_trash(os_path):
self.log.debug("Sending %s to trash", os_path)
# Looking at the code in send2trash, I don't think the errors it
# raises let us distinguish permission errors from other errors in
# code. So for now, just let them all get logged as server errors.
send2trash(os_path)
return
else:
self.log.warning("Skipping trash for %s, on different device "
"to home directory", os_path)
if os.path.isdir(os_path):
# Don't permanently delete non-empty directories.
if is_non_empty_dir(os_path):
raise web.HTTPError(400, u'Directory %s not empty' % os_path)
self.log.debug("Removing directory %s", os_path)
with self.perm_to_403():
shutil.rmtree(os_path)
else:
self.log.debug("Unlinking file %s", os_path)
with self.perm_to_403():
rm(os_path)
def rename_file(self, old_path, new_path):
"""Rename a file."""
old_path = old_path.strip('/')
new_path = new_path.strip('/')
if new_path == old_path:
return
# Perform path validation prior to converting to os-specific value since this
# is still relative to root_dir.
self._validate_path(new_path)
new_os_path = self._get_os_path(new_path)
old_os_path = self._get_os_path(old_path)
# Should we proceed with the move?
if os.path.exists(new_os_path) and not samefile(old_os_path, new_os_path):
raise web.HTTPError(409, u'File already exists: %s' % new_path)
# Move the file
try:
with self.perm_to_403():
shutil.move(old_os_path, new_os_path)
except web.HTTPError:
raise
except Exception as e:
raise web.HTTPError(500, u'Unknown error renaming file: %s %s' %
(old_path, e)) from e
def info_string(self):
return _("Serving notebooks from local directory: %s") % self.root_dir
def get_kernel_path(self, path, model=None):
"""Return the initial API path of a kernel associated with a given notebook"""
if self.dir_exists(path):
return path
if '/' in path:
parent_dir = path.rsplit('/', 1)[0]
else:
parent_dir = ''
return parent_dir
@staticmethod
def _validate_path(path):
"""Checks if the path contains invalid characters relative to the current platform"""
if sys.platform == 'win32':
# On Windows systems, we MUST disallow colons otherwise an Alternative Data Stream will
# be created and confusion will reign! (See https://github.com/jupyter/notebook/issues/5190)
# Go ahead and add other invalid (and non-path-separator) characters here as well so there's
# consistent behavior - although all others will result in '[Errno 22]Invalid Argument' errors.
invalid_chars = '?:><*"|'
else:
# On non-windows systems, allow the underlying file creation to perform enforcement when appropriate
invalid_chars = ''
for char in invalid_chars:
if char in path:
raise web.HTTPError(400, "Path '{}' contains characters that are invalid for the filesystem. "
"Path names on this filesystem cannot contain any of the following "
"characters: {}".format(path, invalid_chars))

View file

@ -0,0 +1,327 @@
"""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 tornado import gen, web
from notebook.utils import maybe_future, url_path_join, url_escape
from jupyter_client.jsonutil import date_default
from notebook.base.handlers import (
IPythonHandler, APIHandler, path_regex,
)
def validate_model(model, expect_content):
"""
Validate a model returned by a ContentsManager method.
If expect_content is True, then we expect non-null entries for 'content'
and 'format'.
"""
required_keys = {
"name",
"path",
"type",
"writable",
"created",
"last_modified",
"mimetype",
"content",
"format",
}
missing = required_keys - set(model.keys())
if missing:
raise web.HTTPError(
500,
u"Missing Model Keys: {missing}".format(missing=missing),
)
maybe_none_keys = ['content', 'format']
if expect_content:
errors = [key for key in maybe_none_keys if model[key] is None]
if errors:
raise web.HTTPError(
500,
u"Keys unexpectedly None: {keys}".format(keys=errors),
)
else:
errors = {
key: model[key]
for key in maybe_none_keys
if model[key] is not None
}
if errors:
raise web.HTTPError(
500,
u"Keys unexpectedly not None: {keys}".format(keys=errors),
)
class ContentsHandler(APIHandler):
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=date_default))
@web.authenticated
@gen.coroutine
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 ''
type = self.get_query_argument('type', default=None)
if type not in {None, 'directory', 'file', 'notebook'}:
raise web.HTTPError(400, u'Type %r is invalid' % type)
format = self.get_query_argument('format', default=None)
if format not in {None, 'text', 'base64'}:
raise web.HTTPError(400, u'Format %r is invalid' % format)
content = self.get_query_argument('content', default='1')
if content not in {'0', '1'}:
raise web.HTTPError(400, u'Content %r is invalid' % content)
content = int(content)
model = yield maybe_future(self.contents_manager.get(
path=path, type=type, format=format, content=content,
))
validate_model(model, expect_content=content)
self._finish_model(model, location=False)
@web.authenticated
@gen.coroutine
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, u'JSON body missing')
model = yield maybe_future(cm.update(model, path))
validate_model(model, expect_content=False)
self._finish_model(model)
@gen.coroutine
def _copy(self, copy_from, copy_to=None):
"""Copy a file, optionally specifying a target directory."""
self.log.info(u"Copying {copy_from} to {copy_to}".format(
copy_from=copy_from,
copy_to=copy_to or '',
))
model = yield maybe_future(self.contents_manager.copy(copy_from, copy_to))
self.set_status(201)
validate_model(model, expect_content=False)
self._finish_model(model)
@gen.coroutine
def _upload(self, model, path):
"""Handle upload of a new file to path"""
self.log.info(u"Uploading file to %s", path)
model = yield maybe_future(self.contents_manager.new(model, path))
self.set_status(201)
validate_model(model, expect_content=False)
self._finish_model(model)
@gen.coroutine
def _new_untitled(self, path, type='', ext=''):
"""Create a new, empty untitled entity"""
self.log.info(u"Creating new %s in %s", type or 'file', path)
model = yield maybe_future(self.contents_manager.new_untitled(path=path, type=type, ext=ext))
self.set_status(201)
validate_model(model, expect_content=False)
self._finish_model(model)
@gen.coroutine
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(u"Saving file at %s", path)
model = yield maybe_future(self.contents_manager.save(model, path))
validate_model(model, expect_content=False)
self._finish_model(model)
@web.authenticated
@gen.coroutine
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 = yield maybe_future(cm.file_exists(path))
if file_exists:
raise web.HTTPError(400, "Cannot POST to files, use PUT instead.")
dir_exists = yield maybe_future(cm.dir_exists(path))
if not dir_exists:
raise web.HTTPError(404, "No such directory: %s" % path)
model = self.get_json_body()
if model is not None:
copy_from = model.get('copy_from')
ext = model.get('ext', '')
type = model.get('type', '')
if copy_from:
yield self._copy(copy_from, path)
else:
yield self._new_untitled(path, type=type, ext=ext)
else:
yield self._new_untitled(path)
@web.authenticated
@gen.coroutine
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()
if model:
if model.get('copy_from'):
raise web.HTTPError(400, "Cannot copy with PUT, only POST")
exists = yield maybe_future(self.contents_manager.file_exists(path))
if exists:
yield maybe_future(self._save(model, path))
else:
yield maybe_future(self._upload(model, path))
else:
yield maybe_future(self._new_untitled(path))
@web.authenticated
@gen.coroutine
def delete(self, path=''):
"""delete a file in the given path"""
cm = self.contents_manager
self.log.warning('delete %s', path)
yield maybe_future(cm.delete(path))
self.set_status(204)
self.finish()
class CheckpointsHandler(APIHandler):
@web.authenticated
@gen.coroutine
def get(self, path=''):
"""get lists checkpoints for a file"""
cm = self.contents_manager
checkpoints = yield maybe_future(cm.list_checkpoints(path))
data = json.dumps(checkpoints, default=date_default)
self.finish(data)
@web.authenticated
@gen.coroutine
def post(self, path=''):
"""post creates a new checkpoint"""
cm = self.contents_manager
checkpoint = yield maybe_future(cm.create_checkpoint(path))
data = json.dumps(checkpoint, default=date_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(APIHandler):
@web.authenticated
@gen.coroutine
def post(self, path, checkpoint_id):
"""post restores a file from a checkpoint"""
cm = self.contents_manager
yield maybe_future(cm.restore_checkpoint(checkpoint_id, path))
self.set_status(204)
self.finish()
@web.authenticated
@gen.coroutine
def delete(self, path, checkpoint_id):
"""delete clears a checkpoint for a given file"""
cm = self.contents_manager
yield maybe_future(cm.delete_checkpoint(checkpoint_id, path))
self.set_status(204)
self.finish()
class NotebooksRedirectHandler(IPythonHandler):
"""Redirect /api/notebooks to /api/contents"""
SUPPORTED_METHODS = ('GET', 'PUT', 'PATCH', 'POST', 'DELETE')
def get(self, path):
self.log.warning("/api/notebooks is deprecated, use /api/contents")
self.redirect(url_path_join(
self.base_url,
'api/contents',
path
))
put = patch = post = delete = get
class TrustNotebooksHandler(IPythonHandler):
""" Handles trust/signing of notebooks """
@web.authenticated
@gen.coroutine
def post(self,path=''):
cm = self.contents_manager
yield maybe_future(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),
(r"/api/contents%s/checkpoints/%s" % (path_regex, _checkpoint_id_regex),
ModifyCheckpointsHandler),
(r"/api/contents%s/trust" % path_regex, TrustNotebooksHandler),
(r"/api/contents%s" % path_regex, ContentsHandler),
(r"/api/notebooks/?(.*)", NotebooksRedirectHandler),
]

View file

@ -0,0 +1,70 @@
from notebook.services.contents.filemanager import FileContentsManager
from contextlib import contextmanager
from tornado import web
import nbformat
import base64
import os, io
class LargeFileManager(FileContentsManager):
"""Handle large file upload."""
def save(self, model, path=''):
"""Save the file model and return the model with no content."""
chunk = model.get('chunk', None)
if chunk is not None:
path = path.strip('/')
if 'type' not in model:
raise web.HTTPError(400, u'No file type provided')
if model['type'] != 'file':
raise web.HTTPError(400, u'File type "{}" is not supported for large file transfer'.format(model['type']))
if 'content' not in model and model['type'] != 'directory':
raise web.HTTPError(400, u'No file content provided')
os_path = self._get_os_path(path)
try:
if chunk == 1:
self.log.debug("Saving %s", os_path)
self.run_pre_save_hook(model=model, path=path)
super(LargeFileManager, self)._save_file(os_path, model['content'], model.get('format'))
else:
self._save_large_file(os_path, model['content'], model.get('format'))
except web.HTTPError:
raise
except Exception as e:
self.log.error(u'Error while saving file: %s %s', path, e, exc_info=True)
raise web.HTTPError(500, u'Unexpected error while saving file: %s %s' % (path, e)) from e
model = self.get(path, content=False)
# Last chunk
if chunk == -1:
self.run_post_save_hook(model=model, os_path=os_path)
return model
else:
return super(LargeFileManager, self).save(model, path)
def _save_large_file(self, os_path, content, format):
"""Save content of a generic file."""
if format not in {'text', 'base64'}:
raise web.HTTPError(
400,
"Must specify format of file contents as 'text' or 'base64'",
)
try:
if format == 'text':
bcontent = content.encode('utf8')
else:
b64_bytes = content.encode('ascii')
bcontent = base64.b64decode(b64_bytes)
except Exception as e:
raise web.HTTPError(
400, u'Encoding error saving %s: %s' % (os_path, e)
) from e
with self.perm_to_403(os_path):
if os.path.islink(os_path):
os_path = os.path.join(os.path.dirname(os_path), os.readlink(os_path))
with io.open(os_path, 'ab') as f:
f.write(bcontent)

View file

@ -0,0 +1,532 @@
"""A base class for contents managers."""
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
from fnmatch import fnmatch
import itertools
import json
import os
import re
from tornado.web import HTTPError, RequestHandler
from ...files.handlers import FilesHandler
from .checkpoints import Checkpoints
from traitlets.config.configurable import LoggingConfigurable
from nbformat import sign, validate as validate_nb, ValidationError
from nbformat.v4 import new_notebook
from ipython_genutils.importstring import import_item
from traitlets import (
Any,
Bool,
Dict,
Instance,
List,
TraitError,
Type,
Unicode,
validate,
default,
)
from ipython_genutils.py3compat import string_types
from notebook.base.handlers import IPythonHandler
from notebook.transutils import _
copy_pat = re.compile(r'\-Copy\d*\.')
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.
Most APIs take a path argument,
which is always an API-style unicode path,
and always refers to a directory.
- unicode, not url-escaped
- '/'-separated
- leading and trailing '/' will be stripped
- if unspecified, path defaults to '',
indicating the root path.
"""
root_dir = Unicode('/', config=True)
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(), [
u'__pycache__', '*.pyc', '*.pyo',
'.DS_Store', '*.so', '*.dylib', '*~',
], config=True, help="""
Glob patterns to hide in file and directory listings.
""")
untitled_notebook = Unicode(_("Untitled"), config=True,
help="The base name used when creating untitled notebooks."
)
untitled_file = Unicode("untitled", config=True,
help="The base name used when creating untitled files."
)
untitled_directory = Unicode("Untitled Folder", config=True,
help="The base name used when creating untitled directories."
)
pre_save_hook = Any(None, config=True, allow_none=True,
help="""Python callable or importstring thereof
To be called on a contents model prior to save.
This can be used to process the structure,
such as removing notebook outputs or other side effects that
should not be saved.
It will be called as (all arguments passed by keyword)::
hook(path=path, model=model, contents_manager=self)
- model: the model to be saved. Includes file contents.
Modifying this dict will affect the file that is stored.
- path: the API path of the save destination
- contents_manager: this ContentsManager instance
"""
)
@validate('pre_save_hook')
def _validate_pre_save_hook(self, proposal):
value = proposal['value']
if isinstance(value, string_types):
value = import_item(self.pre_save_hook)
if not callable(value):
raise TraitError("pre_save_hook must be callable")
return value
def run_pre_save_hook(self, model, path, **kwargs):
"""Run the pre-save hook if defined, and log errors"""
if self.pre_save_hook:
try:
self.log.debug("Running pre-save hook on %s", path)
self.pre_save_hook(model=model, path=path, contents_manager=self, **kwargs)
except Exception:
self.log.error("Pre-save hook failed on %s", path, exc_info=True)
checkpoints_class = Type(Checkpoints, config=True)
checkpoints = Instance(Checkpoints, config=True)
checkpoints_kwargs = Dict(config=True)
@default('checkpoints')
def _default_checkpoints(self):
return self.checkpoints_class(**self.checkpoints_kwargs)
@default('checkpoints_kwargs')
def _default_checkpoints_kwargs(self):
return dict(
parent=self,
log=self.log,
)
files_handler_class = Type(
FilesHandler, klass=RequestHandler, allow_none=True, config=True,
help="""handler class to use when serving raw file requests.
Default is a fallback that talks to the ContentsManager API,
which may be inefficient, especially for large files.
Local files-based ContentsManagers can use a StaticFileHandler subclass,
which will be much more efficient.
Access to these files should be Authenticated.
"""
)
files_handler_params = Dict(
config=True,
help="""Extra parameters to pass to files_handler_class.
For example, StaticFileHandlers generally expect a `path` argument
specifying the root directory from which to serve files.
"""
)
def get_extra_handlers(self):
"""Return additional handlers
Default: self.files_handler_class on /files/.*
"""
handlers = []
if self.files_handler_class:
handlers.append(
(r"/files/(.*)", self.files_handler_class, self.files_handler_params)
)
return handlers
# ContentsManager API part 1: methods that must be
# implemented in subclasses.
def dir_exists(self, path):
"""Does a directory exist at the given path?
Like os.path.isdir
Override this method in subclasses.
Parameters
----------
path : string
The path to check
Returns
-------
exists : bool
Whether the path does indeed exist.
"""
raise NotImplementedError
def is_hidden(self, path):
"""Is path a hidden directory or file?
Parameters
----------
path : string
The path to check. This is an API path (`/` separated,
relative to root dir).
Returns
-------
hidden : bool
Whether the path is hidden.
"""
raise NotImplementedError
def file_exists(self, path=''):
"""Does a file exist at the given path?
Like os.path.isfile
Override this method in subclasses.
Parameters
----------
path : string
The API path of a file to check for.
Returns
-------
exists : bool
Whether the file exists.
"""
raise NotImplementedError('must be implemented in a subclass')
def exists(self, path):
"""Does a file or directory exist at the given path?
Like os.path.exists
Parameters
----------
path : string
The API path of a file or directory to check for.
Returns
-------
exists : bool
Whether the target exists.
"""
return self.file_exists(path) or self.dir_exists(path)
def get(self, path, content=True, type=None, format=None):
"""Get a file or directory model."""
raise NotImplementedError('must be implemented in a subclass')
def save(self, model, path):
"""
Save a file or directory model to path.
Should return the saved model with no content. Save implementations
should call self.run_pre_save_hook(model=model, path=path) prior to
writing any data.
"""
raise NotImplementedError('must be implemented in a subclass')
def delete_file(self, path):
"""Delete the file or directory at path."""
raise NotImplementedError('must be implemented in a subclass')
def rename_file(self, old_path, new_path):
"""Rename a file or directory."""
raise NotImplementedError('must be implemented in a subclass')
# ContentsManager API part 2: methods that have useable default
# implementations, but can be overridden in subclasses.
def delete(self, path):
"""Delete a file/directory and any associated checkpoints."""
path = path.strip('/')
if not path:
raise HTTPError(400, "Can't delete root")
self.delete_file(path)
self.checkpoints.delete_all_checkpoints(path)
def rename(self, old_path, new_path):
"""Rename a file and any checkpoints associated with that file."""
self.rename_file(old_path, new_path)
self.checkpoints.rename_all_checkpoints(old_path, new_path)
def update(self, model, path):
"""Update the file's path
For use in PATCH requests, to enable renaming a file without
re-uploading its contents. Only used for renaming at the moment.
"""
path = path.strip('/')
new_path = model.get('path', path).strip('/')
if path != new_path:
self.rename(path, new_path)
model = self.get(new_path, content=False)
return model
def info_string(self):
return "Serving contents"
def get_kernel_path(self, path, model=None):
"""Return the API path for the kernel
KernelManagers can turn this value into a filesystem path,
or ignore it altogether.
The default value here will start kernels in the directory of the
notebook server. FileContentsManager overrides this to use the
directory containing the notebook.
"""
return ''
def increment_filename(self, filename, path='', insert=''):
"""Increment a filename until it is unique.
Parameters
----------
filename : unicode
The name of a file, including extension
path : unicode
The API path of the target's directory
insert: unicode
The characters to insert after the base filename
Returns
-------
name : unicode
A filename that is unique, based on the input filename.
"""
# Extract the full suffix from the filename (e.g. .tar.gz)
path = path.strip('/')
basename, dot, ext = filename.rpartition('.')
if ext != 'ipynb':
basename, dot, ext = filename.partition('.')
suffix = dot + ext
for i in itertools.count():
if i:
insert_i = '{}{}'.format(insert, i)
else:
insert_i = ''
name = u'{basename}{insert}{suffix}'.format(basename=basename,
insert=insert_i, suffix=suffix)
if not self.exists(u'{}/{}'.format(path, name)):
break
return name
def validate_notebook_model(self, model):
"""Add failed-validation message to model"""
try:
validate_nb(model['content'])
except ValidationError as e:
model['message'] = u'Notebook validation failed: {}:\n{}'.format(
e.message, json.dumps(e.instance, indent=1, default=lambda obj: '<UNKNOWN>'),
)
return model
def new_untitled(self, path='', type='', ext=''):
"""Create a new untitled file or directory in path
path must be a directory
File extension can be specified.
Use `new` to create files with a fully specified path (including filename).
"""
path = path.strip('/')
if not self.dir_exists(path):
raise HTTPError(404, 'No such directory: %s' % path)
model = {}
if type:
model['type'] = type
if ext == '.ipynb':
model.setdefault('type', 'notebook')
else:
model.setdefault('type', 'file')
insert = ''
if model['type'] == 'directory':
untitled = self.untitled_directory
insert = ' '
elif model['type'] == 'notebook':
untitled = self.untitled_notebook
ext = '.ipynb'
elif model['type'] == 'file':
untitled = self.untitled_file
else:
raise HTTPError(400, "Unexpected model type: %r" % model['type'])
name = self.increment_filename(untitled + ext, path, insert=insert)
path = u'{0}/{1}'.format(path, name)
return self.new(model, path)
def new(self, model=None, path=''):
"""Create a new file or directory and return its model with no content.
To create a new untitled entity in a directory, use `new_untitled`.
"""
path = path.strip('/')
if model is None:
model = {}
if path.endswith('.ipynb'):
model.setdefault('type', 'notebook')
else:
model.setdefault('type', 'file')
# no content, not a directory, so fill out new-file model
if 'content' not in model and model['type'] != 'directory':
if model['type'] == 'notebook':
model['content'] = new_notebook()
model['format'] = 'json'
else:
model['content'] = ''
model['type'] = 'file'
model['format'] = 'text'
model = self.save(model, path)
return model
def copy(self, from_path, to_path=None):
"""Copy an existing file and return its new model.
If to_path not specified, it will be the parent directory of from_path.
If to_path is a directory, filename will increment `from_path-Copy#.ext`.
Considering multi-part extensions, the Copy# part will be placed before the first dot for all the extensions except `ipynb`.
For easier manual searching in case of notebooks, the Copy# part will be placed before the last dot.
from_path must be a full path to a file.
"""
path = from_path.strip('/')
if to_path is not None:
to_path = to_path.strip('/')
if '/' in path:
from_dir, from_name = path.rsplit('/', 1)
else:
from_dir = ''
from_name = path
model = self.get(path)
model.pop('path', None)
model.pop('name', None)
if model['type'] == 'directory':
raise HTTPError(400, "Can't copy directories")
if to_path is None:
to_path = from_dir
if self.dir_exists(to_path):
name = copy_pat.sub(u'.', from_name)
to_name = self.increment_filename(name, to_path, insert='-Copy')
to_path = u'{0}/{1}'.format(to_path, to_name)
model = self.save(model, to_path)
return model
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)
# Part 3: Checkpoints API
def create_checkpoint(self, path):
"""Create a checkpoint."""
return self.checkpoints.create_checkpoint(self, path)
def restore_checkpoint(self, checkpoint_id, path):
"""
Restore a checkpoint.
"""
self.checkpoints.restore_checkpoint(self, checkpoint_id, path)
def list_checkpoints(self, path):
return self.checkpoints.list_checkpoints(path)
def delete_checkpoint(self, checkpoint_id, path):
return self.checkpoints.delete_checkpoint(checkpoint_id, path)

View file

@ -0,0 +1,723 @@
# coding: utf-8
"""Test the contents webservice API."""
from contextlib import contextmanager
from functools import partial
import io
import json
import os
import shutil
import sys
from unicodedata import normalize
pjoin = os.path.join
import requests
from ..filecheckpoints import GenericFileCheckpoints
from traitlets.config import Config
from notebook.utils import url_path_join, url_escape, to_os_path
from notebook.tests.launchnotebook import NotebookTestBase, assert_http_error
from nbformat import write, from_dict
from nbformat.v4 import (
new_notebook, new_markdown_cell,
)
from nbformat import v2
from ipython_genutils import py3compat
from ipython_genutils.tempdir import TemporaryDirectory
try: #PY3
from base64 import encodebytes, decodebytes
except ImportError: #PY2
from base64 import encodestring as encodebytes, decodestring as decodebytes
def uniq_stable(elems):
"""uniq_stable(elems) -> list
Return from an iterable, a list of all the unique elements in the input,
maintaining the order in which they first appear.
"""
seen = set()
return [x for x in elems if x not in seen and not seen.add(x)]
def notebooks_only(dir_model):
return [nb for nb in dir_model['content'] if nb['type']=='notebook']
def dirs_only(dir_model):
return [x for x in dir_model['content'] if x['type']=='directory']
class API(object):
"""Wrapper for contents API calls."""
def __init__(self, request):
self.request = request
def _req(self, verb, path, body=None, params=None):
response = self.request(verb,
url_path_join('api/contents', path),
data=body, params=params,
)
response.raise_for_status()
return response
def list(self, path='/'):
return self._req('GET', path)
def read(self, path, type=None, format=None, content=None):
params = {}
if type is not None:
params['type'] = type
if format is not None:
params['format'] = format
if content == False:
params['content'] = '0'
return self._req('GET', path, params=params)
def create_untitled(self, path='/', ext='.ipynb'):
body = None
if ext:
body = json.dumps({'ext': ext})
return self._req('POST', path, body)
def mkdir_untitled(self, path='/'):
return self._req('POST', path, json.dumps({'type': 'directory'}))
def copy(self, copy_from, path='/'):
body = json.dumps({'copy_from':copy_from})
return self._req('POST', path, body)
def create(self, path='/'):
return self._req('PUT', path)
def upload(self, path, body):
return self._req('PUT', path, body)
def mkdir(self, path='/'):
return self._req('PUT', path, json.dumps({'type': 'directory'}))
def copy_put(self, copy_from, path='/'):
body = json.dumps({'copy_from':copy_from})
return self._req('PUT', path, body)
def save(self, path, body):
return self._req('PUT', path, body)
def delete(self, path='/'):
return self._req('DELETE', path)
def rename(self, path, new_path):
body = json.dumps({'path': new_path})
return self._req('PATCH', path, body)
def get_checkpoints(self, path):
return self._req('GET', url_path_join(path, 'checkpoints'))
def new_checkpoint(self, path):
return self._req('POST', url_path_join(path, 'checkpoints'))
def restore_checkpoint(self, path, checkpoint_id):
return self._req('POST', url_path_join(path, 'checkpoints', checkpoint_id))
def delete_checkpoint(self, path, checkpoint_id):
return self._req('DELETE', url_path_join(path, 'checkpoints', checkpoint_id))
class APITest(NotebookTestBase):
"""Test the kernels web service API"""
dirs_nbs = [('', 'inroot'),
('Directory with spaces in', 'inspace'),
(u'unicodé', 'innonascii'),
('foo', 'a'),
('foo', 'b'),
('foo', 'name with spaces'),
('foo', u'unicodé'),
('foo/bar', 'baz'),
('ordering', 'A'),
('ordering', 'b'),
('ordering', 'C'),
(u'å b', u'ç d'),
]
hidden_dirs = ['.hidden', '__pycache__']
# Don't include root dir.
dirs = uniq_stable([py3compat.cast_unicode(d) for (d,n) in dirs_nbs[1:]])
top_level_dirs = {normalize('NFC', d.split('/')[0]) for d in dirs}
@staticmethod
def _blob_for_name(name):
return name.encode('utf-8') + b'\xFF'
@staticmethod
def _txt_for_name(name):
return u'%s text file' % name
def to_os_path(self, api_path):
return to_os_path(api_path, root=self.notebook_dir)
def make_dir(self, api_path):
"""Create a directory at api_path"""
os_path = self.to_os_path(api_path)
try:
os.makedirs(os_path)
except OSError:
print("Directory already exists: %r" % os_path)
def make_txt(self, api_path, txt):
"""Make a text file at a given api_path"""
os_path = self.to_os_path(api_path)
with io.open(os_path, 'w', encoding='utf-8') as f:
f.write(txt)
def make_blob(self, api_path, blob):
"""Make a binary file at a given api_path"""
os_path = self.to_os_path(api_path)
with io.open(os_path, 'wb') as f:
f.write(blob)
def make_nb(self, api_path, nb):
"""Make a notebook file at a given api_path"""
os_path = self.to_os_path(api_path)
with io.open(os_path, 'w', encoding='utf-8') as f:
write(nb, f, version=4)
def delete_dir(self, api_path):
"""Delete a directory at api_path, removing any contents."""
os_path = self.to_os_path(api_path)
shutil.rmtree(os_path, ignore_errors=True)
def delete_file(self, api_path):
"""Delete a file at the given path if it exists."""
if self.isfile(api_path):
os.unlink(self.to_os_path(api_path))
def isfile(self, api_path):
return os.path.isfile(self.to_os_path(api_path))
def isdir(self, api_path):
return os.path.isdir(self.to_os_path(api_path))
def setUp(self):
for d in (self.dirs + self.hidden_dirs):
self.make_dir(d)
self.addCleanup(partial(self.delete_dir, d))
for d, name in self.dirs_nbs:
# create a notebook
nb = new_notebook()
nbname = u'{}/{}.ipynb'.format(d, name)
self.make_nb(nbname, nb)
self.addCleanup(partial(self.delete_file, nbname))
# create a text file
txt = self._txt_for_name(name)
txtname = u'{}/{}.txt'.format(d, name)
self.make_txt(txtname, txt)
self.addCleanup(partial(self.delete_file, txtname))
blob = self._blob_for_name(name)
blobname = u'{}/{}.blob'.format(d, name)
self.make_blob(blobname, blob)
self.addCleanup(partial(self.delete_file, blobname))
self.api = API(self.request)
def test_list_notebooks(self):
nbs = notebooks_only(self.api.list().json())
self.assertEqual(len(nbs), 1)
self.assertEqual(nbs[0]['name'], 'inroot.ipynb')
nbs = notebooks_only(self.api.list('/Directory with spaces in/').json())
self.assertEqual(len(nbs), 1)
self.assertEqual(nbs[0]['name'], 'inspace.ipynb')
nbs = notebooks_only(self.api.list(u'/unicodé/').json())
self.assertEqual(len(nbs), 1)
self.assertEqual(nbs[0]['name'], 'innonascii.ipynb')
self.assertEqual(nbs[0]['path'], u'unicodé/innonascii.ipynb')
nbs = notebooks_only(self.api.list('/foo/bar/').json())
self.assertEqual(len(nbs), 1)
self.assertEqual(nbs[0]['name'], 'baz.ipynb')
self.assertEqual(nbs[0]['path'], 'foo/bar/baz.ipynb')
nbs = notebooks_only(self.api.list('foo').json())
self.assertEqual(len(nbs), 4)
nbnames = { normalize('NFC', n['name']) for n in nbs }
expected = [ u'a.ipynb', u'b.ipynb', u'name with spaces.ipynb', u'unicodé.ipynb']
expected = { normalize('NFC', name) for name in expected }
self.assertEqual(nbnames, expected)
nbs = notebooks_only(self.api.list('ordering').json())
nbnames = {n['name'] for n in nbs}
expected = {'A.ipynb', 'b.ipynb', 'C.ipynb'}
self.assertEqual(nbnames, expected)
def test_list_dirs(self):
dirs = dirs_only(self.api.list().json())
dir_names = {normalize('NFC', d['name']) for d in dirs}
self.assertEqual(dir_names, self.top_level_dirs) # Excluding hidden dirs
def test_get_dir_no_content(self):
for d in self.dirs:
model = self.api.read(d, content=False).json()
self.assertEqual(model['path'], d)
self.assertEqual(model['type'], 'directory')
self.assertIn('content', model)
self.assertEqual(model['content'], None)
def test_list_nonexistant_dir(self):
with assert_http_error(404):
self.api.list('nonexistant')
def test_get_nb_contents(self):
for d, name in self.dirs_nbs:
path = url_path_join(d, name + '.ipynb')
nb = self.api.read(path).json()
self.assertEqual(nb['name'], u'%s.ipynb' % name)
self.assertEqual(nb['path'], path)
self.assertEqual(nb['type'], 'notebook')
self.assertIn('content', nb)
self.assertEqual(nb['format'], 'json')
self.assertIn('metadata', nb['content'])
self.assertIsInstance(nb['content']['metadata'], dict)
def test_get_nb_no_content(self):
for d, name in self.dirs_nbs:
path = url_path_join(d, name + '.ipynb')
nb = self.api.read(path, content=False).json()
self.assertEqual(nb['name'], u'%s.ipynb' % name)
self.assertEqual(nb['path'], path)
self.assertEqual(nb['type'], 'notebook')
self.assertIn('content', nb)
self.assertEqual(nb['content'], None)
def test_get_nb_invalid(self):
nb = {
'nbformat': 4,
'metadata': {},
'cells': [{
'cell_type': 'wrong',
'metadata': {},
}],
}
path = u'å b/Validate tést.ipynb'
self.make_txt(path, py3compat.cast_unicode(json.dumps(nb)))
model = self.api.read(path).json()
self.assertEqual(model['path'], path)
self.assertEqual(model['type'], 'notebook')
self.assertIn('content', model)
self.assertIn('message', model)
self.assertIn("validation failed", model['message'].lower())
def test_get_contents_no_such_file(self):
# Name that doesn't exist - should be a 404
with assert_http_error(404):
self.api.read('foo/q.ipynb')
def test_get_text_file_contents(self):
for d, name in self.dirs_nbs:
path = url_path_join(d, name + '.txt')
model = self.api.read(path).json()
self.assertEqual(model['name'], u'%s.txt' % name)
self.assertEqual(model['path'], path)
self.assertIn('content', model)
self.assertEqual(model['format'], 'text')
self.assertEqual(model['type'], 'file')
self.assertEqual(model['content'], self._txt_for_name(name))
# Name that doesn't exist - should be a 404
with assert_http_error(404):
self.api.read('foo/q.txt')
# Specifying format=text should fail on a non-UTF-8 file
with assert_http_error(400):
self.api.read('foo/bar/baz.blob', type='file', format='text')
def test_get_binary_file_contents(self):
for d, name in self.dirs_nbs:
path = url_path_join(d, name + '.blob')
model = self.api.read(path).json()
self.assertEqual(model['name'], u'%s.blob' % name)
self.assertEqual(model['path'], path)
self.assertIn('content', model)
self.assertEqual(model['format'], 'base64')
self.assertEqual(model['type'], 'file')
self.assertEqual(
decodebytes(model['content'].encode('ascii')),
self._blob_for_name(name),
)
# Name that doesn't exist - should be a 404
with assert_http_error(404):
self.api.read('foo/q.txt')
def test_get_bad_type(self):
with assert_http_error(400):
self.api.read(u'unicodé', type='file') # this is a directory
with assert_http_error(400):
self.api.read(u'unicodé/innonascii.ipynb', type='directory')
def _check_created(self, resp, path, type='notebook'):
self.assertEqual(resp.status_code, 201)
location_header = py3compat.str_to_unicode(resp.headers['Location'])
self.assertEqual(location_header, url_path_join(self.url_prefix, u'api/contents', url_escape(path)))
rjson = resp.json()
self.assertEqual(rjson['name'], path.rsplit('/', 1)[-1])
self.assertEqual(rjson['path'], path)
self.assertEqual(rjson['type'], type)
isright = self.isdir if type == 'directory' else self.isfile
assert isright(path)
def test_create_untitled(self):
resp = self.api.create_untitled(path=u'å b')
self._check_created(resp, u'å b/Untitled.ipynb')
# Second time
resp = self.api.create_untitled(path=u'å b')
self._check_created(resp, u'å b/Untitled1.ipynb')
# And two directories down
resp = self.api.create_untitled(path='foo/bar')
self._check_created(resp, 'foo/bar/Untitled.ipynb')
def test_create_untitled_txt(self):
resp = self.api.create_untitled(path='foo/bar', ext='.txt')
self._check_created(resp, 'foo/bar/untitled.txt', type='file')
resp = self.api.read(path='foo/bar/untitled.txt')
model = resp.json()
self.assertEqual(model['type'], 'file')
self.assertEqual(model['format'], 'text')
self.assertEqual(model['content'], '')
def test_upload(self):
nb = new_notebook()
nbmodel = {'content': nb, 'type': 'notebook'}
path = u'å b/Upload tést.ipynb'
resp = self.api.upload(path, body=json.dumps(nbmodel))
self._check_created(resp, path)
def test_mkdir_untitled(self):
resp = self.api.mkdir_untitled(path=u'å b')
self._check_created(resp, u'å b/Untitled Folder', type='directory')
# Second time
resp = self.api.mkdir_untitled(path=u'å b')
self._check_created(resp, u'å b/Untitled Folder 1', type='directory')
# And two directories down
resp = self.api.mkdir_untitled(path='foo/bar')
self._check_created(resp, 'foo/bar/Untitled Folder', type='directory')
def test_mkdir(self):
path = u'å b/New ∂ir'
resp = self.api.mkdir(path)
self._check_created(resp, path, type='directory')
def test_mkdir_hidden_400(self):
with assert_http_error(400):
resp = self.api.mkdir(u'å b/.hidden')
def test_upload_txt(self):
body = u'ünicode téxt'
model = {
'content' : body,
'format' : 'text',
'type' : 'file',
}
path = u'å b/Upload tést.txt'
resp = self.api.upload(path, body=json.dumps(model))
# check roundtrip
resp = self.api.read(path)
model = resp.json()
self.assertEqual(model['type'], 'file')
self.assertEqual(model['format'], 'text')
self.assertEqual(model['content'], body)
def test_upload_b64(self):
body = b'\xFFblob'
b64body = encodebytes(body).decode('ascii')
model = {
'content' : b64body,
'format' : 'base64',
'type' : 'file',
}
path = u'å b/Upload tést.blob'
resp = self.api.upload(path, body=json.dumps(model))
# check roundtrip
resp = self.api.read(path)
model = resp.json()
self.assertEqual(model['type'], 'file')
self.assertEqual(model['path'], path)
self.assertEqual(model['format'], 'base64')
decoded = decodebytes(model['content'].encode('ascii'))
self.assertEqual(decoded, body)
def test_upload_v2(self):
nb = v2.new_notebook()
ws = v2.new_worksheet()
nb.worksheets.append(ws)
ws.cells.append(v2.new_code_cell(input='print("hi")'))
nbmodel = {'content': nb, 'type': 'notebook'}
path = u'å b/Upload tést.ipynb'
resp = self.api.upload(path, body=json.dumps(nbmodel))
self._check_created(resp, path)
resp = self.api.read(path)
data = resp.json()
self.assertEqual(data['content']['nbformat'], 4)
def test_copy(self):
resp = self.api.copy(u'å b/ç d.ipynb', u'å b')
self._check_created(resp, u'å b/ç d-Copy1.ipynb')
resp = self.api.copy(u'å b/ç d.ipynb', u'å b')
self._check_created(resp, u'å b/ç d-Copy2.ipynb')
def test_copy_copy(self):
resp = self.api.copy(u'å b/ç d.ipynb', u'å b')
self._check_created(resp, u'å b/ç d-Copy1.ipynb')
resp = self.api.copy(u'å b/ç d-Copy1.ipynb', u'å b')
self._check_created(resp, u'å b/ç d-Copy2.ipynb')
def test_copy_path(self):
resp = self.api.copy(u'foo/a.ipynb', u'å b')
self._check_created(resp, u'å b/a.ipynb')
resp = self.api.copy(u'foo/a.ipynb', u'å b')
self._check_created(resp, u'å b/a-Copy1.ipynb')
def test_copy_put_400(self):
with assert_http_error(400):
resp = self.api.copy_put(u'å b/ç d.ipynb', u'å b/cøpy.ipynb')
def test_copy_dir_400(self):
# can't copy directories
with assert_http_error(400):
resp = self.api.copy(u'å b', u'foo')
def test_delete(self):
for d, name in self.dirs_nbs:
print('%r, %r' % (d, name))
resp = self.api.delete(url_path_join(d, name + '.ipynb'))
self.assertEqual(resp.status_code, 204)
for d in self.dirs + ['/']:
nbs = notebooks_only(self.api.list(d).json())
print('------')
print(d)
print(nbs)
self.assertEqual(nbs, [])
def test_delete_dirs(self):
# depth-first delete everything, so we don't try to delete empty directories
for name in sorted(self.dirs + ['/'], key=len, reverse=True):
listing = self.api.list(name).json()['content']
for model in listing:
self.api.delete(model['path'])
listing = self.api.list('/').json()['content']
self.assertEqual(listing, [])
def test_delete_non_empty_dir(self):
if sys.platform == 'win32':
self.skipTest("Disabled deleting non-empty dirs on Windows")
# Test that non empty directory can be deleted
self.api.delete(u'å b')
# Check if directory has actually been deleted
with assert_http_error(404):
self.api.list(u'å b')
def test_rename(self):
resp = self.api.rename('foo/a.ipynb', 'foo/z.ipynb')
self.assertEqual(resp.headers['Location'].split('/')[-1], 'z.ipynb')
self.assertEqual(resp.json()['name'], 'z.ipynb')
self.assertEqual(resp.json()['path'], 'foo/z.ipynb')
assert self.isfile('foo/z.ipynb')
nbs = notebooks_only(self.api.list('foo').json())
nbnames = set(n['name'] for n in nbs)
self.assertIn('z.ipynb', nbnames)
self.assertNotIn('a.ipynb', nbnames)
def test_checkpoints_follow_file(self):
# Read initial file state
orig = self.api.read('foo/a.ipynb')
# Create a checkpoint of initial state
r = self.api.new_checkpoint('foo/a.ipynb')
cp1 = r.json()
# Modify file and save
nbcontent = json.loads(orig.text)['content']
nb = from_dict(nbcontent)
hcell = new_markdown_cell('Created by test')
nb.cells.append(hcell)
nbmodel = {'content': nb, 'type': 'notebook'}
self.api.save('foo/a.ipynb', body=json.dumps(nbmodel))
# Rename the file.
self.api.rename('foo/a.ipynb', 'foo/z.ipynb')
# Looking for checkpoints in the old location should yield no results.
self.assertEqual(self.api.get_checkpoints('foo/a.ipynb').json(), [])
# Looking for checkpoints in the new location should work.
cps = self.api.get_checkpoints('foo/z.ipynb').json()
self.assertEqual(cps, [cp1])
# Delete the file. The checkpoint should be deleted as well.
self.api.delete('foo/z.ipynb')
cps = self.api.get_checkpoints('foo/z.ipynb').json()
self.assertEqual(cps, [])
def test_rename_existing(self):
with assert_http_error(409):
self.api.rename('foo/a.ipynb', 'foo/b.ipynb')
def test_save(self):
resp = self.api.read('foo/a.ipynb')
nbcontent = json.loads(resp.text)['content']
nb = from_dict(nbcontent)
nb.cells.append(new_markdown_cell(u'Created by test ³'))
nbmodel = {'content': nb, 'type': 'notebook'}
resp = self.api.save('foo/a.ipynb', body=json.dumps(nbmodel))
nbcontent = self.api.read('foo/a.ipynb').json()['content']
newnb = from_dict(nbcontent)
self.assertEqual(newnb.cells[0].source,
u'Created by test ³')
def test_checkpoints(self):
resp = self.api.read('foo/a.ipynb')
r = self.api.new_checkpoint('foo/a.ipynb')
self.assertEqual(r.status_code, 201)
cp1 = r.json()
self.assertEqual(set(cp1), {'id', 'last_modified'})
self.assertEqual(r.headers['Location'].split('/')[-1], cp1['id'])
# Modify it
nbcontent = json.loads(resp.text)['content']
nb = from_dict(nbcontent)
hcell = new_markdown_cell('Created by test')
nb.cells.append(hcell)
# Save
nbmodel= {'content': nb, 'type': 'notebook'}
resp = self.api.save('foo/a.ipynb', body=json.dumps(nbmodel))
# List checkpoints
cps = self.api.get_checkpoints('foo/a.ipynb').json()
self.assertEqual(cps, [cp1])
nbcontent = self.api.read('foo/a.ipynb').json()['content']
nb = from_dict(nbcontent)
self.assertEqual(nb.cells[0].source, 'Created by test')
# Restore cp1
r = self.api.restore_checkpoint('foo/a.ipynb', cp1['id'])
self.assertEqual(r.status_code, 204)
nbcontent = self.api.read('foo/a.ipynb').json()['content']
nb = from_dict(nbcontent)
self.assertEqual(nb.cells, [])
# Delete cp1
r = self.api.delete_checkpoint('foo/a.ipynb', cp1['id'])
self.assertEqual(r.status_code, 204)
cps = self.api.get_checkpoints('foo/a.ipynb').json()
self.assertEqual(cps, [])
def test_file_checkpoints(self):
"""
Test checkpointing of non-notebook files.
"""
filename = 'foo/a.txt'
resp = self.api.read(filename)
orig_content = json.loads(resp.text)['content']
# Create a checkpoint.
r = self.api.new_checkpoint(filename)
self.assertEqual(r.status_code, 201)
cp1 = r.json()
self.assertEqual(set(cp1), {'id', 'last_modified'})
self.assertEqual(r.headers['Location'].split('/')[-1], cp1['id'])
# Modify the file and save.
new_content = orig_content + '\nsecond line'
model = {
'content': new_content,
'type': 'file',
'format': 'text',
}
resp = self.api.save(filename, body=json.dumps(model))
# List checkpoints
cps = self.api.get_checkpoints(filename).json()
self.assertEqual(cps, [cp1])
content = self.api.read(filename).json()['content']
self.assertEqual(content, new_content)
# Restore cp1
r = self.api.restore_checkpoint(filename, cp1['id'])
self.assertEqual(r.status_code, 204)
restored_content = self.api.read(filename).json()['content']
self.assertEqual(restored_content, orig_content)
# Delete cp1
r = self.api.delete_checkpoint(filename, cp1['id'])
self.assertEqual(r.status_code, 204)
cps = self.api.get_checkpoints(filename).json()
self.assertEqual(cps, [])
@contextmanager
def patch_cp_root(self, dirname):
"""
Temporarily patch the root dir of our checkpoint manager.
"""
cpm = self.notebook.contents_manager.checkpoints
old_dirname = cpm.root_dir
cpm.root_dir = dirname
try:
yield
finally:
cpm.root_dir = old_dirname
def test_checkpoints_separate_root(self):
"""
Test that FileCheckpoints functions correctly even when it's
using a different root dir from FileContentsManager. This also keeps
the implementation honest for use with ContentsManagers that don't map
models to the filesystem
Override this method to a no-op when testing other managers.
"""
with TemporaryDirectory() as td:
with self.patch_cp_root(td):
self.test_checkpoints()
with TemporaryDirectory() as td:
with self.patch_cp_root(td):
self.test_file_checkpoints()
class GenericFileCheckpointsAPITest(APITest):
"""
Run the tests from APITest with GenericFileCheckpoints.
"""
config = Config()
config.FileContentsManager.checkpoints_class = GenericFileCheckpoints
def test_config_did_something(self):
self.assertIsInstance(
self.notebook.contents_manager.checkpoints,
GenericFileCheckpoints,
)

View file

@ -0,0 +1,131 @@
# encoding: utf-8
"""Tests for file IO"""
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
import io as stdlib_io
import os.path
import stat
import nose.tools as nt
from ipython_genutils.testing.decorators import skip_win32
from ..fileio import atomic_writing
from ipython_genutils.tempdir import TemporaryDirectory
umask = 0
def test_atomic_writing():
class CustomExc(Exception): pass
with TemporaryDirectory() as td:
f1 = os.path.join(td, 'penguin')
with stdlib_io.open(f1, 'w') as f:
f.write(u'Before')
if os.name != 'nt':
os.chmod(f1, 0o701)
orig_mode = stat.S_IMODE(os.stat(f1).st_mode)
f2 = os.path.join(td, 'flamingo')
try:
os.symlink(f1, f2)
have_symlink = True
except (AttributeError, NotImplementedError, OSError):
# AttributeError: Python doesn't support it
# NotImplementedError: The system doesn't support it
# OSError: The user lacks the privilege (Windows)
have_symlink = False
with nt.assert_raises(CustomExc):
with atomic_writing(f1) as f:
f.write(u'Failing write')
raise CustomExc
# Because of the exception, the file should not have been modified
with stdlib_io.open(f1, 'r') as f:
nt.assert_equal(f.read(), u'Before')
with atomic_writing(f1) as f:
f.write(u'Overwritten')
with stdlib_io.open(f1, 'r') as f:
nt.assert_equal(f.read(), u'Overwritten')
if os.name != 'nt':
mode = stat.S_IMODE(os.stat(f1).st_mode)
nt.assert_equal(mode, orig_mode)
if have_symlink:
# Check that writing over a file preserves a symlink
with atomic_writing(f2) as f:
f.write(u'written from symlink')
with stdlib_io.open(f1, 'r') as f:
nt.assert_equal(f.read(), u'written from symlink')
def _save_umask():
global umask
umask = os.umask(0)
os.umask(umask)
def _restore_umask():
os.umask(umask)
@skip_win32
@nt.with_setup(_save_umask, _restore_umask)
def test_atomic_writing_umask():
with TemporaryDirectory() as td:
os.umask(0o022)
f1 = os.path.join(td, '1')
with atomic_writing(f1) as f:
f.write(u'1')
mode = stat.S_IMODE(os.stat(f1).st_mode)
nt.assert_equal(mode, 0o644, '{:o} != 644'.format(mode))
os.umask(0o057)
f2 = os.path.join(td, '2')
with atomic_writing(f2) as f:
f.write(u'2')
mode = stat.S_IMODE(os.stat(f2).st_mode)
nt.assert_equal(mode, 0o620, '{:o} != 620'.format(mode))
def test_atomic_writing_newlines():
with TemporaryDirectory() as td:
path = os.path.join(td, 'testfile')
lf = u'a\nb\nc\n'
plat = lf.replace(u'\n', os.linesep)
crlf = lf.replace(u'\n', u'\r\n')
# test default
with stdlib_io.open(path, 'w') as f:
f.write(lf)
with stdlib_io.open(path, 'r', newline='') as f:
read = f.read()
nt.assert_equal(read, plat)
# test newline=LF
with stdlib_io.open(path, 'w', newline='\n') as f:
f.write(lf)
with stdlib_io.open(path, 'r', newline='') as f:
read = f.read()
nt.assert_equal(read, lf)
# test newline=CRLF
with atomic_writing(path, newline='\r\n') as f:
f.write(lf)
with stdlib_io.open(path, 'r', newline='') as f:
read = f.read()
nt.assert_equal(read, crlf)
# test newline=no convert
text = u'crlf\r\ncr\rlf\n'
with atomic_writing(path, newline='') as f:
f.write(text)
with stdlib_io.open(path, 'r', newline='') as f:
read = f.read()
nt.assert_equal(read, text)

View file

@ -0,0 +1,113 @@
from unittest import TestCase
from ipython_genutils.tempdir import TemporaryDirectory
from ..largefilemanager import LargeFileManager
import os
from tornado import web
def _make_dir(contents_manager, api_path):
"""
Make a directory.
"""
os_path = contents_manager._get_os_path(api_path)
try:
os.makedirs(os_path)
except OSError:
print("Directory already exists: %r" % os_path)
class TestLargeFileManager(TestCase):
def setUp(self):
self._temp_dir = TemporaryDirectory()
self.td = self._temp_dir.name
self.contents_manager = LargeFileManager(root_dir=self.td)
def make_dir(self, api_path):
"""make a subdirectory at api_path
override in subclasses if contents are not on the filesystem.
"""
_make_dir(self.contents_manager, api_path)
def test_save(self):
cm = self.contents_manager
# Create a notebook
model = cm.new_untitled(type='notebook')
name = model['name']
path = model['path']
# Get the model with 'content'
full_model = cm.get(path)
# Save the notebook
model = cm.save(full_model, path)
assert isinstance(model, dict)
self.assertIn('name', model)
self.assertIn('path', model)
self.assertEqual(model['name'], name)
self.assertEqual(model['path'], path)
try:
model = {'name': 'test', 'path': 'test', 'chunk': 1}
cm.save(model, model['path'])
except web.HTTPError as e:
self.assertEqual('HTTP 400: Bad Request (No file type provided)', str(e))
try:
model = {'name': 'test', 'path': 'test', 'chunk': 1, 'type': 'notebook'}
cm.save(model, model['path'])
except web.HTTPError as e:
self.assertEqual('HTTP 400: Bad Request (File type "notebook" is not supported for large file transfer)', str(e))
try:
model = {'name': 'test', 'path': 'test', 'chunk': 1, 'type': 'file'}
cm.save(model, model['path'])
except web.HTTPError as e:
self.assertEqual('HTTP 400: Bad Request (No file content provided)', str(e))
try:
model = {'name': 'test', 'path': 'test', 'chunk': 2, 'type': 'file',
'content': u'test', 'format': 'json'}
cm.save(model, model['path'])
except web.HTTPError as e:
self.assertEqual("HTTP 400: Bad Request (Must specify format of file contents as 'text' or 'base64')",
str(e))
# Save model for different chunks
model = {'name': 'test', 'path': 'test', 'type': 'file',
'content': u'test==', 'format': 'text'}
name = model['name']
path = model['path']
cm.save(model, path)
for chunk in (1, 2, -1):
for fm in ('text', 'base64'):
full_model = cm.get(path)
full_model['chunk'] = chunk
full_model['format'] = fm
model_res = cm.save(full_model, path)
assert isinstance(model_res, dict)
self.assertIn('name', model_res)
self.assertIn('path', model_res)
self.assertNotIn('chunk', model_res)
self.assertEqual(model_res['name'], name)
self.assertEqual(model_res['path'], path)
# Test in sub-directory
# Create a directory and notebook in that directory
sub_dir = '/foo/'
self.make_dir('foo')
model = cm.new_untitled(path=sub_dir, type='notebook')
name = model['name']
path = model['path']
model = cm.get(path)
# Change the name in the model for rename
model = cm.save(model, path)
assert isinstance(model, dict)
self.assertIn('name', model)
self.assertIn('path', model)
self.assertEqual(model['name'], 'Untitled.ipynb')
self.assertEqual(model['path'], 'foo/Untitled.ipynb')

View file

@ -0,0 +1,667 @@
# coding: utf-8
"""Tests for the notebook manager."""
from __future__ import print_function
import os
import sys
import time
from contextlib import contextmanager
from itertools import combinations
from nose import SkipTest
from tornado.web import HTTPError
from unittest import TestCase
from tempfile import NamedTemporaryFile
from nbformat import v4 as nbformat
from ipython_genutils.tempdir import TemporaryDirectory
from traitlets import TraitError
from ipython_genutils.testing import decorators as dec
from ..filemanager import FileContentsManager
def _make_dir(contents_manager, api_path):
"""
Make a directory.
"""
os_path = contents_manager._get_os_path(api_path)
try:
os.makedirs(os_path)
except OSError:
print("Directory already exists: %r" % os_path)
class TestFileContentsManager(TestCase):
@contextmanager
def assertRaisesHTTPError(self, status, msg=None):
msg = msg or "Should have raised HTTPError(%i)" % status
try:
yield
except HTTPError as e:
self.assertEqual(e.status_code, status)
else:
self.fail(msg)
def symlink(self, contents_manager, src, dst):
"""Make a symlink to src from dst
src and dst are api_paths
"""
src_os_path = contents_manager._get_os_path(src)
dst_os_path = contents_manager._get_os_path(dst)
print(src_os_path, dst_os_path, os.path.isfile(src_os_path))
os.symlink(src_os_path, dst_os_path)
def test_root_dir(self):
with TemporaryDirectory() as td:
fm = FileContentsManager(root_dir=td)
self.assertEqual(fm.root_dir, td)
def test_missing_root_dir(self):
with TemporaryDirectory() as td:
root = os.path.join(td, 'notebook', 'dir', 'is', 'missing')
self.assertRaises(TraitError, FileContentsManager, root_dir=root)
def test_invalid_root_dir(self):
with NamedTemporaryFile() as tf:
self.assertRaises(TraitError, FileContentsManager, root_dir=tf.name)
def test_get_os_path(self):
# full filesystem path should be returned with correct operating system
# separators.
with TemporaryDirectory() as td:
root = td
fm = FileContentsManager(root_dir=root)
path = fm._get_os_path('/path/to/notebook/test.ipynb')
rel_path_list = '/path/to/notebook/test.ipynb'.split('/')
fs_path = os.path.join(fm.root_dir, *rel_path_list)
self.assertEqual(path, fs_path)
fm = FileContentsManager(root_dir=root)
path = fm._get_os_path('test.ipynb')
fs_path = os.path.join(fm.root_dir, 'test.ipynb')
self.assertEqual(path, fs_path)
fm = FileContentsManager(root_dir=root)
path = fm._get_os_path('////test.ipynb')
fs_path = os.path.join(fm.root_dir, 'test.ipynb')
self.assertEqual(path, fs_path)
def test_checkpoint_subdir(self):
subd = u'sub ∂ir'
cp_name = 'test-cp.ipynb'
with TemporaryDirectory() as td:
root = td
os.mkdir(os.path.join(td, subd))
fm = FileContentsManager(root_dir=root)
cpm = fm.checkpoints
cp_dir = cpm.checkpoint_path(
'cp', 'test.ipynb'
)
cp_subdir = cpm.checkpoint_path(
'cp', '/%s/test.ipynb' % subd
)
self.assertNotEqual(cp_dir, cp_subdir)
self.assertEqual(cp_dir, os.path.join(root, cpm.checkpoint_dir, cp_name))
self.assertEqual(cp_subdir, os.path.join(root, subd, cpm.checkpoint_dir, cp_name))
@dec.skipif(sys.platform == 'win32' and sys.version_info[0] < 3)
def test_bad_symlink(self):
with TemporaryDirectory() as td:
cm = FileContentsManager(root_dir=td)
path = 'test bad symlink'
_make_dir(cm, path)
file_model = cm.new_untitled(path=path, ext='.txt')
# create a broken symlink
self.symlink(cm, "target", '%s/%s' % (path, 'bad symlink'))
model = cm.get(path)
contents = {
content['name']: content for content in model['content']
}
self.assertTrue('untitled.txt' in contents)
self.assertEqual(contents['untitled.txt'], file_model)
# broken symlinks should still be shown in the contents manager
self.assertTrue('bad symlink' in contents)
@dec.skipif(sys.platform == 'win32')
def test_recursive_symlink(self):
with TemporaryDirectory() as td:
cm = FileContentsManager(root_dir=td)
path = 'test recursive symlink'
_make_dir(cm, path)
os_path = cm._get_os_path(path)
os.symlink("recursive", os.path.join(os_path, "recursive"))
file_model = cm.new_untitled(path=path, ext='.txt')
model = cm.get(path)
contents = {
content['name']: content for content in model['content']
}
self.assertIn('untitled.txt', contents)
self.assertEqual(contents['untitled.txt'], file_model)
# recursive symlinks should not be shown in the contents manager
self.assertNotIn('recursive', contents)
@dec.skipif(sys.platform == 'win32' and sys.version_info[0] < 3)
def test_good_symlink(self):
with TemporaryDirectory() as td:
cm = FileContentsManager(root_dir=td)
parent = 'test good symlink'
name = 'good symlink'
path = '{0}/{1}'.format(parent, name)
_make_dir(cm, parent)
file_model = cm.new(path=parent + '/zfoo.txt')
# create a good symlink
self.symlink(cm, file_model['path'], path)
symlink_model = cm.get(path, content=False)
dir_model = cm.get(parent)
self.assertEqual(
sorted(dir_model['content'], key=lambda x: x['name']),
[symlink_model, file_model],
)
def test_403(self):
if hasattr(os, 'getuid'):
if os.getuid() == 0:
raise SkipTest("Can't test permissions as root")
if sys.platform.startswith('win'):
raise SkipTest("Can't test permissions on Windows")
with TemporaryDirectory() as td:
cm = FileContentsManager(root_dir=td)
model = cm.new_untitled(type='file')
os_path = cm._get_os_path(model['path'])
os.chmod(os_path, 0o400)
try:
with cm.open(os_path, 'w') as f:
f.write(u"don't care")
except HTTPError as e:
self.assertEqual(e.status_code, 403)
else:
self.fail("Should have raised HTTPError(403)")
def test_escape_root(self):
with TemporaryDirectory() as td:
cm = FileContentsManager(root_dir=td)
# make foo, bar next to root
with open(os.path.join(cm.root_dir, '..', 'foo'), 'w') as f:
f.write('foo')
with open(os.path.join(cm.root_dir, '..', 'bar'), 'w') as f:
f.write('bar')
with self.assertRaisesHTTPError(404):
cm.get('..')
with self.assertRaisesHTTPError(404):
cm.get('foo/../../../bar')
with self.assertRaisesHTTPError(404):
cm.delete('../foo')
with self.assertRaisesHTTPError(404):
cm.rename('../foo', '../bar')
with self.assertRaisesHTTPError(404):
cm.save(model={
'type': 'file',
'content': u'',
'format': 'text',
}, path='../foo')
class TestContentsManager(TestCase):
@contextmanager
def assertRaisesHTTPError(self, status, msg=None):
msg = msg or "Should have raised HTTPError(%i)" % status
try:
yield
except HTTPError as e:
self.assertEqual(e.status_code, status)
else:
self.fail(msg)
def make_populated_dir(self, api_path):
cm = self.contents_manager
self.make_dir(api_path)
cm.new(path="/".join([api_path, "nb.ipynb"]))
cm.new(path="/".join([api_path, "file.txt"]))
def check_populated_dir_files(self, api_path):
dir_model = self.contents_manager.get(api_path)
self.assertEqual(dir_model['path'], api_path)
self.assertEqual(dir_model['type'], "directory")
for entry in dir_model['content']:
if entry['type'] == "directory":
continue
elif entry['type'] == "file":
self.assertEqual(entry['name'], "file.txt")
complete_path = "/".join([api_path, "file.txt"])
self.assertEqual(entry["path"], complete_path)
elif entry['type'] == "notebook":
self.assertEqual(entry['name'], "nb.ipynb")
complete_path = "/".join([api_path, "nb.ipynb"])
self.assertEqual(entry["path"], complete_path)
def setUp(self):
self._temp_dir = TemporaryDirectory()
self.td = self._temp_dir.name
self.contents_manager = FileContentsManager(
root_dir=self.td,
)
def tearDown(self):
self._temp_dir.cleanup()
def make_dir(self, api_path):
"""make a subdirectory at api_path
override in subclasses if contents are not on the filesystem.
"""
_make_dir(self.contents_manager, api_path)
def add_code_cell(self, nb):
output = nbformat.new_output("display_data", {'application/javascript': "alert('hi');"})
cell = nbformat.new_code_cell("print('hi')", outputs=[output])
nb.cells.append(cell)
def new_notebook(self):
cm = self.contents_manager
model = cm.new_untitled(type='notebook')
name = model['name']
path = model['path']
full_model = cm.get(path)
nb = full_model['content']
nb['metadata']['counter'] = int(1e6 * time.time())
self.add_code_cell(nb)
cm.save(full_model, path)
return nb, name, path
def test_new_untitled(self):
cm = self.contents_manager
# Test in root directory
model = cm.new_untitled(type='notebook')
assert isinstance(model, dict)
self.assertIn('name', model)
self.assertIn('path', model)
self.assertIn('type', model)
self.assertEqual(model['type'], 'notebook')
self.assertEqual(model['name'], 'Untitled.ipynb')
self.assertEqual(model['path'], 'Untitled.ipynb')
# Test in sub-directory
model = cm.new_untitled(type='directory')
assert isinstance(model, dict)
self.assertIn('name', model)
self.assertIn('path', model)
self.assertIn('type', model)
self.assertEqual(model['type'], 'directory')
self.assertEqual(model['name'], 'Untitled Folder')
self.assertEqual(model['path'], 'Untitled Folder')
sub_dir = model['path']
model = cm.new_untitled(path=sub_dir)
assert isinstance(model, dict)
self.assertIn('name', model)
self.assertIn('path', model)
self.assertIn('type', model)
self.assertEqual(model['type'], 'file')
self.assertEqual(model['name'], 'untitled')
self.assertEqual(model['path'], '%s/untitled' % sub_dir)
# Test with a compound extension
model = cm.new_untitled(path=sub_dir, ext='.foo.bar')
self.assertEqual(model['name'], 'untitled.foo.bar')
model = cm.new_untitled(path=sub_dir, ext='.foo.bar')
self.assertEqual(model['name'], 'untitled1.foo.bar')
def test_modified_date(self):
cm = self.contents_manager
# Create a new notebook.
nb, name, path = self.new_notebook()
model = cm.get(path)
# Add a cell and save.
self.add_code_cell(model['content'])
cm.save(model, path)
# Reload notebook and verify that last_modified incremented.
saved = cm.get(path)
self.assertGreaterEqual(saved['last_modified'], model['last_modified'])
# Move the notebook and verify that last_modified stayed the same.
# (The frontend fires a warning if last_modified increases on the
# renamed file.)
new_path = 'renamed.ipynb'
cm.rename(path, new_path)
renamed = cm.get(new_path)
self.assertGreaterEqual(
renamed['last_modified'],
saved['last_modified'],
)
def test_get(self):
cm = self.contents_manager
# Create a notebook
model = cm.new_untitled(type='notebook')
name = model['name']
path = model['path']
# Check that we 'get' on the notebook we just created
model2 = cm.get(path)
assert isinstance(model2, dict)
self.assertIn('name', model2)
self.assertIn('path', model2)
self.assertEqual(model['name'], name)
self.assertEqual(model['path'], path)
nb_as_file = cm.get(path, content=True, type='file')
self.assertEqual(nb_as_file['path'], path)
self.assertEqual(nb_as_file['type'], 'file')
self.assertEqual(nb_as_file['format'], 'text')
self.assertNotIsInstance(nb_as_file['content'], dict)
nb_as_bin_file = cm.get(path, content=True, type='file', format='base64')
self.assertEqual(nb_as_bin_file['format'], 'base64')
# Test in sub-directory
sub_dir = '/foo/'
self.make_dir('foo')
model = cm.new_untitled(path=sub_dir, ext='.ipynb')
model2 = cm.get(sub_dir + name)
assert isinstance(model2, dict)
self.assertIn('name', model2)
self.assertIn('path', model2)
self.assertIn('content', model2)
self.assertEqual(model2['name'], 'Untitled.ipynb')
self.assertEqual(model2['path'], '{0}/{1}'.format(sub_dir.strip('/'), name))
# Test with a regular file.
file_model_path = cm.new_untitled(path=sub_dir, ext='.txt')['path']
file_model = cm.get(file_model_path)
self.assertDictContainsSubset(
{
'content': u'',
'format': u'text',
'mimetype': u'text/plain',
'name': u'untitled.txt',
'path': u'foo/untitled.txt',
'type': u'file',
'writable': True,
},
file_model,
)
self.assertIn('created', file_model)
self.assertIn('last_modified', file_model)
# Test getting directory model
# Create a sub-sub directory to test getting directory contents with a
# subdir.
self.make_dir('foo/bar')
dirmodel = cm.get('foo')
self.assertEqual(dirmodel['type'], 'directory')
self.assertIsInstance(dirmodel['content'], list)
self.assertEqual(len(dirmodel['content']), 3)
self.assertEqual(dirmodel['path'], 'foo')
self.assertEqual(dirmodel['name'], 'foo')
# Directory contents should match the contents of each individual entry
# when requested with content=False.
model2_no_content = cm.get(sub_dir + name, content=False)
file_model_no_content = cm.get(u'foo/untitled.txt', content=False)
sub_sub_dir_no_content = cm.get('foo/bar', content=False)
self.assertEqual(sub_sub_dir_no_content['path'], 'foo/bar')
self.assertEqual(sub_sub_dir_no_content['name'], 'bar')
for entry in dirmodel['content']:
# Order isn't guaranteed by the spec, so this is a hacky way of
# verifying that all entries are matched.
if entry['path'] == sub_sub_dir_no_content['path']:
self.assertEqual(entry, sub_sub_dir_no_content)
elif entry['path'] == model2_no_content['path']:
self.assertEqual(entry, model2_no_content)
elif entry['path'] == file_model_no_content['path']:
self.assertEqual(entry, file_model_no_content)
else:
self.fail("Unexpected directory entry: %s" % entry())
with self.assertRaises(HTTPError):
cm.get('foo', type='file')
def test_update(self):
cm = self.contents_manager
# Create a notebook
model = cm.new_untitled(type='notebook')
name = model['name']
path = model['path']
# Change the name in the model for rename
model['path'] = 'test.ipynb'
model = cm.update(model, path)
assert isinstance(model, dict)
self.assertIn('name', model)
self.assertIn('path', model)
self.assertEqual(model['name'], 'test.ipynb')
# Make sure the old name is gone
self.assertRaises(HTTPError, cm.get, path)
# Test in sub-directory
# Create a directory and notebook in that directory
sub_dir = '/foo/'
self.make_dir('foo')
model = cm.new_untitled(path=sub_dir, type='notebook')
path = model['path']
# Change the name in the model for rename
d = path.rsplit('/', 1)[0]
new_path = model['path'] = d + '/test_in_sub.ipynb'
model = cm.update(model, path)
assert isinstance(model, dict)
self.assertIn('name', model)
self.assertIn('path', model)
self.assertEqual(model['name'], 'test_in_sub.ipynb')
self.assertEqual(model['path'], new_path)
# Make sure the old name is gone
self.assertRaises(HTTPError, cm.get, path)
def test_save(self):
cm = self.contents_manager
# Create a notebook
model = cm.new_untitled(type='notebook')
name = model['name']
path = model['path']
# Get the model with 'content'
full_model = cm.get(path)
# Save the notebook
model = cm.save(full_model, path)
assert isinstance(model, dict)
self.assertIn('name', model)
self.assertIn('path', model)
self.assertEqual(model['name'], name)
self.assertEqual(model['path'], path)
# Test in sub-directory
# Create a directory and notebook in that directory
sub_dir = '/foo/'
self.make_dir('foo')
model = cm.new_untitled(path=sub_dir, type='notebook')
name = model['name']
path = model['path']
model = cm.get(path)
# Change the name in the model for rename
model = cm.save(model, path)
assert isinstance(model, dict)
self.assertIn('name', model)
self.assertIn('path', model)
self.assertEqual(model['name'], 'Untitled.ipynb')
self.assertEqual(model['path'], 'foo/Untitled.ipynb')
def test_delete(self):
cm = self.contents_manager
# Create a notebook
nb, name, path = self.new_notebook()
# Delete the notebook
cm.delete(path)
# Check that deleting a non-existent path raises an error.
self.assertRaises(HTTPError, cm.delete, path)
# Check that a 'get' on the deleted notebook raises and error
self.assertRaises(HTTPError, cm.get, path)
def test_rename(self):
cm = self.contents_manager
# Create a new notebook
nb, name, path = self.new_notebook()
# Rename the notebook
cm.rename(path, "changed_path")
# Attempting to get the notebook under the old name raises an error
self.assertRaises(HTTPError, cm.get, path)
# Fetching the notebook under the new name is successful
assert isinstance(cm.get("changed_path"), dict)
# Test validation. Currently, only Windows has a non-empty set of invalid characters
if sys.platform == 'win32' and isinstance(cm, FileContentsManager):
with self.assertRaisesHTTPError(400):
cm.rename("changed_path", "prevent: in name")
# Ported tests on nested directory renaming from pgcontents
all_dirs = ['foo', 'bar', 'foo/bar', 'foo/bar/foo', 'foo/bar/foo/bar']
unchanged_dirs = all_dirs[:2]
changed_dirs = all_dirs[2:]
for _dir in all_dirs:
self.make_populated_dir(_dir)
self.check_populated_dir_files(_dir)
# Renaming to an existing directory should fail
for src, dest in combinations(all_dirs, 2):
with self.assertRaisesHTTPError(409):
cm.rename(src, dest)
# Creating a notebook in a non_existant directory should fail
with self.assertRaisesHTTPError(404):
cm.new_untitled("foo/bar_diff", ext=".ipynb")
cm.rename("foo/bar", "foo/bar_diff")
# Assert that unchanged directories remain so
for unchanged in unchanged_dirs:
self.check_populated_dir_files(unchanged)
# Assert changed directories can no longer be accessed under old names
for changed_dirname in changed_dirs:
with self.assertRaisesHTTPError(404):
cm.get(changed_dirname)
new_dirname = changed_dirname.replace("foo/bar", "foo/bar_diff", 1)
self.check_populated_dir_files(new_dirname)
# Created a notebook in the renamed directory should work
cm.new_untitled("foo/bar_diff", ext=".ipynb")
def test_delete_root(self):
cm = self.contents_manager
with self.assertRaises(HTTPError) as err:
cm.delete('')
self.assertEqual(err.exception.status_code, 400)
def test_copy(self):
cm = self.contents_manager
parent = u'å b'
name = u'nb √.ipynb'
path = u'{0}/{1}'.format(parent, name)
self.make_dir(parent)
orig = cm.new(path=path)
# copy with unspecified name
copy = cm.copy(path)
self.assertEqual(copy['name'], orig['name'].replace('.ipynb', '-Copy1.ipynb'))
# copy with specified name
copy2 = cm.copy(path, u'å b/copy 2.ipynb')
self.assertEqual(copy2['name'], u'copy 2.ipynb')
self.assertEqual(copy2['path'], u'å b/copy 2.ipynb')
# copy with specified path
copy2 = cm.copy(path, u'/')
self.assertEqual(copy2['name'], name)
self.assertEqual(copy2['path'], name)
def test_trust_notebook(self):
cm = self.contents_manager
nb, name, path = self.new_notebook()
untrusted = cm.get(path)['content']
assert not cm.notary.check_cells(untrusted)
# print(untrusted)
cm.trust_notebook(path)
trusted = cm.get(path)['content']
# print(trusted)
assert cm.notary.check_cells(trusted)
def test_mark_trusted_cells(self):
cm = self.contents_manager
nb, name, path = self.new_notebook()
cm.mark_trusted_cells(nb, path)
for cell in nb.cells:
if cell.cell_type == 'code':
assert not cell.metadata.trusted
cm.trust_notebook(path)
nb = cm.get(path)['content']
for cell in nb.cells:
if cell.cell_type == 'code':
assert cell.metadata.trusted
def test_check_and_sign(self):
cm = self.contents_manager
nb, name, path = self.new_notebook()
cm.mark_trusted_cells(nb, path)
cm.check_and_sign(nb, path)
assert not cm.notary.check_signature(nb)
cm.trust_notebook(path)
nb = cm.get(path)['content']
cm.mark_trusted_cells(nb, path)
cm.check_and_sign(nb, path)
assert cm.notary.check_signature(nb)
class TestContentsManagerNoAtomic(TestContentsManager):
"""
Make same test in no atomic case than in atomic case, using inheritance
"""
def setUp(self):
self._temp_dir = TemporaryDirectory()
self.td = self._temp_dir.name
self.contents_manager = FileContentsManager(
root_dir = self.td,
)
self.contents_manager.use_atomic_writing = False

View file

@ -0,0 +1,496 @@
"""Tornado handlers for kernels.
Preliminary documentation at https://github.com/ipython/ipython/wiki/IPEP-16%3A-Notebook-multi-directory-dashboard-and-URL-mapping#kernels-api
"""
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
import json
import logging
from textwrap import dedent
from tornado import gen, web
from tornado.concurrent import Future
from tornado.ioloop import IOLoop
from jupyter_client import protocol_version as client_protocol_version
from jupyter_client.jsonutil import date_default
from ipython_genutils.py3compat import cast_unicode
from notebook.utils import maybe_future, url_path_join, url_escape
from ...base.handlers import APIHandler
from ...base.zmqhandlers import AuthenticatedZMQStreamHandler, deserialize_binary_message
class MainKernelHandler(APIHandler):
@web.authenticated
@gen.coroutine
def get(self):
km = self.kernel_manager
kernels = yield maybe_future(km.list_kernels())
self.finish(json.dumps(kernels, default=date_default))
@web.authenticated
@gen.coroutine
def post(self):
km = self.kernel_manager
model = self.get_json_body()
if model is None:
model = {
'name': km.default_kernel_name
}
else:
model.setdefault('name', km.default_kernel_name)
kernel_id = yield maybe_future(km.start_kernel(kernel_name=model['name']))
model = yield maybe_future(km.kernel_model(kernel_id))
location = url_path_join(self.base_url, 'api', 'kernels', url_escape(kernel_id))
self.set_header('Location', location)
self.set_status(201)
self.finish(json.dumps(model, default=date_default))
class KernelHandler(APIHandler):
@web.authenticated
def get(self, kernel_id):
km = self.kernel_manager
model = km.kernel_model(kernel_id)
self.finish(json.dumps(model, default=date_default))
@web.authenticated
@gen.coroutine
def delete(self, kernel_id):
km = self.kernel_manager
yield maybe_future(km.shutdown_kernel(kernel_id))
self.set_status(204)
self.finish()
class KernelActionHandler(APIHandler):
@web.authenticated
@gen.coroutine
def post(self, kernel_id, action):
km = self.kernel_manager
if action == 'interrupt':
yield maybe_future(km.interrupt_kernel(kernel_id))
self.set_status(204)
if action == 'restart':
try:
yield maybe_future(km.restart_kernel(kernel_id))
except Exception as e:
self.log.error("Exception restarting kernel", exc_info=True)
self.set_status(500)
else:
model = yield maybe_future(km.kernel_model(kernel_id))
self.write(json.dumps(model, default=date_default))
self.finish()
class ZMQChannelsHandler(AuthenticatedZMQStreamHandler):
'''There is one ZMQChannelsHandler per running kernel and it oversees all
the sessions.
'''
# class-level registry of open sessions
# allows checking for conflict on session-id,
# which is used as a zmq identity and must be unique.
_open_sessions = {}
@property
def kernel_info_timeout(self):
km_default = self.kernel_manager.kernel_info_timeout
return self.settings.get('kernel_info_timeout', km_default)
@property
def iopub_msg_rate_limit(self):
return self.settings.get('iopub_msg_rate_limit', 0)
@property
def iopub_data_rate_limit(self):
return self.settings.get('iopub_data_rate_limit', 0)
@property
def rate_limit_window(self):
return self.settings.get('rate_limit_window', 1.0)
def __repr__(self):
return "%s(%s)" % (self.__class__.__name__, getattr(self, 'kernel_id', 'uninitialized'))
def create_stream(self):
km = self.kernel_manager
identity = self.session.bsession
for channel in ('shell', 'control', 'iopub', 'stdin'):
meth = getattr(km, 'connect_' + channel)
self.channels[channel] = stream = meth(self.kernel_id, identity=identity)
stream.channel = channel
def request_kernel_info(self):
"""send a request for kernel_info"""
km = self.kernel_manager
kernel = km.get_kernel(self.kernel_id)
try:
# check for previous request
future = kernel._kernel_info_future
except AttributeError:
self.log.debug("Requesting kernel info from %s", self.kernel_id)
# Create a kernel_info channel to query the kernel protocol version.
# This channel will be closed after the kernel_info reply is received.
if self.kernel_info_channel is None:
self.kernel_info_channel = km.connect_shell(self.kernel_id)
self.kernel_info_channel.on_recv(self._handle_kernel_info_reply)
self.session.send(self.kernel_info_channel, "kernel_info_request")
# store the future on the kernel, so only one request is sent
kernel._kernel_info_future = self._kernel_info_future
else:
if not future.done():
self.log.debug("Waiting for pending kernel_info request")
future.add_done_callback(lambda f: self._finish_kernel_info(f.result()))
return self._kernel_info_future
def _handle_kernel_info_reply(self, msg):
"""process the kernel_info_reply
enabling msg spec adaptation, if necessary
"""
idents,msg = self.session.feed_identities(msg)
try:
msg = self.session.deserialize(msg)
except:
self.log.error("Bad kernel_info reply", exc_info=True)
self._kernel_info_future.set_result({})
return
else:
info = msg['content']
self.log.debug("Received kernel info: %s", info)
if msg['msg_type'] != 'kernel_info_reply' or 'protocol_version' not in info:
self.log.error("Kernel info request failed, assuming current %s", info)
info = {}
self._finish_kernel_info(info)
# close the kernel_info channel, we don't need it anymore
if self.kernel_info_channel:
self.kernel_info_channel.close()
self.kernel_info_channel = None
def _finish_kernel_info(self, info):
"""Finish handling kernel_info reply
Set up protocol adaptation, if needed,
and signal that connection can continue.
"""
protocol_version = info.get('protocol_version', client_protocol_version)
if protocol_version != client_protocol_version:
self.session.adapt_version = int(protocol_version.split('.')[0])
self.log.info("Adapting from protocol version {protocol_version} (kernel {kernel_id}) to {client_protocol_version} (client).".format(protocol_version=protocol_version, kernel_id=self.kernel_id, client_protocol_version=client_protocol_version))
if not self._kernel_info_future.done():
self._kernel_info_future.set_result(info)
def initialize(self):
super(ZMQChannelsHandler, self).initialize()
self.zmq_stream = None
self.channels = {}
self.kernel_id = None
self.kernel_info_channel = None
self._kernel_info_future = Future()
self._close_future = Future()
self.session_key = ''
# Rate limiting code
self._iopub_window_msg_count = 0
self._iopub_window_byte_count = 0
self._iopub_msgs_exceeded = False
self._iopub_data_exceeded = False
# Queue of (time stamp, byte count)
# Allows you to specify that the byte count should be lowered
# by a delta amount at some point in the future.
self._iopub_window_byte_queue = []
@gen.coroutine
def pre_get(self):
# authenticate first
super(ZMQChannelsHandler, self).pre_get()
# check session collision:
yield self._register_session()
# then request kernel info, waiting up to a certain time before giving up.
# We don't want to wait forever, because browsers don't take it well when
# servers never respond to websocket connection requests.
kernel = self.kernel_manager.get_kernel(self.kernel_id)
self.session.key = kernel.session.key
future = self.request_kernel_info()
def give_up():
"""Don't wait forever for the kernel to reply"""
if future.done():
return
self.log.warning("Timeout waiting for kernel_info reply from %s", self.kernel_id)
future.set_result({})
loop = IOLoop.current()
loop.add_timeout(loop.time() + self.kernel_info_timeout, give_up)
# actually wait for it
yield future
@gen.coroutine
def get(self, kernel_id):
self.kernel_id = cast_unicode(kernel_id, 'ascii')
yield super(ZMQChannelsHandler, self).get(kernel_id=kernel_id)
@gen.coroutine
def _register_session(self):
"""Ensure we aren't creating a duplicate session.
If a previous identical session is still open, close it to avoid collisions.
This is likely due to a client reconnecting from a lost network connection,
where the socket on our side has not been cleaned up yet.
"""
self.session_key = '%s:%s' % (self.kernel_id, self.session.session)
stale_handler = self._open_sessions.get(self.session_key)
if stale_handler:
self.log.warning("Replacing stale connection: %s", self.session_key)
yield stale_handler.close()
self._open_sessions[self.session_key] = self
def open(self, kernel_id):
super(ZMQChannelsHandler, self).open()
km = self.kernel_manager
km.notify_connect(kernel_id)
# on new connections, flush the message buffer
buffer_info = km.get_buffer(kernel_id, self.session_key)
if buffer_info and buffer_info['session_key'] == self.session_key:
self.log.info("Restoring connection for %s", self.session_key)
self.channels = buffer_info['channels']
replay_buffer = buffer_info['buffer']
if replay_buffer:
self.log.info("Replaying %s buffered messages", len(replay_buffer))
for channel, msg_list in replay_buffer:
stream = self.channels[channel]
self._on_zmq_reply(stream, msg_list)
else:
try:
self.create_stream()
except web.HTTPError as e:
self.log.error("Error opening stream: %s", e)
# WebSockets don't response to traditional error codes so we
# close the connection.
for channel, stream in self.channels.items():
if not stream.closed():
stream.close()
self.close()
return
km.add_restart_callback(self.kernel_id, self.on_kernel_restarted)
km.add_restart_callback(self.kernel_id, self.on_restart_failed, 'dead')
for channel, stream in self.channels.items():
stream.on_recv_stream(self._on_zmq_reply)
def on_message(self, msg):
if not self.channels:
# already closed, ignore the message
self.log.debug("Received message on closed websocket %r", msg)
return
if isinstance(msg, bytes):
msg = deserialize_binary_message(msg)
else:
msg = json.loads(msg)
channel = msg.pop('channel', None)
if channel is None:
self.log.warning("No channel specified, assuming shell: %s", msg)
channel = 'shell'
if channel not in self.channels:
self.log.warning("No such channel: %r", channel)
return
am = self.kernel_manager.allowed_message_types
mt = msg['header']['msg_type']
if am and mt not in am:
self.log.warning('Received message of type "%s", which is not allowed. Ignoring.' % mt)
else:
stream = self.channels[channel]
self.session.send(stream, msg)
def _on_zmq_reply(self, stream, msg_list):
idents, fed_msg_list = self.session.feed_identities(msg_list)
msg = self.session.deserialize(fed_msg_list)
parent = msg['parent_header']
def write_stderr(error_message):
self.log.warning(error_message)
msg = self.session.msg("stream",
content={"text": error_message + '\n', "name": "stderr"},
parent=parent
)
msg['channel'] = 'iopub'
self.write_message(json.dumps(msg, default=date_default))
channel = getattr(stream, 'channel', None)
msg_type = msg['header']['msg_type']
if channel == 'iopub' and msg_type == 'status' and msg['content'].get('execution_state') == 'idle':
# reset rate limit counter on status=idle,
# to avoid 'Run All' hitting limits prematurely.
self._iopub_window_byte_queue = []
self._iopub_window_msg_count = 0
self._iopub_window_byte_count = 0
self._iopub_msgs_exceeded = False
self._iopub_data_exceeded = False
if channel == 'iopub' and msg_type not in {'status', 'comm_open', 'execute_input'}:
# Remove the counts queued for removal.
now = IOLoop.current().time()
while len(self._iopub_window_byte_queue) > 0:
queued = self._iopub_window_byte_queue[0]
if (now >= queued[0]):
self._iopub_window_byte_count -= queued[1]
self._iopub_window_msg_count -= 1
del self._iopub_window_byte_queue[0]
else:
# This part of the queue hasn't be reached yet, so we can
# abort the loop.
break
# Increment the bytes and message count
self._iopub_window_msg_count += 1
if msg_type == 'stream':
byte_count = sum([len(x) for x in msg_list])
else:
byte_count = 0
self._iopub_window_byte_count += byte_count
# Queue a removal of the byte and message count for a time in the
# future, when we are no longer interested in it.
self._iopub_window_byte_queue.append((now + self.rate_limit_window, byte_count))
# Check the limits, set the limit flags, and reset the
# message and data counts.
msg_rate = float(self._iopub_window_msg_count) / self.rate_limit_window
data_rate = float(self._iopub_window_byte_count) / self.rate_limit_window
# Check the msg rate
if self.iopub_msg_rate_limit > 0 and msg_rate > self.iopub_msg_rate_limit:
if not self._iopub_msgs_exceeded:
self._iopub_msgs_exceeded = True
write_stderr(dedent("""\
IOPub message rate exceeded.
The notebook server will temporarily stop sending output
to the client in order to avoid crashing it.
To change this limit, set the config variable
`--NotebookApp.iopub_msg_rate_limit`.
Current values:
NotebookApp.iopub_msg_rate_limit={} (msgs/sec)
NotebookApp.rate_limit_window={} (secs)
""".format(self.iopub_msg_rate_limit, self.rate_limit_window)))
else:
# resume once we've got some headroom below the limit
if self._iopub_msgs_exceeded and msg_rate < (0.8 * self.iopub_msg_rate_limit):
self._iopub_msgs_exceeded = False
if not self._iopub_data_exceeded:
self.log.warning("iopub messages resumed")
# Check the data rate
if self.iopub_data_rate_limit > 0 and data_rate > self.iopub_data_rate_limit:
if not self._iopub_data_exceeded:
self._iopub_data_exceeded = True
write_stderr(dedent("""\
IOPub data rate exceeded.
The notebook server will temporarily stop sending output
to the client in order to avoid crashing it.
To change this limit, set the config variable
`--NotebookApp.iopub_data_rate_limit`.
Current values:
NotebookApp.iopub_data_rate_limit={} (bytes/sec)
NotebookApp.rate_limit_window={} (secs)
""".format(self.iopub_data_rate_limit, self.rate_limit_window)))
else:
# resume once we've got some headroom below the limit
if self._iopub_data_exceeded and data_rate < (0.8 * self.iopub_data_rate_limit):
self._iopub_data_exceeded = False
if not self._iopub_msgs_exceeded:
self.log.warning("iopub messages resumed")
# If either of the limit flags are set, do not send the message.
if self._iopub_msgs_exceeded or self._iopub_data_exceeded:
# we didn't send it, remove the current message from the calculus
self._iopub_window_msg_count -= 1
self._iopub_window_byte_count -= byte_count
self._iopub_window_byte_queue.pop(-1)
return
super(ZMQChannelsHandler, self)._on_zmq_reply(stream, msg)
def close(self):
super(ZMQChannelsHandler, self).close()
return self._close_future
def on_close(self):
self.log.debug("Websocket closed %s", self.session_key)
# unregister myself as an open session (only if it's really me)
if self._open_sessions.get(self.session_key) is self:
self._open_sessions.pop(self.session_key)
km = self.kernel_manager
if self.kernel_id in km:
km.notify_disconnect(self.kernel_id)
km.remove_restart_callback(
self.kernel_id, self.on_kernel_restarted,
)
km.remove_restart_callback(
self.kernel_id, self.on_restart_failed, 'dead',
)
# start buffering instead of closing if this was the last connection
if km._kernel_connections[self.kernel_id] == 0:
km.start_buffering(self.kernel_id, self.session_key, self.channels)
self._close_future.set_result(None)
return
# This method can be called twice, once by self.kernel_died and once
# from the WebSocket close event. If the WebSocket connection is
# closed before the ZMQ streams are setup, they could be None.
for channel, stream in self.channels.items():
if stream is not None and not stream.closed():
stream.on_recv(None)
stream.close()
self.channels = {}
self._close_future.set_result(None)
def _send_status_message(self, status):
iopub = self.channels.get('iopub', None)
if iopub and not iopub.closed():
# flush IOPub before sending a restarting/dead status message
# ensures proper ordering on the IOPub channel
# that all messages from the stopped kernel have been delivered
iopub.flush()
msg = self.session.msg("status",
{'execution_state': status}
)
msg['channel'] = 'iopub'
self.write_message(json.dumps(msg, default=date_default))
def on_kernel_restarted(self):
logging.warn("kernel %s restarted", self.kernel_id)
self._send_status_message('restarting')
def on_restart_failed(self):
logging.error("kernel %s restarted failed!", self.kernel_id)
self._send_status_message('dead')
#-----------------------------------------------------------------------------
# URL to handler mappings
#-----------------------------------------------------------------------------
_kernel_id_regex = r"(?P<kernel_id>\w+-\w+-\w+-\w+-\w+)"
_kernel_action_regex = r"(?P<action>restart|interrupt)"
default_handlers = [
(r"/api/kernels", MainKernelHandler),
(r"/api/kernels/%s" % _kernel_id_regex, KernelHandler),
(r"/api/kernels/%s/%s" % (_kernel_id_regex, _kernel_action_regex), KernelActionHandler),
(r"/api/kernels/%s/channels" % _kernel_id_regex, ZMQChannelsHandler),
]

View file

@ -0,0 +1,509 @@
"""A MultiKernelManager for use in the notebook webserver
- raises HTTPErrors
- creates REST API models
"""
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
from collections import defaultdict
from datetime import datetime, timedelta
from functools import partial
import os
from tornado import web
from tornado.concurrent import Future
from tornado.ioloop import IOLoop, PeriodicCallback
from jupyter_client.session import Session
from jupyter_client.multikernelmanager import MultiKernelManager
from traitlets import (Any, Bool, Dict, List, Unicode, TraitError, Integer,
Float, Instance, default, validate
)
from notebook.utils import maybe_future, to_os_path, exists
from notebook._tz import utcnow, isoformat
from ipython_genutils.py3compat import getcwd
from notebook.prometheus.metrics import KERNEL_CURRENTLY_RUNNING_TOTAL
# Since use of AsyncMultiKernelManager is optional at the moment, don't require appropriate jupyter_client.
# This will be confirmed at runtime in notebookapp. The following block can be removed once the jupyter_client's
# floor has been updated.
try:
from jupyter_client.multikernelmanager import AsyncMultiKernelManager
except ImportError:
class AsyncMultiKernelManager(object):
"""Empty class to satisfy unused reference by AsyncMappingKernelManager."""
def __init__(self, **kwargs):
pass
class MappingKernelManager(MultiKernelManager):
"""A KernelManager that handles notebook mapping and HTTP error handling"""
@default('kernel_manager_class')
def _default_kernel_manager_class(self):
return "jupyter_client.ioloop.IOLoopKernelManager"
kernel_argv = List(Unicode())
root_dir = Unicode(config=True)
_kernel_connections = Dict()
_culler_callback = None
_initialized_culler = False
@default('root_dir')
def _default_root_dir(self):
try:
return self.parent.notebook_dir
except AttributeError:
return getcwd()
@validate('root_dir')
def _update_root_dir(self, proposal):
"""Do a bit of validation of the root dir."""
value = proposal['value']
if not os.path.isabs(value):
# If we receive a non-absolute path, make it absolute.
value = os.path.abspath(value)
if not exists(value) or not os.path.isdir(value):
raise TraitError("kernel root dir %r is not a directory" % value)
return value
cull_idle_timeout = Integer(0, config=True,
help="""Timeout (in seconds) after which a kernel is considered idle and ready to be culled.
Values of 0 or lower disable culling. Very short timeouts may result in kernels being culled
for users with poor network connections."""
)
cull_interval_default = 300 # 5 minutes
cull_interval = Integer(cull_interval_default, config=True,
help="""The interval (in seconds) on which to check for idle kernels exceeding the cull timeout value."""
)
cull_connected = Bool(False, config=True,
help="""Whether to consider culling kernels which have one or more connections.
Only effective if cull_idle_timeout > 0."""
)
cull_busy = Bool(False, config=True,
help="""Whether to consider culling kernels which are busy.
Only effective if cull_idle_timeout > 0."""
)
buffer_offline_messages = Bool(True, config=True,
help="""Whether messages from kernels whose frontends have disconnected should be buffered in-memory.
When True (default), messages are buffered and replayed on reconnect,
avoiding lost messages due to interrupted connectivity.
Disable if long-running kernels will produce too much output while
no frontends are connected.
"""
)
kernel_info_timeout = Float(60, config=True,
help="""Timeout for giving up on a kernel (in seconds).
On starting and restarting kernels, we check whether the
kernel is running and responsive by sending kernel_info_requests.
This sets the timeout in seconds for how long the kernel can take
before being presumed dead.
This affects the MappingKernelManager (which handles kernel restarts)
and the ZMQChannelsHandler (which handles the startup).
"""
)
_kernel_buffers = Any()
@default('_kernel_buffers')
def _default_kernel_buffers(self):
return defaultdict(lambda: {'buffer': [], 'session_key': '', 'channels': {}})
last_kernel_activity = Instance(datetime,
help="The last activity on any kernel, including shutting down a kernel")
allowed_message_types = List(trait=Unicode(), config=True,
help="""White list of allowed kernel message types.
When the list is empty, all message types are allowed.
"""
)
#-------------------------------------------------------------------------
# Methods for managing kernels and sessions
#-------------------------------------------------------------------------
def __init__(self, **kwargs):
# Pin the superclass to better control the MRO. This is needed by
# AsyncMappingKernelManager so that it can give priority to methods
# on AsyncMultiKernelManager over this superclass.
self.pinned_superclass = MultiKernelManager
self.pinned_superclass.__init__(self, **kwargs)
self.last_kernel_activity = utcnow()
def _handle_kernel_died(self, kernel_id):
"""notice that a kernel died"""
self.log.warning("Kernel %s died, removing from map.", kernel_id)
self.remove_kernel(kernel_id)
def cwd_for_path(self, path):
"""Turn API path into absolute OS path."""
os_path = to_os_path(path, self.root_dir)
# in the case of notebooks and kernels not being on the same filesystem,
# walk up to root_dir if the paths don't exist
while not os.path.isdir(os_path) and os_path != self.root_dir:
os_path = os.path.dirname(os_path)
return os_path
async def start_kernel(self, kernel_id=None, path=None, **kwargs):
"""Start a kernel for a session and return its kernel_id.
Parameters
----------
kernel_id : uuid
The uuid to associate the new kernel with. If this
is not None, this kernel will be persistent whenever it is
requested.
path : API path
The API path (unicode, '/' delimited) for the cwd.
Will be transformed to an OS path relative to root_dir.
kernel_name : str
The name identifying which kernel spec to launch. This is ignored if
an existing kernel is returned, but it may be checked in the future.
"""
if kernel_id is None:
if path is not None:
kwargs['cwd'] = self.cwd_for_path(path)
kernel_id = await maybe_future(self.pinned_superclass.start_kernel(self, **kwargs))
self._kernel_connections[kernel_id] = 0
self.start_watching_activity(kernel_id)
self.log.info("Kernel started: %s, name: %s" % (kernel_id, self._kernels[kernel_id].kernel_name))
self.log.debug("Kernel args: %r" % kwargs)
# register callback for failed auto-restart
self.add_restart_callback(kernel_id,
lambda : self._handle_kernel_died(kernel_id),
'dead',
)
# Increase the metric of number of kernels running
# for the relevant kernel type by 1
KERNEL_CURRENTLY_RUNNING_TOTAL.labels(
type=self._kernels[kernel_id].kernel_name
).inc()
else:
self._check_kernel_id(kernel_id)
self.log.info("Using existing kernel: %s" % kernel_id)
# Initialize culling if not already
if not self._initialized_culler:
self.initialize_culler()
return kernel_id
def start_buffering(self, kernel_id, session_key, channels):
"""Start buffering messages for a kernel
Parameters
----------
kernel_id : str
The id of the kernel to start buffering.
session_key: str
The session_key, if any, that should get the buffer.
If the session_key matches the current buffered session_key,
the buffer will be returned.
channels: dict({'channel': ZMQStream})
The zmq channels whose messages should be buffered.
"""
if not self.buffer_offline_messages:
for channel, stream in channels.items():
stream.close()
return
self.log.info("Starting buffering for %s", session_key)
self._check_kernel_id(kernel_id)
# clear previous buffering state
self.stop_buffering(kernel_id)
buffer_info = self._kernel_buffers[kernel_id]
# record the session key because only one session can buffer
buffer_info['session_key'] = session_key
# TODO: the buffer should likely be a memory bounded queue, we're starting with a list to keep it simple
buffer_info['buffer'] = []
buffer_info['channels'] = channels
# forward any future messages to the internal buffer
def buffer_msg(channel, msg_parts):
self.log.debug("Buffering msg on %s:%s", kernel_id, channel)
buffer_info['buffer'].append((channel, msg_parts))
for channel, stream in channels.items():
stream.on_recv(partial(buffer_msg, channel))
def get_buffer(self, kernel_id, session_key):
"""Get the buffer for a given kernel
Parameters
----------
kernel_id : str
The id of the kernel to stop buffering.
session_key: str, optional
The session_key, if any, that should get the buffer.
If the session_key matches the current buffered session_key,
the buffer will be returned.
"""
self.log.debug("Getting buffer for %s", kernel_id)
if kernel_id not in self._kernel_buffers:
return
buffer_info = self._kernel_buffers[kernel_id]
if buffer_info['session_key'] == session_key:
# remove buffer
self._kernel_buffers.pop(kernel_id)
# only return buffer_info if it's a match
return buffer_info
else:
self.stop_buffering(kernel_id)
def stop_buffering(self, kernel_id):
"""Stop buffering kernel messages
Parameters
----------
kernel_id : str
The id of the kernel to stop buffering.
"""
self.log.debug("Clearing buffer for %s", kernel_id)
self._check_kernel_id(kernel_id)
if kernel_id not in self._kernel_buffers:
return
buffer_info = self._kernel_buffers.pop(kernel_id)
# close buffering streams
for stream in buffer_info['channels'].values():
if not stream.closed():
stream.on_recv(None)
stream.close()
msg_buffer = buffer_info['buffer']
if msg_buffer:
self.log.info("Discarding %s buffered messages for %s",
len(msg_buffer), buffer_info['session_key'])
def shutdown_kernel(self, kernel_id, now=False, restart=False):
"""Shutdown a kernel by kernel_id"""
self._check_kernel_id(kernel_id)
kernel = self._kernels[kernel_id]
if kernel._activity_stream:
kernel._activity_stream.close()
kernel._activity_stream = None
self.stop_buffering(kernel_id)
self._kernel_connections.pop(kernel_id, None)
# Decrease the metric of number of kernels
# running for the relevant kernel type by 1
KERNEL_CURRENTLY_RUNNING_TOTAL.labels(
type=self._kernels[kernel_id].kernel_name
).dec()
return self.pinned_superclass.shutdown_kernel(self, kernel_id, now=now, restart=restart)
async def restart_kernel(self, kernel_id, now=False):
"""Restart a kernel by kernel_id"""
self._check_kernel_id(kernel_id)
await maybe_future(self.pinned_superclass.restart_kernel(self, kernel_id, now=now))
kernel = self.get_kernel(kernel_id)
# return a Future that will resolve when the kernel has successfully restarted
channel = kernel.connect_shell()
future = Future()
def finish():
"""Common cleanup when restart finishes/fails for any reason."""
if not channel.closed():
channel.close()
loop.remove_timeout(timeout)
kernel.remove_restart_callback(on_restart_failed, 'dead')
def on_reply(msg):
self.log.debug("Kernel info reply received: %s", kernel_id)
finish()
if not future.done():
future.set_result(msg)
def on_timeout():
self.log.warning("Timeout waiting for kernel_info_reply: %s", kernel_id)
finish()
if not future.done():
future.set_exception(TimeoutError("Timeout waiting for restart"))
def on_restart_failed():
self.log.warning("Restarting kernel failed: %s", kernel_id)
finish()
if not future.done():
future.set_exception(RuntimeError("Restart failed"))
kernel.add_restart_callback(on_restart_failed, 'dead')
kernel.session.send(channel, "kernel_info_request")
channel.on_recv(on_reply)
loop = IOLoop.current()
timeout = loop.add_timeout(loop.time() + self.kernel_info_timeout, on_timeout)
return future
def notify_connect(self, kernel_id):
"""Notice a new connection to a kernel"""
if kernel_id in self._kernel_connections:
self._kernel_connections[kernel_id] += 1
def notify_disconnect(self, kernel_id):
"""Notice a disconnection from a kernel"""
if kernel_id in self._kernel_connections:
self._kernel_connections[kernel_id] -= 1
def kernel_model(self, kernel_id):
"""Return a JSON-safe dict representing a kernel
For use in representing kernels in the JSON APIs.
"""
self._check_kernel_id(kernel_id)
kernel = self._kernels[kernel_id]
model = {
"id": kernel_id,
"name": kernel.kernel_name,
"last_activity": isoformat(kernel.last_activity),
"execution_state": kernel.execution_state,
"connections": self._kernel_connections[kernel_id],
}
return model
def list_kernels(self):
"""Returns a list of kernel_id's of kernels running."""
kernels = []
kernel_ids = self.pinned_superclass.list_kernel_ids(self)
for kernel_id in kernel_ids:
model = self.kernel_model(kernel_id)
kernels.append(model)
return kernels
# override _check_kernel_id to raise 404 instead of KeyError
def _check_kernel_id(self, kernel_id):
"""Check a that a kernel_id exists and raise 404 if not."""
if kernel_id not in self:
raise web.HTTPError(404, u'Kernel does not exist: %s' % kernel_id)
# monitoring activity:
def start_watching_activity(self, kernel_id):
"""Start watching IOPub messages on a kernel for activity.
- update last_activity on every message
- record execution_state from status messages
"""
kernel = self._kernels[kernel_id]
# add busy/activity markers:
kernel.execution_state = 'starting'
kernel.last_activity = utcnow()
kernel._activity_stream = kernel.connect_iopub()
session = Session(
config=kernel.session.config,
key=kernel.session.key,
)
def record_activity(msg_list):
"""Record an IOPub message arriving from a kernel"""
self.last_kernel_activity = kernel.last_activity = utcnow()
idents, fed_msg_list = session.feed_identities(msg_list)
msg = session.deserialize(fed_msg_list)
msg_type = msg['header']['msg_type']
if msg_type == 'status':
kernel.execution_state = msg['content']['execution_state']
self.log.debug("activity on %s: %s (%s)", kernel_id, msg_type, kernel.execution_state)
else:
self.log.debug("activity on %s: %s", kernel_id, msg_type)
kernel._activity_stream.on_recv(record_activity)
def initialize_culler(self):
"""Start idle culler if 'cull_idle_timeout' is greater than zero.
Regardless of that value, set flag that we've been here.
"""
if not self._initialized_culler and self.cull_idle_timeout > 0:
if self._culler_callback is None:
loop = IOLoop.current()
if self.cull_interval <= 0: # handle case where user set invalid value
self.log.warning("Invalid value for 'cull_interval' detected (%s) - using default value (%s).",
self.cull_interval, self.cull_interval_default)
self.cull_interval = self.cull_interval_default
self._culler_callback = PeriodicCallback(
self.cull_kernels, 1000*self.cull_interval)
self.log.info("Culling kernels with idle durations > %s seconds at %s second intervals ...",
self.cull_idle_timeout, self.cull_interval)
if self.cull_busy:
self.log.info("Culling kernels even if busy")
if self.cull_connected:
self.log.info("Culling kernels even with connected clients")
self._culler_callback.start()
self._initialized_culler = True
async def cull_kernels(self):
self.log.debug("Polling every %s seconds for kernels idle > %s seconds...",
self.cull_interval, self.cull_idle_timeout)
"""Create a separate list of kernels to avoid conflicting updates while iterating"""
for kernel_id in list(self._kernels):
try:
await self.cull_kernel_if_idle(kernel_id)
except Exception as e:
self.log.exception("The following exception was encountered while checking the "
"idle duration of kernel {}: {}".format(kernel_id, e))
async def cull_kernel_if_idle(self, kernel_id):
try:
kernel = self._kernels[kernel_id]
except KeyError:
return # KeyErrors are somewhat expected since the kernel can be shutdown as the culling check is made.
if hasattr(kernel, 'last_activity'): # last_activity is monkey-patched, so ensure that has occurred
self.log.debug("kernel_id=%s, kernel_name=%s, last_activity=%s",
kernel_id, kernel.kernel_name, kernel.last_activity)
dt_now = utcnow()
dt_idle = dt_now - kernel.last_activity
# Compute idle properties
is_idle_time = dt_idle > timedelta(seconds=self.cull_idle_timeout)
is_idle_execute = self.cull_busy or (kernel.execution_state != 'busy')
connections = self._kernel_connections.get(kernel_id, 0)
is_idle_connected = self.cull_connected or not connections
# Cull the kernel if all three criteria are met
if (is_idle_time and is_idle_execute and is_idle_connected):
idle_duration = int(dt_idle.total_seconds())
self.log.warning("Culling '%s' kernel '%s' (%s) with %d connections due to %s seconds of inactivity.",
kernel.execution_state, kernel.kernel_name, kernel_id, connections, idle_duration)
await maybe_future(self.shutdown_kernel(kernel_id))
# AsyncMappingKernelManager inherits as much as possible from MappingKernelManager, overriding
# only what is different.
class AsyncMappingKernelManager(MappingKernelManager, AsyncMultiKernelManager):
@default('kernel_manager_class')
def _default_kernel_manager_class(self):
return "jupyter_client.ioloop.AsyncIOLoopKernelManager"
def __init__(self, **kwargs):
# Pin the superclass to better control the MRO.
self.pinned_superclass = AsyncMultiKernelManager
self.pinned_superclass.__init__(self, **kwargs)
self.last_kernel_activity = utcnow()
async def shutdown_kernel(self, kernel_id, now=False, restart=False):
"""Shutdown a kernel by kernel_id"""
self._check_kernel_id(kernel_id)
kernel = self._kernels[kernel_id]
if kernel._activity_stream:
kernel._activity_stream.close()
kernel._activity_stream = None
self.stop_buffering(kernel_id)
self._kernel_connections.pop(kernel_id, None)
# Decrease the metric of number of kernels
# running for the relevant kernel type by 1
KERNEL_CURRENTLY_RUNNING_TOTAL.labels(
type=self._kernels[kernel_id].kernel_name
).dec()
return await self.pinned_superclass.shutdown_kernel(self, kernel_id, now=now, restart=restart)

View file

@ -0,0 +1,283 @@
"""Test the kernels service API."""
import json
import sys
import time
from requests import HTTPError
from traitlets.config import Config
from tornado.httpclient import HTTPRequest
from tornado.ioloop import IOLoop
from tornado.websocket import websocket_connect
from unittest import SkipTest
from jupyter_client.kernelspec import NATIVE_KERNEL_NAME
from notebook.utils import url_path_join
from notebook.tests.launchnotebook import NotebookTestBase, assert_http_error
try:
from jupyter_client import AsyncMultiKernelManager
async_testing_enabled = True
except ImportError:
async_testing_enabled = False
class KernelAPI(object):
"""Wrapper for kernel REST API requests"""
def __init__(self, request, base_url, headers):
self.request = request
self.base_url = base_url
self.headers = headers
def _req(self, verb, path, body=None):
response = self.request(verb,
url_path_join('api/kernels', path), data=body)
if 400 <= response.status_code < 600:
try:
response.reason = response.json()['message']
except:
pass
response.raise_for_status()
return response
def list(self):
return self._req('GET', '')
def get(self, id):
return self._req('GET', id)
def start(self, name=NATIVE_KERNEL_NAME):
body = json.dumps({'name': name})
return self._req('POST', '', body)
def shutdown(self, id):
return self._req('DELETE', id)
def interrupt(self, id):
return self._req('POST', url_path_join(id, 'interrupt'))
def restart(self, id):
return self._req('POST', url_path_join(id, 'restart'))
def websocket(self, id):
loop = IOLoop()
loop.make_current()
req = HTTPRequest(
url_path_join(self.base_url.replace('http', 'ws', 1), 'api/kernels', id, 'channels'),
headers=self.headers,
)
f = websocket_connect(req)
return loop.run_sync(lambda : f)
class KernelAPITest(NotebookTestBase):
"""Test the kernels web service API"""
def setUp(self):
self.kern_api = KernelAPI(self.request,
base_url=self.base_url(),
headers=self.auth_headers(),
)
def tearDown(self):
for k in self.kern_api.list().json():
self.kern_api.shutdown(k['id'])
def test_no_kernels(self):
"""Make sure there are no kernels running at the start"""
kernels = self.kern_api.list().json()
self.assertEqual(kernels, [])
def test_default_kernel(self):
# POST request
r = self.kern_api._req('POST', '')
kern1 = r.json()
self.assertEqual(r.headers['location'], url_path_join(self.url_prefix, 'api/kernels', kern1['id']))
self.assertEqual(r.status_code, 201)
self.assertIsInstance(kern1, dict)
report_uri = url_path_join(self.url_prefix, 'api/security/csp-report')
expected_csp = '; '.join([
"frame-ancestors 'self'",
'report-uri ' + report_uri,
"default-src 'none'"
])
self.assertEqual(r.headers['Content-Security-Policy'], expected_csp)
def test_main_kernel_handler(self):
# POST request
r = self.kern_api.start()
kern1 = r.json()
self.assertEqual(r.headers['location'], url_path_join(self.url_prefix, 'api/kernels', kern1['id']))
self.assertEqual(r.status_code, 201)
self.assertIsInstance(kern1, dict)
report_uri = url_path_join(self.url_prefix, 'api/security/csp-report')
expected_csp = '; '.join([
"frame-ancestors 'self'",
'report-uri ' + report_uri,
"default-src 'none'"
])
self.assertEqual(r.headers['Content-Security-Policy'], expected_csp)
# GET request
r = self.kern_api.list()
self.assertEqual(r.status_code, 200)
assert isinstance(r.json(), list)
self.assertEqual(r.json()[0]['id'], kern1['id'])
self.assertEqual(r.json()[0]['name'], kern1['name'])
# create another kernel and check that they both are added to the
# list of kernels from a GET request
kern2 = self.kern_api.start().json()
assert isinstance(kern2, dict)
r = self.kern_api.list()
kernels = r.json()
self.assertEqual(r.status_code, 200)
assert isinstance(kernels, list)
self.assertEqual(len(kernels), 2)
# Interrupt a kernel
r = self.kern_api.interrupt(kern2['id'])
self.assertEqual(r.status_code, 204)
# Restart a kernel
r = self.kern_api.restart(kern2['id'])
rekern = r.json()
self.assertEqual(rekern['id'], kern2['id'])
self.assertEqual(rekern['name'], kern2['name'])
def test_kernel_handler(self):
# GET kernel with given id
kid = self.kern_api.start().json()['id']
r = self.kern_api.get(kid)
kern1 = r.json()
self.assertEqual(r.status_code, 200)
assert isinstance(kern1, dict)
self.assertIn('id', kern1)
self.assertEqual(kern1['id'], kid)
# Request a bad kernel id and check that a JSON
# message is returned!
bad_id = '111-111-111-111-111'
with assert_http_error(404, 'Kernel does not exist: ' + bad_id):
self.kern_api.get(bad_id)
# DELETE kernel with id
r = self.kern_api.shutdown(kid)
self.assertEqual(r.status_code, 204)
kernels = self.kern_api.list().json()
self.assertEqual(kernels, [])
# Request to delete a non-existent kernel id
bad_id = '111-111-111-111-111'
with assert_http_error(404, 'Kernel does not exist: ' + bad_id):
self.kern_api.shutdown(bad_id)
def test_connections(self):
kid = self.kern_api.start().json()['id']
model = self.kern_api.get(kid).json()
self.assertEqual(model['connections'], 0)
ws = self.kern_api.websocket(kid)
model = self.kern_api.get(kid).json()
self.assertEqual(model['connections'], 1)
ws.close()
# give it some time to close on the other side:
for i in range(10):
model = self.kern_api.get(kid).json()
if model['connections'] > 0:
time.sleep(0.1)
else:
break
model = self.kern_api.get(kid).json()
self.assertEqual(model['connections'], 0)
class AsyncKernelAPITest(KernelAPITest):
"""Test the kernels web service API using the AsyncMappingKernelManager"""
@classmethod
def setup_class(cls):
if not async_testing_enabled: # Can be removed once jupyter_client >= 6.1 is required.
raise SkipTest("AsyncKernelAPITest tests skipped due to down-level jupyter_client!")
if sys.version_info < (3, 6): # Can be removed once 3.5 is dropped.
raise SkipTest("AsyncKernelAPITest tests skipped due to Python < 3.6!")
super(AsyncKernelAPITest, cls).setup_class()
@classmethod
def get_argv(cls):
argv = super(AsyncKernelAPITest, cls).get_argv()
# before we extend the argv with the class, ensure that appropriate jupyter_client is available.
# if not available, don't set kernel_manager_class, resulting in the repeat of sync-based tests.
if async_testing_enabled:
argv.extend(['--NotebookApp.kernel_manager_class='
'notebook.services.kernels.kernelmanager.AsyncMappingKernelManager'])
return argv
class KernelFilterTest(NotebookTestBase):
# A special install of NotebookTestBase where only `kernel_info_request`
# messages are allowed.
config = Config({
'NotebookApp': {
'MappingKernelManager': {
'allowed_message_types': ['kernel_info_request']
}
}
})
# Sanity check verifying that the configurable was properly set.
def test_config(self):
self.assertEqual(self.notebook.kernel_manager.allowed_message_types, ['kernel_info_request'])
class KernelCullingTest(NotebookTestBase):
"""Test kernel culling """
@classmethod
def get_argv(cls):
argv = super(KernelCullingTest, cls).get_argv()
# Enable culling with 2s timeout and 1s intervals
argv.extend(['--MappingKernelManager.cull_idle_timeout=2',
'--MappingKernelManager.cull_interval=1',
'--MappingKernelManager.cull_connected=False'])
return argv
def setUp(self):
self.kern_api = KernelAPI(self.request,
base_url=self.base_url(),
headers=self.auth_headers(),
)
def tearDown(self):
for k in self.kern_api.list().json():
self.kern_api.shutdown(k['id'])
def test_culling(self):
kid = self.kern_api.start().json()['id']
ws = self.kern_api.websocket(kid)
model = self.kern_api.get(kid).json()
self.assertEqual(model['connections'], 1)
assert not self.get_cull_status(kid) # connected, should not be culled
ws.close()
assert self.get_cull_status(kid) # not connected, should be culled
def get_cull_status(self, kid):
culled = False
for i in range(15): # Need max of 3s to ensure culling timeout exceeded
try:
self.kern_api.get(kid)
except HTTPError as e:
assert e.response.status_code == 404
culled = True
break
else:
time.sleep(0.2)
return culled

View file

@ -0,0 +1,107 @@
"""Tornado handlers for kernel specifications.
Preliminary documentation at https://github.com/ipython/ipython/wiki/IPEP-25%3A-Registry-of-installed-kernels#rest-api
"""
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
import glob
import json
import os
pjoin = os.path.join
from tornado import web, gen
from ...base.handlers import APIHandler
from ...utils import maybe_future, url_path_join, url_unescape
def kernelspec_model(handler, name, spec_dict, resource_dir):
"""Load a KernelSpec by name and return the REST API model"""
d = {
'name': name,
'spec': spec_dict,
'resources': {}
}
# Add resource files if they exist
resource_dir = resource_dir
for resource in ['kernel.js', 'kernel.css']:
if os.path.exists(pjoin(resource_dir, resource)):
d['resources'][resource] = url_path_join(
handler.base_url,
'kernelspecs',
name,
resource
)
for logo_file in glob.glob(pjoin(resource_dir, 'logo-*')):
fname = os.path.basename(logo_file)
no_ext, _ = os.path.splitext(fname)
d['resources'][no_ext] = url_path_join(
handler.base_url,
'kernelspecs',
name,
fname
)
return d
def is_kernelspec_model(spec_dict):
"""Returns True if spec_dict is already in proper form. This will occur when using a gateway."""
return isinstance(spec_dict, dict) and 'name' in spec_dict and 'spec' in spec_dict and 'resources' in spec_dict
class MainKernelSpecHandler(APIHandler):
@web.authenticated
@gen.coroutine
def get(self):
ksm = self.kernel_spec_manager
km = self.kernel_manager
model = {}
model['default'] = km.default_kernel_name
model['kernelspecs'] = specs = {}
kspecs = yield maybe_future(ksm.get_all_specs())
for kernel_name, kernel_info in kspecs.items():
try:
if is_kernelspec_model(kernel_info):
d = kernel_info
else:
d = kernelspec_model(self, kernel_name, kernel_info['spec'], kernel_info['resource_dir'])
except Exception:
self.log.error("Failed to load kernel spec: '%s'", kernel_name, exc_info=True)
continue
specs[kernel_name] = d
self.set_header("Content-Type", 'application/json')
self.finish(json.dumps(model))
class KernelSpecHandler(APIHandler):
@web.authenticated
@gen.coroutine
def get(self, kernel_name):
ksm = self.kernel_spec_manager
kernel_name = url_unescape(kernel_name)
try:
spec = yield maybe_future(ksm.get_kernel_spec(kernel_name))
except KeyError as e:
raise web.HTTPError(404, u'Kernel spec %s not found' % kernel_name) from e
if is_kernelspec_model(spec):
model = spec
else:
model = kernelspec_model(self, kernel_name, spec.to_dict(), spec.resource_dir)
self.set_header("Content-Type", 'application/json')
self.finish(json.dumps(model))
# URL to handler mappings
kernel_name_regex = r"(?P<kernel_name>[\w\.\-%]+)"
default_handlers = [
(r"/api/kernelspecs", MainKernelSpecHandler),
(r"/api/kernelspecs/%s" % kernel_name_regex, KernelSpecHandler),
]

View file

@ -0,0 +1,137 @@
# coding: utf-8
"""Test the kernel specs webservice API."""
import errno
import io
import json
import os
import shutil
pjoin = os.path.join
import requests
from jupyter_client.kernelspec import NATIVE_KERNEL_NAME
from notebook.utils import url_path_join, url_escape
from notebook.tests.launchnotebook import NotebookTestBase, assert_http_error
# Copied from jupyter_client.tests.test_kernelspec so updating that doesn't
# break these tests
sample_kernel_json = {'argv':['cat', '{connection_file}'],
'display_name':'Test kernel',
}
some_resource = u"The very model of a modern major general"
class KernelSpecAPI(object):
"""Wrapper for notebook API calls."""
def __init__(self, request):
self.request = request
def _req(self, verb, path, body=None):
response = self.request(verb,
path,
data=body,
)
response.raise_for_status()
return response
def list(self):
return self._req('GET', 'api/kernelspecs')
def kernel_spec_info(self, name):
return self._req('GET', url_path_join('api/kernelspecs', name))
def kernel_resource(self, name, path):
return self._req('GET', url_path_join('kernelspecs', name, path))
class APITest(NotebookTestBase):
"""Test the kernelspec web service API"""
def setUp(self):
self.create_spec('sample')
self.create_spec('sample 2')
self.ks_api = KernelSpecAPI(self.request)
def create_spec(self, name):
sample_kernel_dir = pjoin(self.data_dir, 'kernels', name)
try:
os.makedirs(sample_kernel_dir)
except OSError as e:
if e.errno != errno.EEXIST:
raise
with open(pjoin(sample_kernel_dir, 'kernel.json'), 'w') as f:
json.dump(sample_kernel_json, f)
with io.open(pjoin(sample_kernel_dir, 'resource.txt'), 'w',
encoding='utf-8') as f:
f.write(some_resource)
def test_list_kernelspecs_bad(self):
"""Can list kernelspecs when one is invalid"""
bad_kernel_dir = pjoin(self.data_dir, 'kernels', 'bad')
try:
os.makedirs(bad_kernel_dir)
except OSError as e:
if e.errno != errno.EEXIST:
raise
with open(pjoin(bad_kernel_dir, 'kernel.json'), 'w') as f:
f.write("garbage")
model = self.ks_api.list().json()
assert isinstance(model, dict)
self.assertEqual(model['default'], NATIVE_KERNEL_NAME)
specs = model['kernelspecs']
assert isinstance(specs, dict)
# 2: the sample kernelspec created in setUp, and the native Python kernel
self.assertGreaterEqual(len(specs), 2)
shutil.rmtree(bad_kernel_dir)
def test_list_kernelspecs(self):
model = self.ks_api.list().json()
assert isinstance(model, dict)
self.assertEqual(model['default'], NATIVE_KERNEL_NAME)
specs = model['kernelspecs']
assert isinstance(specs, dict)
# 2: the sample kernelspec created in setUp, and the native Python kernel
self.assertGreaterEqual(len(specs), 2)
def is_sample_kernelspec(s):
return s['name'] == 'sample' and s['spec']['display_name'] == 'Test kernel'
def is_default_kernelspec(s):
return s['name'] == NATIVE_KERNEL_NAME and s['spec']['display_name'].startswith("Python")
assert any(is_sample_kernelspec(s) for s in specs.values()), specs
assert any(is_default_kernelspec(s) for s in specs.values()), specs
def test_get_kernelspec(self):
model = self.ks_api.kernel_spec_info('Sample').json() # Case insensitive
self.assertEqual(model['name'].lower(), 'sample')
self.assertIsInstance(model['spec'], dict)
self.assertEqual(model['spec']['display_name'], 'Test kernel')
self.assertIsInstance(model['resources'], dict)
def test_get_kernelspec_spaces(self):
model = self.ks_api.kernel_spec_info('sample%202').json()
self.assertEqual(model['name'].lower(), 'sample 2')
def test_get_nonexistant_kernelspec(self):
with assert_http_error(404):
self.ks_api.kernel_spec_info('nonexistant')
def test_get_kernel_resource_file(self):
res = self.ks_api.kernel_resource('sAmple', 'resource.txt')
self.assertEqual(res.text, some_resource)
def test_get_nonexistant_resource(self):
with assert_http_error(404):
self.ks_api.kernel_resource('nonexistant', 'resource.txt')
with assert_http_error(404):
self.ks_api.kernel_resource('sample', 'nonexistant.txt')

View file

@ -0,0 +1,39 @@
import json
from tornado import web
from ...base.handlers import APIHandler
class NbconvertRootHandler(APIHandler):
@web.authenticated
def get(self):
self.check_xsrf_cookie()
try:
from nbconvert.exporters import base
except ImportError as e:
raise web.HTTPError(500, "Could not import nbconvert: %s" % e) from e
res = {}
exporters = base.get_export_names()
for exporter_name in exporters:
try:
exporter_class = base.get_exporter(exporter_name)
except ValueError:
# I think the only way this will happen is if the entrypoint
# is uninstalled while this method is running
continue
# XXX: According to the docs, it looks like this should be set to None
# if the exporter shouldn't be exposed to the front-end and a friendly
# name if it should. However, none of the built-in exports have it defined.
# if not exporter_class.export_from_notebook:
# continue
res[exporter_name] = {
"output_mimetype": exporter_class.output_mimetype,
}
self.finish(json.dumps(res))
default_handlers = [
(r"/api/nbconvert", NbconvertRootHandler),
]

View file

@ -0,0 +1,31 @@
import requests
from notebook.utils import url_path_join
from notebook.tests.launchnotebook import NotebookTestBase
class NbconvertAPI(object):
"""Wrapper for nbconvert API calls."""
def __init__(self, request):
self.request = request
def _req(self, verb, path, body=None, params=None):
response = self.request(verb,
url_path_join('api/nbconvert', path),
data=body, params=params,
)
response.raise_for_status()
return response
def list_formats(self):
return self._req('GET', '')
class APITest(NotebookTestBase):
def setUp(self):
self.nbconvert_api = NbconvertAPI(self.request)
def test_list_formats(self):
formats = self.nbconvert_api.list_formats().json()
self.assertIsInstance(formats, dict)
self.assertIn('python', formats)
self.assertIn('html', formats)
self.assertEqual(formats['python']['output_mimetype'], 'text/x-python')

View file

@ -0,0 +1,4 @@
# URI for the CSP Report. Included here to prevent a cyclic dependency.
# csp_report_uri is needed both by the BaseHandler (for setting the report-uri)
# and by the CSPReportHandler (which depends on the BaseHandler).
csp_report_uri = r"/api/security/csp-report"

View file

@ -0,0 +1,32 @@
"""Tornado handlers for security logging."""
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
from tornado import web
from ...base.handlers import APIHandler
from . import csp_report_uri
class CSPReportHandler(APIHandler):
'''Accepts a content security policy violation report'''
_track_activity = False
def skip_check_origin(self):
"""Don't check origin when reporting origin-check violations!"""
return True
def check_xsrf_cookie(self):
# don't check XSRF for CSP reports
return
@web.authenticated
def post(self):
'''Log a content security policy violation report'''
self.log.warning("Content security violation: %s",
self.request.body.decode('utf8', 'replace'))
default_handlers = [
(csp_report_uri, CSPReportHandler)
]

View file

@ -0,0 +1,175 @@
"""Tornado handlers for the sessions web service.
Preliminary documentation at https://github.com/ipython/ipython/wiki/IPEP-16%3A-Notebook-multi-directory-dashboard-and-URL-mapping#sessions-api
"""
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
import json
from tornado import gen, web
from ...base.handlers import APIHandler
from jupyter_client.jsonutil import date_default
from notebook.utils import maybe_future, url_path_join
from jupyter_client.kernelspec import NoSuchKernel
class SessionRootHandler(APIHandler):
@web.authenticated
@gen.coroutine
def get(self):
# Return a list of running sessions
sm = self.session_manager
sessions = yield maybe_future(sm.list_sessions())
self.finish(json.dumps(sessions, default=date_default))
@web.authenticated
@gen.coroutine
def post(self):
# Creates a new session
#(unless a session already exists for the named session)
sm = self.session_manager
model = self.get_json_body()
if model is None:
raise web.HTTPError(400, "No JSON data provided")
if 'notebook' in model and 'path' in model['notebook']:
self.log.warning('Sessions API changed, see updated swagger docs')
model['path'] = model['notebook']['path']
model['type'] = 'notebook'
try:
path = model['path']
except KeyError as e:
raise web.HTTPError(400, "Missing field in JSON data: path") from e
try:
mtype = model['type']
except KeyError as e:
raise web.HTTPError(400, "Missing field in JSON data: type") from e
name = model.get('name', None)
kernel = model.get('kernel', {})
kernel_name = kernel.get('name', None)
kernel_id = kernel.get('id', None)
if not kernel_id and not kernel_name:
self.log.debug("No kernel specified, using default kernel")
kernel_name = None
exists = yield maybe_future(sm.session_exists(path=path))
if exists:
model = yield maybe_future(sm.get_session(path=path))
else:
try:
model = yield maybe_future(
sm.create_session(path=path, kernel_name=kernel_name,
kernel_id=kernel_id, name=name,
type=mtype))
except NoSuchKernel:
msg = ("The '%s' kernel is not available. Please pick another "
"suitable kernel instead, or install that kernel." % kernel_name)
status_msg = '%s not found' % kernel_name
self.log.warning('Kernel not found: %s' % kernel_name)
self.set_status(501)
self.finish(json.dumps(dict(message=msg, short_message=status_msg)))
return
location = url_path_join(self.base_url, 'api', 'sessions', model['id'])
self.set_header('Location', location)
self.set_status(201)
self.finish(json.dumps(model, default=date_default))
class SessionHandler(APIHandler):
@web.authenticated
@gen.coroutine
def get(self, session_id):
# Returns the JSON model for a single session
sm = self.session_manager
model = yield maybe_future(sm.get_session(session_id=session_id))
self.finish(json.dumps(model, default=date_default))
@web.authenticated
@gen.coroutine
def patch(self, session_id):
"""Patch updates sessions:
- path updates session to track renamed paths
- kernel.name starts a new kernel with a given kernelspec
"""
sm = self.session_manager
km = self.kernel_manager
model = self.get_json_body()
if model is None:
raise web.HTTPError(400, "No JSON data provided")
# get the previous session model
before = yield maybe_future(sm.get_session(session_id=session_id))
changes = {}
if 'notebook' in model and 'path' in model['notebook']:
self.log.warning('Sessions API changed, see updated swagger docs')
model['path'] = model['notebook']['path']
model['type'] = 'notebook'
if 'path' in model:
changes['path'] = model['path']
if 'name' in model:
changes['name'] = model['name']
if 'type' in model:
changes['type'] = model['type']
if 'kernel' in model:
# Kernel id takes precedence over name.
if model['kernel'].get('id') is not None:
kernel_id = model['kernel']['id']
if kernel_id not in km:
raise web.HTTPError(400, "No such kernel: %s" % kernel_id)
changes['kernel_id'] = kernel_id
elif model['kernel'].get('name') is not None:
kernel_name = model['kernel']['name']
kernel_id = yield sm.start_kernel_for_session(
session_id, kernel_name=kernel_name, name=before['name'],
path=before['path'], type=before['type'])
changes['kernel_id'] = kernel_id
yield maybe_future(sm.update_session(session_id, **changes))
model = yield maybe_future(sm.get_session(session_id=session_id))
if model['kernel']['id'] != before['kernel']['id']:
# kernel_id changed because we got a new kernel
# shutdown the old one
yield maybe_future(
km.shutdown_kernel(before['kernel']['id'])
)
self.finish(json.dumps(model, default=date_default))
@web.authenticated
@gen.coroutine
def delete(self, session_id):
# Deletes the session with given session_id
sm = self.session_manager
try:
yield maybe_future(sm.delete_session(session_id))
except KeyError as e:
# the kernel was deleted but the session wasn't!
raise web.HTTPError(410, "Kernel deleted before session") from e
self.set_status(204)
self.finish()
#-----------------------------------------------------------------------------
# URL to handler mappings
#-----------------------------------------------------------------------------
_session_id_regex = r"(?P<session_id>\w+-\w+-\w+-\w+-\w+)"
default_handlers = [
(r"/api/sessions/%s" % _session_id_regex, SessionHandler),
(r"/api/sessions", SessionRootHandler)
]

View file

@ -0,0 +1,275 @@
"""A base class session manager."""
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
import uuid
try:
import sqlite3
except ImportError:
# fallback on pysqlite2 if Python was build without sqlite
from pysqlite2 import dbapi2 as sqlite3
from tornado import gen, web
from traitlets.config.configurable import LoggingConfigurable
from ipython_genutils.py3compat import unicode_type
from traitlets import Instance
from notebook.utils import maybe_future
class SessionManager(LoggingConfigurable):
kernel_manager = Instance('notebook.services.kernels.kernelmanager.MappingKernelManager')
contents_manager = Instance('notebook.services.contents.manager.ContentsManager')
# Session database initialized below
_cursor = None
_connection = None
_columns = {'session_id', 'path', 'name', 'type', 'kernel_id'}
@property
def cursor(self):
"""Start a cursor and create a database called 'session'"""
if self._cursor is None:
self._cursor = self.connection.cursor()
self._cursor.execute("""CREATE TABLE session
(session_id, path, name, type, kernel_id)""")
return self._cursor
@property
def connection(self):
"""Start a database connection"""
if self._connection is None:
self._connection = sqlite3.connect(':memory:')
self._connection.row_factory = sqlite3.Row
return self._connection
def close(self):
"""Close the sqlite connection"""
if self._cursor is not None:
self._cursor.close()
self._cursor = None
def __del__(self):
"""Close connection once SessionManager closes"""
self.close()
@gen.coroutine
def session_exists(self, path):
"""Check to see if the session of a given name exists"""
exists = False
self.cursor.execute("SELECT * FROM session WHERE path=?", (path,))
row = self.cursor.fetchone()
if row is not None:
# Note, although we found a row for the session, the associated kernel may have
# been culled or died unexpectedly. If that's the case, we should delete the
# row, thereby terminating the session. This can be done via a call to
# row_to_model that tolerates that condition. If row_to_model returns None,
# we'll return false, since, at that point, the session doesn't exist anyway.
model = yield maybe_future(self.row_to_model(row, tolerate_culled=True))
if model is not None:
exists = True
raise gen.Return(exists)
def new_session_id(self):
"Create a uuid for a new session"
return unicode_type(uuid.uuid4())
@gen.coroutine
def create_session(self, path=None, name=None, type=None, kernel_name=None, kernel_id=None):
"""Creates a session and returns its model"""
session_id = self.new_session_id()
if kernel_id is not None and kernel_id in self.kernel_manager:
pass
else:
kernel_id = yield self.start_kernel_for_session(session_id, path, name, type, kernel_name)
result = yield maybe_future(
self.save_session(session_id, path=path, name=name, type=type, kernel_id=kernel_id)
)
# py2-compat
raise gen.Return(result)
@gen.coroutine
def start_kernel_for_session(self, session_id, path, name, type, kernel_name):
"""Start a new kernel for a given session."""
# allow contents manager to specify kernels cwd
kernel_path = self.contents_manager.get_kernel_path(path=path)
kernel_id = yield maybe_future(
self.kernel_manager.start_kernel(path=kernel_path, kernel_name=kernel_name)
)
# py2-compat
raise gen.Return(kernel_id)
@gen.coroutine
def save_session(self, session_id, path=None, name=None, type=None, kernel_id=None):
"""Saves the items for the session with the given session_id
Given a session_id (and any other of the arguments), this method
creates a row in the sqlite session database that holds the information
for a session.
Parameters
----------
session_id : str
uuid for the session; this method must be given a session_id
path : str
the path for the given session
name: str
the name of the session
type: string
the type of the session
kernel_id : str
a uuid for the kernel associated with this session
Returns
-------
model : dict
a dictionary of the session model
"""
self.cursor.execute("INSERT INTO session VALUES (?,?,?,?,?)",
(session_id, path, name, type, kernel_id)
)
result = yield maybe_future(self.get_session(session_id=session_id))
raise gen.Return(result)
@gen.coroutine
def get_session(self, **kwargs):
"""Returns the model for a particular session.
Takes a keyword argument and searches for the value in the session
database, then returns the rest of the session's info.
Parameters
----------
**kwargs : keyword argument
must be given one of the keywords and values from the session database
(i.e. session_id, path, name, type, kernel_id)
Returns
-------
model : dict
returns a dictionary that includes all the information from the
session described by the kwarg.
"""
if not kwargs:
raise TypeError("must specify a column to query")
conditions = []
for column in kwargs.keys():
if column not in self._columns:
raise TypeError("No such column: %r", column)
conditions.append("%s=?" % column)
query = "SELECT * FROM session WHERE %s" % (' AND '.join(conditions))
self.cursor.execute(query, list(kwargs.values()))
try:
row = self.cursor.fetchone()
except KeyError:
# The kernel is missing, so the session just got deleted.
row = None
if row is None:
q = []
for key, value in kwargs.items():
q.append("%s=%r" % (key, value))
raise web.HTTPError(404, u'Session not found: %s' % (', '.join(q)))
model = yield maybe_future(self.row_to_model(row))
raise gen.Return(model)
@gen.coroutine
def update_session(self, session_id, **kwargs):
"""Updates the values in the session database.
Changes the values of the session with the given session_id
with the values from the keyword arguments.
Parameters
----------
session_id : str
a uuid that identifies a session in the sqlite3 database
**kwargs : str
the key must correspond to a column title in session database,
and the value replaces the current value in the session
with session_id.
"""
yield maybe_future(self.get_session(session_id=session_id))
if not kwargs:
# no changes
return
sets = []
for column in kwargs.keys():
if column not in self._columns:
raise TypeError("No such column: %r" % column)
sets.append("%s=?" % column)
query = "UPDATE session SET %s WHERE session_id=?" % (', '.join(sets))
self.cursor.execute(query, list(kwargs.values()) + [session_id])
def kernel_culled(self, kernel_id):
"""Checks if the kernel is still considered alive and returns true if its not found. """
return kernel_id not in self.kernel_manager
@gen.coroutine
def row_to_model(self, row, tolerate_culled=False):
"""Takes sqlite database session row and turns it into a dictionary"""
kernel_culled = yield maybe_future(self.kernel_culled(row['kernel_id']))
if kernel_culled:
# The kernel was culled or died without deleting the session.
# We can't use delete_session here because that tries to find
# and shut down the kernel - so we'll delete the row directly.
#
# If caller wishes to tolerate culled kernels, log a warning
# and return None. Otherwise, raise KeyError with a similar
# message.
self.cursor.execute("DELETE FROM session WHERE session_id=?",
(row['session_id'],))
msg = "Kernel '{kernel_id}' appears to have been culled or died unexpectedly, " \
"invalidating session '{session_id}'. The session has been removed.".\
format(kernel_id=row['kernel_id'],session_id=row['session_id'])
if tolerate_culled:
self.log.warning(msg + " Continuing...")
raise gen.Return(None)
raise KeyError(msg)
kernel_model = yield maybe_future(self.kernel_manager.kernel_model(row['kernel_id']))
model = {
'id': row['session_id'],
'path': row['path'],
'name': row['name'],
'type': row['type'],
'kernel': kernel_model
}
if row['type'] == 'notebook':
# Provide the deprecated API.
model['notebook'] = {'path': row['path'], 'name': row['name']}
raise gen.Return(model)
@gen.coroutine
def list_sessions(self):
"""Returns a list of dictionaries containing all the information from
the session database"""
c = self.cursor.execute("SELECT * FROM session")
result = []
# We need to use fetchall() here, because row_to_model can delete rows,
# which messes up the cursor if we're iterating over rows.
for row in c.fetchall():
try:
model = yield maybe_future(self.row_to_model(row))
result.append(model)
except KeyError:
pass
raise gen.Return(result)
@gen.coroutine
def delete_session(self, session_id):
"""Deletes the row in the session database with given session_id"""
session = yield maybe_future(self.get_session(session_id=session_id))
yield maybe_future(self.kernel_manager.shutdown_kernel(session['kernel']['id']))
self.cursor.execute("DELETE FROM session WHERE session_id=?", (session_id,))

View file

@ -0,0 +1,256 @@
"""Tests for the session manager."""
from functools import partial
from unittest import TestCase
from tornado import gen, web
from tornado.ioloop import IOLoop
from ..sessionmanager import SessionManager
from notebook.services.kernels.kernelmanager import MappingKernelManager
from notebook.services.contents.manager import ContentsManager
from notebook._tz import utcnow, isoformat
class DummyKernel(object):
def __init__(self, kernel_name='python'):
self.kernel_name = kernel_name
dummy_date = utcnow()
dummy_date_s = isoformat(dummy_date)
class DummyMKM(MappingKernelManager):
"""MappingKernelManager interface that doesn't start kernels, for testing"""
def __init__(self, *args, **kwargs):
super(DummyMKM, self).__init__(*args, **kwargs)
self.id_letters = iter(u'ABCDEFGHIJK')
def _new_id(self):
return next(self.id_letters)
def start_kernel(self, kernel_id=None, path=None, kernel_name='python', **kwargs):
kernel_id = kernel_id or self._new_id()
k = self._kernels[kernel_id] = DummyKernel(kernel_name=kernel_name)
self._kernel_connections[kernel_id] = 0
k.last_activity = dummy_date
k.execution_state = 'idle'
return kernel_id
def shutdown_kernel(self, kernel_id, now=False):
del self._kernels[kernel_id]
class TestSessionManager(TestCase):
def setUp(self):
self.sm = SessionManager(
kernel_manager=DummyMKM(),
contents_manager=ContentsManager(),
)
self.loop = IOLoop()
self.addCleanup(partial(self.loop.close, all_fds=True))
def create_sessions(self, *kwarg_list):
@gen.coroutine
def co_add():
sessions = []
for kwargs in kwarg_list:
kwargs.setdefault('type', 'notebook')
session = yield self.sm.create_session(**kwargs)
sessions.append(session)
raise gen.Return(sessions)
return self.loop.run_sync(co_add)
def create_session(self, **kwargs):
return self.create_sessions(kwargs)[0]
def test_get_session(self):
sm = self.sm
session_id = self.create_session(path='/path/to/test.ipynb', kernel_name='bar')['id']
model = self.loop.run_sync(lambda: sm.get_session(session_id=session_id))
expected = {'id':session_id,
'path': u'/path/to/test.ipynb',
'notebook': {'path': u'/path/to/test.ipynb', 'name': None},
'type': 'notebook',
'name': None,
'kernel': {
'id': 'A',
'name': 'bar',
'connections': 0,
'last_activity': dummy_date_s,
'execution_state': 'idle',
}}
self.assertEqual(model, expected)
def test_bad_get_session(self):
# Should raise error if a bad key is passed to the database.
sm = self.sm
session_id = self.create_session(path='/path/to/test.ipynb',
kernel_name='foo')['id']
with self.assertRaises(TypeError):
self.loop.run_sync(lambda: sm.get_session(bad_id=session_id)) # Bad keyword
def test_get_session_dead_kernel(self):
sm = self.sm
session = self.create_session(path='/path/to/1/test1.ipynb', kernel_name='python')
# kill the kernel
sm.kernel_manager.shutdown_kernel(session['kernel']['id'])
with self.assertRaises(KeyError):
self.loop.run_sync(lambda: sm.get_session(session_id=session['id']))
# no sessions left
listed = self.loop.run_sync(lambda: sm.list_sessions())
self.assertEqual(listed, [])
def test_list_sessions(self):
sm = self.sm
sessions = self.create_sessions(
dict(path='/path/to/1/test1.ipynb', kernel_name='python'),
dict(path='/path/to/2/test2.py', type='file', kernel_name='python'),
dict(path='/path/to/3', name='foo', type='console', kernel_name='python'),
)
sessions = self.loop.run_sync(lambda: sm.list_sessions())
expected = [
{
'id':sessions[0]['id'],
'path': u'/path/to/1/test1.ipynb',
'type': 'notebook',
'notebook': {'path': u'/path/to/1/test1.ipynb', 'name': None},
'name': None,
'kernel': {
'id': 'A',
'name':'python',
'connections': 0,
'last_activity': dummy_date_s,
'execution_state': 'idle',
}
}, {
'id':sessions[1]['id'],
'path': u'/path/to/2/test2.py',
'type': 'file',
'name': None,
'kernel': {
'id': 'B',
'name':'python',
'connections': 0,
'last_activity': dummy_date_s,
'execution_state': 'idle',
}
}, {
'id':sessions[2]['id'],
'path': u'/path/to/3',
'type': 'console',
'name': 'foo',
'kernel': {
'id': 'C',
'name':'python',
'connections': 0,
'last_activity': dummy_date_s,
'execution_state': 'idle',
}
}
]
self.assertEqual(sessions, expected)
def test_list_sessions_dead_kernel(self):
sm = self.sm
sessions = self.create_sessions(
dict(path='/path/to/1/test1.ipynb', kernel_name='python'),
dict(path='/path/to/2/test2.ipynb', kernel_name='python'),
)
# kill one of the kernels
sm.kernel_manager.shutdown_kernel(sessions[0]['kernel']['id'])
listed = self.loop.run_sync(lambda: sm.list_sessions())
expected = [
{
'id': sessions[1]['id'],
'path': u'/path/to/2/test2.ipynb',
'type': 'notebook',
'name': None,
'notebook': {'path': u'/path/to/2/test2.ipynb', 'name': None},
'kernel': {
'id': 'B',
'name':'python',
'connections': 0,
'last_activity': dummy_date_s,
'execution_state': 'idle',
}
}
]
self.assertEqual(listed, expected)
def test_update_session(self):
sm = self.sm
session_id = self.create_session(path='/path/to/test.ipynb',
kernel_name='julia')['id']
self.loop.run_sync(lambda: sm.update_session(session_id, path='/path/to/new_name.ipynb'))
model = self.loop.run_sync(lambda: sm.get_session(session_id=session_id))
expected = {'id':session_id,
'path': u'/path/to/new_name.ipynb',
'type': 'notebook',
'name': None,
'notebook': {'path': u'/path/to/new_name.ipynb', 'name': None},
'kernel': {
'id': 'A',
'name':'julia',
'connections': 0,
'last_activity': dummy_date_s,
'execution_state': 'idle',
}
}
self.assertEqual(model, expected)
def test_bad_update_session(self):
# try to update a session with a bad keyword ~ raise error
sm = self.sm
session_id = self.create_session(path='/path/to/test.ipynb',
kernel_name='ir')['id']
with self.assertRaises(TypeError):
self.loop.run_sync(lambda: sm.update_session(session_id=session_id, bad_kw='test.ipynb')) # Bad keyword
def test_delete_session(self):
sm = self.sm
sessions = self.create_sessions(
dict(path='/path/to/1/test1.ipynb', kernel_name='python'),
dict(path='/path/to/2/test2.ipynb', kernel_name='python'),
dict(path='/path/to/3', name='foo', type='console', kernel_name='python'),
)
self.loop.run_sync(lambda: sm.delete_session(sessions[1]['id']))
new_sessions = self.loop.run_sync(lambda: sm.list_sessions())
expected = [{
'id': sessions[0]['id'],
'path': u'/path/to/1/test1.ipynb',
'type': 'notebook',
'name': None,
'notebook': {'path': u'/path/to/1/test1.ipynb', 'name': None},
'kernel': {
'id': 'A',
'name':'python',
'connections': 0,
'last_activity': dummy_date_s,
'execution_state': 'idle',
}
}, {
'id': sessions[2]['id'],
'type': 'console',
'path': u'/path/to/3',
'name': 'foo',
'kernel': {
'id': 'C',
'name':'python',
'connections': 0,
'last_activity': dummy_date_s,
'execution_state': 'idle',
}
}
]
self.assertEqual(new_sessions, expected)
def test_bad_delete_session(self):
# try to delete a session that doesn't exist ~ raise error
sm = self.sm
self.create_session(path='/path/to/test.ipynb', kernel_name='python')
with self.assertRaises(TypeError):
self.loop.run_sync(lambda : sm.delete_session(bad_kwarg='23424')) # Bad keyword
with self.assertRaises(web.HTTPError):
self.loop.run_sync(lambda : sm.delete_session(session_id='23424')) # nonexistent

View file

@ -0,0 +1,290 @@
"""Test the sessions web service API."""
import errno
from functools import partial
import io
import os
import json
import shutil
import sys
import time
from unittest import SkipTest
from notebook.utils import url_path_join
from notebook.tests.launchnotebook import NotebookTestBase, assert_http_error
from nbformat.v4 import new_notebook
from nbformat import write
try:
from jupyter_client import AsyncMultiKernelManager
async_testing_enabled = True
except ImportError:
async_testing_enabled = False
pjoin = os.path.join
class SessionAPI(object):
"""Wrapper for notebook API calls."""
def __init__(self, request):
self.request = request
def _req(self, verb, path, body=None):
response = self.request(verb,
url_path_join('api/sessions', path), data=body)
if 400 <= response.status_code < 600:
try:
response.reason = response.json()['message']
except:
pass
response.raise_for_status()
return response
def list(self):
return self._req('GET', '')
def get(self, id):
return self._req('GET', id)
def create(self, path, type='notebook', kernel_name='python', kernel_id=None):
body = json.dumps({'path': path,
'type': type,
'kernel': {'name': kernel_name,
'id': kernel_id}})
return self._req('POST', '', body)
def create_deprecated(self, path):
body = json.dumps({'notebook': {'path': path},
'kernel': {'name': 'python',
'id': 'foo'}})
return self._req('POST', '', body)
def modify_path(self, id, path):
body = json.dumps({'path': path})
return self._req('PATCH', id, body)
def modify_path_deprecated(self, id, path):
body = json.dumps({'notebook': {'path': path}})
return self._req('PATCH', id, body)
def modify_type(self, id, type):
body = json.dumps({'type': type})
return self._req('PATCH', id, body)
def modify_kernel_name(self, id, kernel_name):
body = json.dumps({'kernel': {'name': kernel_name}})
return self._req('PATCH', id, body)
def modify_kernel_id(self, id, kernel_id):
# Also send a dummy name to show that id takes precedence.
body = json.dumps({'kernel': {'id': kernel_id, 'name': 'foo'}})
return self._req('PATCH', id, body)
def delete(self, id):
return self._req('DELETE', id)
class SessionAPITest(NotebookTestBase):
"""Test the sessions web service API"""
def setUp(self):
nbdir = self.notebook_dir
subdir = pjoin(nbdir, 'foo')
try:
os.mkdir(subdir)
except OSError as e:
# Deleting the folder in an earlier test may have failed
if e.errno != errno.EEXIST:
raise
self.addCleanup(partial(shutil.rmtree, subdir, ignore_errors=True))
with io.open(pjoin(subdir, 'nb1.ipynb'), 'w', encoding='utf-8') as f:
nb = new_notebook()
write(nb, f, version=4)
self.sess_api = SessionAPI(self.request)
@self.addCleanup
def cleanup_sessions():
for session in self.sess_api.list().json():
self.sess_api.delete(session['id'])
# This is necessary in some situations on Windows: without it, it
# fails to delete the directory because something is still using
# it. I think there is a brief period after the kernel terminates
# where Windows still treats its working directory as in use. On my
# Windows VM, 0.01s is not long enough, but 0.1s appears to work
# reliably. -- TK, 15 December 2014
time.sleep(0.1)
def test_create(self):
sessions = self.sess_api.list().json()
self.assertEqual(len(sessions), 0)
resp = self.sess_api.create('foo/nb1.ipynb')
self.assertEqual(resp.status_code, 201)
newsession = resp.json()
self.assertIn('id', newsession)
self.assertEqual(newsession['path'], 'foo/nb1.ipynb')
self.assertEqual(newsession['type'], 'notebook')
self.assertEqual(resp.headers['Location'], self.url_prefix + 'api/sessions/{0}'.format(newsession['id']))
sessions = self.sess_api.list().json()
self.assertEqual(sessions, [newsession])
# Retrieve it
sid = newsession['id']
got = self.sess_api.get(sid).json()
self.assertEqual(got, newsession)
def test_create_file_session(self):
resp = self.sess_api.create('foo/nb1.py', type='file')
self.assertEqual(resp.status_code, 201)
newsession = resp.json()
self.assertEqual(newsession['path'], 'foo/nb1.py')
self.assertEqual(newsession['type'], 'file')
def test_create_console_session(self):
resp = self.sess_api.create('foo/abc123', type='console')
self.assertEqual(resp.status_code, 201)
newsession = resp.json()
self.assertEqual(newsession['path'], 'foo/abc123')
self.assertEqual(newsession['type'], 'console')
def test_create_deprecated(self):
resp = self.sess_api.create_deprecated('foo/nb1.ipynb')
self.assertEqual(resp.status_code, 201)
newsession = resp.json()
self.assertEqual(newsession['path'], 'foo/nb1.ipynb')
self.assertEqual(newsession['type'], 'notebook')
self.assertEqual(newsession['notebook']['path'], 'foo/nb1.ipynb')
def test_create_with_kernel_id(self):
# create a new kernel
r = self.request('POST', 'api/kernels')
r.raise_for_status()
kernel = r.json()
resp = self.sess_api.create('foo/nb1.ipynb', kernel_id=kernel['id'])
self.assertEqual(resp.status_code, 201)
newsession = resp.json()
self.assertIn('id', newsession)
self.assertEqual(newsession['path'], 'foo/nb1.ipynb')
self.assertEqual(newsession['kernel']['id'], kernel['id'])
self.assertEqual(resp.headers['Location'], self.url_prefix + 'api/sessions/{0}'.format(newsession['id']))
sessions = self.sess_api.list().json()
self.assertEqual(sessions, [newsession])
# Retrieve it
sid = newsession['id']
got = self.sess_api.get(sid).json()
self.assertEqual(got, newsession)
def test_delete(self):
newsession = self.sess_api.create('foo/nb1.ipynb').json()
sid = newsession['id']
resp = self.sess_api.delete(sid)
self.assertEqual(resp.status_code, 204)
sessions = self.sess_api.list().json()
self.assertEqual(sessions, [])
with assert_http_error(404):
self.sess_api.get(sid)
def test_modify_path(self):
newsession = self.sess_api.create('foo/nb1.ipynb').json()
sid = newsession['id']
changed = self.sess_api.modify_path(sid, 'nb2.ipynb').json()
self.assertEqual(changed['id'], sid)
self.assertEqual(changed['path'], 'nb2.ipynb')
def test_modify_path_deprecated(self):
newsession = self.sess_api.create('foo/nb1.ipynb').json()
sid = newsession['id']
changed = self.sess_api.modify_path_deprecated(sid, 'nb2.ipynb').json()
self.assertEqual(changed['id'], sid)
self.assertEqual(changed['notebook']['path'], 'nb2.ipynb')
def test_modify_type(self):
newsession = self.sess_api.create('foo/nb1.ipynb').json()
sid = newsession['id']
changed = self.sess_api.modify_type(sid, 'console').json()
self.assertEqual(changed['id'], sid)
self.assertEqual(changed['type'], 'console')
def test_modify_kernel_name(self):
before = self.sess_api.create('foo/nb1.ipynb').json()
sid = before['id']
after = self.sess_api.modify_kernel_name(sid, before['kernel']['name']).json()
self.assertEqual(after['id'], sid)
self.assertEqual(after['path'], before['path'])
self.assertEqual(after['type'], before['type'])
self.assertNotEqual(after['kernel']['id'], before['kernel']['id'])
# check kernel list, to be sure previous kernel was cleaned up
r = self.request('GET', 'api/kernels')
r.raise_for_status()
kernel_list = r.json()
after['kernel'].pop('last_activity')
[ k.pop('last_activity') for k in kernel_list ]
self.assertEqual(kernel_list, [after['kernel']])
def test_modify_kernel_id(self):
before = self.sess_api.create('foo/nb1.ipynb').json()
sid = before['id']
# create a new kernel
r = self.request('POST', 'api/kernels')
r.raise_for_status()
kernel = r.json()
# Attach our session to the existing kernel
after = self.sess_api.modify_kernel_id(sid, kernel['id']).json()
self.assertEqual(after['id'], sid)
self.assertEqual(after['path'], before['path'])
self.assertEqual(after['type'], before['type'])
self.assertNotEqual(after['kernel']['id'], before['kernel']['id'])
self.assertEqual(after['kernel']['id'], kernel['id'])
# check kernel list, to be sure previous kernel was cleaned up
r = self.request('GET', 'api/kernels')
r.raise_for_status()
kernel_list = r.json()
kernel.pop('last_activity')
[ k.pop('last_activity') for k in kernel_list ]
self.assertEqual(kernel_list, [kernel])
class AsyncSessionAPITest(SessionAPITest):
"""Test the sessions web service API using the AsyncMappingKernelManager"""
@classmethod
def setup_class(cls):
if not async_testing_enabled: # Can be removed once jupyter_client >= 6.1 is required.
raise SkipTest("AsyncSessionAPITest tests skipped due to down-level jupyter_client!")
if sys.version_info < (3, 6): # Can be removed once 3.5 is dropped.
raise SkipTest("AsyncSessionAPITest tests skipped due to Python < 3.6!")
super(AsyncSessionAPITest, cls).setup_class()
@classmethod
def get_argv(cls):
argv = super(AsyncSessionAPITest, cls).get_argv()
# Before we extend the argv with the class, ensure that appropriate jupyter_client is available.
# if not available, don't set kernel_manager_class, resulting in the repeat of sync-based tests.
if async_testing_enabled:
argv.extend(['--NotebookApp.kernel_manager_class='
'notebook.services.kernels.kernelmanager.AsyncMappingKernelManager'])
return argv

View file

@ -0,0 +1,15 @@
"""HTTP handler to shut down the notebook server.
"""
from tornado import web, ioloop
from notebook.base.handlers import IPythonHandler
class ShutdownHandler(IPythonHandler):
@web.authenticated
def post(self):
self.log.info("Shutting down on /api/shutdown request.")
ioloop.IOLoop.current().stop()
default_handlers = [
(r"/api/shutdown", ShutdownHandler),
]