@jupyterlab/extensionmanager 4.0.0-alpha.8 → 4.0.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/model.ts ADDED
@@ -0,0 +1,691 @@
1
+ // Copyright (c) Jupyter Development Team.
2
+ // Distributed under the terms of the Modified BSD License.
3
+
4
+ /* global RequestInit */
5
+
6
+ import { Dialog, showDialog } from '@jupyterlab/apputils';
7
+ import { PageConfig, URLExt } from '@jupyterlab/coreutils';
8
+ import { ServerConnection, ServiceManager } from '@jupyterlab/services';
9
+ import { ITranslator, nullTranslator } from '@jupyterlab/translation';
10
+ import { VDomModel } from '@jupyterlab/ui-components';
11
+ import { Debouncer } from '@lumino/polling';
12
+ import * as semver from 'semver';
13
+ import { reportInstallError } from './dialog';
14
+
15
+ /**
16
+ * Information about an extension.
17
+ */
18
+ export interface IEntry {
19
+ /**
20
+ * The name of the extension.
21
+ */
22
+ name: string;
23
+
24
+ /**
25
+ * A short description of the extension.
26
+ */
27
+ description: string;
28
+
29
+ /**
30
+ * A representative link of the package.
31
+ */
32
+ homepage_url: string;
33
+
34
+ /**
35
+ * Whether the extension is currently installed.
36
+ */
37
+ installed?: boolean | null;
38
+
39
+ /**
40
+ * Whether the extension is allowed or not.
41
+ */
42
+ allowed: boolean;
43
+
44
+ /**
45
+ * Whether the extension is approved by the system administrators.
46
+ */
47
+ approved: boolean;
48
+
49
+ /**
50
+ * Whether the extension is currently enabled.
51
+ */
52
+ enabled: boolean;
53
+
54
+ /**
55
+ * The latest version of the extension.
56
+ */
57
+ latest_version: string;
58
+
59
+ /**
60
+ * The installed version of the extension.
61
+ */
62
+ installed_version: string;
63
+
64
+ /**
65
+ * A flag indicating the status of an installed extension.
66
+ */
67
+ status: 'ok' | 'warning' | 'error' | 'deprecated' | null;
68
+
69
+ /**
70
+ * The package type (prebuilt or source).
71
+ */
72
+ pkg_type: 'prebuilt' | 'source';
73
+
74
+ /**
75
+ * The information about extension installation.
76
+ */
77
+ install?: IInstall | null;
78
+
79
+ /**
80
+ * Package author.
81
+ */
82
+ author?: string;
83
+
84
+ /**
85
+ * Package license.
86
+ */
87
+ license?: string;
88
+
89
+ /**
90
+ * URL to the package bug tracker.
91
+ */
92
+ bug_tracker_url?: string;
93
+
94
+ /**
95
+ * URL to the package documentation.
96
+ */
97
+ documentation_url?: string;
98
+
99
+ /**
100
+ * URL to the package URL in the packager website.
101
+ */
102
+ package_manager_url?: string;
103
+
104
+ /**
105
+ * URL to the package code source.
106
+ */
107
+ repository_url?: string;
108
+ }
109
+
110
+ /**
111
+ * Information about extension installation.
112
+ */
113
+ export interface IInstall {
114
+ /**
115
+ * The used package manager (e.g. pip, conda...)
116
+ */
117
+ packageManager: string | undefined;
118
+
119
+ /**
120
+ * The package name as known by the package manager.
121
+ */
122
+ packageName: string | undefined;
123
+
124
+ /**
125
+ * The uninstallation instructions as a comprehensive
126
+ * text for the end user.
127
+ */
128
+ uninstallInstructions: string | undefined;
129
+ }
130
+
131
+ /**
132
+ * An object representing a server reply to performing an action.
133
+ */
134
+ export interface IActionReply {
135
+ /**
136
+ * The status category of the reply.
137
+ */
138
+ status: 'ok' | 'warning' | 'error' | null;
139
+
140
+ /**
141
+ * An optional message when the status is not 'ok'.
142
+ */
143
+ message?: string;
144
+
145
+ /**
146
+ * Follow-up restart needed by the action
147
+ */
148
+ needs_restart: ('frontend' | 'kernel' | 'server')[];
149
+ }
150
+
151
+ /**
152
+ * Extension manager metadata
153
+ */
154
+ interface IExtensionManagerMetadata {
155
+ /**
156
+ * Extension manager name.
157
+ */
158
+ name: string;
159
+ /**
160
+ * Whether the extension manager can un-/install extensions.
161
+ */
162
+ can_install: boolean;
163
+ /**
164
+ * Extensions installation path.
165
+ */
166
+ install_path: string | null;
167
+ }
168
+
169
+ /**
170
+ * The server API path for querying/modifying installed extensions.
171
+ */
172
+ const EXTENSION_API_PATH = 'lab/api/extensions';
173
+
174
+ /**
175
+ * Extension actions that the server API accepts
176
+ */
177
+ export type Action = 'install' | 'uninstall' | 'enable' | 'disable';
178
+
179
+ /**
180
+ * Model for an extension list.
181
+ */
182
+ export class ListModel extends VDomModel {
183
+ constructor(
184
+ serviceManager: ServiceManager.IManager,
185
+ translator?: ITranslator
186
+ ) {
187
+ super();
188
+
189
+ const metadata = JSON.parse(
190
+ // The page config option may not be defined; e.g. in the federated example
191
+ PageConfig.getOption('extensionManager') || '{}'
192
+ ) as IExtensionManagerMetadata;
193
+
194
+ this.name = metadata.name;
195
+ this.canInstall = metadata.can_install;
196
+ this.installPath = metadata.install_path;
197
+ this.translator = translator || nullTranslator;
198
+ this._installed = [];
199
+ this._lastSearchResult = [];
200
+ this.serviceManager = serviceManager;
201
+ this._debouncedSearch = new Debouncer(this.search.bind(this), 1000);
202
+ }
203
+
204
+ /**
205
+ * Extension manager name.
206
+ */
207
+ readonly name: string;
208
+
209
+ /**
210
+ * Whether the extension manager support installation methods or not.
211
+ */
212
+ readonly canInstall: boolean;
213
+
214
+ /**
215
+ * Extensions installation path.
216
+ */
217
+ installPath: string | null;
218
+
219
+ /**
220
+ * A readonly array of the installed extensions.
221
+ */
222
+ get installed(): ReadonlyArray<IEntry> {
223
+ return this._installed;
224
+ }
225
+
226
+ /**
227
+ * Whether the warning is disclaimed or not.
228
+ */
229
+ get isDisclaimed(): boolean {
230
+ return this._isDisclaimed;
231
+ }
232
+ set isDisclaimed(v: boolean) {
233
+ if (v !== this._isDisclaimed) {
234
+ this._isDisclaimed = v;
235
+ this.stateChanged.emit();
236
+ void this._debouncedSearch.invoke();
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Whether the extension manager is enabled or not.
242
+ */
243
+ get isEnabled(): boolean {
244
+ return this._isEnabled;
245
+ }
246
+ set isEnabled(v: boolean) {
247
+ if (v !== this._isEnabled) {
248
+ this._isEnabled = v;
249
+ this.stateChanged.emit();
250
+ }
251
+ }
252
+
253
+ get isLoadingInstalledExtensions(): boolean {
254
+ return this._isLoadingInstalledExtensions;
255
+ }
256
+
257
+ get isSearching(): boolean {
258
+ return this._isSearching;
259
+ }
260
+
261
+ /**
262
+ * A readonly array containing the latest search result
263
+ */
264
+ get searchResult(): ReadonlyArray<IEntry> {
265
+ return this._lastSearchResult;
266
+ }
267
+
268
+ /**
269
+ * The search query.
270
+ *
271
+ * Setting its value triggers a new search.
272
+ */
273
+ get query(): string {
274
+ return this._query;
275
+ }
276
+ set query(value: string) {
277
+ if (this._query !== value) {
278
+ this._query = value;
279
+ this._page = 1;
280
+ void this._debouncedSearch.invoke();
281
+ }
282
+ }
283
+
284
+ /**
285
+ * The current search page.
286
+ *
287
+ * Setting its value triggers a new search.
288
+ *
289
+ * ### Note
290
+ * First page is 1.
291
+ */
292
+ get page(): number {
293
+ return this._page;
294
+ }
295
+ set page(value: number) {
296
+ if (this._page !== value) {
297
+ this._page = value;
298
+ void this._debouncedSearch.invoke();
299
+ }
300
+ }
301
+
302
+ /**
303
+ * The search pagination.
304
+ *
305
+ * Setting its value triggers a new search.
306
+ */
307
+ get pagination(): number {
308
+ return this._pagination;
309
+ }
310
+ set pagination(value: number) {
311
+ if (this._pagination !== value) {
312
+ this._pagination = value;
313
+ void this._debouncedSearch.invoke();
314
+ }
315
+ }
316
+
317
+ /**
318
+ * The last page of results in the current search.
319
+ */
320
+ get lastPage(): number {
321
+ return this._lastPage;
322
+ }
323
+
324
+ /**
325
+ * Dispose the extensions list model.
326
+ */
327
+ dispose(): void {
328
+ if (this.isDisposed) {
329
+ return;
330
+ }
331
+ this._debouncedSearch.dispose();
332
+ super.dispose();
333
+ }
334
+
335
+ /**
336
+ * Whether there are currently any actions pending.
337
+ */
338
+ hasPendingActions(): boolean {
339
+ return this._pendingActions.length > 0;
340
+ }
341
+
342
+ /**
343
+ * Install an extension.
344
+ *
345
+ * @param entry An entry indicating which extension to install.
346
+ */
347
+ async install(entry: IEntry): Promise<void> {
348
+ await this.performAction('install', entry).then(data => {
349
+ if (data.status !== 'ok') {
350
+ reportInstallError(entry.name, data.message, this.translator);
351
+ }
352
+ return this.update(true);
353
+ });
354
+ }
355
+
356
+ /**
357
+ * Uninstall an extension.
358
+ *
359
+ * @param entry An entry indicating which extension to uninstall.
360
+ */
361
+ async uninstall(entry: IEntry): Promise<void> {
362
+ if (!entry.installed) {
363
+ throw new Error(`Not installed, cannot uninstall: ${entry.name}`);
364
+ }
365
+ await this.performAction('uninstall', entry);
366
+ return this.update(true);
367
+ }
368
+
369
+ /**
370
+ * Enable an extension.
371
+ *
372
+ * @param entry An entry indicating which extension to enable.
373
+ */
374
+ async enable(entry: IEntry): Promise<void> {
375
+ if (entry.enabled) {
376
+ throw new Error(`Already enabled: ${entry.name}`);
377
+ }
378
+ await this.performAction('enable', entry);
379
+ await this.refreshInstalled(true);
380
+ }
381
+
382
+ /**
383
+ * Disable an extension.
384
+ *
385
+ * @param entry An entry indicating which extension to disable.
386
+ */
387
+ async disable(entry: IEntry): Promise<void> {
388
+ if (!entry.enabled) {
389
+ throw new Error(`Already disabled: ${entry.name}`);
390
+ }
391
+ await this.performAction('disable', entry);
392
+ await this.refreshInstalled(true);
393
+ }
394
+
395
+ /**
396
+ * Refresh installed packages
397
+ *
398
+ * @param force Force refreshing the list of installed packages
399
+ */
400
+ async refreshInstalled(force = false): Promise<void> {
401
+ this.installedError = null;
402
+ this._isLoadingInstalledExtensions = true;
403
+ this.stateChanged.emit();
404
+ try {
405
+ const [extensions] = await Private.requestAPI<IEntry[]>({
406
+ refresh: force ? 1 : 0
407
+ });
408
+ this._installed = extensions.sort(Private.comparator);
409
+ } catch (reason) {
410
+ this.installedError = reason.toString();
411
+ } finally {
412
+ this._isLoadingInstalledExtensions = false;
413
+ this.stateChanged.emit();
414
+ }
415
+ }
416
+
417
+ /**
418
+ * Search with current query.
419
+ *
420
+ * Sets searchError and totalEntries as appropriate.
421
+ *
422
+ * @returns The extensions matching the current query.
423
+ */
424
+ protected async search(force = false): Promise<void> {
425
+ if (!this.isDisclaimed) {
426
+ return Promise.reject('Installation warning is not disclaimed.');
427
+ }
428
+
429
+ this.searchError = null;
430
+ this._isSearching = true;
431
+ this.stateChanged.emit();
432
+ try {
433
+ const [extensions, links] = await Private.requestAPI<IEntry[]>({
434
+ query: this.query ?? '',
435
+ page: this.page,
436
+ per_page: this.pagination,
437
+ refresh: force ? 1 : 0
438
+ });
439
+
440
+ const lastURL = links['last'];
441
+ if (lastURL) {
442
+ const lastPage = URLExt.queryStringToObject(
443
+ URLExt.parse(lastURL).search ?? ''
444
+ )['page'];
445
+
446
+ if (lastPage) {
447
+ this._lastPage = parseInt(lastPage, 10);
448
+ }
449
+ }
450
+
451
+ const installedNames = this._installed.map(pkg => pkg.name);
452
+ this._lastSearchResult = extensions
453
+ .filter(pkg => !installedNames.includes(pkg.name))
454
+ .sort(Private.comparator);
455
+ } catch (reason) {
456
+ this.searchError = reason.toString();
457
+ } finally {
458
+ this._isSearching = false;
459
+ this.stateChanged.emit();
460
+ }
461
+ }
462
+
463
+ /**
464
+ * Update the current model.
465
+ *
466
+ * This will query the packages repository, and the notebook server.
467
+ *
468
+ * Emits the `stateChanged` signal on successful completion.
469
+ */
470
+ protected async update(force = false): Promise<void> {
471
+ if (this.isDisclaimed) {
472
+ // First refresh the installed list - so the search results are correctly filtered
473
+ await this.refreshInstalled(force);
474
+ await this.search();
475
+ }
476
+ }
477
+
478
+ /**
479
+ * Send a request to the server to perform an action on an extension.
480
+ *
481
+ * @param action A valid action to perform.
482
+ * @param entry The extension to perform the action on.
483
+ */
484
+ protected performAction(
485
+ action: string,
486
+ entry: IEntry
487
+ ): Promise<IActionReply> {
488
+ const actionRequest = Private.requestAPI<IActionReply>(
489
+ {},
490
+ {
491
+ method: 'POST',
492
+ body: JSON.stringify({
493
+ cmd: action,
494
+ extension_name: entry.name
495
+ })
496
+ }
497
+ );
498
+
499
+ actionRequest.then(
500
+ ([reply]) => {
501
+ const trans = this.translator.load('jupyterlab');
502
+ if (reply.needs_restart.includes('server')) {
503
+ void showDialog({
504
+ title: trans.__('Information'),
505
+ body: trans.__(
506
+ 'You will need to restart JupyterLab to apply the changes.'
507
+ ),
508
+ buttons: [Dialog.okButton({ label: trans.__('Ok') })]
509
+ });
510
+ } else {
511
+ const followUps: string[] = [];
512
+ if (reply.needs_restart.includes('frontend')) {
513
+ followUps.push(
514
+ // @ts-expect-error isElectron is not a standard attribute
515
+ window.isElectron
516
+ ? trans.__('reload JupyterLab')
517
+ : trans.__('refresh the web page')
518
+ );
519
+ }
520
+ if (reply.needs_restart.includes('kernel')) {
521
+ followUps.push(
522
+ trans.__('install the extension in all kernels and restart them')
523
+ );
524
+ }
525
+ void showDialog({
526
+ title: trans.__('Information'),
527
+ body: trans.__(
528
+ 'You will need to %1 to apply the changes.',
529
+ followUps.join(trans.__(' and '))
530
+ ),
531
+ buttons: [Dialog.okButton({ label: trans.__('Ok') })]
532
+ });
533
+ }
534
+ this.actionError = null;
535
+ },
536
+ reason => {
537
+ this.actionError = reason.toString();
538
+ }
539
+ );
540
+ this.addPendingAction(actionRequest);
541
+ return actionRequest.then(([reply]) => reply);
542
+ }
543
+
544
+ /**
545
+ * Add a pending action.
546
+ *
547
+ * @param pending A promise that resolves when the action is completed.
548
+ */
549
+ protected addPendingAction(pending: Promise<any>): void {
550
+ // Add to pending actions collection
551
+ this._pendingActions.push(pending);
552
+
553
+ // Ensure action is removed when resolved
554
+ const remove = () => {
555
+ const i = this._pendingActions.indexOf(pending);
556
+ this._pendingActions.splice(i, 1);
557
+ this.stateChanged.emit(undefined);
558
+ };
559
+ pending.then(remove, remove);
560
+
561
+ // Signal changed state
562
+ this.stateChanged.emit(undefined);
563
+ }
564
+
565
+ actionError: string | null = null;
566
+
567
+ /**
568
+ * Contains an error message if an error occurred when querying installed extensions.
569
+ */
570
+ installedError: string | null = null;
571
+
572
+ /**
573
+ * Contains an error message if an error occurred when searching for extensions.
574
+ */
575
+ searchError: string | null = null;
576
+
577
+ /**
578
+ * Whether a reload should be considered due to actions taken.
579
+ */
580
+ promptReload = false;
581
+
582
+ /**
583
+ * The service manager to use for building.
584
+ */
585
+ protected serviceManager: ServiceManager.IManager;
586
+
587
+ protected translator: ITranslator;
588
+
589
+ private _isDisclaimed = false;
590
+ private _isEnabled = false;
591
+ private _isLoadingInstalledExtensions = false;
592
+ private _isSearching = false;
593
+
594
+ private _query: string = '';
595
+ private _page: number = 1;
596
+ private _pagination: number = 30;
597
+ private _lastPage: number = 1;
598
+
599
+ private _installed: IEntry[];
600
+ private _lastSearchResult: IEntry[];
601
+ private _pendingActions: Promise<any>[] = [];
602
+ private _debouncedSearch: Debouncer<void, void>;
603
+ }
604
+
605
+ /**
606
+ * ListModel statics.
607
+ */
608
+ export namespace ListModel {
609
+ /**
610
+ * Utility function to check whether an entry can be updated.
611
+ *
612
+ * @param entry The entry to check.
613
+ */
614
+ export function entryHasUpdate(entry: IEntry): boolean {
615
+ if (!entry.installed || !entry.latest_version) {
616
+ return false;
617
+ }
618
+ return semver.lt(entry.installed_version, entry.latest_version);
619
+ }
620
+ }
621
+
622
+ /**
623
+ * A namespace for private functionality.
624
+ */
625
+ namespace Private {
626
+ /**
627
+ * A comparator function that sorts allowedExtensions orgs to the top.
628
+ */
629
+ export function comparator(a: IEntry, b: IEntry): number {
630
+ if (a.name === b.name) {
631
+ return 0;
632
+ } else {
633
+ return a.name > b.name ? 1 : -1;
634
+ }
635
+ }
636
+
637
+ const LINK_PARSER = /<([^>]+)>; rel="([^"]+)",?/g;
638
+
639
+ /**
640
+ * Call the API extension
641
+ *
642
+ * @param queryArgs Query arguments
643
+ * @param init Initial values for the request
644
+ * @returns The response body interpreted as JSON and the response link header
645
+ */
646
+ export async function requestAPI<T>(
647
+ queryArgs: { [k: string]: any } = {},
648
+ init: RequestInit = {}
649
+ ): Promise<[T, { [key: string]: string }]> {
650
+ // Make request to Jupyter API
651
+ const settings = ServerConnection.makeSettings();
652
+ const requestUrl = URLExt.join(
653
+ settings.baseUrl,
654
+ EXTENSION_API_PATH // API Namespace
655
+ );
656
+
657
+ let response: Response;
658
+ try {
659
+ response = await ServerConnection.makeRequest(
660
+ requestUrl + URLExt.objectToQueryString(queryArgs),
661
+ init,
662
+ settings
663
+ );
664
+ } catch (error) {
665
+ throw new ServerConnection.NetworkError(error);
666
+ }
667
+
668
+ let data: any = await response.text();
669
+
670
+ if (data.length > 0) {
671
+ try {
672
+ data = JSON.parse(data);
673
+ } catch (error) {
674
+ console.log('Not a JSON response body.', response);
675
+ }
676
+ }
677
+
678
+ if (!response.ok) {
679
+ throw new ServerConnection.ResponseError(response, data.message || data);
680
+ }
681
+
682
+ const link = response.headers.get('Link') ?? '';
683
+
684
+ const links: { [key: string]: string } = {};
685
+ let match: RegExpExecArray | null = null;
686
+ while ((match = LINK_PARSER.exec(link)) !== null) {
687
+ links[match[2]] = match[1];
688
+ }
689
+ return [data, links];
690
+ }
691
+ }