Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add default pack_models #3738

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 63 additions & 12 deletions packages/base/src/widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import $ from 'jquery';

import { NativeView } from './nativeview';

import { JSONObject, JSONValue } from '@lumino/coreutils';
import { JSONObject, JSONValue, JSONExt } from '@lumino/coreutils';

import { Message, MessageLoop } from '@lumino/messaging';

Expand All @@ -29,6 +29,18 @@ import { BufferJSON, Dict } from './utils';

import { KernelMessage } from '@jupyterlab/services';

/**
* The magic key used in the widget graph serialization.
*/
const IPY_MODEL_ = 'IPY_MODEL_';

/**
* A best-effort method for performing deep copies.
*/
const deepcopyJSON = (x: JSONValue) => JSON.parse(JSON.stringify(x));

const deepcopy = globalThis.structuredClone || deepcopyJSON;

/**
* Replace model ids with models recursively.
*/
Expand All @@ -38,24 +50,55 @@ export function unpack_models(
): Promise<WidgetModel | Dict<WidgetModel> | WidgetModel[] | any> {
if (Array.isArray(value)) {
const unpacked: any[] = [];
value.forEach((sub_value, key) => {
for (const sub_value of value) {
unpacked.push(unpack_models(sub_value, manager));
});
}
return Promise.all(unpacked);
} else if (value instanceof Object && typeof value !== 'string') {
const unpacked: { [key: string]: any } = {};
Object.keys(value).forEach((key) => {
unpacked[key] = unpack_models(value[key], manager);
});
for (const [key, sub_value] of Object.entries(value)) {
unpacked[key] = unpack_models(sub_value, manager);
}
return utils.resolvePromisesDict(unpacked);
} else if (typeof value === 'string' && value.slice(0, 10) === 'IPY_MODEL_') {
} else if (typeof value === 'string' && value.slice(0, 10) === IPY_MODEL_) {
// get_model returns a promise already
return manager!.get_model(value.slice(10, value.length));
} else {
return Promise.resolve(value);
}
}

/** Replace models with ids recursively.
*
* If the commonly-used `unpack_models` is given as the `deseralize` method,
* pack_models would be the appropriate `serialize`.
* However, the default serialize method will have the same effect, when
* `unpack_models` is used as the deserialize method.
* This is to ensure backwards compatibility, see:
* https://github.com/jupyter-widgets/ipywidgets/pull/3738/commits/f9e27328bb631eb5247a7a6563595d3e655492c7#diff-efb19099381ae8911dd7f69b015a0138d08da7164512c1ee112aa75100bc9be2
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This URL seems to be broken? Maybe due to a rebase?

*/
export function pack_models(
value: WidgetModel | Dict<WidgetModel> | WidgetModel[] | any,
widget?: WidgetModel
): any | Dict<unknown> | string | (Dict<unknown> | string)[] {
if (Array.isArray(value)) {
const model_ids: string[] = [];
for (const model of value) {
model_ids.push(pack_models(model, widget));
}
return model_ids;
} else if (value instanceof WidgetModel) {
return `${IPY_MODEL_}${value.model_id}`;
} else if (value instanceof Object && typeof value !== 'string') {
const packed: { [key: string]: string } = {};
for (const [key, sub_value] of Object.entries(value)) {
packed[key] = pack_models(sub_value, widget);
}
} else {
return value;
}
}

/**
* Type declaration for general widget serializers.
*/
Expand Down Expand Up @@ -524,18 +567,26 @@ export class WidgetModel extends Backbone.Model {
* binary array buffers.
*/
serialize(state: Dict<any>): JSONObject {
const deepcopy =
globalThis.structuredClone || ((x: any) => JSON.parse(JSON.stringify(x)));
const serializers =
(this.constructor as typeof WidgetModel).serializers || {};
(this.constructor as typeof WidgetModel).serializers ||
JSONExt.emptyObject;
for (const k of Object.keys(state)) {
try {
if (serializers[k] && serializers[k].serialize) {
state[k] = serializers[k].serialize!(state[k], this);
const keySerializers = serializers[k] || JSONExt.emptyObject;
let { serialize } = keySerializers;

if (serialize == null && keySerializers.deserialize === unpack_models) {
// handle https://github.com/jupyter-widgets/ipywidgets/issues/3735
serialize = deepcopyJSON;
}

if (serialize) {
state[k] = serialize(state[k], this);
} else {
// the default serializer just deep-copies the object
state[k] = deepcopy(state[k]);
}

if (state[k] && state[k].toJSON) {
state[k] = state[k].toJSON();
}
Expand Down
3 changes: 3 additions & 0 deletions packages/base/test/karma-cov.conf.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ module.exports = function (config) {
{ type: 'html', dir: 'test/coverage' },
],
},
mochaReporter: {
showDiff: true,
},
port: 9876,
colors: true,
singleRun: true,
Expand Down
3 changes: 3 additions & 0 deletions packages/base/test/karma.conf.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ module.exports = function (config) {
basePath: '..',
frameworks: ['mocha'],
reporters: ['mocha'],
mochaReporter: {
showDiff: true,
},
files: ['test/build/bundle.js'],
port: 9876,
colors: true,
Expand Down
24 changes: 24 additions & 0 deletions packages/base/test/src/dummy-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,11 +133,35 @@ class BinaryWidgetView extends TestWidgetView {
_rendered = 0;
}

class ContainerWidget extends TestWidget {
static serializers = {
...widgets.WidgetModel.serializers,
children: { deserialize: widgets.unpack_models },
};
defaults(): Backbone.ObjectHash {
return {
...super.defaults(),
_model_name: 'ContainerWidget',
_view_name: 'ContainerWidgetView',
children: [],
};
}
}

class ContainerWidgetView extends TestWidgetView {
render(): void {
this._rendered += 1;
}
_rendered = 0;
}

const testWidgets = {
TestWidget,
TestWidgetView,
BinaryWidget,
BinaryWidgetView,
ContainerWidget,
ContainerWidgetView,
};

export class DummyManager implements widgets.IWidgetManager {
Expand Down
62 changes: 62 additions & 0 deletions packages/base/test/src/widget_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,68 @@ describe('unpack_models', function () {
});
});

describe('serialize/deserialize', function () {
before(async function () {
this.manager = new DummyManager();
this.widgetChild = await this.manager.new_widget({
model_name: 'WidgetModel',
model_module: '@jupyter-widgets/base',
model_module_version: '1.2.0',
view_name: 'WidgetView',
view_module: '@jupyter-widgets/base',
view_module_version: '1.2.0',
model_id: 'widgetChild',
});

this.widgetChild2 = await this.manager.new_widget({
model_name: 'WidgetModel',
model_module: '@jupyter-widgets/base',
model_module_version: '1.2.0',
view_name: 'WidgetView',
view_module: '@jupyter-widgets/base',
view_module_version: '1.2.0',
model_id: 'widgetChild2',
});

this.widgetContainer = await this.manager.new_widget(
{
model_name: 'ContainerWidget',
model_module: 'test-widgets',
model_module_version: '1.2.0',
view_name: 'ContainerWidgetView',
view_module: 'test-widgets',
view_module_version: '1.2.0',
model_id: 'widgetContainer',
},
{ children: [`IPY_MODEL_${this.widgetChild.model_id}`] }
);
});
it('serializes', function () {
const state = this.widgetContainer.get_state(false);
const serializedState = this.widgetContainer.serialize(state);
expect(serializedState).to.deep.equal({
_model_module: 'test-widgets',
_model_module_version: '1.0.0',
_model_name: 'ContainerWidget',
_view_count: null,
_view_module: 'test-widgets',
_view_module_version: '1.0.0',
_view_name: 'ContainerWidgetView',
children: ['IPY_MODEL_widgetChild'],
});
});
it('deserializes', async function () {
const serializedState = { children: ['IPY_MODEL_widgetChild2'] };
const state = await (
this.widgetContainer.constructor as typeof WidgetModel
)._deserialize_state(serializedState, this.manager);
await this.widgetContainer.set_state(state);
expect(this.widgetContainer.get('children')).to.deep.equal([
this.widgetChild2,
]);
});
});

describe('WidgetModel', function () {
before(async function () {
this.setup = async function (): Promise<void> {
Expand Down