variantgrid-api 1.9.0__tar.gz → 1.11.0__tar.gz

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