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/cli.py ADDED
@@ -0,0 +1,777 @@
1
+ """Friendly command-line entry point for launching Plexora.
2
+
3
+ `plexora-server` remains the low-level sidecar command used by Jupyter proxy
4
+ integrations. This module backs the end-user `plexora` command: it starts the
5
+ same Waitress server, prints the URL, and opens a browser only when doing so
6
+ looks appropriate for the current environment.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import getpass
13
+ import os
14
+ import platform
15
+ import socket
16
+ import sys
17
+ import threading
18
+ import time
19
+ import urllib.request
20
+ import webbrowser
21
+ from pathlib import Path
22
+ from urllib.parse import quote
23
+
24
+
25
+ HEADLESS_ENV_VARS = (
26
+ "CI",
27
+ "SLURM_JOB_ID",
28
+ "PBS_JOBID",
29
+ "LSB_JOBID",
30
+ "SSH_CONNECTION",
31
+ "SSH_CLIENT",
32
+ )
33
+
34
+ DEFAULT_PORT = 8000
35
+
36
+ #: Set on the child of the one re-exec `python -m plexora --plugins` may need,
37
+ #: so a mistake in that logic loops zero times instead of forever.
38
+ REEXEC_ENV_VAR = "PLEXORA_CLI_REEXEC"
39
+
40
+
41
+ # A deliberate duplicate of plexora._url.clean_prefix, kept in sync by
42
+ # tests/test_url_helpers.py's parity test. This module must stay importable
43
+ # WITHOUT the plexora package: tests/test_cli.py loads it straight off disk via
44
+ # spec_from_file_location, and under a PyInstaller onefile build `plexora` is
45
+ # not on a path an importlib file loader could reach. Six lines is a cheaper
46
+ # price than either of those.
47
+ def _clean_base_url(base_url):
48
+ if not base_url:
49
+ return ""
50
+ base_url = str(base_url).strip()
51
+ if base_url == "/":
52
+ return ""
53
+ return "/" + base_url.strip("/")
54
+
55
+
56
+ def _public_host(host):
57
+ return "127.0.0.1" if host in ("0.0.0.0", "::") else host
58
+
59
+
60
+ def browser_url(host, port, base_url="", datasource=None):
61
+ path = _clean_base_url(base_url)
62
+ if datasource:
63
+ path = f"{path}/{quote(datasource.strip('/'), safe='')}"
64
+ return f"http://{_public_host(host)}:{int(port)}{path or '/'}"
65
+
66
+
67
+ def should_open_browser(env=None, system=None, preference="auto"):
68
+ """Whether the friendly CLI should open a browser.
69
+
70
+ `preference` is one of:
71
+ - "yes": explicit --browser
72
+ - "no": explicit --no-browser
73
+ - "auto": desktop when likely interactive, quiet on HPC/CI/SSH/headless
74
+ """
75
+ if preference == "yes":
76
+ return True
77
+ if preference == "no":
78
+ return False
79
+
80
+ env = os.environ if env is None else env
81
+ system = platform.system() if system is None else system
82
+
83
+ if any(env.get(name) for name in HEADLESS_ENV_VARS):
84
+ return False
85
+ if system in ("Windows", "Darwin"):
86
+ return True
87
+ return bool(env.get("DISPLAY") or env.get("WAYLAND_DISPLAY"))
88
+
89
+
90
+ def _wait_until_ready(url, timeout=30):
91
+ health_url = url.rstrip("/") + "/health"
92
+ deadline = time.time() + timeout
93
+ while time.time() < deadline:
94
+ try:
95
+ with urllib.request.urlopen(health_url, timeout=1) as response:
96
+ if response.status < 500:
97
+ return True
98
+ except Exception:
99
+ time.sleep(0.25)
100
+ return False
101
+
102
+
103
+ def _open_browser_when_ready(open_url, health_url, *, wait_fn=_wait_until_ready, open_fn=webbrowser.open):
104
+ if wait_fn(health_url):
105
+ open_fn(open_url)
106
+
107
+
108
+ def _schedule_browser_open(open_url, health_url):
109
+ thread = threading.Thread(
110
+ target=_open_browser_when_ready,
111
+ args=(open_url, health_url),
112
+ daemon=True,
113
+ )
114
+ thread.start()
115
+ return thread
116
+
117
+
118
+ def version_string():
119
+ """The installed version, or an honest admission that there isn't one.
120
+
121
+ A source checkout that was never `pip install`ed has no distribution
122
+ metadata, and `--version` blowing up with PackageNotFoundError would be a
123
+ worse answer than saying so.
124
+ """
125
+ try:
126
+ from importlib.metadata import version, PackageNotFoundError
127
+ except ImportError: # pragma: no cover - Python < 3.8
128
+ return "unknown"
129
+ try:
130
+ return version("plexora")
131
+ except PackageNotFoundError:
132
+ return "unknown (source checkout)"
133
+
134
+
135
+ # -- port selection ------------------------------------------------------
136
+
137
+
138
+ def _probe_bind(host, port):
139
+ """Bind exactly as Waitress will and report the port, or None if taken.
140
+
141
+ SO_REUSEADDR is set off Windows because Waitress sets it too, and the probe
142
+ has to ask the same question the real bind will: without it, a port left in
143
+ TIME_WAIT by a Plexora that exited seconds ago reads as busy even though
144
+ Waitress would take it happily. On Windows that option means something else
145
+ entirely -- it lets a second socket steal a port that is actively bound --
146
+ so setting it there would make every probe answer "free".
147
+ """
148
+ host = host or "127.0.0.1"
149
+ family = socket.AF_INET6 if ":" in host else socket.AF_INET
150
+ with socket.socket(family, socket.SOCK_STREAM) as sock:
151
+ if os.name != "nt":
152
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
153
+ try:
154
+ sock.bind((host, port))
155
+ except OSError:
156
+ return None
157
+ return sock.getsockname()[1]
158
+
159
+
160
+ def _resolve_port(host, requested, *, explicit, log=print):
161
+ """Which port to serve on, given what the user asked for.
162
+
163
+ The interesting case is the one nobody asks for: a second `plexora` in
164
+ another terminal. Waitress's own failure there is an OSError traceback from
165
+ inside serve(), after the URL has already been printed -- so the user is
166
+ told to open a page that belongs to the FIRST server. Moving to another
167
+ port is both recoverable and what they meant, so an unrequested 8000 gives
168
+ way; an explicitly requested port does not, because "--port 9000" is a
169
+ statement about something else on the network expecting 9000.
170
+
171
+ There is a small TOCTOU window between this probe and Waitress's bind. It
172
+ is accepted: losing that race puts us back at today's behaviour, which is
173
+ the OSError this function exists to make rare rather than impossible.
174
+ """
175
+ if requested == 0:
176
+ chosen = _probe_bind(host, 0)
177
+ if chosen is None:
178
+ raise SystemExit(f"Could not obtain any free port on {host}.")
179
+ return chosen
180
+
181
+ if _probe_bind(host, requested) is not None:
182
+ return requested
183
+
184
+ if explicit:
185
+ raise SystemExit(
186
+ f"Port {requested} on {host} is already in use.\n"
187
+ f"Another Plexora may already be running there -- try opening "
188
+ f"http://{_public_host(host)}:{requested}/ first.\n"
189
+ f"Otherwise pass a different --port, or --port 0 to pick a free one."
190
+ )
191
+
192
+ chosen = _probe_bind(host, 0)
193
+ if chosen is None:
194
+ raise SystemExit(f"Could not obtain any free port on {host}.")
195
+ log(f"Port {requested} is in use; using {chosen} instead.")
196
+ return chosen
197
+
198
+
199
+ # -- `python -m plexora` and the one thing it cannot do late -------------
200
+
201
+
202
+ def _plugins_argument(argv):
203
+ """The value of --plugins in `argv`, or None if it isn't there.
204
+
205
+ Hand-scanned rather than run through build_parser(), because this has to
206
+ answer the question BEFORE argparse could reject an unrelated argv (a
207
+ subcommand, a typo) -- and because a parse error at this point would be
208
+ reported from the wrong place entirely.
209
+ """
210
+ for index, item in enumerate(argv):
211
+ if item == "--plugins":
212
+ return argv[index + 1] if index + 1 < len(argv) else None
213
+ if item.startswith("--plugins="):
214
+ return item.split("=", 1)[1]
215
+ return None
216
+
217
+
218
+ def bootstrap_program(plugins):
219
+ """A `python -c` program that pins PLEXORA_PLUGINS before any import.
220
+
221
+ The env write has to happen in a program that runs BEFORE `plexora` is in
222
+ sys.modules, and there is no entry point that qualifies: `plexora` is
223
+ generated as `from plexora.cli import main`, which imports the package to
224
+ reach the submodule, and `-m plexora` imports it to find `__main__`. Both
225
+ therefore register Blueprints before main() has seen a single argument. A
226
+ `-c` string is the one shape that can get in front of that.
227
+
228
+ The value is embedded as a literal rather than passed in the child's
229
+ environment because the environment cannot carry it: `--plugins ""` is a
230
+ deliberate core-only build, and on Windows setting a variable to "" DELETES
231
+ it, so the child would read "unset" -- which means activate everything, the
232
+ exact opposite. `repr` of a str is always a valid, fully escaped Python
233
+ literal, so a plugin list cannot break out of it.
234
+ """
235
+ return (
236
+ "import os, sys; "
237
+ f"os.environ['PLEXORA_PLUGINS'] = {str(plugins)!r}; "
238
+ "from plexora.cli import main; "
239
+ "raise SystemExit(main())"
240
+ )
241
+
242
+
243
+ def _relaunch(command): # pragma: no cover - exercised through injection
244
+ """Replace this process with `command`, or the nearest thing available.
245
+
246
+ os.execv is the honest call on POSIX: same pid, same terminal, no orphan.
247
+ Windows has no real exec -- the CRT emulation starts a NEW process and
248
+ exits this one, at which point cmd.exe decides the command finished and
249
+ prints a fresh prompt while a server is still running and still writing to
250
+ that console. So Windows waits on a child and passes its exit code up
251
+ instead; one extra process is a better outcome than a terminal that lies.
252
+ """
253
+ if os.name == "nt":
254
+ import subprocess
255
+
256
+ raise SystemExit(subprocess.run(command).returncode)
257
+ os.execv(command[0], command)
258
+
259
+
260
+ def maybe_reexec_for_plugins(argv=None, *, modules=None, environ=None, relaunch=_relaunch):
261
+ """Re-launch once so that `--plugins` means anything at all.
262
+
263
+ Blueprint registration happens inside the first `import plexora`, and every
264
+ entry point has already done that import by the time main() runs -- the
265
+ console script because it reaches `main` through `plexora.cli`, and
266
+ `python -m plexora` because `-m` imports the package to find `__main__`.
267
+ So writing PLEXORA_PLUGINS in main() is always too late: it lands after the
268
+ decision it is supposed to make. `--plugins` silently did nothing from
269
+ either command. Everything else the CLI sets (the data path, the base URL,
270
+ the host and port) is resolved on demand or overridden on app.config
271
+ afterwards, so this flag is the whole of the problem.
272
+
273
+ The cure is one re-exec into a `-c` program that sets the variable first
274
+ (`bootstrap_program`). It costs a second interpreter startup, which is why
275
+ it is conditional: nothing happens unless `--plugins` was actually passed
276
+ AND the environment does not already say the same thing.
277
+
278
+ Returns False -- meaning "carry on in this process" -- when the package is
279
+ genuinely not imported yet, for an argv without --plugins, for an
280
+ environment that already agrees, and for the child of a re-exec that has
281
+ already happened.
282
+ """
283
+ argv = list(sys.argv[1:] if argv is None else argv)
284
+ modules = sys.modules if modules is None else modules
285
+ environ = os.environ if environ is None else environ
286
+
287
+ if environ.get(REEXEC_ENV_VAR):
288
+ return False
289
+ if "plexora" not in modules:
290
+ return False
291
+ requested = _plugins_argument(argv)
292
+ if requested is None:
293
+ return False
294
+ if environ.get("PLEXORA_PLUGINS") == requested:
295
+ return False
296
+
297
+ environ[REEXEC_ENV_VAR] = "1"
298
+ relaunch([sys.executable, "-c", bootstrap_program(requested), *argv])
299
+ return True
300
+
301
+
302
+ # -- remote / SSH ---------------------------------------------------------
303
+
304
+
305
+ def remote_host(fqdn=None, hostname=None):
306
+ """The name a user on their laptop should ssh to.
307
+
308
+ getfqdn() is preferred because a bare hostname off a cluster node is rarely
309
+ resolvable from outside, but it is not trusted blindly: on a machine with
310
+ an unhelpful /etc/hosts it returns "localhost", which would print a tunnel
311
+ command that connects to the user's own laptop.
312
+ """
313
+ fqdn = socket.getfqdn() if fqdn is None else fqdn
314
+ if fqdn and "." in fqdn and not fqdn.lower().startswith("localhost"):
315
+ return fqdn
316
+ return hostname or socket.gethostname()
317
+
318
+
319
+ def scheduler_topology(env=None, hostname=None):
320
+ """`(scheduler, node, login_host)` for the batch job we are inside.
321
+
322
+ All three are None off a cluster. `node` is where Plexora is actually
323
+ running and `login_host` is the only machine on the way to it that accepts
324
+ connections from the outside world -- which is why a single -L forward is
325
+ the wrong instruction to print here, and knowing this is what lets the CLI
326
+ print the right one instead of a paragraph of caveats.
327
+
328
+ `login_host` may still come back None: the variable that carries it is a
329
+ convention, not a guarantee, and some sites submit from a host that isn't
330
+ the one you ssh into. Callers print a placeholder rather than guessing.
331
+ """
332
+ env = os.environ if env is None else env
333
+ hostname = hostname or socket.gethostname()
334
+
335
+ if env.get("SLURM_JOB_ID"):
336
+ return ("slurm", env.get("SLURMD_NODENAME") or hostname,
337
+ env.get("SLURM_SUBMIT_HOST") or None)
338
+ if env.get("PBS_JOBID"):
339
+ return ("pbs", hostname, env.get("PBS_O_HOST") or None)
340
+ if env.get("LSB_JOBID"):
341
+ hosts = (env.get("LSB_HOSTS") or "").split()
342
+ return ("lsf", hosts[0] if hosts else hostname,
343
+ env.get("LSB_SUB_HOST") or None)
344
+ return (None, None, None)
345
+
346
+
347
+ #: Emitted on its own line so `plexora connect --srun` can learn which compute
348
+ #: node the scheduler actually granted. Nothing else can tell it: the job is
349
+ #: submitted from the login node and the allocation is not known until the job
350
+ #: starts.
351
+ ANNOUNCE_PREFIX = "[plexora-remote]"
352
+
353
+
354
+ def remote_instructions(user, host, port, base_url="", *, login_host=None,
355
+ node=None, bind_node=False):
356
+ """The lines `plexora --remote` prints, as a pure function.
357
+
358
+ `node` is the switch between the two shapes: set means we are on a compute
359
+ node behind a login node and the user needs two hops, unset means the host
360
+ they ssh to is the host Plexora is on.
361
+ """
362
+ prefix = _clean_base_url(base_url)
363
+ local_url = f"http://localhost:{port}{prefix or '/'}"
364
+ login = login_host or "<login-host>"
365
+ lines = [f"{ANNOUNCE_PREFIX} node={node or host} port={port}", ""]
366
+
367
+ if node and bind_node:
368
+ lines += [
369
+ f"Plexora is running on compute node {node}, bound to 0.0.0.0:{port}.",
370
+ "From your own machine, run:",
371
+ f" ssh -N -L {port}:{node}:{port} {user}@{login}",
372
+ f"then open {local_url}",
373
+ "",
374
+ "Note: --bind-node makes this port reachable from anywhere on the",
375
+ "cluster's internal network for as long as Plexora runs.",
376
+ ]
377
+ elif node:
378
+ lines += [
379
+ f"Plexora is running on compute node {node}, bound to 127.0.0.1:{port}.",
380
+ "From your own machine, run:",
381
+ f" ssh -N -J {user}@{login} {user}@{node} -L {port}:127.0.0.1:{port}",
382
+ f"then open {local_url}",
383
+ "",
384
+ "If your cluster refuses ssh into a compute node, restart Plexora",
385
+ "with --bind-node for a login-node forward instead.",
386
+ ]
387
+ else:
388
+ lines += [
389
+ f"Plexora is running on {host}, bound to 127.0.0.1:{port}.",
390
+ "From your own machine, run:",
391
+ f" ssh -N -L {port}:127.0.0.1:{port} {user}@{host}",
392
+ f"then open {local_url}",
393
+ ]
394
+
395
+ if node and login_host is None:
396
+ lines += ["", f"Replace {login} with the cluster login node you ssh into "
397
+ "(or restart with --login-host)."]
398
+ return lines
399
+
400
+
401
+ #: Words that mean "do this instead of starting a server". Recognised only as
402
+ #: the FIRST argument, which is how anyone types them -- so a project may still
403
+ #: be called "config" as long as it is opened from the picker rather than as
404
+ #: `plexora config`.
405
+ SUBCOMMANDS = ("where", "config", "connect")
406
+
407
+
408
+ def split_command(argv):
409
+ """`(subcommand, remaining_argv)`, or `(None, argv)` for a plain launch."""
410
+ if argv and argv[0] in SUBCOMMANDS:
411
+ return argv[0], list(argv[1:])
412
+ return None, list(argv)
413
+
414
+
415
+ def build_parser(command=None):
416
+ """The parser for ONE invocation shape.
417
+
418
+ Subcommands and the bare `plexora [datasource]` form cannot share a parser.
419
+ An argparse subparsers action is itself a positional, so with an optional
420
+ positional beside it argparse gives the subparsers action first refusal on
421
+ the only argument -- `plexora tonsil` dies with "invalid choice: 'tonsil'"
422
+ -- and when a subcommand IS matched, the trailing `datasource` action then
423
+ runs with nothing left to consume and quietly resets it to None, throwing
424
+ away what `plexora connect host tonsil` just parsed. Splitting on argv[0]
425
+ in `split_command` costs three lines and has neither failure.
426
+ """
427
+ if command == "where":
428
+ parser = argparse.ArgumentParser(
429
+ prog="plexora where",
430
+ description="Print the resolved data directory and how it was chosen.",
431
+ )
432
+ parser.add_argument(
433
+ "--data-dir-only",
434
+ action="store_true",
435
+ help="Print only the data directory, for scripting.",
436
+ )
437
+ return parser
438
+
439
+ if command == "config":
440
+ parser = argparse.ArgumentParser(
441
+ prog="plexora config",
442
+ description="Show or change where Plexora keeps its data.",
443
+ )
444
+ config_subs = parser.add_subparsers(dest="config_command")
445
+ config_subs.add_parser("show", help="Print the current settings file.")
446
+ config_set = config_subs.add_parser("set", help="Change a setting.")
447
+ config_set.add_argument("key", choices=("data-dir", "shared-dirs"))
448
+ config_set.add_argument(
449
+ "value",
450
+ help="A path for data-dir; a comma-separated list for shared-dirs "
451
+ "(pass an empty string to clear).",
452
+ )
453
+ return parser
454
+
455
+ if command == "connect":
456
+ return _build_connect_parser()
457
+
458
+ parser = argparse.ArgumentParser(
459
+ prog="plexora",
460
+ description="Start the Plexora local image viewer server.",
461
+ epilog="Other commands: " + ", ".join(f"plexora {name}" for name in SUBCOMMANDS)
462
+ + ". Run one with --help for its own options.",
463
+ )
464
+ parser.add_argument(
465
+ "datasource",
466
+ nargs="?",
467
+ help="Optional datasource/project name to open directly.",
468
+ )
469
+ parser.add_argument(
470
+ "--version",
471
+ action="version",
472
+ version=f"plexora {version_string()}",
473
+ )
474
+ # Honours the same override the Docker image and run.py already use, so
475
+ # every entry point answers to one variable.
476
+ parser.add_argument("--host", default=os.environ.get("PLEXORA_HOST", "127.0.0.1"))
477
+ # default=None rather than 8000 so main() can tell "the user wants 8000"
478
+ # from "the user did not say", which is the difference between failing on a
479
+ # busy port and quietly moving off it.
480
+ parser.add_argument(
481
+ "--port",
482
+ type=int,
483
+ default=None,
484
+ help=f"Port to serve on (default {DEFAULT_PORT}, or the next free one "
485
+ f"if that is taken). Pass 0 to always pick a free port.",
486
+ )
487
+ parser.add_argument("--data-dir", default=None)
488
+ parser.add_argument("--base-url", default=None)
489
+ parser.add_argument(
490
+ "--plugins",
491
+ default=None,
492
+ help=(
493
+ "Comma-separated plugins to activate. Omit for all installed; pass "
494
+ "an empty string for a core-only build."
495
+ ),
496
+ )
497
+ parser.add_argument(
498
+ "-r",
499
+ "--remote",
500
+ action="store_true",
501
+ help="Serving on a machine you reached over SSH: print the exact "
502
+ "tunnel command to run from your own computer.",
503
+ )
504
+ parser.add_argument(
505
+ "--login-host",
506
+ default=None,
507
+ help="With --remote on a cluster, the login node to tunnel through "
508
+ "when the scheduler does not say.",
509
+ )
510
+ parser.add_argument(
511
+ "--bind-node",
512
+ action="store_true",
513
+ help="With --remote on a cluster, bind all interfaces so the login "
514
+ "node can forward to this one. For sites that refuse ssh into a "
515
+ "compute node; the port becomes visible cluster-internally.",
516
+ )
517
+ browser_group = parser.add_mutually_exclusive_group()
518
+ browser_group.add_argument(
519
+ "--browser",
520
+ action="store_true",
521
+ help="Open the Plexora URL in a browser even if the environment looks headless.",
522
+ )
523
+ browser_group.add_argument(
524
+ "--no-browser",
525
+ action="store_true",
526
+ help="Do not open a browser; only print the URL and serve.",
527
+ )
528
+ return parser
529
+
530
+
531
+ def _build_connect_parser():
532
+ """Define `plexora connect`, without importing the module that runs it.
533
+
534
+ The flags live here and the logic lives in plexora/connect.py, imported
535
+ lazily by _run_connect. That split is not tidiness: build_parser() runs in
536
+ a standalone-loaded cli.py where `import plexora.connect` would fail, and
537
+ argparse definitions are pure data anyway.
538
+ """
539
+ connect = argparse.ArgumentParser(
540
+ prog="plexora connect",
541
+ description="Start Plexora on a remote host, tunnel to it, and open a "
542
+ "browser here. Run this on your OWN computer, not on the "
543
+ "remote host.",
544
+ )
545
+ connect.add_argument("target", help="[user@]host to ssh into.")
546
+ connect.add_argument(
547
+ "datasource",
548
+ nargs="?",
549
+ help="Optional datasource/project name to open directly.",
550
+ )
551
+ connect.add_argument(
552
+ "--remote-command",
553
+ default="plexora",
554
+ help="How to invoke Plexora on the remote host. Use this when it is "
555
+ "not on a non-interactive PATH, e.g. "
556
+ "\"conda run -n imaging plexora\".",
557
+ )
558
+ connect.add_argument(
559
+ "--srun",
560
+ default=None,
561
+ metavar="ARGS",
562
+ help="Treat the target as a SLURM login node and run Plexora inside a "
563
+ "job, e.g. --srun \"-p interactive -t 4:00:00 --mem 16G\".",
564
+ )
565
+ connect.add_argument(
566
+ "--bind-node",
567
+ action="store_true",
568
+ help="With --srun, forward from the login node instead of ssh-ing into "
569
+ "the compute node. For sites that refuse the latter.",
570
+ )
571
+ connect.add_argument(
572
+ "-J",
573
+ "--jump",
574
+ default=None,
575
+ help="ssh -J jump host to reach the target through.",
576
+ )
577
+ connect.add_argument(
578
+ "--ssh-opt",
579
+ action="append",
580
+ default=[],
581
+ metavar="KEY=VALUE",
582
+ help="Extra ssh -o option; repeatable. Keys, ports and usernames are "
583
+ "usually better placed in ~/.ssh/config.",
584
+ )
585
+ connect.add_argument(
586
+ "--port", type=int, default=None,
587
+ help="Local port to serve the tunnel on (default: a free one).",
588
+ )
589
+ connect.add_argument(
590
+ "--remote-port", type=int, default=None,
591
+ help="Port to use on the remote host (default: a free-looking high one).",
592
+ )
593
+ connect.add_argument(
594
+ "--timeout", type=float, default=None,
595
+ help="Seconds to wait for Plexora to answer (default 60, or 900 with "
596
+ "--srun, where the job may sit in a queue).",
597
+ )
598
+ connect.add_argument("--data-dir", default=None,
599
+ help="Data directory to use ON THE REMOTE HOST.")
600
+ connect.add_argument("--plugins", default=None,
601
+ help="Plugins to activate on the remote host.")
602
+ connect.add_argument(
603
+ "--no-browser", action="store_true",
604
+ help="Set up the tunnel and print the URL, but do not open a browser.",
605
+ )
606
+ return connect
607
+
608
+
609
+ def _browser_preference(args):
610
+ if args.browser:
611
+ return "yes"
612
+ if args.no_browser:
613
+ return "no"
614
+ return "auto"
615
+
616
+
617
+ def _run_where(args):
618
+ from plexora import paths
619
+
620
+ if args.data_dir_only:
621
+ print(paths.data_root())
622
+ return 0
623
+ for line in paths.describe():
624
+ print(line)
625
+ return 0
626
+
627
+
628
+ def _run_config(args):
629
+ from plexora import paths
630
+
631
+ if args.config_command == "set":
632
+ settings = paths.read_settings()
633
+ if args.key == "data-dir":
634
+ settings["data_dir"] = str(Path(args.value).expanduser().resolve())
635
+ else:
636
+ entries = [part.strip() for part in args.value.split(",")]
637
+ settings["shared_dirs"] = [
638
+ str(Path(part).expanduser().resolve()) for part in entries if part
639
+ ]
640
+ paths.write_settings(settings)
641
+ # The resolution is cached per process, so a `set` immediately followed
642
+ # by a read in the same process must not answer from before the write.
643
+ paths.reset()
644
+ print(f"Wrote {paths.settings_path()}")
645
+
646
+ settings = paths.read_settings()
647
+ if not settings:
648
+ print(f"No settings recorded ({paths.settings_path()} does not exist).")
649
+ else:
650
+ print(f"{paths.settings_path()}:")
651
+ for key, value in sorted(settings.items()):
652
+ print(f" {key} = {value}")
653
+ return 0
654
+
655
+
656
+ def _run_connect(args):
657
+ from plexora.connect import connect
658
+
659
+ return connect(
660
+ args.target,
661
+ datasource=args.datasource,
662
+ remote_command=args.remote_command,
663
+ srun=args.srun,
664
+ bind_node=args.bind_node,
665
+ jump=args.jump,
666
+ ssh_opts=args.ssh_opt,
667
+ local_port=args.port,
668
+ remote_port=args.remote_port,
669
+ timeout=args.timeout,
670
+ data_dir=args.data_dir,
671
+ plugins=args.plugins,
672
+ browser=not args.no_browser,
673
+ )
674
+
675
+
676
+ def _print_remote_instructions(args, port):
677
+ """Work out this machine's place in the world and say how to reach it."""
678
+ _scheduler, node, detected_login = scheduler_topology()
679
+ for line in remote_instructions(
680
+ getpass.getuser(),
681
+ remote_host(),
682
+ port,
683
+ args.base_url or "",
684
+ login_host=args.login_host or detected_login,
685
+ node=node,
686
+ bind_node=args.bind_node,
687
+ ):
688
+ print(line)
689
+ print("")
690
+
691
+
692
+ def main(argv=None):
693
+ command, rest = split_command(list(sys.argv[1:] if argv is None else argv))
694
+
695
+ # Only for the serve invocation. `plexora connect --plugins X` also carries
696
+ # the flag, but that one is an instruction for the REMOTE host and has
697
+ # nothing to say about which Blueprints this process registered.
698
+ if command is None and maybe_reexec_for_plugins(rest):
699
+ return 0 # not reached: _relaunch replaces or supersedes this process
700
+
701
+ args = build_parser(command).parse_args(rest)
702
+
703
+ # Handled before anything sets PLEXORA_DATA_PATH, so `where` reports the
704
+ # rule that a plain `plexora` would actually follow rather than one this
705
+ # invocation just installed.
706
+ if command == "where":
707
+ return _run_where(args)
708
+ if command == "config":
709
+ return _run_config(args)
710
+ if command == "connect":
711
+ return _run_connect(args)
712
+
713
+ # --bind-node is the whole point of the flag: the login node cannot forward
714
+ # to a port that only listens on the compute node's loopback.
715
+ host = "0.0.0.0" if (args.remote and args.bind_node) else args.host
716
+ if args.remote and not args.bind_node and _public_host(host) != "127.0.0.1":
717
+ print(f"Warning: --host {host} with --remote; the printed tunnel still "
718
+ f"targets 127.0.0.1. Use --bind-node if you meant to expose the "
719
+ f"port on this machine's network.")
720
+
721
+ port = _resolve_port(host, DEFAULT_PORT if args.port is None else args.port,
722
+ explicit=args.port is not None)
723
+
724
+ # Set only for an explicit --data-dir. There is deliberately no default
725
+ # written here any more: plexora.paths resolves on demand, so the CLI no
726
+ # longer has to guess the answer before the app imports -- which is what
727
+ # made this the only entry point that got the right directory.
728
+ if args.data_dir:
729
+ os.environ["PLEXORA_DATA_PATH"] = str(Path(args.data_dir).expanduser())
730
+ if args.base_url is not None:
731
+ os.environ["PLEXORA_BASE_URL"] = args.base_url
732
+ if args.plugins is not None:
733
+ os.environ["PLEXORA_PLUGINS"] = args.plugins
734
+
735
+ from waitress import serve
736
+ from plexora import app, paths, _clean_base_url as app_clean_base_url
737
+
738
+ if args.base_url is not None:
739
+ app.config["PLEXORA_BASE_URL"] = app_clean_base_url(args.base_url)
740
+
741
+ # Printed before the URL, because a first-time user reading this is about
742
+ # to import data and the one thing they will want later is where it went.
743
+ notice = paths.first_run_notice()
744
+ if notice:
745
+ print(notice)
746
+
747
+ health_url = browser_url(host, port, args.base_url)
748
+ url = browser_url(host, port, args.base_url, args.datasource)
749
+
750
+ if args.remote:
751
+ _print_remote_instructions(args, port)
752
+ print(f"Serving Plexora at {url}")
753
+
754
+ # --remote means nobody is sitting at this machine, so a browser here would
755
+ # open on the wrong desktop -- but an explicit --browser still wins, for the
756
+ # case where "remote" is a workstation with a screen.
757
+ preference = _browser_preference(args)
758
+ if args.remote and preference == "auto":
759
+ preference = "no"
760
+ if should_open_browser(preference=preference):
761
+ print("Opening browser...")
762
+ _schedule_browser_open(url, health_url)
763
+ elif preference == "auto":
764
+ print("Browser auto-open skipped: headless environment detected.")
765
+
766
+ serve(
767
+ app,
768
+ host=host,
769
+ port=port,
770
+ max_request_body_size=1073741824000000,
771
+ max_request_header_size=85899345920000,
772
+ threads=8,
773
+ )
774
+
775
+
776
+ if __name__ == "__main__":
777
+ main()