variantgrid-api 1.10.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.10.0/src/variantgrid_api.egg-info → variantgrid_api-1.11.0}/PKG-INFO +13 -4
- {variantgrid_api-1.10.0 → variantgrid_api-1.11.0}/README.md +12 -3
- {variantgrid_api-1.10.0 → variantgrid_api-1.11.0}/pyproject.toml +1 -1
- {variantgrid_api-1.10.0 → variantgrid_api-1.11.0}/src/variantgrid_api/api_client.py +75 -20
- {variantgrid_api-1.10.0 → variantgrid_api-1.11.0}/src/variantgrid_api/cli.py +20 -2
- {variantgrid_api-1.10.0 → variantgrid_api-1.11.0}/src/variantgrid_api/data_models.py +8 -0
- {variantgrid_api-1.10.0 → variantgrid_api-1.11.0}/src/variantgrid_api/mock_variantgrid_api.py +31 -5
- {variantgrid_api-1.10.0 → variantgrid_api-1.11.0/src/variantgrid_api.egg-info}/PKG-INFO +13 -4
- {variantgrid_api-1.10.0 → variantgrid_api-1.11.0}/tests/test_api_client_annotation.py +131 -1
- {variantgrid_api-1.10.0 → variantgrid_api-1.11.0}/tests/test_api_client_capabilities.py +55 -1
- {variantgrid_api-1.10.0 → variantgrid_api-1.11.0}/tests/test_cli.py +46 -0
- {variantgrid_api-1.10.0 → variantgrid_api-1.11.0}/tests/test_mock_variantgrid_api.py +36 -0
- {variantgrid_api-1.10.0 → variantgrid_api-1.11.0}/LICENSE +0 -0
- {variantgrid_api-1.10.0 → variantgrid_api-1.11.0}/setup.cfg +0 -0
- {variantgrid_api-1.10.0 → variantgrid_api-1.11.0}/src/variantgrid_api.egg-info/SOURCES.txt +0 -0
- {variantgrid_api-1.10.0 → variantgrid_api-1.11.0}/src/variantgrid_api.egg-info/dependency_links.txt +0 -0
- {variantgrid_api-1.10.0 → variantgrid_api-1.11.0}/src/variantgrid_api.egg-info/entry_points.txt +0 -0
- {variantgrid_api-1.10.0 → variantgrid_api-1.11.0}/src/variantgrid_api.egg-info/requires.txt +0 -0
- {variantgrid_api-1.10.0 → variantgrid_api-1.11.0}/src/variantgrid_api.egg-info/top_level.txt +0 -0
- {variantgrid_api-1.10.0 → variantgrid_api-1.11.0}/tests/test_alignment_files.py +0 -0
- {variantgrid_api-1.10.0 → variantgrid_api-1.11.0}/tests/test_api_client.py +0 -0
- {variantgrid_api-1.10.0 → variantgrid_api-1.11.0}/tests/test_api_client_bulk.py +0 -0
- {variantgrid_api-1.10.0 → variantgrid_api-1.11.0}/tests/test_api_client_patients.py +0 -0
- {variantgrid_api-1.10.0 → variantgrid_api-1.11.0}/tests/test_api_client_validation.py +0 -0
- {variantgrid_api-1.10.0 → variantgrid_api-1.11.0}/tests/test_data_models.py +0 -0
- {variantgrid_api-1.10.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
|
|
@@ -128,10 +131,12 @@ file as `bam_file`, and a QC's first alignment file as its `bam_file`. A CRAM th
|
|
|
128
131
|
|
|
129
132
|
Servers of different ages accept different calls. The client asks the server which features it has
|
|
130
133
|
(`GET api/v1/capabilities`, fetched once on first use), and each call that needs a newer server
|
|
131
|
-
checks that list first: the patient / specimen / extraction calls,
|
|
134
|
+
checks that list first: the patient / specimen / extraction calls,
|
|
132
135
|
`link_sequencing_sample_extraction` and `link_sequencing_sample_extractions`, the annotate flow
|
|
133
136
|
(`poll_upload_status`, `wait_for_annotation`, `download_annotated`, `annotate_vcf`), and `upload_file` with
|
|
134
|
-
`metadata` or `
|
|
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.
|
|
135
140
|
|
|
136
141
|
By default an unsupported call raises `UnsupportedFeatureError`. To run the same code against old and new
|
|
137
142
|
servers, skip those calls instead: they log a warning and return `None`. Two calls downgrade rather than skip
|
|
@@ -141,6 +146,7 @@ when the server has the older form of the feature: `upload_file` on a server wit
|
|
|
141
146
|
|
|
142
147
|
```
|
|
143
148
|
from variantgrid_api.api_client import VariantGridAPI, UnsupportedFeaturePolicy
|
|
149
|
+
from variantgrid_api.data_models import ServerFeature
|
|
144
150
|
|
|
145
151
|
api = VariantGridAPI(server, api_token, unsupported_feature_policy=UnsupportedFeaturePolicy.SKIP)
|
|
146
152
|
api.create_patient(patient) # skipped on a server without patients
|
|
@@ -148,6 +154,9 @@ api.create_patient(patient) # skipped on a server without patients
|
|
|
148
154
|
# Uploads only when the server has an importer for this file type
|
|
149
155
|
api.upload_file("sample_CombinedVariantOutput.tsv", path=None,
|
|
150
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])
|
|
151
160
|
```
|
|
152
161
|
|
|
153
162
|
Where the fallback isn't simply "do nothing", branch on `api.supports("feature")` or
|
|
@@ -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
|
|
@@ -107,10 +110,12 @@ file as `bam_file`, and a QC's first alignment file as its `bam_file`. A CRAM th
|
|
|
107
110
|
|
|
108
111
|
Servers of different ages accept different calls. The client asks the server which features it has
|
|
109
112
|
(`GET api/v1/capabilities`, fetched once on first use), and each call that needs a newer server
|
|
110
|
-
checks that list first: the patient / specimen / extraction calls,
|
|
113
|
+
checks that list first: the patient / specimen / extraction calls,
|
|
111
114
|
`link_sequencing_sample_extraction` and `link_sequencing_sample_extractions`, the annotate flow
|
|
112
115
|
(`poll_upload_status`, `wait_for_annotation`, `download_annotated`, `annotate_vcf`), and `upload_file` with
|
|
113
|
-
`metadata` or `
|
|
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.
|
|
114
119
|
|
|
115
120
|
By default an unsupported call raises `UnsupportedFeatureError`. To run the same code against old and new
|
|
116
121
|
servers, skip those calls instead: they log a warning and return `None`. Two calls downgrade rather than skip
|
|
@@ -120,6 +125,7 @@ when the server has the older form of the feature: `upload_file` on a server wit
|
|
|
120
125
|
|
|
121
126
|
```
|
|
122
127
|
from variantgrid_api.api_client import VariantGridAPI, UnsupportedFeaturePolicy
|
|
128
|
+
from variantgrid_api.data_models import ServerFeature
|
|
123
129
|
|
|
124
130
|
api = VariantGridAPI(server, api_token, unsupported_feature_policy=UnsupportedFeaturePolicy.SKIP)
|
|
125
131
|
api.create_patient(patient) # skipped on a server without patients
|
|
@@ -127,6 +133,9 @@ api.create_patient(patient) # skipped on a server without patients
|
|
|
127
133
|
# Uploads only when the server has an importer for this file type
|
|
128
134
|
api.upload_file("sample_CombinedVariantOutput.tsv", path=None,
|
|
129
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])
|
|
130
139
|
```
|
|
131
140
|
|
|
132
141
|
Where the fallback isn't simply "do nothing", branch on `api.supports("feature")` or
|
|
@@ -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 Dict, 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
|
|
@@ -477,7 +480,8 @@ class VariantGridAPI:
|
|
|
477
480
|
return self._post("seqauto/api/v1/sequencing_sample/link_extractions", json_data)
|
|
478
481
|
|
|
479
482
|
def upload_file(self, filename: str, path=_UNSET, metadata: Optional[dict] = None,
|
|
480
|
-
file_type: Optional[Union[UploadFileType, str]] = None
|
|
483
|
+
file_type: Optional[Union[UploadFileType, str]] = None,
|
|
484
|
+
required_features: Optional[Iterable[Union[ServerFeature, str]]] = None):
|
|
481
485
|
""" Upload a file via multipart POST to upload/api/v1/file_upload.
|
|
482
486
|
|
|
483
487
|
Returns {"uploaded_file_id": <id>, "sha256_hash": <hash>, ...}; identify the upload by
|
|
@@ -505,7 +509,14 @@ class VariantGridAPI:
|
|
|
505
509
|
|
|
506
510
|
file_type: the server's name for what this file is, eg UploadFileType.DRAGEN_TSO500_COMBINED_VARIANT_OUTPUT.
|
|
507
511
|
Not sent (the server decides from the filename) - if given, the upload only happens when
|
|
508
|
-
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
|
|
509
520
|
if metadata and not self.supports(ServerFeature.UPLOAD_METADATA):
|
|
510
521
|
self._unsupported(f"upload metadata for '{filename}' (server doesn't support feature 'upload_metadata')")
|
|
511
522
|
metadata = None
|
|
@@ -630,37 +641,65 @@ class VariantGridAPI:
|
|
|
630
641
|
sleep(poll_interval)
|
|
631
642
|
|
|
632
643
|
@staticmethod
|
|
633
|
-
def _attachment_filename(response, export_type: str) -> str:
|
|
644
|
+
def _attachment_filename(response, export_type: str, compression: str = "zip") -> str:
|
|
634
645
|
content_disposition = response.headers.get("Content-Disposition", "")
|
|
635
646
|
if m := re.search(r'filename\*?=(?:UTF-8\'\')?"?([^";]+)"?', content_disposition):
|
|
636
647
|
return urllib.parse.unquote(m.group(1))
|
|
637
|
-
|
|
648
|
+
if export_type == "vcf":
|
|
649
|
+
ext = ".vcf.gz"
|
|
650
|
+
else:
|
|
651
|
+
ext = ".csv" if compression == "none" else ".csv.zip"
|
|
638
652
|
return f"download{ext}"
|
|
639
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
|
+
|
|
640
673
|
def download_annotated(self, uploaded_file_id: Optional[int] = None, sha256: Optional[str] = None,
|
|
641
674
|
export_type: str = "vcf", dest_path: Optional[Union[str, Path]] = None,
|
|
642
675
|
timeout: float = 3600, poll_interval: float = 10,
|
|
643
|
-
sleep: Callable = time.sleep) -> Optional[Path]:
|
|
676
|
+
sleep: Callable = time.sleep, compression: str = "zip") -> Optional[Path]:
|
|
644
677
|
""" Download the cohort-level annotated export of an uploaded VCF, saving it to disk.
|
|
645
678
|
|
|
646
|
-
export_type is 'vcf' (gzipped *.vcf.gz) or 'csv'
|
|
647
|
-
|
|
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.
|
|
648
685
|
|
|
649
686
|
The endpoint returns 202 while the file is still being generated - this polls until it is ready (200),
|
|
650
687
|
then streams the attachment to dest_path. If dest_path is None the server's attachment filename is used
|
|
651
|
-
in the current directory; if it is an existing directory
|
|
652
|
-
|
|
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.
|
|
653
691
|
|
|
654
692
|
Raises TimeoutError if the file isn't ready within 'timeout' seconds. Needs server feature 'upload_status' """
|
|
655
693
|
if not self._require(ServerFeature.UPLOAD_STATUS):
|
|
656
694
|
return None
|
|
657
|
-
|
|
658
|
-
raise ValueError(f"export_type must be 'vcf' or 'csv', got {export_type!r}")
|
|
695
|
+
compression = self._download_compression(export_type, compression)
|
|
659
696
|
segment = self._upload_key_segment(uploaded_file_id, sha256)
|
|
660
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
|
|
661
700
|
deadline = time.monotonic() + timeout
|
|
662
701
|
while True:
|
|
663
|
-
response = requests.get(url, headers=self.headers, stream=True)
|
|
702
|
+
response = requests.get(url, params=params, headers=self.headers, stream=True)
|
|
664
703
|
if response.status_code == 202:
|
|
665
704
|
if self.log_response:
|
|
666
705
|
self.logger.info("Download of '%s' still generating: %s", url, self._safe_json(response))
|
|
@@ -671,22 +710,34 @@ class VariantGridAPI:
|
|
|
671
710
|
if not response.ok:
|
|
672
711
|
# Raises with logging (JSON 'error' message from the server)
|
|
673
712
|
self._handle_json_response(response, f"download {export_type} for {segment}")
|
|
674
|
-
dest = self._resolve_download_dest(dest_path, response, export_type)
|
|
713
|
+
dest = self._resolve_download_dest(dest_path, response, export_type, compression)
|
|
675
714
|
with open(dest, "wb") as f:
|
|
676
715
|
for chunk in response.iter_content(chunk_size=8192):
|
|
677
716
|
if chunk:
|
|
678
717
|
f.write(chunk)
|
|
679
718
|
return dest
|
|
680
719
|
|
|
681
|
-
def _resolve_download_dest(self, dest_path: Optional[Union[str, Path]], response, export_type: str
|
|
682
|
-
|
|
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)
|
|
683
723
|
if dest_path is None:
|
|
684
724
|
return Path(filename)
|
|
685
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)
|
|
686
729
|
if dest.is_dir():
|
|
687
730
|
return dest / filename
|
|
688
731
|
return dest
|
|
689
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
|
+
|
|
690
741
|
@staticmethod
|
|
691
742
|
def _safe_json(response):
|
|
692
743
|
try:
|
|
@@ -695,20 +746,24 @@ class VariantGridAPI:
|
|
|
695
746
|
return None
|
|
696
747
|
|
|
697
748
|
def annotate_vcf(self, filename: str, export_type: str = "vcf", dest_path: Optional[Union[str, Path]] = None,
|
|
698
|
-
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]:
|
|
699
751
|
""" Convenience one-liner: upload a VCF, wait for annotation to finish, download the annotated export.
|
|
700
752
|
|
|
701
753
|
Chains upload_file -> wait_for_annotation -> download_annotated and returns the Path written.
|
|
702
|
-
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 """
|
|
703
756
|
if not self._require(ServerFeature.UPLOAD_STATUS):
|
|
704
757
|
return None
|
|
758
|
+
compression = self._download_compression(export_type, compression)
|
|
705
759
|
# path is SeqAuto-only and makes ad-hoc uploads fail the import - omit it for the annotate flow
|
|
706
760
|
upload = self.upload_file(filename, path=None)
|
|
707
761
|
uploaded_file_id = upload["uploaded_file_id"]
|
|
708
762
|
self.wait_for_annotation(uploaded_file_id=uploaded_file_id,
|
|
709
763
|
timeout=timeout, poll_interval=poll_interval, sleep=sleep)
|
|
710
764
|
return self.download_annotated(uploaded_file_id=uploaded_file_id, export_type=export_type,
|
|
711
|
-
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)
|
|
712
767
|
|
|
713
768
|
|
|
714
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)")
|
|
@@ -526,6 +526,11 @@ class ServerFeature(_ServerName):
|
|
|
526
526
|
UPLOAD_METADATA = "upload_metadata"
|
|
527
527
|
CRAM_ALIGNMENT_FILES = "cram_alignment_files" # a 'bam_file' path ending .cram is stored as a CRAM
|
|
528
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"
|
|
529
534
|
|
|
530
535
|
|
|
531
536
|
class UploadFileType(_ServerName):
|
|
@@ -554,6 +559,9 @@ class UploadFileType(_ServerName):
|
|
|
554
559
|
class ServerCapabilities:
|
|
555
560
|
""" What a server accepts, from GET api/v1/capabilities (SACGF/variantgrid_sapath#443).
|
|
556
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
|
+
|
|
557
565
|
A server without that endpoint (404) is LEGACY: version 'legacy', no features, no upload file types """
|
|
558
566
|
LEGACY: ClassVar["ServerCapabilities"]
|
|
559
567
|
|
{variantgrid_api-1.10.0 → variantgrid_api-1.11.0}/src/variantgrid_api/mock_variantgrid_api.py
RENAMED
|
@@ -238,7 +238,10 @@ class MockVariantGridAPI:
|
|
|
238
238
|
"extraction": str(reference_json(extraction_reference)),
|
|
239
239
|
} for sample_name, extraction_reference in extractions.items()])
|
|
240
240
|
|
|
241
|
-
def upload_file(self, filename, path=_UNSET, metadata=None, file_type=None):
|
|
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
|
|
242
245
|
if metadata and not self.supports(ServerFeature.UPLOAD_METADATA):
|
|
243
246
|
self._unsupported(f"upload metadata for '{filename}' (server doesn't support feature 'upload_metadata')")
|
|
244
247
|
metadata = None
|
|
@@ -250,6 +253,8 @@ class MockVariantGridAPI:
|
|
|
250
253
|
extra = {"metadata": metadata} if metadata is not None else {}
|
|
251
254
|
if file_type is not None:
|
|
252
255
|
extra["file_type"] = file_type
|
|
256
|
+
if required_features is not None:
|
|
257
|
+
extra["required_features"] = required_features
|
|
253
258
|
self._record("upload_file", filename, path=path, **extra)
|
|
254
259
|
return self._ret("upload_file", {"uploaded_file_id": 1, "sha256_hash": "deadbeef",
|
|
255
260
|
"path": path, "status": "ok"})
|
|
@@ -278,21 +283,42 @@ class MockVariantGridAPI:
|
|
|
278
283
|
"error": None,
|
|
279
284
|
})
|
|
280
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
|
+
|
|
281
302
|
def download_annotated(self, uploaded_file_id=None, sha256=None, export_type="vcf",
|
|
282
|
-
dest_path=None, timeout=3600, poll_interval=10, sleep=None):
|
|
303
|
+
dest_path=None, timeout=3600, poll_interval=10, sleep=None, compression="zip"):
|
|
283
304
|
if not self._require(ServerFeature.UPLOAD_STATUS):
|
|
284
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 {}
|
|
285
309
|
self._record("download_annotated", uploaded_file_id, sha256, export_type=export_type,
|
|
286
|
-
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)
|
|
287
311
|
default = Path(dest_path) if dest_path is not None else Path(f"download.{export_type}")
|
|
288
312
|
return self._ret("download_annotated", default)
|
|
289
313
|
|
|
290
314
|
def annotate_vcf(self, filename, export_type="vcf", dest_path=None,
|
|
291
|
-
timeout=3600, poll_interval=10, sleep=None):
|
|
315
|
+
timeout=3600, poll_interval=10, sleep=None, compression="zip"):
|
|
292
316
|
if not self._require(ServerFeature.UPLOAD_STATUS):
|
|
293
317
|
return None
|
|
318
|
+
compression = self._download_compression(export_type, compression)
|
|
319
|
+
extra = {"compression": compression} if compression != "zip" else {}
|
|
294
320
|
self._record("annotate_vcf", filename, export_type=export_type, dest_path=dest_path,
|
|
295
|
-
timeout=timeout, poll_interval=poll_interval, sleep=sleep)
|
|
321
|
+
timeout=timeout, poll_interval=poll_interval, sleep=sleep, **extra)
|
|
296
322
|
default = Path(dest_path) if dest_path is not None else Path(f"download.{export_type}")
|
|
297
323
|
return self._ret("annotate_vcf", default)
|
|
298
324
|
|
|
@@ -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
|
|
@@ -128,10 +131,12 @@ file as `bam_file`, and a QC's first alignment file as its `bam_file`. A CRAM th
|
|
|
128
131
|
|
|
129
132
|
Servers of different ages accept different calls. The client asks the server which features it has
|
|
130
133
|
(`GET api/v1/capabilities`, fetched once on first use), and each call that needs a newer server
|
|
131
|
-
checks that list first: the patient / specimen / extraction calls,
|
|
134
|
+
checks that list first: the patient / specimen / extraction calls,
|
|
132
135
|
`link_sequencing_sample_extraction` and `link_sequencing_sample_extractions`, the annotate flow
|
|
133
136
|
(`poll_upload_status`, `wait_for_annotation`, `download_annotated`, `annotate_vcf`), and `upload_file` with
|
|
134
|
-
`metadata` or `
|
|
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.
|
|
135
140
|
|
|
136
141
|
By default an unsupported call raises `UnsupportedFeatureError`. To run the same code against old and new
|
|
137
142
|
servers, skip those calls instead: they log a warning and return `None`. Two calls downgrade rather than skip
|
|
@@ -141,6 +146,7 @@ when the server has the older form of the feature: `upload_file` on a server wit
|
|
|
141
146
|
|
|
142
147
|
```
|
|
143
148
|
from variantgrid_api.api_client import VariantGridAPI, UnsupportedFeaturePolicy
|
|
149
|
+
from variantgrid_api.data_models import ServerFeature
|
|
144
150
|
|
|
145
151
|
api = VariantGridAPI(server, api_token, unsupported_feature_policy=UnsupportedFeaturePolicy.SKIP)
|
|
146
152
|
api.create_patient(patient) # skipped on a server without patients
|
|
@@ -148,6 +154,9 @@ api.create_patient(patient) # skipped on a server without patients
|
|
|
148
154
|
# Uploads only when the server has an importer for this file type
|
|
149
155
|
api.upload_file("sample_CombinedVariantOutput.tsv", path=None,
|
|
150
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])
|
|
151
160
|
```
|
|
152
161
|
|
|
153
162
|
Where the fallback isn't simply "do nothing", branch on `api.supports("feature")` or
|
|
@@ -3,7 +3,9 @@ import gzip
|
|
|
3
3
|
import pytest
|
|
4
4
|
import responses
|
|
5
5
|
|
|
6
|
-
from variantgrid_api.api_client import AnnotationError, VariantGridAPI
|
|
6
|
+
from variantgrid_api.api_client import AnnotationError, VariantGridAPI, UnsupportedFeatureError, \
|
|
7
|
+
UnsupportedFeaturePolicy
|
|
8
|
+
from variantgrid_api.data_models import ServerCapabilities
|
|
7
9
|
|
|
8
10
|
|
|
9
11
|
@pytest.fixture
|
|
@@ -173,6 +175,43 @@ def test_download_annotated_uses_attachment_name_in_dir(api, server, tmp_path):
|
|
|
173
175
|
assert out.read_bytes() == b"zipbytes"
|
|
174
176
|
|
|
175
177
|
|
|
178
|
+
@responses.activate
|
|
179
|
+
def test_download_annotated_trailing_slash_creates_dir(api, server, tmp_path):
|
|
180
|
+
""" 'results/' names a directory even when it doesn't exist yet (#35) """
|
|
181
|
+
url = f"{server}/upload/api/v1/download/123/csv"
|
|
182
|
+
responses.add(responses.GET, url, body=b"zipbytes", status=200,
|
|
183
|
+
headers={"Content-Disposition": 'attachment; filename="cohort.csv.zip"'})
|
|
184
|
+
|
|
185
|
+
dest_dir = tmp_path / "results" / "nested"
|
|
186
|
+
out = api.download_annotated(uploaded_file_id=123, export_type="csv", dest_path=f"{dest_dir}/", sleep=_no_sleep)
|
|
187
|
+
assert out == dest_dir / "cohort.csv.zip"
|
|
188
|
+
assert out.read_bytes() == b"zipbytes"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@responses.activate
|
|
192
|
+
def test_download_annotated_new_path_without_slash_is_a_file(api, server, tmp_path):
|
|
193
|
+
""" Without a trailing slash a path that doesn't exist is still the file to write """
|
|
194
|
+
url = f"{server}/upload/api/v1/download/123/csv"
|
|
195
|
+
responses.add(responses.GET, url, body=b"zipbytes", status=200,
|
|
196
|
+
headers={"Content-Disposition": 'attachment; filename="cohort.csv.zip"'})
|
|
197
|
+
|
|
198
|
+
dest = tmp_path / "results"
|
|
199
|
+
out = api.download_annotated(uploaded_file_id=123, export_type="csv", dest_path=str(dest), sleep=_no_sleep)
|
|
200
|
+
assert out == dest
|
|
201
|
+
assert dest.is_file()
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@responses.activate
|
|
205
|
+
def test_download_annotated_server_error_does_not_create_dir(api, server, tmp_path):
|
|
206
|
+
url = f"{server}/upload/api/v1/download/123/vcf"
|
|
207
|
+
responses.add(responses.GET, url, json={"error": "Export templates not configured"}, status=400)
|
|
208
|
+
|
|
209
|
+
dest_dir = tmp_path / "results"
|
|
210
|
+
with pytest.raises(Exception):
|
|
211
|
+
api.download_annotated(uploaded_file_id=123, dest_path=f"{dest_dir}/", sleep=_no_sleep)
|
|
212
|
+
assert not dest_dir.exists()
|
|
213
|
+
|
|
214
|
+
|
|
176
215
|
@responses.activate
|
|
177
216
|
def test_download_annotated_follows_202_generating(api, server, tmp_path):
|
|
178
217
|
url = f"{server}/upload/api/v1/download/123/vcf"
|
|
@@ -203,6 +242,97 @@ def test_download_annotated_raises_on_server_error(api, server, tmp_path):
|
|
|
203
242
|
api.download_annotated(uploaded_file_id=123, dest_path=tmp_path, sleep=_no_sleep)
|
|
204
243
|
|
|
205
244
|
|
|
245
|
+
@responses.activate
|
|
246
|
+
def test_download_annotated_default_sends_no_compression(api, server, tmp_path):
|
|
247
|
+
""" The default (zip) request is exactly what older clients sent (#36) """
|
|
248
|
+
url = f"{server}/upload/api/v1/download/123/csv"
|
|
249
|
+
responses.add(responses.GET, url, body=b"zipbytes", status=200)
|
|
250
|
+
|
|
251
|
+
out = api.download_annotated(uploaded_file_id=123, export_type="csv", dest_path=tmp_path, sleep=_no_sleep)
|
|
252
|
+
assert "compression" not in responses.calls[-1].request.url
|
|
253
|
+
assert out == tmp_path / "download.csv.zip"
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@responses.activate
|
|
257
|
+
def test_download_annotated_plain_csv(api, server, tmp_path):
|
|
258
|
+
""" compression='none' asks for a plain CSV (SACGF/variantgrid#2368) """
|
|
259
|
+
url = f"{server}/upload/api/v1/download/123/csv"
|
|
260
|
+
responses.add(responses.GET, url, body=b"a,b\n", status=200, content_type="text/csv",
|
|
261
|
+
match=[responses.matchers.query_param_matcher({"compression": "none"})])
|
|
262
|
+
|
|
263
|
+
out = api.download_annotated(uploaded_file_id=123, export_type="csv", dest_path=tmp_path,
|
|
264
|
+
compression="none", sleep=_no_sleep)
|
|
265
|
+
assert out == tmp_path / "download.csv" # no Content-Disposition - fallback name has no .zip
|
|
266
|
+
assert out.read_bytes() == b"a,b\n"
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@responses.activate
|
|
270
|
+
def test_download_annotated_plain_csv_keeps_query_while_generating(api, server, tmp_path):
|
|
271
|
+
url = f"{server}/upload/api/v1/download/123/csv"
|
|
272
|
+
responses.add(responses.GET, url, json={"status": "generating"}, status=202)
|
|
273
|
+
responses.add(responses.GET, url, body=b"a,b\n", status=200,
|
|
274
|
+
headers={"Content-Disposition": 'attachment; filename="cohort.csv"'})
|
|
275
|
+
|
|
276
|
+
out = api.download_annotated(uploaded_file_id=123, export_type="csv", dest_path=tmp_path,
|
|
277
|
+
compression="none", poll_interval=0, sleep=_no_sleep)
|
|
278
|
+
assert out == tmp_path / "cohort.csv"
|
|
279
|
+
assert all("compression=none" in c.request.url for c in responses.calls)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def test_download_annotated_plain_csv_old_server_raises(server, api_token, capabilities_json):
|
|
283
|
+
capabilities_json["features"].remove("annotated_download_compression")
|
|
284
|
+
api = VariantGridAPI(server, api_token)
|
|
285
|
+
api._capabilities = ServerCapabilities.from_json(capabilities_json)
|
|
286
|
+
with pytest.raises(UnsupportedFeatureError):
|
|
287
|
+
api.download_annotated(uploaded_file_id=123, export_type="csv", compression="none")
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
@responses.activate
|
|
291
|
+
def test_download_annotated_plain_csv_old_server_skip_downloads_zip(server, api_token, capabilities_json, tmp_path):
|
|
292
|
+
capabilities_json["features"].remove("annotated_download_compression")
|
|
293
|
+
api = VariantGridAPI(server, api_token, unsupported_feature_policy=UnsupportedFeaturePolicy.SKIP)
|
|
294
|
+
api._capabilities = ServerCapabilities.from_json(capabilities_json)
|
|
295
|
+
url = f"{server}/upload/api/v1/download/123/csv"
|
|
296
|
+
responses.add(responses.GET, url, body=b"zipbytes", status=200)
|
|
297
|
+
|
|
298
|
+
out = api.download_annotated(uploaded_file_id=123, export_type="csv", dest_path=tmp_path,
|
|
299
|
+
compression="none", sleep=_no_sleep)
|
|
300
|
+
assert "compression" not in responses.calls[-1].request.url
|
|
301
|
+
assert out == tmp_path / "download.csv.zip"
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
@pytest.mark.parametrize("export_type, compression", [("vcf", "none"), ("csv", "bz2"), ("vcf", "gzip")])
|
|
305
|
+
def test_download_annotated_rejects_bad_compression(api, export_type, compression):
|
|
306
|
+
with pytest.raises(ValueError):
|
|
307
|
+
api.download_annotated(uploaded_file_id=123, export_type=export_type, compression=compression)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def test_annotate_vcf_checks_compression_before_upload(server, api_token, capabilities_json, tmp_path):
|
|
311
|
+
""" Raises before uploading - no request is made (responses isn't active, so one would fail) """
|
|
312
|
+
capabilities_json["features"].remove("annotated_download_compression")
|
|
313
|
+
api = VariantGridAPI(server, api_token)
|
|
314
|
+
api._capabilities = ServerCapabilities.from_json(capabilities_json)
|
|
315
|
+
src = tmp_path / "input.vcf"
|
|
316
|
+
src.write_text("##fileformat=VCFv4.2\n")
|
|
317
|
+
with pytest.raises(UnsupportedFeatureError):
|
|
318
|
+
api.annotate_vcf(str(src), export_type="csv", compression="none")
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
@responses.activate
|
|
322
|
+
def test_annotate_vcf_plain_csv(api, server, tmp_path):
|
|
323
|
+
responses.add(responses.POST, f"{server}/upload/api/v1/file_upload", json={"uploaded_file_id": 456}, status=200)
|
|
324
|
+
responses.add(responses.GET, f"{server}/upload/api/v1/upload_status/456",
|
|
325
|
+
json={"annotation_complete": True, "error": None}, status=200)
|
|
326
|
+
responses.add(responses.GET, f"{server}/upload/api/v1/download/456/csv", body=b"a,b\n", status=200,
|
|
327
|
+
match=[responses.matchers.query_param_matcher({"compression": "none"})])
|
|
328
|
+
src = tmp_path / "input.vcf"
|
|
329
|
+
src.write_text("##fileformat=VCFv4.2\n")
|
|
330
|
+
|
|
331
|
+
out = api.annotate_vcf(str(src), export_type="csv", compression="none", dest_path=tmp_path,
|
|
332
|
+
poll_interval=0, sleep=_no_sleep)
|
|
333
|
+
assert out == tmp_path / "download.csv"
|
|
334
|
+
|
|
335
|
+
|
|
206
336
|
def test_download_annotated_rejects_bad_export_type(api):
|
|
207
337
|
with pytest.raises(ValueError):
|
|
208
338
|
api.download_annotated(uploaded_file_id=123, export_type="bam")
|
|
@@ -113,6 +113,33 @@ def test_capabilities_other_error_raises(api, capabilities_url):
|
|
|
113
113
|
api.capabilities
|
|
114
114
|
|
|
115
115
|
|
|
116
|
+
@responses.activate
|
|
117
|
+
def test_capabilities_anonymous_response(server, capabilities_url, capabilities_json):
|
|
118
|
+
""" SACGF/variantgrid#2732 - the endpoint is public. No token sends no Authorization header, and the
|
|
119
|
+
anonymous response (no git_hash, version without its commit suffix) parses """
|
|
120
|
+
del capabilities_json["git_hash"]
|
|
121
|
+
capabilities_json["version"] = "vg4"
|
|
122
|
+
responses.add(responses.GET, capabilities_url, json=capabilities_json, status=200)
|
|
123
|
+
|
|
124
|
+
api = VariantGridAPI(server, None)
|
|
125
|
+
assert api.supports(ServerFeature.SYMBOLIC_ALTS)
|
|
126
|
+
assert api.capabilities.git_hash is None
|
|
127
|
+
assert api.capabilities.version == "vg4"
|
|
128
|
+
assert "Authorization" not in responses.calls[0].request.headers
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_token_sent_when_given(server, api_token):
|
|
132
|
+
assert VariantGridAPI(server, api_token).headers == {"Authorization": f"Token {api_token}"}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@responses.activate
|
|
136
|
+
def test_vg3_has_no_symbolic_alts(api, capabilities_url):
|
|
137
|
+
""" VG3 reports its capabilities but not symbolic_alts """
|
|
138
|
+
responses.add(responses.GET, capabilities_url, status=200,
|
|
139
|
+
json={"version": "3.x", "features": ["upload_status"], "upload_file_types": ["vcf"]})
|
|
140
|
+
assert not api.supports(ServerFeature.SYMBOLIC_ALTS)
|
|
141
|
+
|
|
142
|
+
|
|
116
143
|
def test_constructing_client_makes_no_request(server, api_token):
|
|
117
144
|
with responses.RequestsMock() as rsps: # raises if any request is made
|
|
118
145
|
VariantGridAPI(server, api_token)
|
|
@@ -143,6 +170,8 @@ def _gated_calls(vg_objects, vcf):
|
|
|
143
170
|
"link_sequencing_sample_extractions": lambda api: api.link_sequencing_sample_extractions(
|
|
144
171
|
vg_objects["sample_sheet_lookup"], {"fake_sample_1": "2600000001C"}),
|
|
145
172
|
"upload_file_file_type": lambda api: api.upload_file(vcf, path=None, file_type=CVO),
|
|
173
|
+
"upload_file_required_features": lambda api: api.upload_file(
|
|
174
|
+
vcf, path=None, required_features=[ServerFeature.SYMBOLIC_ALTS]),
|
|
146
175
|
"poll_upload_status": lambda api: api.poll_upload_status(uploaded_file_id=1),
|
|
147
176
|
"wait_for_annotation": lambda api: api.wait_for_annotation(uploaded_file_id=1, sleep=lambda _: None),
|
|
148
177
|
"download_annotated": lambda api: api.download_annotated(uploaded_file_id=1, dest_path=vcf + ".out"),
|
|
@@ -152,7 +181,7 @@ def _gated_calls(vg_objects, vcf):
|
|
|
152
181
|
|
|
153
182
|
GATED = ["create_patient", "create_specimen", "create_extraction", "link_sequencing_sample_extraction",
|
|
154
183
|
"link_sequencing_sample_extractions",
|
|
155
|
-
"upload_file_file_type",
|
|
184
|
+
"upload_file_file_type", "upload_file_required_features",
|
|
156
185
|
"poll_upload_status", "wait_for_annotation", "download_annotated", "annotate_vcf"]
|
|
157
186
|
|
|
158
187
|
|
|
@@ -226,6 +255,31 @@ def test_upload_file_type_posts_when_accepted(skip_api, server, capabilities_url
|
|
|
226
255
|
assert "file_type" not in upload_call.request.url # a client-side gate only, not sent
|
|
227
256
|
|
|
228
257
|
|
|
258
|
+
@responses.activate
|
|
259
|
+
def test_upload_file_required_features_posts_when_supported(skip_api, server, capabilities_url, capabilities_json,
|
|
260
|
+
vcf):
|
|
261
|
+
""" SACGF/variantgrid_api#38 - an SV VCF with symbolic alts goes only to a server with symbolic_alts """
|
|
262
|
+
responses.add(responses.GET, capabilities_url, json=capabilities_json, status=200)
|
|
263
|
+
upload_url = f"{server}/upload/api/v1/file_upload"
|
|
264
|
+
responses.add(responses.POST, upload_url, json={"uploaded_file_id": 1}, status=200)
|
|
265
|
+
|
|
266
|
+
assert skip_api.upload_file(vcf, path=None,
|
|
267
|
+
required_features=[ServerFeature.SYMBOLIC_ALTS]) == {"uploaded_file_id": 1}
|
|
268
|
+
upload_call = responses.calls[-1]
|
|
269
|
+
assert upload_call.request.url == upload_url # a client-side gate only, nothing sent
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@responses.activate
|
|
273
|
+
def test_upload_file_required_features_checked_before_metadata(api, capabilities_url, vcf):
|
|
274
|
+
""" A missing required feature skips/raises on its own - the metadata check doesn't run first """
|
|
275
|
+
responses.add(responses.GET, capabilities_url, status=200,
|
|
276
|
+
json={"version": "3.x", "features": ["upload_metadata"], "upload_file_types": ["vcf"]})
|
|
277
|
+
with pytest.raises(UnsupportedFeatureError, match="symbolic_alts"):
|
|
278
|
+
api.upload_file(vcf, path=None, metadata={"genome_build": "GRCh37"},
|
|
279
|
+
required_features=[ServerFeature.SYMBOLIC_ALTS])
|
|
280
|
+
assert len(responses.calls) == 1
|
|
281
|
+
|
|
282
|
+
|
|
229
283
|
@responses.activate
|
|
230
284
|
def test_upload_file_type_skipped_when_server_has_endpoint_but_not_type(skip_api, capabilities_url, capabilities_json,
|
|
231
285
|
vcf):
|
|
@@ -98,6 +98,52 @@ def test_csv_export_type(vcf, tmp_path):
|
|
|
98
98
|
assert download_call.request.url.endswith("/csv")
|
|
99
99
|
|
|
100
100
|
|
|
101
|
+
@responses.activate
|
|
102
|
+
def test_dest_with_trailing_slash_creates_dir(vcf, tmp_path):
|
|
103
|
+
""" '-o results/' that doesn't exist yet is created, and the download keeps the server's filename (#35) """
|
|
104
|
+
_add_capabilities()
|
|
105
|
+
responses.add(responses.GET, STATUS_RE, json={"annotation_complete": True, "error": None}, status=200)
|
|
106
|
+
responses.add(responses.GET, DOWNLOAD_RE, body=b"zip", status=200,
|
|
107
|
+
headers={"Content-Disposition": 'attachment; filename="out.csv.zip"'})
|
|
108
|
+
|
|
109
|
+
results = tmp_path / "results"
|
|
110
|
+
rc = cli.main(_argv(vcf, "--export-type", "csv", "-o", f"{results}/"))
|
|
111
|
+
|
|
112
|
+
assert rc == cli.EXIT_OK
|
|
113
|
+
assert (results / "out.csv.zip").read_bytes() == b"zip"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@responses.activate
|
|
117
|
+
def test_csv_compression_none(vcf, tmp_path):
|
|
118
|
+
""" --compression none downloads a plain CSV (#36) """
|
|
119
|
+
_add_capabilities(("upload_status", "annotated_download_compression"))
|
|
120
|
+
responses.add(responses.GET, STATUS_RE, json={"annotation_complete": True, "error": None}, status=200)
|
|
121
|
+
responses.add(responses.GET, re.compile(DOWNLOAD_RE.pattern.rstrip("$") + r"\?compression=none$"),
|
|
122
|
+
body=b"a,b\n", status=200, headers={"Content-Disposition": 'attachment; filename="out.csv"'})
|
|
123
|
+
|
|
124
|
+
rc = cli.main(_argv(vcf, "--export-type", "csv", "--compression", "none", "-o", str(tmp_path)))
|
|
125
|
+
|
|
126
|
+
assert rc == cli.EXIT_OK
|
|
127
|
+
assert (tmp_path / "out.csv").read_bytes() == b"a,b\n"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@responses.activate
|
|
131
|
+
def test_csv_compression_none_old_server_is_error_before_upload(vcf, capsys):
|
|
132
|
+
_add_capabilities()
|
|
133
|
+
|
|
134
|
+
rc = cli.main(_argv(vcf, "--export-type", "csv", "--compression", "none"))
|
|
135
|
+
|
|
136
|
+
assert rc == cli.EXIT_ERROR
|
|
137
|
+
assert "can't send a plain CSV" in capsys.readouterr().err
|
|
138
|
+
assert [c.request.url for c in responses.calls] == [CAPABILITIES_URL]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_vcf_compression_none_is_error(vcf, capsys):
|
|
142
|
+
rc = cli.main(_argv(vcf, "--compression", "none"))
|
|
143
|
+
assert rc == cli.EXIT_ERROR
|
|
144
|
+
assert "only to --export-type csv" in capsys.readouterr().err
|
|
145
|
+
|
|
146
|
+
|
|
101
147
|
@responses.activate
|
|
102
148
|
def test_wait_uploads_polls_then_downloads(vcf, tmp_path):
|
|
103
149
|
"""--wait on a new file: upload, poll until complete, then download - no external loop."""
|
|
@@ -141,6 +141,42 @@ def test_mock_download_annotated_defaults_to_dest_path(mock_api):
|
|
|
141
141
|
assert str(result) == "/tmp/out.vcf.gz"
|
|
142
142
|
|
|
143
143
|
|
|
144
|
+
def test_mock_download_annotated_compression(mock_api):
|
|
145
|
+
""" #36 - compression is recorded only when not the default """
|
|
146
|
+
mock_api.download_annotated(uploaded_file_id=1, export_type="csv")
|
|
147
|
+
mock_api.download_annotated(uploaded_file_id=1, export_type="csv", compression="none")
|
|
148
|
+
(_, default_kwargs), (_, plain_kwargs) = mock_api.get_calls("download_annotated")
|
|
149
|
+
assert "compression" not in default_kwargs
|
|
150
|
+
assert plain_kwargs["compression"] == "none"
|
|
151
|
+
with pytest.raises(ValueError):
|
|
152
|
+
mock_api.download_annotated(uploaded_file_id=1, export_type="vcf", compression="none")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_mock_plain_csv_on_older_server_follows_policy():
|
|
156
|
+
older = ServerCapabilities(version="4.0.0", features=frozenset({ServerFeature.UPLOAD_STATUS}))
|
|
157
|
+
with pytest.raises(UnsupportedFeatureError):
|
|
158
|
+
MockVariantGridAPI(capabilities=older).annotate_vcf("input.vcf", export_type="csv", compression="none")
|
|
159
|
+
|
|
160
|
+
mock_api = MockVariantGridAPI(capabilities=older, unsupported_feature_policy=UnsupportedFeaturePolicy.SKIP)
|
|
161
|
+
mock_api.annotate_vcf("input.vcf", export_type="csv", compression="none")
|
|
162
|
+
(_, kwargs), = mock_api.get_calls("annotate_vcf")
|
|
163
|
+
assert "compression" not in kwargs # fell back to zip
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_mock_upload_file_required_features(mock_api):
|
|
167
|
+
""" #38 - recorded only when given, and gated like the real client """
|
|
168
|
+
mock_api.upload_file("sv.vcf", path=None, required_features=[ServerFeature.SYMBOLIC_ALTS])
|
|
169
|
+
assert mock_api.get_calls("upload_file") == [
|
|
170
|
+
(("sv.vcf",), {"path": None, "required_features": [ServerFeature.SYMBOLIC_ALTS]})]
|
|
171
|
+
|
|
172
|
+
vg3 = ServerCapabilities(version="3.x", features=frozenset({ServerFeature.UPLOAD_STATUS}))
|
|
173
|
+
with pytest.raises(UnsupportedFeatureError):
|
|
174
|
+
MockVariantGridAPI(capabilities=vg3).upload_file("sv.vcf", required_features=[ServerFeature.SYMBOLIC_ALTS])
|
|
175
|
+
skip_api = MockVariantGridAPI(capabilities=vg3, unsupported_feature_policy=UnsupportedFeaturePolicy.SKIP)
|
|
176
|
+
assert skip_api.upload_file("sv.vcf", required_features=[ServerFeature.SYMBOLIC_ALTS]) is None
|
|
177
|
+
skip_api.assert_not_called("upload_file")
|
|
178
|
+
|
|
179
|
+
|
|
144
180
|
def test_mock_annotate_vcf_records_and_returns(mock_api):
|
|
145
181
|
result = mock_api.annotate_vcf("input.vcf", export_type="csv")
|
|
146
182
|
mock_api.assert_called_once("annotate_vcf")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{variantgrid_api-1.10.0 → variantgrid_api-1.11.0}/src/variantgrid_api.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{variantgrid_api-1.10.0 → variantgrid_api-1.11.0}/src/variantgrid_api.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
{variantgrid_api-1.10.0 → variantgrid_api-1.11.0}/src/variantgrid_api.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|