plexora 0.0.1__py3-none-any.whl
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.
- plexora/__init__.py +129 -0
- plexora/__main__.py +15 -0
- plexora/_url.py +105 -0
- plexora/api/__init__.py +55 -0
- plexora/api/dataset.py +398 -0
- plexora/api/http.py +19 -0
- plexora/api/plugin.py +541 -0
- plexora/api/store.py +221 -0
- plexora/cli.py +777 -0
- plexora/client/dist/354_bundle.js +2 -0
- plexora/client/dist/354_bundle.js.LICENSE.txt +20 -0
- plexora/client/dist/418_bundle.js +2 -0
- plexora/client/dist/418_bundle.js.LICENSE.txt +1 -0
- plexora/client/dist/693b935cb1f907814be9c6199a68c217.svg +19 -0
- plexora/client/dist/713c134ff47cd328a5b711526f151082.svg +18 -0
- plexora/client/dist/770_bundle.js +1 -0
- plexora/client/dist/ff743f408972e0e96ca9153ed5ae4c83.svg +23 -0
- plexora/client/dist/vendor_bundle.js +2 -0
- plexora/client/dist/vendor_bundle.js.LICENSE.txt +308 -0
- plexora/client/external/openseadragon-bin-2.4.0/LICENSE.txt +28 -0
- plexora/client/external/openseadragon-bin-2.4.0/canvas-overlay-hd.js +140 -0
- plexora/client/external/openseadragon-bin-2.4.0/changelog.txt +501 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/.gitattributes +17 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/LICENSE.txt +116 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/Toolbar.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/fullpage.psd +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/home.psd +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/next.psd +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/previous.psd +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/rotateleft.psd +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/rotateright.psd +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/zoom.psd +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/README.md +9 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-scalebar.js +562 -0
- plexora/client/src/css/import.css +474 -0
- plexora/client/src/css/main.css +903 -0
- plexora/client/src/css/openProject.css +363 -0
- plexora/client/src/css/quickView.css +166 -0
- plexora/client/src/css/tokens.css +51 -0
- plexora/client/src/css/viewer.css +1655 -0
- plexora/client/src/img/apple-touch-icon.png +0 -0
- plexora/client/src/img/favicon.ico +0 -0
- plexora/client/src/img/logo.ai +8892 -33
- plexora/client/src/img/logo.svg +1 -0
- plexora/client/src/img/logo_with_text.ai +9011 -34
- plexora/client/src/img/logo_with_text.svg +1 -0
- plexora/client/src/js/main.js +704 -0
- plexora/client/src/js/pluginRegistry.js +108 -0
- plexora/client/src/js/services/appStatus.js +286 -0
- plexora/client/src/js/services/browsePicker.js +73 -0
- plexora/client/src/js/services/dataLayer.js +400 -0
- plexora/client/src/js/services/datasetContext.js +143 -0
- plexora/client/src/js/services/glRenderer.js +143 -0
- plexora/client/src/js/services/importFormValidation.js +257 -0
- plexora/client/src/js/services/numericData.js +100 -0
- plexora/client/src/js/services/passVariablesToFrontend.js +20 -0
- plexora/client/src/js/services/simpleEventHandler.js +25 -0
- plexora/client/src/js/vendor.js +29 -0
- plexora/client/src/js/views/channelList.js +663 -0
- plexora/client/src/js/views/colorSwatchPicker.js +202 -0
- plexora/client/src/js/views/columnClassifier.js +146 -0
- plexora/client/src/js/views/coordinateField.js +133 -0
- plexora/client/src/js/views/dataSourceField.js +276 -0
- plexora/client/src/js/views/imageViewer.js +3306 -0
- plexora/client/src/js/views/miniMap.js +787 -0
- plexora/client/src/js/views/navbarControls.js +178 -0
- plexora/client/src/js/views/openProjectPage.js +239 -0
- plexora/client/src/js/views/projectEdit.js +335 -0
- plexora/client/src/js/views/quickViewLanding.js +136 -0
- plexora/client/src/js/views/rainbow.js +326 -0
- plexora/client/src/js/views/requirementsModal.js +572 -0
- plexora/client/src/js/views/rgbImageViewer.js +115 -0
- plexora/client/src/js/views/searchableSelect.js +411 -0
- plexora/client/src/js/views/segmentationProgress.js +122 -0
- plexora/client/src/js/views/toolLoader.js +747 -0
- plexora/client/src/js/views/viewerControls.js +689 -0
- plexora/client/src/js/views/viewerManager.js +434 -0
- plexora/client/src/js/views/viewerSidebar.js +1117 -0
- plexora/client/src/js/workers/tileDecoder.js +130 -0
- plexora/client/src/shaders/frag.glsl +528 -0
- plexora/client/src/shaders/vert.glsl +12 -0
- plexora/client/templates/base.html +212 -0
- plexora/client/templates/index.html +238 -0
- plexora/client/templates/open_project.html +96 -0
- plexora/client/templates/project_columns.html +86 -0
- plexora/client/templates/project_edit.html +196 -0
- plexora/client/templates/upload.html +118 -0
- plexora/connect.py +528 -0
- plexora/datasource.py +782 -0
- plexora/jupyter.py +406 -0
- plexora/notebook_env.py +232 -0
- plexora/paths.py +486 -0
- plexora/plugins/__init__.py +12 -0
- plexora/plugins/cell_explorer/__init__.py +100 -0
- plexora/plugins/cell_explorer/server/__init__.py +11 -0
- plexora/plugins/cell_explorer/server/routes.py +167 -0
- plexora/plugins/cell_explorer/server/state.py +281 -0
- plexora/plugins/cell_explorer/server/values.py +145 -0
- plexora/plugins/cell_explorer/server/variables.py +367 -0
- plexora/plugins/cell_explorer/static/cellExplorerApi.js +158 -0
- plexora/plugins/cell_explorer/static/cellExplorerColors.js +316 -0
- plexora/plugins/cell_explorer/static/cellExplorerContinuous.js +403 -0
- plexora/plugins/cell_explorer/static/cellExplorerFigureBridge.js +133 -0
- plexora/plugins/cell_explorer/static/cellExplorerLegend.js +188 -0
- plexora/plugins/cell_explorer/static/cellExplorerRoiBridge.js +822 -0
- plexora/plugins/cell_explorer/static/cellExplorerSidebarController.js +826 -0
- plexora/plugins/cell_explorer/static/cellExplorerState.js +372 -0
- plexora/plugins/cell_explorer/static/cell_explorer.css +752 -0
- plexora/plugins/cell_explorer/templates/cell_explorer/panel.html +137 -0
- plexora/plugins/figure_builder/__init__.py +125 -0
- plexora/plugins/figure_builder/server/__init__.py +7 -0
- plexora/plugins/figure_builder/server/compose.py +365 -0
- plexora/plugins/figure_builder/server/export.py +784 -0
- plexora/plugins/figure_builder/server/export_jobs.py +148 -0
- plexora/plugins/figure_builder/server/operations.py +556 -0
- plexora/plugins/figure_builder/server/pixels.py +142 -0
- plexora/plugins/figure_builder/server/provenance.py +174 -0
- plexora/plugins/figure_builder/server/render.py +278 -0
- plexora/plugins/figure_builder/server/repository.py +701 -0
- plexora/plugins/figure_builder/server/routes.py +594 -0
- plexora/plugins/figure_builder/server/schema.py +1023 -0
- plexora/plugins/figure_builder/server/sources.py +120 -0
- plexora/plugins/figure_builder/server/textmetrics.py +155 -0
- plexora/plugins/figure_builder/static/figureActions.js +363 -0
- plexora/plugins/figure_builder/static/figureBuilderApi.js +255 -0
- plexora/plugins/figure_builder/static/figureCanvas.js +2362 -0
- plexora/plugins/figure_builder/static/figureCaptureBoxes.js +369 -0
- plexora/plugins/figure_builder/static/figureCaptureDock.js +466 -0
- plexora/plugins/figure_builder/static/figureCaptureTool.js +1034 -0
- plexora/plugins/figure_builder/static/figureConfirm.js +136 -0
- plexora/plugins/figure_builder/static/figureContextBar.js +1193 -0
- plexora/plugins/figure_builder/static/figureContextMenu.js +253 -0
- plexora/plugins/figure_builder/static/figureDocumentState.js +275 -0
- plexora/plugins/figure_builder/static/figureExportUi.js +174 -0
- plexora/plugins/figure_builder/static/figureLibrary.js +247 -0
- plexora/plugins/figure_builder/static/figureQuickEdit.js +628 -0
- plexora/plugins/figure_builder/static/figureRichText.js +634 -0
- plexora/plugins/figure_builder/static/figureSceneSnapshot.js +361 -0
- plexora/plugins/figure_builder/static/figureSchema.js +218 -0
- plexora/plugins/figure_builder/static/figureSidebarController.js +1159 -0
- plexora/plugins/figure_builder/static/figureTextEditor.js +503 -0
- plexora/plugins/figure_builder/static/figureTextPanel.js +491 -0
- plexora/plugins/figure_builder/static/figureViewOptions.js +273 -0
- plexora/plugins/figure_builder/static/figureWorkspace.js +2030 -0
- plexora/plugins/figure_builder/static/figure_builder.css +3292 -0
- plexora/plugins/figure_builder/templates/figure_builder/library.html +74 -0
- plexora/plugins/figure_builder/templates/figure_builder/workspace.html +27 -0
- plexora/plugins/figure_builder/templates/figure_builder/workspace_body.html +458 -0
- plexora/plugins/gating/__init__.py +76 -0
- plexora/plugins/gating/server/__init__.py +0 -0
- plexora/plugins/gating/server/anndata_gates.py +367 -0
- plexora/plugins/gating/server/database.py +16 -0
- plexora/plugins/gating/server/model.py +320 -0
- plexora/plugins/gating/server/routes.py +235 -0
- plexora/plugins/gating/static/csvGatingList.js +947 -0
- plexora/plugins/gating/static/gating.css +289 -0
- plexora/plugins/gating/static/gatingApi.js +221 -0
- plexora/plugins/gating/static/gatingSidebarController.js +513 -0
- plexora/plugins/gating/templates/gating/legacy.html +26 -0
- plexora/plugins/gating/templates/gating/panel.html +69 -0
- plexora/plugins/roi/__init__.py +95 -0
- plexora/plugins/roi/server/__init__.py +8 -0
- plexora/plugins/roi/server/adapters.py +655 -0
- plexora/plugins/roi/server/geojson.py +267 -0
- plexora/plugins/roi/server/geometry.py +181 -0
- plexora/plugins/roi/server/mapping.py +169 -0
- plexora/plugins/roi/server/operations.py +293 -0
- plexora/plugins/roi/server/repository.py +215 -0
- plexora/plugins/roi/server/routes.py +409 -0
- plexora/plugins/roi/server/schema.py +323 -0
- plexora/plugins/roi/static/roi.css +583 -0
- plexora/plugins/roi/static/roiApi.js +177 -0
- plexora/plugins/roi/static/roiFigureBridge.js +107 -0
- plexora/plugins/roi/static/roiGeometry.js +350 -0
- plexora/plugins/roi/static/roiRenderer.js +278 -0
- plexora/plugins/roi/static/roiSidebarController.js +1038 -0
- plexora/plugins/roi/static/roiState.js +518 -0
- plexora/plugins/roi/static/roiTools.js +993 -0
- plexora/plugins/roi/templates/roi/panel.html +240 -0
- plexora/proxy.py +57 -0
- plexora/server/models/adapters/__init__.py +94 -0
- plexora/server/models/adapters/anndata_adapter.py +388 -0
- plexora/server/models/adapters/base.py +93 -0
- plexora/server/models/adapters/classify.py +192 -0
- plexora/server/models/adapters/csv_adapter.py +105 -0
- plexora/server/models/adapters/inspection.py +265 -0
- plexora/server/models/adapters/spatialdata_adapter.py +248 -0
- plexora/server/models/centroid_tiles.py +305 -0
- plexora/server/models/data_model.py +1659 -0
- plexora/server/models/database_model.py +169 -0
- plexora/server/models/project.py +1270 -0
- plexora/server/plugins.py +199 -0
- plexora/server/routes/browse_routes.py +41 -0
- plexora/server/routes/data_routes.py +294 -0
- plexora/server/routes/import_routes.py +603 -0
- plexora/server/routes/page_routes.py +139 -0
- plexora/server/routes/project_routes.py +438 -0
- plexora/server/routes/quick_view_routes.py +62 -0
- plexora/server/routes/system_routes.py +39 -0
- plexora/server/routes/tool_routes.py +418 -0
- plexora/server/utils/addHEColumns.py +11 -0
- plexora/server/utils/fast_png.py +97 -0
- plexora/server/utils/fullConversion.py +50 -0
- plexora/server/utils/native_dialog.py +168 -0
- plexora/server/utils/pre_normalization.py +47 -0
- plexora/server/utils/segmentation_pyramid.py +598 -0
- plexora/server/utils/smallestenclosingcircle.py +126 -0
- plexora/server/utils/tiffsurgeon.py +372 -0
- plexora/server_cli.py +48 -0
- plexora/test.py +0 -0
- plexora-0.0.1.dist-info/METADATA +341 -0
- plexora-0.0.1.dist-info/RECORD +248 -0
- plexora-0.0.1.dist-info/WHEEL +5 -0
- plexora-0.0.1.dist-info/entry_points.txt +12 -0
- plexora-0.0.1.dist-info/licenses/LICENSE +207 -0
- plexora-0.0.1.dist-info/top_level.txt +1 -0
plexora/api/plugin.py
ADDED
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
"""What a plugin declares about itself.
|
|
2
|
+
|
|
3
|
+
A plugin package exposes a module-level `PLUGIN = Plugin(...)`. Plexora finds
|
|
4
|
+
it either through the `plexora.plugins` entry point group (how a third-party
|
|
5
|
+
distribution ships) or by scanning the bundled plugins directory (how
|
|
6
|
+
first-party ones ship). Both paths produce this same descriptor, so a bundled
|
|
7
|
+
plugin gets no privileges an installed one lacks.
|
|
8
|
+
|
|
9
|
+
The descriptor is data, not behaviour. Everything the host needs in order to
|
|
10
|
+
render a tool -- its label, its panels, its assets, what data it needs -- is
|
|
11
|
+
declared here, so core never has to name a specific plugin.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import Any, Iterable, Mapping
|
|
19
|
+
|
|
20
|
+
from plexora.server.models.project import ROLE_LABELS, ROLE_NAMES, Project
|
|
21
|
+
|
|
22
|
+
#: Plugin names become URL segments and SQL identifiers, so they are
|
|
23
|
+
#: restricted rather than escaped. Matches plexora.api.store's rule.
|
|
24
|
+
_SAFE_NAME = re.compile(r"^[a-z][a-z0-9_]*$")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class Requirement:
|
|
29
|
+
"""One thing a plugin needs that the project does not have yet.
|
|
30
|
+
|
|
31
|
+
Data rather than a message, because core renders it: the requirements modal
|
|
32
|
+
turns a list of these into a form without knowing which plugin asked or
|
|
33
|
+
what it wants them for. `kind` picks the input widget; `key` is what the
|
|
34
|
+
answer is posted back under.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
key: str
|
|
38
|
+
#: 'data' | 'segmentation' | 'classification' | 'features' | 'role'
|
|
39
|
+
#: | 'coordinates'
|
|
40
|
+
kind: str
|
|
41
|
+
label: str
|
|
42
|
+
#: Offered but not blocking -- the tool opens whether or not it is given.
|
|
43
|
+
optional: bool = False
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def role(self) -> str | None:
|
|
47
|
+
"""The column role this asks for, for kind == 'role'."""
|
|
48
|
+
return self.key.split(":", 1)[1] if self.key.startswith("role:") else None
|
|
49
|
+
|
|
50
|
+
def describe(self) -> dict:
|
|
51
|
+
# `role` is sent, not left for the client to parse back out of `key`.
|
|
52
|
+
# Omitting it is not cosmetic: the requirements modal keys its answers
|
|
53
|
+
# by `requirement.role`, so an absent field made every role select post
|
|
54
|
+
# under the literal key "undefined" -- each field clobbering the last,
|
|
55
|
+
# and the whole lot dropped server-side by with_role_answers' `role in
|
|
56
|
+
# ROLE_NAMES` filter, while `confirm` (which reads `key`, and so worked)
|
|
57
|
+
# marked the questions answered so they were never asked again. The
|
|
58
|
+
# visible symptom was a project silently keeping the adapter's
|
|
59
|
+
# positional row number as its cell id, which puts every segmentation
|
|
60
|
+
# outline on the wrong cell.
|
|
61
|
+
return {"key": self.key, "kind": self.kind, "label": self.label,
|
|
62
|
+
"optional": self.optional, "role": self.role}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
#: The acquirable inputs a plugin may name, and how each is described to the
|
|
66
|
+
#: user. Roles are generated from ROLE_NAMES so the vocabulary cannot drift
|
|
67
|
+
#: from what the project record can actually store.
|
|
68
|
+
_INPUT_LABELS = {
|
|
69
|
+
"table": ("data", "Single-cell data"),
|
|
70
|
+
"segmentation": ("segmentation", "Segmentation mask"),
|
|
71
|
+
"markers": ("classification", "Marker and metadata columns"),
|
|
72
|
+
"features": ("features", "Expression values"),
|
|
73
|
+
# Stands in for the x/y role pair wherever the table is built from a read
|
|
74
|
+
# spec -- see `_coordinate_keys`. Never named by a plugin directly: a
|
|
75
|
+
# plugin declares the roles it reads, and core decides which question
|
|
76
|
+
# actually answers them for this project's format.
|
|
77
|
+
"coordinates": ("coordinates", "Cell coordinates"),
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
#: The roles the coordinate question answers, and the key it answers them with.
|
|
81
|
+
_COORDINATE_ROLES = ("x", "y")
|
|
82
|
+
COORDINATES_KEY = "coordinates"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _coordinate_keys(project, roles) -> list[str]:
|
|
86
|
+
"""How this project's x/y roles are asked for.
|
|
87
|
+
|
|
88
|
+
For a CSV they are two ordinary column roles -- the table is the file, and
|
|
89
|
+
a role just names a column in it. For AnnData and SpatialData the table
|
|
90
|
+
does not exist until the adapter builds it, and the coordinates may come
|
|
91
|
+
from a single `obsm` array holding both axes, which no pair of
|
|
92
|
+
single-column selects can express. There the two roles collapse into one
|
|
93
|
+
`coordinates` question.
|
|
94
|
+
"""
|
|
95
|
+
wanted = [role for role in _COORDINATE_ROLES if role in roles]
|
|
96
|
+
if not wanted:
|
|
97
|
+
return []
|
|
98
|
+
if project.columns_are_structural:
|
|
99
|
+
return [COORDINATES_KEY]
|
|
100
|
+
return [f"role:{role}" for role in wanted]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _requirement(key: str, optional: bool = False) -> Requirement:
|
|
104
|
+
if key.startswith("role:"):
|
|
105
|
+
role = key.split(":", 1)[1]
|
|
106
|
+
return Requirement(key=key, kind="role",
|
|
107
|
+
label=ROLE_LABELS.get(role, role), optional=optional)
|
|
108
|
+
kind, label = _INPUT_LABELS[key]
|
|
109
|
+
return Requirement(key=key, kind=kind, label=label, optional=optional)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass(frozen=True)
|
|
113
|
+
class Requires:
|
|
114
|
+
"""What a datasource must offer before this plugin's tool is usable.
|
|
115
|
+
|
|
116
|
+
Two different questions, deliberately kept apart:
|
|
117
|
+
|
|
118
|
+
`applies_to` -- could this plugin EVER work here? A flat RGB image has no
|
|
119
|
+
channels, and no amount of uploading changes that, so the tool is hidden.
|
|
120
|
+
|
|
121
|
+
`satisfied_by` -- can it work RIGHT NOW? A project with the wrong image
|
|
122
|
+
kind fails both; a project merely missing its feature table fails only this
|
|
123
|
+
one, and that is a recoverable state: the tool stays listed and opening it
|
|
124
|
+
asks for what is missing (see tool_routes.tool_panel).
|
|
125
|
+
|
|
126
|
+
Collapsing the two hides a tool from a project that could have used it
|
|
127
|
+
after one upload, which also hides the upload path itself.
|
|
128
|
+
|
|
129
|
+
Image data is not listed because every plugin gets it -- that is the floor
|
|
130
|
+
of the contract.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
#: Needs a feature table (CSV/AnnData/SpatialData). Acquirable.
|
|
134
|
+
table: bool = False
|
|
135
|
+
#: Needs a segmentation mask. Acquirable.
|
|
136
|
+
segmentation: bool = False
|
|
137
|
+
#: Needs the marker/metadata split to have been established, so it can
|
|
138
|
+
#: offer the user a marker list that is not a guess.
|
|
139
|
+
markers: bool = False
|
|
140
|
+
#: Reads the marker intensities themselves, and so depends on *which*
|
|
141
|
+
#: numbers those are. A file can hold raw counts in `X` and a log-transformed
|
|
142
|
+
#: copy in a layer, and nothing about the values says which is which: a
|
|
143
|
+
#: threshold set on one is meaningless on the other. Declaring this is what
|
|
144
|
+
#: puts that choice, and the log switch beside it, in front of the user once
|
|
145
|
+
#: -- never asked for a CSV, which has only one table of numbers.
|
|
146
|
+
features: bool = False
|
|
147
|
+
#: Column roles this plugin resolves through `dataset.schema` -- any of
|
|
148
|
+
#: ROLE_NAMES. Declaring them is what lets core ask for the ones a project
|
|
149
|
+
#: never recorded, instead of the plugin growing its own "type the column
|
|
150
|
+
#: name" box.
|
|
151
|
+
roles: tuple[str, ...] = ()
|
|
152
|
+
#: Inputs to offer but never block on, named the same way (`"segmentation"`,
|
|
153
|
+
#: `"role:image_id"`). The tool opens without them; it just does less.
|
|
154
|
+
optional: tuple[str, ...] = ()
|
|
155
|
+
#: Image kinds this plugin cannot handle. 'rgb' is the flat quick-view
|
|
156
|
+
#: path: no channels, so marker tools are meaningless there. Permanent.
|
|
157
|
+
excluded_image_kinds: tuple[str, ...] = ("rgb",)
|
|
158
|
+
|
|
159
|
+
def __post_init__(self):
|
|
160
|
+
unknown = [r for r in self.roles if r not in ROLE_NAMES]
|
|
161
|
+
if unknown:
|
|
162
|
+
raise ValueError(
|
|
163
|
+
f"unknown column role(s) {unknown!r}: expected any of {list(ROLE_NAMES)}"
|
|
164
|
+
)
|
|
165
|
+
for key in self.optional:
|
|
166
|
+
if key not in _INPUT_LABELS and key.split(":", 1)[0] != "role":
|
|
167
|
+
raise ValueError(f"unknown optional requirement {key!r}")
|
|
168
|
+
if key.startswith("role:") and key.split(":", 1)[1] not in ROLE_NAMES:
|
|
169
|
+
raise ValueError(f"unknown column role in optional requirement {key!r}")
|
|
170
|
+
|
|
171
|
+
def applies_to(self, project) -> bool:
|
|
172
|
+
"""Whether this plugin is compatible with the datasource at all."""
|
|
173
|
+
project = _as_project(project)
|
|
174
|
+
return project.image.kind not in self.excluded_image_kinds
|
|
175
|
+
|
|
176
|
+
def missing_from(self, project) -> list[Requirement]:
|
|
177
|
+
"""Which acquirable inputs this datasource still lacks, in the order
|
|
178
|
+
they should be asked for: the file first, then what it contains.
|
|
179
|
+
|
|
180
|
+
Roles and markers are reported only once there is a table -- asking
|
|
181
|
+
which column holds the cell id before any columns exist is a question
|
|
182
|
+
with no answers, and the table requirement already covers it.
|
|
183
|
+
"""
|
|
184
|
+
project = _as_project(project)
|
|
185
|
+
missing = []
|
|
186
|
+
if self.table and not project.has_table:
|
|
187
|
+
missing.append(_requirement("table"))
|
|
188
|
+
if self.segmentation and not project.segmentation.requested:
|
|
189
|
+
missing.append(_requirement("segmentation"))
|
|
190
|
+
if project.has_table:
|
|
191
|
+
if self.markers and not project.columns.classified:
|
|
192
|
+
missing.append(_requirement("markers"))
|
|
193
|
+
for key in self._column_keys(project):
|
|
194
|
+
if not _answered(project, key):
|
|
195
|
+
missing.append(_requirement(key))
|
|
196
|
+
return missing
|
|
197
|
+
|
|
198
|
+
def _column_keys(self, project) -> list[str]:
|
|
199
|
+
"""Every question about this project's columns that this plugin's roles
|
|
200
|
+
imply, in ask order -- with x/y already translated into whichever form
|
|
201
|
+
this project's format can actually answer (see `_coordinate_keys`)."""
|
|
202
|
+
keys = [f"role:{role}" for role in self.roles
|
|
203
|
+
if role not in _COORDINATE_ROLES]
|
|
204
|
+
return keys + _coordinate_keys(project, self.roles)
|
|
205
|
+
|
|
206
|
+
def declared_keys(self, project) -> list[str]:
|
|
207
|
+
"""Every input this plugin names, required and optional, in ask order.
|
|
208
|
+
|
|
209
|
+
Used to work out what has never been put in front of the user -- which
|
|
210
|
+
is not the same question as what is absent, and needs the whole list
|
|
211
|
+
rather than just the unmet part of it.
|
|
212
|
+
"""
|
|
213
|
+
project = _as_project(project)
|
|
214
|
+
keys = []
|
|
215
|
+
if self.table:
|
|
216
|
+
keys.append("table")
|
|
217
|
+
if self.segmentation:
|
|
218
|
+
keys.append("segmentation")
|
|
219
|
+
# Ahead of the column questions because it is the consequential one:
|
|
220
|
+
# which numbers are being read decides what every answer below it means.
|
|
221
|
+
if self.features:
|
|
222
|
+
keys.append("features")
|
|
223
|
+
if self.markers:
|
|
224
|
+
keys.append("markers")
|
|
225
|
+
keys.extend(self._column_keys(project))
|
|
226
|
+
keys.extend(key for key in self.optional if key not in keys)
|
|
227
|
+
return keys
|
|
228
|
+
|
|
229
|
+
def unconfirmed_from(self, project) -> list[Requirement]:
|
|
230
|
+
"""Inputs this project has an answer for that the user never gave.
|
|
231
|
+
|
|
232
|
+
The column predictor fills in most of a conventionally-named table, and
|
|
233
|
+
a guess that happens to be right is still a guess -- so the first time a
|
|
234
|
+
tool opens, what it depends on is shown once for confirmation, prefilled.
|
|
235
|
+
After that the answer is recorded and never asked for again.
|
|
236
|
+
|
|
237
|
+
Absent inputs are deliberately not here: those are `missing_from`'s and
|
|
238
|
+
`optional_missing_from`'s, and listing an input twice would render the
|
|
239
|
+
same field twice in one form.
|
|
240
|
+
"""
|
|
241
|
+
project = _as_project(project)
|
|
242
|
+
return [
|
|
243
|
+
_requirement(key, optional=key in self.optional)
|
|
244
|
+
for key in project.unconfirmed(self.declared_keys(project))
|
|
245
|
+
if not _never_confirmed(project, key) and _answered(project, key)
|
|
246
|
+
]
|
|
247
|
+
|
|
248
|
+
def optional_missing_from(self, project) -> list[Requirement]:
|
|
249
|
+
"""The non-blocking inputs this datasource lacks, so the modal can
|
|
250
|
+
offer them alongside the required ones.
|
|
251
|
+
|
|
252
|
+
Offered once. A user who was shown an optional field and left it blank
|
|
253
|
+
has answered it -- there may be no such column in their data -- and
|
|
254
|
+
re-offering it every time a tool opens is worse than not offering it.
|
|
255
|
+
A plugin that genuinely cannot proceed without one asks for it directly
|
|
256
|
+
through `requested_from`.
|
|
257
|
+
"""
|
|
258
|
+
project = _as_project(project)
|
|
259
|
+
missing = []
|
|
260
|
+
for key in project.unconfirmed(self.optional):
|
|
261
|
+
if key == "table" and project.has_table:
|
|
262
|
+
continue
|
|
263
|
+
if key == "segmentation" and project.segmentation.requested:
|
|
264
|
+
continue
|
|
265
|
+
if key == "markers" and project.columns.classified:
|
|
266
|
+
continue
|
|
267
|
+
if key.startswith("role:") or key == COORDINATES_KEY:
|
|
268
|
+
# Through `_answered` rather than reading the role directly, so
|
|
269
|
+
# the states that are answers without being a named column --
|
|
270
|
+
# "one image", a recorded coordinate source -- count here the
|
|
271
|
+
# same way they do for a blocking requirement.
|
|
272
|
+
if not project.has_table or _answered(project, key):
|
|
273
|
+
continue
|
|
274
|
+
missing.append(_requirement(key, optional=True))
|
|
275
|
+
return missing
|
|
276
|
+
|
|
277
|
+
def requested_from(self, project, keys: Iterable[str]) -> list[Requirement]:
|
|
278
|
+
"""Descriptors for named inputs this project still cannot answer.
|
|
279
|
+
|
|
280
|
+
For a plugin demanding something mid-session, after its panel is
|
|
281
|
+
already open -- gating needs an image-id column only when the user
|
|
282
|
+
chooses to write gates back to the source file, which may be an hour
|
|
283
|
+
into a session or never.
|
|
284
|
+
|
|
285
|
+
Ignores `confirmed` on purpose: the user may have been offered this as
|
|
286
|
+
an optional field and skipped it, which is a fine answer right up until
|
|
287
|
+
they ask for the one action that cannot proceed without it. Restricted
|
|
288
|
+
to keys the plugin declared, so this cannot become a back door for
|
|
289
|
+
asking about something it never said it used.
|
|
290
|
+
"""
|
|
291
|
+
project = _as_project(project)
|
|
292
|
+
declared = set(self.declared_keys(project))
|
|
293
|
+
return [_requirement(key, optional=key in self.optional)
|
|
294
|
+
for key in keys
|
|
295
|
+
if key in declared and not _answered(project, key)]
|
|
296
|
+
|
|
297
|
+
def satisfied_by(self, project) -> bool:
|
|
298
|
+
"""Whether the plugin can be opened as things stand."""
|
|
299
|
+
project = _as_project(project)
|
|
300
|
+
return self.applies_to(project) and not self.missing_from(project)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
#: Inputs that are a path the user typed or browsed to, never something the
|
|
304
|
+
#: app worked out. There is nothing to confirm about them -- showing a file
|
|
305
|
+
#: path back and asking "is this the file you chose?" is noise -- so they are
|
|
306
|
+
#: only ever asked for when absent.
|
|
307
|
+
_GIVEN_KEYS = frozenset({"table", "segmentation"})
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _never_confirmed(project: Project, key: str) -> bool:
|
|
311
|
+
"""Whether this input is one the user is never shown for confirmation.
|
|
312
|
+
|
|
313
|
+
Either because they supplied it themselves (`_GIVEN_KEYS`), or because the
|
|
314
|
+
answer is not a guess in the first place: an AnnData or SpatialData file
|
|
315
|
+
states its own marker/metadata split, and putting `var` and `obs` in a
|
|
316
|
+
drag-and-drop box asks the user to confirm what the file already says.
|
|
317
|
+
|
|
318
|
+
`features` is asked for every format, which it was not: a CSV was skipped
|
|
319
|
+
on the grounds that it has one table of numbers and no layer to prefer.
|
|
320
|
+
True, and only half the question -- the other half is whether those numbers
|
|
321
|
+
are raw counts, and that is exactly as open for a CSV as for an .h5ad with
|
|
322
|
+
no layers. Skipping it left the log1p switch with nowhere to appear on the
|
|
323
|
+
one format that most often arrives untransformed. The matrix picker still
|
|
324
|
+
stands down on its own (`feature_options` is empty for a CSV, and the modal
|
|
325
|
+
drops a select with nothing to choose between).
|
|
326
|
+
"""
|
|
327
|
+
if key in _GIVEN_KEYS:
|
|
328
|
+
return True
|
|
329
|
+
if key == "markers":
|
|
330
|
+
return project.columns_are_structural
|
|
331
|
+
return False
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _answered(project: Project, key: str) -> bool:
|
|
335
|
+
"""Whether the project currently holds a value for this input.
|
|
336
|
+
|
|
337
|
+
Says nothing about who supplied it -- the predictor's guess counts as an
|
|
338
|
+
answer here, which is exactly why `unconfirmed_from` needs this as well as
|
|
339
|
+
the `confirmed` list to tell a guess from a decision.
|
|
340
|
+
"""
|
|
341
|
+
if key == "table":
|
|
342
|
+
return project.has_table
|
|
343
|
+
if key == "segmentation":
|
|
344
|
+
return project.segmentation.requested
|
|
345
|
+
if key == "markers":
|
|
346
|
+
return project.columns.classified
|
|
347
|
+
if key == "features":
|
|
348
|
+
# Never absent: a table is always being read from some matrix, so this
|
|
349
|
+
# is only ever a value nobody has looked at rather than a gap. It
|
|
350
|
+
# reaches the user through `unconfirmed_from`, never `missing_from`.
|
|
351
|
+
return project.has_table
|
|
352
|
+
if key == COORDINATES_KEY:
|
|
353
|
+
# The recorded read spec, not the roles: `roles.x`/`roles.y` are the
|
|
354
|
+
# literal "X"/"Y" the adapter emits and are set the moment a table
|
|
355
|
+
# exists, so they say nothing about whether anyone chose a source.
|
|
356
|
+
return bool(project.has_table and project.dataset
|
|
357
|
+
and project.dataset.coordinates)
|
|
358
|
+
if key == "role:cell_id" and project.columns_are_structural:
|
|
359
|
+
# The role is not the answer for these formats. It names a column of
|
|
360
|
+
# the table the adapter EMITS, and the importer sets it to the
|
|
361
|
+
# adapter's own positional "id" the moment a table loads -- so reading
|
|
362
|
+
# it here would report every AnnData and SpatialData project as having
|
|
363
|
+
# answered a question nobody was asked, which is exactly what left a
|
|
364
|
+
# project drawing gates against row numbers while its mask carried the
|
|
365
|
+
# label values from obs.
|
|
366
|
+
#
|
|
367
|
+
# The read spec is the answer: a named obs column, or the explicit
|
|
368
|
+
# "number the rows" that names none. See DataSpec.row_number_ids.
|
|
369
|
+
return bool(project.has_table and project.dataset
|
|
370
|
+
and (project.dataset.obs_id_field
|
|
371
|
+
or project.dataset.row_number_ids))
|
|
372
|
+
if key == "role:image_id":
|
|
373
|
+
# "This table covers one image" is an answer, and the only one some
|
|
374
|
+
# files have -- so it counts here, while a bare absent role does not.
|
|
375
|
+
# See DataSpec.single_image for why it is not stored as a blank role.
|
|
376
|
+
return bool(project.has_table and project.dataset
|
|
377
|
+
and (project.roles.image_id or project.dataset.single_image))
|
|
378
|
+
if key.startswith("role:"):
|
|
379
|
+
return bool(project.has_table and project.roles.get(key.split(":", 1)[1]))
|
|
380
|
+
return False
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _as_project(project) -> Project:
|
|
384
|
+
"""Accept a Project or a raw config entry.
|
|
385
|
+
|
|
386
|
+
Callers inside core hold a Project. Tests and a few older call sites hold
|
|
387
|
+
the entry dict, and rejecting those would make this contract annoying to
|
|
388
|
+
exercise without buying any safety.
|
|
389
|
+
"""
|
|
390
|
+
if isinstance(project, Project):
|
|
391
|
+
return project
|
|
392
|
+
return Project.from_entry("", project or {})
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
#: Core menus a plugin may add an entry to. Named here so a typo is a startup
|
|
396
|
+
#: error rather than an entry that renders nowhere and cannot be found.
|
|
397
|
+
#:
|
|
398
|
+
#: 'file' the File dropdown, on every page.
|
|
399
|
+
#: 'open_project' the tab strip on the Open Project page.
|
|
400
|
+
NAV_MENUS = ("file", "open_project")
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
@dataclass(frozen=True)
|
|
404
|
+
class NavItem:
|
|
405
|
+
"""One entry a plugin contributes to a core menu.
|
|
406
|
+
|
|
407
|
+
Data, not markup, and deliberately so. A plugin whose home is a page of its
|
|
408
|
+
own -- Figure Builder's library is not about any one datasource, so it
|
|
409
|
+
cannot be a tool panel -- still needs a way in, and the alternatives were
|
|
410
|
+
both worse: core naming the plugin in a template, or core JavaScript probing
|
|
411
|
+
a plugin route to decide whether to unhide a hidden link (which
|
|
412
|
+
tests/test_datalayer_requests.py rules out, because core must not know a
|
|
413
|
+
plugin's addresses).
|
|
414
|
+
|
|
415
|
+
Rendering stays core's: it emits a plain link with its own classes, so a
|
|
416
|
+
plugin cannot style, script or restructure a core menu by contributing to
|
|
417
|
+
it.
|
|
418
|
+
"""
|
|
419
|
+
|
|
420
|
+
menu: str
|
|
421
|
+
label: str
|
|
422
|
+
#: Appended to this plugin's own url_prefix. A plugin can only ever link
|
|
423
|
+
#: into its own namespace, which is what stops a nav entry becoming a way
|
|
424
|
+
#: to point a core menu at an arbitrary URL.
|
|
425
|
+
path: str = ""
|
|
426
|
+
#: Sort key within the menu. Ties break on label, so the order is stable
|
|
427
|
+
#: whatever sequence plugins were discovered in.
|
|
428
|
+
order: int = 0
|
|
429
|
+
|
|
430
|
+
def __post_init__(self):
|
|
431
|
+
if self.menu not in NAV_MENUS:
|
|
432
|
+
raise ValueError(
|
|
433
|
+
f"unknown nav menu {self.menu!r}: expected any of {list(NAV_MENUS)}"
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
@dataclass(frozen=True)
|
|
438
|
+
class Plugin:
|
|
439
|
+
"""A plugin's self-description."""
|
|
440
|
+
|
|
441
|
+
name: str
|
|
442
|
+
label: str
|
|
443
|
+
version: str = "0"
|
|
444
|
+
|
|
445
|
+
#: Zero-argument callable returning this plugin's Flask Blueprint, mounted
|
|
446
|
+
#: under /plugins/<name>/ so a plugin can never shadow a core route or
|
|
447
|
+
#: another plugin's, whatever it names its endpoints.
|
|
448
|
+
#:
|
|
449
|
+
#: A factory rather than the Blueprint itself so that importing the
|
|
450
|
+
#: descriptor stays cheap. The descriptor module is what discovery reads;
|
|
451
|
+
#: if it had to build a Blueprint, importing it would drag in the plugin's
|
|
452
|
+
#: whole dependency tree, and a core-only build would pay for addons it
|
|
453
|
+
#: never installs.
|
|
454
|
+
blueprint_factory: Any = None
|
|
455
|
+
|
|
456
|
+
#: DOM slot id -> template path, rendered into the page for this tool.
|
|
457
|
+
panels: Mapping[str, str] = field(default_factory=dict)
|
|
458
|
+
|
|
459
|
+
#: Client assets, as filenames within the plugin's own static/ directory.
|
|
460
|
+
#: Core turns them into full URLs, so a plugin never writes a path that
|
|
461
|
+
#: assumes where the app is mounted. Cache-busted with `version` rather
|
|
462
|
+
#: than a hand-typed string kept in sync in two places, which is how the
|
|
463
|
+
#: two copies previously drifted apart.
|
|
464
|
+
scripts: tuple[str, ...] = ()
|
|
465
|
+
styles: tuple[str, ...] = ()
|
|
466
|
+
|
|
467
|
+
requires: Requires = field(default_factory=Requires)
|
|
468
|
+
|
|
469
|
+
#: One sentence shown in the requirements modal when nothing is blocking:
|
|
470
|
+
#: what these inputs would buy the user, in the plugin's own words.
|
|
471
|
+
#:
|
|
472
|
+
#: Core owns the wording for the blocking case -- "this tool needs a little
|
|
473
|
+
#: more about this project" is true of every plugin. It is the non-blocking
|
|
474
|
+
#: case that cannot be written generically: a form made entirely of optional
|
|
475
|
+
#: fields has to say why anyone would fill it in, and only the plugin knows.
|
|
476
|
+
#: Carried on the descriptor rather than looked up by name, so core still
|
|
477
|
+
#: renders a form without knowing which plugin asked.
|
|
478
|
+
intro: str = ""
|
|
479
|
+
|
|
480
|
+
#: Entries this plugin adds to core menus. See NavItem: a plugin whose home
|
|
481
|
+
#: is a page rather than a tool panel has no other way in.
|
|
482
|
+
nav_items: tuple[NavItem, ...] = ()
|
|
483
|
+
|
|
484
|
+
#: Whether this plugin colours cells in the viewer. At most one plugin may
|
|
485
|
+
#: do so at a time -- the shader holds a single range table -- so the
|
|
486
|
+
#: client treats this as a claim, not a guarantee.
|
|
487
|
+
owns_cell_layer: bool = False
|
|
488
|
+
|
|
489
|
+
def __post_init__(self):
|
|
490
|
+
if not _SAFE_NAME.match(self.name or ""):
|
|
491
|
+
raise ValueError(
|
|
492
|
+
f"invalid plugin name {self.name!r}: expected lowercase letters, "
|
|
493
|
+
"digits and underscores, starting with a letter"
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
@property
|
|
497
|
+
def url_prefix(self) -> str:
|
|
498
|
+
return f"/plugins/{self.name}"
|
|
499
|
+
|
|
500
|
+
def load_blueprint(self):
|
|
501
|
+
"""Build the Blueprint. Called once, at install time, only for plugins
|
|
502
|
+
this process actually activates."""
|
|
503
|
+
return self.blueprint_factory() if self.blueprint_factory is not None else None
|
|
504
|
+
|
|
505
|
+
def asset_urls(self, kind: str, base_url: str = "") -> list[str]:
|
|
506
|
+
"""Cache-busted, base-URL-safe URLs for this plugin's assets.
|
|
507
|
+
|
|
508
|
+
`base_url` is prepended so plugin assets resolve under a mounted
|
|
509
|
+
deployment (Jupyter's proxy sets PLEXORA_BASE_URL). Core's own template
|
|
510
|
+
tags are built the same way, for the same reason: they used to be
|
|
511
|
+
written relative to the page (`../client/...`), which resolves against
|
|
512
|
+
whatever URL the page was served at and so broke on any page that was
|
|
513
|
+
not exactly one segment deep -- see tests/test_page_assets.py.
|
|
514
|
+
"""
|
|
515
|
+
assets = self.scripts if kind == "scripts" else self.styles
|
|
516
|
+
return [f"{base_url}{self.url_prefix}/static/{name}?v={self.version}" for name in assets]
|
|
517
|
+
|
|
518
|
+
def describe(self) -> dict:
|
|
519
|
+
"""The shape core hands the client for the Tools menu."""
|
|
520
|
+
return {"name": self.name, "label": self.label}
|
|
521
|
+
|
|
522
|
+
def describe_nav(self, base_url: str = "") -> list[dict]:
|
|
523
|
+
"""This plugin's menu entries, with hrefs already resolved.
|
|
524
|
+
|
|
525
|
+
`id` is stamped from the plugin name and the path so the element can be
|
|
526
|
+
found by a test or a stylesheet without core having to invent a naming
|
|
527
|
+
scheme per plugin. Built here, next to `asset_urls`, for the same
|
|
528
|
+
reason: a URL a plugin writes itself is a URL that assumes where the app
|
|
529
|
+
is mounted.
|
|
530
|
+
"""
|
|
531
|
+
return [
|
|
532
|
+
{
|
|
533
|
+
"menu": item.menu,
|
|
534
|
+
"label": item.label,
|
|
535
|
+
"href": f"{base_url}{self.url_prefix}{item.path}",
|
|
536
|
+
"id": f"nav_{self.name}{item.path.replace('/', '_').rstrip('_')}",
|
|
537
|
+
"order": item.order,
|
|
538
|
+
"plugin": self.name,
|
|
539
|
+
}
|
|
540
|
+
for item in self.nav_items
|
|
541
|
+
]
|