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
@@ -0,0 +1,367 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import json
5
+ from pathlib import Path
6
+
7
+ import h5py
8
+ import numpy as np
9
+ import pandas as pd
10
+ import polars as pl
11
+
12
+ from plexora import api
13
+
14
+ try:
15
+ from anndata.io import read_elem, write_elem # anndata >= 0.10, public API
16
+ except ImportError: # pragma: no cover - older anndata fallback
17
+ from anndata._io.specs import read_elem, write_elem
18
+
19
+ # Column name used for the var_names index when the gates table is
20
+ # represented in-memory as a polars DataFrame (AnnData's own on-disk
21
+ # dataframe encoding stores the index separately, under this same name, so
22
+ # a real ad.read_h5ad() sees adata.uns[table_name].index.name == this value).
23
+ INDEX_COLUMN = "var_names"
24
+
25
+
26
+ def _pandas_to_polars(df: pd.DataFrame, index_name: str) -> pl.DataFrame:
27
+ """Manual conversion (no pl.from_pandas()/pyarrow -- not an installed
28
+ dependency here, see anndata_gates design notes in the project plan)."""
29
+ data = {index_name: df.index.astype(str).tolist()}
30
+ for col in df.columns:
31
+ data[str(col)] = df[col].to_numpy(dtype="float64")
32
+ return pl.DataFrame(data)
33
+
34
+
35
+ def _polars_to_pandas(df: pl.DataFrame, index_name: str) -> pd.DataFrame:
36
+ """Manual conversion (no .to_pandas()/pyarrow) -- the write_elem/read_elem
37
+ calls into AnnData's own on-disk dataframe codec are the only reason
38
+ pandas appears in this module at all."""
39
+ image_columns = [c for c in df.columns if c != index_name]
40
+ return pd.DataFrame(
41
+ df.select(image_columns).to_numpy(),
42
+ index=pd.Index(df[index_name].to_list(), name=index_name),
43
+ columns=image_columns,
44
+ )
45
+
46
+
47
+ def _resolve_path(source) -> str:
48
+ """Location of the AnnData *group* holding this datasource's data: the
49
+ .h5ad file itself, or the selected table inside a SpatialData .zarr
50
+ store. Everything below reads and writes through this one location, so
51
+ gates land in the table the user actually imported -- never anywhere
52
+ else in the store.
53
+
54
+ `source` is the api.TableSource core hands the plugin. It is deliberately
55
+ not the raw config entry: where a table lives on disk is core's business,
56
+ and this plugin only needs to be told, not to know the file format.
57
+ """
58
+ if source is None or not source.path:
59
+ raise ValueError("No AnnData file path configured for this datasource")
60
+ if source.kind == 'spatialdata':
61
+ from plexora.server.models.adapters.spatialdata_adapter import table_path
62
+
63
+ return str(table_path(source.path, source.table))
64
+ return source.path
65
+
66
+
67
+ def _consolidated_format(path: Path) -> int | None:
68
+ """Which zarr format's consolidated index this group carries, if any.
69
+
70
+ A consolidated index is a cached copy of every child's metadata, kept
71
+ beside the group: `.zmetadata` in zarr v2, a `consolidated_metadata` key
72
+ inside `zarr.json` in v3. Readers that find one trust it completely and
73
+ never list the directory -- which is what makes it both the reason a
74
+ write is refused and the reason a write must be followed by a refresh.
75
+
76
+ Read off disk rather than from an opened group so the answer is known
77
+ before choosing how to open it.
78
+ """
79
+ if (path / '.zmetadata').is_file():
80
+ return 2
81
+ metadata = path / 'zarr.json'
82
+ if metadata.is_file():
83
+ try:
84
+ document = json.loads(metadata.read_text(encoding='utf-8'))
85
+ except (OSError, ValueError): # pragma: no cover - unreadable metadata
86
+ return None
87
+ if document.get('consolidated_metadata') is not None:
88
+ return 3
89
+ return None
90
+
91
+
92
+ @contextlib.contextmanager
93
+ def _open_group(path: str, writable: bool = False):
94
+ """Yield the root group of an on-disk AnnData, for either backend.
95
+
96
+ anndata's element codec (read_elem/write_elem) is storage-agnostic, so
97
+ every caller below works unchanged against an h5py group from an .h5ad
98
+ or a zarr group from a SpatialData table. A .zarr store is a directory;
99
+ that's what distinguishes the two here.
100
+
101
+ Writing to a *consolidated* zarr group needs two extra steps, both
102
+ required and neither optional:
103
+
104
+ 1. Open with `use_consolidated=False`. anndata refuses to write to a
105
+ group whose metadata is consolidated (`is_group_consolidated()` in
106
+ anndata/_io/specs/registry.py) and raises "Cannot overwrite/edit a
107
+ store with consolidated metadata" -- a real store written by
108
+ spatialdata hits this, because it consolidates each table. Opening
109
+ without the index sidesteps a guard that exists to stop exactly the
110
+ staleness step 2 repairs.
111
+ 2. Rebuild the index afterwards. Skipping it loses the write silently:
112
+ the new group is on disk, but every reader consults the stale index
113
+ instead of listing the directory, so `anndata.read_zarr` and
114
+ `spatialdata.read_zarr` both report no gates and the save appears to
115
+ have done nothing. Verified against a copy of a real store.
116
+
117
+ The refresh is confined to this group -- the table, never the store
118
+ root. That is not just conservatism: re-consolidating a SpatialData
119
+ root, which is zarr v3, silently DROPS every v2 table from the index
120
+ (real stores mix the two), leaving a store whose tables have vanished.
121
+ Nothing needs it anyway; a root index enumerates which elements exist,
122
+ and adding a key inside a table does not change that.
123
+ """
124
+ if Path(path).is_dir():
125
+ import zarr
126
+
127
+ # Path (not str) deliberately: zarr v3 parses a string store as a
128
+ # URL, mangling table names containing characters like '#'.
129
+ location = Path(path)
130
+ if not writable:
131
+ yield zarr.open_group(location, mode='r')
132
+ return
133
+
134
+ consolidated = _consolidated_format(location)
135
+ yield zarr.open_group(location, mode='a', use_consolidated=False)
136
+ # Only on success, and only if there was an index to begin with:
137
+ # a store that never had one needs no refresh, and writing one
138
+ # would change how every other tool reads it.
139
+ if consolidated is not None:
140
+ zarr.consolidate_metadata(
141
+ zarr.storage.LocalStore(location), zarr_format=consolidated
142
+ )
143
+ return
144
+ with h5py.File(path, 'r+' if writable else 'r') as handle:
145
+ yield handle
146
+
147
+
148
+ def _read_frame(group, key) -> pd.DataFrame:
149
+ """Read just obs or var off an already-open group. This is the same
150
+ codec anndata uses internally, so it costs one dataframe read and never
151
+ touches X -- the property the old backed-mode .h5ad read had, now true
152
+ for the zarr backend too (zarr-backed AnnData has no backed mode)."""
153
+ return read_elem(group[key])
154
+
155
+
156
+ def _read_obs_column(path: str, subset: dict, column: str) -> pl.Series | None:
157
+ """Lightweight read of a single obs column, optionally filtered by the
158
+ same subset.column/subset.value rule AnnDataAdapter.load_table()
159
+ applies. Returns None if the column doesn't exist. Never loads X --
160
+ safe/cheap to call repeatedly."""
161
+ with _open_group(path) as group:
162
+ obs = _read_frame(group, 'obs')
163
+ if column not in obs.columns:
164
+ return None
165
+ subset_column = (subset or {}).get('column')
166
+ if subset_column:
167
+ if subset_column not in obs.columns:
168
+ raise ValueError(f"Subset column {subset_column!r} not found in adata.obs")
169
+ mask = obs[subset_column].astype(str).to_numpy() == str(subset.get('value'))
170
+ obs = obs.loc[mask]
171
+ if obs.empty:
172
+ raise ValueError("Subset filter matched zero observations")
173
+ return pl.Series(column, obs[column].astype(str).to_numpy())
174
+
175
+
176
+ def resolve_current_image_id(
177
+ path: str, source, datasource_name: str, imageid_column: str
178
+ ) -> str:
179
+ """Which column of the gates table this Plexora datasource's gates
180
+ should be written to. Re-applies this datasource's own registration
181
+ subset (if any) and requires the configured imageid column to resolve
182
+ to exactly one value within it; falls back to the datasource's own
183
+ registered name if the column doesn't exist at all."""
184
+ subset = dict(getattr(source, 'subset', None) or {})
185
+ values = _read_obs_column(path, subset, imageid_column)
186
+ if values is None:
187
+ return datasource_name
188
+
189
+ unique_values = values.unique().to_list()
190
+ if len(unique_values) != 1:
191
+ raise ValueError(
192
+ f"Column {imageid_column!r} does not resolve to a single image "
193
+ f"for datasource {datasource_name!r} (found {len(unique_values)} "
194
+ "distinct values within this datasource's own data) -- refusing "
195
+ "to guess which gates-table column to update."
196
+ )
197
+ return unique_values[0]
198
+
199
+
200
+ def all_image_ids(path: str, imageid_column: str) -> list[str] | None:
201
+ """Every distinct value of imageid_column across the *whole* source
202
+ file (no subset applied) -- used to eagerly create one gates-table
203
+ column per known image. None if the column doesn't exist anywhere."""
204
+ values = _read_obs_column(path, {}, imageid_column)
205
+ if values is None:
206
+ return None
207
+ return sorted(values.unique().to_list())
208
+
209
+
210
+ def _read_var_names(path: str) -> list[str]:
211
+ with _open_group(path) as group:
212
+ return [str(v) for v in _read_frame(group, 'var').index]
213
+
214
+
215
+ def save_gates_to_anndata(
216
+ source,
217
+ datasource_name: str,
218
+ active_gates: dict,
219
+ table_name: str = "gates",
220
+ imageid_column: str = "imageid",
221
+ ) -> dict:
222
+ """Writes only adata.uns[table_name] (rows=var_names, one column per
223
+ known image) via direct h5py + anndata's element codec -- never a full
224
+ ad.read_h5ad()/write_h5ad() round trip. Overwrites only the column for
225
+ the image this datasource currently represents; every other image's
226
+ column (freshly added blank, or previously saved) is left untouched.
227
+
228
+ active_gates: {channel_name: lower_bound}, already filtered to
229
+ currently-active gates by the caller.
230
+ """
231
+ path = _resolve_path(source)
232
+ var_names = _read_var_names(path)
233
+ current_image_id = resolve_current_image_id(
234
+ path, source, datasource_name, imageid_column
235
+ )
236
+ known_image_ids = all_image_ids(path, imageid_column) or [current_image_id]
237
+ if current_image_id not in known_image_ids:
238
+ known_image_ids = sorted(set(known_image_ids) | {current_image_id})
239
+
240
+ with _open_group(path, writable=True) as f:
241
+ uns = f.require_group('uns')
242
+
243
+ if table_name in uns:
244
+ existing = read_elem(uns[table_name])
245
+ if not isinstance(existing, pd.DataFrame):
246
+ raise ValueError(
247
+ f"adata.uns[{table_name!r}] already exists and is not a table"
248
+ )
249
+ existing_table = _pandas_to_polars(existing, INDEX_COLUMN)
250
+ existing_var_names = existing_table[INDEX_COLUMN].to_list()
251
+ if existing_var_names == var_names:
252
+ # Fast, exact path -- the overwhelmingly common case (panel
253
+ # hasn't changed between saves). No realignment needed.
254
+ table = existing_table
255
+ else:
256
+ # Realign rows to the current var_names -- stale vars are
257
+ # dropped, new vars appear as all-NaN rows. Expected if the
258
+ # source panel changed since the last save, not a bug.
259
+ # Done via an explicit first-occurrence-name lookup rather
260
+ # than a polars join: real panels can have duplicate
261
+ # var_names (e.g. a marker re-stained across cycles -- see
262
+ # the var_index note below), and joining on a non-unique key
263
+ # produces a row explosion (each duplicate on one side
264
+ # matches every duplicate on the other) instead of a clean
265
+ # 1:1 realignment.
266
+ old_index = {}
267
+ for i, name in enumerate(existing_var_names):
268
+ if name not in old_index:
269
+ old_index[name] = i
270
+ table = pl.DataFrame({INDEX_COLUMN: var_names})
271
+ for col in existing_table.columns:
272
+ if col == INDEX_COLUMN:
273
+ continue
274
+ old_values = existing_table[col].to_list()
275
+ new_values = [
276
+ old_values[old_index[name]] if name in old_index else None
277
+ for name in var_names
278
+ ]
279
+ table = table.with_columns(pl.Series(col, new_values, dtype=pl.Float64))
280
+ else:
281
+ table = pl.DataFrame({INDEX_COLUMN: var_names})
282
+
283
+ for image_id in known_image_ids:
284
+ if image_id not in table.columns:
285
+ table = table.with_columns(
286
+ pl.lit(None, dtype=pl.Float64).alias(image_id)
287
+ )
288
+
289
+ values = np.full(len(var_names), np.nan, dtype="float64")
290
+ # active_gates is keyed by the *gating channel* name, which for a
291
+ # duplicate var_name is the deduplicated display name the frontend
292
+ # actually shows/sends (AnnDataAdapter.api.deduplicate_names(): first
293
+ # occurrence keeps the plain name, e.g. "PTPRC"; the next becomes
294
+ # "PTPRC_1", etc.) -- not the raw, still-duplicated var_names list.
295
+ # Matching against raw var_names here used to silently drop every
296
+ # gate on a "_N"-suffixed channel (no raw var_name is ever literally
297
+ # "PTPRC_1"), undercounting how many gated markers actually got
298
+ # written with zero error -- confirmed against real exemplar data
299
+ # (orion.h5ad has a duplicated "PTPRC").
300
+ var_index = {name: i for i, name in enumerate(api.deduplicate_names(var_names))}
301
+ n_written = 0
302
+ for channel, lower_bound in active_gates.items():
303
+ idx = var_index.get(channel)
304
+ if idx is None or lower_bound is None:
305
+ continue
306
+ values[idx] = float(lower_bound)
307
+ n_written += 1
308
+
309
+ table = table.with_columns(pl.Series(current_image_id, values, dtype=pl.Float64))
310
+
311
+ if table_name in uns:
312
+ del uns[table_name]
313
+ write_elem(uns, table_name, _polars_to_pandas(table, INDEX_COLUMN))
314
+
315
+ return {
316
+ "table_name": table_name,
317
+ "imageid_column": imageid_column,
318
+ "image_id": current_image_id,
319
+ "path": path,
320
+ "n_active_gates": n_written,
321
+ "n_image_columns": len(table.columns) - 1,
322
+ }
323
+
324
+
325
+ def load_gates_from_anndata(
326
+ source,
327
+ datasource_name: str,
328
+ table_name: str = "gates",
329
+ imageid_column: str = "imageid",
330
+ ) -> dict:
331
+ """Reverse of save_gates_to_anndata: reads adata.uns[table_name]'s column
332
+ for this datasource's current image back out as {channel: lower_bound},
333
+ keyed by the same deduplicated display names save_gates_to_anndata
334
+ writes against -- so it plugs directly into gatingList.gating_channels
335
+ client-side without a name-mapping step. Read-only, no h5py write mode.
336
+ """
337
+ path = _resolve_path(source)
338
+ current_image_id = resolve_current_image_id(
339
+ path, source, datasource_name, imageid_column
340
+ )
341
+
342
+ with _open_group(path) as f:
343
+ uns = f.get('uns')
344
+ if uns is None or table_name not in uns:
345
+ return {"image_id": current_image_id, "gates": {}}
346
+ existing = read_elem(uns[table_name])
347
+ if not isinstance(existing, pd.DataFrame) or current_image_id not in existing.columns:
348
+ return {"image_id": current_image_id, "gates": {}}
349
+ column = existing[current_image_id]
350
+
351
+ # Table rows are ordered like var_names at the time of the last save
352
+ # (see save_gates_to_anndata's realignment note) -- matched back to
353
+ # today's deduplicated display names by position, the same "common,
354
+ # overwhelmingly likely" fast-path assumption that function documents.
355
+ var_names = _read_var_names(path)
356
+ display_names = api.deduplicate_names(var_names)
357
+
358
+ gates = {}
359
+ for position, name in enumerate(display_names):
360
+ if position >= len(column):
361
+ break
362
+ value = column.iloc[position]
363
+ if value is None or (isinstance(value, float) and np.isnan(value)):
364
+ continue
365
+ gates[name] = float(value)
366
+
367
+ return {"image_id": current_image_id, "gates": gates}
@@ -0,0 +1,16 @@
1
+ """Where gating's saved gates used to live.
2
+
3
+ Persistence goes through `plexora.api.store`, which namespaces every table as
4
+ `plugin_gating_<name>`. This name is all that remains of the older scheme, in
5
+ which gating wrote a bare `gatinglist` table straight into the shared
6
+ per-datasource sqlite file.
7
+
8
+ It is declared so `PluginStore` can adopt gates saved by those builds: the row
9
+ is copied into the namespaced table on first read and this table is then
10
+ dropped. Once every project a user opens has been read once, nothing refers to
11
+ it and the constant can go.
12
+ """
13
+
14
+ #: Pre-namespacing table name, retained only so existing projects can be
15
+ #: converted. Nothing writes here; the store deletes it after adopting it.
16
+ LEGACY_STATE_TABLE = "gatinglist"