Uploaded Test files
This commit is contained in:
parent
f584ad9d97
commit
2e81cb7d99
16627 changed files with 2065359 additions and 102444 deletions
0
venv/Lib/site-packages/notebook/services/__init__.py
Normal file
0
venv/Lib/site-packages/notebook/services/__init__.py
Normal file
Binary file not shown.
Binary file not shown.
0
venv/Lib/site-packages/notebook/services/api/__init__.py
Normal file
0
venv/Lib/site-packages/notebook/services/api/__init__.py
Normal file
Binary file not shown.
Binary file not shown.
857
venv/Lib/site-packages/notebook/services/api/api.yaml
Normal file
857
venv/Lib/site-packages/notebook/services/api/api.yaml
Normal 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.
|
56
venv/Lib/site-packages/notebook/services/api/handlers.py
Normal file
56
venv/Lib/site-packages/notebook/services/api/handlers.py
Normal 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),
|
||||
]
|
Binary file not shown.
Binary file not shown.
|
@ -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
|
|
@ -0,0 +1 @@
|
|||
from .manager import ConfigManager
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
39
venv/Lib/site-packages/notebook/services/config/handlers.py
Normal file
39
venv/Lib/site-packages/notebook/services/config/handlers.py
Normal 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),
|
||||
]
|
58
venv/Lib/site-packages/notebook/services/config/manager.py
Normal file
58
venv/Lib/site-packages/notebook/services/config/manager.py
Normal 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)
|
Binary file not shown.
Binary file not shown.
|
@ -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(), {})
|
||||
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
142
venv/Lib/site-packages/notebook/services/contents/checkpoints.py
Normal file
142
venv/Lib/site-packages/notebook/services/contents/checkpoints.py
Normal 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")
|
|
@ -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,
|
||||
}
|
341
venv/Lib/site-packages/notebook/services/contents/fileio.py
Normal file
341
venv/Lib/site-packages/notebook/services/contents/fileio.py
Normal 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)
|
623
venv/Lib/site-packages/notebook/services/contents/filemanager.py
Normal file
623
venv/Lib/site-packages/notebook/services/contents/filemanager.py
Normal 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))
|
327
venv/Lib/site-packages/notebook/services/contents/handlers.py
Normal file
327
venv/Lib/site-packages/notebook/services/contents/handlers.py
Normal 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),
|
||||
]
|
|
@ -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)
|
532
venv/Lib/site-packages/notebook/services/contents/manager.py
Normal file
532
venv/Lib/site-packages/notebook/services/contents/manager.py
Normal 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)
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
@ -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)
|
|
@ -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')
|
|
@ -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
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
496
venv/Lib/site-packages/notebook/services/kernels/handlers.py
Normal file
496
venv/Lib/site-packages/notebook/services/kernels/handlers.py
Normal 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),
|
||||
]
|
|
@ -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)
|
Binary file not shown.
Binary file not shown.
|
@ -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
|
Binary file not shown.
Binary file not shown.
107
venv/Lib/site-packages/notebook/services/kernelspecs/handlers.py
Normal file
107
venv/Lib/site-packages/notebook/services/kernelspecs/handlers.py
Normal 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),
|
||||
]
|
Binary file not shown.
Binary file not shown.
|
@ -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')
|
Binary file not shown.
Binary file not shown.
|
@ -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),
|
||||
]
|
Binary file not shown.
Binary file not shown.
|
@ -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')
|
|
@ -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"
|
Binary file not shown.
Binary file not shown.
|
@ -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)
|
||||
]
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
175
venv/Lib/site-packages/notebook/services/sessions/handlers.py
Normal file
175
venv/Lib/site-packages/notebook/services/sessions/handlers.py
Normal 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)
|
||||
]
|
||||
|
|
@ -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,))
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -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
|
||||
|
|
@ -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
|
15
venv/Lib/site-packages/notebook/services/shutdown.py
Normal file
15
venv/Lib/site-packages/notebook/services/shutdown.py
Normal 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),
|
||||
]
|
Loading…
Add table
Add a link
Reference in a new issue