variantgrid-api 1.4.0__tar.gz → 1.5.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.
- {variantgrid_api-1.4.0/src/variantgrid_api.egg-info → variantgrid_api-1.5.0}/PKG-INFO +48 -1
- variantgrid_api-1.5.0/README.md +101 -0
- {variantgrid_api-1.4.0 → variantgrid_api-1.5.0}/pyproject.toml +1 -1
- {variantgrid_api-1.4.0 → variantgrid_api-1.5.0}/src/variantgrid_api/api_client.py +204 -18
- {variantgrid_api-1.4.0 → variantgrid_api-1.5.0}/src/variantgrid_api/cli.py +4 -1
- variantgrid_api-1.5.0/src/variantgrid_api/data_models.py +465 -0
- {variantgrid_api-1.4.0 → variantgrid_api-1.5.0}/src/variantgrid_api/mock_variantgrid_api.py +109 -4
- {variantgrid_api-1.4.0 → variantgrid_api-1.5.0/src/variantgrid_api.egg-info}/PKG-INFO +48 -1
- {variantgrid_api-1.4.0 → variantgrid_api-1.5.0}/src/variantgrid_api.egg-info/SOURCES.txt +2 -0
- {variantgrid_api-1.4.0 → variantgrid_api-1.5.0}/tests/test_api_client.py +27 -0
- {variantgrid_api-1.4.0 → variantgrid_api-1.5.0}/tests/test_api_client_annotation.py +5 -0
- variantgrid_api-1.5.0/tests/test_api_client_capabilities.py +207 -0
- variantgrid_api-1.5.0/tests/test_api_client_patients.py +245 -0
- {variantgrid_api-1.4.0 → variantgrid_api-1.5.0}/tests/test_cli.py +25 -0
- {variantgrid_api-1.4.0 → variantgrid_api-1.5.0}/tests/test_mock_variantgrid_api.py +88 -0
- variantgrid_api-1.4.0/README.md +0 -54
- variantgrid_api-1.4.0/src/variantgrid_api/data_models.py +0 -269
- {variantgrid_api-1.4.0 → variantgrid_api-1.5.0}/LICENSE +0 -0
- {variantgrid_api-1.4.0 → variantgrid_api-1.5.0}/setup.cfg +0 -0
- {variantgrid_api-1.4.0 → variantgrid_api-1.5.0}/src/variantgrid_api.egg-info/dependency_links.txt +0 -0
- {variantgrid_api-1.4.0 → variantgrid_api-1.5.0}/src/variantgrid_api.egg-info/entry_points.txt +0 -0
- {variantgrid_api-1.4.0 → variantgrid_api-1.5.0}/src/variantgrid_api.egg-info/requires.txt +0 -0
- {variantgrid_api-1.4.0 → variantgrid_api-1.5.0}/src/variantgrid_api.egg-info/top_level.txt +0 -0
- {variantgrid_api-1.4.0 → variantgrid_api-1.5.0}/tests/test_api_client_bulk.py +0 -0
- {variantgrid_api-1.4.0 → variantgrid_api-1.5.0}/tests/test_api_client_validation.py +0 -0
- {variantgrid_api-1.4.0 → variantgrid_api-1.5.0}/tests/test_data_models.py +0 -0
- {variantgrid_api-1.4.0 → variantgrid_api-1.5.0}/tests/test_sequencer_model_from_name.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: variantgrid_api
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.5.0
|
|
4
4
|
Summary: A Python API client for VariantGrid
|
|
5
5
|
Author-email: Dave Lawrence <davmlaw@gmail.com>
|
|
6
6
|
License: MIT License
|
|
@@ -88,6 +88,53 @@ From Python it's `upload_file()` / `poll_upload_status()` / `download_annotated(
|
|
|
88
88
|
**[Annotate a VCF](https://github.com/SACGF/variantgrid_api/wiki/Annotate-a-VCF)** on the wiki for batches, the
|
|
89
89
|
submit-now/download-later pattern, and all the options.
|
|
90
90
|
|
|
91
|
+
## Patients, specimens and extractions
|
|
92
|
+
|
|
93
|
+
VariantGrid can record which patient, specimen and extraction a lab's sequencing came from, and specimen-level
|
|
94
|
+
measures such as TMB, MSI and GIS. This needs a server at or after SACGF/variantgrid#1716.
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
from variantgrid_api.data_models import Patient, Specimen, Extraction, ExternalReference, NucleicAcid
|
|
98
|
+
|
|
99
|
+
api.create_patient(Patient(patient_code="C0000001"))
|
|
100
|
+
api.create_specimen(Specimen(patient="C0000001", reference_id="2600000001"))
|
|
101
|
+
api.create_extraction(Extraction(specimen="2600000001", reference_id="2600000001C",
|
|
102
|
+
nucleic_acid_source=NucleicAcid.DNA))
|
|
103
|
+
api.upload_file("sample.vcf.gz", path=None,
|
|
104
|
+
metadata={"extraction": "2600000001C", "genome_build": "GRCh37"})
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
A bare string names a record by its local reference. Use `ExternalReference(code=..., external_type=...)` to
|
|
108
|
+
name it by a LIMS identifier instead. See `examples/example_tso500.py` for a full run.
|
|
109
|
+
|
|
110
|
+
## Talking to more than one VariantGrid version
|
|
111
|
+
|
|
112
|
+
Servers of different ages accept different calls. The client asks the server which features it has
|
|
113
|
+
(`GET api/v1/capabilities`, fetched once on first use), and each call that needs a newer server
|
|
114
|
+
checks that list first: the patient / specimen / extraction calls, specimen measures,
|
|
115
|
+
`link_sequencing_sample_extraction`, the annotate flow (`poll_upload_status`, `wait_for_annotation`,
|
|
116
|
+
`download_annotated`, `annotate_vcf`), and `upload_file` with `metadata` or `file_type`. A server without the
|
|
117
|
+
capabilities endpoint counts as legacy and reports no features.
|
|
118
|
+
|
|
119
|
+
By default an unsupported call raises `UnsupportedFeatureError`. To run the same code against old and new
|
|
120
|
+
servers, skip those calls instead: they log a warning and return `None`. `upload_file` is the exception: on a
|
|
121
|
+
server without upload metadata it drops the `metadata` and still uploads the file.
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
from variantgrid_api.api_client import VariantGridAPI, UnsupportedFeaturePolicy
|
|
125
|
+
|
|
126
|
+
api = VariantGridAPI(server, api_token, unsupported_feature_policy=UnsupportedFeaturePolicy.SKIP)
|
|
127
|
+
api.create_patient(patient) # skipped on a server without patients
|
|
128
|
+
|
|
129
|
+
# Uploads only when the server has an importer for this file type
|
|
130
|
+
api.upload_file("sample_CombinedVariantOutput.tsv", path=None,
|
|
131
|
+
file_type="dragen_tso500_combined_variant_output")
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Where the fallback isn't simply "do nothing", branch on `api.supports("feature")` or
|
|
135
|
+
`api.accepts_upload("file_type")`. `api.capabilities.version` is useful for logging which server you reached.
|
|
136
|
+
For tests, `MockVariantGridAPI(capabilities=ServerCapabilities.LEGACY)` behaves like an old server.
|
|
137
|
+
|
|
91
138
|
## Testing
|
|
92
139
|
|
|
93
140
|
```
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# variantgrid_api
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/variantgrid_api/) [](https://pypi.org/project/variantgrid_api/)
|
|
4
|
+
|
|
5
|
+
Python API client for [VariantGrid](https://github.com/SACGF/variantgrid) Open source Variant database and analysis platform
|
|
6
|
+
|
|
7
|
+
See [changelog](https://github.com/SACGF/variantgrid_api/blob/main/CHANGELOG.md)
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
python3 -m pip install variantgrid_api
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Example
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
from variantgrid_api.api_client import VariantGridAPI
|
|
19
|
+
from variantgrid_api.data_models import EnrichmentKit
|
|
20
|
+
|
|
21
|
+
api = VariantGridAPI(server="https://variantgrid.com", api_token="YOUR_API_TOKEN")
|
|
22
|
+
enrichment_kit = EnrichmentKit(name="idt_haem", version=1)
|
|
23
|
+
result = api.create_enrichment_kit(enrichment_kit)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Annotate a VCF and download it back
|
|
27
|
+
|
|
28
|
+
Upload a VCF, have VariantGrid import + annotate any novel variants, then download the cohort-level annotated
|
|
29
|
+
export (all samples, single-sample VCFs included). Annotation can take a while, so the quickest way in is the
|
|
30
|
+
`vg_api` command line tool: the first call uploads, and running the same command again downloads the result
|
|
31
|
+
once it's ready.
|
|
32
|
+
|
|
33
|
+
```console
|
|
34
|
+
$ export VARIANTGRID_API_TOKEN=YOUR_API_TOKEN
|
|
35
|
+
$ vg_api annotate_vcf input.vcf.gz -o results/
|
|
36
|
+
Uploaded input.vcf.gz (id=13256).
|
|
37
|
+
Annotating input.vcf.gz - run the same command again later to download.
|
|
38
|
+
|
|
39
|
+
$ vg_api annotate_vcf input.vcf.gz -o results/ # once it's done
|
|
40
|
+
Annotated vcf written to results/input.vcf_annotated_v254_GRCh38.vcf.gz
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
From Python it's `upload_file()` / `poll_upload_status()` / `download_annotated()`, or the blocking
|
|
44
|
+
`annotate_vcf()` one-liner. See
|
|
45
|
+
**[Annotate a VCF](https://github.com/SACGF/variantgrid_api/wiki/Annotate-a-VCF)** on the wiki for batches, the
|
|
46
|
+
submit-now/download-later pattern, and all the options.
|
|
47
|
+
|
|
48
|
+
## Patients, specimens and extractions
|
|
49
|
+
|
|
50
|
+
VariantGrid can record which patient, specimen and extraction a lab's sequencing came from, and specimen-level
|
|
51
|
+
measures such as TMB, MSI and GIS. This needs a server at or after SACGF/variantgrid#1716.
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
from variantgrid_api.data_models import Patient, Specimen, Extraction, ExternalReference, NucleicAcid
|
|
55
|
+
|
|
56
|
+
api.create_patient(Patient(patient_code="C0000001"))
|
|
57
|
+
api.create_specimen(Specimen(patient="C0000001", reference_id="2600000001"))
|
|
58
|
+
api.create_extraction(Extraction(specimen="2600000001", reference_id="2600000001C",
|
|
59
|
+
nucleic_acid_source=NucleicAcid.DNA))
|
|
60
|
+
api.upload_file("sample.vcf.gz", path=None,
|
|
61
|
+
metadata={"extraction": "2600000001C", "genome_build": "GRCh37"})
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
A bare string names a record by its local reference. Use `ExternalReference(code=..., external_type=...)` to
|
|
65
|
+
name it by a LIMS identifier instead. See `examples/example_tso500.py` for a full run.
|
|
66
|
+
|
|
67
|
+
## Talking to more than one VariantGrid version
|
|
68
|
+
|
|
69
|
+
Servers of different ages accept different calls. The client asks the server which features it has
|
|
70
|
+
(`GET api/v1/capabilities`, fetched once on first use), and each call that needs a newer server
|
|
71
|
+
checks that list first: the patient / specimen / extraction calls, specimen measures,
|
|
72
|
+
`link_sequencing_sample_extraction`, the annotate flow (`poll_upload_status`, `wait_for_annotation`,
|
|
73
|
+
`download_annotated`, `annotate_vcf`), and `upload_file` with `metadata` or `file_type`. A server without the
|
|
74
|
+
capabilities endpoint counts as legacy and reports no features.
|
|
75
|
+
|
|
76
|
+
By default an unsupported call raises `UnsupportedFeatureError`. To run the same code against old and new
|
|
77
|
+
servers, skip those calls instead: they log a warning and return `None`. `upload_file` is the exception: on a
|
|
78
|
+
server without upload metadata it drops the `metadata` and still uploads the file.
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
from variantgrid_api.api_client import VariantGridAPI, UnsupportedFeaturePolicy
|
|
82
|
+
|
|
83
|
+
api = VariantGridAPI(server, api_token, unsupported_feature_policy=UnsupportedFeaturePolicy.SKIP)
|
|
84
|
+
api.create_patient(patient) # skipped on a server without patients
|
|
85
|
+
|
|
86
|
+
# Uploads only when the server has an importer for this file type
|
|
87
|
+
api.upload_file("sample_CombinedVariantOutput.tsv", path=None,
|
|
88
|
+
file_type="dragen_tso500_combined_variant_output")
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Where the fallback isn't simply "do nothing", branch on `api.supports("feature")` or
|
|
92
|
+
`api.accepts_upload("file_type")`. `api.capabilities.version` is useful for logging which server you reached.
|
|
93
|
+
For tests, `MockVariantGridAPI(capabilities=ServerCapabilities.LEGACY)` behaves like an old server.
|
|
94
|
+
|
|
95
|
+
## Testing
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
# Install required testing packages
|
|
99
|
+
python3 -m pip install -e ".[test]"
|
|
100
|
+
python3 -m pytest --cov=variantgrid_api
|
|
101
|
+
```
|
|
@@ -12,7 +12,9 @@ from typing import List, Optional, Callable, Union
|
|
|
12
12
|
import requests
|
|
13
13
|
|
|
14
14
|
from variantgrid_api.data_models import EnrichmentKit, SequencingRun, SampleSheet, JointCalledVCF, \
|
|
15
|
-
SampleSheetLookup, SequencingFile, QCGeneList, QCExecStats, QCGeneCoverage, SequencerModel, Sequencer
|
|
15
|
+
SampleSheetLookup, SequencingFile, QCGeneList, QCExecStats, QCGeneCoverage, SequencerModel, Sequencer, \
|
|
16
|
+
SequencingSampleLookup, Patient, Specimen, Extraction, SpecimenMeasure, ExternalReference, ReferenceLike, \
|
|
17
|
+
reference_json, ServerCapabilities
|
|
16
18
|
|
|
17
19
|
|
|
18
20
|
_UNSET = object()
|
|
@@ -31,6 +33,20 @@ class EmptyInputPolicy(Enum):
|
|
|
31
33
|
ERROR = "error"
|
|
32
34
|
|
|
33
35
|
|
|
36
|
+
class UnsupportedFeaturePolicy(Enum):
|
|
37
|
+
""" What a gated call does when the server lacks the feature (see VariantGridAPI.capabilities) """
|
|
38
|
+
SKIP = "skip" # log a warning, return None
|
|
39
|
+
ERROR = "error" # raise UnsupportedFeatureError
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class UnsupportedFeatureError(Exception):
|
|
43
|
+
""" Raised (under UnsupportedFeaturePolicy.ERROR) when a call needs a feature the server doesn't have """
|
|
44
|
+
|
|
45
|
+
def __init__(self, message, capabilities: Optional[ServerCapabilities] = None):
|
|
46
|
+
super().__init__(message)
|
|
47
|
+
self.capabilities = capabilities
|
|
48
|
+
|
|
49
|
+
|
|
34
50
|
class AnnotationError(Exception):
|
|
35
51
|
"""Raised when the server reports an error while importing/annotating an uploaded file."""
|
|
36
52
|
|
|
@@ -43,7 +59,8 @@ class VariantGridAPI:
|
|
|
43
59
|
def __init__(self, server, api_token,
|
|
44
60
|
empty_input_policy=EmptyInputPolicy.ERROR,
|
|
45
61
|
logger: Optional[logging.Logger] = None,
|
|
46
|
-
log_request=False, log_response=False
|
|
62
|
+
log_request=False, log_response=False,
|
|
63
|
+
unsupported_feature_policy=UnsupportedFeaturePolicy.ERROR
|
|
47
64
|
):
|
|
48
65
|
self.server = server
|
|
49
66
|
self.headers = {"Authorization": f"Token {api_token}"}
|
|
@@ -53,6 +70,8 @@ class VariantGridAPI:
|
|
|
53
70
|
self.validation_handler = self._get_validation_handler(empty_input_policy, logger)
|
|
54
71
|
self.log_request = log_request
|
|
55
72
|
self.log_response = log_response
|
|
73
|
+
self.unsupported_feature_policy = unsupported_feature_policy
|
|
74
|
+
self._capabilities: Optional[ServerCapabilities] = None
|
|
56
75
|
|
|
57
76
|
def _get_url(self, url):
|
|
58
77
|
return urllib.parse.urljoin(self.server, url)
|
|
@@ -110,10 +129,61 @@ class VariantGridAPI:
|
|
|
110
129
|
if obj is None:
|
|
111
130
|
self.validation_handler(f"{info}: is None")
|
|
112
131
|
|
|
132
|
+
def _validate_reference(self, info: str, reference: Optional[ReferenceLike]):
|
|
133
|
+
if isinstance(reference, str):
|
|
134
|
+
self._validate_string(info, reference)
|
|
135
|
+
else:
|
|
136
|
+
self._validate_object(info, reference)
|
|
137
|
+
|
|
113
138
|
def _validate_list(self, info: str, list_obj: List):
|
|
114
139
|
if not list_obj:
|
|
115
140
|
self.validation_handler(f"{info}: empty list")
|
|
116
141
|
|
|
142
|
+
##########################################################
|
|
143
|
+
## Server capabilities (SACGF/variantgrid_sapath#443)
|
|
144
|
+
## One client talks to servers of different ages. Calls needing a newer server are gated on the
|
|
145
|
+
## features it reports, and handled by unsupported_feature_policy when it lacks them
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def capabilities(self) -> ServerCapabilities:
|
|
149
|
+
""" Fetched on first use then cached, so constructing the client and ungated calls do no extra
|
|
150
|
+
request. A server without the endpoint is ServerCapabilities.LEGACY - it answers 404, or a redirect
|
|
151
|
+
to its login page when its login middleware doesn't exempt /api/ (so redirects aren't followed) """
|
|
152
|
+
if self._capabilities is None:
|
|
153
|
+
url = self._get_url("api/v1/capabilities")
|
|
154
|
+
response = requests.get(url, headers=self.headers, allow_redirects=False)
|
|
155
|
+
if response.status_code == 404 or response.is_redirect:
|
|
156
|
+
self.logger.info("Server has no capabilities endpoint (HTTP %s) - treating as legacy",
|
|
157
|
+
response.status_code)
|
|
158
|
+
self._capabilities = ServerCapabilities.LEGACY
|
|
159
|
+
else:
|
|
160
|
+
data = self._handle_json_response(response, f"{url=}")
|
|
161
|
+
self._capabilities = ServerCapabilities.from_json(data)
|
|
162
|
+
return self._capabilities
|
|
163
|
+
|
|
164
|
+
def supports(self, feature: str) -> bool:
|
|
165
|
+
return feature in self.capabilities.features
|
|
166
|
+
|
|
167
|
+
def accepts_upload(self, file_type: str) -> bool:
|
|
168
|
+
""" file_type is the server's UploadedFileTypes name in lower case, eg 'dragen_tso500_combined_variant_output' """
|
|
169
|
+
return file_type in self.capabilities.upload_file_types
|
|
170
|
+
|
|
171
|
+
def _unsupported(self, message: str) -> bool:
|
|
172
|
+
""" Applies unsupported_feature_policy - returns False (caller skips) or raises """
|
|
173
|
+
capabilities = self.capabilities
|
|
174
|
+
message = f"{message} (server version '{capabilities.version}')"
|
|
175
|
+
if self.unsupported_feature_policy == UnsupportedFeaturePolicy.SKIP:
|
|
176
|
+
self.logger.warning("Skipping: %s", message)
|
|
177
|
+
return False
|
|
178
|
+
raise UnsupportedFeatureError(message, capabilities)
|
|
179
|
+
|
|
180
|
+
def _require(self, feature: str) -> bool:
|
|
181
|
+
""" True if the server supports feature, otherwise applies unsupported_feature_policy """
|
|
182
|
+
return self.supports(feature) or self._unsupported(f"server doesn't support feature '{feature}'")
|
|
183
|
+
|
|
184
|
+
def _require_upload(self, file_type: str) -> bool:
|
|
185
|
+
return self.accepts_upload(file_type) or self._unsupported(f"server doesn't accept upload file type '{file_type}'")
|
|
186
|
+
|
|
117
187
|
def create_experiment(self, experiment: str):
|
|
118
188
|
self._validate_string("experiment", experiment)
|
|
119
189
|
json_data = {
|
|
@@ -173,12 +243,16 @@ class VariantGridAPI:
|
|
|
173
243
|
for sf in sequencing_files:
|
|
174
244
|
data = sf.to_dict()
|
|
175
245
|
# put into hierarchial JSON DRF expects
|
|
176
|
-
fastq_r1 = data.pop("fastq_r1")
|
|
177
|
-
fastq_r2 = data.pop("fastq_r2")
|
|
178
|
-
|
|
179
|
-
"fastq_r1": {"path": fastq_r1}
|
|
180
|
-
|
|
181
|
-
|
|
246
|
+
fastq_r1 = data.pop("fastq_r1", None)
|
|
247
|
+
fastq_r2 = data.pop("fastq_r2", None)
|
|
248
|
+
if fastq_r1:
|
|
249
|
+
unaligned_reads = {"fastq_r1": {"path": fastq_r1}}
|
|
250
|
+
if fastq_r2:
|
|
251
|
+
unaligned_reads["fastq_r2"] = {"path": fastq_r2}
|
|
252
|
+
data["unaligned_reads"] = unaligned_reads
|
|
253
|
+
elif fastq_r2:
|
|
254
|
+
raise ValueError(f"SequencingFile '{sf.sample_name}' has fastq_r2 without fastq_r1")
|
|
255
|
+
# No FastQs (BAM-first run) - server resolves the sample from sample_name
|
|
182
256
|
records.append(data)
|
|
183
257
|
|
|
184
258
|
json_data = {
|
|
@@ -231,7 +305,73 @@ class VariantGridAPI:
|
|
|
231
305
|
return self._post("seqauto/api/v1/qc_gene_coverage/bulk_create",
|
|
232
306
|
json_data)
|
|
233
307
|
|
|
234
|
-
|
|
308
|
+
##########################################################
|
|
309
|
+
## Patient -> Specimen -> Extraction (SACGF/variantgrid#1707)
|
|
310
|
+
## Creates are upserts keyed on the identifiers sent, so re-posting returns the same rows
|
|
311
|
+
|
|
312
|
+
def create_patient(self, patient: Patient):
|
|
313
|
+
if not self._require("patients"):
|
|
314
|
+
return None
|
|
315
|
+
self._validate_object("patient", patient)
|
|
316
|
+
return self._post("patients/api/v1/patient/", patient.to_dict())
|
|
317
|
+
|
|
318
|
+
def create_specimen(self, specimen: Specimen):
|
|
319
|
+
""" The specimen's patient must already exist on the server, otherwise this is a 400 """
|
|
320
|
+
if not self._require("patients"):
|
|
321
|
+
return None
|
|
322
|
+
self._validate_object("specimen", specimen)
|
|
323
|
+
return self._post("patients/api/v1/specimen/", specimen.to_dict())
|
|
324
|
+
|
|
325
|
+
def create_extraction(self, extraction: Extraction):
|
|
326
|
+
""" The extraction's specimen must already exist on the server, otherwise this is a 400 """
|
|
327
|
+
if not self._require("patients"):
|
|
328
|
+
return None
|
|
329
|
+
self._validate_object("extraction", extraction)
|
|
330
|
+
return self._post("patients/api/v1/extraction/", extraction.to_dict())
|
|
331
|
+
|
|
332
|
+
def create_specimen_measure(self, specimen_reference: ReferenceLike, measure: SpecimenMeasure):
|
|
333
|
+
""" An unknown specimen is a 400. Replaces any existing measure of the same type for the specimen """
|
|
334
|
+
if not self._require("specimen_measures"):
|
|
335
|
+
return None
|
|
336
|
+
self._validate_reference("specimen_reference", specimen_reference)
|
|
337
|
+
self._validate_object("measure", measure)
|
|
338
|
+
json_data = {"specimen": reference_json(specimen_reference), **measure.to_dict()}
|
|
339
|
+
return self._post("patients/api/v1/specimen_measure/", json_data)
|
|
340
|
+
|
|
341
|
+
def create_specimen_measures(self, specimen_reference: ReferenceLike, measures: List[SpecimenMeasure]):
|
|
342
|
+
""" A run's measures (TMB, MSI, GIS etc) against one specimen in one call """
|
|
343
|
+
if not self._require("specimen_measures"):
|
|
344
|
+
return None
|
|
345
|
+
self._validate_reference("specimen_reference", specimen_reference)
|
|
346
|
+
self._validate_list("measures", measures)
|
|
347
|
+
json_data = {
|
|
348
|
+
"specimen": reference_json(specimen_reference),
|
|
349
|
+
"measures": [measure.to_dict() for measure in measures],
|
|
350
|
+
}
|
|
351
|
+
return self._post("patients/api/v1/specimen_measure/bulk_create", json_data)
|
|
352
|
+
|
|
353
|
+
def link_sequencing_sample_extraction(self, sequencing_sample_lookup: SequencingSampleLookup,
|
|
354
|
+
extraction_reference: ReferenceLike) -> Optional[dict]:
|
|
355
|
+
""" Name the extraction a sequencing sample was made from. One call per sequencing sample is
|
|
356
|
+
enough - the server carries it to every Sample made from that sample's VCFs, and on to the
|
|
357
|
+
new rows if the sample sheet is re-sent.
|
|
358
|
+
|
|
359
|
+
Returns {"sequencing_sample", "match_status", "match_error", "extraction"}. An unknown
|
|
360
|
+
sequencing sample is a 400, but an extraction the server doesn't have yet is not an error:
|
|
361
|
+
the response is a 202 with match_status 'Pending', and the link attaches itself once the
|
|
362
|
+
extraction is created - there's no need to re-send. """
|
|
363
|
+
if not self._require("link_extraction"):
|
|
364
|
+
return None
|
|
365
|
+
self._validate_object("sequencing_sample_lookup", sequencing_sample_lookup)
|
|
366
|
+
self._validate_reference("extraction_reference", extraction_reference)
|
|
367
|
+
json_data = {
|
|
368
|
+
"sequencing_sample": sequencing_sample_lookup.to_dict(),
|
|
369
|
+
"extraction": reference_json(extraction_reference),
|
|
370
|
+
}
|
|
371
|
+
return self._post("seqauto/api/v1/sequencing_sample/link_extraction", json_data)
|
|
372
|
+
|
|
373
|
+
def upload_file(self, filename: str, path=_UNSET, metadata: Optional[dict] = None,
|
|
374
|
+
file_type: Optional[str] = None):
|
|
235
375
|
""" Upload a file via multipart POST to upload/api/v1/file_upload.
|
|
236
376
|
|
|
237
377
|
Returns {"uploaded_file_id": <id>, "sha256_hash": <hash>, ...}; identify the upload by
|
|
@@ -241,16 +381,53 @@ class VariantGridAPI:
|
|
|
241
381
|
(JointCalledVCF / SingleSampleVCF) by path. Defaults to `filename` for backwards
|
|
242
382
|
compatibility. Pass path=None to omit the query param entirely - required for ad-hoc
|
|
243
383
|
uploads such as the annotate/download flow, where sending a client-side path makes
|
|
244
|
-
SeqAuto deployments try (and fail) to match it to a registered VCF.
|
|
384
|
+
SeqAuto deployments try (and fail) to match it to a registered VCF.
|
|
385
|
+
|
|
386
|
+
metadata: facts about the file it doesn't carry itself, sent as extra query params. A VCF accepts:
|
|
387
|
+
'genome_build' - the build's own name ('GRCh37'), not an alias ('hg19')
|
|
388
|
+
'source' - the caller/software, eg 'DRAGEN TSO500 SmallVariant'
|
|
389
|
+
'extraction' - reference (str or ExternalReference) for every sample in the file
|
|
390
|
+
'sample_extractions' - {vcf_sample_name: reference} for a multi-sample VCF
|
|
391
|
+
Send 'extraction' or 'sample_extractions', not both. An unknown key is a 400. An extraction
|
|
392
|
+
the server doesn't have yet is not - it attaches once the extraction is created.
|
|
393
|
+
Needs the server feature 'upload_metadata'. Without it, SKIP uploads the file without the
|
|
394
|
+
metadata (as older clients did) rather than not at all, and ERROR raises
|
|
395
|
+
|
|
396
|
+
file_type: the server's name for what this file is, eg 'dragen_tso500_combined_variant_output'.
|
|
397
|
+
Not sent (the server decides from the filename) - if given, the upload only happens when
|
|
398
|
+
accepts_upload(file_type), so an older server doesn't mis-import it as something else """
|
|
399
|
+
if metadata and not self.supports("upload_metadata"):
|
|
400
|
+
self._unsupported(f"upload metadata for '{filename}' (server doesn't support feature 'upload_metadata')")
|
|
401
|
+
metadata = None
|
|
402
|
+
if file_type and not self._require_upload(file_type):
|
|
403
|
+
return None
|
|
245
404
|
url = self._get_url("upload/api/v1/file_upload")
|
|
246
405
|
if path is _UNSET:
|
|
247
406
|
path = filename
|
|
248
407
|
params = {"path": path} if path is not None else {}
|
|
408
|
+
if metadata:
|
|
409
|
+
params.update(self._upload_metadata_params(metadata))
|
|
249
410
|
with open(filename, "rb") as f:
|
|
250
411
|
response = requests.post(url, headers=self.headers, files={"file": f}, params=params)
|
|
251
412
|
extra_error_message = f"{filename=}"
|
|
252
413
|
return self._handle_json_response(response, extra_error_message)
|
|
253
414
|
|
|
415
|
+
@staticmethod
|
|
416
|
+
def _upload_metadata_params(metadata: dict) -> dict:
|
|
417
|
+
""" Query params are strings - references and objects go as JSON, which the server parses """
|
|
418
|
+
if reserved := {"path", "force"} & set(metadata):
|
|
419
|
+
raise ValueError(f"Upload metadata can't use reserved query param(s): {', '.join(sorted(reserved))}")
|
|
420
|
+
params = {}
|
|
421
|
+
for key, value in metadata.items():
|
|
422
|
+
if isinstance(value, ExternalReference):
|
|
423
|
+
value = reference_json(value)
|
|
424
|
+
elif isinstance(value, dict):
|
|
425
|
+
value = {k: reference_json(v) for k, v in value.items()}
|
|
426
|
+
if isinstance(value, (dict, list)):
|
|
427
|
+
value = json.dumps(value)
|
|
428
|
+
params[key] = value
|
|
429
|
+
return params
|
|
430
|
+
|
|
254
431
|
###############
|
|
255
432
|
## Get methods
|
|
256
433
|
|
|
@@ -291,17 +468,19 @@ class VariantGridAPI:
|
|
|
291
468
|
return f"sha256/{sha256}"
|
|
292
469
|
raise ValueError("Must provide one of 'uploaded_file_id' or 'sha256'")
|
|
293
470
|
|
|
294
|
-
def poll_upload_status(self, uploaded_file_id: Optional[int] = None, sha256: Optional[str] = None) -> dict:
|
|
471
|
+
def poll_upload_status(self, uploaded_file_id: Optional[int] = None, sha256: Optional[str] = None) -> Optional[dict]:
|
|
295
472
|
""" Single GET of an uploaded file's import/annotation status.
|
|
296
473
|
|
|
297
474
|
Keyed by uploaded_file_id (returned from upload_file) or the SHA-256 of the uploaded file.
|
|
298
|
-
See wait_for_annotation to block until annotation is complete. """
|
|
475
|
+
See wait_for_annotation to block until annotation is complete. Needs server feature 'upload_status' """
|
|
476
|
+
if not self._require("upload_status"):
|
|
477
|
+
return None
|
|
299
478
|
segment = self._upload_key_segment(uploaded_file_id, sha256)
|
|
300
479
|
return self._get(f"upload/api/v1/upload_status/{segment}")
|
|
301
480
|
|
|
302
481
|
def wait_for_annotation(self, uploaded_file_id: Optional[int] = None, sha256: Optional[str] = None,
|
|
303
482
|
timeout: float = 3600, poll_interval: float = 10, sleep: Callable = time.sleep,
|
|
304
|
-
max_transient_errors: int = 5) -> dict:
|
|
483
|
+
max_transient_errors: int = 5) -> Optional[dict]:
|
|
305
484
|
""" Poll poll_upload_status until 'annotation_complete' is true, then return the final status dict.
|
|
306
485
|
|
|
307
486
|
Raises AnnotationError if the server reports an 'error', or TimeoutError if 'timeout' seconds elapse.
|
|
@@ -310,7 +489,9 @@ class VariantGridAPI:
|
|
|
310
489
|
Transient server hiccups (5xx / connection / timeout - e.g. a brief 500 right after upload while the
|
|
311
490
|
server is still creating the upload record) are tolerated: up to 'max_transient_errors' *consecutive*
|
|
312
491
|
failures are retried before giving up. A 4xx response is treated as a real error and raised immediately.
|
|
313
|
-
The success counter resets whenever a poll succeeds. """
|
|
492
|
+
The success counter resets whenever a poll succeeds. Needs server feature 'upload_status' """
|
|
493
|
+
if not self._require("upload_status"):
|
|
494
|
+
return None
|
|
314
495
|
deadline = time.monotonic() + timeout
|
|
315
496
|
transient_errors = 0
|
|
316
497
|
while True:
|
|
@@ -349,7 +530,7 @@ class VariantGridAPI:
|
|
|
349
530
|
def download_annotated(self, uploaded_file_id: Optional[int] = None, sha256: Optional[str] = None,
|
|
350
531
|
export_type: str = "vcf", dest_path: Optional[Union[str, Path]] = None,
|
|
351
532
|
timeout: float = 3600, poll_interval: float = 10,
|
|
352
|
-
sleep: Callable = time.sleep) -> Path:
|
|
533
|
+
sleep: Callable = time.sleep) -> Optional[Path]:
|
|
353
534
|
""" Download the cohort-level annotated export of an uploaded VCF, saving it to disk.
|
|
354
535
|
|
|
355
536
|
export_type is 'vcf' (gzipped *.vcf.gz) or 'csv' (zipped *.csv.zip). The export covers all samples
|
|
@@ -360,7 +541,9 @@ class VariantGridAPI:
|
|
|
360
541
|
in the current directory; if it is an existing directory the attachment filename is placed inside it;
|
|
361
542
|
otherwise it is treated as the full destination path. Returns the Path written.
|
|
362
543
|
|
|
363
|
-
Raises TimeoutError if the file isn't ready within 'timeout' seconds. """
|
|
544
|
+
Raises TimeoutError if the file isn't ready within 'timeout' seconds. Needs server feature 'upload_status' """
|
|
545
|
+
if not self._require("upload_status"):
|
|
546
|
+
return None
|
|
364
547
|
if export_type not in ("vcf", "csv"):
|
|
365
548
|
raise ValueError(f"export_type must be 'vcf' or 'csv', got {export_type!r}")
|
|
366
549
|
segment = self._upload_key_segment(uploaded_file_id, sha256)
|
|
@@ -402,10 +585,13 @@ class VariantGridAPI:
|
|
|
402
585
|
return None
|
|
403
586
|
|
|
404
587
|
def annotate_vcf(self, filename: str, export_type: str = "vcf", dest_path: Optional[Union[str, Path]] = None,
|
|
405
|
-
timeout: float = 3600, poll_interval: float = 10, sleep: Callable = time.sleep) -> Path:
|
|
588
|
+
timeout: float = 3600, poll_interval: float = 10, sleep: Callable = time.sleep) -> Optional[Path]:
|
|
406
589
|
""" Convenience one-liner: upload a VCF, wait for annotation to finish, download the annotated export.
|
|
407
590
|
|
|
408
|
-
Chains upload_file -> wait_for_annotation -> download_annotated and returns the Path written.
|
|
591
|
+
Chains upload_file -> wait_for_annotation -> download_annotated and returns the Path written.
|
|
592
|
+
Needs server feature 'upload_status' - checked before uploading """
|
|
593
|
+
if not self._require("upload_status"):
|
|
594
|
+
return None
|
|
409
595
|
# path is SeqAuto-only and makes ad-hoc uploads fail the import - omit it for the annotate flow
|
|
410
596
|
upload = self.upload_file(filename, path=None)
|
|
411
597
|
uploaded_file_id = upload["uploaded_file_id"]
|
|
@@ -13,7 +13,7 @@ import sys
|
|
|
13
13
|
|
|
14
14
|
import requests
|
|
15
15
|
|
|
16
|
-
from variantgrid_api.api_client import VariantGridAPI, AnnotationError
|
|
16
|
+
from variantgrid_api.api_client import VariantGridAPI, AnnotationError, UnsupportedFeatureError
|
|
17
17
|
|
|
18
18
|
DEFAULT_SERVER = "https://variantgrid.com"
|
|
19
19
|
|
|
@@ -147,6 +147,9 @@ def main(argv=None):
|
|
|
147
147
|
except requests.HTTPError as e:
|
|
148
148
|
print(f"HTTP error: {e}", file=sys.stderr)
|
|
149
149
|
return EXIT_ERROR
|
|
150
|
+
except UnsupportedFeatureError as e:
|
|
151
|
+
print(f"This VariantGrid server can't annotate uploaded VCFs via the API: {e}", file=sys.stderr)
|
|
152
|
+
return EXIT_ERROR
|
|
150
153
|
|
|
151
154
|
|
|
152
155
|
if __name__ == "__main__":
|