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.
Files changed (248) hide show
  1. plexora/__init__.py +129 -0
  2. plexora/__main__.py +15 -0
  3. plexora/_url.py +105 -0
  4. plexora/api/__init__.py +55 -0
  5. plexora/api/dataset.py +398 -0
  6. plexora/api/http.py +19 -0
  7. plexora/api/plugin.py +541 -0
  8. plexora/api/store.py +221 -0
  9. plexora/cli.py +777 -0
  10. plexora/client/dist/354_bundle.js +2 -0
  11. plexora/client/dist/354_bundle.js.LICENSE.txt +20 -0
  12. plexora/client/dist/418_bundle.js +2 -0
  13. plexora/client/dist/418_bundle.js.LICENSE.txt +1 -0
  14. plexora/client/dist/693b935cb1f907814be9c6199a68c217.svg +19 -0
  15. plexora/client/dist/713c134ff47cd328a5b711526f151082.svg +18 -0
  16. plexora/client/dist/770_bundle.js +1 -0
  17. plexora/client/dist/ff743f408972e0e96ca9153ed5ae4c83.svg +23 -0
  18. plexora/client/dist/vendor_bundle.js +2 -0
  19. plexora/client/dist/vendor_bundle.js.LICENSE.txt +308 -0
  20. plexora/client/external/openseadragon-bin-2.4.0/LICENSE.txt +28 -0
  21. plexora/client/external/openseadragon-bin-2.4.0/canvas-overlay-hd.js +140 -0
  22. plexora/client/external/openseadragon-bin-2.4.0/changelog.txt +501 -0
  23. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/.gitattributes +17 -0
  24. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/LICENSE.txt +116 -0
  25. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/Toolbar.png +0 -0
  26. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/fullpage.psd +0 -0
  27. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/home.psd +0 -0
  28. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/next.psd +0 -0
  29. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/previous.psd +0 -0
  30. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/rotateleft.psd +0 -0
  31. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/rotateright.psd +0 -0
  32. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/zoom.psd +0 -0
  33. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/README.md +9 -0
  34. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_grouphover.png +0 -0
  35. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_hover.png +0 -0
  36. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_pressed.png +0 -0
  37. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_rest.png +0 -0
  38. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_grouphover.png +0 -0
  39. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_hover.png +0 -0
  40. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_pressed.png +0 -0
  41. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_rest.png +0 -0
  42. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_grouphover.png +0 -0
  43. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_hover.png +0 -0
  44. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_pressed.png +0 -0
  45. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_rest.png +0 -0
  46. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_grouphover.png +0 -0
  47. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_hover.png +0 -0
  48. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_pressed.png +0 -0
  49. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_rest.png +0 -0
  50. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_grouphover.png +0 -0
  51. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_hover.png +0 -0
  52. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_pressed.png +0 -0
  53. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_rest.png +0 -0
  54. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_grouphover.png +0 -0
  55. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_hover.png +0 -0
  56. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_pressed.png +0 -0
  57. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_rest.png +0 -0
  58. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_grouphover.png +0 -0
  59. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_hover.png +0 -0
  60. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_pressed.png +0 -0
  61. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_rest.png +0 -0
  62. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_grouphover.png +0 -0
  63. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_hover.png +0 -0
  64. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_pressed.png +0 -0
  65. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_rest.png +0 -0
  66. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-scalebar.js +562 -0
  67. plexora/client/src/css/import.css +474 -0
  68. plexora/client/src/css/main.css +903 -0
  69. plexora/client/src/css/openProject.css +363 -0
  70. plexora/client/src/css/quickView.css +166 -0
  71. plexora/client/src/css/tokens.css +51 -0
  72. plexora/client/src/css/viewer.css +1655 -0
  73. plexora/client/src/img/apple-touch-icon.png +0 -0
  74. plexora/client/src/img/favicon.ico +0 -0
  75. plexora/client/src/img/logo.ai +8892 -33
  76. plexora/client/src/img/logo.svg +1 -0
  77. plexora/client/src/img/logo_with_text.ai +9011 -34
  78. plexora/client/src/img/logo_with_text.svg +1 -0
  79. plexora/client/src/js/main.js +704 -0
  80. plexora/client/src/js/pluginRegistry.js +108 -0
  81. plexora/client/src/js/services/appStatus.js +286 -0
  82. plexora/client/src/js/services/browsePicker.js +73 -0
  83. plexora/client/src/js/services/dataLayer.js +400 -0
  84. plexora/client/src/js/services/datasetContext.js +143 -0
  85. plexora/client/src/js/services/glRenderer.js +143 -0
  86. plexora/client/src/js/services/importFormValidation.js +257 -0
  87. plexora/client/src/js/services/numericData.js +100 -0
  88. plexora/client/src/js/services/passVariablesToFrontend.js +20 -0
  89. plexora/client/src/js/services/simpleEventHandler.js +25 -0
  90. plexora/client/src/js/vendor.js +29 -0
  91. plexora/client/src/js/views/channelList.js +663 -0
  92. plexora/client/src/js/views/colorSwatchPicker.js +202 -0
  93. plexora/client/src/js/views/columnClassifier.js +146 -0
  94. plexora/client/src/js/views/coordinateField.js +133 -0
  95. plexora/client/src/js/views/dataSourceField.js +276 -0
  96. plexora/client/src/js/views/imageViewer.js +3306 -0
  97. plexora/client/src/js/views/miniMap.js +787 -0
  98. plexora/client/src/js/views/navbarControls.js +178 -0
  99. plexora/client/src/js/views/openProjectPage.js +239 -0
  100. plexora/client/src/js/views/projectEdit.js +335 -0
  101. plexora/client/src/js/views/quickViewLanding.js +136 -0
  102. plexora/client/src/js/views/rainbow.js +326 -0
  103. plexora/client/src/js/views/requirementsModal.js +572 -0
  104. plexora/client/src/js/views/rgbImageViewer.js +115 -0
  105. plexora/client/src/js/views/searchableSelect.js +411 -0
  106. plexora/client/src/js/views/segmentationProgress.js +122 -0
  107. plexora/client/src/js/views/toolLoader.js +747 -0
  108. plexora/client/src/js/views/viewerControls.js +689 -0
  109. plexora/client/src/js/views/viewerManager.js +434 -0
  110. plexora/client/src/js/views/viewerSidebar.js +1117 -0
  111. plexora/client/src/js/workers/tileDecoder.js +130 -0
  112. plexora/client/src/shaders/frag.glsl +528 -0
  113. plexora/client/src/shaders/vert.glsl +12 -0
  114. plexora/client/templates/base.html +212 -0
  115. plexora/client/templates/index.html +238 -0
  116. plexora/client/templates/open_project.html +96 -0
  117. plexora/client/templates/project_columns.html +86 -0
  118. plexora/client/templates/project_edit.html +196 -0
  119. plexora/client/templates/upload.html +118 -0
  120. plexora/connect.py +528 -0
  121. plexora/datasource.py +782 -0
  122. plexora/jupyter.py +406 -0
  123. plexora/notebook_env.py +232 -0
  124. plexora/paths.py +486 -0
  125. plexora/plugins/__init__.py +12 -0
  126. plexora/plugins/cell_explorer/__init__.py +100 -0
  127. plexora/plugins/cell_explorer/server/__init__.py +11 -0
  128. plexora/plugins/cell_explorer/server/routes.py +167 -0
  129. plexora/plugins/cell_explorer/server/state.py +281 -0
  130. plexora/plugins/cell_explorer/server/values.py +145 -0
  131. plexora/plugins/cell_explorer/server/variables.py +367 -0
  132. plexora/plugins/cell_explorer/static/cellExplorerApi.js +158 -0
  133. plexora/plugins/cell_explorer/static/cellExplorerColors.js +316 -0
  134. plexora/plugins/cell_explorer/static/cellExplorerContinuous.js +403 -0
  135. plexora/plugins/cell_explorer/static/cellExplorerFigureBridge.js +133 -0
  136. plexora/plugins/cell_explorer/static/cellExplorerLegend.js +188 -0
  137. plexora/plugins/cell_explorer/static/cellExplorerRoiBridge.js +822 -0
  138. plexora/plugins/cell_explorer/static/cellExplorerSidebarController.js +826 -0
  139. plexora/plugins/cell_explorer/static/cellExplorerState.js +372 -0
  140. plexora/plugins/cell_explorer/static/cell_explorer.css +752 -0
  141. plexora/plugins/cell_explorer/templates/cell_explorer/panel.html +137 -0
  142. plexora/plugins/figure_builder/__init__.py +125 -0
  143. plexora/plugins/figure_builder/server/__init__.py +7 -0
  144. plexora/plugins/figure_builder/server/compose.py +365 -0
  145. plexora/plugins/figure_builder/server/export.py +784 -0
  146. plexora/plugins/figure_builder/server/export_jobs.py +148 -0
  147. plexora/plugins/figure_builder/server/operations.py +556 -0
  148. plexora/plugins/figure_builder/server/pixels.py +142 -0
  149. plexora/plugins/figure_builder/server/provenance.py +174 -0
  150. plexora/plugins/figure_builder/server/render.py +278 -0
  151. plexora/plugins/figure_builder/server/repository.py +701 -0
  152. plexora/plugins/figure_builder/server/routes.py +594 -0
  153. plexora/plugins/figure_builder/server/schema.py +1023 -0
  154. plexora/plugins/figure_builder/server/sources.py +120 -0
  155. plexora/plugins/figure_builder/server/textmetrics.py +155 -0
  156. plexora/plugins/figure_builder/static/figureActions.js +363 -0
  157. plexora/plugins/figure_builder/static/figureBuilderApi.js +255 -0
  158. plexora/plugins/figure_builder/static/figureCanvas.js +2362 -0
  159. plexora/plugins/figure_builder/static/figureCaptureBoxes.js +369 -0
  160. plexora/plugins/figure_builder/static/figureCaptureDock.js +466 -0
  161. plexora/plugins/figure_builder/static/figureCaptureTool.js +1034 -0
  162. plexora/plugins/figure_builder/static/figureConfirm.js +136 -0
  163. plexora/plugins/figure_builder/static/figureContextBar.js +1193 -0
  164. plexora/plugins/figure_builder/static/figureContextMenu.js +253 -0
  165. plexora/plugins/figure_builder/static/figureDocumentState.js +275 -0
  166. plexora/plugins/figure_builder/static/figureExportUi.js +174 -0
  167. plexora/plugins/figure_builder/static/figureLibrary.js +247 -0
  168. plexora/plugins/figure_builder/static/figureQuickEdit.js +628 -0
  169. plexora/plugins/figure_builder/static/figureRichText.js +634 -0
  170. plexora/plugins/figure_builder/static/figureSceneSnapshot.js +361 -0
  171. plexora/plugins/figure_builder/static/figureSchema.js +218 -0
  172. plexora/plugins/figure_builder/static/figureSidebarController.js +1159 -0
  173. plexora/plugins/figure_builder/static/figureTextEditor.js +503 -0
  174. plexora/plugins/figure_builder/static/figureTextPanel.js +491 -0
  175. plexora/plugins/figure_builder/static/figureViewOptions.js +273 -0
  176. plexora/plugins/figure_builder/static/figureWorkspace.js +2030 -0
  177. plexora/plugins/figure_builder/static/figure_builder.css +3292 -0
  178. plexora/plugins/figure_builder/templates/figure_builder/library.html +74 -0
  179. plexora/plugins/figure_builder/templates/figure_builder/workspace.html +27 -0
  180. plexora/plugins/figure_builder/templates/figure_builder/workspace_body.html +458 -0
  181. plexora/plugins/gating/__init__.py +76 -0
  182. plexora/plugins/gating/server/__init__.py +0 -0
  183. plexora/plugins/gating/server/anndata_gates.py +367 -0
  184. plexora/plugins/gating/server/database.py +16 -0
  185. plexora/plugins/gating/server/model.py +320 -0
  186. plexora/plugins/gating/server/routes.py +235 -0
  187. plexora/plugins/gating/static/csvGatingList.js +947 -0
  188. plexora/plugins/gating/static/gating.css +289 -0
  189. plexora/plugins/gating/static/gatingApi.js +221 -0
  190. plexora/plugins/gating/static/gatingSidebarController.js +513 -0
  191. plexora/plugins/gating/templates/gating/legacy.html +26 -0
  192. plexora/plugins/gating/templates/gating/panel.html +69 -0
  193. plexora/plugins/roi/__init__.py +95 -0
  194. plexora/plugins/roi/server/__init__.py +8 -0
  195. plexora/plugins/roi/server/adapters.py +655 -0
  196. plexora/plugins/roi/server/geojson.py +267 -0
  197. plexora/plugins/roi/server/geometry.py +181 -0
  198. plexora/plugins/roi/server/mapping.py +169 -0
  199. plexora/plugins/roi/server/operations.py +293 -0
  200. plexora/plugins/roi/server/repository.py +215 -0
  201. plexora/plugins/roi/server/routes.py +409 -0
  202. plexora/plugins/roi/server/schema.py +323 -0
  203. plexora/plugins/roi/static/roi.css +583 -0
  204. plexora/plugins/roi/static/roiApi.js +177 -0
  205. plexora/plugins/roi/static/roiFigureBridge.js +107 -0
  206. plexora/plugins/roi/static/roiGeometry.js +350 -0
  207. plexora/plugins/roi/static/roiRenderer.js +278 -0
  208. plexora/plugins/roi/static/roiSidebarController.js +1038 -0
  209. plexora/plugins/roi/static/roiState.js +518 -0
  210. plexora/plugins/roi/static/roiTools.js +993 -0
  211. plexora/plugins/roi/templates/roi/panel.html +240 -0
  212. plexora/proxy.py +57 -0
  213. plexora/server/models/adapters/__init__.py +94 -0
  214. plexora/server/models/adapters/anndata_adapter.py +388 -0
  215. plexora/server/models/adapters/base.py +93 -0
  216. plexora/server/models/adapters/classify.py +192 -0
  217. plexora/server/models/adapters/csv_adapter.py +105 -0
  218. plexora/server/models/adapters/inspection.py +265 -0
  219. plexora/server/models/adapters/spatialdata_adapter.py +248 -0
  220. plexora/server/models/centroid_tiles.py +305 -0
  221. plexora/server/models/data_model.py +1659 -0
  222. plexora/server/models/database_model.py +169 -0
  223. plexora/server/models/project.py +1270 -0
  224. plexora/server/plugins.py +199 -0
  225. plexora/server/routes/browse_routes.py +41 -0
  226. plexora/server/routes/data_routes.py +294 -0
  227. plexora/server/routes/import_routes.py +603 -0
  228. plexora/server/routes/page_routes.py +139 -0
  229. plexora/server/routes/project_routes.py +438 -0
  230. plexora/server/routes/quick_view_routes.py +62 -0
  231. plexora/server/routes/system_routes.py +39 -0
  232. plexora/server/routes/tool_routes.py +418 -0
  233. plexora/server/utils/addHEColumns.py +11 -0
  234. plexora/server/utils/fast_png.py +97 -0
  235. plexora/server/utils/fullConversion.py +50 -0
  236. plexora/server/utils/native_dialog.py +168 -0
  237. plexora/server/utils/pre_normalization.py +47 -0
  238. plexora/server/utils/segmentation_pyramid.py +598 -0
  239. plexora/server/utils/smallestenclosingcircle.py +126 -0
  240. plexora/server/utils/tiffsurgeon.py +372 -0
  241. plexora/server_cli.py +48 -0
  242. plexora/test.py +0 -0
  243. plexora-0.0.1.dist-info/METADATA +341 -0
  244. plexora-0.0.1.dist-info/RECORD +248 -0
  245. plexora-0.0.1.dist-info/WHEEL +5 -0
  246. plexora-0.0.1.dist-info/entry_points.txt +12 -0
  247. plexora-0.0.1.dist-info/licenses/LICENSE +207 -0
  248. 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
+ ]