@jupyterlab/services
Version:
Client APIs for the Jupyter services REST APIs
872 lines (819 loc) • 25.2 kB
text/typescript
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
// We explicitly reference the jest typings since the jest.d.ts file shipped
// with jest 26 masks the @types/jest typings
/// <reference types="jest" />
import { PathExt } from '@jupyterlab/coreutils';
import { PartialJSONObject, ReadonlyJSONObject, UUID } from '@lumino/coreutils';
import { AttachedProperty } from '@lumino/properties';
import { ISignal, Signal } from '@lumino/signaling';
import { BaseManager } from './basemanager';
import { Contents, ContentsManager } from './contents';
import { Kernel, KernelMessage } from './kernel';
import { KernelSpec } from './kernelspec';
import { ServiceManager } from './manager';
import { ServerConnection } from './serverconnection';
import { Session } from './session';
import { User, UserManager } from './user';
// The default kernel name
export const DEFAULT_NAME = 'python3';
export const KERNELSPECS: { [key: string]: KernelSpec.ISpecModel } = {
[DEFAULT_NAME]: {
argv: [
'/Users/someuser/miniconda3/envs/jupyterlab/bin/python',
'-m',
'ipykernel_launcher',
'-f',
'{connection_file}'
],
display_name: 'Python 3',
language: 'python',
metadata: {},
name: DEFAULT_NAME,
resources: {}
},
irkernel: {
argv: [
'/Users/someuser/miniconda3/envs/jupyterlab/bin/python',
'-m',
'ipykernel_launcher',
'-f',
'{connection_file}'
],
display_name: 'R',
language: 'r',
metadata: {},
name: 'irkernel',
resources: {}
}
};
export const KERNEL_MODELS: Kernel.IModel[] = [
{
name: DEFAULT_NAME,
id: UUID.uuid4()
},
{
name: 'r',
id: UUID.uuid4()
},
{
name: DEFAULT_NAME,
id: UUID.uuid4()
}
];
// Notebook Paths for certain kernel name
export const NOTEBOOK_PATHS: { [kernelName: string]: string[] } = {
python3: ['Untitled.ipynb', 'Untitled1.ipynb', 'Untitled2.ipynb'],
r: ['Visualization.ipynb', 'Analysis.ipynb', 'Conclusion.ipynb']
};
/**
* Clone a kernel connection.
*/
export function cloneKernel(
kernel: Kernel.IKernelConnection
): Kernel.IKernelConnection {
return (kernel as any).clone();
}
/**
* A mock kernel object.
*
* @param model The model of the kernel
*/
export const KernelMock = jest.fn<
Kernel.IKernelConnection,
[Private.RecursivePartial<Kernel.IKernelConnection.IOptions>]
>(options => {
const model = { id: 'foo', name: DEFAULT_NAME, ...options.model };
options = {
clientId: UUID.uuid4(),
username: UUID.uuid4(),
...options,
model
};
let executionCount = 0;
const spec = Private.kernelSpecForKernelName(model.name)!;
const thisObject: Kernel.IKernelConnection = {
...jest.requireActual('@jupyterlab/services'),
...options,
...model,
model,
serverSettings: ServerConnection.makeSettings(
options.serverSettings as Partial<ServerConnection.ISettings>
),
status: 'idle',
spec: Promise.resolve(spec),
dispose: jest.fn(),
clone: jest.fn(() => {
const newKernel = Private.cloneKernel(options);
newKernel.iopubMessage.connect((_, args) => {
iopubMessageSignal.emit(args);
});
newKernel.statusChanged.connect((_, args) => {
(thisObject as any).status = args;
statusChangedSignal.emit(args);
});
return newKernel;
}),
info: Promise.resolve(Private.getInfo(model!.name!)),
shutdown: jest.fn(() => Promise.resolve(void 0)),
requestHistory: jest.fn(() => {
const historyReply = KernelMessage.createMessage({
channel: 'shell',
msgType: 'history_reply',
session: options.clientId!,
username: options.username!,
content: {
history: [],
status: 'ok'
}
});
return Promise.resolve(historyReply);
}),
restart: jest.fn(() => Promise.resolve(void 0)),
requestExecute: jest.fn(options => {
const msgId = UUID.uuid4();
executionCount++;
Private.lastMessageProperty.set(thisObject, msgId);
const msg = KernelMessage.createMessage({
channel: 'iopub',
msgType: 'execute_input',
session: thisObject.clientId,
username: thisObject.username,
msgId,
content: {
code: options.code,
execution_count: executionCount
}
});
iopubMessageSignal.emit(msg);
const reply = KernelMessage.createMessage<KernelMessage.IExecuteReplyMsg>(
{
channel: 'shell',
msgType: 'execute_reply',
session: thisObject.clientId,
username: thisObject.username,
msgId,
content: {
user_expressions: {},
execution_count: executionCount,
status: 'ok'
}
}
);
return new MockShellFuture(reply) as Kernel.IShellFuture<
KernelMessage.IExecuteRequestMsg,
KernelMessage.IExecuteReplyMsg
>;
})
};
// Add signals.
const iopubMessageSignal = new Signal<
Kernel.IKernelConnection,
KernelMessage.IIOPubMessage
>(thisObject);
const statusChangedSignal = new Signal<
Kernel.IKernelConnection,
Kernel.Status
>(thisObject);
const pendingInputSignal = new Signal<Kernel.IKernelConnection, boolean>(
thisObject
);
(thisObject as any).statusChanged = statusChangedSignal;
(thisObject as any).iopubMessage = iopubMessageSignal;
(thisObject as any).pendingInput = pendingInputSignal;
(thisObject as any).hasPendingInput = false;
return thisObject;
});
/**
* A mock session connection.
*
* @param options Addition session options to use
* @param model A session model to use
*/
export const SessionConnectionMock = jest.fn<
Session.ISessionConnection,
[
Private.RecursivePartial<Session.ISessionConnection.IOptions>,
Kernel.IKernelConnection | null
]
>((options, kernel) => {
const name = kernel?.name || options.model?.kernel?.name || DEFAULT_NAME;
kernel = kernel || new KernelMock({ model: { name } });
const model = {
id: UUID.uuid4(),
path: 'foo',
type: 'notebook',
name: 'foo',
...options.model,
kernel: kernel!.model
};
const thisObject: Session.ISessionConnection = {
...jest.requireActual('@jupyterlab/services'),
...options,
model,
...model,
kernel,
serverSettings: ServerConnection.makeSettings(
options.serverSettings as Partial<ServerConnection.ISettings>
),
dispose: jest.fn(),
changeKernel: jest.fn(partialModel => {
return changeKernel(kernel!, partialModel!);
}),
shutdown: jest.fn(() => Promise.resolve(void 0)),
setPath: jest.fn(path => {
(thisObject as any).path = path;
propertyChangedSignal.emit('path');
return Promise.resolve();
}),
setName: jest.fn(name => {
(thisObject as any).name = name;
propertyChangedSignal.emit('name');
return Promise.resolve();
}),
setType: jest.fn(type => {
(thisObject as any).type = type;
propertyChangedSignal.emit('type');
return Promise.resolve();
})
};
const disposedSignal = new Signal<Session.ISessionConnection, undefined>(
thisObject
);
const propertyChangedSignal = new Signal<
Session.ISessionConnection,
'path' | 'name' | 'type'
>(thisObject);
const statusChangedSignal = new Signal<
Session.ISessionConnection,
Kernel.Status
>(thisObject);
const connectionStatusChangedSignal = new Signal<
Session.ISessionConnection,
Kernel.ConnectionStatus
>(thisObject);
const kernelChangedSignal = new Signal<
Session.ISessionConnection,
Session.ISessionConnection.IKernelChangedArgs
>(thisObject);
const iopubMessageSignal = new Signal<
Session.ISessionConnection,
KernelMessage.IIOPubMessage
>(thisObject);
const unhandledMessageSignal = new Signal<
Session.ISessionConnection,
KernelMessage.IMessage
>(thisObject);
const pendingInputSignal = new Signal<Session.ISessionConnection, boolean>(
thisObject
);
kernel!.iopubMessage.connect((_, args) => {
iopubMessageSignal.emit(args);
}, thisObject);
kernel!.statusChanged.connect((_, args) => {
statusChangedSignal.emit(args);
}, thisObject);
kernel!.pendingInput.connect((_, args) => {
pendingInputSignal.emit(args);
}, thisObject);
(thisObject as any).disposed = disposedSignal;
(thisObject as any).connectionStatusChanged = connectionStatusChangedSignal;
(thisObject as any).propertyChanged = propertyChangedSignal;
(thisObject as any).statusChanged = statusChangedSignal;
(thisObject as any).kernelChanged = kernelChangedSignal;
(thisObject as any).iopubMessage = iopubMessageSignal;
(thisObject as any).unhandledMessage = unhandledMessageSignal;
(thisObject as any).pendingInput = pendingInputSignal;
return thisObject;
});
/**
* A mock contents manager.
*/
export const ContentsManagerMock = jest.fn<Contents.IManager, []>(() => {
const files = new Map<string, Map<string, Contents.IModel>>();
const dummy = new ContentsManager();
const checkpoints = new Map<string, Contents.ICheckpointModel>();
const checkPointContent = new Map<string, string>();
const baseModel = Private.createFile({ type: 'directory' });
// create the default drive
files.set(
'',
new Map<string, Contents.IModel>([
['', { ...baseModel, path: '', name: '' }]
])
);
const thisObject: Contents.IManager = {
...jest.requireActual('@jupyterlab/services'),
newUntitled: jest.fn(options => {
const driveName = dummy.driveName(options?.path || '');
const localPath = dummy.localPath(options?.path || '');
// create the test file without the drive name
const createOptions = { ...options, path: localPath };
const model = Private.createFile(createOptions || {});
// re-add the drive name to the model
const drivePath = driveName ? `${driveName}:${model.path}` : model.path;
const driveModel = {
...model,
path: drivePath
};
files.get(driveName)!.set(model.path, driveModel);
fileChangedSignal.emit({
type: 'new',
oldValue: null,
newValue: driveModel
});
return Promise.resolve(driveModel);
}),
createCheckpoint: jest.fn(path => {
const lastModified = new Date().toISOString();
const data = { id: UUID.uuid4(), last_modified: lastModified };
checkpoints.set(path, data);
const driveName = dummy.driveName(path);
const localPath = dummy.localPath(path);
checkPointContent.set(
path,
files.get(driveName)!.get(localPath)?.content
);
return Promise.resolve(data);
}),
listCheckpoints: jest.fn(path => {
const p = checkpoints.get(path);
if (p !== undefined) {
return Promise.resolve([p]);
}
return Promise.resolve([]);
}),
deleteCheckpoint: jest.fn(path => {
if (!checkpoints.has(path)) {
return Private.makeResponseError(404);
}
checkpoints.delete(path);
return Promise.resolve();
}),
restoreCheckpoint: jest.fn(path => {
if (!checkpoints.has(path)) {
return Private.makeResponseError(404);
}
const driveName = dummy.driveName(path);
const localPath = dummy.localPath(path);
(files.get(driveName)!.get(localPath) as any).content =
checkPointContent.get(path);
return Promise.resolve();
}),
getSharedModelFactory: jest.fn(() => {
return null;
}),
normalize: jest.fn(path => {
return dummy.normalize(path);
}),
localPath: jest.fn(path => {
return dummy.localPath(path);
}),
resolvePath: jest.fn((root, path) => {
return dummy.resolvePath(root, path);
}),
get: jest.fn((path, options) => {
const driveName = dummy.driveName(path);
const localPath = dummy.localPath(path);
const drive = files.get(driveName)!;
path = Private.fixSlash(localPath);
if (!drive.has(path)) {
return Private.makeResponseError(404);
}
const model = drive.get(path)!;
const overrides: { hash?: string; last_modified?: string } = {};
if (path == 'random-hash.txt') {
overrides.hash = Math.random().toString();
} else if (path == 'newer-timestamp-no-hash.txt') {
overrides.hash = undefined;
const tomorrow = new Date();
tomorrow.setDate(new Date().getDate() + 1);
overrides.last_modified = tomorrow.toISOString();
}
if (model.type === 'directory') {
if (options?.content !== false) {
const content: Contents.IModel[] = [];
drive.forEach(fileModel => {
const localPath = dummy.localPath(fileModel.path);
if (
// If file path is under this directory, add it to contents array.
PathExt.dirname(localPath) == model.path &&
// But the directory should exclude itself from the contents array.
fileModel !== model
) {
content.push(fileModel);
}
});
return Promise.resolve({ ...model, content });
}
return Promise.resolve(model);
}
if (options?.content != false) {
return Promise.resolve(model);
}
return Promise.resolve({ ...model, content: '', ...overrides });
}),
driveName: jest.fn(path => {
return dummy.driveName(path);
}),
rename: jest.fn((oldPath, newPath) => {
const driveName = dummy.driveName(oldPath);
const drive = files.get(driveName)!;
let oldLocalPath = dummy.localPath(oldPath);
let newLocalPath = dummy.localPath(newPath);
oldLocalPath = Private.fixSlash(oldLocalPath);
newLocalPath = Private.fixSlash(newLocalPath);
if (!drive.has(oldLocalPath)) {
return Private.makeResponseError(404);
}
const oldValue = drive.get(oldPath)!;
drive.delete(oldPath);
const name = PathExt.basename(newLocalPath);
const newValue = { ...oldValue, name, path: newLocalPath };
drive.set(newPath, newValue);
fileChangedSignal.emit({
type: 'rename',
oldValue,
newValue
});
return Promise.resolve(newValue);
}),
delete: jest.fn(path => {
const driveName = dummy.driveName(path);
const localPath = dummy.localPath(path);
const drive = files.get(driveName)!;
path = Private.fixSlash(localPath);
if (!drive.has(path)) {
return Private.makeResponseError(404);
}
const oldValue = drive.get(path)!;
drive.delete(path);
fileChangedSignal.emit({
type: 'delete',
oldValue,
newValue: null
});
return Promise.resolve(void 0);
}),
save: jest.fn((path, options) => {
if (path == 'readonly.txt') {
return Private.makeResponseError(403);
}
path = Private.fixSlash(path);
const timeStamp = new Date().toISOString();
const drive = files.get(dummy.driveName(path))!;
if (drive.has(path)) {
const updates =
path == 'frozen-time-and-hash.txt'
? {}
: {
last_modified: timeStamp,
hash: timeStamp
};
drive.set(path, {
...drive.get(path)!,
...options,
...updates
});
} else {
drive.set(path, {
path,
name: PathExt.basename(path),
content: '',
writable: true,
created: timeStamp,
type: 'file',
format: 'text',
mimetype: 'plain/text',
...options,
last_modified: timeStamp,
hash: timeStamp,
hash_algorithm: 'static'
});
}
fileChangedSignal.emit({
type: 'save',
oldValue: null,
newValue: drive.get(path)!
});
return Promise.resolve(drive.get(path)!);
}),
getDownloadUrl: jest.fn(path => {
return dummy.getDownloadUrl(path);
}),
addDrive: jest.fn(drive => {
dummy.addDrive(drive);
files.set(
drive.name,
new Map<string, Contents.IModel>([
['', { ...baseModel, path: '', name: '' }]
])
);
}),
dispose: jest.fn()
};
const fileChangedSignal = new Signal<
Contents.IManager,
Contents.IChangedArgs
>(thisObject);
(thisObject as any).fileChanged = fileChangedSignal;
return thisObject;
});
/**
* A mock sessions manager.
*/
export const SessionManagerMock = jest.fn<Session.IManager, []>(() => {
let sessions: Session.IModel[] = [];
const thisObject: Session.IManager = {
...jest.requireActual('@jupyterlab/services'),
ready: Promise.resolve(void 0),
isReady: true,
startNew: jest.fn(options => {
const session = new SessionConnectionMock({ model: options }, null);
sessions.push(session.model);
runningChangedSignal.emit(sessions);
return Promise.resolve(session);
}),
connectTo: jest.fn(options => {
return new SessionConnectionMock(options, null);
}),
stopIfNeeded: jest.fn(path => {
const length = sessions.length;
sessions = sessions.filter(model => model.path !== path);
if (sessions.length !== length) {
runningChangedSignal.emit(sessions);
}
return Promise.resolve(void 0);
}),
refreshRunning: jest.fn(() => Promise.resolve(void 0)),
running: jest.fn(() => sessions[Symbol.iterator]())
};
const runningChangedSignal = new Signal<Session.IManager, Session.IModel[]>(
thisObject
);
(thisObject as any).runningChanged = runningChangedSignal;
return thisObject;
});
/**
* A mock kernel specs manager
*/
export const KernelSpecManagerMock = jest.fn<KernelSpec.IManager, []>(() => {
const thisObject: KernelSpec.IManager = {
...jest.requireActual('@jupyterlab/services'),
specs: { default: DEFAULT_NAME, kernelspecs: KERNELSPECS },
isReady: true,
ready: Promise.resolve(void 0),
refreshSpecs: jest.fn(() => Promise.resolve(void 0))
};
return thisObject;
});
/**
* A mock service manager.
*/
export const ServiceManagerMock = jest.fn<ServiceManager.IManager, []>(() => {
const thisObject: ServiceManager.IManager = {
...jest.requireActual('@jupyterlab/services'),
ready: Promise.resolve(void 0),
isReady: true,
contents: new ContentsManagerMock(),
sessions: new SessionManagerMock(),
kernelspecs: new KernelSpecManagerMock(),
dispose: jest.fn()
};
return thisObject;
});
/**
* A mock kernel shell future.
*/
export const MockShellFuture = jest.fn<
Kernel.IShellFuture,
[KernelMessage.IShellMessage]
>((result: KernelMessage.IShellMessage) => {
const thisObject: Kernel.IShellFuture = {
...jest.requireActual('@jupyterlab/services'),
dispose: jest.fn(),
done: Promise.resolve(result)
};
return thisObject;
});
export function changeKernel(
kernel: Kernel.IKernelConnection,
partialModel: Partial<Kernel.IModel>
): Promise<Kernel.IKernelConnection> {
if (partialModel.id) {
const kernelIdx = KERNEL_MODELS.findIndex(model => {
return model.id === partialModel.id;
});
if (kernelIdx !== -1) {
(kernel.model as any) = Private.RUNNING_KERNELS[kernelIdx].model;
(kernel.id as any) = partialModel.id;
return Promise.resolve(Private.RUNNING_KERNELS[kernelIdx]);
} else {
throw new Error(
`Unable to change kernel to one with id: ${partialModel.id}`
);
}
} else if (partialModel.name) {
const kernelIdx = KERNEL_MODELS.findIndex(model => {
return model.name === partialModel.name;
});
if (kernelIdx !== -1) {
(kernel.model as any) = Private.RUNNING_KERNELS[kernelIdx].model;
(kernel.id as any) = partialModel.id;
return Promise.resolve(Private.RUNNING_KERNELS[kernelIdx]);
} else {
throw new Error(
`Unable to change kernel to one with name: ${partialModel.name}`
);
}
} else {
throw new Error(`Unable to change kernel`);
}
}
/**
* A namespace for module private data.
*/
namespace Private {
export type RecursivePartial<T> = {
[P in keyof T]?: RecursivePartial<T[P]>;
};
export function createFile(
options?: Contents.ICreateOptions
): Contents.IModel {
options = options || {};
let name = UUID.uuid4();
switch (options.type) {
case 'directory':
name = `Untitled Folder_${name}`;
break;
case 'notebook':
name = `Untitled_${name}.ipynb`;
break;
default:
name = `untitled_${name}${options.ext || '.txt'}`;
}
const path = PathExt.join(options.path || '', name);
let content = '';
if (options.type === 'notebook') {
content = JSON.stringify({});
}
const timeStamp = new Date().toISOString();
return {
path,
content,
name,
last_modified: timeStamp,
writable: true,
created: timeStamp,
type: options.type || 'file',
format: 'text',
mimetype: 'plain/text'
};
}
export function fixSlash(path: string): string {
if (path.endsWith('/')) {
path = path.slice(0, path.length - 1);
}
return path;
}
export function makeResponseError<T>(status: number): Promise<T> {
const resp = new Response(void 0, { status });
return Promise.reject(new ServerConnection.ResponseError(resp));
}
export function cloneKernel(
options: RecursivePartial<Kernel.IKernelConnection.IOptions>
): Kernel.IKernelConnection {
return new KernelMock({ ...options, clientId: UUID.uuid4() });
}
// Get the kernel spec for kernel name
export function kernelSpecForKernelName(name: string): KernelSpec.ISpecModel {
return KERNELSPECS[name];
}
// Get the kernel info for kernel name
export function getInfo(name: string): KernelMessage.IInfoReply {
return {
protocol_version: '1',
implementation: 'foo',
implementation_version: '1',
language_info: {
version: '1',
name
},
banner: 'hello, world!',
help_links: [],
status: 'ok'
};
}
// This list of running kernels simply mirrors the KERNEL_MODELS and KERNELSPECS lists
export const RUNNING_KERNELS: Kernel.IKernelConnection[] = KERNEL_MODELS.map(
(model, _) => {
return new KernelMock({ model });
}
);
export const lastMessageProperty = new AttachedProperty<
Kernel.IKernelConnection,
string
>({
name: 'lastMessageId',
create: () => ''
});
}
/**
* The user API service manager.
*/
export class FakeUserManager extends BaseManager implements User.IManager {
private _isReady = false;
private _ready: Promise<void>;
private _identity: User.IIdentity;
private _permissions: ReadonlyJSONObject;
private _userChanged = new Signal<this, User.IUser>(this);
private _connectionFailure = new Signal<this, Error>(this);
/**
* Create a new user manager.
*/
constructor(
options: UserManager.IOptions = {},
identity: User.IIdentity,
permissions: ReadonlyJSONObject
) {
super(options);
// Initialize internal data.
this._ready = new Promise<void>(resolve => {
// Schedule updating the user to the next macro task queue.
setTimeout(() => {
this._identity = identity;
this._permissions = permissions;
this._userChanged.emit({
identity: this._identity,
permissions: this._permissions as PartialJSONObject
});
resolve();
}, 0);
})
.then(() => {
if (this.isDisposed) {
return;
}
this._isReady = true;
})
.catch(_ => undefined);
}
/**
* The server settings for the manager.
*/
readonly serverSettings: ServerConnection.ISettings;
/**
* Test whether the manager is ready.
*/
get isReady(): boolean {
return this._isReady;
}
/**
* A promise that fulfills when the manager is ready.
*/
get ready(): Promise<void> {
return this._ready;
}
/**
* Get the most recently fetched identity.
*/
get identity(): User.IIdentity | null {
return this._identity;
}
/**
* Get the most recently fetched permissions.
*/
get permissions(): ReadonlyJSONObject | null {
return this._permissions;
}
/**
* A signal emitted when the user changes.
*/
get userChanged(): ISignal<this, User.IUser> {
return this._userChanged;
}
/**
* A signal emitted when there is a connection failure.
*/
get connectionFailure(): ISignal<this, Error> {
return this._connectionFailure;
}
/**
* Dispose of the resources used by the manager.
*/
dispose(): void {
super.dispose();
}
/**
* Force a refresh of the specs from the server.
*
* @returns A promise that resolves when the specs are fetched.
*
* #### Notes
* This is intended to be called only in response to a user action,
* since the manager maintains its internal state.
*/
async refreshUser(): Promise<void> {
return Promise.resolve();
}
}