variantgrid-api 1.3.2__tar.gz → 1.5.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.3.2/src/variantgrid_api.egg-info → variantgrid_api-1.5.0}/PKG-INFO +48 -1
  2. variantgrid_api-1.5.0/README.md +101 -0
  3. {variantgrid_api-1.3.2 → variantgrid_api-1.5.0}/pyproject.toml +1 -1
  4. {variantgrid_api-1.3.2 → variantgrid_api-1.5.0}/src/variantgrid_api/api_client.py +204 -18
  5. {variantgrid_api-1.3.2 → variantgrid_api-1.5.0}/src/variantgrid_api/cli.py +4 -1
  6. variantgrid_api-1.5.0/src/variantgrid_api/data_models.py +465 -0
  7. {variantgrid_api-1.3.2 → variantgrid_api-1.5.0}/src/variantgrid_api/mock_variantgrid_api.py +109 -4
  8. {variantgrid_api-1.3.2 → variantgrid_api-1.5.0/src/variantgrid_api.egg-info}/PKG-INFO +48 -1
  9. {variantgrid_api-1.3.2 → variantgrid_api-1.5.0}/src/variantgrid_api.egg-info/SOURCES.txt +2 -0
  10. {variantgrid_api-1.3.2 → variantgrid_api-1.5.0}/tests/test_api_client.py +47 -0
  11. {variantgrid_api-1.3.2 → variantgrid_api-1.5.0}/tests/test_api_client_annotation.py +5 -0
  12. variantgrid_api-1.5.0/tests/test_api_client_capabilities.py +207 -0
  13. variantgrid_api-1.5.0/tests/test_api_client_patients.py +245 -0
  14. {variantgrid_api-1.3.2 → variantgrid_api-1.5.0}/tests/test_cli.py +25 -0
  15. {variantgrid_api-1.3.2 → variantgrid_api-1.5.0}/tests/test_mock_variantgrid_api.py +88 -0
  16. variantgrid_api-1.3.2/README.md +0 -54
  17. variantgrid_api-1.3.2/src/variantgrid_api/data_models.py +0 -265
  18. {variantgrid_api-1.3.2 → variantgrid_api-1.5.0}/LICENSE +0 -0
  19. {variantgrid_api-1.3.2 → variantgrid_api-1.5.0}/setup.cfg +0 -0
  20. {variantgrid_api-1.3.2 → variantgrid_api-1.5.0}/src/variantgrid_api.egg-info/dependency_links.txt +0 -0
  21. {variantgrid_api-1.3.2 → variantgrid_api-1.5.0}/src/variantgrid_api.egg-info/entry_points.txt +0 -0
  22. {variantgrid_api-1.3.2 → variantgrid_api-1.5.0}/src/variantgrid_api.egg-info/requires.txt +0 -0
  23. {variantgrid_api-1.3.2 → variantgrid_api-1.5.0}/src/variantgrid_api.egg-info/top_level.txt +0 -0
  24. {variantgrid_api-1.3.2 → variantgrid_api-1.5.0}/tests/test_api_client_bulk.py +0 -0
  25. {variantgrid_api-1.3.2 → variantgrid_api-1.5.0}/tests/test_api_client_validation.py +0 -0
  26. {variantgrid_api-1.3.2 → variantgrid_api-1.5.0}/tests/test_data_models.py +0 -0
  27. {variantgrid_api-1.3.2 → variantgrid_api-1.5.0}/tests/test_sequencer_model_from_name.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: variantgrid_api
3
- Version: 1.3.2
3
+ Version: 1.5.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.3.2"
7
+ version = "1.5.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
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: str) -> bool:
165
+ return feature in self.capabilities.features
166
+
167
+ def accepts_upload(self, file_type: str) -> bool:
168
+ """ file_type is the server's UploadedFileTypes name in lower case, eg 'dragen_tso500_combined_variant_output' """
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: 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: 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 = {
@@ -173,12 +243,16 @@ class VariantGridAPI:
173
243
  for sf in sequencing_files:
174
244
  data = sf.to_dict()
175
245
  # 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
- }
246
+ fastq_r1 = data.pop("fastq_r1", None)
247
+ fastq_r2 = data.pop("fastq_r2", None)
248
+ if fastq_r1:
249
+ unaligned_reads = {"fastq_r1": {"path": fastq_r1}}
250
+ if fastq_r2:
251
+ unaligned_reads["fastq_r2"] = {"path": fastq_r2}
252
+ data["unaligned_reads"] = unaligned_reads
253
+ elif fastq_r2:
254
+ raise ValueError(f"SequencingFile '{sf.sample_name}' has fastq_r2 without fastq_r1")
255
+ # No FastQs (BAM-first run) - server resolves the sample from sample_name
182
256
  records.append(data)
183
257
 
184
258
  json_data = {
@@ -231,7 +305,73 @@ class VariantGridAPI:
231
305
  return self._post("seqauto/api/v1/qc_gene_coverage/bulk_create",
232
306
  json_data)
233
307
 
234
- def upload_file(self, filename: str, path=_UNSET):
308
+ ##########################################################
309
+ ## Patient -> Specimen -> Extraction (SACGF/variantgrid#1707)
310
+ ## Creates are upserts keyed on the identifiers sent, so re-posting returns the same rows
311
+
312
+ def create_patient(self, patient: Patient):
313
+ if not self._require("patients"):
314
+ return None
315
+ self._validate_object("patient", patient)
316
+ return self._post("patients/api/v1/patient/", patient.to_dict())
317
+
318
+ def create_specimen(self, specimen: Specimen):
319
+ """ The specimen's patient must already exist on the server, otherwise this is a 400 """
320
+ if not self._require("patients"):
321
+ return None
322
+ self._validate_object("specimen", specimen)
323
+ return self._post("patients/api/v1/specimen/", specimen.to_dict())
324
+
325
+ def create_extraction(self, extraction: Extraction):
326
+ """ The extraction's specimen must already exist on the server, otherwise this is a 400 """
327
+ if not self._require("patients"):
328
+ return None
329
+ self._validate_object("extraction", extraction)
330
+ return self._post("patients/api/v1/extraction/", extraction.to_dict())
331
+
332
+ def create_specimen_measure(self, specimen_reference: ReferenceLike, measure: SpecimenMeasure):
333
+ """ An unknown specimen is a 400. Replaces any existing measure of the same type for the specimen """
334
+ if not self._require("specimen_measures"):
335
+ return None
336
+ self._validate_reference("specimen_reference", specimen_reference)
337
+ self._validate_object("measure", measure)
338
+ json_data = {"specimen": reference_json(specimen_reference), **measure.to_dict()}
339
+ return self._post("patients/api/v1/specimen_measure/", json_data)
340
+
341
+ def create_specimen_measures(self, specimen_reference: ReferenceLike, measures: List[SpecimenMeasure]):
342
+ """ A run's measures (TMB, MSI, GIS etc) against one specimen in one call """
343
+ if not self._require("specimen_measures"):
344
+ return None
345
+ self._validate_reference("specimen_reference", specimen_reference)
346
+ self._validate_list("measures", measures)
347
+ json_data = {
348
+ "specimen": reference_json(specimen_reference),
349
+ "measures": [measure.to_dict() for measure in measures],
350
+ }
351
+ return self._post("patients/api/v1/specimen_measure/bulk_create", json_data)
352
+
353
+ def link_sequencing_sample_extraction(self, sequencing_sample_lookup: SequencingSampleLookup,
354
+ extraction_reference: ReferenceLike) -> Optional[dict]:
355
+ """ Name the extraction a sequencing sample was made from. One call per sequencing sample is
356
+ enough - the server carries it to every Sample made from that sample's VCFs, and on to the
357
+ new rows if the sample sheet is re-sent.
358
+
359
+ Returns {"sequencing_sample", "match_status", "match_error", "extraction"}. An unknown
360
+ sequencing sample is a 400, but an extraction the server doesn't have yet is not an error:
361
+ the response is a 202 with match_status 'Pending', and the link attaches itself once the
362
+ extraction is created - there's no need to re-send. """
363
+ if not self._require("link_extraction"):
364
+ return None
365
+ self._validate_object("sequencing_sample_lookup", sequencing_sample_lookup)
366
+ self._validate_reference("extraction_reference", extraction_reference)
367
+ json_data = {
368
+ "sequencing_sample": sequencing_sample_lookup.to_dict(),
369
+ "extraction": reference_json(extraction_reference),
370
+ }
371
+ return self._post("seqauto/api/v1/sequencing_sample/link_extraction", json_data)
372
+
373
+ def upload_file(self, filename: str, path=_UNSET, metadata: Optional[dict] = None,
374
+ file_type: Optional[str] = None):
235
375
  """ Upload a file via multipart POST to upload/api/v1/file_upload.
236
376
 
237
377
  Returns {"uploaded_file_id": <id>, "sha256_hash": <hash>, ...}; identify the upload by
@@ -241,16 +381,53 @@ class VariantGridAPI:
241
381
  (JointCalledVCF / SingleSampleVCF) by path. Defaults to `filename` for backwards
242
382
  compatibility. Pass path=None to omit the query param entirely - required for ad-hoc
243
383
  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. """
384
+ SeqAuto deployments try (and fail) to match it to a registered VCF.
385
+
386
+ metadata: facts about the file it doesn't carry itself, sent as extra query params. A VCF accepts:
387
+ 'genome_build' - the build's own name ('GRCh37'), not an alias ('hg19')
388
+ 'source' - the caller/software, eg 'DRAGEN TSO500 SmallVariant'
389
+ 'extraction' - reference (str or ExternalReference) for every sample in the file
390
+ 'sample_extractions' - {vcf_sample_name: reference} for a multi-sample VCF
391
+ Send 'extraction' or 'sample_extractions', not both. An unknown key is a 400. An extraction
392
+ the server doesn't have yet is not - it attaches once the extraction is created.
393
+ Needs the server feature 'upload_metadata'. Without it, SKIP uploads the file without the
394
+ metadata (as older clients did) rather than not at all, and ERROR raises
395
+
396
+ file_type: the server's name for what this file is, eg 'dragen_tso500_combined_variant_output'.
397
+ Not sent (the server decides from the filename) - if given, the upload only happens when
398
+ accepts_upload(file_type), so an older server doesn't mis-import it as something else """
399
+ if metadata and not self.supports("upload_metadata"):
400
+ self._unsupported(f"upload metadata for '{filename}' (server doesn't support feature 'upload_metadata')")
401
+ metadata = None
402
+ if file_type and not self._require_upload(file_type):
403
+ return None
245
404
  url = self._get_url("upload/api/v1/file_upload")
246
405
  if path is _UNSET:
247
406
  path = filename
248
407
  params = {"path": path} if path is not None else {}
408
+ if metadata:
409
+ params.update(self._upload_metadata_params(metadata))
249
410
  with open(filename, "rb") as f:
250
411
  response = requests.post(url, headers=self.headers, files={"file": f}, params=params)
251
412
  extra_error_message = f"{filename=}"
252
413
  return self._handle_json_response(response, extra_error_message)
253
414
 
415
+ @staticmethod
416
+ def _upload_metadata_params(metadata: dict) -> dict:
417
+ """ Query params are strings - references and objects go as JSON, which the server parses """
418
+ if reserved := {"path", "force"} & set(metadata):
419
+ raise ValueError(f"Upload metadata can't use reserved query param(s): {', '.join(sorted(reserved))}")
420
+ params = {}
421
+ for key, value in metadata.items():
422
+ if isinstance(value, ExternalReference):
423
+ value = reference_json(value)
424
+ elif isinstance(value, dict):
425
+ value = {k: reference_json(v) for k, v in value.items()}
426
+ if isinstance(value, (dict, list)):
427
+ value = json.dumps(value)
428
+ params[key] = value
429
+ return params
430
+
254
431
  ###############
255
432
  ## Get methods
256
433
 
@@ -291,17 +468,19 @@ class VariantGridAPI:
291
468
  return f"sha256/{sha256}"
292
469
  raise ValueError("Must provide one of 'uploaded_file_id' or 'sha256'")
293
470
 
294
- def poll_upload_status(self, uploaded_file_id: Optional[int] = None, sha256: Optional[str] = None) -> dict:
471
+ def poll_upload_status(self, uploaded_file_id: Optional[int] = None, sha256: Optional[str] = None) -> Optional[dict]:
295
472
  """ Single GET of an uploaded file's import/annotation status.
296
473
 
297
474
  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. """
475
+ See wait_for_annotation to block until annotation is complete. Needs server feature 'upload_status' """
476
+ if not self._require("upload_status"):
477
+ return None
299
478
  segment = self._upload_key_segment(uploaded_file_id, sha256)
300
479
  return self._get(f"upload/api/v1/upload_status/{segment}")
301
480
 
302
481
  def wait_for_annotation(self, uploaded_file_id: Optional[int] = None, sha256: Optional[str] = None,
303
482
  timeout: float = 3600, poll_interval: float = 10, sleep: Callable = time.sleep,
304
- max_transient_errors: int = 5) -> dict:
483
+ max_transient_errors: int = 5) -> Optional[dict]:
305
484
  """ Poll poll_upload_status until 'annotation_complete' is true, then return the final status dict.
306
485
 
307
486
  Raises AnnotationError if the server reports an 'error', or TimeoutError if 'timeout' seconds elapse.
@@ -310,7 +489,9 @@ class VariantGridAPI:
310
489
  Transient server hiccups (5xx / connection / timeout - e.g. a brief 500 right after upload while the
311
490
  server is still creating the upload record) are tolerated: up to 'max_transient_errors' *consecutive*
312
491
  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. """
492
+ The success counter resets whenever a poll succeeds. Needs server feature 'upload_status' """
493
+ if not self._require("upload_status"):
494
+ return None
314
495
  deadline = time.monotonic() + timeout
315
496
  transient_errors = 0
316
497
  while True:
@@ -349,7 +530,7 @@ class VariantGridAPI:
349
530
  def download_annotated(self, uploaded_file_id: Optional[int] = None, sha256: Optional[str] = None,
350
531
  export_type: str = "vcf", dest_path: Optional[Union[str, Path]] = None,
351
532
  timeout: float = 3600, poll_interval: float = 10,
352
- sleep: Callable = time.sleep) -> Path:
533
+ sleep: Callable = time.sleep) -> Optional[Path]:
353
534
  """ Download the cohort-level annotated export of an uploaded VCF, saving it to disk.
354
535
 
355
536
  export_type is 'vcf' (gzipped *.vcf.gz) or 'csv' (zipped *.csv.zip). The export covers all samples
@@ -360,7 +541,9 @@ class VariantGridAPI:
360
541
  in the current directory; if it is an existing directory the attachment filename is placed inside it;
361
542
  otherwise it is treated as the full destination path. Returns the Path written.
362
543
 
363
- Raises TimeoutError if the file isn't ready within 'timeout' seconds. """
544
+ Raises TimeoutError if the file isn't ready within 'timeout' seconds. Needs server feature 'upload_status' """
545
+ if not self._require("upload_status"):
546
+ return None
364
547
  if export_type not in ("vcf", "csv"):
365
548
  raise ValueError(f"export_type must be 'vcf' or 'csv', got {export_type!r}")
366
549
  segment = self._upload_key_segment(uploaded_file_id, sha256)
@@ -402,10 +585,13 @@ class VariantGridAPI:
402
585
  return None
403
586
 
404
587
  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:
588
+ timeout: float = 3600, poll_interval: float = 10, sleep: Callable = time.sleep) -> Optional[Path]:
406
589
  """ Convenience one-liner: upload a VCF, wait for annotation to finish, download the annotated export.
407
590
 
408
- Chains upload_file -> wait_for_annotation -> download_annotated and returns the Path written. """
591
+ Chains upload_file -> wait_for_annotation -> download_annotated and returns the Path written.
592
+ Needs server feature 'upload_status' - checked before uploading """
593
+ if not self._require("upload_status"):
594
+ return None
409
595
  # path is SeqAuto-only and makes ad-hoc uploads fail the import - omit it for the annotate flow
410
596
  upload = self.upload_file(filename, path=None)
411
597
  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__":