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