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/jupyter.py ADDED
@@ -0,0 +1,406 @@
1
+ import atexit
2
+ import html
3
+ import os
4
+ import socket
5
+ import subprocess
6
+ import sys
7
+ import time
8
+ import urllib.request
9
+ import webbrowser
10
+ from pathlib import Path
11
+
12
+ from plexora._url import join_display
13
+ from plexora.datasource import register_datasource, register_anndata_datasource
14
+ from plexora.notebook_env import COLAB, PORT_PLACEHOLDER, colab_origin, resolve_display
15
+
16
+
17
+ _SERVERS = {}
18
+
19
+
20
+ def _default_data_dir():
21
+ """Where a notebook viewer keeps its data when the caller names nowhere.
22
+
23
+ This used to be `Path(__file__).parent / "data"` -- i.e. inside the
24
+ installed package. Under a real install that is site-packages: read-only
25
+ for a system or conda install, destroyed by `pip install -U`, and invisible
26
+ to `pip uninstall`. The one resolver decides now, exactly as it does for
27
+ the CLI, so a notebook and a terminal see the same projects.
28
+ """
29
+ from plexora import paths
30
+
31
+ return paths.data_root()
32
+
33
+
34
+ def _free_port():
35
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
36
+ sock.bind(("127.0.0.1", 0))
37
+ return sock.getsockname()[1]
38
+
39
+
40
+ class ServerStartError(RuntimeError):
41
+ """The sidecar process could not be started, with a reason worth reading."""
42
+
43
+
44
+ def _wait_until_ready(port, timeout=30, process=None):
45
+ """Block until the sidecar answers, or explain why it never will.
46
+
47
+ `process` is what makes this useful. Without it a child that died on its
48
+ first line -- the overwhelmingly common case being a `plexora` that is not
49
+ importable from the interpreter running the notebook -- was indistinguishable
50
+ from one that was merely slow, so the notebook cell sat there for the full
51
+ 30 seconds and then reported a timeout, which is the one explanation that
52
+ was not true.
53
+ """
54
+ deadline = time.time() + timeout
55
+ url = f"http://127.0.0.1:{port}/config"
56
+ while time.time() < deadline:
57
+ if process is not None and process.poll() is not None:
58
+ raise ServerStartError(
59
+ f"The Plexora server process exited with code "
60
+ f"{process.returncode} before it was ready.\n"
61
+ f"The usual cause is that `{sys.executable}` cannot import "
62
+ f"plexora -- install it into the environment running this "
63
+ f"kernel, e.g. `pip install -e .` from a source checkout."
64
+ )
65
+ try:
66
+ with urllib.request.urlopen(url, timeout=1) as response:
67
+ if response.status < 500:
68
+ return
69
+ except Exception:
70
+ time.sleep(0.25)
71
+ raise ServerStartError(f"Plexora server did not become ready on port {port}")
72
+
73
+
74
+ def _server_key(data_dir, base_url_template, plugins):
75
+ """What makes two viewers able to share one sidecar.
76
+
77
+ `base_url_template`, not the base URL: in proxy mode the URL contains the
78
+ port, which is freshly chosen on every call, so keying on it meant the
79
+ lookup could never hit and each `plexora.view()` in a notebook started
80
+ another server. Harmless when proxying was opt-in and rare; not harmless
81
+ now that hosted notebooks reach it by default.
82
+ """
83
+ return (str(Path(data_dir).expanduser().resolve()), base_url_template, plugins)
84
+
85
+
86
+ def _start_server(data_dir, base_url_template, port=None, plugins=None):
87
+ """Start (or reuse) a sidecar; returns `(port, base_url)`.
88
+
89
+ `base_url_template` may contain `{port}`, which is filled in once the port
90
+ is settled -- the proxied mount path has to name the port it is proxying,
91
+ but the port is not known until after the cache has been consulted.
92
+ """
93
+ key = _server_key(data_dir, base_url_template, plugins)
94
+ existing = _SERVERS.get(key)
95
+ if existing and existing.poll() is None:
96
+ return existing._plexora_port, existing._plexora_base_url
97
+
98
+ # One retry, because the gap between _free_port() releasing a port and the
99
+ # child binding it is a real window on a busy machine, and losing that race
100
+ # is both plausible and entirely recoverable.
101
+ attempts = 2 if port is None else 1
102
+ last = None
103
+ for attempt in range(attempts):
104
+ chosen = port or _free_port()
105
+ base_url = base_url_template.replace(PORT_PLACEHOLDER, str(chosen))
106
+ try:
107
+ process = _spawn_server(data_dir, base_url, chosen, plugins)
108
+ except ServerStartError as exc:
109
+ last = exc
110
+ continue
111
+ process._plexora_port = chosen
112
+ process._plexora_base_url = base_url
113
+ _SERVERS[key] = process
114
+ return chosen, base_url
115
+ raise last
116
+
117
+
118
+ def _spawn_server(data_dir, base_url, port, plugins):
119
+ resolved_data_dir = str(Path(data_dir).expanduser().resolve())
120
+ cmd = [
121
+ sys.executable,
122
+ "-m",
123
+ "plexora.server_cli",
124
+ "--host",
125
+ "127.0.0.1",
126
+ "--port",
127
+ str(port),
128
+ "--data-dir",
129
+ resolved_data_dir,
130
+ "--base-url",
131
+ base_url,
132
+ "--notebook-mode",
133
+ ]
134
+ if plugins is not None:
135
+ cmd.extend(["--plugins", plugins])
136
+ # The FLAG is what actually decides the child's plugins: server_cli.main()
137
+ # writes it into its own os.environ before `from plexora import app`, which
138
+ # is in-process and therefore exact. The environment variable below is
139
+ # belt-and-braces and cannot be the mechanism, because `plugins=""` -- a
140
+ # deliberate core-only build -- does not survive a process boundary on
141
+ # Windows at all: setting a variable to "" there deletes it, and the child
142
+ # would read "unset", which means activate everything.
143
+ #
144
+ # The data path has no such constraint either way -- it resolves on demand
145
+ # -- but is passed both ways so parent and child cannot disagree about
146
+ # which directory this viewer is for.
147
+ env = os.environ.copy()
148
+ env["PLEXORA_DATA_PATH"] = resolved_data_dir
149
+ env["PLEXORA_BASE_URL"] = base_url
150
+ env["PLEXORA_NOTEBOOK_MODE"] = "1"
151
+ if plugins is not None:
152
+ env["PLEXORA_PLUGINS"] = plugins
153
+ # No cwd: this used to be the package's parent, which is the repository
154
+ # root only when running from a checkout and site-packages otherwise.
155
+ # Nothing the child does is relative to its working directory any more.
156
+ process = subprocess.Popen(cmd, env=env)
157
+ try:
158
+ _wait_until_ready(port, process=process)
159
+ except ServerStartError:
160
+ if process.poll() is None:
161
+ process.terminate()
162
+ raise
163
+ return process
164
+
165
+
166
+ def _cleanup_servers():
167
+ for process in _SERVERS.values():
168
+ if process.poll() is None:
169
+ process.terminate()
170
+
171
+
172
+ atexit.register(_cleanup_servers)
173
+
174
+
175
+ class PlexoraViewer:
176
+ def __init__(
177
+ self,
178
+ datasource,
179
+ data_dir=None,
180
+ proxy="auto",
181
+ height=850,
182
+ width="100%",
183
+ base_url=None,
184
+ plugins=None,
185
+ start=True,
186
+ ):
187
+ """`proxy` is one of:
188
+
189
+ - "auto" (default) -- look at the environment and decide. Local Jupyter
190
+ and VS Code Remote get a direct localhost URL exactly as before;
191
+ JupyterHub, Open OnDemand and Colab get the proxied form they need.
192
+ - True -- always proxy through the notebook server.
193
+ - False -- always use a direct 127.0.0.1 URL.
194
+
195
+ The default changed from False. That was only ever right when the
196
+ browser and the kernel were the same machine; anywhere else it produced
197
+ an iframe pointing at the user's own laptop and rendered blank.
198
+ """
199
+ self.datasource = datasource
200
+ self.data_dir = Path(data_dir or os.environ.get("PLEXORA_DATA_PATH", _default_data_dir())).expanduser().resolve()
201
+ self.proxy = proxy
202
+ self.height = height
203
+ self.width = width
204
+ self._base_url = base_url
205
+ # Kept as-is (not truthy-or) so plugins="" -- explicitly core-only --
206
+ # stays distinguishable from "not passed, use whatever is installed".
207
+ self.plugins = plugins
208
+ self._port = None
209
+ self._display_base = None
210
+ if start:
211
+ self.start()
212
+
213
+ @classmethod
214
+ def from_files(
215
+ cls,
216
+ name,
217
+ image,
218
+ segmentation,
219
+ features,
220
+ x,
221
+ y,
222
+ id_column="CellID",
223
+ celltype_column=None,
224
+ channel_names=None,
225
+ copy=False,
226
+ data_dir=None,
227
+ **viewer_kwargs,
228
+ ):
229
+ resolved_data_dir = Path(data_dir or os.environ.get("PLEXORA_DATA_PATH", _default_data_dir())).expanduser().resolve()
230
+ register_datasource(
231
+ name=name,
232
+ image=image,
233
+ segmentation=segmentation,
234
+ features=features,
235
+ x=x,
236
+ y=y,
237
+ id_column=id_column,
238
+ celltype_column=celltype_column,
239
+ channel_names=channel_names,
240
+ copy=copy,
241
+ data_dir=resolved_data_dir,
242
+ )
243
+ return cls(datasource=name, data_dir=resolved_data_dir, **viewer_kwargs)
244
+
245
+ @classmethod
246
+ def from_anndata(
247
+ cls,
248
+ name,
249
+ image,
250
+ features=None,
251
+ adata=None,
252
+ segmentation=None,
253
+ coordinate_source=None,
254
+ obsm_key=None,
255
+ x=None,
256
+ y=None,
257
+ feature_source="X",
258
+ layer=None,
259
+ feature_obs_columns=None,
260
+ obs_id_field=None,
261
+ celltype_column=None,
262
+ subset_by=None,
263
+ subset_value=None,
264
+ channel_names=None,
265
+ copy=False,
266
+ data_dir=None,
267
+ **viewer_kwargs,
268
+ ):
269
+ resolved_data_dir = Path(data_dir or os.environ.get("PLEXORA_DATA_PATH", _default_data_dir())).expanduser().resolve()
270
+ register_anndata_datasource(
271
+ name=name,
272
+ image=image,
273
+ features=features,
274
+ adata=adata,
275
+ segmentation=segmentation,
276
+ coordinate_source=coordinate_source,
277
+ obsm_key=obsm_key,
278
+ x=x,
279
+ y=y,
280
+ feature_source=feature_source,
281
+ layer=layer,
282
+ feature_obs_columns=feature_obs_columns,
283
+ obs_id_field=obs_id_field,
284
+ celltype_column=celltype_column,
285
+ subset_by=subset_by,
286
+ subset_value=subset_value,
287
+ channel_names=channel_names,
288
+ copy=copy,
289
+ data_dir=resolved_data_dir,
290
+ )
291
+ return cls(datasource=name, data_dir=resolved_data_dir, **viewer_kwargs)
292
+
293
+ def start(self):
294
+ """Resolve where this viewer lives, then start or reuse a server.
295
+
296
+ Resolution happens against the PORT PLACEHOLDER rather than a real
297
+ port, so a second view() in the same notebook produces the same cache
298
+ key and reuses the first sidecar instead of spawning another one.
299
+ """
300
+ if self._port is not None:
301
+ return self._port
302
+ server_base, display_base = resolve_display(self.proxy, self._base_url)
303
+ self._port, _ = _start_server(
304
+ self.data_dir, server_base, plugins=self.plugins
305
+ )
306
+ # Substituted only now, against whichever port the sidecar actually
307
+ # ended up on -- which is not necessarily the one this call would have
308
+ # picked, since it may have reused a server started by an earlier cell.
309
+ if display_base is COLAB:
310
+ self._display_base = colab_origin(self._port)
311
+ else:
312
+ self._display_base = display_base.replace(
313
+ PORT_PLACEHOLDER, str(self._port)
314
+ )
315
+ return self._port
316
+
317
+ @property
318
+ def url(self):
319
+ self.start()
320
+ if self._display_base is None:
321
+ raise RuntimeError(
322
+ "Colab did not return a public URL for this port. That needs a "
323
+ "connected notebook frontend, so it fails under 'Run all' or a "
324
+ "reconnect. Use viewer.iframe(), which does not, or re-run this "
325
+ "cell on its own."
326
+ )
327
+ return join_display(self._display_base, self.datasource)
328
+
329
+ def _colab_iframe(self):
330
+ """Let Colab's frontend work out the URL, since the kernel could not.
331
+
332
+ This is the fallback for `colab_origin()` returning None, and it is not
333
+ a lesser version of it -- it is the more reliable one. The helper emits
334
+ Javascript that calls `proxyPort` in the notebook frontend, so it needs
335
+ no kernel-to-frontend round trip and works under "Run all" and after a
336
+ reconnect. It is only the fallback because it displays its own output
337
+ rather than returning a URL, which `.url` and `.open()` need.
338
+ """
339
+ from google.colab.output import serve_kernel_port_as_iframe
340
+
341
+ return serve_kernel_port_as_iframe(
342
+ self._port,
343
+ path=f"/{self.datasource}",
344
+ width=str(self.width),
345
+ height=str(self.height),
346
+ )
347
+
348
+ def iframe(self):
349
+ self.start()
350
+ if self._display_base is None:
351
+ return self._colab_iframe()
352
+ try:
353
+ from IPython.display import HTML
354
+ except ImportError:
355
+ return self._repr_html_()
356
+ return HTML(self._repr_html_())
357
+
358
+ def _ipython_display_(self):
359
+ """Render when the viewer is the last expression in a cell.
360
+
361
+ Defined rather than leaving IPython to find `_repr_html_`, because the
362
+ Colab fallback cannot be expressed as HTML at all: a <script> in cell
363
+ output runs inside Colab's sandboxed output frame, where
364
+ `google.colab.kernel` does not exist. Only frontend Javascript can
365
+ resolve the port, and only this hook can emit it.
366
+ """
367
+ self.start()
368
+ if self._display_base is None:
369
+ self._colab_iframe()
370
+ return
371
+ from IPython.display import display, HTML
372
+
373
+ display(HTML(self._repr_html_()))
374
+
375
+ def open(self):
376
+ """Open the viewer in a browser, where that means anything.
377
+
378
+ A hosted notebook's URL is a path on the hub's origin, which this
379
+ process cannot turn into something webbrowser could open -- and if it
380
+ could, the browser here is on the wrong machine anyway. Printing it is
381
+ the honest outcome.
382
+ """
383
+ url = self.url
384
+ if not url.lower().startswith(("http://", "https://")):
385
+ print(f"Open this under your Jupyter server's address: {url}")
386
+ return url
387
+ webbrowser.open(url)
388
+ return url
389
+
390
+ def _repr_html_(self):
391
+ self.start()
392
+ if self._display_base is None:
393
+ # Reachable only when something bypassed _ipython_display_ -- a
394
+ # message beats an exception raised from inside a repr, which
395
+ # IPython would show as a traceback about formatting.
396
+ return (
397
+ "<pre>Plexora is running, but Colab did not return a public URL "
398
+ "for it.\nCall viewer.iframe() to display it.</pre>"
399
+ )
400
+ src = html.escape(self.url, quote=True)
401
+ width = html.escape(str(self.width), quote=True)
402
+ height = int(self.height)
403
+ return (
404
+ f'<iframe src="{src}" width="{width}" height="{height}" '
405
+ 'style="border: 0; width: 100%;" allowfullscreen></iframe>'
406
+ )
@@ -0,0 +1,232 @@
1
+ """Working out what kind of notebook this is, and therefore what URL works.
2
+
3
+ `PlexoraViewer` starts a server on 127.0.0.1 and shows it in an iframe. That
4
+ is correct exactly when the browser rendering the notebook is on the same
5
+ machine as the kernel -- local Jupyter, and (because it forwards ports for you)
6
+ VS Code Remote. Everywhere else it produces a URL pointing at the user's own
7
+ laptop, where nothing is listening, and the cell renders a blank box with no
8
+ error anywhere.
9
+
10
+ "Everywhere else" is most of the interesting places: JupyterHub, Open OnDemand,
11
+ Colab, a plain `jupyter lab` on a workstation the user ssh'd into. Each needs a
12
+ different URL, and each can be recognised from the environment, so the viewer
13
+ asks here instead of making the user pass `proxy=True` after finding out the
14
+ hard way that they needed to.
15
+
16
+ Two ideas do most of the work:
17
+
18
+ **A hosted URL is a PATH, not a host.** Under jupyter-server-proxy the viewer
19
+ is reachable at `<notebook prefix>proxy/<port>` on the notebook's OWN origin --
20
+ which is the origin holding the user's auth cookie, and the only one that will
21
+ be allowed to load. So the display base stays path-only and an iframe resolves
22
+ it. Writing in a hostname would be both wrong and unauthenticated.
23
+
24
+ **Colab is the exception that proves it.** `proxyPort()` returns a whole
25
+ separate `https://…googleusercontent.com` origin, which is why `join_display`
26
+ accepts a full origin at all and why `clean_prefix` refuses one.
27
+
28
+ Imports only `plexora._url`, and everything that touches an optional
29
+ third-party module does so behind `_module_available` inside a function -- this
30
+ is consulted on the import path of a notebook helper, and a hard dependency on
31
+ `jupyter_server` would make `import plexora` fail on a machine that has no
32
+ Jupyter at all.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import importlib.util
38
+ import os
39
+ import sys
40
+
41
+ from plexora._url import is_full_origin, prefix_with_slash
42
+
43
+
44
+ def _module_available(name):
45
+ """Whether `name` could be imported, without importing it. Never raises.
46
+
47
+ `find_spec` alone is not enough, and its failure modes are exactly the ones
48
+ that turn up in notebooks: it raises ValueError for a module already in
49
+ sys.modules whose `__spec__` is None (dynamically created modules, some
50
+ frozen ones, anything a hub's startup code injected), and
51
+ ModuleNotFoundError when an intermediate package is missing. Every caller
52
+ here is on the display path of a notebook cell, where an exception would
53
+ replace the viewer with a traceback about introspection.
54
+ """
55
+ if name in sys.modules:
56
+ return True
57
+ try:
58
+ return importlib.util.find_spec(name) is not None
59
+ except Exception:
60
+ return False
61
+
62
+
63
+ #: Variables that mean "the kernel is not on the machine holding the screen".
64
+ #: The scheduler ones are not padding: Open OnDemand runs Jupyter inside a
65
+ #: batch job and sets no hub variable of its own, so its job variables are the
66
+ #: only evidence available that a direct localhost URL cannot work.
67
+ REMOTE_ENV_VARS = (
68
+ "SSH_CONNECTION",
69
+ "SSH_CLIENT",
70
+ "SSH_TTY",
71
+ "SLURM_JOB_ID",
72
+ "PBS_JOBID",
73
+ "LSB_JOBID",
74
+ )
75
+
76
+ HUB_ENV_VARS = ("JUPYTERHUB_SERVICE_PREFIX", "JUPYTERHUB_USER", "JUPYTERHUB_API_URL")
77
+
78
+ #: Stands in for a port that has not been chosen yet, so `resolve_display` can
79
+ #: be asked its question BEFORE a server exists -- which is what lets the
80
+ #: sidecar cache be keyed on the answer. Same spelling as the placeholder
81
+ #: `plexora/proxy.py` hands jupyter-server-proxy, for one convention rather
82
+ #: than two.
83
+ PORT_PLACEHOLDER = "{port}"
84
+
85
+ #: Returned as the display base when only the Colab FRONTEND can answer, and
86
+ #: only once a real port exists. Every other case resolves to a string that a
87
+ #: port can simply be substituted into; Colab needs a round trip, so it gets a
88
+ #: sentinel and the caller makes that call after starting the server.
89
+ COLAB = "colab"
90
+
91
+
92
+ def looks_remote(env=None):
93
+ env = os.environ if env is None else env
94
+ return any(env.get(name) for name in REMOTE_ENV_VARS)
95
+
96
+
97
+ def in_hub(env=None):
98
+ env = os.environ if env is None else env
99
+ return any(env.get(name) for name in HUB_ENV_VARS)
100
+
101
+
102
+ def in_colab():
103
+ return _module_available("google.colab")
104
+
105
+
106
+ def colab_origin(port):
107
+ """The public origin Colab will proxy `port` on, or None.
108
+
109
+ None is a normal outcome, not just an error path: `eval_js` runs Javascript
110
+ in the notebook FRONTEND and waits for an answer, so it needs a browser
111
+ actually connected to this kernel. Under "Run all", a reconnect, or a
112
+ headless execution there is nobody to ask, and it hangs or raises. The
113
+ caller falls back to Colab's own iframe helper, which does not need a
114
+ round trip.
115
+ """
116
+ try:
117
+ from google.colab.output import eval_js
118
+ except Exception:
119
+ return None
120
+ try:
121
+ origin = eval_js(f"google.colab.kernel.proxyPort({int(port)})")
122
+ except Exception:
123
+ return None
124
+ if not origin or not is_full_origin(origin):
125
+ return None
126
+ return str(origin).rstrip("/")
127
+
128
+
129
+ def discover_jupyter_prefix(env=None, echo=print):
130
+ """The notebook server's base_url (e.g. `/user/me/`), or None.
131
+
132
+ JUPYTERHUB_SERVICE_PREFIX is asked first because a hub sets it in the
133
+ kernel's own environment and it is definitive. Failing that, ask the
134
+ jupyter_server library which servers are running on this machine -- which
135
+ is how Open OnDemand is found, since it configures a random prefix per job
136
+ and advertises it nowhere else.
137
+ """
138
+ env = os.environ if env is None else env
139
+ from_hub = env.get("JUPYTERHUB_SERVICE_PREFIX")
140
+ if from_hub:
141
+ return prefix_with_slash(from_hub)
142
+
143
+ if not _module_available("jupyter_server"):
144
+ return None
145
+ try:
146
+ from jupyter_server.serverapp import list_running_servers
147
+
148
+ servers = [entry for entry in list_running_servers() if entry.get("base_url")]
149
+ except Exception:
150
+ return None
151
+ if not servers:
152
+ return None
153
+ if len(servers) > 1:
154
+ echo(
155
+ "Plexora found several running Jupyter servers and is assuming "
156
+ f"{servers[0]['base_url']!r}. Pass base_url=... to plexora.view() "
157
+ "if that is the wrong one."
158
+ )
159
+ return prefix_with_slash(servers[0]["base_url"])
160
+
161
+
162
+ def proxy_hint_if_missing(echo=print):
163
+ """Mention jupyter-server-proxy if it looks absent. Never raises.
164
+
165
+ Only a hint, and only ever a hint: on several hubs the kernel runs in a
166
+ different environment from the notebook server, so the package being
167
+ missing HERE says nothing about whether the proxy will work THERE. Turning
168
+ that guess into an exception would break setups that were fine.
169
+ """
170
+ if not _module_available("jupyter_server_proxy"):
171
+ echo(
172
+ "Note: jupyter-server-proxy does not appear to be installed in this "
173
+ "environment. If the viewer below does not load, install it in the "
174
+ "environment running your Jupyter server:\n"
175
+ " pip install jupyter-server-proxy"
176
+ )
177
+
178
+
179
+ def resolve_display(proxy="auto", base_url=None, port=PORT_PLACEHOLDER, env=None,
180
+ echo=print):
181
+ """`(server_base_url, display_base)` -- how to mount, and how to reach it.
182
+
183
+ The two differ whenever a proxy is involved, and conflating them is the
184
+ original bug: the sidecar has to generate links under the path the proxy
185
+ exposes it at, while the notebook has to load that same path against the
186
+ hub's origin.
187
+
188
+ `port` may be `PORT_PLACEHOLDER`, and normally is: the caller wants this
189
+ answer before it has picked a port, so that the answer can decide whether
190
+ an already-running sidecar will do. Both returned strings then carry the
191
+ placeholder through for the caller to substitute. `COLAB` is the one
192
+ display base that cannot work that way -- see the constant.
193
+
194
+ First match wins, and the order encodes what beats what:
195
+
196
+ 1. An explicit `base_url` is an instruction; nothing overrides it.
197
+ 2. `proxy=False` is also an instruction -- the pre-existing default, kept
198
+ working verbatim for every notebook that passes it today.
199
+ 3. Colab, whose proxy is a whole origin rather than a path (returns the
200
+ `COLAB` sentinel; ask `colab_origin(real_port)` once you have one).
201
+ 4. A discoverable notebook prefix, but only with a reason to use it:
202
+ `proxy=True`, or evidence the kernel is not local.
203
+ 5. Direct localhost. Deliberately last and deliberately not an error --
204
+ this is plain local Jupyter, and it is also VS Code Remote, which
205
+ forwards the port itself and would be broken by "helpfully" proxying.
206
+ """
207
+ env = os.environ if env is None else env
208
+
209
+ if base_url is not None:
210
+ if is_full_origin(base_url):
211
+ # A caller who already knows the public origin (a bespoke reverse
212
+ # proxy, a tunnel they set up) -- the server still mounts at root.
213
+ return "", str(base_url).rstrip("/")
214
+ mounted = f"{prefix_with_slash(base_url)}proxy/{port}"
215
+ return mounted, mounted
216
+
217
+ if proxy is False:
218
+ return "", f"http://127.0.0.1:{port}"
219
+
220
+ if in_colab():
221
+ # Colab maps the whole port onto a subdomain root, so there is no path
222
+ # to mount under -- the server stays at "/" and only the display
223
+ # changes.
224
+ return "", COLAB
225
+
226
+ prefix = discover_jupyter_prefix(env, echo=echo)
227
+ if prefix and (proxy is True or looks_remote(env) or in_hub(env)):
228
+ proxy_hint_if_missing(echo=echo)
229
+ mounted = f"{prefix}proxy/{port}"
230
+ return mounted, mounted
231
+
232
+ return "", f"http://127.0.0.1:{port}"