variantgrid-api 1.4.0__tar.gz → 1.6.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {variantgrid_api-1.4.0/src/variantgrid_api.egg-info → variantgrid_api-1.6.0}/PKG-INFO +48 -1
- variantgrid_api-1.6.0/README.md +101 -0
- {variantgrid_api-1.4.0 → variantgrid_api-1.6.0}/pyproject.toml +1 -1
- {variantgrid_api-1.4.0 → variantgrid_api-1.6.0}/src/variantgrid_api/api_client.py +207 -18
- {variantgrid_api-1.4.0 → variantgrid_api-1.6.0}/src/variantgrid_api/cli.py +4 -1
- variantgrid_api-1.6.0/src/variantgrid_api/data_models.py +506 -0
- {variantgrid_api-1.4.0 → variantgrid_api-1.6.0}/src/variantgrid_api/mock_variantgrid_api.py +111 -5
- {variantgrid_api-1.4.0 → variantgrid_api-1.6.0/src/variantgrid_api.egg-info}/PKG-INFO +48 -1
- {variantgrid_api-1.4.0 → variantgrid_api-1.6.0}/src/variantgrid_api.egg-info/SOURCES.txt +2 -0
- {variantgrid_api-1.4.0 → variantgrid_api-1.6.0}/tests/test_api_client.py +56 -1
- {variantgrid_api-1.4.0 → variantgrid_api-1.6.0}/tests/test_api_client_annotation.py +5 -0
- variantgrid_api-1.6.0/tests/test_api_client_capabilities.py +234 -0
- variantgrid_api-1.6.0/tests/test_api_client_patients.py +245 -0
- {variantgrid_api-1.4.0 → variantgrid_api-1.6.0}/tests/test_cli.py +25 -0
- {variantgrid_api-1.4.0 → variantgrid_api-1.6.0}/tests/test_mock_variantgrid_api.py +97 -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.6.0}/LICENSE +0 -0
- {variantgrid_api-1.4.0 → variantgrid_api-1.6.0}/setup.cfg +0 -0
- {variantgrid_api-1.4.0 → variantgrid_api-1.6.0}/src/variantgrid_api.egg-info/dependency_links.txt +0 -0
- {variantgrid_api-1.4.0 → variantgrid_api-1.6.0}/src/variantgrid_api.egg-info/entry_points.txt +0 -0
- {variantgrid_api-1.4.0 → variantgrid_api-1.6.0}/src/variantgrid_api.egg-info/requires.txt +0 -0
- {variantgrid_api-1.4.0 → variantgrid_api-1.6.0}/src/variantgrid_api.egg-info/top_level.txt +0 -0
- {variantgrid_api-1.4.0 → variantgrid_api-1.6.0}/tests/test_api_client_bulk.py +0 -0
- {variantgrid_api-1.4.0 → variantgrid_api-1.6.0}/tests/test_api_client_validation.py +0 -0
- {variantgrid_api-1.4.0 → variantgrid_api-1.6.0}/tests/test_data_models.py +0 -0
- {variantgrid_api-1.4.0 → variantgrid_api-1.6.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.6.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, ServerFeature, UploadFileType
|
|
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: Union[ServerFeature, str]) -> bool:
|
|
165
|
+
return feature in self.capabilities.features
|
|
166
|
+
|
|
167
|
+
def accepts_upload(self, file_type: Union[UploadFileType, str]) -> bool:
|
|
168
|
+
""" file_type is an UploadFileType, or the server's UploadedFileTypes name in lower case """
|
|
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: Union[ServerFeature, 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: Union[UploadFileType, 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 = {
|
|
@@ -171,14 +241,21 @@ class VariantGridAPI:
|
|
|
171
241
|
self._validate_list("sequencing_files", sequencing_files)
|
|
172
242
|
records = []
|
|
173
243
|
for sf in sequencing_files:
|
|
244
|
+
# The server requires both paths - catch it here, naming the record, rather than a 400 for the batch
|
|
245
|
+
self._validate_string(f"SequencingFile '{sf.sample_name}' bam_file.path", sf.bam_file and sf.bam_file.path)
|
|
246
|
+
self._validate_string(f"SequencingFile '{sf.sample_name}' vcf_file.path", sf.vcf_file and sf.vcf_file.path)
|
|
174
247
|
data = sf.to_dict()
|
|
175
248
|
# 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
|
-
|
|
249
|
+
fastq_r1 = data.pop("fastq_r1", None)
|
|
250
|
+
fastq_r2 = data.pop("fastq_r2", None)
|
|
251
|
+
if fastq_r1:
|
|
252
|
+
unaligned_reads = {"fastq_r1": {"path": fastq_r1}}
|
|
253
|
+
if fastq_r2:
|
|
254
|
+
unaligned_reads["fastq_r2"] = {"path": fastq_r2}
|
|
255
|
+
data["unaligned_reads"] = unaligned_reads
|
|
256
|
+
elif fastq_r2:
|
|
257
|
+
raise ValueError(f"SequencingFile '{sf.sample_name}' has fastq_r2 without fastq_r1")
|
|
258
|
+
# No FastQs (BAM-first run) - server resolves the sample from sample_name
|
|
182
259
|
records.append(data)
|
|
183
260
|
|
|
184
261
|
json_data = {
|
|
@@ -231,7 +308,73 @@ class VariantGridAPI:
|
|
|
231
308
|
return self._post("seqauto/api/v1/qc_gene_coverage/bulk_create",
|
|
232
309
|
json_data)
|
|
233
310
|
|
|
234
|
-
|
|
311
|
+
##########################################################
|
|
312
|
+
## Patient -> Specimen -> Extraction (SACGF/variantgrid#1707)
|
|
313
|
+
## Creates are upserts keyed on the identifiers sent, so re-posting returns the same rows
|
|
314
|
+
|
|
315
|
+
def create_patient(self, patient: Patient):
|
|
316
|
+
if not self._require(ServerFeature.PATIENTS):
|
|
317
|
+
return None
|
|
318
|
+
self._validate_object("patient", patient)
|
|
319
|
+
return self._post("patients/api/v1/patient/", patient.to_dict())
|
|
320
|
+
|
|
321
|
+
def create_specimen(self, specimen: Specimen):
|
|
322
|
+
""" The specimen's patient must already exist on the server, otherwise this is a 400 """
|
|
323
|
+
if not self._require(ServerFeature.PATIENTS):
|
|
324
|
+
return None
|
|
325
|
+
self._validate_object("specimen", specimen)
|
|
326
|
+
return self._post("patients/api/v1/specimen/", specimen.to_dict())
|
|
327
|
+
|
|
328
|
+
def create_extraction(self, extraction: Extraction):
|
|
329
|
+
""" The extraction's specimen must already exist on the server, otherwise this is a 400 """
|
|
330
|
+
if not self._require(ServerFeature.PATIENTS):
|
|
331
|
+
return None
|
|
332
|
+
self._validate_object("extraction", extraction)
|
|
333
|
+
return self._post("patients/api/v1/extraction/", extraction.to_dict())
|
|
334
|
+
|
|
335
|
+
def create_specimen_measure(self, specimen_reference: ReferenceLike, measure: SpecimenMeasure):
|
|
336
|
+
""" An unknown specimen is a 400. Replaces any existing measure of the same type for the specimen """
|
|
337
|
+
if not self._require(ServerFeature.SPECIMEN_MEASURES):
|
|
338
|
+
return None
|
|
339
|
+
self._validate_reference("specimen_reference", specimen_reference)
|
|
340
|
+
self._validate_object("measure", measure)
|
|
341
|
+
json_data = {"specimen": reference_json(specimen_reference), **measure.to_dict()}
|
|
342
|
+
return self._post("patients/api/v1/specimen_measure/", json_data)
|
|
343
|
+
|
|
344
|
+
def create_specimen_measures(self, specimen_reference: ReferenceLike, measures: List[SpecimenMeasure]):
|
|
345
|
+
""" A run's measures (TMB, MSI, GIS etc) against one specimen in one call """
|
|
346
|
+
if not self._require(ServerFeature.SPECIMEN_MEASURES):
|
|
347
|
+
return None
|
|
348
|
+
self._validate_reference("specimen_reference", specimen_reference)
|
|
349
|
+
self._validate_list("measures", measures)
|
|
350
|
+
json_data = {
|
|
351
|
+
"specimen": reference_json(specimen_reference),
|
|
352
|
+
"measures": [measure.to_dict() for measure in measures],
|
|
353
|
+
}
|
|
354
|
+
return self._post("patients/api/v1/specimen_measure/bulk_create", json_data)
|
|
355
|
+
|
|
356
|
+
def link_sequencing_sample_extraction(self, sequencing_sample_lookup: SequencingSampleLookup,
|
|
357
|
+
extraction_reference: ReferenceLike) -> Optional[dict]:
|
|
358
|
+
""" Name the extraction a sequencing sample was made from. One call per sequencing sample is
|
|
359
|
+
enough - the server carries it to every Sample made from that sample's VCFs, and on to the
|
|
360
|
+
new rows if the sample sheet is re-sent.
|
|
361
|
+
|
|
362
|
+
Returns {"sequencing_sample", "match_status", "match_error", "extraction"}. An unknown
|
|
363
|
+
sequencing sample is a 400, but an extraction the server doesn't have yet is not an error:
|
|
364
|
+
the response is a 202 with match_status 'Pending', and the link attaches itself once the
|
|
365
|
+
extraction is created - there's no need to re-send. """
|
|
366
|
+
if not self._require(ServerFeature.LINK_EXTRACTION):
|
|
367
|
+
return None
|
|
368
|
+
self._validate_object("sequencing_sample_lookup", sequencing_sample_lookup)
|
|
369
|
+
self._validate_reference("extraction_reference", extraction_reference)
|
|
370
|
+
json_data = {
|
|
371
|
+
"sequencing_sample": sequencing_sample_lookup.to_dict(),
|
|
372
|
+
"extraction": reference_json(extraction_reference),
|
|
373
|
+
}
|
|
374
|
+
return self._post("seqauto/api/v1/sequencing_sample/link_extraction", json_data)
|
|
375
|
+
|
|
376
|
+
def upload_file(self, filename: str, path=_UNSET, metadata: Optional[dict] = None,
|
|
377
|
+
file_type: Optional[Union[UploadFileType, str]] = None):
|
|
235
378
|
""" Upload a file via multipart POST to upload/api/v1/file_upload.
|
|
236
379
|
|
|
237
380
|
Returns {"uploaded_file_id": <id>, "sha256_hash": <hash>, ...}; identify the upload by
|
|
@@ -241,16 +384,53 @@ class VariantGridAPI:
|
|
|
241
384
|
(JointCalledVCF / SingleSampleVCF) by path. Defaults to `filename` for backwards
|
|
242
385
|
compatibility. Pass path=None to omit the query param entirely - required for ad-hoc
|
|
243
386
|
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.
|
|
387
|
+
SeqAuto deployments try (and fail) to match it to a registered VCF.
|
|
388
|
+
|
|
389
|
+
metadata: facts about the file it doesn't carry itself, sent as extra query params. A VCF accepts:
|
|
390
|
+
'genome_build' - the build's own name ('GRCh37'), not an alias ('hg19')
|
|
391
|
+
'source' - the caller/software, eg 'DRAGEN TSO500 SmallVariant'
|
|
392
|
+
'extraction' - reference (str or ExternalReference) for every sample in the file
|
|
393
|
+
'sample_extractions' - {vcf_sample_name: reference} for a multi-sample VCF
|
|
394
|
+
Send 'extraction' or 'sample_extractions', not both. An unknown key is a 400. An extraction
|
|
395
|
+
the server doesn't have yet is not - it attaches once the extraction is created.
|
|
396
|
+
Needs the server feature 'upload_metadata'. Without it, SKIP uploads the file without the
|
|
397
|
+
metadata (as older clients did) rather than not at all, and ERROR raises
|
|
398
|
+
|
|
399
|
+
file_type: the server's name for what this file is, eg UploadFileType.DRAGEN_TSO500_COMBINED_VARIANT_OUTPUT.
|
|
400
|
+
Not sent (the server decides from the filename) - if given, the upload only happens when
|
|
401
|
+
accepts_upload(file_type), so an older server doesn't mis-import it as something else """
|
|
402
|
+
if metadata and not self.supports(ServerFeature.UPLOAD_METADATA):
|
|
403
|
+
self._unsupported(f"upload metadata for '{filename}' (server doesn't support feature 'upload_metadata')")
|
|
404
|
+
metadata = None
|
|
405
|
+
if file_type and not self._require_upload(file_type):
|
|
406
|
+
return None
|
|
245
407
|
url = self._get_url("upload/api/v1/file_upload")
|
|
246
408
|
if path is _UNSET:
|
|
247
409
|
path = filename
|
|
248
410
|
params = {"path": path} if path is not None else {}
|
|
411
|
+
if metadata:
|
|
412
|
+
params.update(self._upload_metadata_params(metadata))
|
|
249
413
|
with open(filename, "rb") as f:
|
|
250
414
|
response = requests.post(url, headers=self.headers, files={"file": f}, params=params)
|
|
251
415
|
extra_error_message = f"{filename=}"
|
|
252
416
|
return self._handle_json_response(response, extra_error_message)
|
|
253
417
|
|
|
418
|
+
@staticmethod
|
|
419
|
+
def _upload_metadata_params(metadata: dict) -> dict:
|
|
420
|
+
""" Query params are strings - references and objects go as JSON, which the server parses """
|
|
421
|
+
if reserved := {"path", "force"} & set(metadata):
|
|
422
|
+
raise ValueError(f"Upload metadata can't use reserved query param(s): {', '.join(sorted(reserved))}")
|
|
423
|
+
params = {}
|
|
424
|
+
for key, value in metadata.items():
|
|
425
|
+
if isinstance(value, ExternalReference):
|
|
426
|
+
value = reference_json(value)
|
|
427
|
+
elif isinstance(value, dict):
|
|
428
|
+
value = {k: reference_json(v) for k, v in value.items()}
|
|
429
|
+
if isinstance(value, (dict, list)):
|
|
430
|
+
value = json.dumps(value)
|
|
431
|
+
params[key] = value
|
|
432
|
+
return params
|
|
433
|
+
|
|
254
434
|
###############
|
|
255
435
|
## Get methods
|
|
256
436
|
|
|
@@ -291,17 +471,19 @@ class VariantGridAPI:
|
|
|
291
471
|
return f"sha256/{sha256}"
|
|
292
472
|
raise ValueError("Must provide one of 'uploaded_file_id' or 'sha256'")
|
|
293
473
|
|
|
294
|
-
def poll_upload_status(self, uploaded_file_id: Optional[int] = None, sha256: Optional[str] = None) -> dict:
|
|
474
|
+
def poll_upload_status(self, uploaded_file_id: Optional[int] = None, sha256: Optional[str] = None) -> Optional[dict]:
|
|
295
475
|
""" Single GET of an uploaded file's import/annotation status.
|
|
296
476
|
|
|
297
477
|
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. """
|
|
478
|
+
See wait_for_annotation to block until annotation is complete. Needs server feature 'upload_status' """
|
|
479
|
+
if not self._require(ServerFeature.UPLOAD_STATUS):
|
|
480
|
+
return None
|
|
299
481
|
segment = self._upload_key_segment(uploaded_file_id, sha256)
|
|
300
482
|
return self._get(f"upload/api/v1/upload_status/{segment}")
|
|
301
483
|
|
|
302
484
|
def wait_for_annotation(self, uploaded_file_id: Optional[int] = None, sha256: Optional[str] = None,
|
|
303
485
|
timeout: float = 3600, poll_interval: float = 10, sleep: Callable = time.sleep,
|
|
304
|
-
max_transient_errors: int = 5) -> dict:
|
|
486
|
+
max_transient_errors: int = 5) -> Optional[dict]:
|
|
305
487
|
""" Poll poll_upload_status until 'annotation_complete' is true, then return the final status dict.
|
|
306
488
|
|
|
307
489
|
Raises AnnotationError if the server reports an 'error', or TimeoutError if 'timeout' seconds elapse.
|
|
@@ -310,7 +492,9 @@ class VariantGridAPI:
|
|
|
310
492
|
Transient server hiccups (5xx / connection / timeout - e.g. a brief 500 right after upload while the
|
|
311
493
|
server is still creating the upload record) are tolerated: up to 'max_transient_errors' *consecutive*
|
|
312
494
|
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. """
|
|
495
|
+
The success counter resets whenever a poll succeeds. Needs server feature 'upload_status' """
|
|
496
|
+
if not self._require(ServerFeature.UPLOAD_STATUS):
|
|
497
|
+
return None
|
|
314
498
|
deadline = time.monotonic() + timeout
|
|
315
499
|
transient_errors = 0
|
|
316
500
|
while True:
|
|
@@ -349,7 +533,7 @@ class VariantGridAPI:
|
|
|
349
533
|
def download_annotated(self, uploaded_file_id: Optional[int] = None, sha256: Optional[str] = None,
|
|
350
534
|
export_type: str = "vcf", dest_path: Optional[Union[str, Path]] = None,
|
|
351
535
|
timeout: float = 3600, poll_interval: float = 10,
|
|
352
|
-
sleep: Callable = time.sleep) -> Path:
|
|
536
|
+
sleep: Callable = time.sleep) -> Optional[Path]:
|
|
353
537
|
""" Download the cohort-level annotated export of an uploaded VCF, saving it to disk.
|
|
354
538
|
|
|
355
539
|
export_type is 'vcf' (gzipped *.vcf.gz) or 'csv' (zipped *.csv.zip). The export covers all samples
|
|
@@ -360,7 +544,9 @@ class VariantGridAPI:
|
|
|
360
544
|
in the current directory; if it is an existing directory the attachment filename is placed inside it;
|
|
361
545
|
otherwise it is treated as the full destination path. Returns the Path written.
|
|
362
546
|
|
|
363
|
-
Raises TimeoutError if the file isn't ready within 'timeout' seconds. """
|
|
547
|
+
Raises TimeoutError if the file isn't ready within 'timeout' seconds. Needs server feature 'upload_status' """
|
|
548
|
+
if not self._require(ServerFeature.UPLOAD_STATUS):
|
|
549
|
+
return None
|
|
364
550
|
if export_type not in ("vcf", "csv"):
|
|
365
551
|
raise ValueError(f"export_type must be 'vcf' or 'csv', got {export_type!r}")
|
|
366
552
|
segment = self._upload_key_segment(uploaded_file_id, sha256)
|
|
@@ -402,10 +588,13 @@ class VariantGridAPI:
|
|
|
402
588
|
return None
|
|
403
589
|
|
|
404
590
|
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:
|
|
591
|
+
timeout: float = 3600, poll_interval: float = 10, sleep: Callable = time.sleep) -> Optional[Path]:
|
|
406
592
|
""" Convenience one-liner: upload a VCF, wait for annotation to finish, download the annotated export.
|
|
407
593
|
|
|
408
|
-
Chains upload_file -> wait_for_annotation -> download_annotated and returns the Path written.
|
|
594
|
+
Chains upload_file -> wait_for_annotation -> download_annotated and returns the Path written.
|
|
595
|
+
Needs server feature 'upload_status' - checked before uploading """
|
|
596
|
+
if not self._require(ServerFeature.UPLOAD_STATUS):
|
|
597
|
+
return None
|
|
409
598
|
# path is SeqAuto-only and makes ad-hoc uploads fail the import - omit it for the annotate flow
|
|
410
599
|
upload = self.upload_file(filename, path=None)
|
|
411
600
|
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__":
|