variantgrid-api 1.4.0__tar.gz → 1.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. {variantgrid_api-1.4.0/src/variantgrid_api.egg-info → variantgrid_api-1.6.0}/PKG-INFO +48 -1
  2. variantgrid_api-1.6.0/README.md +101 -0
  3. {variantgrid_api-1.4.0 → variantgrid_api-1.6.0}/pyproject.toml +1 -1
  4. {variantgrid_api-1.4.0 → variantgrid_api-1.6.0}/src/variantgrid_api/api_client.py +207 -18
  5. {variantgrid_api-1.4.0 → variantgrid_api-1.6.0}/src/variantgrid_api/cli.py +4 -1
  6. variantgrid_api-1.6.0/src/variantgrid_api/data_models.py +506 -0
  7. {variantgrid_api-1.4.0 → variantgrid_api-1.6.0}/src/variantgrid_api/mock_variantgrid_api.py +111 -5
  8. {variantgrid_api-1.4.0 → variantgrid_api-1.6.0/src/variantgrid_api.egg-info}/PKG-INFO +48 -1
  9. {variantgrid_api-1.4.0 → variantgrid_api-1.6.0}/src/variantgrid_api.egg-info/SOURCES.txt +2 -0
  10. {variantgrid_api-1.4.0 → variantgrid_api-1.6.0}/tests/test_api_client.py +56 -1
  11. {variantgrid_api-1.4.0 → variantgrid_api-1.6.0}/tests/test_api_client_annotation.py +5 -0
  12. variantgrid_api-1.6.0/tests/test_api_client_capabilities.py +234 -0
  13. variantgrid_api-1.6.0/tests/test_api_client_patients.py +245 -0
  14. {variantgrid_api-1.4.0 → variantgrid_api-1.6.0}/tests/test_cli.py +25 -0
  15. {variantgrid_api-1.4.0 → variantgrid_api-1.6.0}/tests/test_mock_variantgrid_api.py +97 -0
  16. variantgrid_api-1.4.0/README.md +0 -54
  17. variantgrid_api-1.4.0/src/variantgrid_api/data_models.py +0 -269
  18. {variantgrid_api-1.4.0 → variantgrid_api-1.6.0}/LICENSE +0 -0
  19. {variantgrid_api-1.4.0 → variantgrid_api-1.6.0}/setup.cfg +0 -0
  20. {variantgrid_api-1.4.0 → variantgrid_api-1.6.0}/src/variantgrid_api.egg-info/dependency_links.txt +0 -0
  21. {variantgrid_api-1.4.0 → variantgrid_api-1.6.0}/src/variantgrid_api.egg-info/entry_points.txt +0 -0
  22. {variantgrid_api-1.4.0 → variantgrid_api-1.6.0}/src/variantgrid_api.egg-info/requires.txt +0 -0
  23. {variantgrid_api-1.4.0 → variantgrid_api-1.6.0}/src/variantgrid_api.egg-info/top_level.txt +0 -0
  24. {variantgrid_api-1.4.0 → variantgrid_api-1.6.0}/tests/test_api_client_bulk.py +0 -0
  25. {variantgrid_api-1.4.0 → variantgrid_api-1.6.0}/tests/test_api_client_validation.py +0 -0
  26. {variantgrid_api-1.4.0 → variantgrid_api-1.6.0}/tests/test_data_models.py +0 -0
  27. {variantgrid_api-1.4.0 → variantgrid_api-1.6.0}/tests/test_sequencer_model_from_name.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: variantgrid_api
3
- Version: 1.4.0
3
+ Version: 1.6.0
4
4
  Summary: A Python API client for VariantGrid
5
5
  Author-email: Dave Lawrence <davmlaw@gmail.com>
6
6
  License: MIT License
@@ -88,6 +88,53 @@ From Python it's `upload_file()` / `poll_upload_status()` / `download_annotated(
88
88
  **[Annotate a VCF](https://github.com/SACGF/variantgrid_api/wiki/Annotate-a-VCF)** on the wiki for batches, the
89
89
  submit-now/download-later pattern, and all the options.
90
90
 
91
+ ## Patients, specimens and extractions
92
+
93
+ VariantGrid can record which patient, specimen and extraction a lab's sequencing came from, and specimen-level
94
+ measures such as TMB, MSI and GIS. This needs a server at or after SACGF/variantgrid#1716.
95
+
96
+ ```
97
+ from variantgrid_api.data_models import Patient, Specimen, Extraction, ExternalReference, NucleicAcid
98
+
99
+ api.create_patient(Patient(patient_code="C0000001"))
100
+ api.create_specimen(Specimen(patient="C0000001", reference_id="2600000001"))
101
+ api.create_extraction(Extraction(specimen="2600000001", reference_id="2600000001C",
102
+ nucleic_acid_source=NucleicAcid.DNA))
103
+ api.upload_file("sample.vcf.gz", path=None,
104
+ metadata={"extraction": "2600000001C", "genome_build": "GRCh37"})
105
+ ```
106
+
107
+ A bare string names a record by its local reference. Use `ExternalReference(code=..., external_type=...)` to
108
+ name it by a LIMS identifier instead. See `examples/example_tso500.py` for a full run.
109
+
110
+ ## Talking to more than one VariantGrid version
111
+
112
+ Servers of different ages accept different calls. The client asks the server which features it has
113
+ (`GET api/v1/capabilities`, fetched once on first use), and each call that needs a newer server
114
+ checks that list first: the patient / specimen / extraction calls, specimen measures,
115
+ `link_sequencing_sample_extraction`, the annotate flow (`poll_upload_status`, `wait_for_annotation`,
116
+ `download_annotated`, `annotate_vcf`), and `upload_file` with `metadata` or `file_type`. A server without the
117
+ capabilities endpoint counts as legacy and reports no features.
118
+
119
+ By default an unsupported call raises `UnsupportedFeatureError`. To run the same code against old and new
120
+ servers, skip those calls instead: they log a warning and return `None`. `upload_file` is the exception: on a
121
+ server without upload metadata it drops the `metadata` and still uploads the file.
122
+
123
+ ```
124
+ from variantgrid_api.api_client import VariantGridAPI, UnsupportedFeaturePolicy
125
+
126
+ api = VariantGridAPI(server, api_token, unsupported_feature_policy=UnsupportedFeaturePolicy.SKIP)
127
+ api.create_patient(patient) # skipped on a server without patients
128
+
129
+ # Uploads only when the server has an importer for this file type
130
+ api.upload_file("sample_CombinedVariantOutput.tsv", path=None,
131
+ file_type="dragen_tso500_combined_variant_output")
132
+ ```
133
+
134
+ Where the fallback isn't simply "do nothing", branch on `api.supports("feature")` or
135
+ `api.accepts_upload("file_type")`. `api.capabilities.version` is useful for logging which server you reached.
136
+ For tests, `MockVariantGridAPI(capabilities=ServerCapabilities.LEGACY)` behaves like an old server.
137
+
91
138
  ## Testing
92
139
 
93
140
  ```
@@ -0,0 +1,101 @@
1
+ # variantgrid_api
2
+
3
+ [![PyPi version](https://img.shields.io/pypi/v/variantgrid_api.svg)](https://pypi.org/project/variantgrid_api/) [![Python versions](https://img.shields.io/pypi/pyversions/variantgrid_api.svg)](https://pypi.org/project/variantgrid_api/)
4
+
5
+ Python API client for [VariantGrid](https://github.com/SACGF/variantgrid) Open source Variant database and analysis platform
6
+
7
+ See [changelog](https://github.com/SACGF/variantgrid_api/blob/main/CHANGELOG.md)
8
+
9
+ ## Install
10
+
11
+ ```
12
+ python3 -m pip install variantgrid_api
13
+ ```
14
+
15
+ ## Example
16
+
17
+ ```
18
+ from variantgrid_api.api_client import VariantGridAPI
19
+ from variantgrid_api.data_models import EnrichmentKit
20
+
21
+ api = VariantGridAPI(server="https://variantgrid.com", api_token="YOUR_API_TOKEN")
22
+ enrichment_kit = EnrichmentKit(name="idt_haem", version=1)
23
+ result = api.create_enrichment_kit(enrichment_kit)
24
+ ```
25
+
26
+ ## Annotate a VCF and download it back
27
+
28
+ Upload a VCF, have VariantGrid import + annotate any novel variants, then download the cohort-level annotated
29
+ export (all samples, single-sample VCFs included). Annotation can take a while, so the quickest way in is the
30
+ `vg_api` command line tool: the first call uploads, and running the same command again downloads the result
31
+ once it's ready.
32
+
33
+ ```console
34
+ $ export VARIANTGRID_API_TOKEN=YOUR_API_TOKEN
35
+ $ vg_api annotate_vcf input.vcf.gz -o results/
36
+ Uploaded input.vcf.gz (id=13256).
37
+ Annotating input.vcf.gz - run the same command again later to download.
38
+
39
+ $ vg_api annotate_vcf input.vcf.gz -o results/ # once it's done
40
+ Annotated vcf written to results/input.vcf_annotated_v254_GRCh38.vcf.gz
41
+ ```
42
+
43
+ From Python it's `upload_file()` / `poll_upload_status()` / `download_annotated()`, or the blocking
44
+ `annotate_vcf()` one-liner. See
45
+ **[Annotate a VCF](https://github.com/SACGF/variantgrid_api/wiki/Annotate-a-VCF)** on the wiki for batches, the
46
+ submit-now/download-later pattern, and all the options.
47
+
48
+ ## Patients, specimens and extractions
49
+
50
+ VariantGrid can record which patient, specimen and extraction a lab's sequencing came from, and specimen-level
51
+ measures such as TMB, MSI and GIS. This needs a server at or after SACGF/variantgrid#1716.
52
+
53
+ ```
54
+ from variantgrid_api.data_models import Patient, Specimen, Extraction, ExternalReference, NucleicAcid
55
+
56
+ api.create_patient(Patient(patient_code="C0000001"))
57
+ api.create_specimen(Specimen(patient="C0000001", reference_id="2600000001"))
58
+ api.create_extraction(Extraction(specimen="2600000001", reference_id="2600000001C",
59
+ nucleic_acid_source=NucleicAcid.DNA))
60
+ api.upload_file("sample.vcf.gz", path=None,
61
+ metadata={"extraction": "2600000001C", "genome_build": "GRCh37"})
62
+ ```
63
+
64
+ A bare string names a record by its local reference. Use `ExternalReference(code=..., external_type=...)` to
65
+ name it by a LIMS identifier instead. See `examples/example_tso500.py` for a full run.
66
+
67
+ ## Talking to more than one VariantGrid version
68
+
69
+ Servers of different ages accept different calls. The client asks the server which features it has
70
+ (`GET api/v1/capabilities`, fetched once on first use), and each call that needs a newer server
71
+ checks that list first: the patient / specimen / extraction calls, specimen measures,
72
+ `link_sequencing_sample_extraction`, the annotate flow (`poll_upload_status`, `wait_for_annotation`,
73
+ `download_annotated`, `annotate_vcf`), and `upload_file` with `metadata` or `file_type`. A server without the
74
+ capabilities endpoint counts as legacy and reports no features.
75
+
76
+ By default an unsupported call raises `UnsupportedFeatureError`. To run the same code against old and new
77
+ servers, skip those calls instead: they log a warning and return `None`. `upload_file` is the exception: on a
78
+ server without upload metadata it drops the `metadata` and still uploads the file.
79
+
80
+ ```
81
+ from variantgrid_api.api_client import VariantGridAPI, UnsupportedFeaturePolicy
82
+
83
+ api = VariantGridAPI(server, api_token, unsupported_feature_policy=UnsupportedFeaturePolicy.SKIP)
84
+ api.create_patient(patient) # skipped on a server without patients
85
+
86
+ # Uploads only when the server has an importer for this file type
87
+ api.upload_file("sample_CombinedVariantOutput.tsv", path=None,
88
+ file_type="dragen_tso500_combined_variant_output")
89
+ ```
90
+
91
+ Where the fallback isn't simply "do nothing", branch on `api.supports("feature")` or
92
+ `api.accepts_upload("file_type")`. `api.capabilities.version` is useful for logging which server you reached.
93
+ For tests, `MockVariantGridAPI(capabilities=ServerCapabilities.LEGACY)` behaves like an old server.
94
+
95
+ ## Testing
96
+
97
+ ```
98
+ # Install required testing packages
99
+ python3 -m pip install -e ".[test]"
100
+ python3 -m pytest --cov=variantgrid_api
101
+ ```
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "variantgrid_api"
7
- version = "1.4.0"
7
+ version = "1.6.0"
8
8
  description = "A Python API client for VariantGrid"
9
9
  authors = [
10
10
  { name = "Dave Lawrence", email = "davmlaw@gmail.com" }
@@ -12,7 +12,9 @@ from typing import List, Optional, Callable, Union
12
12
  import requests
13
13
 
14
14
  from variantgrid_api.data_models import EnrichmentKit, SequencingRun, SampleSheet, JointCalledVCF, \
15
- SampleSheetLookup, SequencingFile, QCGeneList, QCExecStats, QCGeneCoverage, SequencerModel, Sequencer
15
+ SampleSheetLookup, SequencingFile, QCGeneList, QCExecStats, QCGeneCoverage, SequencerModel, Sequencer, \
16
+ SequencingSampleLookup, Patient, Specimen, Extraction, SpecimenMeasure, ExternalReference, ReferenceLike, \
17
+ reference_json, ServerCapabilities, ServerFeature, UploadFileType
16
18
 
17
19
 
18
20
  _UNSET = object()
@@ -31,6 +33,20 @@ class EmptyInputPolicy(Enum):
31
33
  ERROR = "error"
32
34
 
33
35
 
36
+ class UnsupportedFeaturePolicy(Enum):
37
+ """ What a gated call does when the server lacks the feature (see VariantGridAPI.capabilities) """
38
+ SKIP = "skip" # log a warning, return None
39
+ ERROR = "error" # raise UnsupportedFeatureError
40
+
41
+
42
+ class UnsupportedFeatureError(Exception):
43
+ """ Raised (under UnsupportedFeaturePolicy.ERROR) when a call needs a feature the server doesn't have """
44
+
45
+ def __init__(self, message, capabilities: Optional[ServerCapabilities] = None):
46
+ super().__init__(message)
47
+ self.capabilities = capabilities
48
+
49
+
34
50
  class AnnotationError(Exception):
35
51
  """Raised when the server reports an error while importing/annotating an uploaded file."""
36
52
 
@@ -43,7 +59,8 @@ class VariantGridAPI:
43
59
  def __init__(self, server, api_token,
44
60
  empty_input_policy=EmptyInputPolicy.ERROR,
45
61
  logger: Optional[logging.Logger] = None,
46
- log_request=False, log_response=False
62
+ log_request=False, log_response=False,
63
+ unsupported_feature_policy=UnsupportedFeaturePolicy.ERROR
47
64
  ):
48
65
  self.server = server
49
66
  self.headers = {"Authorization": f"Token {api_token}"}
@@ -53,6 +70,8 @@ class VariantGridAPI:
53
70
  self.validation_handler = self._get_validation_handler(empty_input_policy, logger)
54
71
  self.log_request = log_request
55
72
  self.log_response = log_response
73
+ self.unsupported_feature_policy = unsupported_feature_policy
74
+ self._capabilities: Optional[ServerCapabilities] = None
56
75
 
57
76
  def _get_url(self, url):
58
77
  return urllib.parse.urljoin(self.server, url)
@@ -110,10 +129,61 @@ class VariantGridAPI:
110
129
  if obj is None:
111
130
  self.validation_handler(f"{info}: is None")
112
131
 
132
+ def _validate_reference(self, info: str, reference: Optional[ReferenceLike]):
133
+ if isinstance(reference, str):
134
+ self._validate_string(info, reference)
135
+ else:
136
+ self._validate_object(info, reference)
137
+
113
138
  def _validate_list(self, info: str, list_obj: List):
114
139
  if not list_obj:
115
140
  self.validation_handler(f"{info}: empty list")
116
141
 
142
+ ##########################################################
143
+ ## Server capabilities (SACGF/variantgrid_sapath#443)
144
+ ## One client talks to servers of different ages. Calls needing a newer server are gated on the
145
+ ## features it reports, and handled by unsupported_feature_policy when it lacks them
146
+
147
+ @property
148
+ def capabilities(self) -> ServerCapabilities:
149
+ """ Fetched on first use then cached, so constructing the client and ungated calls do no extra
150
+ request. A server without the endpoint is ServerCapabilities.LEGACY - it answers 404, or a redirect
151
+ to its login page when its login middleware doesn't exempt /api/ (so redirects aren't followed) """
152
+ if self._capabilities is None:
153
+ url = self._get_url("api/v1/capabilities")
154
+ response = requests.get(url, headers=self.headers, allow_redirects=False)
155
+ if response.status_code == 404 or response.is_redirect:
156
+ self.logger.info("Server has no capabilities endpoint (HTTP %s) - treating as legacy",
157
+ response.status_code)
158
+ self._capabilities = ServerCapabilities.LEGACY
159
+ else:
160
+ data = self._handle_json_response(response, f"{url=}")
161
+ self._capabilities = ServerCapabilities.from_json(data)
162
+ return self._capabilities
163
+
164
+ def supports(self, feature: Union[ServerFeature, str]) -> bool:
165
+ return feature in self.capabilities.features
166
+
167
+ def accepts_upload(self, file_type: Union[UploadFileType, str]) -> bool:
168
+ """ file_type is an UploadFileType, or the server's UploadedFileTypes name in lower case """
169
+ return file_type in self.capabilities.upload_file_types
170
+
171
+ def _unsupported(self, message: str) -> bool:
172
+ """ Applies unsupported_feature_policy - returns False (caller skips) or raises """
173
+ capabilities = self.capabilities
174
+ message = f"{message} (server version '{capabilities.version}')"
175
+ if self.unsupported_feature_policy == UnsupportedFeaturePolicy.SKIP:
176
+ self.logger.warning("Skipping: %s", message)
177
+ return False
178
+ raise UnsupportedFeatureError(message, capabilities)
179
+
180
+ def _require(self, feature: Union[ServerFeature, str]) -> bool:
181
+ """ True if the server supports feature, otherwise applies unsupported_feature_policy """
182
+ return self.supports(feature) or self._unsupported(f"server doesn't support feature '{feature}'")
183
+
184
+ def _require_upload(self, file_type: Union[UploadFileType, str]) -> bool:
185
+ return self.accepts_upload(file_type) or self._unsupported(f"server doesn't accept upload file type '{file_type}'")
186
+
117
187
  def create_experiment(self, experiment: str):
118
188
  self._validate_string("experiment", experiment)
119
189
  json_data = {
@@ -171,14 +241,21 @@ class VariantGridAPI:
171
241
  self._validate_list("sequencing_files", sequencing_files)
172
242
  records = []
173
243
  for sf in sequencing_files:
244
+ # The server requires both paths - catch it here, naming the record, rather than a 400 for the batch
245
+ self._validate_string(f"SequencingFile '{sf.sample_name}' bam_file.path", sf.bam_file and sf.bam_file.path)
246
+ self._validate_string(f"SequencingFile '{sf.sample_name}' vcf_file.path", sf.vcf_file and sf.vcf_file.path)
174
247
  data = sf.to_dict()
175
248
  # put into hierarchial JSON DRF expects
176
- fastq_r1 = data.pop("fastq_r1")
177
- fastq_r2 = data.pop("fastq_r2")
178
- data["unaligned_reads"] = {
179
- "fastq_r1": {"path": fastq_r1},
180
- "fastq_r2": {"path": fastq_r2}
181
- }
249
+ fastq_r1 = data.pop("fastq_r1", None)
250
+ fastq_r2 = data.pop("fastq_r2", None)
251
+ if fastq_r1:
252
+ unaligned_reads = {"fastq_r1": {"path": fastq_r1}}
253
+ if fastq_r2:
254
+ unaligned_reads["fastq_r2"] = {"path": fastq_r2}
255
+ data["unaligned_reads"] = unaligned_reads
256
+ elif fastq_r2:
257
+ raise ValueError(f"SequencingFile '{sf.sample_name}' has fastq_r2 without fastq_r1")
258
+ # No FastQs (BAM-first run) - server resolves the sample from sample_name
182
259
  records.append(data)
183
260
 
184
261
  json_data = {
@@ -231,7 +308,73 @@ class VariantGridAPI:
231
308
  return self._post("seqauto/api/v1/qc_gene_coverage/bulk_create",
232
309
  json_data)
233
310
 
234
- def upload_file(self, filename: str, path=_UNSET):
311
+ ##########################################################
312
+ ## Patient -> Specimen -> Extraction (SACGF/variantgrid#1707)
313
+ ## Creates are upserts keyed on the identifiers sent, so re-posting returns the same rows
314
+
315
+ def create_patient(self, patient: Patient):
316
+ if not self._require(ServerFeature.PATIENTS):
317
+ return None
318
+ self._validate_object("patient", patient)
319
+ return self._post("patients/api/v1/patient/", patient.to_dict())
320
+
321
+ def create_specimen(self, specimen: Specimen):
322
+ """ The specimen's patient must already exist on the server, otherwise this is a 400 """
323
+ if not self._require(ServerFeature.PATIENTS):
324
+ return None
325
+ self._validate_object("specimen", specimen)
326
+ return self._post("patients/api/v1/specimen/", specimen.to_dict())
327
+
328
+ def create_extraction(self, extraction: Extraction):
329
+ """ The extraction's specimen must already exist on the server, otherwise this is a 400 """
330
+ if not self._require(ServerFeature.PATIENTS):
331
+ return None
332
+ self._validate_object("extraction", extraction)
333
+ return self._post("patients/api/v1/extraction/", extraction.to_dict())
334
+
335
+ def create_specimen_measure(self, specimen_reference: ReferenceLike, measure: SpecimenMeasure):
336
+ """ An unknown specimen is a 400. Replaces any existing measure of the same type for the specimen """
337
+ if not self._require(ServerFeature.SPECIMEN_MEASURES):
338
+ return None
339
+ self._validate_reference("specimen_reference", specimen_reference)
340
+ self._validate_object("measure", measure)
341
+ json_data = {"specimen": reference_json(specimen_reference), **measure.to_dict()}
342
+ return self._post("patients/api/v1/specimen_measure/", json_data)
343
+
344
+ def create_specimen_measures(self, specimen_reference: ReferenceLike, measures: List[SpecimenMeasure]):
345
+ """ A run's measures (TMB, MSI, GIS etc) against one specimen in one call """
346
+ if not self._require(ServerFeature.SPECIMEN_MEASURES):
347
+ return None
348
+ self._validate_reference("specimen_reference", specimen_reference)
349
+ self._validate_list("measures", measures)
350
+ json_data = {
351
+ "specimen": reference_json(specimen_reference),
352
+ "measures": [measure.to_dict() for measure in measures],
353
+ }
354
+ return self._post("patients/api/v1/specimen_measure/bulk_create", json_data)
355
+
356
+ def link_sequencing_sample_extraction(self, sequencing_sample_lookup: SequencingSampleLookup,
357
+ extraction_reference: ReferenceLike) -> Optional[dict]:
358
+ """ Name the extraction a sequencing sample was made from. One call per sequencing sample is
359
+ enough - the server carries it to every Sample made from that sample's VCFs, and on to the
360
+ new rows if the sample sheet is re-sent.
361
+
362
+ Returns {"sequencing_sample", "match_status", "match_error", "extraction"}. An unknown
363
+ sequencing sample is a 400, but an extraction the server doesn't have yet is not an error:
364
+ the response is a 202 with match_status 'Pending', and the link attaches itself once the
365
+ extraction is created - there's no need to re-send. """
366
+ if not self._require(ServerFeature.LINK_EXTRACTION):
367
+ return None
368
+ self._validate_object("sequencing_sample_lookup", sequencing_sample_lookup)
369
+ self._validate_reference("extraction_reference", extraction_reference)
370
+ json_data = {
371
+ "sequencing_sample": sequencing_sample_lookup.to_dict(),
372
+ "extraction": reference_json(extraction_reference),
373
+ }
374
+ return self._post("seqauto/api/v1/sequencing_sample/link_extraction", json_data)
375
+
376
+ def upload_file(self, filename: str, path=_UNSET, metadata: Optional[dict] = None,
377
+ file_type: Optional[Union[UploadFileType, str]] = None):
235
378
  """ Upload a file via multipart POST to upload/api/v1/file_upload.
236
379
 
237
380
  Returns {"uploaded_file_id": <id>, "sha256_hash": <hash>, ...}; identify the upload by
@@ -241,16 +384,53 @@ class VariantGridAPI:
241
384
  (JointCalledVCF / SingleSampleVCF) by path. Defaults to `filename` for backwards
242
385
  compatibility. Pass path=None to omit the query param entirely - required for ad-hoc
243
386
  uploads such as the annotate/download flow, where sending a client-side path makes
244
- SeqAuto deployments try (and fail) to match it to a registered VCF. """
387
+ SeqAuto deployments try (and fail) to match it to a registered VCF.
388
+
389
+ metadata: facts about the file it doesn't carry itself, sent as extra query params. A VCF accepts:
390
+ 'genome_build' - the build's own name ('GRCh37'), not an alias ('hg19')
391
+ 'source' - the caller/software, eg 'DRAGEN TSO500 SmallVariant'
392
+ 'extraction' - reference (str or ExternalReference) for every sample in the file
393
+ 'sample_extractions' - {vcf_sample_name: reference} for a multi-sample VCF
394
+ Send 'extraction' or 'sample_extractions', not both. An unknown key is a 400. An extraction
395
+ the server doesn't have yet is not - it attaches once the extraction is created.
396
+ Needs the server feature 'upload_metadata'. Without it, SKIP uploads the file without the
397
+ metadata (as older clients did) rather than not at all, and ERROR raises
398
+
399
+ file_type: the server's name for what this file is, eg UploadFileType.DRAGEN_TSO500_COMBINED_VARIANT_OUTPUT.
400
+ Not sent (the server decides from the filename) - if given, the upload only happens when
401
+ accepts_upload(file_type), so an older server doesn't mis-import it as something else """
402
+ if metadata and not self.supports(ServerFeature.UPLOAD_METADATA):
403
+ self._unsupported(f"upload metadata for '{filename}' (server doesn't support feature 'upload_metadata')")
404
+ metadata = None
405
+ if file_type and not self._require_upload(file_type):
406
+ return None
245
407
  url = self._get_url("upload/api/v1/file_upload")
246
408
  if path is _UNSET:
247
409
  path = filename
248
410
  params = {"path": path} if path is not None else {}
411
+ if metadata:
412
+ params.update(self._upload_metadata_params(metadata))
249
413
  with open(filename, "rb") as f:
250
414
  response = requests.post(url, headers=self.headers, files={"file": f}, params=params)
251
415
  extra_error_message = f"{filename=}"
252
416
  return self._handle_json_response(response, extra_error_message)
253
417
 
418
+ @staticmethod
419
+ def _upload_metadata_params(metadata: dict) -> dict:
420
+ """ Query params are strings - references and objects go as JSON, which the server parses """
421
+ if reserved := {"path", "force"} & set(metadata):
422
+ raise ValueError(f"Upload metadata can't use reserved query param(s): {', '.join(sorted(reserved))}")
423
+ params = {}
424
+ for key, value in metadata.items():
425
+ if isinstance(value, ExternalReference):
426
+ value = reference_json(value)
427
+ elif isinstance(value, dict):
428
+ value = {k: reference_json(v) for k, v in value.items()}
429
+ if isinstance(value, (dict, list)):
430
+ value = json.dumps(value)
431
+ params[key] = value
432
+ return params
433
+
254
434
  ###############
255
435
  ## Get methods
256
436
 
@@ -291,17 +471,19 @@ class VariantGridAPI:
291
471
  return f"sha256/{sha256}"
292
472
  raise ValueError("Must provide one of 'uploaded_file_id' or 'sha256'")
293
473
 
294
- def poll_upload_status(self, uploaded_file_id: Optional[int] = None, sha256: Optional[str] = None) -> dict:
474
+ def poll_upload_status(self, uploaded_file_id: Optional[int] = None, sha256: Optional[str] = None) -> Optional[dict]:
295
475
  """ Single GET of an uploaded file's import/annotation status.
296
476
 
297
477
  Keyed by uploaded_file_id (returned from upload_file) or the SHA-256 of the uploaded file.
298
- See wait_for_annotation to block until annotation is complete. """
478
+ See wait_for_annotation to block until annotation is complete. Needs server feature 'upload_status' """
479
+ if not self._require(ServerFeature.UPLOAD_STATUS):
480
+ return None
299
481
  segment = self._upload_key_segment(uploaded_file_id, sha256)
300
482
  return self._get(f"upload/api/v1/upload_status/{segment}")
301
483
 
302
484
  def wait_for_annotation(self, uploaded_file_id: Optional[int] = None, sha256: Optional[str] = None,
303
485
  timeout: float = 3600, poll_interval: float = 10, sleep: Callable = time.sleep,
304
- max_transient_errors: int = 5) -> dict:
486
+ max_transient_errors: int = 5) -> Optional[dict]:
305
487
  """ Poll poll_upload_status until 'annotation_complete' is true, then return the final status dict.
306
488
 
307
489
  Raises AnnotationError if the server reports an 'error', or TimeoutError if 'timeout' seconds elapse.
@@ -310,7 +492,9 @@ class VariantGridAPI:
310
492
  Transient server hiccups (5xx / connection / timeout - e.g. a brief 500 right after upload while the
311
493
  server is still creating the upload record) are tolerated: up to 'max_transient_errors' *consecutive*
312
494
  failures are retried before giving up. A 4xx response is treated as a real error and raised immediately.
313
- The success counter resets whenever a poll succeeds. """
495
+ The success counter resets whenever a poll succeeds. Needs server feature 'upload_status' """
496
+ if not self._require(ServerFeature.UPLOAD_STATUS):
497
+ return None
314
498
  deadline = time.monotonic() + timeout
315
499
  transient_errors = 0
316
500
  while True:
@@ -349,7 +533,7 @@ class VariantGridAPI:
349
533
  def download_annotated(self, uploaded_file_id: Optional[int] = None, sha256: Optional[str] = None,
350
534
  export_type: str = "vcf", dest_path: Optional[Union[str, Path]] = None,
351
535
  timeout: float = 3600, poll_interval: float = 10,
352
- sleep: Callable = time.sleep) -> Path:
536
+ sleep: Callable = time.sleep) -> Optional[Path]:
353
537
  """ Download the cohort-level annotated export of an uploaded VCF, saving it to disk.
354
538
 
355
539
  export_type is 'vcf' (gzipped *.vcf.gz) or 'csv' (zipped *.csv.zip). The export covers all samples
@@ -360,7 +544,9 @@ class VariantGridAPI:
360
544
  in the current directory; if it is an existing directory the attachment filename is placed inside it;
361
545
  otherwise it is treated as the full destination path. Returns the Path written.
362
546
 
363
- Raises TimeoutError if the file isn't ready within 'timeout' seconds. """
547
+ Raises TimeoutError if the file isn't ready within 'timeout' seconds. Needs server feature 'upload_status' """
548
+ if not self._require(ServerFeature.UPLOAD_STATUS):
549
+ return None
364
550
  if export_type not in ("vcf", "csv"):
365
551
  raise ValueError(f"export_type must be 'vcf' or 'csv', got {export_type!r}")
366
552
  segment = self._upload_key_segment(uploaded_file_id, sha256)
@@ -402,10 +588,13 @@ class VariantGridAPI:
402
588
  return None
403
589
 
404
590
  def annotate_vcf(self, filename: str, export_type: str = "vcf", dest_path: Optional[Union[str, Path]] = None,
405
- timeout: float = 3600, poll_interval: float = 10, sleep: Callable = time.sleep) -> Path:
591
+ timeout: float = 3600, poll_interval: float = 10, sleep: Callable = time.sleep) -> Optional[Path]:
406
592
  """ Convenience one-liner: upload a VCF, wait for annotation to finish, download the annotated export.
407
593
 
408
- Chains upload_file -> wait_for_annotation -> download_annotated and returns the Path written. """
594
+ Chains upload_file -> wait_for_annotation -> download_annotated and returns the Path written.
595
+ Needs server feature 'upload_status' - checked before uploading """
596
+ if not self._require(ServerFeature.UPLOAD_STATUS):
597
+ return None
409
598
  # path is SeqAuto-only and makes ad-hoc uploads fail the import - omit it for the annotate flow
410
599
  upload = self.upload_file(filename, path=None)
411
600
  uploaded_file_id = upload["uploaded_file_id"]
@@ -13,7 +13,7 @@ import sys
13
13
 
14
14
  import requests
15
15
 
16
- from variantgrid_api.api_client import VariantGridAPI, AnnotationError
16
+ from variantgrid_api.api_client import VariantGridAPI, AnnotationError, UnsupportedFeatureError
17
17
 
18
18
  DEFAULT_SERVER = "https://variantgrid.com"
19
19
 
@@ -147,6 +147,9 @@ def main(argv=None):
147
147
  except requests.HTTPError as e:
148
148
  print(f"HTTP error: {e}", file=sys.stderr)
149
149
  return EXIT_ERROR
150
+ except UnsupportedFeatureError as e:
151
+ print(f"This VariantGrid server can't annotate uploaded VCFs via the API: {e}", file=sys.stderr)
152
+ return EXIT_ERROR
150
153
 
151
154
 
152
155
  if __name__ == "__main__":