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,105 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import polars as pl
6
+
7
+ from .base import NormalizedDatasource
8
+
9
+
10
+ class CsvAdapter:
11
+ """Adapter for the flat-CSV feature-table workflow.
12
+
13
+ Takes the project's DataSpec (server/models/project.py): `src` says where
14
+ the file is, `roles` say what its columns mean. A role the project never
15
+ recorded is None here, which is why the coordinate columns are optional --
16
+ a CSV imported but not yet fully described still loads, it just has no
17
+ usable coordinates until something asks the user for them.
18
+ """
19
+
20
+ def __init__(self, spec):
21
+ self.csv_path = Path(spec.src)
22
+ self.x_column = spec.roles.x
23
+ self.y_column = spec.roles.y
24
+ self.id_field = spec.roles.cell_id
25
+ self.celltype_column = spec.roles.celltype
26
+ # The marker/metadata split the user confirmed at import. A CSV header
27
+ # does not draw that line itself, which is the whole reason the
28
+ # classification screen exists -- so this is the only thing that knows
29
+ # Area and Eccentricity are measurements rather than stains.
30
+ self.marker_columns = list(spec.columns.markers)
31
+ # Explicit opt-in only -- no heuristic guessing at whether the values
32
+ # "look" already transformed. Same contract as AnnDataAdapter, which is
33
+ # where this used to be honoured and only there: a CSV kept the flag
34
+ # and read straight past it, so a user who asked for the transform got
35
+ # a project that said it was transformed and was not.
36
+ self.apply_log_transform = bool(spec.is_transformed)
37
+
38
+ def read_obs_column(self, name: str):
39
+ """Always None: a CSV's table IS the file.
40
+
41
+ There is no second place to look -- `load_table()` reads every column,
42
+ so anything a caller could ask for is already in `frame()`. Returning
43
+ None says "nothing to add here", which is what lets the caller treat a
44
+ column that is missing from both as genuinely unknown rather than as a
45
+ format it forgot to handle.
46
+ """
47
+ return None
48
+
49
+ def load_table(self) -> NormalizedDatasource:
50
+ df = pl.read_csv(self.csv_path)
51
+ # Manufacture a stable positional 'id' column, mirroring pandas'
52
+ # implicit RangeIndex usage in the code this replaced -- must happen
53
+ # immediately after read_csv, before any other transform, since
54
+ # downstream code treats 'id' as a stable per-row identity.
55
+ df = df.with_row_index("id").with_columns(pl.col("id").cast(pl.Int64))
56
+ numeric_cols = [c for c, dt in df.schema.items() if dt in (pl.Float32, pl.Float64)]
57
+ df = df.with_columns([
58
+ pl.when(pl.col(c) == float("-inf")).then(0).otherwise(pl.col(c)).alias(c)
59
+ for c in numeric_cols
60
+ ])
61
+
62
+ if self.id_field and self.id_field in df.columns:
63
+ source_obs_ids = df[self.id_field].cast(pl.Utf8).to_list()
64
+ else:
65
+ source_obs_ids = df["id"].cast(pl.Utf8).to_list()
66
+
67
+ feature_columns = self._feature_columns(df)
68
+ if self.apply_log_transform:
69
+ # After the -inf guard above and over the markers only, matching
70
+ # AnnDataAdapter: transforming a coordinate or a cell id would move
71
+ # every cell on the image. Numeric ones at that -- the split is the
72
+ # user's to correct, so a text column can end up in the marker box,
73
+ # and log1p on it is an error rather than a bad number.
74
+ transform = [c for c in feature_columns if df.schema[c].is_numeric()]
75
+ df = df.with_columns([pl.col(c).log1p().alias(c) for c in transform])
76
+
77
+ return NormalizedDatasource(
78
+ table=df,
79
+ id_column="id",
80
+ source_obs_ids=source_obs_ids,
81
+ x_column=self.x_column,
82
+ y_column=self.y_column,
83
+ feature_columns=feature_columns,
84
+ celltype_column=self.celltype_column,
85
+ )
86
+
87
+ def _feature_columns(self, df) -> list[str]:
88
+ """Which columns hold marker intensities.
89
+
90
+ The recorded split when there is one, narrowed to columns this file
91
+ actually has: the answer can outlive the file it was given for, and a
92
+ marker naming a column that is gone would be log-transformed into a
93
+ polars error and offered as a gate with no data behind it.
94
+
95
+ Everything-but-the-roles is the fallback, for a project registered
96
+ before the classification screen ran. It cannot tell a stain from a
97
+ measurement -- that is what the screen is for -- so it is a last resort
98
+ and not the answer.
99
+ """
100
+ excluded = {"id", self.x_column, self.y_column, self.id_field, self.celltype_column}
101
+ recorded = [c for c in self.marker_columns
102
+ if c in df.columns and c not in excluded]
103
+ if recorded:
104
+ return recorded
105
+ return [c for c in df.columns if c not in excluded]
@@ -0,0 +1,265 @@
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+
5
+ from . import classify
6
+ from .anndata_adapter import describe_obsm, is_likely_image_identifier_name
7
+
8
+ # Read-only structural inspection of not-yet-registered source files -- used
9
+ # by the standalone import wizard (a future Stage 3 endpoint) to populate a
10
+ # progressive-disclosure config UI, and reusable directly from Jupyter/tests.
11
+ # Deliberately separate from data_model.py's module-global-based caching:
12
+ # these operate on files that aren't loaded (or registered) yet.
13
+
14
+ _MAX_CANDIDATE_VALUES = 500
15
+
16
+
17
+ def _is_subset_candidate(series) -> bool:
18
+ """A column is worth offering as an image/sample subset choice if it's
19
+ non-numeric (categorical/string-like) with more than one, but not an
20
+ unreasonably large number of, distinct values."""
21
+ try:
22
+ if np.issubdtype(series.dtype, np.number):
23
+ return False
24
+ except TypeError:
25
+ pass
26
+ n_unique = series.nunique(dropna=True)
27
+ return 1 < n_unique <= _MAX_CANDIDATE_VALUES
28
+
29
+
30
+ def _inspect_adata(adata) -> dict:
31
+ """The format-independent half of inspection: everything the config UI
32
+ needs about an already-opened AnnData. Shared by inspect_anndata() and
33
+ inspect_spatialdata_table(), since a SpatialData table *is* an AnnData
34
+ and its config page offers exactly the same choices."""
35
+ obs_columns = []
36
+ for column in adata.obs.columns:
37
+ series = adata.obs[column]
38
+ is_candidate = _is_subset_candidate(series)
39
+ n_unique = series.nunique(dropna=True)
40
+ # Same name heuristic the adapter's own ambiguity guard enforces
41
+ # (adapters/anndata_adapter.py) -- lets the UI reserve its
42
+ # "this file may span multiple images" warning for genuine
43
+ # identifier-shaped columns instead of every categorical column
44
+ # (cell_type/cluster/condition columns are common and would
45
+ # otherwise trigger a misleading warning on ordinary single-image data).
46
+ likely_identifier = is_candidate and is_likely_image_identifier_name(column) and n_unique > 1
47
+ # Every column gets its actual values so the subset picker can
48
+ # always offer a dropdown of real categories instead of asking the
49
+ # user to type a value freehand -- is_subset_candidate above only
50
+ # gates the ambiguity-warning heuristic, not whether a column's
51
+ # values are shown at all. But a column with more distinct values
52
+ # than _MAX_CANDIDATE_VALUES (e.g. a per-cell numeric ID or
53
+ # centroid coordinate -- effectively unique per row) is never a
54
+ # usable subset choice, and for a real hundreds-of-thousands-of-rows
55
+ # table, listing it anyway inflates this inspection payload to tens
56
+ # of MB. A config page embeds that payload as a literal
57
+ # JavaScript object ({{data|tojson}}, not JSON.parse'd), so a
58
+ # bloated payload freezes the importing browser tab outright --
59
+ # capping here is what keeps that page loadable for large
60
+ # single-cell tables (confirmed against a real 686k-cell exemplar,
61
+ # whose id/centroid columns alone produced a 30MB payload).
62
+ values = (
63
+ sorted(str(v) for v in series.dropna().unique().tolist())
64
+ if n_unique <= _MAX_CANDIDATE_VALUES
65
+ else []
66
+ )
67
+ entry = {
68
+ "name": column,
69
+ "dtype": str(series.dtype),
70
+ "is_subset_candidate": is_candidate,
71
+ "likely_multi_image_identifier": likely_identifier,
72
+ "values": values,
73
+ }
74
+ obs_columns.append(entry)
75
+
76
+ return {
77
+ "obs_count": int(adata.n_obs),
78
+ # anndata's Layers/AxisArrays mappings can report a spurious
79
+ # `None` key (observed with anndata 0.13.2) even when no real
80
+ # layer/obsm entry of that name exists -- filter it out.
81
+ "obsm_keys": [k for k in adata.obsm.keys() if k],
82
+ # The same arrays with their shapes, which is what a user picking
83
+ # between them actually needs: `spatial` and `X_umap` are routinely both
84
+ # (n, 2) float32, so the name is the only thing separating a cell's
85
+ # position from its embedding -- and the name is exactly what cannot be
86
+ # trusted. Kept beside `obsm_keys` rather than replacing it so existing
87
+ # callers of that key are untouched.
88
+ "obsm": describe_obsm(adata),
89
+ "layers": [k for k in adata.layers.keys() if k],
90
+ "obs_columns": obs_columns,
91
+ "var_names": [str(v) for v in adata.var_names],
92
+ "n_var": int(adata.n_vars),
93
+ }
94
+
95
+
96
+ def inspect_csv(path) -> dict:
97
+ """Structural inspection of a flat CSV: its columns and their dtypes.
98
+
99
+ Reads a small number of rows rather than the file: a real quantification
100
+ table runs to millions of rows, and nothing here needs values -- only
101
+ names, and enough of each column to tell a number from a label. Polars
102
+ infers from the rows it is given, so a column that is empty at the top of
103
+ the file can be misread; that is acceptable because the user confirms the
104
+ split on the classification screen, which is the whole reason it exists.
105
+ """
106
+ import polars as pl
107
+
108
+ frame = pl.read_csv(path, n_rows=_DTYPE_SAMPLE_ROWS)
109
+ columns = [{"name": name, "dtype": str(dtype)} for name, dtype in frame.schema.items()]
110
+ return {
111
+ "data_type": "csv",
112
+ "columns": columns,
113
+ **classify.classify_columns(columns),
114
+ }
115
+
116
+
117
+ #: Rows read to infer CSV dtypes. Enough to get past a sparse first row,
118
+ #: cheap enough to run on every keystroke-triggered inspection.
119
+ _DTYPE_SAMPLE_ROWS = 200
120
+
121
+ #: obsm keys that conventionally hold cell centroids, in preference order.
122
+ _SPATIAL_OBSM_KEYS = ("spatial", "X_spatial", "spatial_centroid")
123
+
124
+
125
+ def propose_read_spec(inspection: dict) -> dict:
126
+ """How to read this file, worked out from its own structure.
127
+
128
+ The import page asks for a path and nothing else, so everything the old
129
+ two-step config form made the user choose has to be answered from the file
130
+ itself. Almost all of it can be: `obsm['spatial']` is near-universal for
131
+ centroids, `X` is the feature matrix by definition, and a positional cell
132
+ id is the right default regardless (see the uint32-packing note in
133
+ datasource.py).
134
+
135
+ Returns the read spec plus `ambiguous`: the columns that make the file span
136
+ several images, which is the one thing that genuinely cannot be guessed --
137
+ picking an image for the user would silently load the wrong cells. The
138
+ caller turns that into a subset choice on the form.
139
+
140
+ An unresolved coordinate source is NOT an error here. It leaves the x/y
141
+ roles unset, and whatever first needs them asks for them.
142
+ """
143
+ obsm_keys = inspection.get("obsm_keys") or []
144
+ obs_columns = inspection.get("obs_columns") or []
145
+ names = [c.get("name") for c in obs_columns if c.get("name")]
146
+
147
+ coordinates = {}
148
+ obsm_key = next((k for k in _SPATIAL_OBSM_KEYS if k in obsm_keys), None)
149
+ if obsm_key:
150
+ coordinates = {"source": "obsm", "obsm_key": obsm_key}
151
+ else:
152
+ guessed = classify.guess_roles(names)
153
+ if guessed.get("x") and guessed.get("y"):
154
+ coordinates = {"source": "obs", "x_column": guessed["x"], "y_column": guessed["y"]}
155
+
156
+ return {
157
+ "coordinates": coordinates,
158
+ "features": {"source": "X"},
159
+ "ambiguous": [
160
+ c["name"] for c in obs_columns if c.get("likely_multi_image_identifier")
161
+ ],
162
+ **classify.classify_from_inspection(inspection),
163
+ }
164
+
165
+
166
+ def inspect_anndata(path) -> dict:
167
+ """Structural inspection of an .h5ad file: obsm keys, layers, obs
168
+ columns (flagging which look like image/sample subset candidates and,
169
+ for those, their available values), var names, and observation count.
170
+ """
171
+ import anndata as ad
172
+
173
+ adata = ad.read_h5ad(path, backed='r')
174
+ try:
175
+ return {"data_type": "anndata", **_inspect_adata(adata)}
176
+ finally:
177
+ if adata.isbacked:
178
+ adata.file.close()
179
+
180
+
181
+ def source_layers(spec) -> list[str]:
182
+ """The extra expression matrices a project's source file carries.
183
+
184
+ Recorded at import (DataSpec.layers), so this is only reached for a project
185
+ imported before that was recorded -- but it has to be reached, because the
186
+ one control that can fix a project reading the wrong matrix would otherwise
187
+ be the one control it never offers.
188
+
189
+ Cheaper than a full inspection on purpose: a zarr group listing, or a backed
190
+ h5ad open, so nothing materializes a second matrix just to learn its name.
191
+ Best-effort -- an unreadable file means no picker, which is what every
192
+ surface here did before there was one.
193
+ """
194
+ if spec is None or spec.type == "csv":
195
+ return []
196
+ if spec.layers:
197
+ return list(spec.layers)
198
+ try:
199
+ if spec.type == "spatialdata":
200
+ from .spatialdata_adapter import list_table_layers
201
+
202
+ return list_table_layers(spec.src, spec.table)
203
+ import anndata as ad
204
+
205
+ adata = ad.read_h5ad(spec.src, backed="r")
206
+ try:
207
+ return [str(name) for name in adata.layers.keys() if name]
208
+ finally:
209
+ if adata.isbacked:
210
+ adata.file.close()
211
+ except Exception:
212
+ return []
213
+
214
+
215
+ def source_obsm(spec) -> list[dict]:
216
+ """The obsm arrays a project's source file carries, with their shapes.
217
+
218
+ The coordinate question's counterpart to `source_layers`, and reached for
219
+ the same reason: a project imported before obsm was recorded would
220
+ otherwise be offered no candidates at all, and that is precisely the
221
+ project whose coordinate source was picked by a name heuristic nobody was
222
+ shown.
223
+
224
+ Best-effort in the same way -- an unreadable file means no obsm choices,
225
+ which is what every surface offered before this existed.
226
+ """
227
+ if spec is None or spec.type == "csv":
228
+ return []
229
+ if spec.obsm:
230
+ return [dict(entry) for entry in spec.obsm]
231
+ try:
232
+ if spec.type == "spatialdata":
233
+ from .spatialdata_adapter import read_spatialdata_table
234
+
235
+ return describe_obsm(read_spatialdata_table(spec.src, spec.table))
236
+ import anndata as ad
237
+
238
+ adata = ad.read_h5ad(spec.src, backed="r")
239
+ try:
240
+ return describe_obsm(adata)
241
+ finally:
242
+ if adata.isbacked:
243
+ adata.file.close()
244
+ except Exception:
245
+ return []
246
+
247
+
248
+ def inspect_spatialdata_table(store, table) -> dict:
249
+ """Same inspection, for one table inside a SpatialData (.zarr) store.
250
+
251
+ Reads only the named table (see spatialdata_adapter.read_spatialdata_table
252
+ for why the whole store is deliberately not opened). There's no backed
253
+ mode for a zarr-backed AnnData, so unlike inspect_anndata this
254
+ materializes the table -- acceptable because the import form has the
255
+ user pick a single table first, and its shape is shown there.
256
+ """
257
+ from .spatialdata_adapter import read_spatialdata_table
258
+
259
+ adata = read_spatialdata_table(store, table)
260
+ return {
261
+ "data_type": "spatialdata",
262
+ "store": str(store),
263
+ "table": str(table),
264
+ **_inspect_adata(adata),
265
+ }
@@ -0,0 +1,248 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from .anndata_adapter import AnnDataAdapter
6
+
7
+ # A SpatialData store keeps every annotation table as a plain AnnData group
8
+ # under <store>.zarr/tables/<name>, so once the user has picked *which* table
9
+ # to use it is an ordinary AnnData and every downstream config field,
10
+ # inspection result and adapter behavior is identical to the .h5ad path.
11
+ # That's why SpatialDataAdapter subclasses AnnDataAdapter and overrides only
12
+ # the read.
13
+ TABLES_GROUP = "tables"
14
+
15
+ # Zarr's own "this directory is a group" markers -- v3 writes zarr.json, v2
16
+ # writes .zgroup. Both appear in a single real store: spatialdata 0.7 wrote
17
+ # the exemplar LSP20209.zarr with a v3 root but v2 table groups, so anything
18
+ # sniffing the layout has to accept either.
19
+ _GROUP_MARKERS = ("zarr.json", ".zgroup")
20
+
21
+
22
+ def table_path(store, table) -> Path:
23
+ """Resolve <store>/tables/<table>, rejecting anything that isn't a plain
24
+ table name. Without this an empty name resolves to the tables/ group
25
+ itself -- which *is* a valid zarr group, so the read gets that far and
26
+ then fails deep inside anndata with a confusing
27
+ "AnnData.__init__() got an unexpected keyword argument '<some table>'".
28
+ Separators are refused for the same reason a table name arriving from a
29
+ form post shouldn't be able to address anything outside tables/.
30
+ """
31
+ name = str(table or '').strip()
32
+ if not name or name in ('.', '..') or '/' in name or '\\' in name:
33
+ raise ValueError(f"Invalid SpatialData table name: {str(table)!r}")
34
+ return Path(store) / TABLES_GROUP / name
35
+
36
+
37
+ def _is_group(path: Path) -> bool:
38
+ return path.is_dir() and any((path / marker).exists() for marker in _GROUP_MARKERS)
39
+
40
+
41
+ def read_spatialdata_table(store, table):
42
+ """Read a single table out of a SpatialData store as an AnnData.
43
+
44
+ Deliberately reads *only* the named table rather than going through
45
+ spatialdata.read_zarr(store, selection=('tables',)), which eagerly
46
+ materializes every table's X: on the exemplar LSP20209.zarr that is
47
+ 0.55s and +846MB (it loads two 1536-dim embedding tables nobody asked
48
+ for) versus 0.04s and +13MB for the one table actually being imported.
49
+ A future plugin that genuinely needs the other elements can open the
50
+ full store itself -- the config records the store path alongside the
51
+ table name for exactly that reason.
52
+
53
+ spatialdata._io.io_table._read_table is the same per-table reader
54
+ sd.read_zarr calls, so this keeps spatialdata's own TableModel version
55
+ parsing rather than reimplementing it. It's a private symbol though, so
56
+ a spatialdata upgrade that moves it falls back to anndata's reader
57
+ (_read_table is a thin wrapper over exactly that, plus attrs
58
+ normalization we don't consume) instead of breaking a packaged build.
59
+ A test pins the import so the move is noticed rather than silently
60
+ absorbed.
61
+ """
62
+ path = table_path(store, table)
63
+ if not _is_group(path):
64
+ raise ValueError(
65
+ f"SpatialData store {str(store)!r} has no table named {str(table)!r} "
66
+ f"(expected a zarr group at {path})"
67
+ )
68
+
69
+ try:
70
+ from spatialdata._io.io_table import _read_table
71
+ except ImportError:
72
+ import anndata as ad
73
+
74
+ return ad.read_zarr(path)
75
+ return _read_table(path)
76
+
77
+
78
+ def _node_length(node) -> int | None:
79
+ """Length of an on-disk AnnData node along its first axis, from zarr
80
+ metadata only -- never reads values.
81
+
82
+ A column is a plain array under zarr v2 but frequently a *group* under
83
+ v3, where anndata encodes strings as 'nullable-string-array'
84
+ (values/mask) and categoricals as 'categorical' (codes/categories);
85
+ sparse X is a group too, carrying its shape in an attr. Both encodings
86
+ occur in one real store -- spatialdata 0.7 wrote the exemplar
87
+ LSP20209.zarr with a v3 root but v2 table groups -- so this handles all
88
+ of them rather than assuming either.
89
+ """
90
+ shape = getattr(node, "shape", None)
91
+ if shape is not None:
92
+ return int(shape[0])
93
+ attr_shape = node.attrs.get("shape")
94
+ if attr_shape:
95
+ return int(attr_shape[0])
96
+ # A group standing in for one column: every encoding keeps a full-length
97
+ # array under one of these names ('categories' is deliberately absent --
98
+ # it holds the distinct levels, not one entry per observation).
99
+ for child in ("codes", "values", "data"):
100
+ try:
101
+ child_shape = getattr(node[child], "shape", None)
102
+ except KeyError:
103
+ continue
104
+ if child_shape is not None:
105
+ return int(child_shape[0])
106
+ return None
107
+
108
+
109
+ def _frame_length(group, key) -> int | None:
110
+ """Row count of an on-disk AnnData dataframe group (obs/var). Prefers the
111
+ index anndata records under the '_index' attr, falling back to any column
112
+ -- they all share the axis length."""
113
+ try:
114
+ frame = group[key]
115
+ except KeyError:
116
+ return None
117
+ index_name = frame.attrs.get("_index")
118
+ candidates = ([index_name] if index_name else []) + [
119
+ k for k in frame.keys() if k != index_name
120
+ ]
121
+ for candidate in candidates:
122
+ try:
123
+ length = _node_length(frame[candidate])
124
+ except (KeyError, AttributeError, IndexError, TypeError):
125
+ continue
126
+ if length is not None:
127
+ return length
128
+ return None
129
+
130
+
131
+ def _table_shape(group) -> tuple[int | None, int | None]:
132
+ """(n_obs, n_var) for a table group. X carries both in one place when it
133
+ is readable (a dense array's shape, or a sparse group's 'shape' attr);
134
+ obs/var are the fallback for a table whose X is absent."""
135
+ try:
136
+ x_shape = getattr(group["X"], "shape", None) or group["X"].attrs.get("shape")
137
+ if x_shape is not None and len(x_shape) >= 2:
138
+ return int(x_shape[0]), int(x_shape[1])
139
+ except (KeyError, AttributeError, IndexError, TypeError):
140
+ pass
141
+ return _frame_length(group, "obs"), _frame_length(group, "var")
142
+
143
+
144
+ def list_spatialdata_tables(store) -> list[dict]:
145
+ """Every table in a SpatialData store, with its shape, for the import
146
+ form's table picker. Metadata-only: shapes come from zarr's array
147
+ headers, so listing all three tables of the exemplar store (one of them
148
+ 243348x1536) costs ~5ms and ~1MB rather than loading any values.
149
+
150
+ Raises ValueError if `store` isn't a readable SpatialData store, so the
151
+ form can tell "not a store" apart from "a store with no tables" (a real
152
+ state -- a store may hold only images/labels/shapes).
153
+ """
154
+ import zarr
155
+
156
+ store_path = Path(store)
157
+ if not _is_group(store_path):
158
+ raise ValueError(f"{str(store)!r} is not a zarr store.")
159
+ tables_dir = store_path / TABLES_GROUP
160
+ if not tables_dir.is_dir():
161
+ return []
162
+
163
+ tables = []
164
+ for entry in sorted(tables_dir.iterdir()):
165
+ if not _is_group(entry):
166
+ continue
167
+ try:
168
+ # Path (not str) matches spatialdata's own reader: zarr v3 parses
169
+ # a string store as a URL, so table names containing characters
170
+ # like '#' would be truncated.
171
+ group = zarr.open_group(entry, mode="r")
172
+ except Exception:
173
+ continue
174
+ n_obs, n_var = _table_shape(group)
175
+ tables.append({"name": entry.name, "n_obs": n_obs, "n_var": n_var})
176
+ return tables
177
+
178
+
179
+ def list_table_layers(store, table) -> list[str]:
180
+ """The extra expression matrices one table carries alongside its X.
181
+
182
+ Metadata-only, like list_spatialdata_tables above: zarr's group listing
183
+ names the children without reading any of them, so this costs a directory
184
+ walk rather than the hundreds of MB materializing a second matrix would.
185
+
186
+ Best-effort by design -- an unreadable or layer-less table is an ordinary
187
+ empty answer, not an error. The caller is asking "is there anything to
188
+ choose between here?", and "no" is a fine reply.
189
+ """
190
+ import zarr
191
+
192
+ path = table_path(store, table)
193
+ if not _is_group(path):
194
+ return []
195
+ try:
196
+ group = zarr.open_group(path, mode="r")
197
+ return sorted(str(name) for name in group["layers"].keys() if name)
198
+ except Exception:
199
+ return []
200
+
201
+
202
+ class SpatialDataAdapter(AnnDataAdapter):
203
+ """Adapter for a single table inside a SpatialData (.zarr) store.
204
+
205
+ `spec.src` is the store root and `spec.table` names the table within it;
206
+ every other field means exactly what it means for AnnData (see
207
+ anndata_adapter.py), because the resolved table *is* an AnnData.
208
+
209
+ Table coordinates are used as-is, in the pixel space of the registered
210
+ image -- no SpatialData coordinate-system transform is applied. That
211
+ holds for tables written alongside the image they annotate (the
212
+ exemplar store's obsm['spatial'] is literally its X_centroid/Y_centroid
213
+ columns); a store needing a transform would have to grow explicit
214
+ support here.
215
+ """
216
+
217
+ def __init__(self, spec):
218
+ super().__init__(spec)
219
+ self.table = spec.table
220
+ if not self.table:
221
+ raise ValueError(
222
+ "A SpatialData datasource needs a table -- it names which "
223
+ "table inside the .zarr store to load."
224
+ )
225
+
226
+ def _read_adata(self):
227
+ return read_spatialdata_table(self.path, self.table)
228
+
229
+ def _read_obs(self):
230
+ """The table's obs group, without the rest of the table.
231
+
232
+ The same saving as the .h5ad path and for the same reason, only larger:
233
+ `read_spatialdata_table` already avoids the store's *other* tables, but
234
+ it still materializes this one's X -- and the exemplar store keeps a
235
+ 1536-dimensional embedding table whose X is most of its 846 MB. An
236
+ annotation column is a directory read next to that.
237
+ """
238
+ import zarr
239
+
240
+ try:
241
+ from anndata.io import read_elem # anndata >= 0.10, public API
242
+ except ImportError: # pragma: no cover - older anndata
243
+ from anndata._io.specs import read_elem
244
+
245
+ # Path, not str: zarr v3 parses a string store as a URL, so a table name
246
+ # containing '#' would be truncated (same reason as list_spatialdata_tables).
247
+ group = zarr.open_group(table_path(self.path, self.table), mode="r")
248
+ return read_elem(group["obs"])