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.
Files changed (26) hide show
  1. {variantgrid_api-1.10.0/src/variantgrid_api.egg-info → variantgrid_api-1.11.0}/PKG-INFO +13 -4
  2. {variantgrid_api-1.10.0 → variantgrid_api-1.11.0}/README.md +12 -3
  3. {variantgrid_api-1.10.0 → variantgrid_api-1.11.0}/pyproject.toml +1 -1
  4. {variantgrid_api-1.10.0 → variantgrid_api-1.11.0}/src/variantgrid_api/api_client.py +75 -20
  5. {variantgrid_api-1.10.0 → variantgrid_api-1.11.0}/src/variantgrid_api/cli.py +20 -2
  6. {variantgrid_api-1.10.0 → variantgrid_api-1.11.0}/src/variantgrid_api/data_models.py +8 -0
  7. {variantgrid_api-1.10.0 → variantgrid_api-1.11.0}/src/variantgrid_api/mock_variantgrid_api.py +31 -5
  8. {variantgrid_api-1.10.0 → variantgrid_api-1.11.0/src/variantgrid_api.egg-info}/PKG-INFO +13 -4
  9. {variantgrid_api-1.10.0 → variantgrid_api-1.11.0}/tests/test_api_client_annotation.py +131 -1
  10. {variantgrid_api-1.10.0 → variantgrid_api-1.11.0}/tests/test_api_client_capabilities.py +55 -1
  11. {variantgrid_api-1.10.0 → variantgrid_api-1.11.0}/tests/test_cli.py +46 -0
  12. {variantgrid_api-1.10.0 → variantgrid_api-1.11.0}/tests/test_mock_variantgrid_api.py +36 -0
  13. {variantgrid_api-1.10.0 → variantgrid_api-1.11.0}/LICENSE +0 -0
  14. {variantgrid_api-1.10.0 → variantgrid_api-1.11.0}/setup.cfg +0 -0
  15. {variantgrid_api-1.10.0 → variantgrid_api-1.11.0}/src/variantgrid_api.egg-info/SOURCES.txt +0 -0
  16. {variantgrid_api-1.10.0 → variantgrid_api-1.11.0}/src/variantgrid_api.egg-info/dependency_links.txt +0 -0
  17. {variantgrid_api-1.10.0 → variantgrid_api-1.11.0}/src/variantgrid_api.egg-info/entry_points.txt +0 -0
  18. {variantgrid_api-1.10.0 → variantgrid_api-1.11.0}/src/variantgrid_api.egg-info/requires.txt +0 -0
  19. {variantgrid_api-1.10.0 → variantgrid_api-1.11.0}/src/variantgrid_api.egg-info/top_level.txt +0 -0
  20. {variantgrid_api-1.10.0 → variantgrid_api-1.11.0}/tests/test_alignment_files.py +0 -0
  21. {variantgrid_api-1.10.0 → variantgrid_api-1.11.0}/tests/test_api_client.py +0 -0
  22. {variantgrid_api-1.10.0 → variantgrid_api-1.11.0}/tests/test_api_client_bulk.py +0 -0
  23. {variantgrid_api-1.10.0 → variantgrid_api-1.11.0}/tests/test_api_client_patients.py +0 -0
  24. {variantgrid_api-1.10.0 → variantgrid_api-1.11.0}/tests/test_api_client_validation.py +0 -0
  25. {variantgrid_api-1.10.0 → variantgrid_api-1.11.0}/tests/test_data_models.py +0 -0
  26. {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.10.0
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
- Add `--verbose` to log each request, response and the full upload status to stderr.
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, specimen measures,
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 `file_type`. A server without the capabilities endpoint counts as legacy and reports no features.
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
- Add `--verbose` to log each request, response and the full upload status to stderr.
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, specimen measures,
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 `file_type`. A server without the capabilities endpoint counts as legacy and reports no features.
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "variantgrid_api"
7
- version = "1.10.0"
7
+ version = "1.11.0"
8
8
  description = "A Python API client for VariantGrid"
9
9
  authors = [
10
10
  { name = "Dave Lawrence", email = "davmlaw@gmail.com" }
@@ -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
- self.headers = {"Authorization": f"Token {api_token}"}
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
- ext = ".vcf.gz" if export_type == "vcf" else ".csv.zip"
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' (zipped *.csv.zip). The export covers all samples
647
- in the uploaded VCF (single-sample VCFs included).
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 the attachment filename is placed inside it;
652
- otherwise it is treated as the full destination path. Returns the Path written.
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
- if export_type not in ("vcf", "csv"):
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) -> Path:
682
- filename = self._attachment_filename(response, export_type)
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) -> Optional[Path]:
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' - checked before uploading """
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
 
@@ -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.10.0
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
- Add `--verbose` to log each request, response and the full upload status to stderr.
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, specimen measures,
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 `file_type`. A server without the capabilities endpoint counts as legacy and reports no features.
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")