virtool-workflow 0.0.0__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.
- virtool_workflow/__init__.py +13 -0
- virtool_workflow/analysis/__init__.py +1 -0
- virtool_workflow/analysis/fastqc.py +467 -0
- virtool_workflow/analysis/skewer.py +265 -0
- virtool_workflow/analysis/trimming.py +56 -0
- virtool_workflow/analysis/utils.py +27 -0
- virtool_workflow/api/__init__.py +0 -0
- virtool_workflow/api/acquire.py +66 -0
- virtool_workflow/api/client.py +132 -0
- virtool_workflow/api/utils.py +109 -0
- virtool_workflow/cli.py +66 -0
- virtool_workflow/data/__init__.py +22 -0
- virtool_workflow/data/analyses.py +106 -0
- virtool_workflow/data/hmms.py +109 -0
- virtool_workflow/data/indexes.py +319 -0
- virtool_workflow/data/jobs.py +62 -0
- virtool_workflow/data/ml.py +82 -0
- virtool_workflow/data/samples.py +190 -0
- virtool_workflow/data/subtractions.py +244 -0
- virtool_workflow/data/uploads.py +35 -0
- virtool_workflow/decorators.py +47 -0
- virtool_workflow/errors.py +62 -0
- virtool_workflow/files.py +40 -0
- virtool_workflow/hooks.py +140 -0
- virtool_workflow/pytest_plugin/__init__.py +35 -0
- virtool_workflow/pytest_plugin/data.py +197 -0
- virtool_workflow/pytest_plugin/utils.py +9 -0
- virtool_workflow/runtime/__init__.py +0 -0
- virtool_workflow/runtime/config.py +21 -0
- virtool_workflow/runtime/discover.py +95 -0
- virtool_workflow/runtime/events.py +7 -0
- virtool_workflow/runtime/hook.py +129 -0
- virtool_workflow/runtime/path.py +19 -0
- virtool_workflow/runtime/ping.py +54 -0
- virtool_workflow/runtime/redis.py +65 -0
- virtool_workflow/runtime/run.py +276 -0
- virtool_workflow/runtime/run_subprocess.py +168 -0
- virtool_workflow/runtime/sentry.py +28 -0
- virtool_workflow/utils.py +90 -0
- virtool_workflow/workflow.py +90 -0
- virtool_workflow-0.0.0.dist-info/LICENSE +21 -0
- virtool_workflow-0.0.0.dist-info/METADATA +71 -0
- virtool_workflow-0.0.0.dist-info/RECORD +45 -0
- virtool_workflow-0.0.0.dist-info/WHEEL +4 -0
- virtool_workflow-0.0.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,82 @@
|
|
1
|
+
"""A fixture and dataclass for working with machine learning models in workflows."""
|
2
|
+
|
3
|
+
import asyncio
|
4
|
+
import shutil
|
5
|
+
from dataclasses import dataclass
|
6
|
+
from pathlib import Path
|
7
|
+
|
8
|
+
from pyfixtures import fixture
|
9
|
+
from structlog import get_logger
|
10
|
+
from virtool.ml.models import MLModelRelease
|
11
|
+
|
12
|
+
from virtool_workflow.api.client import APIClient
|
13
|
+
from virtool_workflow.data.analyses import WFAnalysis
|
14
|
+
from virtool_workflow.utils import make_directory, move_all_model_files, untar
|
15
|
+
|
16
|
+
logger = get_logger("api")
|
17
|
+
|
18
|
+
|
19
|
+
@dataclass
|
20
|
+
class WFMLModelRelease:
|
21
|
+
"""A machine learning model.
|
22
|
+
|
23
|
+
This class represents a machine learning model and the selected release of that
|
24
|
+
model in the workflow.
|
25
|
+
"""
|
26
|
+
|
27
|
+
id: int
|
28
|
+
"""The unique ID for the model release."""
|
29
|
+
|
30
|
+
name: str
|
31
|
+
"""The name of the model release."""
|
32
|
+
|
33
|
+
path: Path
|
34
|
+
"""The path to the model directory."""
|
35
|
+
|
36
|
+
@property
|
37
|
+
def file_path(self) -> Path:
|
38
|
+
"""The path to the model data file."""
|
39
|
+
return self.path / "model.tar.gz"
|
40
|
+
|
41
|
+
|
42
|
+
@fixture
|
43
|
+
async def ml(
|
44
|
+
_api: APIClient,
|
45
|
+
analysis: WFAnalysis,
|
46
|
+
work_path: Path,
|
47
|
+
) -> WFMLModelRelease | None:
|
48
|
+
if analysis.ml is None:
|
49
|
+
return None
|
50
|
+
|
51
|
+
model_id = analysis.ml.model.id
|
52
|
+
model_release_id = analysis.ml.id
|
53
|
+
|
54
|
+
log = logger.bind(model_id=analysis.ml.id, model_release_id=model_release_id)
|
55
|
+
|
56
|
+
model_release_json = await _api.get_json(
|
57
|
+
f"/ml/{model_id}/releases/{model_release_id}",
|
58
|
+
)
|
59
|
+
model_release = MLModelRelease(**model_release_json)
|
60
|
+
|
61
|
+
log.info("fetched ml model release json")
|
62
|
+
|
63
|
+
release = WFMLModelRelease(
|
64
|
+
id=model_release.id,
|
65
|
+
name=model_release.name,
|
66
|
+
path=work_path / "ml" / str(model_release.model.id) / str(model_release_id),
|
67
|
+
)
|
68
|
+
|
69
|
+
await make_directory(release.path)
|
70
|
+
|
71
|
+
await _api.get_file(
|
72
|
+
f"/ml/{model_id}/releases/{model_release_id}/model.tar.gz",
|
73
|
+
release.file_path,
|
74
|
+
)
|
75
|
+
|
76
|
+
await asyncio.to_thread(untar, release.file_path, release.path)
|
77
|
+
await asyncio.to_thread(move_all_model_files, release.path / "model", release.path)
|
78
|
+
await asyncio.to_thread(shutil.rmtree, release.path / "model")
|
79
|
+
|
80
|
+
log.info("downloaded ml model release file")
|
81
|
+
|
82
|
+
return release
|
@@ -0,0 +1,190 @@
|
|
1
|
+
import asyncio
|
2
|
+
from collections.abc import Callable, Coroutine
|
3
|
+
from dataclasses import dataclass
|
4
|
+
from pathlib import Path
|
5
|
+
from typing import Any
|
6
|
+
|
7
|
+
from pyfixtures import fixture
|
8
|
+
from structlog import get_logger
|
9
|
+
from virtool.jobs.models import Job
|
10
|
+
from virtool.models.enums import LibraryType
|
11
|
+
from virtool.samples.models import Quality, Sample
|
12
|
+
|
13
|
+
from virtool_workflow.analysis.utils import ReadPaths
|
14
|
+
from virtool_workflow.api.client import APIClient
|
15
|
+
from virtool_workflow.data.uploads import WFUploads
|
16
|
+
from virtool_workflow.errors import JobsAPINotFoundError
|
17
|
+
from virtool_workflow.files import VirtoolFileFormat
|
18
|
+
|
19
|
+
logger = get_logger("api")
|
20
|
+
|
21
|
+
|
22
|
+
@dataclass
|
23
|
+
class WFSample:
|
24
|
+
"""A sample whose data is being used in a workflow."""
|
25
|
+
|
26
|
+
id: str
|
27
|
+
"""The unique ID of the sample."""
|
28
|
+
|
29
|
+
library_type: LibraryType
|
30
|
+
"""The library type of the sample."""
|
31
|
+
|
32
|
+
name: str
|
33
|
+
"""The sample's name."""
|
34
|
+
|
35
|
+
paired: bool
|
36
|
+
"""Whether the sample consists of paired reads."""
|
37
|
+
|
38
|
+
quality: Quality
|
39
|
+
"""The quality data for the sample."""
|
40
|
+
|
41
|
+
read_paths: ReadPaths
|
42
|
+
"""The paths to the raw sample reads."""
|
43
|
+
|
44
|
+
@property
|
45
|
+
def min_length(self) -> int | None:
|
46
|
+
"""The minimum observed read length in the sample sequencing data.
|
47
|
+
|
48
|
+
Returns ``None`` if the sample is still being created and no quality data is available.
|
49
|
+
|
50
|
+
"""
|
51
|
+
return self.quality.length[0] if self.quality else None
|
52
|
+
|
53
|
+
@property
|
54
|
+
def max_length(self) -> int | None:
|
55
|
+
"""The maximum observed read length in the sample sequencing data.
|
56
|
+
|
57
|
+
Returns ``None`` if the sample is still being created and no quality data is available.
|
58
|
+
|
59
|
+
"""
|
60
|
+
return self.quality.length[1] if self.quality else None
|
61
|
+
|
62
|
+
|
63
|
+
@dataclass
|
64
|
+
class WFNewSampleUpload:
|
65
|
+
id: int
|
66
|
+
name: str
|
67
|
+
size: int
|
68
|
+
path: Path
|
69
|
+
|
70
|
+
|
71
|
+
@dataclass
|
72
|
+
class WFNewSample:
|
73
|
+
"""A sample that is being created in the workflow."""
|
74
|
+
|
75
|
+
id: str
|
76
|
+
name: str
|
77
|
+
paired: bool
|
78
|
+
uploads: tuple[WFNewSampleUpload] | tuple[WFNewSampleUpload, WFNewSampleUpload]
|
79
|
+
|
80
|
+
delete: Callable[[], Coroutine[None, None, None]]
|
81
|
+
finalize: Callable[[dict[str, Any]], Coroutine[None, None, None]]
|
82
|
+
upload: Callable[[Path, VirtoolFileFormat], Coroutine[None, None, None]]
|
83
|
+
|
84
|
+
|
85
|
+
@fixture
|
86
|
+
async def sample(
|
87
|
+
_api: APIClient,
|
88
|
+
job: Job,
|
89
|
+
uploads: WFUploads,
|
90
|
+
work_path: Path,
|
91
|
+
) -> WFSample:
|
92
|
+
"""The sample associated with the current job."""
|
93
|
+
id_ = job.args["sample_id"]
|
94
|
+
|
95
|
+
base_url_path = f"/samples/{id_}"
|
96
|
+
|
97
|
+
try:
|
98
|
+
sample_json = await _api.get_json(base_url_path)
|
99
|
+
except JobsAPINotFoundError:
|
100
|
+
raise JobsAPINotFoundError("Sample not found")
|
101
|
+
|
102
|
+
sample = Sample(**sample_json)
|
103
|
+
|
104
|
+
reads_path = work_path / "reads"
|
105
|
+
await asyncio.to_thread(reads_path.mkdir, exist_ok=True, parents=True)
|
106
|
+
|
107
|
+
await _api.get_file(
|
108
|
+
f"{base_url_path}/reads/reads_1.fq.gz",
|
109
|
+
reads_path / "reads_1.fq.gz",
|
110
|
+
)
|
111
|
+
|
112
|
+
if sample.paired:
|
113
|
+
read_paths = (
|
114
|
+
reads_path / "reads_1.fq.gz",
|
115
|
+
reads_path / "reads_2.fq.gz",
|
116
|
+
)
|
117
|
+
await _api.get_file(
|
118
|
+
f"{base_url_path}/reads/reads_2.fq.gz",
|
119
|
+
reads_path / "reads_2.fq.gz",
|
120
|
+
)
|
121
|
+
else:
|
122
|
+
read_paths = (reads_path / "reads_1.fq.gz",)
|
123
|
+
|
124
|
+
return WFSample(
|
125
|
+
id=sample.id,
|
126
|
+
library_type=sample.library_type,
|
127
|
+
name=sample.name,
|
128
|
+
paired=sample.paired,
|
129
|
+
quality=sample.quality,
|
130
|
+
read_paths=read_paths,
|
131
|
+
)
|
132
|
+
|
133
|
+
|
134
|
+
@fixture
|
135
|
+
async def new_sample(
|
136
|
+
_api: APIClient,
|
137
|
+
job: Job,
|
138
|
+
uploads: WFUploads,
|
139
|
+
work_path: Path,
|
140
|
+
) -> WFNewSample:
|
141
|
+
"""The sample associated with the current job."""
|
142
|
+
id_ = job.args["sample_id"]
|
143
|
+
|
144
|
+
log = logger.bind(resource="sample", id=id_)
|
145
|
+
log.info("loading sample for sample creation")
|
146
|
+
|
147
|
+
base_url_path = f"/samples/{id_}"
|
148
|
+
|
149
|
+
sample_dict = await _api.get_json(base_url_path)
|
150
|
+
sample = Sample(**sample_dict)
|
151
|
+
|
152
|
+
log.info("got sample json")
|
153
|
+
|
154
|
+
uploads_path = work_path / "uploads"
|
155
|
+
await asyncio.to_thread(uploads_path.mkdir, exist_ok=True, parents=True)
|
156
|
+
|
157
|
+
log.info("created uploads directory")
|
158
|
+
|
159
|
+
files = tuple(
|
160
|
+
WFNewSampleUpload(
|
161
|
+
id=f["id"],
|
162
|
+
name=f["name"],
|
163
|
+
path=Path(uploads_path / f["name"]),
|
164
|
+
size=f["size"],
|
165
|
+
)
|
166
|
+
for f in job.args["files"]
|
167
|
+
)
|
168
|
+
|
169
|
+
await asyncio.gather(*[uploads.download(f.id, f.path) for f in files])
|
170
|
+
|
171
|
+
log.info("downloaded sample files")
|
172
|
+
|
173
|
+
async def finalize(quality: dict[str, Any]):
|
174
|
+
await _api.patch_json(base_url_path, data={"quality": quality})
|
175
|
+
|
176
|
+
async def delete():
|
177
|
+
await _api.delete(base_url_path)
|
178
|
+
|
179
|
+
async def upload(path: Path, fmt: VirtoolFileFormat = "fastq"):
|
180
|
+
await _api.put_file(f"{base_url_path}/reads/{path.name}", path, "fastq")
|
181
|
+
|
182
|
+
return WFNewSample(
|
183
|
+
id=sample.id,
|
184
|
+
delete=delete,
|
185
|
+
finalize=finalize,
|
186
|
+
name=sample.name,
|
187
|
+
paired=sample.paired,
|
188
|
+
upload=upload,
|
189
|
+
uploads=files,
|
190
|
+
)
|
@@ -0,0 +1,244 @@
|
|
1
|
+
import asyncio
|
2
|
+
from collections.abc import Callable, Coroutine
|
3
|
+
from dataclasses import dataclass
|
4
|
+
from pathlib import Path
|
5
|
+
|
6
|
+
from pyfixtures import fixture
|
7
|
+
from structlog import get_logger
|
8
|
+
from virtool.jobs.models import Job
|
9
|
+
from virtool.subtractions.models import (
|
10
|
+
NucleotideComposition,
|
11
|
+
Subtraction,
|
12
|
+
SubtractionFile,
|
13
|
+
)
|
14
|
+
|
15
|
+
from virtool_workflow.api.client import APIClient
|
16
|
+
from virtool_workflow.data.analyses import WFAnalysis
|
17
|
+
from virtool_workflow.data.uploads import WFUploads
|
18
|
+
from virtool_workflow.errors import MissingJobArgumentError
|
19
|
+
|
20
|
+
logger = get_logger("api")
|
21
|
+
|
22
|
+
|
23
|
+
@dataclass
|
24
|
+
class WFSubtraction:
|
25
|
+
"""A Virtool subtraction that has been loaded into the workflow environment.
|
26
|
+
|
27
|
+
The subtraction files are downloaded to the workflow's local work path so they can
|
28
|
+
be used for analysis.
|
29
|
+
"""
|
30
|
+
|
31
|
+
id: str
|
32
|
+
"""The unique ID for the subtraction."""
|
33
|
+
|
34
|
+
files: list[SubtractionFile]
|
35
|
+
"""The files associated with the subtraction."""
|
36
|
+
|
37
|
+
gc: NucleotideComposition
|
38
|
+
"""The nucleotide composition of the subtraction."""
|
39
|
+
|
40
|
+
nickname: str
|
41
|
+
"""The nickname for the subtraction."""
|
42
|
+
|
43
|
+
name: str
|
44
|
+
"""The display name for the subtraction."""
|
45
|
+
|
46
|
+
path: Path
|
47
|
+
"""
|
48
|
+
The path to the subtraction directory.
|
49
|
+
|
50
|
+
The subtraction directory contains the FASTA and Bowtie2 files for the subtraction.
|
51
|
+
"""
|
52
|
+
|
53
|
+
@property
|
54
|
+
def fasta_path(self) -> Path:
|
55
|
+
"""The path to the gzipped FASTA file for the subtraction."""
|
56
|
+
return self.path / "subtraction.fa.gz"
|
57
|
+
|
58
|
+
@property
|
59
|
+
def bowtie2_index_path(self) -> Path:
|
60
|
+
"""The path to Bowtie2 prefix in the running workflow's work_path
|
61
|
+
|
62
|
+
For example, ``/work/subtractions/<id>/subtraction`` refers to the Bowtie2
|
63
|
+
index that comprises the files:
|
64
|
+
|
65
|
+
- ``/work/subtractions/<id>/subtraction.1.bt2``
|
66
|
+
- ``/work/subtractions/<id>/subtraction.2.bt2``
|
67
|
+
- ``/work/subtractions/<id>/subtraction.3.bt2``
|
68
|
+
- ``/work/subtractions/<id>/subtraction.4.bt2``
|
69
|
+
- ``/work/subtractions/<id>/subtraction.rev.1.bt2``
|
70
|
+
- ``/work/subtractions/<id>/subtraction.rev.2.bt2``
|
71
|
+
|
72
|
+
"""
|
73
|
+
return self.path / "subtraction"
|
74
|
+
|
75
|
+
|
76
|
+
@dataclass
|
77
|
+
class WFNewSubtraction:
|
78
|
+
id: str
|
79
|
+
"""The unique ID for the subtraction."""
|
80
|
+
|
81
|
+
delete: Callable[[], Coroutine[None, None, None]]
|
82
|
+
"""
|
83
|
+
A callable that deletes the subtraction from Virtool.
|
84
|
+
|
85
|
+
This should be called if the subtraction creation fails before the subtraction is
|
86
|
+
finalized.
|
87
|
+
"""
|
88
|
+
|
89
|
+
finalize: Callable[[dict[str, int | float], int], Coroutine[None, None, None]]
|
90
|
+
"""
|
91
|
+
A callable that finalizes the subtraction in Virtool.
|
92
|
+
|
93
|
+
This makes it impossible to further alter the files and ready state of the
|
94
|
+
subtraction. This must be called before the workflow ends to make the subtraction
|
95
|
+
usable.
|
96
|
+
"""
|
97
|
+
|
98
|
+
name: str
|
99
|
+
"""The display name for the subtraction."""
|
100
|
+
|
101
|
+
nickname: str
|
102
|
+
"""An optional nickname for the subtraction."""
|
103
|
+
|
104
|
+
path: Path
|
105
|
+
"""
|
106
|
+
The path to the subtraction directory.
|
107
|
+
|
108
|
+
The data files for the subtraction should be created here.
|
109
|
+
"""
|
110
|
+
|
111
|
+
upload: Callable[[Path], Coroutine[None, None, None]]
|
112
|
+
|
113
|
+
@property
|
114
|
+
def fasta_path(self) -> Path:
|
115
|
+
"""The path to the FASTA file that should be used to create the subtraction."""
|
116
|
+
return self.path / "subtraction.fa.gz"
|
117
|
+
|
118
|
+
|
119
|
+
@fixture
|
120
|
+
async def subtractions(
|
121
|
+
_api: APIClient,
|
122
|
+
analysis: WFAnalysis,
|
123
|
+
work_path: Path,
|
124
|
+
) -> list[WFSubtraction]:
|
125
|
+
"""The subtractions to be used for the current analysis job."""
|
126
|
+
subtraction_work_path = work_path / "subtractions"
|
127
|
+
await asyncio.to_thread(subtraction_work_path.mkdir)
|
128
|
+
|
129
|
+
subtractions_ = []
|
130
|
+
|
131
|
+
for subtraction_id in [s.id for s in analysis.subtractions]:
|
132
|
+
subtraction_json = await _api.get_json(f"/subtractions/{subtraction_id}")
|
133
|
+
subtraction = Subtraction(**subtraction_json)
|
134
|
+
subtraction = WFSubtraction(
|
135
|
+
id=subtraction.id,
|
136
|
+
files=subtraction.files,
|
137
|
+
gc=subtraction.gc,
|
138
|
+
nickname=subtraction.nickname,
|
139
|
+
name=subtraction.name,
|
140
|
+
path=subtraction_work_path / subtraction.id,
|
141
|
+
)
|
142
|
+
|
143
|
+
await asyncio.to_thread(subtraction.path.mkdir, parents=True, exist_ok=True)
|
144
|
+
|
145
|
+
subtractions_.append(subtraction)
|
146
|
+
|
147
|
+
# Do this in a separate loop in case fetching the JSON fails. This prevents
|
148
|
+
# expensive and unnecessary file downloads.
|
149
|
+
for subtraction in subtractions_:
|
150
|
+
logger.info("downloading subtraction files", id=subtraction.id)
|
151
|
+
|
152
|
+
for subtraction_file in subtraction.files:
|
153
|
+
await _api.get_file(
|
154
|
+
f"/subtractions/{subtraction.id}/files/{subtraction_file.name}",
|
155
|
+
subtraction.path / subtraction_file.name,
|
156
|
+
)
|
157
|
+
|
158
|
+
return subtractions_
|
159
|
+
|
160
|
+
|
161
|
+
@fixture
|
162
|
+
async def new_subtraction(
|
163
|
+
_api: APIClient,
|
164
|
+
job: Job,
|
165
|
+
uploads: WFUploads,
|
166
|
+
work_path: Path,
|
167
|
+
) -> WFNewSubtraction:
|
168
|
+
"""A new subtraction that will be created during the current job.
|
169
|
+
|
170
|
+
Currently only used for the `create-subtraction` workflow.
|
171
|
+
"""
|
172
|
+
try:
|
173
|
+
id_ = job.args["subtraction_id"]
|
174
|
+
except KeyError:
|
175
|
+
raise MissingJobArgumentError("subtraction_id")
|
176
|
+
|
177
|
+
try:
|
178
|
+
upload_id = job.args["files"][0]["id"]
|
179
|
+
except KeyError:
|
180
|
+
raise MissingJobArgumentError("files")
|
181
|
+
|
182
|
+
subtraction_json = await _api.get_json(f"/subtractions/{id_}")
|
183
|
+
subtraction_ = Subtraction(**subtraction_json)
|
184
|
+
|
185
|
+
subtraction_work_path = work_path / "subtractions" / subtraction_.id
|
186
|
+
await asyncio.to_thread(subtraction_work_path.mkdir, parents=True, exist_ok=True)
|
187
|
+
|
188
|
+
await uploads.download(upload_id, subtraction_work_path / "subtraction.fa.gz")
|
189
|
+
|
190
|
+
url_path = f"/subtractions/{subtraction_.id}"
|
191
|
+
|
192
|
+
async def delete():
|
193
|
+
"""Delete the subtraction if the job fails."""
|
194
|
+
await _api.delete(f"/subtractions/{subtraction_.id}")
|
195
|
+
|
196
|
+
async def finalize(gc: dict[str, int | float], count: int):
|
197
|
+
"""Finalize the subtraction by setting the gc.
|
198
|
+
|
199
|
+
:param gc: the nucleotide composition of the subtraction
|
200
|
+
:param count: the number of sequences in the FASTA file
|
201
|
+
:return: the updated subtraction.
|
202
|
+
"""
|
203
|
+
gc_ = NucleotideComposition(**{"n": 0.0, **gc})
|
204
|
+
|
205
|
+
await _api.patch_json(url_path, {"gc": gc_.dict(), "count": count})
|
206
|
+
|
207
|
+
async def upload(path: Path):
|
208
|
+
"""Upload a file relating to this subtraction.
|
209
|
+
|
210
|
+
Filenames must be one of:
|
211
|
+
- subtraction.fa.gz
|
212
|
+
- subtraction.1.bt2
|
213
|
+
- subtraction.2.bt2
|
214
|
+
- subtraction.3.bt2
|
215
|
+
- subtraction.4.bt2
|
216
|
+
- subtraction.rev.1.bt2
|
217
|
+
- subtraction.rev.2.bt2
|
218
|
+
|
219
|
+
:param path: The path to the file
|
220
|
+
|
221
|
+
"""
|
222
|
+
filename = path.name
|
223
|
+
|
224
|
+
log = logger.bind(id=id_, filename=filename)
|
225
|
+
|
226
|
+
log.info("Uploading subtraction file")
|
227
|
+
|
228
|
+
await _api.put_file(
|
229
|
+
f"/subtractions/{subtraction_.id}/files/{filename}",
|
230
|
+
path,
|
231
|
+
"unknown",
|
232
|
+
)
|
233
|
+
|
234
|
+
log.info("Finished uploading subtraction file")
|
235
|
+
|
236
|
+
return WFNewSubtraction(
|
237
|
+
id=subtraction_.id,
|
238
|
+
name=subtraction_.name,
|
239
|
+
nickname=subtraction_.nickname,
|
240
|
+
path=subtraction_work_path,
|
241
|
+
delete=delete,
|
242
|
+
finalize=finalize,
|
243
|
+
upload=upload,
|
244
|
+
)
|
@@ -0,0 +1,35 @@
|
|
1
|
+
from dataclasses import dataclass
|
2
|
+
from pathlib import Path
|
3
|
+
|
4
|
+
from pyfixtures import fixture
|
5
|
+
|
6
|
+
from virtool_workflow.api.client import APIClient
|
7
|
+
|
8
|
+
|
9
|
+
@dataclass
|
10
|
+
class WFUploads:
|
11
|
+
def __init__(self, api: APIClient):
|
12
|
+
self._api = api
|
13
|
+
|
14
|
+
async def download(self, upload_id: int, path: Path):
|
15
|
+
"""Download the upload with the given ID to the given path."""
|
16
|
+
await self._api.get_file(f"/uploads/{upload_id}", path)
|
17
|
+
|
18
|
+
|
19
|
+
@fixture
|
20
|
+
async def uploads(_api: APIClient) -> WFUploads:
|
21
|
+
"""Provides access to files that have been uploaded to the Virtool instance.
|
22
|
+
|
23
|
+
Files can be downloaded into the workflow environment be calling
|
24
|
+
:meth:`.WFUploads.download`.
|
25
|
+
|
26
|
+
Example:
|
27
|
+
-------
|
28
|
+
.. code-block:: python
|
29
|
+
|
30
|
+
@step
|
31
|
+
async def step_one(uploads: WFUploads, work_path: Path):
|
32
|
+
await uploads.download(1, work_path / "file.txt")
|
33
|
+
|
34
|
+
"""
|
35
|
+
return WFUploads(_api)
|
@@ -0,0 +1,47 @@
|
|
1
|
+
"""Create Workflows by decorating module scope functions."""
|
2
|
+
|
3
|
+
from collections.abc import Callable
|
4
|
+
from types import ModuleType
|
5
|
+
|
6
|
+
from virtool_workflow.errors import WorkflowStepsError
|
7
|
+
from virtool_workflow.workflow import Workflow
|
8
|
+
|
9
|
+
|
10
|
+
def step(func: Callable | None = None, *, name: str | None = None) -> Callable:
|
11
|
+
"""Mark a function as a workflow step function.
|
12
|
+
|
13
|
+
:param func: the workflow step function
|
14
|
+
:param name: the display name of the workflow step. A name
|
15
|
+
will be generated based on the function name if not provided.
|
16
|
+
"""
|
17
|
+
if func is None:
|
18
|
+
return lambda _f: step(_f, name=name)
|
19
|
+
|
20
|
+
func.__workflow_marker__ = "step"
|
21
|
+
func.__workflow_step_props__ = {"name": name}
|
22
|
+
|
23
|
+
return func
|
24
|
+
|
25
|
+
|
26
|
+
def collect(module: ModuleType) -> Workflow:
|
27
|
+
"""Build a :class:`.Workflow` object from a workflow module.
|
28
|
+
|
29
|
+
:param module: A workflow module
|
30
|
+
:return: A workflow object
|
31
|
+
"""
|
32
|
+
workflow = Workflow()
|
33
|
+
|
34
|
+
markers = [
|
35
|
+
value
|
36
|
+
for value in module.__dict__.values()
|
37
|
+
if hasattr(value, "__workflow_marker__")
|
38
|
+
]
|
39
|
+
|
40
|
+
for marked in markers:
|
41
|
+
if marked.__workflow_marker__ == "step":
|
42
|
+
workflow.step(marked, **marked.__workflow_step_props__)
|
43
|
+
|
44
|
+
if not workflow.steps:
|
45
|
+
raise WorkflowStepsError(str(module))
|
46
|
+
|
47
|
+
return workflow
|
@@ -0,0 +1,62 @@
|
|
1
|
+
"""Custom exceptions for ``virtool_workflow``."""
|
2
|
+
|
3
|
+
from subprocess import SubprocessError
|
4
|
+
|
5
|
+
|
6
|
+
class JobAlreadyAcquiredError(Exception):
|
7
|
+
"""Raised when an attempt is made to reacquire a job."""
|
8
|
+
|
9
|
+
def __init__(self, job_id: str) -> None:
|
10
|
+
"""Initialize the exception with a message containing the job ID."""
|
11
|
+
super().__init__(
|
12
|
+
f"Job {job_id} is has already been acquired.",
|
13
|
+
)
|
14
|
+
|
15
|
+
|
16
|
+
class JobsAPIError(Exception):
|
17
|
+
"""A base exception for errors due to HTTP errors from the jobs API."""
|
18
|
+
|
19
|
+
|
20
|
+
class JobsAPIBadRequestError(JobsAPIError):
|
21
|
+
"""A ``400 Bad Request`` response was received from the jobs API."""
|
22
|
+
|
23
|
+
status = 400
|
24
|
+
|
25
|
+
|
26
|
+
class JobsAPIForbiddenError(JobsAPIError):
|
27
|
+
"""A ``403 Forbidden`` response was received from the jobs API."""
|
28
|
+
|
29
|
+
status = 403
|
30
|
+
|
31
|
+
|
32
|
+
class JobsAPINotFoundError(JobsAPIError):
|
33
|
+
"""A ``404 Not Found`` response was received from the jobs API."""
|
34
|
+
|
35
|
+
status = 404
|
36
|
+
|
37
|
+
|
38
|
+
class JobsAPIConflictError(JobsAPIError):
|
39
|
+
"""A ``409 Conflict`` response was received from the jobs API."""
|
40
|
+
|
41
|
+
status = 409
|
42
|
+
|
43
|
+
|
44
|
+
class JobsAPIServerError(JobsAPIError):
|
45
|
+
"""A ``500 Internal Server Error`` response was received from the jobs API."""
|
46
|
+
|
47
|
+
status = 500
|
48
|
+
|
49
|
+
|
50
|
+
class MissingJobArgumentError(ValueError):
|
51
|
+
"""The `job.args` dict is missing a required key for some funcionality."""
|
52
|
+
|
53
|
+
|
54
|
+
class WorkflowStepsError(Exception):
|
55
|
+
"""Raised when no workflow steps are found in a module."""
|
56
|
+
|
57
|
+
def __init__(self, module: str) -> None:
|
58
|
+
super().__init__(f"No workflow steps could be found in {module}")
|
59
|
+
|
60
|
+
|
61
|
+
class SubprocessFailedError(SubprocessError):
|
62
|
+
"""Subprocess exited with non-zero status during a workflow."""
|
@@ -0,0 +1,40 @@
|
|
1
|
+
"""Dataclasses for describing files uploaded to the Virtool server."""
|
2
|
+
|
3
|
+
import datetime
|
4
|
+
from dataclasses import dataclass
|
5
|
+
from typing import Literal
|
6
|
+
|
7
|
+
VirtoolFileFormat = Literal[
|
8
|
+
"sam",
|
9
|
+
"bam",
|
10
|
+
"fasta",
|
11
|
+
"fastq",
|
12
|
+
"csv",
|
13
|
+
"tsv",
|
14
|
+
"json",
|
15
|
+
"unknown",
|
16
|
+
]
|
17
|
+
"""A literal type hint for the format of a :class:`.VirtoolFile`."""
|
18
|
+
|
19
|
+
|
20
|
+
@dataclass
|
21
|
+
class VirtoolFile:
|
22
|
+
"""A description of a file uploaded to the Virtool server."""
|
23
|
+
|
24
|
+
id: int
|
25
|
+
"""The unique ID for the file."""
|
26
|
+
|
27
|
+
name: str
|
28
|
+
"""The name of the file."""
|
29
|
+
|
30
|
+
size: int
|
31
|
+
"""The size of the file in bytes."""
|
32
|
+
|
33
|
+
format: VirtoolFileFormat
|
34
|
+
"""The format of the file."""
|
35
|
+
|
36
|
+
name_on_disk: str | None = None
|
37
|
+
"""The actual name of the file on disk."""
|
38
|
+
|
39
|
+
uploaded_at: datetime.datetime | None = None
|
40
|
+
"""When the file was uploaded."""
|