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.
Files changed (162) hide show
  1. {omicslab-2.2.0 → omicslab-2.6.0}/PKG-INFO +68 -12
  2. {omicslab-2.2.0 → omicslab-2.6.0}/README.developer.md +52 -5
  3. {omicslab-2.2.0 → omicslab-2.6.0}/README.md +66 -11
  4. {omicslab-2.2.0 → omicslab-2.6.0}/pyproject.toml +1 -0
  5. {omicslab-2.2.0 → omicslab-2.6.0}/scripts/bootstrap-env.sh +32 -4
  6. omicslab-2.6.0/src/omicslab/_version.py +1 -0
  7. {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/cli/main.py +81 -14
  8. {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/cli/runner_commands.py +17 -17
  9. {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/cli/utils.py +29 -22
  10. {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/cli/ws_commands.py +548 -39
  11. {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/client.py +10 -0
  12. {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/config.py +16 -1
  13. omicslab-2.6.0/src/omicslab/job/_run/_broker.py +235 -0
  14. omicslab-2.6.0/src/omicslab/job/_run/_git_proxy.py +196 -0
  15. omicslab-2.6.0/src/omicslab/job/_run/_s3_proxy.py +242 -0
  16. {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/job/_run/_storage.py +19 -5
  17. {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/job/_run/analysis.py +6 -0
  18. omicslab-2.6.0/src/omicslab/job/_run/studio.py +1176 -0
  19. {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/resources/jobs.py +234 -14
  20. {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/resources/orgs.py +22 -0
  21. {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/resources/storage.py +16 -9
  22. {omicslab-2.2.0 → omicslab-2.6.0}/tests/conftest.py +2 -1
  23. {omicslab-2.2.0 → omicslab-2.6.0}/tests/e2e/conftest.py +6 -7
  24. omicslab-2.6.0/tests/job/_run/test__broker.py +146 -0
  25. {omicslab-2.2.0 → omicslab-2.6.0}/tests/job/_run/test__engine_helpers.py +26 -2
  26. omicslab-2.6.0/tests/job/_run/test__git_proxy.py +105 -0
  27. omicslab-2.6.0/tests/job/_run/test__s3_proxy.py +175 -0
  28. {omicslab-2.2.0 → omicslab-2.6.0}/tests/job/_run/test__storage.py +88 -0
  29. omicslab-2.6.0/tests/job/_run/test__studio_docker.py +349 -0
  30. {omicslab-2.2.0 → omicslab-2.6.0}/tests/test_cli.py +39 -2
  31. {omicslab-2.2.0 → omicslab-2.6.0}/tests/test_cli_storage.py +170 -0
  32. {omicslab-2.2.0 → omicslab-2.6.0}/tests/test_cli_utils.py +42 -0
  33. omicslab-2.6.0/tests/test_client_broker.py +51 -0
  34. omicslab-2.6.0/tests/test_config.py +20 -0
  35. {omicslab-2.2.0 → omicslab-2.6.0}/tests/test_resources.py +238 -2
  36. {omicslab-2.2.0 → omicslab-2.6.0}/tests/test_ws_commands.py +314 -1
  37. omicslab-2.2.0/src/omicslab/_version.py +0 -1
  38. omicslab-2.2.0/src/omicslab/job/_run/studio.py +0 -613
  39. omicslab-2.2.0/tests/job/_run/test__studio_docker.py +0 -107
  40. {omicslab-2.2.0 → omicslab-2.6.0}/.gitignore +0 -0
  41. {omicslab-2.2.0 → omicslab-2.6.0}/Makefile +0 -0
  42. {omicslab-2.2.0 → omicslab-2.6.0}/envs/.gitignore +0 -0
  43. {omicslab-2.2.0 → omicslab-2.6.0}/envs/dev/hcm.pixi.lock +0 -0
  44. {omicslab-2.2.0 → omicslab-2.6.0}/envs/dev/hcm.pixi.toml +0 -0
  45. {omicslab-2.2.0 → omicslab-2.6.0}/envs/dev/pixi.lock +0 -0
  46. {omicslab-2.2.0 → omicslab-2.6.0}/envs/dev/pixi.toml +0 -0
  47. {omicslab-2.2.0 → omicslab-2.6.0}/envs/dev/us-east-1.pixi.lock +0 -0
  48. {omicslab-2.2.0 → omicslab-2.6.0}/envs/dev/us-east-1.pixi.toml +0 -0
  49. {omicslab-2.2.0 → omicslab-2.6.0}/envs/pixi.toml +0 -0
  50. {omicslab-2.2.0 → omicslab-2.6.0}/envs/prod/hcm.pixi.lock +0 -0
  51. {omicslab-2.2.0 → omicslab-2.6.0}/envs/prod/hcm.pixi.toml +0 -0
  52. {omicslab-2.2.0 → omicslab-2.6.0}/envs/prod/pixi.lock +0 -0
  53. {omicslab-2.2.0 → omicslab-2.6.0}/envs/prod/pixi.toml +0 -0
  54. {omicslab-2.2.0 → omicslab-2.6.0}/envs/regions.json +0 -0
  55. {omicslab-2.2.0 → omicslab-2.6.0}/pixi.config.toml +0 -0
  56. {omicslab-2.2.0 → omicslab-2.6.0}/scripts/check_publish.py +0 -0
  57. {omicslab-2.2.0 → omicslab-2.6.0}/scripts/write_version_manifest.py +0 -0
  58. {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/__init__.py +0 -0
  59. {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/cli/__init__.py +0 -0
  60. {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/cli/analysis_commands.py +0 -0
  61. {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/cli/auth_commands.py +0 -0
  62. {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/cli/context_commands.py +0 -0
  63. {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/cli/credential_commands.py +0 -0
  64. {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/cli/fuse_commands.py +0 -0
  65. {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/cli/job_commands.py +0 -0
  66. {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/cli/org_commands.py +0 -0
  67. {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/cli/setup_commands.py +0 -0
  68. {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/cli/studio_commands.py +0 -0
  69. {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/cli/token_commands.py +0 -0
  70. {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/cli/workspace_commands.py +0 -0
  71. {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/exceptions.py +0 -0
  72. {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/job/__init__.py +0 -0
  73. {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/job/_run/__init__.py +0 -0
  74. {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/job/_run/_auth.py +0 -0
  75. {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/job/_run/_config_download.py +0 -0
  76. {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/job/_run/_consts.py +0 -0
  77. {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/job/_run/_data_prep.py +0 -0
  78. {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/job/_run/_env.py +0 -0
  79. {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/job/_run/_exec.py +0 -0
  80. {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/job/_run/_git.py +0 -0
  81. {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/job/_run/_images.py +0 -0
  82. {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/job/_run/_log.py +0 -0
  83. {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/job/_run/_monitor.py +0 -0
  84. {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/job/_run/_nextflow.py +0 -0
  85. {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/job/_run/_params.py +0 -0
  86. {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/job/_run/_proc.py +0 -0
  87. {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/job/_run/_proxy.py +0 -0
  88. {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/job/_run/_spec.py +0 -0
  89. {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/job/_run/_studio_prep.py +0 -0
  90. {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/job/client.py +0 -0
  91. {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/job/console.py +0 -0
  92. {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/job/executor.py +0 -0
  93. {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/job/paths.py +0 -0
  94. {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/job/system.py +0 -0
  95. {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/resources/__init__.py +0 -0
  96. {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/resources/analysis.py +0 -0
  97. {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/resources/base.py +0 -0
  98. {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/resources/credentials.py +0 -0
  99. {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/resources/runners.py +0 -0
  100. {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/resources/studio.py +0 -0
  101. {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/resources/tokens.py +0 -0
  102. {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/resources/workspaces.py +0 -0
  103. {omicslab-2.2.0 → omicslab-2.6.0}/src/omicslab/selfupdate.py +0 -0
  104. {omicslab-2.2.0 → omicslab-2.6.0}/tests/__init__.py +0 -0
  105. {omicslab-2.2.0 → omicslab-2.6.0}/tests/e2e/__init__.py +0 -0
  106. {omicslab-2.2.0 → omicslab-2.6.0}/tests/e2e/test_cli_e2e.py +0 -0
  107. {omicslab-2.2.0 → omicslab-2.6.0}/tests/e2e/test_tokens.py +0 -0
  108. {omicslab-2.2.0 → omicslab-2.6.0}/tests/fixtures/nfcore-demo-custom.config +0 -0
  109. {omicslab-2.2.0 → omicslab-2.6.0}/tests/fixtures/studio_specs/jupyter.json +0 -0
  110. {omicslab-2.2.0 → omicslab-2.6.0}/tests/fixtures/studio_specs/rstudio.json +0 -0
  111. {omicslab-2.2.0 → omicslab-2.6.0}/tests/job/_run/test__auth.py +0 -0
  112. {omicslab-2.2.0 → omicslab-2.6.0}/tests/job/_run/test__data_prep.py +0 -0
  113. {omicslab-2.2.0 → omicslab-2.6.0}/tests/job/_run/test__env.py +0 -0
  114. {omicslab-2.2.0 → omicslab-2.6.0}/tests/job/_run/test__get_images_cli.py +0 -0
  115. {omicslab-2.2.0 → omicslab-2.6.0}/tests/job/_run/test__git.py +0 -0
  116. {omicslab-2.2.0 → omicslab-2.6.0}/tests/job/_run/test__images.py +0 -0
  117. {omicslab-2.2.0 → omicslab-2.6.0}/tests/job/_run/test__log.py +0 -0
  118. {omicslab-2.2.0 → omicslab-2.6.0}/tests/job/_run/test__monitor.py +0 -0
  119. {omicslab-2.2.0 → omicslab-2.6.0}/tests/job/_run/test__nextflow.py +0 -0
  120. {omicslab-2.2.0 → omicslab-2.6.0}/tests/job/_run/test__params.py +0 -0
  121. {omicslab-2.2.0 → omicslab-2.6.0}/tests/job/_run/test__proxy.py +0 -0
  122. {omicslab-2.2.0 → omicslab-2.6.0}/tests/job/_run/test__proxy_extra.py +0 -0
  123. {omicslab-2.2.0 → omicslab-2.6.0}/tests/job/_run/test__spec.py +0 -0
  124. {omicslab-2.2.0 → omicslab-2.6.0}/tests/job/_run/test_studio_proxy_start.py +0 -0
  125. {omicslab-2.2.0 → omicslab-2.6.0}/tests/job/test_job_cli.py +0 -0
  126. {omicslab-2.2.0 → omicslab-2.6.0}/tests/job/test_job_client.py +0 -0
  127. {omicslab-2.2.0 → omicslab-2.6.0}/tests/job/test_job_client_extra.py +0 -0
  128. {omicslab-2.2.0 → omicslab-2.6.0}/tests/job/test_job_executor.py +0 -0
  129. {omicslab-2.2.0 → omicslab-2.6.0}/tests/job/test_job_executor_extra.py +0 -0
  130. {omicslab-2.2.0 → omicslab-2.6.0}/tests/job/test_job_fuse.py +0 -0
  131. {omicslab-2.2.0 → omicslab-2.6.0}/tests/job/test_job_resource.py +0 -0
  132. {omicslab-2.2.0 → omicslab-2.6.0}/tests/job/test_job_system.py +0 -0
  133. {omicslab-2.2.0 → omicslab-2.6.0}/tests/job/test_snapshot_request.py +0 -0
  134. {omicslab-2.2.0 → omicslab-2.6.0}/tests/job/test_studio_image_download.py +0 -0
  135. {omicslab-2.2.0 → omicslab-2.6.0}/tests/job/test_studio_validate_cli.py +0 -0
  136. {omicslab-2.2.0 → omicslab-2.6.0}/tests/job/test_ws_helpers.py +0 -0
  137. {omicslab-2.2.0 → omicslab-2.6.0}/tests/test_cli_catalog.py +0 -0
  138. {omicslab-2.2.0 → omicslab-2.6.0}/tests/test_cli_context.py +0 -0
  139. {omicslab-2.2.0 → omicslab-2.6.0}/tests/test_client.py +0 -0
  140. {omicslab-2.2.0 → omicslab-2.6.0}/tests/test_fuse.py +0 -0
  141. {omicslab-2.2.0 → omicslab-2.6.0}/tests/test_job_exec.py +0 -0
  142. {omicslab-2.2.0 → omicslab-2.6.0}/tests/test_job_heartbeat.py +0 -0
  143. {omicslab-2.2.0 → omicslab-2.6.0}/tests/test_misc.py +0 -0
  144. {omicslab-2.2.0 → omicslab-2.6.0}/tests/test_runner_helpers.py +0 -0
  145. {omicslab-2.2.0 → omicslab-2.6.0}/tests/test_runner_self_update.py +0 -0
  146. {omicslab-2.2.0 → omicslab-2.6.0}/tests/test_sandbox_report.py +0 -0
  147. {omicslab-2.2.0 → omicslab-2.6.0}/tests/test_selfupdate.py +0 -0
  148. {omicslab-2.2.0 → omicslab-2.6.0}/tests/test_studio_snapshot.py +0 -0
  149. {omicslab-2.2.0 → omicslab-2.6.0}/tests/test_system_monitor_interval.py +0 -0
  150. {omicslab-2.2.0 → omicslab-2.6.0}/tests/tools/hello-nextflow/input.txt +0 -0
  151. {omicslab-2.2.0 → omicslab-2.6.0}/tests/tools/hello-nextflow/main.nf +0 -0
  152. {omicslab-2.2.0 → omicslab-2.6.0}/tests/tools/hello-nextflow/omicslab/main.sh +0 -0
  153. {omicslab-2.2.0 → omicslab-2.6.0}/tests/tools/hello-nextflow/pixi.toml +0 -0
  154. {omicslab-2.2.0 → omicslab-2.6.0}/tests/tools/hello-nextflow/schema.json +0 -0
  155. {omicslab-2.2.0 → omicslab-2.6.0}/tests/tools/hello-simple/omicslab/main.sh +0 -0
  156. {omicslab-2.2.0 → omicslab-2.6.0}/tests/tools/hello-simple/pixi.toml +0 -0
  157. {omicslab-2.2.0 → omicslab-2.6.0}/tests/tools/hello-simple/schema.json +0 -0
  158. {omicslab-2.2.0 → omicslab-2.6.0}/tests/tools/hello-snakemake/Snakefile +0 -0
  159. {omicslab-2.2.0 → omicslab-2.6.0}/tests/tools/hello-snakemake/input.txt +0 -0
  160. {omicslab-2.2.0 → omicslab-2.6.0}/tests/tools/hello-snakemake/omicslab/main.sh +0 -0
  161. {omicslab-2.2.0 → omicslab-2.6.0}/tests/tools/hello-snakemake/pixi.toml +0 -0
  162. {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.2.0
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 jobs launch --workspace <ws_id> --analysis <id> --compute <id>
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. Recursive downloads preserve subdirectory structure and use parallel threads.
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
- # Recursive directory download (preserves subdirectory structure)
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` | Download all files under an s3:// directory prefix |
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`, `terminate_job`, `delete_job`, `connect_job`,
143
- `disconnect_job`, `check_job_ready`, ...) plus the domain aliases
144
- (`launch_analysis`, `launch_studio`, `list_analysis`, `list_studio`, ...)
145
- for convenience. Job resources (`time`/`cpu`/`memory`/`disk`) and the
146
- launch config live in the per-job manifest on S3, retrievable via
147
- `get_job_manifest` / `get_job_audit_params` / `get_job_audit_config`.
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
- GET /api/organizations/ → take first (auto-provisioned Sponsor org)
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 auto-provisions a **Sponsor org + Free subscription + workspace**
259
- (`service_auth/utils/provisioning.py`), so no payment step is needed.
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 jobs launch --workspace <ws_id> --analysis <id> --compute <id>
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. Recursive downloads preserve subdirectory structure and use parallel threads.
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
- # Recursive directory download (preserves subdirectory structure)
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` | Download all files under an s3:// directory prefix |
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`, `terminate_job`, `delete_job`, `connect_job`,
116
- `disconnect_job`, `check_job_ready`, ...) plus the domain aliases
117
- (`launch_analysis`, `launch_studio`, `list_analysis`, `list_studio`, ...)
118
- for convenience. Job resources (`time`/`cpu`/`memory`/`disk`) and the
119
- launch config live in the per-job manifest on S3, retrievable via
120
- `get_job_manifest` / `get_job_audit_params` / `get_job_audit_config`.
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
@@ -32,6 +32,7 @@ dependencies = [
32
32
  "websockets>=13",
33
33
  "psutil>=6",
34
34
  "aiohttp>=3.10",
35
+ "botocore>=1.35,<2",
35
36
  ]
36
37
 
37
38
  [project.scripts]
@@ -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
- curl -fsSL "$PIXI_URL" -o /tmp/pixi.tar.gz
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
- curl -fsSL "$TOML_URL" -o "$OMICSLAB_HOME/pixi.toml"
140
- curl -fsSL "$LOCK_URL" -o "$OMICSLAB_HOME/pixi.lock"
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
- curl -fsSL "$TOOLS_BASE/omicslab-latest.whl" -o /tmp/omicslab.whl
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"[cyan]{org_name}[/cyan]")
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"[green]{ws_name}[/green]")
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"[yellow]{p}[/yellow]")
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({"key": remote_key, "size": sz})
840
+ files_payload.append(
841
+ {"key": remote_key.removeprefix(remote_prefix), "size": sz}
842
+ )
830
843
  try:
831
- result = client.storage.batch_upload(ws, "", files_payload)
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["key"]: entry["url"] for entry in url_list if entry.get("url")
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
- ..., help="Remote file key to download, or s3://bucket/key or s3://bucket/dir/"
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, help="Local path (defaults to filename)"
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="Download all files under an s3:// directory prefix",
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
- Use --recursive (-r) to download all files under an s3:// directory prefix.
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
- dest_dir = Path(destination or ".") if destination else Path(".")
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 (