omicslab 2.0.0__tar.gz → 2.1.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 (146) hide show
  1. {omicslab-2.0.0 → omicslab-2.1.0}/Makefile +0 -2
  2. {omicslab-2.0.0 → omicslab-2.1.0}/PKG-INFO +1 -1
  3. {omicslab-2.0.0 → omicslab-2.1.0}/README.developer.md +3 -3
  4. omicslab-2.1.0/src/omicslab/_version.py +1 -0
  5. {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/cli/main.py +15 -7
  6. {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/job/_run/_data_prep.py +20 -12
  7. {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/job/_run/_exec.py +7 -1
  8. {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/job/_run/_git.py +44 -2
  9. {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/job/_run/_nextflow.py +34 -15
  10. {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/job/_run/_storage.py +61 -7
  11. {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/job/_run/analysis.py +14 -10
  12. {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/job/client.py +10 -1
  13. {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/job/executor.py +11 -0
  14. {omicslab-2.0.0 → omicslab-2.1.0}/tests/e2e/conftest.py +17 -8
  15. {omicslab-2.0.0 → omicslab-2.1.0}/tests/job/_run/test__data_prep.py +80 -5
  16. {omicslab-2.0.0 → omicslab-2.1.0}/tests/job/_run/test__engine_helpers.py +19 -0
  17. {omicslab-2.0.0 → omicslab-2.1.0}/tests/job/_run/test__git.py +68 -0
  18. {omicslab-2.0.0 → omicslab-2.1.0}/tests/job/_run/test__nextflow.py +39 -5
  19. {omicslab-2.0.0 → omicslab-2.1.0}/tests/job/_run/test__storage.py +59 -0
  20. {omicslab-2.0.0 → omicslab-2.1.0}/tests/job/test_job_client.py +23 -0
  21. {omicslab-2.0.0 → omicslab-2.1.0}/tests/job/test_job_executor_extra.py +13 -0
  22. {omicslab-2.0.0 → omicslab-2.1.0}/tests/test_cli_storage.py +3 -0
  23. omicslab-2.0.0/src/omicslab/_version.py +0 -1
  24. {omicslab-2.0.0 → omicslab-2.1.0}/.gitignore +0 -0
  25. {omicslab-2.0.0 → omicslab-2.1.0}/README.md +0 -0
  26. {omicslab-2.0.0 → omicslab-2.1.0}/envs/.gitignore +0 -0
  27. {omicslab-2.0.0 → omicslab-2.1.0}/envs/dev/hcm.pixi.lock +0 -0
  28. {omicslab-2.0.0 → omicslab-2.1.0}/envs/dev/hcm.pixi.toml +0 -0
  29. {omicslab-2.0.0 → omicslab-2.1.0}/envs/dev/pixi.lock +0 -0
  30. {omicslab-2.0.0 → omicslab-2.1.0}/envs/dev/pixi.toml +0 -0
  31. {omicslab-2.0.0 → omicslab-2.1.0}/envs/dev/us-east-1.pixi.lock +0 -0
  32. {omicslab-2.0.0 → omicslab-2.1.0}/envs/dev/us-east-1.pixi.toml +0 -0
  33. {omicslab-2.0.0 → omicslab-2.1.0}/envs/pixi.toml +0 -0
  34. {omicslab-2.0.0 → omicslab-2.1.0}/envs/prod/hcm.pixi.lock +0 -0
  35. {omicslab-2.0.0 → omicslab-2.1.0}/envs/prod/hcm.pixi.toml +0 -0
  36. {omicslab-2.0.0 → omicslab-2.1.0}/envs/prod/pixi.lock +0 -0
  37. {omicslab-2.0.0 → omicslab-2.1.0}/envs/prod/pixi.toml +0 -0
  38. {omicslab-2.0.0 → omicslab-2.1.0}/envs/regions.json +0 -0
  39. {omicslab-2.0.0 → omicslab-2.1.0}/pixi.config.toml +0 -0
  40. {omicslab-2.0.0 → omicslab-2.1.0}/pyproject.toml +0 -0
  41. {omicslab-2.0.0 → omicslab-2.1.0}/scripts/bootstrap-env.sh +0 -0
  42. {omicslab-2.0.0 → omicslab-2.1.0}/scripts/check_publish.py +0 -0
  43. {omicslab-2.0.0 → omicslab-2.1.0}/scripts/write_version_manifest.py +0 -0
  44. {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/__init__.py +0 -0
  45. {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/cli/__init__.py +0 -0
  46. {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/cli/analysis_commands.py +0 -0
  47. {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/cli/auth_commands.py +0 -0
  48. {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/cli/context_commands.py +0 -0
  49. {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/cli/credential_commands.py +0 -0
  50. {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/cli/fuse_commands.py +0 -0
  51. {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/cli/job_commands.py +0 -0
  52. {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/cli/org_commands.py +0 -0
  53. {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/cli/runner_commands.py +0 -0
  54. {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/cli/setup_commands.py +0 -0
  55. {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/cli/studio_commands.py +0 -0
  56. {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/cli/token_commands.py +0 -0
  57. {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/cli/utils.py +0 -0
  58. {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/cli/workspace_commands.py +0 -0
  59. {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/cli/ws_commands.py +0 -0
  60. {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/client.py +0 -0
  61. {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/config.py +0 -0
  62. {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/exceptions.py +0 -0
  63. {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/job/__init__.py +0 -0
  64. {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/job/_run/__init__.py +0 -0
  65. {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/job/_run/_auth.py +0 -0
  66. {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/job/_run/_config_download.py +0 -0
  67. {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/job/_run/_consts.py +0 -0
  68. {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/job/_run/_env.py +0 -0
  69. {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/job/_run/_images.py +0 -0
  70. {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/job/_run/_log.py +0 -0
  71. {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/job/_run/_monitor.py +0 -0
  72. {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/job/_run/_params.py +0 -0
  73. {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/job/_run/_proc.py +0 -0
  74. {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/job/_run/_proxy.py +0 -0
  75. {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/job/_run/_spec.py +0 -0
  76. {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/job/_run/_studio_prep.py +0 -0
  77. {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/job/_run/studio.py +0 -0
  78. {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/job/console.py +0 -0
  79. {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/job/paths.py +0 -0
  80. {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/job/system.py +0 -0
  81. {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/resources/__init__.py +0 -0
  82. {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/resources/analysis.py +0 -0
  83. {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/resources/base.py +0 -0
  84. {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/resources/credentials.py +0 -0
  85. {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/resources/jobs.py +0 -0
  86. {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/resources/orgs.py +0 -0
  87. {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/resources/runners.py +0 -0
  88. {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/resources/storage.py +0 -0
  89. {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/resources/studio.py +0 -0
  90. {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/resources/tokens.py +0 -0
  91. {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/resources/workspaces.py +0 -0
  92. {omicslab-2.0.0 → omicslab-2.1.0}/src/omicslab/selfupdate.py +0 -0
  93. {omicslab-2.0.0 → omicslab-2.1.0}/tests/__init__.py +0 -0
  94. {omicslab-2.0.0 → omicslab-2.1.0}/tests/conftest.py +0 -0
  95. {omicslab-2.0.0 → omicslab-2.1.0}/tests/e2e/__init__.py +0 -0
  96. {omicslab-2.0.0 → omicslab-2.1.0}/tests/e2e/test_cli_e2e.py +0 -0
  97. {omicslab-2.0.0 → omicslab-2.1.0}/tests/e2e/test_tokens.py +0 -0
  98. {omicslab-2.0.0 → omicslab-2.1.0}/tests/fixtures/nfcore-demo-custom.config +0 -0
  99. {omicslab-2.0.0 → omicslab-2.1.0}/tests/fixtures/studio_specs/jupyter.json +0 -0
  100. {omicslab-2.0.0 → omicslab-2.1.0}/tests/fixtures/studio_specs/rstudio.json +0 -0
  101. {omicslab-2.0.0 → omicslab-2.1.0}/tests/job/_run/test__auth.py +0 -0
  102. {omicslab-2.0.0 → omicslab-2.1.0}/tests/job/_run/test__env.py +0 -0
  103. {omicslab-2.0.0 → omicslab-2.1.0}/tests/job/_run/test__get_images_cli.py +0 -0
  104. {omicslab-2.0.0 → omicslab-2.1.0}/tests/job/_run/test__images.py +0 -0
  105. {omicslab-2.0.0 → omicslab-2.1.0}/tests/job/_run/test__log.py +0 -0
  106. {omicslab-2.0.0 → omicslab-2.1.0}/tests/job/_run/test__monitor.py +0 -0
  107. {omicslab-2.0.0 → omicslab-2.1.0}/tests/job/_run/test__params.py +0 -0
  108. {omicslab-2.0.0 → omicslab-2.1.0}/tests/job/_run/test__proxy.py +0 -0
  109. {omicslab-2.0.0 → omicslab-2.1.0}/tests/job/_run/test__proxy_extra.py +0 -0
  110. {omicslab-2.0.0 → omicslab-2.1.0}/tests/job/_run/test__spec.py +0 -0
  111. {omicslab-2.0.0 → omicslab-2.1.0}/tests/job/test_job_cli.py +0 -0
  112. {omicslab-2.0.0 → omicslab-2.1.0}/tests/job/test_job_client_extra.py +0 -0
  113. {omicslab-2.0.0 → omicslab-2.1.0}/tests/job/test_job_executor.py +0 -0
  114. {omicslab-2.0.0 → omicslab-2.1.0}/tests/job/test_job_fuse.py +0 -0
  115. {omicslab-2.0.0 → omicslab-2.1.0}/tests/job/test_job_resource.py +0 -0
  116. {omicslab-2.0.0 → omicslab-2.1.0}/tests/job/test_job_system.py +0 -0
  117. {omicslab-2.0.0 → omicslab-2.1.0}/tests/job/test_studio_validate_cli.py +0 -0
  118. {omicslab-2.0.0 → omicslab-2.1.0}/tests/job/test_ws_helpers.py +0 -0
  119. {omicslab-2.0.0 → omicslab-2.1.0}/tests/test_cli.py +0 -0
  120. {omicslab-2.0.0 → omicslab-2.1.0}/tests/test_cli_catalog.py +0 -0
  121. {omicslab-2.0.0 → omicslab-2.1.0}/tests/test_cli_context.py +0 -0
  122. {omicslab-2.0.0 → omicslab-2.1.0}/tests/test_cli_utils.py +0 -0
  123. {omicslab-2.0.0 → omicslab-2.1.0}/tests/test_client.py +0 -0
  124. {omicslab-2.0.0 → omicslab-2.1.0}/tests/test_fuse.py +0 -0
  125. {omicslab-2.0.0 → omicslab-2.1.0}/tests/test_job_exec.py +0 -0
  126. {omicslab-2.0.0 → omicslab-2.1.0}/tests/test_misc.py +0 -0
  127. {omicslab-2.0.0 → omicslab-2.1.0}/tests/test_resources.py +0 -0
  128. {omicslab-2.0.0 → omicslab-2.1.0}/tests/test_runner_helpers.py +0 -0
  129. {omicslab-2.0.0 → omicslab-2.1.0}/tests/test_runner_self_update.py +0 -0
  130. {omicslab-2.0.0 → omicslab-2.1.0}/tests/test_sandbox_report.py +0 -0
  131. {omicslab-2.0.0 → omicslab-2.1.0}/tests/test_selfupdate.py +0 -0
  132. {omicslab-2.0.0 → omicslab-2.1.0}/tests/test_studio_snapshot.py +0 -0
  133. {omicslab-2.0.0 → omicslab-2.1.0}/tests/test_ws_commands.py +0 -0
  134. {omicslab-2.0.0 → omicslab-2.1.0}/tests/tools/hello-nextflow/input.txt +0 -0
  135. {omicslab-2.0.0 → omicslab-2.1.0}/tests/tools/hello-nextflow/main.nf +0 -0
  136. {omicslab-2.0.0 → omicslab-2.1.0}/tests/tools/hello-nextflow/omicslab/main.sh +0 -0
  137. {omicslab-2.0.0 → omicslab-2.1.0}/tests/tools/hello-nextflow/pixi.toml +0 -0
  138. {omicslab-2.0.0 → omicslab-2.1.0}/tests/tools/hello-nextflow/schema.json +0 -0
  139. {omicslab-2.0.0 → omicslab-2.1.0}/tests/tools/hello-simple/omicslab/main.sh +0 -0
  140. {omicslab-2.0.0 → omicslab-2.1.0}/tests/tools/hello-simple/pixi.toml +0 -0
  141. {omicslab-2.0.0 → omicslab-2.1.0}/tests/tools/hello-simple/schema.json +0 -0
  142. {omicslab-2.0.0 → omicslab-2.1.0}/tests/tools/hello-snakemake/Snakefile +0 -0
  143. {omicslab-2.0.0 → omicslab-2.1.0}/tests/tools/hello-snakemake/input.txt +0 -0
  144. {omicslab-2.0.0 → omicslab-2.1.0}/tests/tools/hello-snakemake/omicslab/main.sh +0 -0
  145. {omicslab-2.0.0 → omicslab-2.1.0}/tests/tools/hello-snakemake/pixi.toml +0 -0
  146. {omicslab-2.0.0 → omicslab-2.1.0}/tests/tools/hello-snakemake/schema.json +0 -0
@@ -1,6 +1,4 @@
1
1
  # === Config (all overridable via environment / CLI) ===
2
- ENDPOINT_URL ?= http://localhost:4566
3
- REGION_NAME ?= us-east-1
4
2
 
5
3
  S3_BUCKET_NAME ?= omicslab
6
4
  S3_PREFIX ?= data/tools
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: omicslab
3
- Version: 2.0.0
3
+ Version: 2.1.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
@@ -191,7 +191,7 @@ This:
191
191
  4. Polls `http://localhost:8000/api/v1/health` until healthy.
192
192
 
193
193
  Notes:
194
- - **Storage is S3-compatible (SeaweedFS).** `ENDPOINT_URL=http://localhost:4566`
194
+ - **Storage is S3-compatible (SeaweedFS).** `AWS_ENDPOINT_URL=http://localhost:4566`
195
195
  (from `.env.test`) with credentials `test`/`testtest` (`AWS_ACCESS_KEY_ID` /
196
196
  `AWS_SECRET_ACCESS_KEY`).
197
197
  - `APP_ENV=test` is set so the backend keeps the DB hermetic and uses test env
@@ -308,7 +308,7 @@ CI re-upload the wheel whenever SDK code changes without regenerating locks,
308
308
  and refresh the pixi cache only when `sdk/envs/pixi.toml` changes (see the
309
309
  gated `upload-bootstrap-env` target in the root `Makefile`).
310
310
 
311
- Config overrides (env vars): `ENDPOINT_URL`, `S3_ENDPOINT_URL`, `S3_BUCKET_NAME`, `S3_PREFIX`, `S3_REGION`.
311
+ Config overrides (env vars): `AWS_ENDPOINT_URL`, `AWS_REGION`, `S3_ENDPOINT_URL`, `S3_BUCKET_NAME`, `S3_PREFIX`, `S3_REGION`.
312
312
 
313
313
  ### 8.1 Runner SDK self-update
314
314
 
@@ -489,7 +489,7 @@ Cloud tests are slow (VM boot + pixi install); use generous timeouts.
489
489
  - **Token scope == access.** An org-scoped token can't touch other orgs. Bootstrap
490
490
  fresh users/orgs for isolated scenarios.
491
491
  - **Storage is S3-compatible (SeaweedFS)** at `:4566`. Use `AWS_ACCESS_KEY_ID=test`,
492
- `AWS_SECRET_ACCESS_KEY=testtest`, `ENDPOINT_URL=http://localhost:4566`.
492
+ `AWS_SECRET_ACCESS_KEY=testtest`, `AWS_ENDPOINT_URL=http://localhost:4566`.
493
493
  - **Empty credential listing**: the backend now returns `[]` (via `ok_json_list`)
494
494
  even when no credentials exist; older behavior returned an empty body.
495
495
  - **Single-get routes** for org/workspace/analysis don't exist; verify via lists.
@@ -0,0 +1 @@
1
+ __version__ = "2.1.0"
@@ -670,7 +670,8 @@ def upload(
670
670
  },
671
671
  )
672
672
  success(
673
- f"Uploaded {local.name} → {remote_key} ({file_size / 1024 / 1024:.1f} MB)"
673
+ f"Uploaded {local.name} → {remote_key} "
674
+ f"({file_size / 1024 / 1024:.1f} MB) using presigned URL"
674
675
  )
675
676
  except Exception as e:
676
677
  try:
@@ -759,7 +760,8 @@ def upload(
759
760
 
760
761
  if resp.is_success:
761
762
  success(
762
- f"Uploaded {local.name} → {remote_key} ({file_size / 1024 / 1024:.1f} MB)"
763
+ f"Uploaded {local.name} → {remote_key} "
764
+ f"({file_size / 1024 / 1024:.1f} MB) using presigned URL"
763
765
  )
764
766
  break
765
767
 
@@ -793,6 +795,8 @@ def upload(
793
795
  info("No files found to upload")
794
796
  return
795
797
 
798
+ info(f"Uploading {len(file_list)} files using presigned URLs...")
799
+
796
800
  from rich.progress import (
797
801
  BarColumn,
798
802
  DownloadColumn,
@@ -880,11 +884,11 @@ def upload(
880
884
  if uploaded[0] < len(file_list):
881
885
  fail(
882
886
  f"Upload incomplete: {uploaded[0]}/{len(file_list)} files uploaded "
883
- f"({total_bytes / 1024 / 1024:.1f} MB)"
887
+ f"({total_bytes / 1024 / 1024:.1f} MB) using presigned URLs"
884
888
  )
885
889
  success(
886
890
  f"Uploaded {uploaded[0]}/{len(file_list)} files "
887
- f"({total_bytes / 1024 / 1024:.1f} MB)"
891
+ f"({total_bytes / 1024 / 1024:.1f} MB) using presigned URLs"
888
892
  )
889
893
 
890
894
 
@@ -1014,6 +1018,8 @@ def download(
1014
1018
  info("No files found in this path.")
1015
1019
  return
1016
1020
 
1021
+ info(f"Downloading {len(download_items)} files using presigned URLs...")
1022
+
1017
1023
  downloaded = 0
1018
1024
  failed = 0
1019
1025
  progress = Progress(
@@ -1075,11 +1081,12 @@ def download(
1075
1081
  if failed:
1076
1082
  success(
1077
1083
  f"Downloaded {downloaded}/{len(download_items)} files "
1078
- f"({failed} failed) to {dest_dir}"
1084
+ f"({failed} failed) to {dest_dir} using presigned URLs"
1079
1085
  )
1080
1086
  else:
1081
1087
  success(
1082
- f"Downloaded {downloaded}/{len(download_items)} files to {dest_dir}"
1088
+ f"Downloaded {downloaded}/{len(download_items)} files to {dest_dir} "
1089
+ f"using presigned URLs"
1083
1090
  )
1084
1091
  return
1085
1092
 
@@ -1127,7 +1134,8 @@ def download(
1127
1134
  progress.update(task, advance=len(chunk))
1128
1135
 
1129
1136
  success(
1130
- f"Downloaded {full_key} → {dest} ({dest.stat().st_size / 1024 / 1024:.1f} MB)"
1137
+ f"Downloaded {full_key} → {dest} "
1138
+ f"({dest.stat().st_size / 1024 / 1024:.1f} MB) using presigned URL"
1131
1139
  )
1132
1140
  except typer.Exit:
1133
1141
  raise
@@ -3,15 +3,17 @@
3
3
  Implements the analysis branch of ``omicslab job prepare-data``: scans
4
4
  ``params.json`` for ``s3://`` values (except ``outdir``), resolves each
5
5
  bucket to a workspace via the API, and decides how to make the data
6
- available locally — public buckets are FUSE-mounted, private buckets in
7
- sandbox mode are downloaded, marketplace/cross-workspace buckets are
8
- mounted with source credentials. ``outdir`` is always rewritten to a
6
+ available locally — public buckets are FUSE-mounted and private buckets are
7
+ downloaded via presigned URLs (``storage_mode="sandbox"``, the platform
8
+ default for every job). Clients running under the future
9
+ EXPORT_JOB_IAM_KEYS safeguard may carry real credentials and mount instead.
10
+ ``outdir`` is always rewritten to a
9
11
  local path, preserving the original S3 path for post-job upload.
10
12
 
11
13
  CSV/TSV file params (samplesheets) are additionally localized *inside*:
12
14
  every ``s3://`` cell is rewritten to the local path of that object, and the
13
- param is repointed at the rewritten copy. On mounted jobs (marketplace
14
- compute) the cells point into the mount; on sandbox jobs the referenced
15
+ param is repointed at the rewritten copy. On mounted jobs (credential-export
16
+ safeguard path) the cells point into the mount; on sandbox jobs the referenced
15
17
  objects are downloaded next to the localized file first.
16
18
  """
17
19
 
@@ -78,6 +80,10 @@ def _download_object(
78
80
 
79
81
  Used by sandbox jobs, which carry no S3 credentials; the presigned URL is
80
82
  minted with the job's API token (``OMICSLAB_TOKEN``).
83
+
84
+ ``key_path`` is the raw parsed ``s3://bucket/data/...`` key; the storage
85
+ API re-adds the ``data/`` prefix internally, so it is stripped here (same
86
+ convention as ``resolve_s3_path``) before minting the URL.
81
87
  """
82
88
  api_base = os.environ.get("API_BASE_URL", "")
83
89
  token = os.environ.get("OMICSLAB_TOKEN", "")
@@ -93,7 +99,9 @@ def _download_object(
93
99
 
94
100
  client = Client(token=token, base_url=api_base)
95
101
  try:
96
- result = client.storage.get_download_url(workspace_id, key_path)
102
+ result = client.storage.get_download_url(
103
+ workspace_id, key_path.removeprefix("data/")
104
+ )
97
105
  finally:
98
106
  client.close()
99
107
  url = result.get("url") if isinstance(result, dict) else result
@@ -123,9 +131,9 @@ def prepare_data(params_file: str, job_dir: str) -> None:
123
131
 
124
132
  **Public workspace**: mounts read-only via s3fs ``--public``.
125
133
  **Private workspace + sandbox mode**: downloads via ``omicslab download``.
126
- **Private workspace + real creds (marketplace compute)**: rewrites path;
127
- the workspace bucket is mounted read-only with the job's credentials
128
- (STS tokens or long-lived provider keys).
134
+ **Private workspace + real creds (EXPORT_JOB_IAM_KEYS safeguard path)**:
135
+ rewrites path; the workspace bucket is mounted read-only with the job's
136
+ credentials (STS tokens or long-lived provider keys).
129
137
  **Bucket not found** (e.g. ``s3://igenomes``): keeps S3 path as-is (Nextflow handles it).
130
138
 
131
139
  ``outdir`` is always rewritten to ``{{ job_dir }}/outdir`` (local only).
@@ -340,7 +348,7 @@ def prepare_data(params_file: str, job_dir: str) -> None:
340
348
  else:
341
349
  info(f"Public data already mounted: {public_mount}")
342
350
 
343
- # === Mount cross-workspace private buckets (marketplace, source credentials) ===
351
+ # === Mount cross-workspace private buckets (EXPORT_JOB_IAM_KEYS path) ===
344
352
  for cm in cross_ws_mounts:
345
353
  os.makedirs(cm["mount_point"], exist_ok=True)
346
354
  mount_cmd = [
@@ -396,12 +404,12 @@ def prepare_data(params_file: str, job_dir: str) -> None:
396
404
  # Single object: fetch it to the exact path the param points at.
397
405
  local_path, mode, workspace_id = _local_for(bucket, key_path)
398
406
  if mode == "sandbox" and local_path and not os.path.isfile(local_path):
399
- info(f"Downloading {value} → {local_path}")
407
+ info(f"Downloading {value} → {local_path} using presigned URL")
400
408
  _download_object(local_path, key_path, workspace_id, job_dir)
401
409
  continue
402
410
  target = os.path.join(job_dir, "workspace", "data")
403
411
  os.makedirs(target, exist_ok=True)
404
- info(f"Downloading {value} → {target}")
412
+ info(f"Downloading {value} → {target} using presigned URLs")
405
413
  result = subprocess.run(
406
414
  ["omicslab", "download", "-y", "-r", value, target],
407
415
  capture_output=True,
@@ -46,7 +46,13 @@ def write_omicslab_config(
46
46
  tool_params = params.get("params", {})
47
47
  if not isinstance(tool_params, dict) or not tool_params:
48
48
  tool_params = params
49
- filtered = {k: v for k, v in tool_params.items() if not isinstance(v, (dict, list))}
49
+ # ``None`` is not a valid Groovy config literal (``None is not defined``),
50
+ # so schema-default nulls must be omitted and left to the pipeline defaults.
51
+ filtered = {
52
+ k: v
53
+ for k, v in tool_params.items()
54
+ if v is not None and not isinstance(v, (dict, list))
55
+ }
50
56
 
51
57
  _nextflow_internal_prefixes = (
52
58
  "$projectDir",
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import logging
4
4
  import os
5
+ import shutil
5
6
  import subprocess
6
7
  import time
7
8
  from urllib.parse import urlparse
@@ -55,6 +56,33 @@ def is_nf_pipeline(params: dict) -> bool:
55
56
  return False
56
57
 
57
58
 
59
+ def _is_git_clone(path: str) -> bool:
60
+ """True when ``path`` is a usable git work tree, not a partial clone."""
61
+ if not os.path.isdir(os.path.join(path, ".git")):
62
+ return False
63
+ try:
64
+ result = subprocess.run(
65
+ ["git", "rev-parse", "--verify", "HEAD"],
66
+ capture_output=True,
67
+ text=True,
68
+ timeout=10,
69
+ cwd=path,
70
+ )
71
+ except (OSError, subprocess.SubprocessError):
72
+ return False
73
+ return result.returncode == 0
74
+
75
+
76
+ def _remove_dir(path: str) -> None:
77
+ """Remove ``path`` if it exists (including a symlink, dangling or not)."""
78
+ if not os.path.lexists(path):
79
+ return
80
+ if os.path.islink(path):
81
+ os.remove(path)
82
+ return
83
+ shutil.rmtree(path, ignore_errors=True)
84
+
85
+
58
86
  def clone_repo(
59
87
  git_url: str,
60
88
  tag: str,
@@ -64,7 +92,10 @@ def clone_repo(
64
92
  ) -> str:
65
93
  """Clone a git repository at a specific tag.
66
94
 
67
- If the target directory already exists, skips cloning.
95
+ Idempotent: an existing checkout is reused. A directory left behind by an
96
+ interrupted clone (or a racing job) is cleared before cloning, otherwise
97
+ ``git clone`` fails with "destination path already exists and is not an
98
+ empty directory".
68
99
  Git authentication uses the ``.git-credentials`` file written by
69
100
  ``omicslab job setup-auth`` — no embedded tokens in URLs.
70
101
 
@@ -82,9 +113,11 @@ def clone_repo(
82
113
  Raises:
83
114
  RuntimeError: If git clone fails after all retries.
84
115
  """
85
- if os.path.isdir(target_dir):
116
+ if _is_git_clone(target_dir):
86
117
  print(f"Repository already cloned at {target_dir}")
87
118
  else:
119
+ # Drop a partial/abandoned checkout so the clone has a clean target.
120
+ _remove_dir(target_dir)
88
121
  print(f"Cloning {git_url} into {target_dir} (tag={tag})")
89
122
  last_error = None
90
123
  for attempt in range(1, MAX_RETRIES + 1):
@@ -113,6 +146,15 @@ def clone_repo(
113
146
  last_error = f"git clone timed out after 120s: {git_url}"
114
147
  print(f"Clone attempt {attempt}/{MAX_RETRIES} timed out")
115
148
 
149
+ # A concurrent job may have completed the clone while we retried.
150
+ if _is_git_clone(target_dir):
151
+ print(f"Repository already cloned at {target_dir}")
152
+ last_error = None
153
+ break
154
+
155
+ # Clear the partial checkout so the next attempt starts clean.
156
+ _remove_dir(target_dir)
157
+
116
158
  if attempt < MAX_RETRIES:
117
159
  delay = RETRY_DELAY_BASE * attempt
118
160
  print(f"Retrying in {delay}s...")
@@ -2,6 +2,18 @@ from __future__ import annotations
2
2
 
3
3
  import os
4
4
 
5
+ # RAM reserved for the Nextflow parent controller. With ``executor = "local"``
6
+ # the controller and the pipeline processes share the same instance, so process
7
+ # memory requests must stay below the instance's usable memory or Nextflow
8
+ # refuses to schedule them, e.g.
9
+ # "Process requirement exceeds available memory -- req: 16 GB; avail: 15.3 GB".
10
+ CONTROLLER_MEMORY_RESERVE_GB = 1
11
+
12
+
13
+ def process_memory_gb(base_memory: int) -> int:
14
+ """Process memory request minus the controller reserve, never below 1 GB."""
15
+ return max(int(base_memory) - CONTROLLER_MEMORY_RESERVE_GB, 1)
16
+
5
17
 
6
18
  def write_runtime_config(
7
19
  output_path: str,
@@ -16,9 +28,20 @@ def write_runtime_config(
16
28
  Credentials are NOT written to this file — Nextflow reads them natively
17
29
  from ``AWS_ACCESS_KEY_ID`` / ``AWS_SECRET_ACCESS_KEY`` environment variables.
18
30
 
31
+ ``resourceLimits`` stays at the job ``memory``; each process memory request
32
+ is reduced by ``CONTROLLER_MEMORY_RESERVE_GB`` because the Nextflow parent
33
+ controller runs on the same instance as the pipeline processes
34
+ (``executor = "local"``).
35
+
19
36
  Written with 0o600 permissions to prevent credential leakage.
20
37
  Process labels follow nf-core conventions.
21
38
  """
39
+ mem_default = process_memory_gb(2)
40
+ mem_single = process_memory_gb(2)
41
+ mem_low = process_memory_gb(4)
42
+ mem_medium = process_memory_gb(16)
43
+ mem_high = process_memory_gb(32)
44
+ mem_high_memory = process_memory_gb(200)
22
45
  endpoint = os.environ.get("AWS_ENDPOINT_URL", "")
23
46
  region = os.environ.get("AWS_REGION", "") or os.environ.get("AWS_REGION_NAME", "")
24
47
  has_aws_creds = bool(os.environ.get("AWS_ACCESS_KEY_ID", ""))
@@ -36,15 +59,8 @@ def write_runtime_config(
36
59
 
37
60
  config = f"""\
38
61
  {aws_block}\
39
- singularity.pullTimeout = 8.h
40
-
41
- docker {{
42
- pullTimeout = 30.min
43
- retry {{
44
- enabled = true
45
- maxRetries = 3
46
- delay = 5000
47
- }}
62
+ apptainer {{
63
+ pullTimeout = 8.h
48
64
  }}
49
65
 
50
66
  timeline {{
@@ -69,6 +85,9 @@ dag {{
69
85
  }}
70
86
 
71
87
  process {{
88
+ // Reserve {CONTROLLER_MEMORY_RESERVE_GB}.GB for the Nextflow parent controller
89
+ // sharing this instance (executor = local); resourceLimits stays at the
90
+ // job memory and only the process requests are reduced.
72
91
  resourceLimits = [
73
92
  cpus: {cpu},
74
93
  memory: {memory}.GB,
@@ -76,34 +95,34 @@ process {{
76
95
  ]
77
96
 
78
97
  cpus = {{ 1 * task.attempt }}
79
- memory = {{ 2.GB * task.attempt }}
98
+ memory = {{ {mem_default}.GB * task.attempt }}
80
99
  time = {{ 4.h * task.attempt }}
81
100
 
82
101
  withLabel:process_single {{
83
102
  cpus = {{ 1 }}
84
- memory = {{ 2.GB * task.attempt }}
103
+ memory = {{ {mem_single}.GB * task.attempt }}
85
104
  time = {{ 4.h * task.attempt }}
86
105
  }}
87
106
  withLabel:process_low {{
88
107
  cpus = {{ 2 * task.attempt }}
89
- memory = {{ 4.GB * task.attempt }}
108
+ memory = {{ {mem_low}.GB * task.attempt }}
90
109
  time = {{ 4.h * task.attempt }}
91
110
  }}
92
111
  withLabel:process_medium {{
93
112
  cpus = {{ 8 * task.attempt }}
94
- memory = {{ 16.GB * task.attempt }}
113
+ memory = {{ {mem_medium}.GB * task.attempt }}
95
114
  time = {{ 8.h * task.attempt }}
96
115
  }}
97
116
  withLabel:process_high {{
98
117
  cpus = {{ 16 * task.attempt }}
99
- memory = {{ 32.GB * task.attempt }}
118
+ memory = {{ {mem_high}.GB * task.attempt }}
100
119
  time = {{ 16.h * task.attempt }}
101
120
  }}
102
121
  withLabel:process_long {{
103
122
  time = {{ 20.h * task.attempt }}
104
123
  }}
105
124
  withLabel:process_high_memory {{
106
- memory = {{ 200.GB * task.attempt }}
125
+ memory = {{ {mem_high_memory}.GB * task.attempt }}
107
126
  }}
108
127
  }}
109
128
  """
@@ -25,6 +25,32 @@ def _has_any_aws_creds() -> bool:
25
25
  )
26
26
 
27
27
 
28
+ def _count_files(source_dir: str) -> int:
29
+ """Number of files under ``source_dir`` (0 when unreadable)."""
30
+ try:
31
+ return sum(len(files) for _, _, files in os.walk(source_dir))
32
+ except OSError:
33
+ return 0
34
+
35
+
36
+ def _failure_detail(result) -> str:
37
+ """Best-effort error text from a captured subprocess result.
38
+
39
+ The CLI reports errors on stdout, so stderr alone is often empty (storage
40
+ quota and allocation errors in particular) — fall back to stdout, then to
41
+ the exit code.
42
+ """
43
+ for stream in (getattr(result, "stderr", None), getattr(result, "stdout", None)):
44
+ if isinstance(stream, bytes):
45
+ text = stream.decode("utf-8", errors="replace")
46
+ else:
47
+ text = str(stream or "")
48
+ text = text.strip()
49
+ if text:
50
+ return text[-500:]
51
+ return f"exit code {getattr(result, 'returncode', '?')}"
52
+
53
+
28
54
  def resolve_bucket_mount_info(bucket: str) -> dict | None:
29
55
  """Resolve an S3 bucket to its workspace mount configuration via the API.
30
56
 
@@ -227,14 +253,17 @@ def upload_outputs(
227
253
  user_email: str = "",
228
254
  *,
229
255
  aws_credentials: bool | None = None,
256
+ on_log: callable | None = None,
230
257
  ) -> bool:
231
- """Upload job outputs to S3, auto-detecting marketplace vs sandbox mode.
258
+ """Upload job outputs to S3, auto-detecting credentials vs presigned mode.
232
259
 
233
- **Marketplace mode**: If ``AWS_ACCESS_KEY_ID`` is set, uses
260
+ **Credential path** (``EXPORT_JOB_IAM_KEYS`` safeguard, or a standalone SDK
261
+ run with its own AWS env): if ``AWS_ACCESS_KEY_ID`` is set, uses
234
262
  ``aws s3 sync`` with the endpoint auto-resolved from the S3 path.
235
263
 
236
- **Sandbox mode**: Otherwise, uses ``omicslab upload`` which resolves
237
- the S3 path to a workspace and uploads via presigned URLs.
264
+ **Sandbox mode** (the platform default): otherwise, uses ``omicslab
265
+ upload`` which resolves the S3 path to a workspace and uploads via
266
+ presigned URLs.
238
267
 
239
268
  Args:
240
269
  source_dir: Local directory with output files.
@@ -245,6 +274,9 @@ def upload_outputs(
245
274
  user_email: Optional user email for audit logging.
246
275
  aws_credentials: Explicit decision for direct S3 upload. ``None`` keeps
247
276
  the legacy environment-based detection.
277
+ on_log: Optional callback receiving the transfer-method log lines
278
+ (``using presigned URLs`` / ``using workspace credentials``) so the
279
+ job log shows which path was used.
248
280
 
249
281
  Returns:
250
282
  True if the upload succeeded.
@@ -258,6 +290,16 @@ def upload_outputs(
258
290
  logger.warning("Invalid S3 path: %s", dest_path)
259
291
  return False
260
292
 
293
+ def _emit(message: str) -> None:
294
+ if on_log is None:
295
+ return
296
+ try:
297
+ on_log(message)
298
+ except Exception: # noqa: BLE001 — logging must never break the upload
299
+ pass
300
+
301
+ file_count = _count_files(source_dir)
302
+
261
303
  # Auto-resolve endpoint from workspace if not explicitly provided
262
304
  if not endpoint:
263
305
  endpoint = _resolve_workspace_endpoint(bucket)
@@ -269,6 +311,8 @@ def upload_outputs(
269
311
  aws_credentials = _has_aws_creds()
270
312
 
271
313
  if aws_credentials:
314
+ method = "workspace credentials (aws s3 sync)"
315
+ _emit(f"Uploading {file_count} file(s) using {method}...")
272
316
  try:
273
317
  aws_cmd = ["aws", "s3", "sync", source_dir, s3_target]
274
318
  if endpoint:
@@ -276,12 +320,18 @@ def upload_outputs(
276
320
  result = subprocess.run(aws_cmd, capture_output=True, timeout=300)
277
321
  if result.returncode == 0:
278
322
  logger.info("Synced %s → %s", source_dir, s3_target)
323
+ _emit(f"Uploaded {file_count} file(s) using {method} → {s3_target}")
279
324
  success = True
280
325
  else:
281
- logger.warning("aws s3 sync failed: %s", result.stderr.decode())
326
+ detail = _failure_detail(result)
327
+ logger.warning("aws s3 sync failed: %s", detail)
328
+ _emit(f"Upload failed using {method}: {detail}")
282
329
  except (subprocess.TimeoutExpired, FileNotFoundError):
283
- pass
330
+ logger.warning("aws s3 sync command failed")
331
+ _emit(f"Upload failed using {method}: command unavailable")
284
332
  else:
333
+ method = "presigned URLs"
334
+ _emit(f"Uploading {file_count} file(s) using {method}...")
285
335
  try:
286
336
  result = subprocess.run(
287
337
  ["omicslab", "upload", "-y", source_dir, dest_path],
@@ -290,11 +340,15 @@ def upload_outputs(
290
340
  )
291
341
  if result.returncode == 0:
292
342
  logger.info("Uploaded %s → %s", source_dir, dest_path)
343
+ _emit(f"Uploaded {file_count} file(s) using {method} → {dest_path}")
293
344
  success = True
294
345
  else:
295
- logger.warning("omicslab upload failed: %s", result.stderr.decode())
346
+ detail = _failure_detail(result)
347
+ logger.warning("omicslab upload failed: %s", detail)
348
+ _emit(f"Upload failed using {method}: {detail}")
296
349
  except (subprocess.TimeoutExpired, FileNotFoundError):
297
350
  logger.warning("omicslab upload command failed")
351
+ _emit(f"Upload failed using {method}: command unavailable")
298
352
 
299
353
  if success and job_id:
300
354
  _log_job_output_upload(dest_path, source_dir, job_id, user_id, user_email)
@@ -54,10 +54,12 @@ def _promote_workspace_env(ws_info: dict) -> None:
54
54
  def _workspace_has_creds(params: dict) -> bool:
55
55
  """True when the job carries real workspace S3 credentials.
56
56
 
57
- Marketplace cloud-VM jobs receive STS tokens when the provider exposes an
58
- assume-role ARN, otherwise the marketplace provider's long-lived keys.
59
- Runner/HPC jobs run in sandbox mode and carry no S3 credentials (presigned
60
- URLs only).
57
+ By default the platform never sends S3 keys to compute — every job runs
58
+ with presigned URLs only. Credentials appear only under the future
59
+ EXPORT_JOB_IAM_KEYS safeguard: marketplace cloud-VM jobs may then receive
60
+ STS tokens when the provider exposes an assume-role ARN, otherwise the
61
+ marketplace provider's long-lived keys. Runner/HPC jobs never receive S3
62
+ credentials.
61
63
  """
62
64
  ws_id = params.get("workspace_id", "")
63
65
  workspaces = params.get("workspaces")
@@ -71,12 +73,12 @@ def _workspace_has_creds(params: dict) -> bool:
71
73
  def _aws_upload_enabled(params: dict) -> bool:
72
74
  """Whether outputs may be uploaded over S3 with the job's own credentials.
73
75
 
74
- Only jobs the backend actually gave workspace credentials to (marketplace
75
- cloud-VM compute: STS tokens or the provider's long-lived keys) upload via
76
- ``aws s3 sync``. Sandbox jobs (runners/HPC, personal or organization cloud)
77
- never receive S3 credentials and must use the presigned ``omicslab upload``
78
- path. The legacy ``_has_aws_creds`` check preserves standalone SDK runs that
79
- configure AWS credentials themselves.
76
+ Only jobs the backend explicitly gave workspace credentials to (the
77
+ EXPORT_JOB_IAM_KEYS safeguard path: STS tokens or the provider's
78
+ long-lived keys) upload via ``aws s3 sync``. By default every job is a
79
+ sandbox job and must use the presigned ``omicslab upload`` path. The legacy
80
+ ``_has_aws_creds`` check preserves standalone SDK runs that configure AWS
81
+ credentials themselves.
80
82
  """
81
83
  if _workspace_has_creds(params):
82
84
  return True
@@ -426,6 +428,7 @@ def _run_nextflow(
426
428
  outdir_s3_full,
427
429
  endpoint="",
428
430
  aws_credentials=_aws_upload_enabled(params),
431
+ on_log=_log,
429
432
  )
430
433
  return 0 if ok else 1
431
434
  return 0
@@ -511,6 +514,7 @@ def _run_tool(
511
514
  outdir_s3_full,
512
515
  endpoint="",
513
516
  aws_credentials=aws_credentials,
517
+ on_log=_log,
514
518
  )
515
519
  return 0 if ok else 1
516
520
  return 0
@@ -617,13 +617,22 @@ class RunnerClient:
617
617
  await asyncio.sleep(self.job_poll_interval)
618
618
 
619
619
  def _reap_terminal_jobs(self) -> None:
620
- """Move terminal jobs from ``_dispatched_jobs`` to ``_completed_jobs``."""
620
+ """Move terminal jobs from ``_dispatched_jobs`` to ``_completed_jobs``.
621
+
622
+ Also drops the job from the executor's SLURM tracking map: the
623
+ supervisor detects completion via the ``exitcode`` file and never
624
+ polls ``get_status`` (which is what normally prunes that map), so
625
+ leaving the entry behind made ``is_idle()`` report the runner as busy
626
+ forever and blocked SDK self-updates.
627
+ """
621
628
  for job_id in list(self._dispatched_jobs):
622
629
  if not self.running:
623
630
  break
624
631
  if self._job_is_terminal(job_id):
625
632
  self._dispatched_jobs.pop(job_id, None)
626
633
  self._completed_jobs.add(job_id)
634
+ if self.executor is not None:
635
+ self.executor.forget_slurm_job(job_id)
627
636
 
628
637
  def _job_is_terminal(self, job_id: str) -> bool:
629
638
  """True when a dispatched job has reached a terminal state.
@@ -170,6 +170,17 @@ class JobExecutor:
170
170
  finally:
171
171
  self._slurm_jobs.pop(job_id, None)
172
172
 
173
+ def forget_slurm_job(self, job_id: str) -> None:
174
+ """Drop a finished job from the SLURM tracking map (no scancel).
175
+
176
+ ``get_status`` prunes terminal jobs when it is polled, but the runner
177
+ controller can reap a job from its own dispatch map via the
178
+ ``exitcode`` file without ever calling ``get_status`` — leaving a stale
179
+ entry here that makes ``is_idle()`` report the runner as busy forever
180
+ and blocks SDK self-updates.
181
+ """
182
+ self._slurm_jobs.pop(job_id, None)
183
+
173
184
  # ── Status / log / exit code (filesystem, job run self-manages) ─────
174
185
 
175
186
  def get_status(self, job_id: str) -> dict: