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.
- plexora/__init__.py +129 -0
- plexora/__main__.py +15 -0
- plexora/_url.py +105 -0
- plexora/api/__init__.py +55 -0
- plexora/api/dataset.py +398 -0
- plexora/api/http.py +19 -0
- plexora/api/plugin.py +541 -0
- plexora/api/store.py +221 -0
- plexora/cli.py +777 -0
- plexora/client/dist/354_bundle.js +2 -0
- plexora/client/dist/354_bundle.js.LICENSE.txt +20 -0
- plexora/client/dist/418_bundle.js +2 -0
- plexora/client/dist/418_bundle.js.LICENSE.txt +1 -0
- plexora/client/dist/693b935cb1f907814be9c6199a68c217.svg +19 -0
- plexora/client/dist/713c134ff47cd328a5b711526f151082.svg +18 -0
- plexora/client/dist/770_bundle.js +1 -0
- plexora/client/dist/ff743f408972e0e96ca9153ed5ae4c83.svg +23 -0
- plexora/client/dist/vendor_bundle.js +2 -0
- plexora/client/dist/vendor_bundle.js.LICENSE.txt +308 -0
- plexora/client/external/openseadragon-bin-2.4.0/LICENSE.txt +28 -0
- plexora/client/external/openseadragon-bin-2.4.0/canvas-overlay-hd.js +140 -0
- plexora/client/external/openseadragon-bin-2.4.0/changelog.txt +501 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/.gitattributes +17 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/LICENSE.txt +116 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/Toolbar.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/fullpage.psd +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/home.psd +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/next.psd +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/previous.psd +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/rotateleft.psd +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/rotateright.psd +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/zoom.psd +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/README.md +9 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-scalebar.js +562 -0
- plexora/client/src/css/import.css +474 -0
- plexora/client/src/css/main.css +903 -0
- plexora/client/src/css/openProject.css +363 -0
- plexora/client/src/css/quickView.css +166 -0
- plexora/client/src/css/tokens.css +51 -0
- plexora/client/src/css/viewer.css +1655 -0
- plexora/client/src/img/apple-touch-icon.png +0 -0
- plexora/client/src/img/favicon.ico +0 -0
- plexora/client/src/img/logo.ai +8892 -33
- plexora/client/src/img/logo.svg +1 -0
- plexora/client/src/img/logo_with_text.ai +9011 -34
- plexora/client/src/img/logo_with_text.svg +1 -0
- plexora/client/src/js/main.js +704 -0
- plexora/client/src/js/pluginRegistry.js +108 -0
- plexora/client/src/js/services/appStatus.js +286 -0
- plexora/client/src/js/services/browsePicker.js +73 -0
- plexora/client/src/js/services/dataLayer.js +400 -0
- plexora/client/src/js/services/datasetContext.js +143 -0
- plexora/client/src/js/services/glRenderer.js +143 -0
- plexora/client/src/js/services/importFormValidation.js +257 -0
- plexora/client/src/js/services/numericData.js +100 -0
- plexora/client/src/js/services/passVariablesToFrontend.js +20 -0
- plexora/client/src/js/services/simpleEventHandler.js +25 -0
- plexora/client/src/js/vendor.js +29 -0
- plexora/client/src/js/views/channelList.js +663 -0
- plexora/client/src/js/views/colorSwatchPicker.js +202 -0
- plexora/client/src/js/views/columnClassifier.js +146 -0
- plexora/client/src/js/views/coordinateField.js +133 -0
- plexora/client/src/js/views/dataSourceField.js +276 -0
- plexora/client/src/js/views/imageViewer.js +3306 -0
- plexora/client/src/js/views/miniMap.js +787 -0
- plexora/client/src/js/views/navbarControls.js +178 -0
- plexora/client/src/js/views/openProjectPage.js +239 -0
- plexora/client/src/js/views/projectEdit.js +335 -0
- plexora/client/src/js/views/quickViewLanding.js +136 -0
- plexora/client/src/js/views/rainbow.js +326 -0
- plexora/client/src/js/views/requirementsModal.js +572 -0
- plexora/client/src/js/views/rgbImageViewer.js +115 -0
- plexora/client/src/js/views/searchableSelect.js +411 -0
- plexora/client/src/js/views/segmentationProgress.js +122 -0
- plexora/client/src/js/views/toolLoader.js +747 -0
- plexora/client/src/js/views/viewerControls.js +689 -0
- plexora/client/src/js/views/viewerManager.js +434 -0
- plexora/client/src/js/views/viewerSidebar.js +1117 -0
- plexora/client/src/js/workers/tileDecoder.js +130 -0
- plexora/client/src/shaders/frag.glsl +528 -0
- plexora/client/src/shaders/vert.glsl +12 -0
- plexora/client/templates/base.html +212 -0
- plexora/client/templates/index.html +238 -0
- plexora/client/templates/open_project.html +96 -0
- plexora/client/templates/project_columns.html +86 -0
- plexora/client/templates/project_edit.html +196 -0
- plexora/client/templates/upload.html +118 -0
- plexora/connect.py +528 -0
- plexora/datasource.py +782 -0
- plexora/jupyter.py +406 -0
- plexora/notebook_env.py +232 -0
- plexora/paths.py +486 -0
- plexora/plugins/__init__.py +12 -0
- plexora/plugins/cell_explorer/__init__.py +100 -0
- plexora/plugins/cell_explorer/server/__init__.py +11 -0
- plexora/plugins/cell_explorer/server/routes.py +167 -0
- plexora/plugins/cell_explorer/server/state.py +281 -0
- plexora/plugins/cell_explorer/server/values.py +145 -0
- plexora/plugins/cell_explorer/server/variables.py +367 -0
- plexora/plugins/cell_explorer/static/cellExplorerApi.js +158 -0
- plexora/plugins/cell_explorer/static/cellExplorerColors.js +316 -0
- plexora/plugins/cell_explorer/static/cellExplorerContinuous.js +403 -0
- plexora/plugins/cell_explorer/static/cellExplorerFigureBridge.js +133 -0
- plexora/plugins/cell_explorer/static/cellExplorerLegend.js +188 -0
- plexora/plugins/cell_explorer/static/cellExplorerRoiBridge.js +822 -0
- plexora/plugins/cell_explorer/static/cellExplorerSidebarController.js +826 -0
- plexora/plugins/cell_explorer/static/cellExplorerState.js +372 -0
- plexora/plugins/cell_explorer/static/cell_explorer.css +752 -0
- plexora/plugins/cell_explorer/templates/cell_explorer/panel.html +137 -0
- plexora/plugins/figure_builder/__init__.py +125 -0
- plexora/plugins/figure_builder/server/__init__.py +7 -0
- plexora/plugins/figure_builder/server/compose.py +365 -0
- plexora/plugins/figure_builder/server/export.py +784 -0
- plexora/plugins/figure_builder/server/export_jobs.py +148 -0
- plexora/plugins/figure_builder/server/operations.py +556 -0
- plexora/plugins/figure_builder/server/pixels.py +142 -0
- plexora/plugins/figure_builder/server/provenance.py +174 -0
- plexora/plugins/figure_builder/server/render.py +278 -0
- plexora/plugins/figure_builder/server/repository.py +701 -0
- plexora/plugins/figure_builder/server/routes.py +594 -0
- plexora/plugins/figure_builder/server/schema.py +1023 -0
- plexora/plugins/figure_builder/server/sources.py +120 -0
- plexora/plugins/figure_builder/server/textmetrics.py +155 -0
- plexora/plugins/figure_builder/static/figureActions.js +363 -0
- plexora/plugins/figure_builder/static/figureBuilderApi.js +255 -0
- plexora/plugins/figure_builder/static/figureCanvas.js +2362 -0
- plexora/plugins/figure_builder/static/figureCaptureBoxes.js +369 -0
- plexora/plugins/figure_builder/static/figureCaptureDock.js +466 -0
- plexora/plugins/figure_builder/static/figureCaptureTool.js +1034 -0
- plexora/plugins/figure_builder/static/figureConfirm.js +136 -0
- plexora/plugins/figure_builder/static/figureContextBar.js +1193 -0
- plexora/plugins/figure_builder/static/figureContextMenu.js +253 -0
- plexora/plugins/figure_builder/static/figureDocumentState.js +275 -0
- plexora/plugins/figure_builder/static/figureExportUi.js +174 -0
- plexora/plugins/figure_builder/static/figureLibrary.js +247 -0
- plexora/plugins/figure_builder/static/figureQuickEdit.js +628 -0
- plexora/plugins/figure_builder/static/figureRichText.js +634 -0
- plexora/plugins/figure_builder/static/figureSceneSnapshot.js +361 -0
- plexora/plugins/figure_builder/static/figureSchema.js +218 -0
- plexora/plugins/figure_builder/static/figureSidebarController.js +1159 -0
- plexora/plugins/figure_builder/static/figureTextEditor.js +503 -0
- plexora/plugins/figure_builder/static/figureTextPanel.js +491 -0
- plexora/plugins/figure_builder/static/figureViewOptions.js +273 -0
- plexora/plugins/figure_builder/static/figureWorkspace.js +2030 -0
- plexora/plugins/figure_builder/static/figure_builder.css +3292 -0
- plexora/plugins/figure_builder/templates/figure_builder/library.html +74 -0
- plexora/plugins/figure_builder/templates/figure_builder/workspace.html +27 -0
- plexora/plugins/figure_builder/templates/figure_builder/workspace_body.html +458 -0
- plexora/plugins/gating/__init__.py +76 -0
- plexora/plugins/gating/server/__init__.py +0 -0
- plexora/plugins/gating/server/anndata_gates.py +367 -0
- plexora/plugins/gating/server/database.py +16 -0
- plexora/plugins/gating/server/model.py +320 -0
- plexora/plugins/gating/server/routes.py +235 -0
- plexora/plugins/gating/static/csvGatingList.js +947 -0
- plexora/plugins/gating/static/gating.css +289 -0
- plexora/plugins/gating/static/gatingApi.js +221 -0
- plexora/plugins/gating/static/gatingSidebarController.js +513 -0
- plexora/plugins/gating/templates/gating/legacy.html +26 -0
- plexora/plugins/gating/templates/gating/panel.html +69 -0
- plexora/plugins/roi/__init__.py +95 -0
- plexora/plugins/roi/server/__init__.py +8 -0
- plexora/plugins/roi/server/adapters.py +655 -0
- plexora/plugins/roi/server/geojson.py +267 -0
- plexora/plugins/roi/server/geometry.py +181 -0
- plexora/plugins/roi/server/mapping.py +169 -0
- plexora/plugins/roi/server/operations.py +293 -0
- plexora/plugins/roi/server/repository.py +215 -0
- plexora/plugins/roi/server/routes.py +409 -0
- plexora/plugins/roi/server/schema.py +323 -0
- plexora/plugins/roi/static/roi.css +583 -0
- plexora/plugins/roi/static/roiApi.js +177 -0
- plexora/plugins/roi/static/roiFigureBridge.js +107 -0
- plexora/plugins/roi/static/roiGeometry.js +350 -0
- plexora/plugins/roi/static/roiRenderer.js +278 -0
- plexora/plugins/roi/static/roiSidebarController.js +1038 -0
- plexora/plugins/roi/static/roiState.js +518 -0
- plexora/plugins/roi/static/roiTools.js +993 -0
- plexora/plugins/roi/templates/roi/panel.html +240 -0
- plexora/proxy.py +57 -0
- plexora/server/models/adapters/__init__.py +94 -0
- plexora/server/models/adapters/anndata_adapter.py +388 -0
- plexora/server/models/adapters/base.py +93 -0
- plexora/server/models/adapters/classify.py +192 -0
- plexora/server/models/adapters/csv_adapter.py +105 -0
- plexora/server/models/adapters/inspection.py +265 -0
- plexora/server/models/adapters/spatialdata_adapter.py +248 -0
- plexora/server/models/centroid_tiles.py +305 -0
- plexora/server/models/data_model.py +1659 -0
- plexora/server/models/database_model.py +169 -0
- plexora/server/models/project.py +1270 -0
- plexora/server/plugins.py +199 -0
- plexora/server/routes/browse_routes.py +41 -0
- plexora/server/routes/data_routes.py +294 -0
- plexora/server/routes/import_routes.py +603 -0
- plexora/server/routes/page_routes.py +139 -0
- plexora/server/routes/project_routes.py +438 -0
- plexora/server/routes/quick_view_routes.py +62 -0
- plexora/server/routes/system_routes.py +39 -0
- plexora/server/routes/tool_routes.py +418 -0
- plexora/server/utils/addHEColumns.py +11 -0
- plexora/server/utils/fast_png.py +97 -0
- plexora/server/utils/fullConversion.py +50 -0
- plexora/server/utils/native_dialog.py +168 -0
- plexora/server/utils/pre_normalization.py +47 -0
- plexora/server/utils/segmentation_pyramid.py +598 -0
- plexora/server/utils/smallestenclosingcircle.py +126 -0
- plexora/server/utils/tiffsurgeon.py +372 -0
- plexora/server_cli.py +48 -0
- plexora/test.py +0 -0
- plexora-0.0.1.dist-info/METADATA +341 -0
- plexora-0.0.1.dist-info/RECORD +248 -0
- plexora-0.0.1.dist-info/WHEEL +5 -0
- plexora-0.0.1.dist-info/entry_points.txt +12 -0
- plexora-0.0.1.dist-info/licenses/LICENSE +207 -0
- 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()
|