omicslab 2.2.0__tar.gz → 2.6.0__tar.gz
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.
- {omicslab-2.2.0 → omicslab-2.6.0}/PKG-INFO +68 -12
- {omicslab-2.2.0 → omicslab-2.6.0}/README.developer.md +52 -5
- {omicslab-2.2.0 → omicslab-2.6.0}/README.md +66 -11
- {omicslab-2.2.0 → omicslab-2.6.0}/pyproject.toml +1 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/scripts/bootstrap-env.sh +32 -4
- omicslab-2.6.0/src/omicslab/_version.py +1 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/cli/main.py +81 -14
- {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/cli/runner_commands.py +17 -17
- {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/cli/utils.py +29 -22
- {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/cli/ws_commands.py +548 -39
- {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/client.py +10 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/config.py +16 -1
- omicslab-2.6.0/src/omicslab/job/_run/_broker.py +235 -0
- omicslab-2.6.0/src/omicslab/job/_run/_git_proxy.py +196 -0
- omicslab-2.6.0/src/omicslab/job/_run/_s3_proxy.py +242 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/job/_run/_storage.py +19 -5
- {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/job/_run/analysis.py +6 -0
- omicslab-2.6.0/src/omicslab/job/_run/studio.py +1176 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/resources/jobs.py +234 -14
- {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/resources/orgs.py +22 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/resources/storage.py +16 -9
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/conftest.py +2 -1
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/e2e/conftest.py +6 -7
- omicslab-2.6.0/tests/job/_run/test__broker.py +146 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/job/_run/test__engine_helpers.py +26 -2
- omicslab-2.6.0/tests/job/_run/test__git_proxy.py +105 -0
- omicslab-2.6.0/tests/job/_run/test__s3_proxy.py +175 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/job/_run/test__storage.py +88 -0
- omicslab-2.6.0/tests/job/_run/test__studio_docker.py +349 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/test_cli.py +39 -2
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/test_cli_storage.py +170 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/test_cli_utils.py +42 -0
- omicslab-2.6.0/tests/test_client_broker.py +51 -0
- omicslab-2.6.0/tests/test_config.py +20 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/test_resources.py +238 -2
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/test_ws_commands.py +314 -1
- omicslab-2.2.0/src/omicslab/_version.py +0 -1
- omicslab-2.2.0/src/omicslab/job/_run/studio.py +0 -613
- omicslab-2.2.0/tests/job/_run/test__studio_docker.py +0 -107
- {omicslab-2.2.0 → omicslab-2.6.0}/.gitignore +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/Makefile +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/envs/.gitignore +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/envs/dev/hcm.pixi.lock +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/envs/dev/hcm.pixi.toml +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/envs/dev/pixi.lock +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/envs/dev/pixi.toml +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/envs/dev/us-east-1.pixi.lock +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/envs/dev/us-east-1.pixi.toml +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/envs/pixi.toml +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/envs/prod/hcm.pixi.lock +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/envs/prod/hcm.pixi.toml +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/envs/prod/pixi.lock +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/envs/prod/pixi.toml +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/envs/regions.json +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/pixi.config.toml +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/scripts/check_publish.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/scripts/write_version_manifest.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/__init__.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/cli/__init__.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/cli/analysis_commands.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/cli/auth_commands.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/cli/context_commands.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/cli/credential_commands.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/cli/fuse_commands.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/cli/job_commands.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/cli/org_commands.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/cli/setup_commands.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/cli/studio_commands.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/cli/token_commands.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/cli/workspace_commands.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/exceptions.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/job/__init__.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/job/_run/__init__.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/job/_run/_auth.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/job/_run/_config_download.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/job/_run/_consts.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/job/_run/_data_prep.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/job/_run/_env.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/job/_run/_exec.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/job/_run/_git.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/job/_run/_images.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/job/_run/_log.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/job/_run/_monitor.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/job/_run/_nextflow.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/job/_run/_params.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/job/_run/_proc.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/job/_run/_proxy.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/job/_run/_spec.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/job/_run/_studio_prep.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/job/client.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/job/console.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/job/executor.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/job/paths.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/job/system.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/resources/__init__.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/resources/analysis.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/resources/base.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/resources/credentials.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/resources/runners.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/resources/studio.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/resources/tokens.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/resources/workspaces.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/selfupdate.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/__init__.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/e2e/__init__.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/e2e/test_cli_e2e.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/e2e/test_tokens.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/fixtures/nfcore-demo-custom.config +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/fixtures/studio_specs/jupyter.json +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/fixtures/studio_specs/rstudio.json +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/job/_run/test__auth.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/job/_run/test__data_prep.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/job/_run/test__env.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/job/_run/test__get_images_cli.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/job/_run/test__git.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/job/_run/test__images.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/job/_run/test__log.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/job/_run/test__monitor.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/job/_run/test__nextflow.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/job/_run/test__params.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/job/_run/test__proxy.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/job/_run/test__proxy_extra.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/job/_run/test__spec.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/job/_run/test_studio_proxy_start.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/job/test_job_cli.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/job/test_job_client.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/job/test_job_client_extra.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/job/test_job_executor.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/job/test_job_executor_extra.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/job/test_job_fuse.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/job/test_job_resource.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/job/test_job_system.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/job/test_snapshot_request.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/job/test_studio_image_download.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/job/test_studio_validate_cli.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/job/test_ws_helpers.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/test_cli_catalog.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/test_cli_context.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/test_client.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/test_fuse.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/test_job_exec.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/test_job_heartbeat.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/test_misc.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/test_runner_helpers.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/test_runner_self_update.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/test_sandbox_report.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/test_selfupdate.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/test_studio_snapshot.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/test_system_monitor_interval.py +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/tools/hello-nextflow/input.txt +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/tools/hello-nextflow/main.nf +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/tools/hello-nextflow/omicslab/main.sh +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/tools/hello-nextflow/pixi.toml +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/tools/hello-nextflow/schema.json +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/tools/hello-simple/omicslab/main.sh +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/tools/hello-simple/pixi.toml +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/tools/hello-simple/schema.json +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/tools/hello-snakemake/Snakefile +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/tools/hello-snakemake/input.txt +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/tools/hello-snakemake/omicslab/main.sh +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/tools/hello-snakemake/pixi.toml +0 -0
- {omicslab-2.2.0 → omicslab-2.6.0}/tests/tools/hello-snakemake/schema.json +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: omicslab
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.6.0
|
|
4
4
|
Summary: Python SDK and CLI for the OmicsLab Platform
|
|
5
5
|
Project-URL: Homepage, https://platform.omicslab.io
|
|
6
6
|
Project-URL: Repository, https://github.com/vieomics/omicslab-platform
|
|
@@ -14,6 +14,7 @@ Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
|
|
|
14
14
|
Classifier: Programming Language :: Python :: 3.12
|
|
15
15
|
Requires-Python: >=3.12
|
|
16
16
|
Requires-Dist: aiohttp>=3.10
|
|
17
|
+
Requires-Dist: botocore<2,>=1.35
|
|
17
18
|
Requires-Dist: httpx>=0.27
|
|
18
19
|
Requires-Dist: psutil>=6
|
|
19
20
|
Requires-Dist: pydantic>=2
|
|
@@ -51,7 +52,7 @@ omicslab use
|
|
|
51
52
|
omicslab ls && omicslab upload ./data.csv
|
|
52
53
|
|
|
53
54
|
# 4. Launch a job
|
|
54
|
-
omicslab
|
|
55
|
+
omicslab ws analysis launch --workspace <ws_id> --analysis <id> --compute <id>
|
|
55
56
|
```
|
|
56
57
|
|
|
57
58
|
## Storage Commands
|
|
@@ -93,7 +94,7 @@ omicslab upload -y ./data.csv s3://my-bucket/data/
|
|
|
93
94
|
|
|
94
95
|
### Download
|
|
95
96
|
|
|
96
|
-
Download files or directories from workspace storage.
|
|
97
|
+
Download files or directories from workspace storage. A folder is detected automatically, so a trailing slash and `-r` are optional; recursive downloads preserve subdirectory structure and use parallel threads.
|
|
97
98
|
|
|
98
99
|
```bash
|
|
99
100
|
# Single file (defaults to filename in current directory)
|
|
@@ -105,7 +106,13 @@ omicslab download results/run1/data.csv ./local_copy.csv
|
|
|
105
106
|
# Download from s3:// path (auto-resolves workspace)
|
|
106
107
|
omicslab download s3://my-bucket/data/results/report.html
|
|
107
108
|
|
|
108
|
-
#
|
|
109
|
+
# Folder download (auto-detected; defaults to ./run1)
|
|
110
|
+
omicslab download results/run1
|
|
111
|
+
|
|
112
|
+
# Folder download with explicit destination
|
|
113
|
+
omicslab download results/run1 ./local_results/
|
|
114
|
+
|
|
115
|
+
# Same thing, explicit recursive (preserves subdirectory structure)
|
|
109
116
|
omicslab download -r s3://my-bucket/data/results/ ./local_results/
|
|
110
117
|
|
|
111
118
|
# Recursive download with custom thread count
|
|
@@ -119,12 +126,13 @@ omicslab download -y -r s3://my-bucket/data/results/ ./local_results/
|
|
|
119
126
|
|
|
120
127
|
| Flag | Description |
|
|
121
128
|
|------|-------------|
|
|
122
|
-
| `--recursive`, `-r` |
|
|
129
|
+
| `--recursive`, `-r` | Force recursive download (folders are detected automatically) |
|
|
130
|
+
| `--zip` | Download a folder as a server-built zip archive |
|
|
123
131
|
| `--threads` | Concurrent download threads (default: 4) |
|
|
124
132
|
| `--workspace-id`, `-w` | Workspace ID (inferred from s3:// path if provided) |
|
|
125
133
|
| `--yes`, `-y` | Skip confirmation prompt for s3:// path resolution |
|
|
126
134
|
|
|
127
|
-
**Recursive download** lists all objects under the prefix, then downloads them in parallel while preserving the original subdirectory structure under the destination directory.
|
|
135
|
+
**Recursive download** lists all objects under the prefix, then downloads them in parallel while preserving the original subdirectory structure under the destination directory. A folder key without a destination downloads into a local directory named after its last segment (`omicslab download snapshots` → `./snapshots/`). An exact object always wins over a same-named folder prefix, and a key that matches neither reports "File or folder not found" instead of a provider 403/404.
|
|
128
136
|
|
|
129
137
|
## Environment Variables
|
|
130
138
|
|
|
@@ -132,6 +140,7 @@ omicslab download -y -r s3://my-bucket/data/results/ ./local_results/
|
|
|
132
140
|
|---|---|
|
|
133
141
|
| `OMICSLAB_TOKEN` | API token (overrides config file) |
|
|
134
142
|
| `OMICSLAB_BASE_URL` | API base URL (default: `https://platform.omicslab.io/api`) |
|
|
143
|
+
| `OMICSLAB_CONFIG_DIR` | Directory holding `config.json` (default: `~/.omicslab`); used by studio sessions to read a container-private config without changing `$HOME` |
|
|
135
144
|
|
|
136
145
|
## Jobs API
|
|
137
146
|
|
|
@@ -139,12 +148,13 @@ Analysis and studio jobs share one backend namespace:
|
|
|
139
148
|
`/workspaces/{workspace_id}/jobs/...` — `job_type` ("analysis" | "studio")
|
|
140
149
|
is part of the launch payload / list filter, not a separate URL space.
|
|
141
150
|
`client.jobs` exposes generic methods (`launch`, `list_jobs`, `get_job`,
|
|
142
|
-
`get_job_manifest`, `
|
|
143
|
-
`disconnect_job`, `check_job_ready`, ...) plus the domain
|
|
144
|
-
(`launch_analysis`, `launch_studio`, `
|
|
145
|
-
for convenience. Job
|
|
146
|
-
launch config live in the
|
|
147
|
-
|
|
151
|
+
`get_job_manifest`, `rerun_job`, `terminate_job`, `delete_job`,
|
|
152
|
+
`connect_job`, `disconnect_job`, `check_job_ready`, ...) plus the domain
|
|
153
|
+
aliases (`launch_analysis`, `launch_studio`, `rerun_analysis`,
|
|
154
|
+
`rerun_studio`, `list_analysis`, `list_studio`, ...) for convenience. Job
|
|
155
|
+
resources (`time`/`cpu`/`memory`/`disk`) and the launch config live in the
|
|
156
|
+
per-job manifest on S3, retrievable via `get_job_manifest` /
|
|
157
|
+
`get_job_audit_params` / `get_job_audit_config`.
|
|
148
158
|
|
|
149
159
|
```python
|
|
150
160
|
from omicslab import Client
|
|
@@ -171,6 +181,52 @@ studio = client.jobs.launch_studio(
|
|
|
171
181
|
compute_id="cloud-comp-...",
|
|
172
182
|
)
|
|
173
183
|
access = client.jobs.connect_job("ws-...", studio["id"]) # {"access_url": ...}
|
|
184
|
+
|
|
185
|
+
# Rerun a job from its resolved launch manifest. Only the overrides below
|
|
186
|
+
# change; tool/tag/pinned commit/versioning/config stay fixed to the source.
|
|
187
|
+
rerun = client.jobs.rerun_analysis(
|
|
188
|
+
"ws-...",
|
|
189
|
+
job_id,
|
|
190
|
+
cpu=8,
|
|
191
|
+
time=7200,
|
|
192
|
+
params={"max_cpus": 8}, # merged per top-level key
|
|
193
|
+
)
|
|
194
|
+
# Masked secret params must be supplied again, otherwise the API returns 400.
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
The same rerun is available for studio sessions via
|
|
198
|
+
`client.jobs.rerun_studio(...)`; the generic `client.jobs.rerun_job(...)`
|
|
199
|
+
works for both. The response carries `old_job_id` and
|
|
200
|
+
`changed_parameters` so callers can show what changed.
|
|
201
|
+
|
|
202
|
+
```python
|
|
203
|
+
# Estimate the max cost before launching
|
|
204
|
+
estimate = client.jobs.estimate_job(
|
|
205
|
+
"ws-...",
|
|
206
|
+
compute_type="cloud",
|
|
207
|
+
compute_id="cloud-comp-...",
|
|
208
|
+
time=7200,
|
|
209
|
+
cpu=8,
|
|
210
|
+
memory=32,
|
|
211
|
+
disk=100,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
# Status counts + per-period launch trend (workspace or organization scope)
|
|
215
|
+
counts = client.jobs.get_job_counts("ws-...", job_type="analysis", granularity="daily")
|
|
216
|
+
org_counts = client.orgs.get_job_counts("org-...", job_type="analysis")
|
|
217
|
+
|
|
218
|
+
# Snapshot a RUNNING studio session into workspace storage
|
|
219
|
+
client.jobs.snapshot_studio("ws-...", studio["id"], outdir="snapshots")
|
|
220
|
+
|
|
221
|
+
# Server-side search/filter/sort (exact statuses need filter_column="status")
|
|
222
|
+
client.jobs.list_analysis(
|
|
223
|
+
"ws-...",
|
|
224
|
+
search="bacass",
|
|
225
|
+
filter_column="status",
|
|
226
|
+
filter_value="running,failed",
|
|
227
|
+
sort_column="created_at",
|
|
228
|
+
sort_desc=True,
|
|
229
|
+
)
|
|
174
230
|
```
|
|
175
231
|
|
|
176
232
|
## Testing
|
|
@@ -92,11 +92,11 @@ and then hand the raw token to `Client`.
|
|
|
92
92
|
|
|
93
93
|
| Property | Class | Purpose |
|
|
94
94
|
|------------|--------------------------|---------|
|
|
95
|
-
| `orgs` | `OrgResource` | Org CRUD + members + roles |
|
|
95
|
+
| `orgs` | `OrgResource` | Org CRUD + members + roles + job counts |
|
|
96
96
|
| `workspaces` | `WorkspaceResource` | Workspace CRUD, members, pin, linked resources, budget/storage-quota (read-only) |
|
|
97
97
|
| `analysis` | `AnalysisResource` | Tool catalog (batch tools) CRUD/tags/profiles/schema |
|
|
98
98
|
| `studio` | `StudioResource` | Tool catalog (interactive tools) CRUD + tags/profiles/schema |
|
|
99
|
-
| `jobs` | `JobResource` | Launch/list/get/terminate analysis + studio jobs |
|
|
99
|
+
| `jobs` | `JobResource` | Launch/list/get/rerun/estimate/counts/snapshot/terminate analysis + studio jobs |
|
|
100
100
|
| `storage` | `StorageResource` | Upload/download list/delete/rename vs workspace S3 |
|
|
101
101
|
| `credentials` | `CredentialResource` | S3/GitHub/Cloud credentials (user + org scopes) |
|
|
102
102
|
| `tokens` | `TokenResource` | Create/list/revoke API tokens |
|
|
@@ -248,15 +248,16 @@ bootstraps one session-scoped user via raw `httpx`:
|
|
|
248
248
|
```
|
|
249
249
|
POST /api/auth/register/ (unique email)
|
|
250
250
|
POST /api/auth/standard/ → access_token cookie
|
|
251
|
-
|
|
251
|
+
POST /api/organizations/ → create the test org
|
|
252
252
|
POST /api/credentials/organization/{org}/storage/base/ + link s3 (storage cred)
|
|
253
253
|
POST /api/organizations/{org}/workspaces/ (workspace)
|
|
254
254
|
POST /api/tokens/ → omx_ token scoped to org (admin)
|
|
255
255
|
Client(token=..., base_url=...)
|
|
256
256
|
```
|
|
257
257
|
|
|
258
|
-
Registration
|
|
259
|
-
|
|
258
|
+
Registration does not create any organization; the conftest creates the org
|
|
259
|
+
explicitly. A subscription is still activated for it through the payment flow
|
|
260
|
+
(see `test_subscription`).
|
|
260
261
|
|
|
261
262
|
### 7.2 Fixtures
|
|
262
263
|
|
|
@@ -463,6 +464,52 @@ Studio jobs are unchanged: they run as background sandbox services via
|
|
|
463
464
|
`executor.submit(...)` (spec/bash path), as do jobs dispatched to a runner
|
|
464
465
|
daemon over WebSocket. Only `job run --work-dir` analysis is native/synchronous.
|
|
465
466
|
|
|
467
|
+
Studio session mounts (no token exposure, snapshot-safe):
|
|
468
|
+
|
|
469
|
+
- The SDK environment is bind-mounted **read-only at its own absolute path**
|
|
470
|
+
(pixi/conda envs are path-sensitive), and `omicslab`/`omx` are exposed on
|
|
471
|
+
`PATH` via a dedicated `/opt/omicslab/bin` directory.
|
|
472
|
+
- `pixi` itself is mounted there too, together with the SDK project's
|
|
473
|
+
`pixi.toml`/`pixi.lock`, so `pixi run ...` works inside the session. A
|
|
474
|
+
writable copy of the env's small `conda-meta` directory is mounted over the
|
|
475
|
+
read-only one because pixi takes its install lock there and appends to
|
|
476
|
+
`history`; the copy lives in the job dir and is never snapshotted.
|
|
477
|
+
- Users can create their own project envs in the writable workspace
|
|
478
|
+
(`pixi init` + `pixi add`); those envs are in the writable layer and **are**
|
|
479
|
+
included in snapshots. Pixi keeps its default package cache under the
|
|
480
|
+
session user's home, so that cache is part of the writable layer too and is
|
|
481
|
+
included in snapshots. No cache directory is mounted: sessions need no extra
|
|
482
|
+
mount, the cache never runs out of a fixed size cap, and pixi works for any
|
|
483
|
+
image user without permission fixes.
|
|
484
|
+
- The staged CLI config is mounted from the host into
|
|
485
|
+
`OMICSLAB_CONFIG_DIR=/run/omicslab` before the container starts. The token
|
|
486
|
+
is replaced with a placeholder first; the SDK talks to a host-side
|
|
487
|
+
credential broker over `OMICSLAB_BROKER_SOCKET`, and the broker injects the
|
|
488
|
+
real `Authorization` header upstream. The mount is writable so context
|
|
489
|
+
commands such as `omx cd` can persist, but it contains only the placeholder
|
|
490
|
+
token — the real token never appears in the container env, `docker inspect`,
|
|
491
|
+
the writable layer, or a snapshot.
|
|
492
|
+
Presigned S3 URLs are still fetched directly by the container. Commands
|
|
493
|
+
that bypass `Client` (for example the websocket log stream) are not proxied
|
|
494
|
+
yet.
|
|
495
|
+
- A selected **personal** GitHub credential is injected by a host-side Git
|
|
496
|
+
HTTPS proxy. Git inside the session rewrites `https://github.com/` to the
|
|
497
|
+
proxy URL, so no token or credential helper is present in the container.
|
|
498
|
+
Studio launches reject organization and marketplace credentials. The token
|
|
499
|
+
stays in the agent process and is never logged or returned to the session.
|
|
500
|
+
- When `STUDIO_EXPORT_AWS_CREDENTIALS` is enabled (Settings → Studio), the
|
|
501
|
+
backend mints short-lived STS credentials for the workspace storage and the
|
|
502
|
+
agent starts a host-side S3 signing proxy. The session receives only
|
|
503
|
+
`AWS_ACCESS_KEY_ID=proxy`, `AWS_SECRET_ACCESS_KEY=proxy`, and
|
|
504
|
+
`AWS_ENDPOINT_URL=<host proxy>`; the real STS never enters the container.
|
|
505
|
+
Only credentials carrying a session token are proxied; long-lived provider
|
|
506
|
+
keys are never exported by this path.
|
|
507
|
+
- The old implicit `/home/omicslab` bind is gone (opt-in via
|
|
508
|
+
`runtime.home_mount` only): user files stay in the container's writable layer
|
|
509
|
+
and are therefore included in `docker commit` snapshots.
|
|
510
|
+
- Bind mounts and tmpfs are never committed, so neither the SDK nor the token
|
|
511
|
+
can leak into a snapshot.
|
|
512
|
+
|
|
466
513
|
The SDK bootstrap URL is configurable:
|
|
467
514
|
|
|
468
515
|
- Env var `SDK_TOOLS_BASE` (backend `settings.py`), default
|
|
@@ -24,7 +24,7 @@ omicslab use
|
|
|
24
24
|
omicslab ls && omicslab upload ./data.csv
|
|
25
25
|
|
|
26
26
|
# 4. Launch a job
|
|
27
|
-
omicslab
|
|
27
|
+
omicslab ws analysis launch --workspace <ws_id> --analysis <id> --compute <id>
|
|
28
28
|
```
|
|
29
29
|
|
|
30
30
|
## Storage Commands
|
|
@@ -66,7 +66,7 @@ omicslab upload -y ./data.csv s3://my-bucket/data/
|
|
|
66
66
|
|
|
67
67
|
### Download
|
|
68
68
|
|
|
69
|
-
Download files or directories from workspace storage.
|
|
69
|
+
Download files or directories from workspace storage. A folder is detected automatically, so a trailing slash and `-r` are optional; recursive downloads preserve subdirectory structure and use parallel threads.
|
|
70
70
|
|
|
71
71
|
```bash
|
|
72
72
|
# Single file (defaults to filename in current directory)
|
|
@@ -78,7 +78,13 @@ omicslab download results/run1/data.csv ./local_copy.csv
|
|
|
78
78
|
# Download from s3:// path (auto-resolves workspace)
|
|
79
79
|
omicslab download s3://my-bucket/data/results/report.html
|
|
80
80
|
|
|
81
|
-
#
|
|
81
|
+
# Folder download (auto-detected; defaults to ./run1)
|
|
82
|
+
omicslab download results/run1
|
|
83
|
+
|
|
84
|
+
# Folder download with explicit destination
|
|
85
|
+
omicslab download results/run1 ./local_results/
|
|
86
|
+
|
|
87
|
+
# Same thing, explicit recursive (preserves subdirectory structure)
|
|
82
88
|
omicslab download -r s3://my-bucket/data/results/ ./local_results/
|
|
83
89
|
|
|
84
90
|
# Recursive download with custom thread count
|
|
@@ -92,12 +98,13 @@ omicslab download -y -r s3://my-bucket/data/results/ ./local_results/
|
|
|
92
98
|
|
|
93
99
|
| Flag | Description |
|
|
94
100
|
|------|-------------|
|
|
95
|
-
| `--recursive`, `-r` |
|
|
101
|
+
| `--recursive`, `-r` | Force recursive download (folders are detected automatically) |
|
|
102
|
+
| `--zip` | Download a folder as a server-built zip archive |
|
|
96
103
|
| `--threads` | Concurrent download threads (default: 4) |
|
|
97
104
|
| `--workspace-id`, `-w` | Workspace ID (inferred from s3:// path if provided) |
|
|
98
105
|
| `--yes`, `-y` | Skip confirmation prompt for s3:// path resolution |
|
|
99
106
|
|
|
100
|
-
**Recursive download** lists all objects under the prefix, then downloads them in parallel while preserving the original subdirectory structure under the destination directory.
|
|
107
|
+
**Recursive download** lists all objects under the prefix, then downloads them in parallel while preserving the original subdirectory structure under the destination directory. A folder key without a destination downloads into a local directory named after its last segment (`omicslab download snapshots` → `./snapshots/`). An exact object always wins over a same-named folder prefix, and a key that matches neither reports "File or folder not found" instead of a provider 403/404.
|
|
101
108
|
|
|
102
109
|
## Environment Variables
|
|
103
110
|
|
|
@@ -105,6 +112,7 @@ omicslab download -y -r s3://my-bucket/data/results/ ./local_results/
|
|
|
105
112
|
|---|---|
|
|
106
113
|
| `OMICSLAB_TOKEN` | API token (overrides config file) |
|
|
107
114
|
| `OMICSLAB_BASE_URL` | API base URL (default: `https://platform.omicslab.io/api`) |
|
|
115
|
+
| `OMICSLAB_CONFIG_DIR` | Directory holding `config.json` (default: `~/.omicslab`); used by studio sessions to read a container-private config without changing `$HOME` |
|
|
108
116
|
|
|
109
117
|
## Jobs API
|
|
110
118
|
|
|
@@ -112,12 +120,13 @@ Analysis and studio jobs share one backend namespace:
|
|
|
112
120
|
`/workspaces/{workspace_id}/jobs/...` — `job_type` ("analysis" | "studio")
|
|
113
121
|
is part of the launch payload / list filter, not a separate URL space.
|
|
114
122
|
`client.jobs` exposes generic methods (`launch`, `list_jobs`, `get_job`,
|
|
115
|
-
`get_job_manifest`, `
|
|
116
|
-
`disconnect_job`, `check_job_ready`, ...) plus the domain
|
|
117
|
-
(`launch_analysis`, `launch_studio`, `
|
|
118
|
-
for convenience. Job
|
|
119
|
-
launch config live in the
|
|
120
|
-
|
|
123
|
+
`get_job_manifest`, `rerun_job`, `terminate_job`, `delete_job`,
|
|
124
|
+
`connect_job`, `disconnect_job`, `check_job_ready`, ...) plus the domain
|
|
125
|
+
aliases (`launch_analysis`, `launch_studio`, `rerun_analysis`,
|
|
126
|
+
`rerun_studio`, `list_analysis`, `list_studio`, ...) for convenience. Job
|
|
127
|
+
resources (`time`/`cpu`/`memory`/`disk`) and the launch config live in the
|
|
128
|
+
per-job manifest on S3, retrievable via `get_job_manifest` /
|
|
129
|
+
`get_job_audit_params` / `get_job_audit_config`.
|
|
121
130
|
|
|
122
131
|
```python
|
|
123
132
|
from omicslab import Client
|
|
@@ -144,6 +153,52 @@ studio = client.jobs.launch_studio(
|
|
|
144
153
|
compute_id="cloud-comp-...",
|
|
145
154
|
)
|
|
146
155
|
access = client.jobs.connect_job("ws-...", studio["id"]) # {"access_url": ...}
|
|
156
|
+
|
|
157
|
+
# Rerun a job from its resolved launch manifest. Only the overrides below
|
|
158
|
+
# change; tool/tag/pinned commit/versioning/config stay fixed to the source.
|
|
159
|
+
rerun = client.jobs.rerun_analysis(
|
|
160
|
+
"ws-...",
|
|
161
|
+
job_id,
|
|
162
|
+
cpu=8,
|
|
163
|
+
time=7200,
|
|
164
|
+
params={"max_cpus": 8}, # merged per top-level key
|
|
165
|
+
)
|
|
166
|
+
# Masked secret params must be supplied again, otherwise the API returns 400.
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
The same rerun is available for studio sessions via
|
|
170
|
+
`client.jobs.rerun_studio(...)`; the generic `client.jobs.rerun_job(...)`
|
|
171
|
+
works for both. The response carries `old_job_id` and
|
|
172
|
+
`changed_parameters` so callers can show what changed.
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
# Estimate the max cost before launching
|
|
176
|
+
estimate = client.jobs.estimate_job(
|
|
177
|
+
"ws-...",
|
|
178
|
+
compute_type="cloud",
|
|
179
|
+
compute_id="cloud-comp-...",
|
|
180
|
+
time=7200,
|
|
181
|
+
cpu=8,
|
|
182
|
+
memory=32,
|
|
183
|
+
disk=100,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
# Status counts + per-period launch trend (workspace or organization scope)
|
|
187
|
+
counts = client.jobs.get_job_counts("ws-...", job_type="analysis", granularity="daily")
|
|
188
|
+
org_counts = client.orgs.get_job_counts("org-...", job_type="analysis")
|
|
189
|
+
|
|
190
|
+
# Snapshot a RUNNING studio session into workspace storage
|
|
191
|
+
client.jobs.snapshot_studio("ws-...", studio["id"], outdir="snapshots")
|
|
192
|
+
|
|
193
|
+
# Server-side search/filter/sort (exact statuses need filter_column="status")
|
|
194
|
+
client.jobs.list_analysis(
|
|
195
|
+
"ws-...",
|
|
196
|
+
search="bacass",
|
|
197
|
+
filter_column="status",
|
|
198
|
+
filter_value="running,failed",
|
|
199
|
+
sort_column="created_at",
|
|
200
|
+
sort_desc=True,
|
|
201
|
+
)
|
|
147
202
|
```
|
|
148
203
|
|
|
149
204
|
## Testing
|
|
@@ -37,13 +37,32 @@ PIXI_LOCK="${PIXI_LOCK:-pixi.lock}"
|
|
|
37
37
|
PIXI_VERSION="${PIXI_VERSION:-v0.76.1}"
|
|
38
38
|
RETRIES=3
|
|
39
39
|
|
|
40
|
+
# Download helper. Every fetch is bounded and retried so a stalled connection
|
|
41
|
+
# fails in minutes with a clear message instead of hanging until the platform's
|
|
42
|
+
# SSH bootstrap deadline (900s) kills the job with no diagnostics. The bucket
|
|
43
|
+
# sits behind a single gateway that also serves the conda/pypi payload, so
|
|
44
|
+
# transient 5xx/timeouts must be retried rather than abort the whole bootstrap.
|
|
45
|
+
FETCH_CONNECT_TIMEOUT="${FETCH_CONNECT_TIMEOUT:-10}"
|
|
46
|
+
FETCH_MAX_TIME="${FETCH_MAX_TIME:-300}"
|
|
47
|
+
FETCH_RETRIES="${FETCH_RETRIES:-5}"
|
|
48
|
+
fetch() {
|
|
49
|
+
local url="$1" out="$2"
|
|
50
|
+
curl -fsSL \
|
|
51
|
+
--connect-timeout "${FETCH_CONNECT_TIMEOUT}" \
|
|
52
|
+
--max-time "${FETCH_MAX_TIME}" \
|
|
53
|
+
--retry "${FETCH_RETRIES}" \
|
|
54
|
+
--retry-delay 5 \
|
|
55
|
+
--retry-connrefused \
|
|
56
|
+
"$url" -o "$out"
|
|
57
|
+
}
|
|
58
|
+
|
|
40
59
|
# ── 1. pixi (from the tools bucket, absolute path under $HOME) ────────────
|
|
41
60
|
PIXI_BIN="${HOME}/.pixi/bin/pixi"
|
|
42
61
|
if [ ! -x "${PIXI_BIN}" ]; then
|
|
43
62
|
PIXI_URL="$TOOLS_BASE/pixi/bin/pixi-${PIXI_VERSION}-linux-amd64.tar.gz"
|
|
44
63
|
echo "Installing pixi ${PIXI_VERSION} from: $PIXI_URL"
|
|
45
64
|
mkdir -p "$(dirname "${PIXI_BIN}")"
|
|
46
|
-
|
|
65
|
+
fetch "$PIXI_URL" /tmp/pixi.tar.gz
|
|
47
66
|
tar -xzf /tmp/pixi.tar.gz -C "$(dirname "${PIXI_BIN}")"
|
|
48
67
|
rm -f /tmp/pixi.tar.gz
|
|
49
68
|
else
|
|
@@ -136,23 +155,32 @@ echo "Fetching manifest:"
|
|
|
136
155
|
echo " TOML: $TOML_URL"
|
|
137
156
|
echo " LOCK: $LOCK_URL"
|
|
138
157
|
mkdir -p "$OMICSLAB_HOME"
|
|
139
|
-
|
|
140
|
-
|
|
158
|
+
fetch "$TOML_URL" "$OMICSLAB_HOME/pixi.toml"
|
|
159
|
+
fetch "$LOCK_URL" "$OMICSLAB_HOME/pixi.lock"
|
|
141
160
|
|
|
142
161
|
# ── 3. pixi install + SDK wheel (with retries) ────────────────────────────
|
|
143
162
|
cd "$OMICSLAB_HOME"
|
|
163
|
+
env_ready=0
|
|
144
164
|
for i in $(seq 1 $RETRIES); do
|
|
145
165
|
if "${PIXI_BIN}" install --frozen; then
|
|
166
|
+
env_ready=1
|
|
146
167
|
break
|
|
147
168
|
fi
|
|
148
169
|
echo "bootstrap-retry-$i"
|
|
149
170
|
sleep 5
|
|
150
171
|
done
|
|
172
|
+
# `set -e` does not fire for a command used as an `if` condition, so without an
|
|
173
|
+
# explicit check the script would carry on with a half-installed environment
|
|
174
|
+
# and fail later at the wheel step with a misleading message.
|
|
175
|
+
if [ "$env_ready" -ne 1 ]; then
|
|
176
|
+
echo "ERROR: pixi install --frozen failed after ${RETRIES} attempts" >&2
|
|
177
|
+
exit 1
|
|
178
|
+
fi
|
|
151
179
|
|
|
152
180
|
# Download the stable alias and resolve the real version from wheel
|
|
153
181
|
# metadata so pip gets a valid PEP 427 filename.
|
|
154
182
|
echo "Installing omicslab SDK wheel..."
|
|
155
|
-
|
|
183
|
+
fetch "$TOOLS_BASE/omicslab-latest.whl" /tmp/omicslab.whl
|
|
156
184
|
VERSION=$("${PIXI_BIN}" run python3 -c "
|
|
157
185
|
import zipfile, os
|
|
158
186
|
z = zipfile.ZipFile('/tmp/omicslab.whl')
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "2.6.0"
|
|
@@ -265,13 +265,13 @@ def pwd():
|
|
|
265
265
|
parts = []
|
|
266
266
|
if org:
|
|
267
267
|
org_name = config.get_org_name() or org[:8]
|
|
268
|
-
parts.append(f"[
|
|
268
|
+
parts.append(f"[bold]{org_name}[/bold]")
|
|
269
269
|
if ws:
|
|
270
270
|
ws_name = config.get_workspace_name() or ws[:8]
|
|
271
|
-
parts.append(f"[
|
|
271
|
+
parts.append(f"[bold]{ws_name}[/bold]")
|
|
272
272
|
if stored_prefix is not None:
|
|
273
273
|
p = "/" if not stored_prefix else stored_prefix.rstrip("/")
|
|
274
|
-
parts.append(f"[
|
|
274
|
+
parts.append(f"[bold]{p}[/bold]")
|
|
275
275
|
|
|
276
276
|
if parts:
|
|
277
277
|
console.print(" ".join(parts))
|
|
@@ -563,6 +563,11 @@ def upload(
|
|
|
563
563
|
error(f"Cannot use --recursive with a file: {source}")
|
|
564
564
|
raise typer.Exit(1)
|
|
565
565
|
|
|
566
|
+
# Studio/sandbox sessions export the job id; use it when the caller did
|
|
567
|
+
# not pass --job-id so uploads are attributed to the running job.
|
|
568
|
+
if job_id is None:
|
|
569
|
+
job_id = os.environ.get("OMICSLAB_JOB_ID") or os.environ.get("JOB_ID") or None
|
|
570
|
+
|
|
566
571
|
config = Config()
|
|
567
572
|
client = get_client()
|
|
568
573
|
|
|
@@ -819,19 +824,31 @@ def upload(
|
|
|
819
824
|
_BATCH_SIZE = 500
|
|
820
825
|
|
|
821
826
|
def _get_upload_urls_batch(batch: list[tuple[str, str]]) -> dict[str, str]:
|
|
822
|
-
"""Get presigned URLs for a batch of files.
|
|
827
|
+
"""Get presigned URLs for a batch of files.
|
|
828
|
+
|
|
829
|
+
Keys are sent relative to the folder ``prefix`` so the upload is
|
|
830
|
+
audited against the folder, with the files listed as affected keys.
|
|
831
|
+
Presigned responses echo those relative keys, so map them back to the
|
|
832
|
+
full remote key used by the uploader.
|
|
833
|
+
"""
|
|
823
834
|
files_payload = []
|
|
824
835
|
for local_path, remote_key in batch:
|
|
825
836
|
try:
|
|
826
837
|
sz = os.path.getsize(local_path)
|
|
827
838
|
except OSError:
|
|
828
839
|
sz = 0
|
|
829
|
-
files_payload.append(
|
|
840
|
+
files_payload.append(
|
|
841
|
+
{"key": remote_key.removeprefix(remote_prefix), "size": sz}
|
|
842
|
+
)
|
|
830
843
|
try:
|
|
831
|
-
result = client.storage.batch_upload(
|
|
844
|
+
result = client.storage.batch_upload(
|
|
845
|
+
ws, remote_prefix, files_payload, job_id=job_id
|
|
846
|
+
)
|
|
832
847
|
url_list = result.get("urls", []) if isinstance(result, dict) else result
|
|
833
848
|
return {
|
|
834
|
-
entry[
|
|
849
|
+
f"{remote_prefix}{entry['key']}": entry["url"]
|
|
850
|
+
for entry in url_list
|
|
851
|
+
if entry.get("url")
|
|
835
852
|
}
|
|
836
853
|
except Exception:
|
|
837
854
|
return {}
|
|
@@ -966,10 +983,15 @@ def _download_folder_as_zip(
|
|
|
966
983
|
@app.command()
|
|
967
984
|
def download(
|
|
968
985
|
key: str = typer.Argument(
|
|
969
|
-
...,
|
|
986
|
+
...,
|
|
987
|
+
help=(
|
|
988
|
+
"Remote file or folder key to download, "
|
|
989
|
+
"or s3://bucket/key or s3://bucket/dir/"
|
|
990
|
+
),
|
|
970
991
|
),
|
|
971
992
|
destination: str | None = typer.Argument(
|
|
972
|
-
None,
|
|
993
|
+
None,
|
|
994
|
+
help="Local path (defaults to the filename, or the folder name for a folder)",
|
|
973
995
|
),
|
|
974
996
|
workspace_id: str | None = typer.Option(
|
|
975
997
|
None,
|
|
@@ -981,7 +1003,7 @@ def download(
|
|
|
981
1003
|
False,
|
|
982
1004
|
"--recursive",
|
|
983
1005
|
"-r",
|
|
984
|
-
help="
|
|
1006
|
+
help="Force recursive download (folders are detected automatically)",
|
|
985
1007
|
),
|
|
986
1008
|
zip_archive: bool = typer.Option(
|
|
987
1009
|
False,
|
|
@@ -998,7 +1020,11 @@ def download(
|
|
|
998
1020
|
"""Download a file or directory from workspace storage.
|
|
999
1021
|
|
|
1000
1022
|
Accepts an s3://bucket/key to resolve the workspace automatically.
|
|
1001
|
-
|
|
1023
|
+
|
|
1024
|
+
Folders are detected automatically: ``omicslab download snapshots`` and
|
|
1025
|
+
``omicslab download snapshots/ ./snapshots -r`` do the same thing. A
|
|
1026
|
+
folder downloads into a local directory named after its last key segment
|
|
1027
|
+
unless a destination is given.
|
|
1002
1028
|
"""
|
|
1003
1029
|
config = Config()
|
|
1004
1030
|
client = get_client()
|
|
@@ -1012,8 +1038,6 @@ def download(
|
|
|
1012
1038
|
if s3_info:
|
|
1013
1039
|
ws = s3_info["workspace_id"]
|
|
1014
1040
|
full_key = s3_info["key_prefix"]
|
|
1015
|
-
if recursive:
|
|
1016
|
-
full_key = full_key.rstrip("/") + "/" if full_key else ""
|
|
1017
1041
|
print_panel(
|
|
1018
1042
|
"S3 Path Resolution",
|
|
1019
1043
|
{
|
|
@@ -1029,6 +1053,45 @@ def download(
|
|
|
1029
1053
|
if prefix:
|
|
1030
1054
|
full_key = prefix + key
|
|
1031
1055
|
|
|
1056
|
+
# A trailing slash always means a folder. Without one, ask the listing
|
|
1057
|
+
# whether the key is an object or a folder instead of blindly presigning
|
|
1058
|
+
# it: a folder key would fail with a provider 403/404 and look like a
|
|
1059
|
+
# broken URL.
|
|
1060
|
+
folder = bool(full_key) and full_key.endswith("/")
|
|
1061
|
+
if full_key and not folder:
|
|
1062
|
+
try:
|
|
1063
|
+
listing = client.storage.list(ws, prefix=full_key)
|
|
1064
|
+
except Exception as e:
|
|
1065
|
+
error(f"Failed to inspect {full_key!r}: {e}")
|
|
1066
|
+
raise typer.Exit(1)
|
|
1067
|
+
objects = (
|
|
1068
|
+
listing
|
|
1069
|
+
if isinstance(listing, list)
|
|
1070
|
+
else listing.get("objects", [])
|
|
1071
|
+
if isinstance(listing, dict)
|
|
1072
|
+
else None
|
|
1073
|
+
)
|
|
1074
|
+
# ``objects is None`` only happens with an unexpected client shape;
|
|
1075
|
+
# fall back to the object download rather than guessing.
|
|
1076
|
+
if objects is not None:
|
|
1077
|
+
if any(obj.get("is_file") and not obj.get("name") for obj in objects):
|
|
1078
|
+
folder = False
|
|
1079
|
+
elif any(
|
|
1080
|
+
not obj.get("is_file") and obj.get("name") == "/" for obj in objects
|
|
1081
|
+
):
|
|
1082
|
+
folder = True
|
|
1083
|
+
else:
|
|
1084
|
+
error(f"File or folder not found: {full_key}")
|
|
1085
|
+
raise typer.Exit(1)
|
|
1086
|
+
|
|
1087
|
+
if zip_archive and not folder:
|
|
1088
|
+
error("--zip requires a folder key.")
|
|
1089
|
+
raise typer.Exit(1)
|
|
1090
|
+
|
|
1091
|
+
if folder:
|
|
1092
|
+
full_key = full_key.rstrip("/") + "/"
|
|
1093
|
+
recursive = True
|
|
1094
|
+
|
|
1032
1095
|
# Recursive directory download
|
|
1033
1096
|
if recursive and (full_key.endswith("/") or not full_key):
|
|
1034
1097
|
if zip_archive:
|
|
@@ -1076,7 +1139,11 @@ def download(
|
|
|
1076
1139
|
info("No files found in this path.")
|
|
1077
1140
|
return
|
|
1078
1141
|
|
|
1079
|
-
|
|
1142
|
+
# A folder downloads into a directory named after its last key
|
|
1143
|
+
# segment, so `download snapshots` is equivalent to
|
|
1144
|
+
# `download snapshots/ ./snapshots -r`.
|
|
1145
|
+
folder_name = full_key.rstrip("/").rsplit("/", 1)[-1]
|
|
1146
|
+
dest_dir = Path(destination) if destination else Path(folder_name or ".")
|
|
1080
1147
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
1081
1148
|
|
|
1082
1149
|
from rich.progress import (
|