flyteplugins-nextflow 0.0.0a0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- flyteplugins/nextflow/__init__.py +28 -0
- flyteplugins/nextflow/_image.py +70 -0
- flyteplugins/nextflow/_run.py +358 -0
- flyteplugins_nextflow-0.0.0a0.dist-info/METADATA +99 -0
- flyteplugins_nextflow-0.0.0a0.dist-info/RECORD +7 -0
- flyteplugins_nextflow-0.0.0a0.dist-info/WHEEL +5 -0
- flyteplugins_nextflow-0.0.0a0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Run Nextflow pipelines on Flyte.
|
|
3
|
+
|
|
4
|
+
The Nextflow head runs inside a Flyte task, and the `nf-flyte` Nextflow plugin turns every
|
|
5
|
+
Nextflow task into a child action of that task, so a whole pipeline is a single Flyte run.
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
import flyte
|
|
9
|
+
from flyteplugins.nextflow import nextflow_image, run_nextflow
|
|
10
|
+
|
|
11
|
+
env = flyte.TaskEnvironment(name="nextflow", image=nextflow_image())
|
|
12
|
+
|
|
13
|
+
@env.task
|
|
14
|
+
async def demo() -> flyte.io.Dir:
|
|
15
|
+
return await run_nextflow("nf-core/demo", revision="1.2.0", profile="test", outdir="results")
|
|
16
|
+
```
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"DEFAULT_NEXTFLOW_VERSION",
|
|
21
|
+
"DEFAULT_NF_FLYTE_VERSION",
|
|
22
|
+
"NextflowError",
|
|
23
|
+
"nextflow_image",
|
|
24
|
+
"run_nextflow",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
from flyteplugins.nextflow._image import DEFAULT_NEXTFLOW_VERSION, DEFAULT_NF_FLYTE_VERSION, nextflow_image
|
|
28
|
+
from flyteplugins.nextflow._run import NextflowError, run_nextflow
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import flyte
|
|
6
|
+
|
|
7
|
+
# Nextflow 25.10.6 bundles nf-amazon 3.4.5, the version nf-flyte is built against
|
|
8
|
+
DEFAULT_NEXTFLOW_VERSION = "25.10.6"
|
|
9
|
+
DEFAULT_NF_FLYTE_VERSION = "0.1.0"
|
|
10
|
+
|
|
11
|
+
NXF_HOME = "/opt/nxf"
|
|
12
|
+
_PLUGIN_ZIP_DIR = "/opt/nf-flyte"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def nextflow_image(
|
|
16
|
+
*,
|
|
17
|
+
nextflow_version: str = DEFAULT_NEXTFLOW_VERSION,
|
|
18
|
+
nf_flyte: str | Path = DEFAULT_NF_FLYTE_VERSION,
|
|
19
|
+
base: flyte.Image | None = None,
|
|
20
|
+
name: str = "nextflow",
|
|
21
|
+
) -> flyte.Image:
|
|
22
|
+
"""
|
|
23
|
+
Image for the task that runs the Nextflow head: a JRE, Nextflow and the nf-flyte plugin.
|
|
24
|
+
|
|
25
|
+
Pipeline processes don't run in this image; each one runs in its own `container`.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
nextflow_version: Nextflow release to install.
|
|
29
|
+
nf_flyte: nf-flyte plugin to install, either a version from the Nextflow plugin
|
|
30
|
+
registry or the path to a locally built plugin zip (`nf-flyte-<version>.zip`).
|
|
31
|
+
base: Image to add Nextflow to (default: the Flyte debian base image).
|
|
32
|
+
name: Image name.
|
|
33
|
+
"""
|
|
34
|
+
image = base or flyte.Image.from_debian_base(name=name)
|
|
35
|
+
image = image.with_apt_packages("openjdk-17-jre-headless", "curl", "unzip").with_env_vars(
|
|
36
|
+
{"NXF_HOME": NXF_HOME, "NXF_VER": nextflow_version, "NXF_ANSI_LOG": "false"}
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
if isinstance(nf_flyte, Path):
|
|
40
|
+
version = _plugin_version_from_zip(nf_flyte)
|
|
41
|
+
image = image.with_source_file(nf_flyte, f"{_PLUGIN_ZIP_DIR}/")
|
|
42
|
+
install_plugin = [
|
|
43
|
+
f"mkdir -p {NXF_HOME}/plugins/nf-flyte-{version}",
|
|
44
|
+
f"unzip -q {_PLUGIN_ZIP_DIR}/{nf_flyte.name} -d {NXF_HOME}/plugins/nf-flyte-{version}",
|
|
45
|
+
]
|
|
46
|
+
else:
|
|
47
|
+
version = nf_flyte
|
|
48
|
+
install_plugin = [f"nextflow plugin install nf-flyte@{version}"]
|
|
49
|
+
|
|
50
|
+
return image.with_env_vars({"NF_FLYTE_VERSION": version}).with_commands(
|
|
51
|
+
[
|
|
52
|
+
"curl -fsSL https://get.nextflow.io | bash && mv nextflow /usr/local/bin/nextflow",
|
|
53
|
+
# Download the Nextflow runtime and plugins at build time, not on every run. Without a
|
|
54
|
+
# version, `plugin install` picks the release bundled with this Nextflow version (e.g.
|
|
55
|
+
# nf-amazon 3.4.5 on 25.10.6, 3.9.2 on 26.04.x), not the newest in the registry.
|
|
56
|
+
# nf-cloudcache keeps resume state in the work dir (see run_nextflow).
|
|
57
|
+
"nextflow info && nextflow plugin install nf-amazon,nf-cloudcache",
|
|
58
|
+
*install_plugin,
|
|
59
|
+
f"chmod -R a+rwX {NXF_HOME}",
|
|
60
|
+
]
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _plugin_version_from_zip(path: Path) -> str:
|
|
65
|
+
prefix = "nf-flyte-"
|
|
66
|
+
if not (path.name.startswith(prefix) and path.suffix == ".zip"):
|
|
67
|
+
raise ValueError(f"Expected an nf-flyte plugin zip named 'nf-flyte-<version>.zip', got '{path.name}'")
|
|
68
|
+
# no existence check: task modules are imported again inside the task pod, where the zip
|
|
69
|
+
# doesn't exist. A missing zip fails the image build instead.
|
|
70
|
+
return path.name[len(prefix) : -len(".zip")]
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import html
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import signal
|
|
9
|
+
import tempfile
|
|
10
|
+
import uuid
|
|
11
|
+
from collections import deque
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Mapping, Sequence
|
|
14
|
+
|
|
15
|
+
from flyteplugins.nextflow._image import DEFAULT_NF_FLYTE_VERSION
|
|
16
|
+
|
|
17
|
+
import flyte
|
|
18
|
+
from flyte.io import Dir
|
|
19
|
+
from flyte.storage import join
|
|
20
|
+
|
|
21
|
+
_LOG_TAIL_LINES = 80
|
|
22
|
+
_READ_CHUNK = 64 * 1024
|
|
23
|
+
# Kubernetes gives a terminating pod 30s by default before SIGKILL
|
|
24
|
+
_SHUTDOWN_GRACE_SECONDS = 20
|
|
25
|
+
|
|
26
|
+
# Nextflow report files rendered into the task's Flyte report, by tab name
|
|
27
|
+
_REPORTS = {"Nextflow report": "report.html", "Timeline": "timeline.html", "DAG": "dag.html"}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class NextflowError(RuntimeError):
|
|
31
|
+
"""Raised when `nextflow run` exits with a non-zero status."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, exit_code: int, output_tail: Sequence[str], log_tail: Sequence[str]):
|
|
34
|
+
self.exit_code = exit_code
|
|
35
|
+
message = [f"nextflow exited with status {exit_code}", *output_tail]
|
|
36
|
+
if log_tail:
|
|
37
|
+
message += ["--- .nextflow.log (tail) ---", *log_tail]
|
|
38
|
+
super().__init__("\n".join(message))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def run_nextflow(
|
|
42
|
+
pipeline: str,
|
|
43
|
+
*,
|
|
44
|
+
revision: str | None = None,
|
|
45
|
+
profile: str | None = None,
|
|
46
|
+
params: Mapping[str, Any] | None = None,
|
|
47
|
+
outdir: str | None = None,
|
|
48
|
+
work_dir: str | None = None,
|
|
49
|
+
config: str | Path | None = None,
|
|
50
|
+
resume: bool = False,
|
|
51
|
+
report: bool = False,
|
|
52
|
+
extra_args: Sequence[str] = (),
|
|
53
|
+
) -> Dir | None:
|
|
54
|
+
"""
|
|
55
|
+
Run a Nextflow pipeline from inside a Flyte task. On a Flyte cluster, every Nextflow task
|
|
56
|
+
runs as a child action of the calling task, through the nf-flyte executor; the calling task
|
|
57
|
+
must use `nextflow_image()`. In a local run, Nextflow uses its own executors instead
|
|
58
|
+
(`local` unless `config` or `profile` selects another, e.g. `docker`).
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
pipeline: Pipeline to run: a repository (e.g. `nf-core/rnaseq`), URL or local path.
|
|
62
|
+
revision: Pipeline revision (git tag, branch or commit).
|
|
63
|
+
profile: Comma separated Nextflow config profiles, e.g. `test`.
|
|
64
|
+
params: Pipeline parameters, passed through a params file.
|
|
65
|
+
outdir: Output directory, passed as `--outdir`. A relative path is placed under this
|
|
66
|
+
task's raw data prefix. The directory is returned as a `Dir`.
|
|
67
|
+
work_dir: Work directory; on a cluster, an object store location Flyte can read.
|
|
68
|
+
Defaults to a `nextflow-work` prefix under this task's raw data prefix, which is shared
|
|
69
|
+
by all attempts of the task, so a retried task resumes where the failed attempt stopped.
|
|
70
|
+
Set a fixed location to resume across runs.
|
|
71
|
+
config: Extra Nextflow config: a path to a config file, or config text.
|
|
72
|
+
resume: Resume a previous run that used the same `work_dir`. Always on when the task
|
|
73
|
+
is being retried.
|
|
74
|
+
report: Render Nextflow's execution report, timeline and DAG into the task's Flyte
|
|
75
|
+
report. The task must be declared with `report=True`.
|
|
76
|
+
extra_args: Extra arguments for `nextflow run`.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
The `outdir` as a `Dir`, or None when no `outdir` is given.
|
|
80
|
+
"""
|
|
81
|
+
tctx = flyte.ctx()
|
|
82
|
+
if tctx is None:
|
|
83
|
+
raise RuntimeError("run_nextflow() must be called from inside a Flyte task")
|
|
84
|
+
remote = tctx.is_in_cluster()
|
|
85
|
+
|
|
86
|
+
storage_root = stable_raw_data_prefix(
|
|
87
|
+
tctx.raw_data_path.path, tctx.task_action.run_name, tctx.task_action.name, tctx.attempt_number
|
|
88
|
+
)
|
|
89
|
+
work_dir = work_dir or join(storage_root, "nextflow-work")
|
|
90
|
+
if outdir is not None and "://" not in outdir and not os.path.isabs(outdir):
|
|
91
|
+
outdir = join(storage_root, outdir)
|
|
92
|
+
if remote and "://" not in work_dir:
|
|
93
|
+
raise ValueError(f"The nf-flyte executor needs an object store work directory Flyte can read, got '{work_dir}'")
|
|
94
|
+
|
|
95
|
+
session_id = session_id_for(work_dir)
|
|
96
|
+
resume = resume or tctx.attempt_number > 0
|
|
97
|
+
|
|
98
|
+
with tempfile.TemporaryDirectory(prefix="nextflow-") as launch_dir:
|
|
99
|
+
launch = Path(launch_dir)
|
|
100
|
+
configs = []
|
|
101
|
+
if remote:
|
|
102
|
+
(launch / "flyte.config").write_text(
|
|
103
|
+
flyte_config(os.environ.get("NF_FLYTE_VERSION", DEFAULT_NF_FLYTE_VERSION))
|
|
104
|
+
)
|
|
105
|
+
configs.append(launch / "flyte.config")
|
|
106
|
+
if config is not None:
|
|
107
|
+
configs.append(_user_config(config, launch))
|
|
108
|
+
|
|
109
|
+
params_file = None
|
|
110
|
+
if params:
|
|
111
|
+
params_file = launch / "params.json"
|
|
112
|
+
params_file.write_text(json.dumps(dict(params)))
|
|
113
|
+
|
|
114
|
+
cmd = build_command(
|
|
115
|
+
pipeline,
|
|
116
|
+
work_dir=work_dir,
|
|
117
|
+
configs=configs,
|
|
118
|
+
revision=revision,
|
|
119
|
+
profile=profile,
|
|
120
|
+
params_file=params_file,
|
|
121
|
+
outdir=outdir,
|
|
122
|
+
resume_session=session_id if resume else None,
|
|
123
|
+
run_name=None
|
|
124
|
+
if "-name" in extra_args
|
|
125
|
+
else nextflow_run_name(tctx.task_action.run_name, tctx.task_action.name, tctx.attempt_number),
|
|
126
|
+
reports_dir=launch if report else None,
|
|
127
|
+
extra_args=extra_args,
|
|
128
|
+
)
|
|
129
|
+
env = nextflow_env(
|
|
130
|
+
os.environ,
|
|
131
|
+
session_id=session_id,
|
|
132
|
+
cache_path=join(work_dir, ".nextflow-cache"),
|
|
133
|
+
run_name=tctx.task_action.run_name if remote else None,
|
|
134
|
+
action_name=tctx.task_action.name if remote else None,
|
|
135
|
+
)
|
|
136
|
+
try:
|
|
137
|
+
exit_code, output_tail = await _stream(cmd, cwd=launch, env=env)
|
|
138
|
+
finally:
|
|
139
|
+
if report:
|
|
140
|
+
await _publish_reports(launch)
|
|
141
|
+
if exit_code != 0:
|
|
142
|
+
raise NextflowError(exit_code, output_tail, _tail(launch / ".nextflow.log"))
|
|
143
|
+
|
|
144
|
+
return Dir.from_existing_remote(outdir) if outdir is not None else None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def stable_raw_data_prefix(raw_data_path: str, run_name: str, action_name: str, attempt: int) -> str:
|
|
148
|
+
"""
|
|
149
|
+
The part of the task's raw data path shared by all of its attempts. Flyte gives each attempt
|
|
150
|
+
its own raw data prefix ending in `<run>-<action>-<attempt>`; resuming after a retry needs the
|
|
151
|
+
work dir to stay put, so drop that last segment.
|
|
152
|
+
"""
|
|
153
|
+
path = raw_data_path.rstrip("/")
|
|
154
|
+
parent, _, last = path.rpartition("/")
|
|
155
|
+
if parent and last == f"{run_name}-{action_name}-{attempt}":
|
|
156
|
+
return parent
|
|
157
|
+
return path
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def session_id_for(work_dir: str) -> str:
|
|
161
|
+
"""
|
|
162
|
+
Nextflow session id for a work dir. Resuming needs the id of the previous session, which
|
|
163
|
+
Nextflow normally looks up in `.nextflow/history` under the launch dir; that doesn't survive
|
|
164
|
+
the task pod, so derive the id from the work dir instead.
|
|
165
|
+
"""
|
|
166
|
+
return str(uuid.uuid5(uuid.NAMESPACE_URL, work_dir.rstrip("/")))
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def nextflow_run_name(run_name: str, action_name: str, attempt: int) -> str:
|
|
170
|
+
"""
|
|
171
|
+
Nextflow normally picks an unused run name from its history file; with history disabled
|
|
172
|
+
(`NXF_IGNORE_RESUME_HISTORY`), the run name has to be given. It must start with a letter and
|
|
173
|
+
contain only letters, digits, `-` and `_`, at most 80 characters.
|
|
174
|
+
"""
|
|
175
|
+
name = re.sub(r"[^a-z0-9]+", "-", f"flyte-{run_name}-{action_name}-{attempt}".lower()).strip("-")
|
|
176
|
+
return name[:80].rstrip("-")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def flyte_config(nf_flyte_version: str) -> str:
|
|
180
|
+
"""Nextflow config that routes every process to the nf-flyte executor."""
|
|
181
|
+
return f"""\
|
|
182
|
+
plugins {{
|
|
183
|
+
id 'nf-flyte@{nf_flyte_version}'
|
|
184
|
+
}}
|
|
185
|
+
|
|
186
|
+
process.executor = 'flyte'
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def build_command(
|
|
191
|
+
pipeline: str,
|
|
192
|
+
*,
|
|
193
|
+
work_dir: str,
|
|
194
|
+
configs: Sequence[Path],
|
|
195
|
+
revision: str | None = None,
|
|
196
|
+
profile: str | None = None,
|
|
197
|
+
params_file: Path | None = None,
|
|
198
|
+
outdir: str | None = None,
|
|
199
|
+
resume_session: str | None = None,
|
|
200
|
+
run_name: str | None = None,
|
|
201
|
+
reports_dir: Path | None = None,
|
|
202
|
+
extra_args: Sequence[str] = (),
|
|
203
|
+
) -> list[str]:
|
|
204
|
+
cmd = ["nextflow"]
|
|
205
|
+
for c in configs:
|
|
206
|
+
cmd += ["-c", str(c)]
|
|
207
|
+
cmd += ["run", pipeline, "-w", work_dir]
|
|
208
|
+
if revision:
|
|
209
|
+
cmd += ["-r", revision]
|
|
210
|
+
if profile:
|
|
211
|
+
cmd += ["-profile", profile]
|
|
212
|
+
if params_file is not None:
|
|
213
|
+
cmd += ["-params-file", str(params_file)]
|
|
214
|
+
if run_name:
|
|
215
|
+
cmd += ["-name", run_name]
|
|
216
|
+
if resume_session:
|
|
217
|
+
cmd += ["-resume", resume_session]
|
|
218
|
+
if reports_dir is not None:
|
|
219
|
+
cmd += [
|
|
220
|
+
"-with-report", str(reports_dir / _REPORTS["Nextflow report"]),
|
|
221
|
+
"-with-timeline", str(reports_dir / _REPORTS["Timeline"]),
|
|
222
|
+
"-with-dag", str(reports_dir / _REPORTS["DAG"]),
|
|
223
|
+
] # fmt: skip
|
|
224
|
+
cmd += list(extra_args)
|
|
225
|
+
if outdir is not None:
|
|
226
|
+
cmd += ["--outdir", outdir]
|
|
227
|
+
return cmd
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def nextflow_env(
|
|
231
|
+
base: Mapping[str, str],
|
|
232
|
+
*,
|
|
233
|
+
session_id: str,
|
|
234
|
+
cache_path: str,
|
|
235
|
+
run_name: str | None = None,
|
|
236
|
+
action_name: str | None = None,
|
|
237
|
+
) -> dict[str, str]:
|
|
238
|
+
"""
|
|
239
|
+
Environment for the Nextflow head. The session id and cloud cache keep resume state outside
|
|
240
|
+
the launch dir; nf-flyte reads the run and parent action it adds task actions to.
|
|
241
|
+
"""
|
|
242
|
+
env = dict(base)
|
|
243
|
+
env["NXF_UUID"] = session_id
|
|
244
|
+
if "://" in cache_path:
|
|
245
|
+
env["NXF_CLOUDCACHE_PATH"] = cache_path
|
|
246
|
+
else:
|
|
247
|
+
# the cloud cache only takes object store paths; locally, move `.nextflow` instead
|
|
248
|
+
env["NXF_CACHE_DIR"] = cache_path
|
|
249
|
+
# resume by the explicit session id: don't require it in the (per-pod) history file
|
|
250
|
+
env["NXF_IGNORE_RESUME_HISTORY"] = "true"
|
|
251
|
+
if run_name and action_name:
|
|
252
|
+
env["NF_FLYTE_RUN_NAME"] = run_name
|
|
253
|
+
env["NF_FLYTE_PARENT_ACTION"] = action_name
|
|
254
|
+
env.setdefault("NXF_ANSI_LOG", "false")
|
|
255
|
+
return env
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _user_config(config: str | Path, launch: Path) -> Path:
|
|
259
|
+
if isinstance(config, Path):
|
|
260
|
+
return config
|
|
261
|
+
if "\n" not in config and config.endswith(".config") and Path(config).is_file():
|
|
262
|
+
return Path(config)
|
|
263
|
+
path = launch / "user.config"
|
|
264
|
+
path.write_text(config)
|
|
265
|
+
return path
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
async def _stream(cmd: Sequence[str], *, cwd: Path, env: Mapping[str, str]) -> tuple[int, list[str]]:
|
|
269
|
+
print("+", " ".join(cmd), flush=True)
|
|
270
|
+
proc = await asyncio.create_subprocess_exec(
|
|
271
|
+
*cmd,
|
|
272
|
+
cwd=str(cwd),
|
|
273
|
+
env=dict(env),
|
|
274
|
+
stdout=asyncio.subprocess.PIPE,
|
|
275
|
+
stderr=asyncio.subprocess.STDOUT,
|
|
276
|
+
)
|
|
277
|
+
tail: deque[str] = deque(maxlen=_LOG_TAIL_LINES)
|
|
278
|
+
uninstall = _forward_sigterm(proc)
|
|
279
|
+
try:
|
|
280
|
+
await _pump_lines(proc.stdout, tail)
|
|
281
|
+
return await proc.wait(), list(tail)
|
|
282
|
+
except asyncio.CancelledError:
|
|
283
|
+
# keep relaying output while Nextflow shuts down: its abort messages belong in the logs
|
|
284
|
+
await _terminate(proc, _pump_lines(proc.stdout, tail))
|
|
285
|
+
raise
|
|
286
|
+
finally:
|
|
287
|
+
uninstall()
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _forward_sigterm(proc: asyncio.subprocess.Process):
|
|
291
|
+
"""
|
|
292
|
+
Pass SIGTERM on to Nextflow so it can shut down cleanly (abort its task actions, save its
|
|
293
|
+
cache) when the task pod is deleted, e.g. on abort. Without a handler, the Flyte runtime
|
|
294
|
+
process dies on SIGTERM and never reaches the cancellation path.
|
|
295
|
+
"""
|
|
296
|
+
|
|
297
|
+
def forward():
|
|
298
|
+
if proc.returncode is None:
|
|
299
|
+
proc.send_signal(signal.SIGTERM)
|
|
300
|
+
|
|
301
|
+
loop = asyncio.get_running_loop()
|
|
302
|
+
try:
|
|
303
|
+
loop.add_signal_handler(signal.SIGTERM, forward)
|
|
304
|
+
except (NotImplementedError, RuntimeError, ValueError):
|
|
305
|
+
# not the main thread's loop, or not supported on this platform
|
|
306
|
+
return lambda: None
|
|
307
|
+
return lambda: loop.remove_signal_handler(signal.SIGTERM)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
async def _terminate(proc: asyncio.subprocess.Process, drain):
|
|
311
|
+
if proc.returncode is None:
|
|
312
|
+
proc.terminate()
|
|
313
|
+
try:
|
|
314
|
+
await asyncio.wait_for(asyncio.gather(drain, proc.wait()), timeout=_SHUTDOWN_GRACE_SECONDS)
|
|
315
|
+
except asyncio.TimeoutError:
|
|
316
|
+
proc.kill()
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
async def _pump_lines(stream: asyncio.StreamReader | None, tail: deque[str]):
|
|
320
|
+
"""Echo output line by line. Reads in chunks: `readline()` fails on lines over 64 KiB."""
|
|
321
|
+
assert stream is not None
|
|
322
|
+
buf = b""
|
|
323
|
+
while chunk := await stream.read(_READ_CHUNK):
|
|
324
|
+
buf += chunk
|
|
325
|
+
*lines, buf = buf.split(b"\n")
|
|
326
|
+
for raw in lines:
|
|
327
|
+
_emit(raw, tail)
|
|
328
|
+
if buf:
|
|
329
|
+
_emit(buf, tail)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _emit(raw: bytes, tail: deque[str]):
|
|
333
|
+
line = raw.decode(errors="replace").rstrip("\r")
|
|
334
|
+
print(line, flush=True)
|
|
335
|
+
tail.append(line)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
async def _publish_reports(launch: Path):
|
|
339
|
+
import flyte.report
|
|
340
|
+
|
|
341
|
+
published = False
|
|
342
|
+
for tab, name in _REPORTS.items():
|
|
343
|
+
path = launch / name
|
|
344
|
+
if path.exists():
|
|
345
|
+
# the reports are full HTML documents; a tab's content goes inside a <div>
|
|
346
|
+
doc = html.escape(path.read_text(errors="replace"), quote=True)
|
|
347
|
+
flyte.report.get_tab(tab).replace(
|
|
348
|
+
f'<iframe srcdoc="{doc}" style="width:100%;height:85vh;border:0"></iframe>'
|
|
349
|
+
)
|
|
350
|
+
published = True
|
|
351
|
+
if published:
|
|
352
|
+
await flyte.report.flush.aio()
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _tail(path: Path) -> list[str]:
|
|
356
|
+
if not path.exists():
|
|
357
|
+
return []
|
|
358
|
+
return path.read_text(errors="replace").splitlines()[-_LOG_TAIL_LINES:]
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: flyteplugins-nextflow
|
|
3
|
+
Version: 0.0.0a0
|
|
4
|
+
Summary: Run Nextflow pipelines on Flyte, with every Nextflow task as a Flyte action
|
|
5
|
+
Author-email: Kyle Hazen <kyle@union.ai>
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: flyte
|
|
9
|
+
|
|
10
|
+
# flyteplugins-nextflow
|
|
11
|
+
|
|
12
|
+
Run [Nextflow](https://www.nextflow.io/) pipelines on Flyte.
|
|
13
|
+
|
|
14
|
+
The Nextflow head runs inside a Flyte task. The [nf-flyte](https://github.com/unionai/nf-flyte)
|
|
15
|
+
Nextflow plugin runs every Nextflow task as a child action of that task. A pipeline
|
|
16
|
+
therefore shows up in Flyte as one run with one action per Nextflow task:
|
|
17
|
+
|
|
18
|
+
- Each process runs in its own `container`, with its `cpus`, `memory`, `disk`,
|
|
19
|
+
`accelerator` and `time` directives. The image only needs `bash`: Flyte stages the task's files.
|
|
20
|
+
- Nextflow keeps its own retries (`errorStrategy`) and `-resume`; Flyte caches task results across runs.
|
|
21
|
+
- Aborting the Flyte run aborts the pipeline and its running tasks.
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install flyteplugins-nextflow
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Quick start
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
import flyte
|
|
33
|
+
from flyte.io import Dir
|
|
34
|
+
from flyteplugins.nextflow import nextflow_image, run_nextflow
|
|
35
|
+
|
|
36
|
+
env = flyte.TaskEnvironment(name="nextflow", image=nextflow_image())
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@env.task
|
|
40
|
+
async def demo() -> Dir:
|
|
41
|
+
return await run_nextflow("nf-core/demo", revision="1.2.0", profile="test", outdir="results")
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`run_nextflow` accepts:
|
|
45
|
+
|
|
46
|
+
| Argument | Description |
|
|
47
|
+
|--------------|-------------|
|
|
48
|
+
| `pipeline` | Repository (`nf-core/rnaseq`), URL or path to run. |
|
|
49
|
+
| `revision` | Git tag, branch or commit (`-r`). |
|
|
50
|
+
| `profile` | Config profiles (`-profile`). |
|
|
51
|
+
| `params` | Pipeline parameters, passed as a params file. |
|
|
52
|
+
| `outdir` | `--outdir`. A relative path goes under the task's raw data prefix. The directory is returned as a `Dir`. |
|
|
53
|
+
| `work_dir` | Work directory. Default: `nextflow-work` under the task's raw data prefix. |
|
|
54
|
+
| `config` | Extra Nextflow config, as a file path or config text. |
|
|
55
|
+
| `resume` | Resume the previous run that used the same `work_dir`. |
|
|
56
|
+
| `report` | Render Nextflow's execution report, timeline and DAG into the task's Flyte report. The task must be declared with `report=True`. |
|
|
57
|
+
| `extra_args` | Any other `nextflow run` arguments. |
|
|
58
|
+
|
|
59
|
+
If Nextflow fails, `run_nextflow` raises a `NextflowError` containing the end of the
|
|
60
|
+
Nextflow output and the end of `.nextflow.log`.
|
|
61
|
+
|
|
62
|
+
### Resume
|
|
63
|
+
|
|
64
|
+
Nextflow's resume state lives in the work directory, not in the task pod:
|
|
65
|
+
- the task cache (the cloud cache on object stores, `.nextflow` locally)
|
|
66
|
+
- a session ID derived from the work directory
|
|
67
|
+
|
|
68
|
+
This has two effects:
|
|
69
|
+
- **Retries resume automatically.** The default work directory is shared by all attempts of
|
|
70
|
+
a task, so a retried task passes `-resume` and picks up where the failed attempt stopped.
|
|
71
|
+
- **Resuming across runs needs a fixed `work_dir`.** Pass the same `work_dir` to each run,
|
|
72
|
+
and `resume=True` from the second run on:
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
await run_nextflow("nf-core/rnaseq", work_dir="s3://my-bucket/rnaseq-work", resume=True)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Local runs
|
|
79
|
+
|
|
80
|
+
When the task runs locally (`flyte run --local`), Nextflow uses its own executors instead
|
|
81
|
+
of nf-flyte: `local` by default, or whatever `profile` or `config` selects, such as `docker`.
|
|
82
|
+
This needs `nextflow` on the `PATH`.
|
|
83
|
+
|
|
84
|
+
## Image
|
|
85
|
+
|
|
86
|
+
`nextflow_image()` builds the image for the task that runs the Nextflow head. It contains
|
|
87
|
+
a JRE, Nextflow (`nextflow_version`, default `25.10.6`) and the nf-flyte plugin. To use a
|
|
88
|
+
locally built plugin, pass its zip:
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
from pathlib import Path
|
|
92
|
+
|
|
93
|
+
image = nextflow_image(nf_flyte=Path("~/nf-flyte/build/distributions/nf-flyte-0.1.0.zip").expanduser())
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Requirements
|
|
97
|
+
|
|
98
|
+
- Flyte v2. On a cluster, the work directory must be on an object store Flyte can read (S3, GCS or Azure); the default, under the task's raw data prefix, always is.
|
|
99
|
+
- Every process container needs `bash`.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
flyteplugins/nextflow/__init__.py,sha256=4kbhKMYm909CnCw1aCdAQhkRg0FnAA3a49YAwWc6GAA,846
|
|
2
|
+
flyteplugins/nextflow/_image.py,sha256=x96z35fqG11WjGHJxBilucbs6r-fqnUc0LsMbolN4Go,2958
|
|
3
|
+
flyteplugins/nextflow/_run.py,sha256=8l0sFqFwTaYbDjk-oHp-6S4O7RhzXvuhyfmN-PAuugw,13024
|
|
4
|
+
flyteplugins_nextflow-0.0.0a0.dist-info/METADATA,sha256=o8mJbpxrIL10Wl-si36LMy9Eovvhttys9kjqxg4hI8o,3892
|
|
5
|
+
flyteplugins_nextflow-0.0.0a0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
6
|
+
flyteplugins_nextflow-0.0.0a0.dist-info/top_level.txt,sha256=cgd779rPu9EsvdtuYgUxNHHgElaQvPn74KhB5XSeMBE,13
|
|
7
|
+
flyteplugins_nextflow-0.0.0a0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
flyteplugins
|