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/paths.py ADDED
@@ -0,0 +1,486 @@
1
+ """Where Plexora's data lives, and who decides.
2
+
3
+ Every path decision in the app comes from here. Before this module the answer
4
+ was computed once, at import time, in `plexora/__init__.py`, and its last
5
+ fallback was `Path("plexora/data").resolve()` -- relative to the *current
6
+ working directory*. That is right only when the process was launched from the
7
+ repository root, which is what `python run.py` does and what the README's
8
+ editable install assumed. For a real `pip install plexora` it meant that
9
+ importing the package from `~/analysis` silently created and used
10
+ `~/analysis/plexora/data`, so running a notebook from a different folder the
11
+ next day made every project look as though it had been deleted.
12
+
13
+ Two properties matter more than the specific locations:
14
+
15
+ **Resolved on demand, never snapshotted.** Everything here is a function.
16
+ Modules that did `from plexora import data_path` captured the value at import,
17
+ which meant any decision made after the first `import plexora` was silently
18
+ ignored -- that is why the Jupyter sidecar has to set real OS environment
19
+ variables before spawning a child rather than just passing `--data-dir`. A
20
+ function can also grow a parameter later; a module constant cannot, and the
21
+ per-user and shared-root work depends on exactly that.
22
+
23
+ **The write root and the read root are different questions.** A project the
24
+ user imported lives in their own root and is theirs to change. A project on a
25
+ site-managed shared root is readable by everyone and writable by nobody, yet a
26
+ user exploring it still needs somewhere to put their gates, ROIs and figures.
27
+ So reads resolve across `roots()` while writes always land in `data_root()`.
28
+ When a project's home root *is* the user root -- the entire single-user case --
29
+ the two collapse to one directory and nothing is different from before.
30
+
31
+ Deliberately a leaf module: it imports nothing from `plexora`, so it cannot
32
+ participate in the import cycle that `plexora/__init__.py` sits at the centre
33
+ of, and it is safe to call from anywhere including the CLI.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import json
39
+ import os
40
+ import sys
41
+ import threading
42
+ from pathlib import Path
43
+ from typing import NamedTuple
44
+
45
+ from platformdirs import user_config_dir, user_data_dir
46
+
47
+ #: Passed to platformdirs as both the app name and (via appauthor=False) the
48
+ #: whole of the path tail. Without appauthor=False, Windows gets
49
+ #: `AppData\Local\plexora\plexora` -- the vendor directory doubled up, which is
50
+ #: what the old appdirs call produced.
51
+ APP_NAME = "plexora"
52
+
53
+ ENV_DATA_PATH = "PLEXORA_DATA_PATH"
54
+ ENV_SHARED_PATH = "PLEXORA_SHARED_PATH"
55
+
56
+ CONFIG_FILENAME = "config.json"
57
+ SETTINGS_FILENAME = "settings.json"
58
+
59
+ #: Directory under a root holding every figure. Dot-prefixed so a project
60
+ #: literally named "figures" cannot collide with it -- projects are directories
61
+ #: under the same root. See figure_builder's repository module.
62
+ FIGURES_DIRNAME = ".figures"
63
+
64
+ #: Written and removed to prove a root is actually writable. A probe beats
65
+ #: `os.access`, which on Windows reports the DACL rather than the effective
66
+ #: permission and cheerfully says yes for a directory that then refuses the
67
+ #: write.
68
+ _PROBE_PREFIX = ".plexora-write-probe"
69
+
70
+
71
+ def _probe_name() -> str:
72
+ """A probe filename no other prober can be holding.
73
+
74
+ Per process AND per thread, because both really do coincide: Waitress runs
75
+ eight threads and the segmentation job adds more, and a cached miss lets
76
+ two of them probe one root at the same moment. With a shared name, one
77
+ thread's unlink lands between the other's write and its own unlink -- and
78
+ on Windows that surfaces as a PermissionError, which this function would
79
+ have read as "not writable". A root wrongly judged read-only silently
80
+ stops recording config changes, which is a far worse failure than the
81
+ write it was trying to avoid.
82
+ """
83
+ return f"{_PROBE_PREFIX}.{os.getpid()}.{threading.get_ident()}"
84
+
85
+
86
+ class DataRootError(RuntimeError):
87
+ """The resolved data root cannot be used.
88
+
89
+ Raised at resolution rather than at first write, so the message names the
90
+ path and the flag that changes it instead of surfacing as an OSError three
91
+ frames down inside a request.
92
+ """
93
+
94
+
95
+ class Resolution(NamedTuple):
96
+ """A resolved root and the rule that chose it, so `plexora where` can
97
+ explain itself. Users who cannot find their projects need to know *why*
98
+ Plexora picked a directory, not just which one."""
99
+
100
+ path: Path
101
+ rule: str
102
+
103
+
104
+ #: Resolution is pure with respect to the environment but does real filesystem
105
+ #: work (mkdir, the write probe), so it is done once per process. `reset()`
106
+ #: clears it; the test suite calls that after repointing the environment.
107
+ _cache: dict[str, object] = {}
108
+ _cache_lock = threading.RLock()
109
+
110
+
111
+ def reset() -> None:
112
+ """Forget the resolved roots.
113
+
114
+ For tests, which repoint `PLEXORA_DATA_PATH` at a tmp_path per test, and
115
+ for `plexora config set`, which changes the answer underneath a live
116
+ process.
117
+ """
118
+ with _cache_lock:
119
+ _cache.clear()
120
+
121
+
122
+ # -- the settings file ---------------------------------------------------
123
+
124
+
125
+ def settings_path() -> Path:
126
+ """Where the persistent choice of data directory is recorded.
127
+
128
+ In the config directory, which is emphatically *not* derived from
129
+ `data_root()` -- the file's whole job is to say where the data root is, so
130
+ resolving it through one would be circular.
131
+
132
+ That said, it does not always land somewhere else: Windows and macOS use a
133
+ single per-app directory for both, so by default this file sits inside the
134
+ default data root even though it is not reached through it. The case that
135
+ matters is a user who moves their data elsewhere and later deletes the old
136
+ default directory. They lose the pointer and Plexora returns to the
137
+ default -- but it says so, because an absent config.json is exactly what
138
+ `first_run_notice` reports on.
139
+ """
140
+ return Path(user_config_dir(APP_NAME, appauthor=False, roaming=False)) / SETTINGS_FILENAME
141
+
142
+
143
+ def read_settings() -> dict:
144
+ """The settings file, or {} when there is not one yet.
145
+
146
+ A damaged file reads as {} rather than raising: the fallbacks below are all
147
+ still available, and refusing to start because a preferences file is
148
+ corrupt would be a worse failure than quietly using the default.
149
+ """
150
+ path = settings_path()
151
+ try:
152
+ text = path.read_text(encoding="utf-8")
153
+ except (OSError, ValueError):
154
+ return {}
155
+ if not text.strip():
156
+ return {}
157
+ try:
158
+ data = json.loads(text)
159
+ except ValueError:
160
+ return {}
161
+ return data if isinstance(data, dict) else {}
162
+
163
+
164
+ def write_settings(data: dict) -> None:
165
+ """Replace the settings file in one step.
166
+
167
+ Same temp-file-and-rename as the project config, and for the same reason:
168
+ a reader in another process sees the whole previous file or the whole new
169
+ one, never the empty window that `open(path, "w")` leaves open.
170
+ """
171
+ path = settings_path()
172
+ path.parent.mkdir(parents=True, exist_ok=True)
173
+ tmp = path.with_name(f"{path.name}.{os.getpid()}.tmp")
174
+ try:
175
+ with tmp.open("w", encoding="utf-8") as handle:
176
+ json.dump(data, handle, indent=4)
177
+ handle.flush()
178
+ os.fsync(handle.fileno())
179
+ os.replace(tmp, path)
180
+ finally:
181
+ tmp.unlink(missing_ok=True)
182
+
183
+
184
+ # -- the user's writable root --------------------------------------------
185
+
186
+
187
+ def _candidate_data_root() -> Resolution:
188
+ """Pick the data root, without touching the filesystem.
189
+
190
+ First hit wins, and the order is deliberate: an explicit instruction for
191
+ this process beats a stored preference, which beats what the build shape
192
+ implies, which beats the platform default.
193
+ """
194
+ from_env = os.environ.get(ENV_DATA_PATH)
195
+ if from_env and from_env.strip():
196
+ return Resolution(Path(from_env).expanduser().resolve(),
197
+ f"{ENV_DATA_PATH} environment variable")
198
+
199
+ stored = read_settings().get("data_dir")
200
+ if isinstance(stored, str) and stored.strip():
201
+ return Resolution(Path(stored).expanduser().resolve(),
202
+ f"data_dir in {settings_path()}")
203
+
204
+ if getattr(sys, "frozen", False):
205
+ # A portable build keeps its data beside the executable so the whole
206
+ # thing can be moved or handed over on a stick as one unit.
207
+ return Resolution(Path(sys.executable).parent.resolve() / "data",
208
+ "frozen build, beside the executable")
209
+
210
+ return Resolution(Path(user_data_dir(APP_NAME, appauthor=False)).resolve(),
211
+ "platform default")
212
+
213
+
214
+ def _prepare_data_root(resolution: Resolution) -> Resolution:
215
+ """Create the root and prove it is writable.
216
+
217
+ The probe runs once per process, on the resolution that gets cached. It is
218
+ worth the two syscalls: a read-only or quota-exhausted root otherwise
219
+ presents as a stack trace from whichever request happened to write first,
220
+ which tells the user nothing about what to do.
221
+ """
222
+ path = resolution.path
223
+ existed = path.exists()
224
+ try:
225
+ path.mkdir(parents=True, exist_ok=True)
226
+ except OSError as exc:
227
+ raise DataRootError(
228
+ f"Plexora's data directory cannot be created: {path}\n"
229
+ f"Chosen by: {resolution.rule}\n"
230
+ f"Point it somewhere writable with 'plexora --data-dir <path>' or "
231
+ f"'plexora config set data-dir <path>'."
232
+ ) from exc
233
+
234
+ probe = path / _probe_name()
235
+ try:
236
+ probe.write_text("", encoding="utf-8")
237
+ probe.unlink()
238
+ except OSError as exc:
239
+ raise DataRootError(
240
+ f"Plexora's data directory is not writable: {path}\n"
241
+ f"Chosen by: {resolution.rule}\n"
242
+ f"Point it somewhere writable with 'plexora --data-dir <path>' or "
243
+ f"'plexora config set data-dir <path>'."
244
+ ) from exc
245
+
246
+ # "First run" is judged by the absence of a project registry rather than by
247
+ # whether we just created the directory: on a shared machine an admin often
248
+ # makes the directory ahead of time, and the notice is still worth printing
249
+ # for the user who has never seen it.
250
+ with _cache_lock:
251
+ _cache["first_run"] = not (path / CONFIG_FILENAME).exists()
252
+ _cache["created"] = not existed
253
+ return resolution
254
+
255
+
256
+ def data_root_resolution() -> Resolution:
257
+ """The user's writable root, with the rule that chose it."""
258
+ with _cache_lock:
259
+ cached = _cache.get("data_root")
260
+ if cached is not None:
261
+ return cached # type: ignore[return-value]
262
+ resolved = _prepare_data_root(_candidate_data_root())
263
+ with _cache_lock:
264
+ _cache["data_root"] = resolved
265
+ return resolved
266
+
267
+
268
+ def data_root() -> Path:
269
+ """Where this user's projects, figures and per-project state are written."""
270
+ return data_root_resolution().path
271
+
272
+
273
+ def first_run_notice() -> str | None:
274
+ """A one-off line naming the data directory, or None if it is not new.
275
+
276
+ Printed by the CLI. The location is a platform convention directory, which
277
+ is the right default and also the one a user is least likely to guess, so
278
+ saying it once beats making them run `plexora where` to find out where
279
+ their work went.
280
+ """
281
+ resolution = data_root_resolution()
282
+ with _cache_lock:
283
+ if not _cache.get("first_run"):
284
+ return None
285
+ return (
286
+ f"Plexora will keep your projects in:\n"
287
+ f" {resolution.path}\n"
288
+ f"Move it any time with 'plexora config set data-dir <path>'."
289
+ )
290
+
291
+
292
+ # -- shared, read-mostly roots -------------------------------------------
293
+
294
+
295
+ def shared_root_resolutions() -> list[Resolution]:
296
+ """Site-managed roots holding projects several users can open.
297
+
298
+ From `PLEXORA_SHARED_PATH` (os.pathsep-separated, like PATH) and then
299
+ `shared_dirs` in the settings file. Neither is created if it is missing:
300
+ a shared root is somebody else's to provision, and silently making an empty
301
+ one would turn a typo into a root that exists and holds nothing.
302
+
303
+ Cached like `data_root`, and for a sharper reason: `shared_roots()` being
304
+ empty is what lets the tile path skip resolving which root a project came
305
+ from, so this is consulted often enough that a settings-file read per call
306
+ would show up.
307
+ """
308
+ with _cache_lock:
309
+ cached = _cache.get("shared_roots")
310
+ if cached is not None:
311
+ return list(cached) # type: ignore[arg-type]
312
+
313
+ seen: set[Path] = set()
314
+ out: list[Resolution] = []
315
+
316
+ def _add(raw, rule):
317
+ if not isinstance(raw, str) or not raw.strip():
318
+ return
319
+ path = Path(raw).expanduser().resolve()
320
+ if path in seen:
321
+ return
322
+ seen.add(path)
323
+ out.append(Resolution(path, rule))
324
+
325
+ from_env = os.environ.get(ENV_SHARED_PATH) or ""
326
+ for entry in from_env.split(os.pathsep):
327
+ _add(entry, f"{ENV_SHARED_PATH} environment variable")
328
+
329
+ stored = read_settings().get("shared_dirs")
330
+ if isinstance(stored, (list, tuple)):
331
+ for entry in stored:
332
+ _add(entry, f"shared_dirs in {settings_path()}")
333
+
334
+ # The user's own root is never also a shared root, whatever the
335
+ # configuration says: it is already first in roots(), and letting it appear
336
+ # twice would make a project look shared to its own owner.
337
+ own = data_root()
338
+ resolved = [r for r in out if r.path != own]
339
+ with _cache_lock:
340
+ _cache["shared_roots"] = resolved
341
+ return list(resolved)
342
+
343
+
344
+ def shared_roots() -> list[Path]:
345
+ return [resolution.path for resolution in shared_root_resolutions()]
346
+
347
+
348
+ def roots() -> list[Path]:
349
+ """Every root a project may be found in, the user's own first.
350
+
351
+ Order is the precedence rule: a name present in more than one root resolves
352
+ to the user's copy. Somebody who has made their own version of a shared
353
+ project means to open theirs.
354
+ """
355
+ return [data_root(), *shared_roots()]
356
+
357
+
358
+ # -- paths within a root -------------------------------------------------
359
+
360
+
361
+ def config_path(root=None) -> Path:
362
+ """The project registry for one root. Defaults to the user's own."""
363
+ return Path(root) / CONFIG_FILENAME if root is not None else data_root() / CONFIG_FILENAME
364
+
365
+
366
+ def project_dir(name, root=None) -> Path:
367
+ """A project's directory in `root`, or in the user's own root.
368
+
369
+ A pure join -- it does not search and does not create. Callers that need to
370
+ know which root actually owns a project ask the project registry, which is
371
+ the thing that reads config.json.
372
+ """
373
+ base = Path(root) if root is not None else data_root()
374
+ return base / name
375
+
376
+
377
+ def project_state_dir(name) -> Path:
378
+ """Where this user's own state for `name` is written, whoever owns it.
379
+
380
+ Always under the user's root, including for a project whose home is a
381
+ shared root. That is what makes a shared project explorable rather than
382
+ merely visible: the gates, ROIs and plugin tables a user produces while
383
+ looking at somebody else's data are theirs, and they have to land
384
+ somewhere writable.
385
+ """
386
+ return data_root() / name
387
+
388
+
389
+ def project_roots(name) -> list[Path]:
390
+ """Every root with a directory for `name`, the user's own first.
391
+
392
+ Directory existence only -- ownership is a question for the registry. Used
393
+ to find derived artifacts (tile pyramids, centroid tiles) that may have
394
+ been built into either the home root or the user's own.
395
+ """
396
+ return [root for root in roots() if (root / name).is_dir()]
397
+
398
+
399
+ def is_writable(root) -> bool:
400
+ """Whether a root accepts writes, probed once per process per root.
401
+
402
+ Shared roots are usually read-only to the people opening them, and that is
403
+ the whole question for derived artifacts: a tile pyramid that already
404
+ exists beside a shared image should be read where it is, but one that has
405
+ to be built has to go somewhere this user can actually write.
406
+ """
407
+ path = Path(root)
408
+ key = f"writable:{path}"
409
+ with _cache_lock:
410
+ cached = _cache.get(key)
411
+ if cached is not None:
412
+ return bool(cached)
413
+ probe = path / _probe_name()
414
+ try:
415
+ path.mkdir(parents=True, exist_ok=True)
416
+ probe.write_text("", encoding="utf-8")
417
+ probe.unlink()
418
+ writable = True
419
+ except OSError:
420
+ writable = False
421
+ with _cache_lock:
422
+ _cache[key] = writable
423
+ return writable
424
+
425
+
426
+ def derived_root(name, home_root=None) -> Path:
427
+ """Where derived artifacts for `name` are BUILT -- pyramids, tile caches.
428
+
429
+ The project's own root when that can be written to, so two users opening
430
+ the same shared image share one pyramid rather than each spending minutes
431
+ building their own. Otherwise the user's own root, because a derived
432
+ artifact that cannot be written is a feature that does not work.
433
+
434
+ Reading is the other half and is not symmetric -- use `find_derived`, which
435
+ also looks where a previous build may have put something.
436
+ """
437
+ if home_root is not None and is_writable(home_root):
438
+ return Path(home_root) / name
439
+ for root in project_roots(name):
440
+ if is_writable(root):
441
+ return root / name
442
+ return project_state_dir(name)
443
+
444
+
445
+ def find_derived(name, *parts, home_root=None) -> Path | None:
446
+ """An existing derived artifact, or None.
447
+
448
+ Looks in the project's own root before the user's own, so a pyramid the
449
+ site built beside a shared image wins over a stale private copy. Returns
450
+ None rather than a non-existent path: callers use that to decide whether
451
+ to build, and a path that merely might exist cannot answer that.
452
+ """
453
+ seen: list[Path] = []
454
+ if home_root is not None:
455
+ seen.append(Path(home_root) / name)
456
+ seen.extend(root / name for root in project_roots(name))
457
+ seen.append(project_state_dir(name))
458
+ for base in seen:
459
+ candidate = base.joinpath(*parts) if parts else base
460
+ if candidate.exists():
461
+ return candidate
462
+ return None
463
+
464
+
465
+ def figures_root() -> Path:
466
+ """Where this user's figures live.
467
+
468
+ Never a shared root. A figure can span several datasources or none, so no
469
+ project owns one and there is nothing for a site-managed root to hold.
470
+ """
471
+ return data_root() / FIGURES_DIRNAME
472
+
473
+
474
+ def describe() -> list[str]:
475
+ """Human-readable lines for `plexora where`."""
476
+ resolution = data_root_resolution()
477
+ lines = [f"data root: {resolution.path}", f" chosen by: {resolution.rule}"]
478
+ shared = shared_root_resolutions()
479
+ if not shared:
480
+ lines.append("shared roots: (none)")
481
+ return lines
482
+ for entry in shared:
483
+ state = "" if entry.path.is_dir() else " [missing]"
484
+ lines.append(f"shared root: {entry.path}{state}")
485
+ lines.append(f" chosen by: {entry.rule}")
486
+ return lines
@@ -0,0 +1,12 @@
1
+ """Plugins bundled with Plexora.
2
+
3
+ Each subpackage here is a plugin: a directory holding its own server code,
4
+ client assets, templates and tests, exposing a module-level `PLUGIN` descriptor
5
+ (see plexora.api.plugin.Plugin).
6
+
7
+ They are discovered by scanning this package -- see plexora.server.plugins --
8
+ and are otherwise ordinary plugins. They get no privileges a pip-installed
9
+ third-party plugin lacks, and they consume only the public `plexora.api`
10
+ surface. That is deliberate: a gap in the API becomes a gap in the shipped
11
+ product rather than something only outside authors run into.
12
+ """
@@ -0,0 +1,100 @@
1
+ """Cell Explorer: colour every cell on the image by one metadata column.
2
+
3
+ The whole plugin follows from one boundary: **it owns the mapping from metadata
4
+ to colour, and core owns the geometry**. Cell Explorer never learns how a
5
+ segmentation tile is drawn, never renders a centroid, and never decides whether
6
+ cells appear as points, outlines or filled shapes -- it hands core a colour per
7
+ cell id and core draws it whichever way the Cells control is set to. That is
8
+ what keeps this plugin small, and what means the next plugin that wants to
9
+ colour cells gets all three representations for free.
10
+
11
+ Deliberately NOT here: marker expression. `.X` and its layers are what
12
+ Thresholding reads, and they are a different question with a different scale
13
+ problem (see TableHandle.log_transformed). This reads annotations -- phenotype,
14
+ cluster, neighbourhood, confidence, area -- and only through the format-agnostic
15
+ `table.metadata_values`, so a CSV column and an AnnData `.obs` column are the
16
+ same thing here.
17
+
18
+ It also never writes. Colours, hidden categories and ranges are display
19
+ preferences and live in the plugin store; the table is the source of truth and
20
+ is opened read-only.
21
+
22
+ Kept import-light, like every descriptor module: this is imported whenever the
23
+ plugin is activated, and building the Blueprint (which pulls in numpy-heavy
24
+ encoding and the repository) is left to the factory.
25
+ """
26
+
27
+ from plexora.api.plugin import Plugin, Requires
28
+
29
+ VERSION = "20260822_figure_bridge"
30
+
31
+
32
+ def _blueprint():
33
+ from plexora.plugins.cell_explorer.server.routes import cell_explorer_bp
34
+
35
+ return cell_explorer_bp
36
+
37
+
38
+ PLUGIN = Plugin(
39
+ name="cell_explorer",
40
+ label="Cell Explorer",
41
+ version=VERSION,
42
+ blueprint_factory=_blueprint,
43
+ # Templates are namespaced by plugin name, so two plugins can both ship a
44
+ # "panel.html" without colliding in Flask's shared template lookup.
45
+ panels={"tool_panel_slot": "cell_explorer/panel.html"},
46
+ # Listed in dependency order for reading, not because the browser needs it:
47
+ # every cross-file reference is inside a method or a constructor, and
48
+ # toolLoader awaits all seven before anything is activated, so the bindings
49
+ # resolve whatever sequence they arrive in. What DOES matter is that all
50
+ # seven are here -- one omitted is a plugin that loads and does nothing,
51
+ # which is what tests/test_cell_explorer_boot.py exists to catch.
52
+ scripts=(
53
+ "cellExplorerColors.js",
54
+ "cellExplorerApi.js",
55
+ "cellExplorerState.js",
56
+ "cellExplorerLegend.js",
57
+ "cellExplorerContinuous.js",
58
+ "cellExplorerRoiBridge.js",
59
+ # Answers Figure Builder's two capture/restore events, the same way
60
+ # the ROI bridge above answers ROI's -- events both ways, no import
61
+ # either way.
62
+ "cellExplorerFigureBridge.js",
63
+ "cellExplorerSidebarController.js",
64
+ ),
65
+ styles=("cell_explorer.css",),
66
+ # A table and a cell id are the floor: without them there is nothing to
67
+ # colour and no way to say which cell a value belongs to.
68
+ #
69
+ # Segmentation and the coordinates are OPTIONAL, and that is the important
70
+ # part. Either one is enough to draw cells -- a mask gives outlines and
71
+ # filled shapes, x/y gives centroids -- so requiring the mask would rule out
72
+ # every project that has coordinates and no segmentation, which is a large
73
+ # share of them. `Requires` has no way to express "segmentation OR (x AND
74
+ # y)", so all three are offered and the panel checks for itself that at
75
+ # least one arrived (see `intro`, and the empty state in panel.html).
76
+ #
77
+ # `role:x`/`role:y` rather than `coordinates`: the coordinate question is a
78
+ # CSV one. On AnnData and SpatialData x/y are answered at import, when the
79
+ # adapter is told where to read them from, and asking again would put an
80
+ # obsm picker in front of a user whose coordinates are already resolved.
81
+ # Core translates between the two forms per format -- see plugin.py's
82
+ # _coordinate_keys.
83
+ requires=Requires(
84
+ table=True,
85
+ roles=("cell_id",),
86
+ optional=("segmentation", "role:x", "role:y"),
87
+ ),
88
+ # Shown instead of core's generic subtitle, which says Plexora filled these
89
+ # in from the data -- true of a form full of guesses to confirm, and wrong
90
+ # for one that is asking for the thing without which this tool has nothing
91
+ # to draw on.
92
+ intro=("Colouring cells needs a way to draw them: a segmentation mask, or "
93
+ "X/Y coordinates for centroids. Either one is enough."),
94
+ # This is the plugin's whole purpose. It gets a LAYER of its own -- see
95
+ # ImageViewer.registerCellLayer -- with its own colours, mode and opacity,
96
+ # which survive another tool being opened over it. Opening Thresholding
97
+ # switches this layer off; its card's eye turns it back on, and the two then
98
+ # stack: gated cells over the phenotype map.
99
+ owns_cell_layer=True,
100
+ )
@@ -0,0 +1,11 @@
1
+ """Cell Explorer's server half.
2
+
3
+ Four modules, one job each: `variables` decides what can be coloured by and how,
4
+ `values` moves one column's values to the browser, `state` remembers display
5
+ preferences, and `routes` is the thin HTTP surface over the three.
6
+
7
+ Everything reads through `plexora.api` and nothing else. That is not a style
8
+ rule -- `data_model` keeps the loaded table in module globals under a load lock
9
+ with two adjacent loaders whose names differ by one underscore, and the api
10
+ handles call the right one. See plexora/api/__init__.py.
11
+ """