variantgrid-api 1.9.0__tar.gz → 1.11.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.9.0/src/variantgrid_api.egg-info → variantgrid_api-1.11.0}/PKG-INFO +38 -12
- {variantgrid_api-1.9.0 → variantgrid_api-1.11.0}/README.md +37 -11
- {variantgrid_api-1.9.0 → variantgrid_api-1.11.0}/pyproject.toml +1 -1
- {variantgrid_api-1.9.0 → variantgrid_api-1.11.0}/src/variantgrid_api/api_client.py +113 -20
- {variantgrid_api-1.9.0 → variantgrid_api-1.11.0}/src/variantgrid_api/cli.py +20 -2
- {variantgrid_api-1.9.0 → variantgrid_api-1.11.0}/src/variantgrid_api/data_models.py +9 -0
- {variantgrid_api-1.9.0 → variantgrid_api-1.11.0}/src/variantgrid_api/mock_variantgrid_api.py +51 -6
- {variantgrid_api-1.9.0 → variantgrid_api-1.11.0/src/variantgrid_api.egg-info}/PKG-INFO +38 -12
- {variantgrid_api-1.9.0 → variantgrid_api-1.11.0}/tests/test_api_client_annotation.py +131 -1
- {variantgrid_api-1.9.0 → variantgrid_api-1.11.0}/tests/test_api_client_capabilities.py +58 -1
- {variantgrid_api-1.9.0 → variantgrid_api-1.11.0}/tests/test_api_client_patients.py +125 -1
- {variantgrid_api-1.9.0 → variantgrid_api-1.11.0}/tests/test_cli.py +46 -0
- {variantgrid_api-1.9.0 → variantgrid_api-1.11.0}/tests/test_mock_variantgrid_api.py +67 -0
- {variantgrid_api-1.9.0 → variantgrid_api-1.11.0}/LICENSE +0 -0
- {variantgrid_api-1.9.0 → variantgrid_api-1.11.0}/setup.cfg +0 -0
- {variantgrid_api-1.9.0 → variantgrid_api-1.11.0}/src/variantgrid_api.egg-info/SOURCES.txt +0 -0
- {variantgrid_api-1.9.0 → variantgrid_api-1.11.0}/src/variantgrid_api.egg-info/dependency_links.txt +0 -0
- {variantgrid_api-1.9.0 → variantgrid_api-1.11.0}/src/variantgrid_api.egg-info/entry_points.txt +0 -0
- {variantgrid_api-1.9.0 → variantgrid_api-1.11.0}/src/variantgrid_api.egg-info/requires.txt +0 -0
- {variantgrid_api-1.9.0 → variantgrid_api-1.11.0}/src/variantgrid_api.egg-info/top_level.txt +0 -0
- {variantgrid_api-1.9.0 → variantgrid_api-1.11.0}/tests/test_alignment_files.py +0 -0
- {variantgrid_api-1.9.0 → variantgrid_api-1.11.0}/tests/test_api_client.py +0 -0
- {variantgrid_api-1.9.0 → variantgrid_api-1.11.0}/tests/test_api_client_bulk.py +0 -0
- {variantgrid_api-1.9.0 → variantgrid_api-1.11.0}/tests/test_api_client_validation.py +0 -0
- {variantgrid_api-1.9.0 → variantgrid_api-1.11.0}/tests/test_data_models.py +0 -0
- {variantgrid_api-1.9.0 → variantgrid_api-1.11.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.11.0
|
|
4
4
|
Summary: A Python API client for VariantGrid
|
|
5
5
|
Author-email: Dave Lawrence <davmlaw@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -64,7 +64,10 @@ $ vg_api annotate_vcf input.vcf.gz -o results/ # once it's done
|
|
|
64
64
|
Annotated vcf written to results/input.vcf_annotated_v254_GRCh38.vcf.gz
|
|
65
65
|
```
|
|
66
66
|
|
|
67
|
-
|
|
67
|
+
`-o` takes a file or a directory. A path ending in `/` is a directory, created if it doesn't exist yet, and the
|
|
68
|
+
download keeps the server's filename. `--export-type csv` downloads a `.csv.zip`. Add `--compression none` for a
|
|
69
|
+
plain `.csv` (needs a server at or after SACGF/variantgrid#2368). Add `--verbose` to log each request, response
|
|
70
|
+
and the full upload status to stderr.
|
|
68
71
|
|
|
69
72
|
From Python it's `upload_file()` / `poll_upload_status()` / `download_annotated()`, or the blocking
|
|
70
73
|
`annotate_vcf()` one-liner. See
|
|
@@ -90,6 +93,21 @@ api.upload_file("sample.vcf.gz", path=None,
|
|
|
90
93
|
A bare string names a record by its local reference. Use `ExternalReference(code=..., external_type=...)` to
|
|
91
94
|
name it by a LIMS identifier instead. See `examples/example_tso500.py` for a full run.
|
|
92
95
|
|
|
96
|
+
Once the sample sheet is registered, link its sequencing samples to their extractions in one call, keyed on
|
|
97
|
+
sample name (what `SequencingSampleLookup.sample_name` holds):
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
api.link_sequencing_sample_extractions(sample_sheet_lookup, {"sample_1": "2600000001C",
|
|
101
|
+
"sample_2": "2600000002C"})
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
It returns a list with one `{"sequencing_sample", "match_status", "match_error", "extraction"}` per sample. An
|
|
105
|
+
extraction the server doesn't have yet is `Pending` for that sample only (HTTP 202) and attaches itself once the
|
|
106
|
+
extraction is created; a sample name that isn't on the sheet is a 400 for the whole batch. On a server from
|
|
107
|
+
before SACGF/variantgrid#2176 (no `link_extractions_bulk` feature) the client makes one
|
|
108
|
+
`link_sequencing_sample_extraction` call per sample instead, so pipelines can use the bulk call unconditionally.
|
|
109
|
+
`link_sequencing_sample_extraction` is still there for a single late link. See `examples/example_haem_20_999.py`.
|
|
110
|
+
|
|
93
111
|
## BAMs and CRAMs
|
|
94
112
|
|
|
95
113
|
A sequencing sample can have several alignment files, eg a BAM and its recalibrated BAM, or a BAM plus a CRAM.
|
|
@@ -113,17 +131,22 @@ file as `bam_file`, and a QC's first alignment file as its `bam_file`. A CRAM th
|
|
|
113
131
|
|
|
114
132
|
Servers of different ages accept different calls. The client asks the server which features it has
|
|
115
133
|
(`GET api/v1/capabilities`, fetched once on first use), and each call that needs a newer server
|
|
116
|
-
checks that list first: the patient / specimen / extraction calls,
|
|
117
|
-
`link_sequencing_sample_extraction`, the annotate flow
|
|
118
|
-
`download_annotated`, `annotate_vcf`), and `upload_file` with
|
|
119
|
-
capabilities endpoint counts as legacy and
|
|
134
|
+
checks that list first: the patient / specimen / extraction calls,
|
|
135
|
+
`link_sequencing_sample_extraction` and `link_sequencing_sample_extractions`, the annotate flow
|
|
136
|
+
(`poll_upload_status`, `wait_for_annotation`, `download_annotated`, `annotate_vcf`), and `upload_file` with
|
|
137
|
+
`metadata`, `file_type` or `required_features`. A server without the capabilities endpoint counts as legacy and
|
|
138
|
+
reports no features. From SACGF/variantgrid#2732 the endpoint is public, so `VariantGridAPI(server, None)` can
|
|
139
|
+
probe a server before you have a token.
|
|
120
140
|
|
|
121
141
|
By default an unsupported call raises `UnsupportedFeatureError`. To run the same code against old and new
|
|
122
|
-
servers, skip those calls instead: they log a warning and return `None`.
|
|
123
|
-
server
|
|
142
|
+
servers, skip those calls instead: they log a warning and return `None`. Two calls downgrade rather than skip
|
|
143
|
+
when the server has the older form of the feature: `upload_file` on a server without upload metadata drops the
|
|
144
|
+
`metadata` and still uploads the file, and `link_sequencing_sample_extractions` on a server with only
|
|
145
|
+
`link_extraction` makes one call per sample.
|
|
124
146
|
|
|
125
147
|
```
|
|
126
148
|
from variantgrid_api.api_client import VariantGridAPI, UnsupportedFeaturePolicy
|
|
149
|
+
from variantgrid_api.data_models import ServerFeature
|
|
127
150
|
|
|
128
151
|
api = VariantGridAPI(server, api_token, unsupported_feature_policy=UnsupportedFeaturePolicy.SKIP)
|
|
129
152
|
api.create_patient(patient) # skipped on a server without patients
|
|
@@ -131,6 +154,9 @@ api.create_patient(patient) # skipped on a server without patients
|
|
|
131
154
|
# Uploads only when the server has an importer for this file type
|
|
132
155
|
api.upload_file("sample_CombinedVariantOutput.tsv", path=None,
|
|
133
156
|
file_type="dragen_tso500_combined_variant_output")
|
|
157
|
+
|
|
158
|
+
# An SV/CNV VCF with symbolic alts (<DEL>, <DUP>, <INV>, <CNV>) - only a server that imports them (not VG3)
|
|
159
|
+
api.upload_file("sample.sv.vcf.gz", path=None, required_features=[ServerFeature.SYMBOLIC_ALTS])
|
|
134
160
|
```
|
|
135
161
|
|
|
136
162
|
Where the fallback isn't simply "do nothing", branch on `api.supports("feature")` or
|
|
@@ -163,10 +189,10 @@ specimen measures. See `examples/example_tso500.py` for a full run.
|
|
|
163
189
|
## Permission to write sequencing data
|
|
164
190
|
|
|
165
191
|
Posts to the server's `seqauto/api/` endpoints - the sequencing `create_*` calls from `create_experiment` through
|
|
166
|
-
`create_sequencing_data` and the QC calls, and `link_sequencing_sample_extraction` - need a
|
|
167
|
-
or one belonging to a member of the server's SeqAuto write group (`seqauto_api_write`
|
|
168
|
-
`SEQAUTO_API_WRITE_GROUP` setting says otherwise). Anyone else gets a 403
|
|
169
|
-
`sequencing_run_has_vcf()`, need only a valid token.
|
|
192
|
+
`create_sequencing_data` and the QC calls, and the `link_sequencing_sample_extraction(s)` calls - need a
|
|
193
|
+
superuser's API token, or one belonging to a member of the server's SeqAuto write group (`seqauto_api_write`
|
|
194
|
+
unless the server's `SEQAUTO_API_WRITE_GROUP` setting says otherwise). Anyone else gets a 403
|
|
195
|
+
(`requests.HTTPError`). Reads, such as `sequencing_run_has_vcf()`, need only a valid token.
|
|
170
196
|
|
|
171
197
|
## Testing
|
|
172
198
|
|
|
@@ -43,7 +43,10 @@ $ vg_api annotate_vcf input.vcf.gz -o results/ # once it's done
|
|
|
43
43
|
Annotated vcf written to results/input.vcf_annotated_v254_GRCh38.vcf.gz
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
`-o` takes a file or a directory. A path ending in `/` is a directory, created if it doesn't exist yet, and the
|
|
47
|
+
download keeps the server's filename. `--export-type csv` downloads a `.csv.zip`. Add `--compression none` for a
|
|
48
|
+
plain `.csv` (needs a server at or after SACGF/variantgrid#2368). Add `--verbose` to log each request, response
|
|
49
|
+
and the full upload status to stderr.
|
|
47
50
|
|
|
48
51
|
From Python it's `upload_file()` / `poll_upload_status()` / `download_annotated()`, or the blocking
|
|
49
52
|
`annotate_vcf()` one-liner. See
|
|
@@ -69,6 +72,21 @@ api.upload_file("sample.vcf.gz", path=None,
|
|
|
69
72
|
A bare string names a record by its local reference. Use `ExternalReference(code=..., external_type=...)` to
|
|
70
73
|
name it by a LIMS identifier instead. See `examples/example_tso500.py` for a full run.
|
|
71
74
|
|
|
75
|
+
Once the sample sheet is registered, link its sequencing samples to their extractions in one call, keyed on
|
|
76
|
+
sample name (what `SequencingSampleLookup.sample_name` holds):
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
api.link_sequencing_sample_extractions(sample_sheet_lookup, {"sample_1": "2600000001C",
|
|
80
|
+
"sample_2": "2600000002C"})
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
It returns a list with one `{"sequencing_sample", "match_status", "match_error", "extraction"}` per sample. An
|
|
84
|
+
extraction the server doesn't have yet is `Pending` for that sample only (HTTP 202) and attaches itself once the
|
|
85
|
+
extraction is created; a sample name that isn't on the sheet is a 400 for the whole batch. On a server from
|
|
86
|
+
before SACGF/variantgrid#2176 (no `link_extractions_bulk` feature) the client makes one
|
|
87
|
+
`link_sequencing_sample_extraction` call per sample instead, so pipelines can use the bulk call unconditionally.
|
|
88
|
+
`link_sequencing_sample_extraction` is still there for a single late link. See `examples/example_haem_20_999.py`.
|
|
89
|
+
|
|
72
90
|
## BAMs and CRAMs
|
|
73
91
|
|
|
74
92
|
A sequencing sample can have several alignment files, eg a BAM and its recalibrated BAM, or a BAM plus a CRAM.
|
|
@@ -92,17 +110,22 @@ file as `bam_file`, and a QC's first alignment file as its `bam_file`. A CRAM th
|
|
|
92
110
|
|
|
93
111
|
Servers of different ages accept different calls. The client asks the server which features it has
|
|
94
112
|
(`GET api/v1/capabilities`, fetched once on first use), and each call that needs a newer server
|
|
95
|
-
checks that list first: the patient / specimen / extraction calls,
|
|
96
|
-
`link_sequencing_sample_extraction`, the annotate flow
|
|
97
|
-
`download_annotated`, `annotate_vcf`), and `upload_file` with
|
|
98
|
-
capabilities endpoint counts as legacy and
|
|
113
|
+
checks that list first: the patient / specimen / extraction calls,
|
|
114
|
+
`link_sequencing_sample_extraction` and `link_sequencing_sample_extractions`, the annotate flow
|
|
115
|
+
(`poll_upload_status`, `wait_for_annotation`, `download_annotated`, `annotate_vcf`), and `upload_file` with
|
|
116
|
+
`metadata`, `file_type` or `required_features`. A server without the capabilities endpoint counts as legacy and
|
|
117
|
+
reports no features. From SACGF/variantgrid#2732 the endpoint is public, so `VariantGridAPI(server, None)` can
|
|
118
|
+
probe a server before you have a token.
|
|
99
119
|
|
|
100
120
|
By default an unsupported call raises `UnsupportedFeatureError`. To run the same code against old and new
|
|
101
|
-
servers, skip those calls instead: they log a warning and return `None`.
|
|
102
|
-
server
|
|
121
|
+
servers, skip those calls instead: they log a warning and return `None`. Two calls downgrade rather than skip
|
|
122
|
+
when the server has the older form of the feature: `upload_file` on a server without upload metadata drops the
|
|
123
|
+
`metadata` and still uploads the file, and `link_sequencing_sample_extractions` on a server with only
|
|
124
|
+
`link_extraction` makes one call per sample.
|
|
103
125
|
|
|
104
126
|
```
|
|
105
127
|
from variantgrid_api.api_client import VariantGridAPI, UnsupportedFeaturePolicy
|
|
128
|
+
from variantgrid_api.data_models import ServerFeature
|
|
106
129
|
|
|
107
130
|
api = VariantGridAPI(server, api_token, unsupported_feature_policy=UnsupportedFeaturePolicy.SKIP)
|
|
108
131
|
api.create_patient(patient) # skipped on a server without patients
|
|
@@ -110,6 +133,9 @@ api.create_patient(patient) # skipped on a server without patients
|
|
|
110
133
|
# Uploads only when the server has an importer for this file type
|
|
111
134
|
api.upload_file("sample_CombinedVariantOutput.tsv", path=None,
|
|
112
135
|
file_type="dragen_tso500_combined_variant_output")
|
|
136
|
+
|
|
137
|
+
# An SV/CNV VCF with symbolic alts (<DEL>, <DUP>, <INV>, <CNV>) - only a server that imports them (not VG3)
|
|
138
|
+
api.upload_file("sample.sv.vcf.gz", path=None, required_features=[ServerFeature.SYMBOLIC_ALTS])
|
|
113
139
|
```
|
|
114
140
|
|
|
115
141
|
Where the fallback isn't simply "do nothing", branch on `api.supports("feature")` or
|
|
@@ -142,10 +168,10 @@ specimen measures. See `examples/example_tso500.py` for a full run.
|
|
|
142
168
|
## Permission to write sequencing data
|
|
143
169
|
|
|
144
170
|
Posts to the server's `seqauto/api/` endpoints - the sequencing `create_*` calls from `create_experiment` through
|
|
145
|
-
`create_sequencing_data` and the QC calls, and `link_sequencing_sample_extraction` - need a
|
|
146
|
-
or one belonging to a member of the server's SeqAuto write group (`seqauto_api_write`
|
|
147
|
-
`SEQAUTO_API_WRITE_GROUP` setting says otherwise). Anyone else gets a 403
|
|
148
|
-
`sequencing_run_has_vcf()`, need only a valid token.
|
|
171
|
+
`create_sequencing_data` and the QC calls, and the `link_sequencing_sample_extraction(s)` calls - need a
|
|
172
|
+
superuser's API token, or one belonging to a member of the server's SeqAuto write group (`seqauto_api_write`
|
|
173
|
+
unless the server's `SEQAUTO_API_WRITE_GROUP` setting says otherwise). Anyone else gets a 403
|
|
174
|
+
(`requests.HTTPError`). Reads, such as `sequencing_run_has_vcf()`, need only a valid token.
|
|
149
175
|
|
|
150
176
|
## Testing
|
|
151
177
|
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import datetime
|
|
2
2
|
import json
|
|
3
3
|
import logging
|
|
4
|
+
import os
|
|
4
5
|
import re
|
|
5
6
|
import time
|
|
6
7
|
import urllib
|
|
7
8
|
import warnings
|
|
8
9
|
from enum import Enum
|
|
9
10
|
from pathlib import Path
|
|
10
|
-
from typing import List, Optional, Callable, Union
|
|
11
|
+
from typing import Dict, Iterable, List, Optional, Callable, Union
|
|
11
12
|
|
|
12
13
|
import requests
|
|
13
14
|
|
|
@@ -63,7 +64,9 @@ class VariantGridAPI:
|
|
|
63
64
|
unsupported_feature_policy=UnsupportedFeaturePolicy.ERROR
|
|
64
65
|
):
|
|
65
66
|
self.server = server
|
|
66
|
-
|
|
67
|
+
# api_token=None sends no Authorization header - enough for the public capabilities endpoint
|
|
68
|
+
# (SACGF/variantgrid#2732); everything else needs a token
|
|
69
|
+
self.headers = {"Authorization": f"Token {api_token}"} if api_token is not None else {}
|
|
67
70
|
if logger is None:
|
|
68
71
|
logger = logging.getLogger(__name__)
|
|
69
72
|
self.logger = logger
|
|
@@ -438,8 +441,47 @@ class VariantGridAPI:
|
|
|
438
441
|
}
|
|
439
442
|
return self._post("seqauto/api/v1/sequencing_sample/link_extraction", json_data)
|
|
440
443
|
|
|
444
|
+
def link_sequencing_sample_extractions(self, sample_sheet_lookup: SampleSheetLookup,
|
|
445
|
+
extractions: Dict[str, ReferenceLike]) -> Optional[List[dict]]:
|
|
446
|
+
""" link_sequencing_sample_extraction() for a whole sample sheet in one call. extractions maps each
|
|
447
|
+
sequencing sample's name (SequencingSampleLookup.sample_name) to its extraction reference.
|
|
448
|
+
|
|
449
|
+
Returns one {"sequencing_sample", "match_status", "match_error", "extraction"} per sample, in the
|
|
450
|
+
order given. match_status is per sample: an extraction the server doesn't have yet is 'Pending'
|
|
451
|
+
(the response is a 202) and attaches itself once created, without affecting the others. A sample
|
|
452
|
+
name that isn't on the sample sheet is a 400 for the whole batch - nothing is linked.
|
|
453
|
+
|
|
454
|
+
Needs ServerFeature.LINK_EXTRACTIONS_BULK (SACGF/variantgrid#2176). A server with only
|
|
455
|
+
ServerFeature.LINK_EXTRACTION gets one link_sequencing_sample_extraction() call per sample and
|
|
456
|
+
the results are collected, so pipelines can call this unconditionally. unsupported_feature_policy
|
|
457
|
+
applies only when the server has neither feature. """
|
|
458
|
+
if not self.supports(ServerFeature.LINK_EXTRACTIONS_BULK) and not self._require(ServerFeature.LINK_EXTRACTION):
|
|
459
|
+
return None
|
|
460
|
+
self._validate_object("sample_sheet_lookup", sample_sheet_lookup)
|
|
461
|
+
if not extractions:
|
|
462
|
+
self.validation_handler("extractions: empty")
|
|
463
|
+
for sample_name, extraction_reference in extractions.items():
|
|
464
|
+
self._validate_string("extractions: sample name", sample_name)
|
|
465
|
+
self._validate_reference(f"extractions['{sample_name}']", extraction_reference)
|
|
466
|
+
|
|
467
|
+
if not self.supports(ServerFeature.LINK_EXTRACTIONS_BULK):
|
|
468
|
+
self.logger.info("Server doesn't support feature '%s' - linking %d sequencing sample(s) one at a time",
|
|
469
|
+
ServerFeature.LINK_EXTRACTIONS_BULK, len(extractions))
|
|
470
|
+
return [self.link_sequencing_sample_extraction(
|
|
471
|
+
SequencingSampleLookup(sample_sheet_lookup=sample_sheet_lookup, sample_name=sample_name),
|
|
472
|
+
extraction_reference)
|
|
473
|
+
for sample_name, extraction_reference in extractions.items()]
|
|
474
|
+
|
|
475
|
+
json_data = {
|
|
476
|
+
"sample_sheet": sample_sheet_lookup.to_dict(),
|
|
477
|
+
"links": [{"sample_name": sample_name, "extraction": reference_json(extraction_reference)}
|
|
478
|
+
for sample_name, extraction_reference in extractions.items()],
|
|
479
|
+
}
|
|
480
|
+
return self._post("seqauto/api/v1/sequencing_sample/link_extractions", json_data)
|
|
481
|
+
|
|
441
482
|
def upload_file(self, filename: str, path=_UNSET, metadata: Optional[dict] = None,
|
|
442
|
-
file_type: Optional[Union[UploadFileType, str]] = None
|
|
483
|
+
file_type: Optional[Union[UploadFileType, str]] = None,
|
|
484
|
+
required_features: Optional[Iterable[Union[ServerFeature, str]]] = None):
|
|
443
485
|
""" Upload a file via multipart POST to upload/api/v1/file_upload.
|
|
444
486
|
|
|
445
487
|
Returns {"uploaded_file_id": <id>, "sha256_hash": <hash>, ...}; identify the upload by
|
|
@@ -467,7 +509,14 @@ class VariantGridAPI:
|
|
|
467
509
|
|
|
468
510
|
file_type: the server's name for what this file is, eg UploadFileType.DRAGEN_TSO500_COMBINED_VARIANT_OUTPUT.
|
|
469
511
|
Not sent (the server decides from the filename) - if given, the upload only happens when
|
|
470
|
-
accepts_upload(file_type), so an older server doesn't mis-import it as something else
|
|
512
|
+
accepts_upload(file_type), so an older server doesn't mis-import it as something else
|
|
513
|
+
|
|
514
|
+
required_features: server features the file needs, eg [ServerFeature.SYMBOLIC_ALTS] for an SV/CNV VCF
|
|
515
|
+
with symbolic alts (<DEL>, <DUP>, <INV>, <CNV>). Not sent - the upload only happens when the
|
|
516
|
+
server supports them all, otherwise unsupported_feature_policy applies (SKIP doesn't upload) """
|
|
517
|
+
for feature in required_features or ():
|
|
518
|
+
if not self._require(feature):
|
|
519
|
+
return None
|
|
471
520
|
if metadata and not self.supports(ServerFeature.UPLOAD_METADATA):
|
|
472
521
|
self._unsupported(f"upload metadata for '{filename}' (server doesn't support feature 'upload_metadata')")
|
|
473
522
|
metadata = None
|
|
@@ -592,37 +641,65 @@ class VariantGridAPI:
|
|
|
592
641
|
sleep(poll_interval)
|
|
593
642
|
|
|
594
643
|
@staticmethod
|
|
595
|
-
def _attachment_filename(response, export_type: str) -> str:
|
|
644
|
+
def _attachment_filename(response, export_type: str, compression: str = "zip") -> str:
|
|
596
645
|
content_disposition = response.headers.get("Content-Disposition", "")
|
|
597
646
|
if m := re.search(r'filename\*?=(?:UTF-8\'\')?"?([^";]+)"?', content_disposition):
|
|
598
647
|
return urllib.parse.unquote(m.group(1))
|
|
599
|
-
|
|
648
|
+
if export_type == "vcf":
|
|
649
|
+
ext = ".vcf.gz"
|
|
650
|
+
else:
|
|
651
|
+
ext = ".csv" if compression == "none" else ".csv.zip"
|
|
600
652
|
return f"download{ext}"
|
|
601
653
|
|
|
654
|
+
DOWNLOAD_COMPRESSIONS = ("zip", "none")
|
|
655
|
+
|
|
656
|
+
def _download_compression(self, export_type: str, compression: str) -> str:
|
|
657
|
+
""" Validates compression and returns what to ask the server for. 'none' needs the server feature
|
|
658
|
+
'annotated_download_compression' - without it, SKIP falls back to 'zip' (with a warning) and ERROR raises """
|
|
659
|
+
if export_type not in ("vcf", "csv"):
|
|
660
|
+
raise ValueError(f"export_type must be 'vcf' or 'csv', got {export_type!r}")
|
|
661
|
+
if compression not in self.DOWNLOAD_COMPRESSIONS:
|
|
662
|
+
raise ValueError(f"compression must be one of {self.DOWNLOAD_COMPRESSIONS}, got {compression!r}")
|
|
663
|
+
if compression == "zip":
|
|
664
|
+
return compression
|
|
665
|
+
if export_type != "csv":
|
|
666
|
+
raise ValueError(f"compression={compression!r} applies only to export_type 'csv' - the VCF is always gzipped")
|
|
667
|
+
if not self.supports(ServerFeature.ANNOTATED_DOWNLOAD_COMPRESSION):
|
|
668
|
+
self._unsupported(f"plain CSV download (server doesn't support feature "
|
|
669
|
+
f"'{ServerFeature.ANNOTATED_DOWNLOAD_COMPRESSION}') - downloading the zip")
|
|
670
|
+
return "zip"
|
|
671
|
+
return compression
|
|
672
|
+
|
|
602
673
|
def download_annotated(self, uploaded_file_id: Optional[int] = None, sha256: Optional[str] = None,
|
|
603
674
|
export_type: str = "vcf", dest_path: Optional[Union[str, Path]] = None,
|
|
604
675
|
timeout: float = 3600, poll_interval: float = 10,
|
|
605
|
-
sleep: Callable = time.sleep) -> Optional[Path]:
|
|
676
|
+
sleep: Callable = time.sleep, compression: str = "zip") -> Optional[Path]:
|
|
606
677
|
""" Download the cohort-level annotated export of an uploaded VCF, saving it to disk.
|
|
607
678
|
|
|
608
|
-
export_type is 'vcf' (gzipped *.vcf.gz) or 'csv'
|
|
609
|
-
|
|
679
|
+
export_type is 'vcf' (gzipped *.vcf.gz) or 'csv'. The export covers all samples in the uploaded VCF
|
|
680
|
+
(single-sample VCFs included).
|
|
681
|
+
|
|
682
|
+
compression (CSV only): 'zip' (default, *.csv.zip) or 'none' for a plain *.csv. 'none' needs the server
|
|
683
|
+
feature 'annotated_download_compression' (SACGF/variantgrid#2368) - without it, unsupported_feature_policy
|
|
684
|
+
SKIP downloads the zip (with a warning) and ERROR raises UnsupportedFeatureError. The VCF is always gzipped.
|
|
610
685
|
|
|
611
686
|
The endpoint returns 202 while the file is still being generated - this polls until it is ready (200),
|
|
612
687
|
then streams the attachment to dest_path. If dest_path is None the server's attachment filename is used
|
|
613
|
-
in the current directory; if it is an existing directory
|
|
614
|
-
|
|
688
|
+
in the current directory; if it is an existing directory, or a str ending in '/' (a directory, created
|
|
689
|
+
if it doesn't exist yet), the attachment filename is placed inside it; otherwise it is treated as the
|
|
690
|
+
full destination path. Returns the Path written.
|
|
615
691
|
|
|
616
692
|
Raises TimeoutError if the file isn't ready within 'timeout' seconds. Needs server feature 'upload_status' """
|
|
617
693
|
if not self._require(ServerFeature.UPLOAD_STATUS):
|
|
618
694
|
return None
|
|
619
|
-
|
|
620
|
-
raise ValueError(f"export_type must be 'vcf' or 'csv', got {export_type!r}")
|
|
695
|
+
compression = self._download_compression(export_type, compression)
|
|
621
696
|
segment = self._upload_key_segment(uploaded_file_id, sha256)
|
|
622
697
|
url = self._get_url(f"upload/api/v1/download/{segment}/{export_type}")
|
|
698
|
+
# Only sent for a plain CSV, so the default request is exactly what older clients sent
|
|
699
|
+
params = {"compression": compression} if compression != "zip" else None
|
|
623
700
|
deadline = time.monotonic() + timeout
|
|
624
701
|
while True:
|
|
625
|
-
response = requests.get(url, headers=self.headers, stream=True)
|
|
702
|
+
response = requests.get(url, params=params, headers=self.headers, stream=True)
|
|
626
703
|
if response.status_code == 202:
|
|
627
704
|
if self.log_response:
|
|
628
705
|
self.logger.info("Download of '%s' still generating: %s", url, self._safe_json(response))
|
|
@@ -633,22 +710,34 @@ class VariantGridAPI:
|
|
|
633
710
|
if not response.ok:
|
|
634
711
|
# Raises with logging (JSON 'error' message from the server)
|
|
635
712
|
self._handle_json_response(response, f"download {export_type} for {segment}")
|
|
636
|
-
dest = self._resolve_download_dest(dest_path, response, export_type)
|
|
713
|
+
dest = self._resolve_download_dest(dest_path, response, export_type, compression)
|
|
637
714
|
with open(dest, "wb") as f:
|
|
638
715
|
for chunk in response.iter_content(chunk_size=8192):
|
|
639
716
|
if chunk:
|
|
640
717
|
f.write(chunk)
|
|
641
718
|
return dest
|
|
642
719
|
|
|
643
|
-
def _resolve_download_dest(self, dest_path: Optional[Union[str, Path]], response, export_type: str
|
|
644
|
-
|
|
720
|
+
def _resolve_download_dest(self, dest_path: Optional[Union[str, Path]], response, export_type: str,
|
|
721
|
+
compression: str = "zip") -> Path:
|
|
722
|
+
filename = self._attachment_filename(response, export_type, compression)
|
|
645
723
|
if dest_path is None:
|
|
646
724
|
return Path(filename)
|
|
647
725
|
dest = Path(dest_path)
|
|
726
|
+
if self._names_directory(dest_path):
|
|
727
|
+
# Path() drops the trailing slash, so check the string - 'results/' is a directory even if it's new
|
|
728
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
648
729
|
if dest.is_dir():
|
|
649
730
|
return dest / filename
|
|
650
731
|
return dest
|
|
651
732
|
|
|
733
|
+
@staticmethod
|
|
734
|
+
def _names_directory(dest_path: Union[str, Path]) -> bool:
|
|
735
|
+
""" A str ending in a path separator names a directory (Path objects can't carry one) """
|
|
736
|
+
if not isinstance(dest_path, str):
|
|
737
|
+
return False
|
|
738
|
+
separators = (os.sep,) + ((os.altsep,) if os.altsep else ())
|
|
739
|
+
return dest_path.endswith(separators)
|
|
740
|
+
|
|
652
741
|
@staticmethod
|
|
653
742
|
def _safe_json(response):
|
|
654
743
|
try:
|
|
@@ -657,20 +746,24 @@ class VariantGridAPI:
|
|
|
657
746
|
return None
|
|
658
747
|
|
|
659
748
|
def annotate_vcf(self, filename: str, export_type: str = "vcf", dest_path: Optional[Union[str, Path]] = None,
|
|
660
|
-
timeout: float = 3600, poll_interval: float = 10, sleep: Callable = time.sleep
|
|
749
|
+
timeout: float = 3600, poll_interval: float = 10, sleep: Callable = time.sleep,
|
|
750
|
+
compression: str = "zip") -> Optional[Path]:
|
|
661
751
|
""" Convenience one-liner: upload a VCF, wait for annotation to finish, download the annotated export.
|
|
662
752
|
|
|
663
753
|
Chains upload_file -> wait_for_annotation -> download_annotated and returns the Path written.
|
|
664
|
-
Needs server feature 'upload_status'
|
|
754
|
+
Needs server feature 'upload_status', and for compression='none' 'annotated_download_compression'
|
|
755
|
+
(see download_annotated) - both checked before uploading """
|
|
665
756
|
if not self._require(ServerFeature.UPLOAD_STATUS):
|
|
666
757
|
return None
|
|
758
|
+
compression = self._download_compression(export_type, compression)
|
|
667
759
|
# path is SeqAuto-only and makes ad-hoc uploads fail the import - omit it for the annotate flow
|
|
668
760
|
upload = self.upload_file(filename, path=None)
|
|
669
761
|
uploaded_file_id = upload["uploaded_file_id"]
|
|
670
762
|
self.wait_for_annotation(uploaded_file_id=uploaded_file_id,
|
|
671
763
|
timeout=timeout, poll_interval=poll_interval, sleep=sleep)
|
|
672
764
|
return self.download_annotated(uploaded_file_id=uploaded_file_id, export_type=export_type,
|
|
673
|
-
dest_path=dest_path, timeout=timeout, poll_interval=poll_interval, sleep=sleep
|
|
765
|
+
dest_path=dest_path, timeout=timeout, poll_interval=poll_interval, sleep=sleep,
|
|
766
|
+
compression=compression)
|
|
674
767
|
|
|
675
768
|
|
|
676
769
|
|
|
@@ -15,6 +15,7 @@ import sys
|
|
|
15
15
|
import requests
|
|
16
16
|
|
|
17
17
|
from variantgrid_api.api_client import VariantGridAPI, AnnotationError, UnsupportedFeatureError
|
|
18
|
+
from variantgrid_api.data_models import ServerFeature
|
|
18
19
|
|
|
19
20
|
DEFAULT_SERVER = "https://variantgrid.com"
|
|
20
21
|
|
|
@@ -116,7 +117,8 @@ def _probe_status(api, logger, sha256, verbose=False):
|
|
|
116
117
|
|
|
117
118
|
|
|
118
119
|
def _download(api, args, sha256):
|
|
119
|
-
path = api.download_annotated(sha256=sha256, export_type=args.export_type, dest_path=args.dest
|
|
120
|
+
path = api.download_annotated(sha256=sha256, export_type=args.export_type, dest_path=args.dest,
|
|
121
|
+
compression=args.compression)
|
|
120
122
|
print(f"Annotated {args.export_type} written to {path}")
|
|
121
123
|
return EXIT_OK
|
|
122
124
|
|
|
@@ -125,8 +127,19 @@ def annotate_vcf_cmd(args):
|
|
|
125
127
|
if not os.path.isfile(args.vcf):
|
|
126
128
|
print(f"No such file: {args.vcf}", file=sys.stderr)
|
|
127
129
|
return EXIT_ERROR
|
|
130
|
+
if args.compression != "zip" and args.export_type != "csv":
|
|
131
|
+
print(f"--compression {args.compression} applies only to --export-type csv (the VCF is always gzipped)",
|
|
132
|
+
file=sys.stderr)
|
|
133
|
+
return EXIT_ERROR
|
|
128
134
|
|
|
129
135
|
api, logger = _build_api(args)
|
|
136
|
+
# Checked before uploading, so we don't upload a file we then can't download as asked. A server without
|
|
137
|
+
# upload_status at all is reported by the first status poll instead
|
|
138
|
+
if (args.compression != "zip" and api.supports(ServerFeature.UPLOAD_STATUS)
|
|
139
|
+
and not api.supports(ServerFeature.ANNOTATED_DOWNLOAD_COMPRESSION)):
|
|
140
|
+
print(f"This VariantGrid server (version '{api.capabilities.version}') can't send a plain CSV - "
|
|
141
|
+
f"leave out --compression {args.compression} to download the .csv.zip", file=sys.stderr)
|
|
142
|
+
return EXIT_ERROR
|
|
130
143
|
name = os.path.basename(args.vcf)
|
|
131
144
|
sha256 = _sha256(args.vcf)
|
|
132
145
|
|
|
@@ -181,8 +194,13 @@ def build_parser():
|
|
|
181
194
|
p.add_argument("vcf", help="Path to the VCF (.vcf / .vcf.gz) to annotate")
|
|
182
195
|
p.add_argument("--export-type", choices=("vcf", "csv"), default="vcf",
|
|
183
196
|
help="Download format (default: vcf)")
|
|
197
|
+
p.add_argument("--compression", choices=("zip", "none"), default="zip",
|
|
198
|
+
help="CSV download compression: zip (default, .csv.zip) or none (plain .csv - needs a server "
|
|
199
|
+
"with the annotated_download_compression feature)")
|
|
184
200
|
p.add_argument("-o", "--dest", default=".",
|
|
185
|
-
help="Destination directory or file for the download (default: current directory)"
|
|
201
|
+
help="Destination directory or file for the download (default: current directory). "
|
|
202
|
+
"A path ending in '/' is a directory, created if it doesn't exist, and the download "
|
|
203
|
+
"keeps the server's filename")
|
|
186
204
|
p.add_argument("--wait", action="store_true",
|
|
187
205
|
help="Poll until annotation finishes and download it, instead of returning immediately "
|
|
188
206
|
"(can take hours)")
|
|
@@ -520,11 +520,17 @@ class ServerFeature(_ServerName):
|
|
|
520
520
|
variantgrid/views_rest.py). An older server just doesn't list a newer one """
|
|
521
521
|
PATIENTS = "patients"
|
|
522
522
|
LINK_EXTRACTION = "link_extraction"
|
|
523
|
+
LINK_EXTRACTIONS_BULK = "link_extractions_bulk" # sequencing_sample/link_extractions (SACGF/variantgrid#2176)
|
|
523
524
|
UPLOAD_STATUS = "upload_status"
|
|
524
525
|
JOINT_CALLED_VCF_CROSS_RUN = "joint_called_vcf_cross_run"
|
|
525
526
|
UPLOAD_METADATA = "upload_metadata"
|
|
526
527
|
CRAM_ALIGNMENT_FILES = "cram_alignment_files" # a 'bam_file' path ending .cram is stored as a CRAM
|
|
527
528
|
ALIGNMENT_FILES = "alignment_files" # SequencingFile and QC 'alignment_files' (SACGF/variantgrid#2105)
|
|
529
|
+
# Annotated CSV download takes ?compression=zip|none, default zip (SACGF/variantgrid#2368)
|
|
530
|
+
ANNOTATED_DOWNLOAD_COMPRESSION = "annotated_download_compression"
|
|
531
|
+
# Imports VCFs with symbolic alts (<DEL>, <DUP>, <INV>, <CNV>) as structural variants - VG4, not VG3
|
|
532
|
+
# (SACGF/variantgrid#2732). Gene-level files have their own UploadFileType instead
|
|
533
|
+
SYMBOLIC_ALTS = "symbolic_alts"
|
|
528
534
|
|
|
529
535
|
|
|
530
536
|
class UploadFileType(_ServerName):
|
|
@@ -553,6 +559,9 @@ class UploadFileType(_ServerName):
|
|
|
553
559
|
class ServerCapabilities:
|
|
554
560
|
""" What a server accepts, from GET api/v1/capabilities (SACGF/variantgrid_sapath#443).
|
|
555
561
|
|
|
562
|
+
A server at or after SACGF/variantgrid#2732 answers anonymous callers too, without git_hash and with
|
|
563
|
+
version lacking its commit suffix
|
|
564
|
+
|
|
556
565
|
A server without that endpoint (404) is LEGACY: version 'legacy', no features, no upload file types """
|
|
557
566
|
LEGACY: ClassVar["ServerCapabilities"]
|
|
558
567
|
|
{variantgrid_api-1.9.0 → variantgrid_api-1.11.0}/src/variantgrid_api/mock_variantgrid_api.py
RENAMED
|
@@ -19,7 +19,8 @@ from pathlib import Path
|
|
|
19
19
|
from typing import Any, List, Optional, Tuple, Union
|
|
20
20
|
|
|
21
21
|
from variantgrid_api.api_client import UnsupportedFeaturePolicy, UnsupportedFeatureError
|
|
22
|
-
from variantgrid_api.data_models import reference_json,
|
|
22
|
+
from variantgrid_api.data_models import reference_json, SequencingSampleLookup, ServerCapabilities, \
|
|
23
|
+
ServerFeature, UploadFileType
|
|
23
24
|
|
|
24
25
|
_UNSET = object()
|
|
25
26
|
|
|
@@ -219,7 +220,28 @@ class MockVariantGridAPI:
|
|
|
219
220
|
"extraction": str(reference_json(extraction_reference)),
|
|
220
221
|
})
|
|
221
222
|
|
|
222
|
-
def
|
|
223
|
+
def link_sequencing_sample_extractions(self, sample_sheet_lookup, extractions):
|
|
224
|
+
""" As the real client, a server without 'link_extractions_bulk' gets one
|
|
225
|
+
link_sequencing_sample_extraction call per sample - those are what's recorded, not this call """
|
|
226
|
+
if not self.supports(ServerFeature.LINK_EXTRACTIONS_BULK):
|
|
227
|
+
if not self._require(ServerFeature.LINK_EXTRACTION):
|
|
228
|
+
return None
|
|
229
|
+
return [self.link_sequencing_sample_extraction(
|
|
230
|
+
SequencingSampleLookup(sample_sheet_lookup=sample_sheet_lookup, sample_name=sample_name),
|
|
231
|
+
extraction_reference)
|
|
232
|
+
for sample_name, extraction_reference in extractions.items()]
|
|
233
|
+
self._record("link_sequencing_sample_extractions", sample_sheet_lookup, extractions)
|
|
234
|
+
return self._ret("link_sequencing_sample_extractions", [{
|
|
235
|
+
"sequencing_sample": sample_name,
|
|
236
|
+
"match_status": "Matched",
|
|
237
|
+
"match_error": None,
|
|
238
|
+
"extraction": str(reference_json(extraction_reference)),
|
|
239
|
+
} for sample_name, extraction_reference in extractions.items()])
|
|
240
|
+
|
|
241
|
+
def upload_file(self, filename, path=_UNSET, metadata=None, file_type=None, required_features=None):
|
|
242
|
+
for feature in required_features or ():
|
|
243
|
+
if not self._require(feature):
|
|
244
|
+
return None
|
|
223
245
|
if metadata and not self.supports(ServerFeature.UPLOAD_METADATA):
|
|
224
246
|
self._unsupported(f"upload metadata for '{filename}' (server doesn't support feature 'upload_metadata')")
|
|
225
247
|
metadata = None
|
|
@@ -231,6 +253,8 @@ class MockVariantGridAPI:
|
|
|
231
253
|
extra = {"metadata": metadata} if metadata is not None else {}
|
|
232
254
|
if file_type is not None:
|
|
233
255
|
extra["file_type"] = file_type
|
|
256
|
+
if required_features is not None:
|
|
257
|
+
extra["required_features"] = required_features
|
|
234
258
|
self._record("upload_file", filename, path=path, **extra)
|
|
235
259
|
return self._ret("upload_file", {"uploaded_file_id": 1, "sha256_hash": "deadbeef",
|
|
236
260
|
"path": path, "status": "ok"})
|
|
@@ -259,21 +283,42 @@ class MockVariantGridAPI:
|
|
|
259
283
|
"error": None,
|
|
260
284
|
})
|
|
261
285
|
|
|
286
|
+
def _download_compression(self, export_type, compression):
|
|
287
|
+
""" Same validation and gating as VariantGridAPI._download_compression """
|
|
288
|
+
if export_type not in ("vcf", "csv"):
|
|
289
|
+
raise ValueError(f"export_type must be 'vcf' or 'csv', got {export_type!r}")
|
|
290
|
+
if compression not in ("zip", "none"):
|
|
291
|
+
raise ValueError(f"compression must be one of ('zip', 'none'), got {compression!r}")
|
|
292
|
+
if compression == "zip":
|
|
293
|
+
return compression
|
|
294
|
+
if export_type != "csv":
|
|
295
|
+
raise ValueError(f"compression={compression!r} applies only to export_type 'csv' - the VCF is always gzipped")
|
|
296
|
+
if not self.supports(ServerFeature.ANNOTATED_DOWNLOAD_COMPRESSION):
|
|
297
|
+
self._unsupported(f"plain CSV download (server doesn't support feature "
|
|
298
|
+
f"'{ServerFeature.ANNOTATED_DOWNLOAD_COMPRESSION}') - downloading the zip")
|
|
299
|
+
return "zip"
|
|
300
|
+
return compression
|
|
301
|
+
|
|
262
302
|
def download_annotated(self, uploaded_file_id=None, sha256=None, export_type="vcf",
|
|
263
|
-
dest_path=None, timeout=3600, poll_interval=10, sleep=None):
|
|
303
|
+
dest_path=None, timeout=3600, poll_interval=10, sleep=None, compression="zip"):
|
|
264
304
|
if not self._require(ServerFeature.UPLOAD_STATUS):
|
|
265
305
|
return None
|
|
306
|
+
compression = self._download_compression(export_type, compression)
|
|
307
|
+
# compression only recorded when not the default, so existing assertions on the recorded kwargs still hold
|
|
308
|
+
extra = {"compression": compression} if compression != "zip" else {}
|
|
266
309
|
self._record("download_annotated", uploaded_file_id, sha256, export_type=export_type,
|
|
267
|
-
dest_path=dest_path, timeout=timeout, poll_interval=poll_interval, sleep=sleep)
|
|
310
|
+
dest_path=dest_path, timeout=timeout, poll_interval=poll_interval, sleep=sleep, **extra)
|
|
268
311
|
default = Path(dest_path) if dest_path is not None else Path(f"download.{export_type}")
|
|
269
312
|
return self._ret("download_annotated", default)
|
|
270
313
|
|
|
271
314
|
def annotate_vcf(self, filename, export_type="vcf", dest_path=None,
|
|
272
|
-
timeout=3600, poll_interval=10, sleep=None):
|
|
315
|
+
timeout=3600, poll_interval=10, sleep=None, compression="zip"):
|
|
273
316
|
if not self._require(ServerFeature.UPLOAD_STATUS):
|
|
274
317
|
return None
|
|
318
|
+
compression = self._download_compression(export_type, compression)
|
|
319
|
+
extra = {"compression": compression} if compression != "zip" else {}
|
|
275
320
|
self._record("annotate_vcf", filename, export_type=export_type, dest_path=dest_path,
|
|
276
|
-
timeout=timeout, poll_interval=poll_interval, sleep=sleep)
|
|
321
|
+
timeout=timeout, poll_interval=poll_interval, sleep=sleep, **extra)
|
|
277
322
|
default = Path(dest_path) if dest_path is not None else Path(f"download.{export_type}")
|
|
278
323
|
return self._ret("annotate_vcf", default)
|
|
279
324
|
|