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/connect.py ADDED
@@ -0,0 +1,528 @@
1
+ """`plexora connect user@host` -- run Plexora over there, use it from here.
2
+
3
+ `plexora --remote` prints the ssh command a user must then paste into a second
4
+ terminal on their own machine, and it has to: a server cannot reach across the
5
+ network and create a tunnel on somebody's laptop. This module is the other end
6
+ of that -- it runs LOCALLY, so it can do both halves.
7
+
8
+ The whole thing shells out to the system `ssh`. No paramiko, no key handling,
9
+ no host-key policy of our own: whatever `~/.ssh/config`, an agent, a
10
+ ProxyJump, a hardware token or a site's kerberos setup already do for
11
+ `ssh user@host` is exactly what happens here, and a connection that works in a
12
+ terminal works here without being described twice.
13
+
14
+ Two shapes, because clusters have two:
15
+
16
+ - Direct -- the machine you ssh to is the machine that runs Plexora. One ssh
17
+ process carrying both the command and the -L forward.
18
+ - Job (`--srun`) -- the machine you ssh to is a login node that is not allowed
19
+ to run anything heavy, and the server belongs on a compute node the
20
+ scheduler picks. Two ssh processes: one holding the job, one tunnelling to
21
+ wherever the job landed. Which node that is cannot be known in advance, so
22
+ the remote `plexora --remote` announces it on stdout and this module reads
23
+ it back.
24
+
25
+ Standalone-loadable on purpose: stdlib only, nothing from `plexora` at module
26
+ level, same rule cli.py follows. tests/test_connect.py loads it straight off
27
+ disk, and there is nothing in here that needs the app.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import atexit
33
+ import re
34
+ import shlex
35
+ import shutil
36
+ import socket
37
+ import subprocess
38
+ import sys
39
+ import threading
40
+ import time
41
+ import urllib.request
42
+ import webbrowser
43
+
44
+
45
+ DEFAULT_REMOTE_COMMAND = "plexora"
46
+
47
+ #: Ports to ask for on the remote side. The ephemeral range: high enough to be
48
+ #: out of the way of real services, and we cannot probe the remote host for a
49
+ #: free one without a round trip that would be wrong by the time it returned.
50
+ #: A collision is handled by retrying rather than prevented.
51
+ REMOTE_PORT_RANGE = (49152, 65000)
52
+
53
+ DEFAULT_TIMEOUT = 60
54
+ #: Generous, because a queued job is not a failure. A user who asked for a GPU
55
+ #: partition at 09:00 on a Monday may genuinely wait this long, and giving up
56
+ #: on them would cancel the allocation they were waiting for.
57
+ DEFAULT_SRUN_TIMEOUT = 900
58
+
59
+ ANNOUNCE_RE = re.compile(r"\[plexora-remote\]\s+node=(\S+)\s+port=(\d+)")
60
+
61
+ #: What a shell says when the remote `plexora` is not on a non-interactive
62
+ #: PATH -- the single most likely way this fails, and the one with a specific
63
+ #: fix worth naming.
64
+ MISSING_COMMAND_MARKERS = (
65
+ "command not found",
66
+ "No such file or directory",
67
+ "is not recognized as an internal or external command",
68
+ "not found",
69
+ )
70
+
71
+ # Seams. Rebound by tests on the loaded module object so no real ssh, browser
72
+ # or network call happens in CI; production reads them exactly once each.
73
+ _popen = subprocess.Popen
74
+ _which = shutil.which
75
+ _open_browser = webbrowser.open
76
+ _urlopen = urllib.request.urlopen
77
+ _sleep = time.sleep
78
+ _now = time.monotonic
79
+
80
+
81
+ class ConnectError(RuntimeError):
82
+ """Something went wrong that the user has to act on."""
83
+
84
+
85
+ class _Retriable(ConnectError):
86
+ """...but trying again on a different port is worth one shot first."""
87
+
88
+
89
+ # -- pure command construction -------------------------------------------
90
+
91
+
92
+ def split_target(target):
93
+ """`("user", "host")` for `user@host`; `(None, "host")` without a user.
94
+
95
+ The user half matters beyond politeness: in `--srun` mode the second hop
96
+ is built here rather than by ssh's own config lookup, and on Windows the
97
+ local account name ("Ajit Nirmal", with a space) is almost never the
98
+ cluster account name.
99
+ """
100
+ if "@" in target:
101
+ user, _, host = target.partition("@")
102
+ return (user or None), host
103
+ return None, target
104
+
105
+
106
+ def remote_command_line(remote_command, port, *, bind_node=False, datasource=None,
107
+ data_dir=None, plugins=None):
108
+ """The command string to hand the remote shell.
109
+
110
+ `remote_command` is spliced in RAW, not shlex-split and rejoined: it is the
111
+ escape hatch for environments where reaching Plexora is itself a shell
112
+ expression -- `conda run -n imaging plexora`, `module load python && ...`,
113
+ a bare absolute path with spaces already quoted by whoever typed it. The
114
+ flags this function adds are quoted, because those come from argv and may
115
+ contain a project name with spaces in it.
116
+ """
117
+ parts = ["--remote", "--no-browser", "--port", str(port)]
118
+ if bind_node:
119
+ parts.append("--bind-node")
120
+ if data_dir:
121
+ parts += ["--data-dir", data_dir]
122
+ if plugins is not None:
123
+ parts += ["--plugins", plugins]
124
+ if datasource:
125
+ parts.append(datasource)
126
+ return " ".join([remote_command] + [shlex.quote(part) for part in parts])
127
+
128
+
129
+ def srun_command_line(srun_args, launch_line):
130
+ """Wrap a remote launch in `srun`, so the server lands on a compute node."""
131
+ return " ".join(["srun", (srun_args or "").strip(), launch_line]).strip()
132
+
133
+
134
+ def _ssh_options(ssh_opts):
135
+ out = []
136
+ for opt in ssh_opts or ():
137
+ out += ["-o", opt]
138
+ return out
139
+
140
+
141
+ def direct_ssh_argv(target, local_port, remote_port, launch_line, *, jump=None,
142
+ ssh_opts=()):
143
+ """One process: the forward and the command that fills it.
144
+
145
+ `-t` asks for a pty so that when this ssh dies -- the user hits Ctrl+C, the
146
+ laptop's lid closes, the network drops -- the remote Plexora gets a SIGHUP
147
+ rather than being left running and holding a port forever.
148
+ """
149
+ argv = ["ssh", "-t", *_ssh_options(ssh_opts)]
150
+ if jump:
151
+ argv += ["-J", jump]
152
+ argv += ["-L", f"{local_port}:127.0.0.1:{remote_port}", target, launch_line]
153
+ return argv
154
+
155
+
156
+ def job_ssh_argv(target, launch_line, *, jump=None, ssh_opts=()):
157
+ """The process that holds the SLURM job open. No forward -- see below."""
158
+ argv = ["ssh", "-t", *_ssh_options(ssh_opts)]
159
+ if jump:
160
+ argv += ["-J", jump]
161
+ argv += [target, launch_line]
162
+ return argv
163
+
164
+
165
+ def tunnel_ssh_argv(target, local_port, node, node_port, *, user=None,
166
+ bind_node=False, ssh_opts=()):
167
+ """The process that carries traffic to wherever the job landed.
168
+
169
+ It cannot be folded into `job_ssh_argv`: an `-L` forward is set up when the
170
+ connection opens, and at that moment the job has not been allocated, so
171
+ there is no node to name yet.
172
+
173
+ Two forms. By default the forward is made by a second ssh that goes
174
+ *through* the login node into the compute node (`-J`), leaving Plexora
175
+ bound to that node's loopback where nothing else on the cluster can reach
176
+ it. Sites that refuse ssh into compute nodes get `--bind-node` instead:
177
+ Plexora listens on all interfaces and the login node forwards to it over
178
+ the internal network -- fewer moving parts, in exchange for the port being
179
+ visible to everyone else on the cluster while it runs.
180
+ """
181
+ argv = ["ssh", "-N", *_ssh_options(ssh_opts)]
182
+ if bind_node:
183
+ argv += ["-L", f"{local_port}:{node}:{node_port}", target]
184
+ return argv
185
+ node_target = f"{user}@{node}" if user else node
186
+ argv += ["-J", target, node_target, "-L", f"{local_port}:127.0.0.1:{node_port}"]
187
+ return argv
188
+
189
+
190
+ def parse_announce(line):
191
+ """`(node, port)` from the remote's announce line, or None."""
192
+ match = ANNOUNCE_RE.search(line)
193
+ if not match:
194
+ return None
195
+ return match.group(1), int(match.group(2))
196
+
197
+
198
+ def looks_like_missing_command(lines):
199
+ return any(marker in line for line in lines for marker in MISSING_COMMAND_MARKERS)
200
+
201
+
202
+ # -- ports ----------------------------------------------------------------
203
+
204
+
205
+ def _local_port_is_free(port):
206
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
207
+ try:
208
+ sock.bind(("127.0.0.1", port))
209
+ except OSError:
210
+ return False
211
+ return True
212
+
213
+
214
+ def _free_local_port():
215
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
216
+ sock.bind(("127.0.0.1", 0))
217
+ return sock.getsockname()[1]
218
+
219
+
220
+ def pick_ports(local_port=None, remote_port=None, *, randint=None, is_free=None,
221
+ free_port=None):
222
+ """`(local, remote)`, matching numbers when that is possible.
223
+
224
+ Same number on both ends is worth a little effort: every message the user
225
+ sees mentions a port, and having one number instead of two removes the
226
+ question of which end a given one refers to.
227
+ """
228
+ import random
229
+
230
+ randint = random.randint if randint is None else randint
231
+ is_free = _local_port_is_free if is_free is None else is_free
232
+ free_port = _free_local_port if free_port is None else free_port
233
+
234
+ remote = remote_port or randint(*REMOTE_PORT_RANGE)
235
+ if local_port:
236
+ return local_port, remote
237
+ if is_free(remote):
238
+ return remote, remote
239
+ return free_port(), remote
240
+
241
+
242
+ # -- running processes ----------------------------------------------------
243
+
244
+
245
+ class _Watched:
246
+ """A spawned ssh, echoed line by line, watched for the announce line.
247
+
248
+ The output is drained on a thread whatever else happens. Not for tidiness:
249
+ ssh writes the remote's stdout into a pipe with a finite buffer, and a
250
+ Plexora that logs a few kilobytes -- which it does on startup -- would
251
+ block forever on a write nobody was reading, which presents as the job
252
+ hanging at exactly the moment it was about to work.
253
+ """
254
+
255
+ def __init__(self, argv, label, *, echo=print):
256
+ self.argv = argv
257
+ self.label = label
258
+ self.lines = []
259
+ self.announce = None
260
+ self.saw_announce = threading.Event()
261
+ self.process = _popen(
262
+ argv,
263
+ stdout=subprocess.PIPE,
264
+ stderr=subprocess.STDOUT,
265
+ text=True,
266
+ bufsize=1,
267
+ )
268
+ self._echo = echo
269
+ self._thread = threading.Thread(target=self._pump, daemon=True)
270
+ self._thread.start()
271
+
272
+ def _pump(self):
273
+ stream = self.process.stdout
274
+ if stream is not None:
275
+ for raw in stream:
276
+ # -t gives us a pty, and a pty gives us \r\n.
277
+ line = raw.rstrip("\r\n")
278
+ self.lines.append(line)
279
+ found = parse_announce(line)
280
+ if found and self.announce is None:
281
+ self.announce = found
282
+ self.saw_announce.set()
283
+ self._echo(f" [{self.label}] {line}")
284
+ # Wake anyone waiting for an announce that is never coming.
285
+ self.saw_announce.set()
286
+
287
+ @property
288
+ def alive(self):
289
+ return self.process.poll() is None
290
+
291
+ def drain(self, timeout=2):
292
+ """Let the reader thread catch up before anyone reads `lines`.
293
+
294
+ Whoever noticed the process had exited got there by polling, and the
295
+ thread is still working through what ssh wrote on its way out. Without
296
+ this pause the diagnosis runs against a buffer that does not yet
297
+ contain "plexora: command not found" -- the one line worth reading.
298
+ """
299
+ self._thread.join(timeout=timeout)
300
+
301
+ def tail(self, count=6):
302
+ return self.lines[-count:]
303
+
304
+ def stop(self):
305
+ if self.process.poll() is not None:
306
+ return
307
+ try:
308
+ self.process.terminate()
309
+ self.process.wait(timeout=5)
310
+ except Exception:
311
+ try:
312
+ self.process.kill()
313
+ except Exception:
314
+ pass
315
+
316
+
317
+ _ACTIVE = []
318
+
319
+
320
+ def _shut_down_active():
321
+ # Reversed so the tunnel goes before the job that it points at: tearing
322
+ # down the job first would leave the tunnel briefly pointing at a node that
323
+ # no longer has anything listening, which is a worse thing to log.
324
+ for watched in reversed(_ACTIVE):
325
+ watched.stop()
326
+ _ACTIVE.clear()
327
+
328
+
329
+ atexit.register(_shut_down_active)
330
+
331
+
332
+ def _wait_for_health(url, deadline, watchers):
333
+ """Poll through the tunnel until Plexora answers, or something dies."""
334
+ while _now() < deadline:
335
+ for watched in watchers:
336
+ if not watched.alive:
337
+ raise _Retriable(
338
+ f"the {watched.label} ssh connection exited with code "
339
+ f"{watched.process.returncode} before Plexora answered"
340
+ )
341
+ try:
342
+ with _urlopen(url, timeout=2) as response:
343
+ if response.status < 500:
344
+ return True
345
+ except Exception:
346
+ _sleep(0.5)
347
+ return False
348
+
349
+
350
+ def _wait_for_announce(watched, deadline, *, echo=print):
351
+ """Block until the remote says where it is, or gives up trying."""
352
+ announced = False
353
+ while _now() < deadline:
354
+ if watched.saw_announce.wait(timeout=5):
355
+ break
356
+ if not watched.alive:
357
+ break
358
+ if not announced:
359
+ echo(" waiting for the scheduler to allocate a node...")
360
+ announced = True
361
+ # Only when we are about to give up: the line may still have been in the
362
+ # reader's buffer. On the success path the process is alive and streaming,
363
+ # so joining its thread would just cost a second for nothing.
364
+ if watched.announce is None:
365
+ watched.drain(timeout=1)
366
+ if watched.announce:
367
+ return watched.announce
368
+ if not watched.alive:
369
+ raise _Retriable(
370
+ f"the job ssh connection exited with code "
371
+ f"{watched.process.returncode} before Plexora started"
372
+ )
373
+ raise ConnectError(
374
+ "Timed out waiting for the remote Plexora to report which node it is "
375
+ "on.\nRaise --timeout if the queue is long, or fall back to running "
376
+ "`plexora --remote` inside your own srun session."
377
+ )
378
+
379
+
380
+ # -- orchestration --------------------------------------------------------
381
+
382
+
383
+ def _no_ssh_message():
384
+ if sys.platform == "win32":
385
+ return (
386
+ "No `ssh` command found.\n"
387
+ "Windows ships OpenSSH as an optional feature: enable it under "
388
+ "Settings > System > Optional features > Add a feature > "
389
+ "'OpenSSH Client', or install Git for Windows, then reopen this "
390
+ "terminal."
391
+ )
392
+ return (
393
+ "No `ssh` command found. Install an OpenSSH client and try again."
394
+ )
395
+
396
+
397
+ def _missing_command_hint(remote_command, watched):
398
+ return ConnectError(
399
+ f"The remote host could not run {remote_command!r}:\n"
400
+ + "\n".join(f" {line}" for line in watched.tail())
401
+ + "\n\nA non-interactive ssh session often has a shorter PATH than a "
402
+ "login shell. Name the command explicitly, e.g.\n"
403
+ " --remote-command \"conda run -n myenv plexora\"\n"
404
+ " --remote-command /home/you/miniconda3/envs/myenv/bin/plexora\n"
405
+ "Or run `plexora --remote` on the host yourself and use the tunnel "
406
+ "command it prints."
407
+ )
408
+
409
+
410
+ def _attempt(target, *, datasource, remote_command, srun, bind_node, jump,
411
+ ssh_opts, local_port, remote_port, timeout, data_dir, plugins,
412
+ browser, echo):
413
+ user, _host = split_target(target)
414
+ local, remote = pick_ports(local_port, remote_port)
415
+ launch = remote_command_line(
416
+ remote_command, remote,
417
+ bind_node=bind_node, datasource=datasource,
418
+ data_dir=data_dir, plugins=plugins,
419
+ )
420
+ deadline = _now() + timeout
421
+ watchers = []
422
+
423
+ try:
424
+ if srun is None:
425
+ argv = direct_ssh_argv(target, local, remote, launch,
426
+ jump=jump, ssh_opts=ssh_opts)
427
+ echo(f"$ {' '.join(argv)}")
428
+ primary = _Watched(argv, "ssh", echo=echo)
429
+ watchers.append(primary)
430
+ _ACTIVE.append(primary)
431
+ else:
432
+ argv = job_ssh_argv(target, srun_command_line(srun, launch),
433
+ jump=jump, ssh_opts=ssh_opts)
434
+ echo(f"$ {' '.join(argv)}")
435
+ primary = _Watched(argv, "job", echo=echo)
436
+ watchers.append(primary)
437
+ _ACTIVE.append(primary)
438
+
439
+ node, node_port = _wait_for_announce(primary, deadline, echo=echo)
440
+ echo(f" Plexora is on {node}:{node_port}; opening the tunnel.")
441
+ tunnel_argv = tunnel_ssh_argv(
442
+ target, local, node, node_port,
443
+ user=user, bind_node=bind_node, ssh_opts=ssh_opts,
444
+ )
445
+ echo(f"$ {' '.join(tunnel_argv)}")
446
+ tunnel = _Watched(tunnel_argv, "tunnel", echo=echo)
447
+ watchers.append(tunnel)
448
+ _ACTIVE.append(tunnel)
449
+
450
+ url = f"http://127.0.0.1:{local}/"
451
+ if not _wait_for_health(url, deadline, watchers):
452
+ raise ConnectError(
453
+ f"Plexora did not answer on {url} within {timeout:g}s.\n"
454
+ "Raise --timeout, or check that the remote host can run "
455
+ "`plexora --remote` on its own."
456
+ )
457
+
458
+ open_url = url if not datasource else url + datasource
459
+ echo("")
460
+ echo(f"Plexora is available at {open_url}")
461
+ echo("Leave this command running; press Ctrl+C to disconnect"
462
+ + (" and end the job." if srun is not None else "."))
463
+ if browser:
464
+ _open_browser(open_url)
465
+
466
+ primary.process.wait()
467
+ return 0
468
+ except _Retriable as exc:
469
+ if not watchers:
470
+ raise
471
+ watchers[0].drain()
472
+ if looks_like_missing_command(watchers[0].lines):
473
+ raise _missing_command_hint(remote_command, watchers[0]) from exc
474
+ raise
475
+ finally:
476
+ for watched in reversed(watchers):
477
+ watched.stop()
478
+ if watched in _ACTIVE:
479
+ _ACTIVE.remove(watched)
480
+
481
+
482
+ def connect(target, datasource=None, *, remote_command=DEFAULT_REMOTE_COMMAND,
483
+ srun=None, bind_node=False, jump=None, ssh_opts=(),
484
+ local_port=None, remote_port=None, timeout=None, data_dir=None,
485
+ plugins=None, browser=True, attempts=3, echo=print):
486
+ """Run Plexora on `target`, tunnel to it, open it here. Returns an exit code.
487
+
488
+ Best-effort by design. Every failure below ends with the printed
489
+ instructions from `plexora --remote` as the fallback, because that path has
490
+ one moving part (the user's own ssh) where this one has several.
491
+ """
492
+ if _which("ssh") is None:
493
+ raise SystemExit(_no_ssh_message())
494
+
495
+ if timeout is None:
496
+ timeout = DEFAULT_SRUN_TIMEOUT if srun is not None else DEFAULT_TIMEOUT
497
+
498
+ # A pinned port is an instruction, not a preference: retrying would just
499
+ # try the same thing again, and the user asked for that number for a reason.
500
+ if remote_port is not None:
501
+ attempts = 1
502
+
503
+ last = None
504
+ for attempt in range(1, attempts + 1):
505
+ try:
506
+ return _attempt(
507
+ target,
508
+ datasource=datasource, remote_command=remote_command, srun=srun,
509
+ bind_node=bind_node, jump=jump, ssh_opts=ssh_opts,
510
+ local_port=local_port, remote_port=remote_port, timeout=timeout,
511
+ data_dir=data_dir, plugins=plugins, browser=browser, echo=echo,
512
+ )
513
+ except KeyboardInterrupt:
514
+ echo("\nDisconnecting.")
515
+ return 0
516
+ except _Retriable as exc:
517
+ last = exc
518
+ if attempt < attempts:
519
+ echo(f" {exc}; retrying on a different port "
520
+ f"({attempt + 1}/{attempts}).")
521
+ except ConnectError as exc:
522
+ raise SystemExit(str(exc)) from exc
523
+
524
+ raise SystemExit(
525
+ f"Could not start Plexora on {target} after {attempts} attempts: {last}\n"
526
+ "Fall back to running `plexora --remote` on the host and pasting the "
527
+ "ssh command it prints."
528
+ )