variantgrid-api 1.5.0__tar.gz → 1.7.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 (25) hide show
  1. {variantgrid_api-1.5.0/src/variantgrid_api.egg-info → variantgrid_api-1.7.0}/PKG-INFO +1 -1
  2. {variantgrid_api-1.5.0 → variantgrid_api-1.7.0}/pyproject.toml +1 -1
  3. {variantgrid_api-1.5.0 → variantgrid_api-1.7.0}/src/variantgrid_api/api_client.py +38 -21
  4. {variantgrid_api-1.5.0 → variantgrid_api-1.7.0}/src/variantgrid_api/data_models.py +64 -2
  5. {variantgrid_api-1.5.0 → variantgrid_api-1.7.0}/src/variantgrid_api/mock_variantgrid_api.py +22 -21
  6. {variantgrid_api-1.5.0 → variantgrid_api-1.7.0/src/variantgrid_api.egg-info}/PKG-INFO +1 -1
  7. {variantgrid_api-1.5.0 → variantgrid_api-1.7.0}/tests/test_api_client.py +76 -1
  8. {variantgrid_api-1.5.0 → variantgrid_api-1.7.0}/tests/test_api_client_capabilities.py +28 -1
  9. {variantgrid_api-1.5.0 → variantgrid_api-1.7.0}/tests/test_mock_variantgrid_api.py +10 -1
  10. {variantgrid_api-1.5.0 → variantgrid_api-1.7.0}/LICENSE +0 -0
  11. {variantgrid_api-1.5.0 → variantgrid_api-1.7.0}/README.md +0 -0
  12. {variantgrid_api-1.5.0 → variantgrid_api-1.7.0}/setup.cfg +0 -0
  13. {variantgrid_api-1.5.0 → variantgrid_api-1.7.0}/src/variantgrid_api/cli.py +0 -0
  14. {variantgrid_api-1.5.0 → variantgrid_api-1.7.0}/src/variantgrid_api.egg-info/SOURCES.txt +0 -0
  15. {variantgrid_api-1.5.0 → variantgrid_api-1.7.0}/src/variantgrid_api.egg-info/dependency_links.txt +0 -0
  16. {variantgrid_api-1.5.0 → variantgrid_api-1.7.0}/src/variantgrid_api.egg-info/entry_points.txt +0 -0
  17. {variantgrid_api-1.5.0 → variantgrid_api-1.7.0}/src/variantgrid_api.egg-info/requires.txt +0 -0
  18. {variantgrid_api-1.5.0 → variantgrid_api-1.7.0}/src/variantgrid_api.egg-info/top_level.txt +0 -0
  19. {variantgrid_api-1.5.0 → variantgrid_api-1.7.0}/tests/test_api_client_annotation.py +0 -0
  20. {variantgrid_api-1.5.0 → variantgrid_api-1.7.0}/tests/test_api_client_bulk.py +0 -0
  21. {variantgrid_api-1.5.0 → variantgrid_api-1.7.0}/tests/test_api_client_patients.py +0 -0
  22. {variantgrid_api-1.5.0 → variantgrid_api-1.7.0}/tests/test_api_client_validation.py +0 -0
  23. {variantgrid_api-1.5.0 → variantgrid_api-1.7.0}/tests/test_cli.py +0 -0
  24. {variantgrid_api-1.5.0 → variantgrid_api-1.7.0}/tests/test_data_models.py +0 -0
  25. {variantgrid_api-1.5.0 → variantgrid_api-1.7.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.5.0
3
+ Version: 1.7.0
4
4
  Summary: A Python API client for VariantGrid
5
5
  Author-email: Dave Lawrence <davmlaw@gmail.com>
6
6
  License: MIT License
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "variantgrid_api"
7
- version = "1.5.0"
7
+ version = "1.7.0"
8
8
  description = "A Python API client for VariantGrid"
9
9
  authors = [
10
10
  { name = "Dave Lawrence", email = "davmlaw@gmail.com" }
@@ -14,7 +14,7 @@ import requests
14
14
  from variantgrid_api.data_models import EnrichmentKit, SequencingRun, SampleSheet, JointCalledVCF, \
15
15
  SampleSheetLookup, SequencingFile, QCGeneList, QCExecStats, QCGeneCoverage, SequencerModel, Sequencer, \
16
16
  SequencingSampleLookup, Patient, Specimen, Extraction, SpecimenMeasure, ExternalReference, ReferenceLike, \
17
- reference_json, ServerCapabilities
17
+ reference_json, ServerCapabilities, ServerFeature, UploadFileType
18
18
 
19
19
 
20
20
  _UNSET = object()
@@ -161,11 +161,11 @@ class VariantGridAPI:
161
161
  self._capabilities = ServerCapabilities.from_json(data)
162
162
  return self._capabilities
163
163
 
164
- def supports(self, feature: str) -> bool:
164
+ def supports(self, feature: Union[ServerFeature, str]) -> bool:
165
165
  return feature in self.capabilities.features
166
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' """
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
169
  return file_type in self.capabilities.upload_file_types
170
170
 
171
171
  def _unsupported(self, message: str) -> bool:
@@ -177,11 +177,11 @@ class VariantGridAPI:
177
177
  return False
178
178
  raise UnsupportedFeatureError(message, capabilities)
179
179
 
180
- def _require(self, feature: str) -> bool:
180
+ def _require(self, feature: Union[ServerFeature, str]) -> bool:
181
181
  """ True if the server supports feature, otherwise applies unsupported_feature_policy """
182
182
  return self.supports(feature) or self._unsupported(f"server doesn't support feature '{feature}'")
183
183
 
184
- def _require_upload(self, file_type: str) -> bool:
184
+ def _require_upload(self, file_type: Union[UploadFileType, str]) -> bool:
185
185
  return self.accepts_upload(file_type) or self._unsupported(f"server doesn't accept upload file type '{file_type}'")
186
186
 
187
187
  def create_experiment(self, experiment: str):
@@ -241,7 +241,22 @@ class VariantGridAPI:
241
241
  self._validate_list("sequencing_files", sequencing_files)
242
242
  records = []
243
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
+ vcf_files = sf.get_vcf_files()
247
+ self._validate_list(f"SequencingFile '{sf.sample_name}' vcf_files", vcf_files)
248
+ for i, vcf_file in enumerate(vcf_files):
249
+ self._validate_string(f"SequencingFile '{sf.sample_name}' vcf_files[{i}].path",
250
+ vcf_file and vcf_file.path)
251
+ # The server keeps one VCF per BAM and caller - a repeated caller would silently replace a path
252
+ callers = [f"{vc.name} {vc.version}" for vcf_file in vcf_files
253
+ if vcf_file and (vc := vcf_file.variant_caller)]
254
+ if repeated := {c for c in callers if callers.count(c) > 1}:
255
+ raise ValueError(f"SequencingFile '{sf.sample_name}' has more than one VCF from variant caller(s) "
256
+ f"{', '.join(sorted(repeated))} - each VCF off a BAM needs its own caller")
244
257
  data = sf.to_dict()
258
+ data.pop("vcf_file", None)
259
+ data.pop("vcf_files", None)
245
260
  # put into hierarchial JSON DRF expects
246
261
  fastq_r1 = data.pop("fastq_r1", None)
247
262
  fastq_r2 = data.pop("fastq_r2", None)
@@ -252,8 +267,10 @@ class VariantGridAPI:
252
267
  data["unaligned_reads"] = unaligned_reads
253
268
  elif fastq_r2:
254
269
  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
256
- records.append(data)
270
+ # No FastQs (BAM-first run) - server resolves the sample from sample_name.
271
+ # The server takes one VCF per record, so each is a record sharing the BAM and FastQs
272
+ for vcf_file in vcf_files:
273
+ records.append({**data, "vcf_file": vcf_file.to_dict() if vcf_file else None})
257
274
 
258
275
  json_data = {
259
276
  "sample_sheet": sample_sheet_lookup.to_dict(),
@@ -310,28 +327,28 @@ class VariantGridAPI:
310
327
  ## Creates are upserts keyed on the identifiers sent, so re-posting returns the same rows
311
328
 
312
329
  def create_patient(self, patient: Patient):
313
- if not self._require("patients"):
330
+ if not self._require(ServerFeature.PATIENTS):
314
331
  return None
315
332
  self._validate_object("patient", patient)
316
333
  return self._post("patients/api/v1/patient/", patient.to_dict())
317
334
 
318
335
  def create_specimen(self, specimen: Specimen):
319
336
  """ The specimen's patient must already exist on the server, otherwise this is a 400 """
320
- if not self._require("patients"):
337
+ if not self._require(ServerFeature.PATIENTS):
321
338
  return None
322
339
  self._validate_object("specimen", specimen)
323
340
  return self._post("patients/api/v1/specimen/", specimen.to_dict())
324
341
 
325
342
  def create_extraction(self, extraction: Extraction):
326
343
  """ The extraction's specimen must already exist on the server, otherwise this is a 400 """
327
- if not self._require("patients"):
344
+ if not self._require(ServerFeature.PATIENTS):
328
345
  return None
329
346
  self._validate_object("extraction", extraction)
330
347
  return self._post("patients/api/v1/extraction/", extraction.to_dict())
331
348
 
332
349
  def create_specimen_measure(self, specimen_reference: ReferenceLike, measure: SpecimenMeasure):
333
350
  """ An unknown specimen is a 400. Replaces any existing measure of the same type for the specimen """
334
- if not self._require("specimen_measures"):
351
+ if not self._require(ServerFeature.SPECIMEN_MEASURES):
335
352
  return None
336
353
  self._validate_reference("specimen_reference", specimen_reference)
337
354
  self._validate_object("measure", measure)
@@ -340,7 +357,7 @@ class VariantGridAPI:
340
357
 
341
358
  def create_specimen_measures(self, specimen_reference: ReferenceLike, measures: List[SpecimenMeasure]):
342
359
  """ A run's measures (TMB, MSI, GIS etc) against one specimen in one call """
343
- if not self._require("specimen_measures"):
360
+ if not self._require(ServerFeature.SPECIMEN_MEASURES):
344
361
  return None
345
362
  self._validate_reference("specimen_reference", specimen_reference)
346
363
  self._validate_list("measures", measures)
@@ -360,7 +377,7 @@ class VariantGridAPI:
360
377
  sequencing sample is a 400, but an extraction the server doesn't have yet is not an error:
361
378
  the response is a 202 with match_status 'Pending', and the link attaches itself once the
362
379
  extraction is created - there's no need to re-send. """
363
- if not self._require("link_extraction"):
380
+ if not self._require(ServerFeature.LINK_EXTRACTION):
364
381
  return None
365
382
  self._validate_object("sequencing_sample_lookup", sequencing_sample_lookup)
366
383
  self._validate_reference("extraction_reference", extraction_reference)
@@ -371,7 +388,7 @@ class VariantGridAPI:
371
388
  return self._post("seqauto/api/v1/sequencing_sample/link_extraction", json_data)
372
389
 
373
390
  def upload_file(self, filename: str, path=_UNSET, metadata: Optional[dict] = None,
374
- file_type: Optional[str] = None):
391
+ file_type: Optional[Union[UploadFileType, str]] = None):
375
392
  """ Upload a file via multipart POST to upload/api/v1/file_upload.
376
393
 
377
394
  Returns {"uploaded_file_id": <id>, "sha256_hash": <hash>, ...}; identify the upload by
@@ -393,10 +410,10 @@ class VariantGridAPI:
393
410
  Needs the server feature 'upload_metadata'. Without it, SKIP uploads the file without the
394
411
  metadata (as older clients did) rather than not at all, and ERROR raises
395
412
 
396
- file_type: the server's name for what this file is, eg 'dragen_tso500_combined_variant_output'.
413
+ file_type: the server's name for what this file is, eg UploadFileType.DRAGEN_TSO500_COMBINED_VARIANT_OUTPUT.
397
414
  Not sent (the server decides from the filename) - if given, the upload only happens when
398
415
  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"):
416
+ if metadata and not self.supports(ServerFeature.UPLOAD_METADATA):
400
417
  self._unsupported(f"upload metadata for '{filename}' (server doesn't support feature 'upload_metadata')")
401
418
  metadata = None
402
419
  if file_type and not self._require_upload(file_type):
@@ -473,7 +490,7 @@ class VariantGridAPI:
473
490
 
474
491
  Keyed by uploaded_file_id (returned from upload_file) or the SHA-256 of the uploaded file.
475
492
  See wait_for_annotation to block until annotation is complete. Needs server feature 'upload_status' """
476
- if not self._require("upload_status"):
493
+ if not self._require(ServerFeature.UPLOAD_STATUS):
477
494
  return None
478
495
  segment = self._upload_key_segment(uploaded_file_id, sha256)
479
496
  return self._get(f"upload/api/v1/upload_status/{segment}")
@@ -490,7 +507,7 @@ class VariantGridAPI:
490
507
  server is still creating the upload record) are tolerated: up to 'max_transient_errors' *consecutive*
491
508
  failures are retried before giving up. A 4xx response is treated as a real error and raised immediately.
492
509
  The success counter resets whenever a poll succeeds. Needs server feature 'upload_status' """
493
- if not self._require("upload_status"):
510
+ if not self._require(ServerFeature.UPLOAD_STATUS):
494
511
  return None
495
512
  deadline = time.monotonic() + timeout
496
513
  transient_errors = 0
@@ -542,7 +559,7 @@ class VariantGridAPI:
542
559
  otherwise it is treated as the full destination path. Returns the Path written.
543
560
 
544
561
  Raises TimeoutError if the file isn't ready within 'timeout' seconds. Needs server feature 'upload_status' """
545
- if not self._require("upload_status"):
562
+ if not self._require(ServerFeature.UPLOAD_STATUS):
546
563
  return None
547
564
  if export_type not in ("vcf", "csv"):
548
565
  raise ValueError(f"export_type must be 'vcf' or 'csv', got {export_type!r}")
@@ -590,7 +607,7 @@ class VariantGridAPI:
590
607
 
591
608
  Chains upload_file -> wait_for_annotation -> download_annotated and returns the Path written.
592
609
  Needs server feature 'upload_status' - checked before uploading """
593
- if not self._require("upload_status"):
610
+ if not self._require(ServerFeature.UPLOAD_STATUS):
594
611
  return None
595
612
  # path is SeqAuto-only and makes ad-hoc uploads fail the import - omit it for the annotate flow
596
613
  upload = self.upload_file(filename, path=None)
@@ -196,12 +196,33 @@ class VCFFile(SingleSampleVCF):
196
196
  @dataclass_json
197
197
  @dataclass
198
198
  class SequencingFile:
199
- """ FastQs are optional - BAM-first runs (sequencer emits BAM, or FastQs not kept) send just BAM + VCF """
199
+ """ FastQs are optional - BAM-first runs (sequencer emits BAM, or FastQs not kept) send just BAM + VCF
200
+
201
+ vcf_files: the VCFs called off this BAM, one per variant caller, eg DRAGEN TSO 500's small variant VCF and
202
+ its gene-level CNV VCF. The server keeps one VCF per BAM and caller, so a second with the same caller
203
+ would replace the first - create_sequencing_data() raises instead.
204
+
205
+ vcf_file is deprecated - use vcf_files. It still works (set it and it is sent, read it back as before),
206
+ and get_vcf_files() gives both """
200
207
  sample_name: str
201
208
  bam_file: BamFile
202
- vcf_file: SingleSampleVCF
209
+ vcf_file: Optional[SingleSampleVCF] = field(default=None, metadata=config(exclude=lambda x: x is None))
203
210
  fastq_r1: Optional[str] = field(default=None, metadata=config(exclude=lambda x: x is None))
204
211
  fastq_r2: Optional[str] = field(default=None, metadata=config(exclude=lambda x: x is None))
212
+ vcf_files: Optional[List[SingleSampleVCF]] = field(default=None, metadata=config(exclude=lambda x: x is None))
213
+
214
+ def __post_init__(self):
215
+ if self.vcf_file is not None:
216
+ warnings.warn(
217
+ "SequencingFile.vcf_file is deprecated; use vcf_files instead.",
218
+ DeprecationWarning,
219
+ stacklevel=3,
220
+ )
221
+
222
+ def get_vcf_files(self) -> List[SingleSampleVCF]:
223
+ """ vcf_file (deprecated) then vcf_files """
224
+ vcf_files = [self.vcf_file] if self.vcf_file is not None else []
225
+ return vcf_files + list(self.vcf_files or [])
205
226
 
206
227
 
207
228
  @dataclass_json
@@ -442,6 +463,47 @@ class SpecimenMeasure:
442
463
  extraction: Optional[ReferenceLike] = _reference_field(default=None) # the arm that produced it
443
464
 
444
465
 
466
+ ############################################################
467
+ ## Server capabilities (SACGF/variantgrid_api#22)
468
+
469
+ class _ServerName(str, Enum):
470
+ """ A name from the server's vocabulary. Subclasses str so plain strings and these are interchangeable,
471
+ and formats as its value so messages read 'patients' rather than 'ServerFeature.PATIENTS' """
472
+
473
+ def __str__(self):
474
+ return self.value
475
+
476
+ def __format__(self, format_spec):
477
+ return format(self.value, format_spec)
478
+
479
+
480
+ class ServerFeature(_ServerName):
481
+ """ Features a server reports in capabilities (API_FEATURES in the variantgrid repo's
482
+ variantgrid/views_rest.py). Names are never removed - an older server just doesn't list a newer one """
483
+ PATIENTS = "patients"
484
+ SPECIMEN_MEASURES = "specimen_measures"
485
+ LINK_EXTRACTION = "link_extraction"
486
+ UPLOAD_STATUS = "upload_status"
487
+ JOINT_CALLED_VCF_CROSS_RUN = "joint_called_vcf_cross_run"
488
+ UPLOAD_METADATA = "upload_metadata"
489
+
490
+
491
+ class UploadFileType(_ServerName):
492
+ """ File types a server imports from an upload - the server's upload.models.UploadedFileTypes names in
493
+ lower case, less the internal ones it drives itself. A server only reports those it has an importer for """
494
+ BED = "bed"
495
+ DRAGEN_TSO500_ALL_FUSIONS = "dragen_tso500_all_fusions"
496
+ DRAGEN_TSO500_COMBINED_VARIANT_OUTPUT = "dragen_tso500_combined_variant_output"
497
+ GENE_COVERAGE = "gene_coverage"
498
+ GENE_LIST = "gene_list"
499
+ GENE_LEVEL_CNV_VCF = "gene_level_cnv_vcf"
500
+ GENE_LEVEL_INSERT_VARIANTS_ONLY = "gene_level_insert_variants_only"
501
+ PATIENT_RECORDS = "patient_records"
502
+ PED = "ped"
503
+ VCF = "vcf"
504
+ VCF_INSERT_VARIANTS_ONLY = "vcf_insert_variants_only"
505
+
506
+
445
507
  @dataclass(frozen=True)
446
508
  class ServerCapabilities:
447
509
  """ What a server accepts, from GET api/v1/capabilities (SACGF/variantgrid_sapath#443).
@@ -16,20 +16,21 @@ import copy
16
16
  import logging
17
17
  import warnings
18
18
  from pathlib import Path
19
- from typing import Any, List, Optional, Tuple
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
22
+ from variantgrid_api.data_models import reference_json, ServerCapabilities, ServerFeature, UploadFileType
23
23
 
24
24
  _UNSET = object()
25
25
 
26
26
  # Every feature and upload file type the real client gates on - the shape of a current (VG4) server
27
27
  MOCK_CAPABILITIES = ServerCapabilities(
28
28
  version="mock",
29
- features=frozenset({"patients", "specimen_measures", "link_extraction", "upload_status",
30
- "joint_called_vcf_cross_run", "upload_metadata"}),
31
- upload_file_types=frozenset({"vcf", "gene_coverage", "dragen_tso500_all_fusions",
32
- "dragen_tso500_combined_variant_output", "gene_level_cnv_vcf"}),
29
+ features=frozenset(ServerFeature),
30
+ upload_file_types=frozenset({UploadFileType.VCF, UploadFileType.GENE_COVERAGE,
31
+ UploadFileType.DRAGEN_TSO500_ALL_FUSIONS,
32
+ UploadFileType.DRAGEN_TSO500_COMBINED_VARIANT_OUTPUT,
33
+ UploadFileType.GENE_LEVEL_CNV_VCF}),
33
34
  )
34
35
 
35
36
 
@@ -86,10 +87,10 @@ class MockVariantGridAPI:
86
87
  # Capabilities — same gating as VariantGridAPI #
87
88
  # ------------------------------------------------------------------ #
88
89
 
89
- def supports(self, feature: str) -> bool:
90
+ def supports(self, feature: Union[ServerFeature, str]) -> bool:
90
91
  return feature in self.capabilities.features
91
92
 
92
- def accepts_upload(self, file_type: str) -> bool:
93
+ def accepts_upload(self, file_type: Union[UploadFileType, str]) -> bool:
93
94
  return file_type in self.capabilities.upload_file_types
94
95
 
95
96
  def _unsupported(self, message: str) -> bool:
@@ -99,10 +100,10 @@ class MockVariantGridAPI:
99
100
  return False
100
101
  raise UnsupportedFeatureError(message, self.capabilities)
101
102
 
102
- def _require(self, feature: str) -> bool:
103
+ def _require(self, feature: Union[ServerFeature, str]) -> bool:
103
104
  return self.supports(feature) or self._unsupported(f"server doesn't support feature '{feature}'")
104
105
 
105
- def _require_upload(self, file_type: str) -> bool:
106
+ def _require_upload(self, file_type: Union[UploadFileType, str]) -> bool:
106
107
  return self.accepts_upload(file_type) or self._unsupported(f"server doesn't accept upload file type '{file_type}'")
107
108
 
108
109
  # ------------------------------------------------------------------ #
@@ -181,39 +182,39 @@ class MockVariantGridAPI:
181
182
  return self._ret("create_multiple_qc_gene_coverage", {"created": len(qc_gene_coverage_list)})
182
183
 
183
184
  def create_patient(self, patient):
184
- if not self._require("patients"):
185
+ if not self._require(ServerFeature.PATIENTS):
185
186
  return None
186
187
  self._record("create_patient", patient)
187
188
  return self._ret("create_patient", {"id": 1, **patient.to_dict()})
188
189
 
189
190
  def create_specimen(self, specimen):
190
- if not self._require("patients"):
191
+ if not self._require(ServerFeature.PATIENTS):
191
192
  return None
192
193
  self._record("create_specimen", specimen)
193
194
  return self._ret("create_specimen", {"id": 1, **specimen.to_dict()})
194
195
 
195
196
  def create_extraction(self, extraction):
196
- if not self._require("patients"):
197
+ if not self._require(ServerFeature.PATIENTS):
197
198
  return None
198
199
  self._record("create_extraction", extraction)
199
200
  return self._ret("create_extraction", {"id": 1, **extraction.to_dict()})
200
201
 
201
202
  def create_specimen_measure(self, specimen_reference, measure):
202
- if not self._require("specimen_measures"):
203
+ if not self._require(ServerFeature.SPECIMEN_MEASURES):
203
204
  return None
204
205
  self._record("create_specimen_measure", specimen_reference, measure)
205
206
  return self._ret("create_specimen_measure", {"id": 1, "specimen": reference_json(specimen_reference),
206
207
  **measure.to_dict()})
207
208
 
208
209
  def create_specimen_measures(self, specimen_reference, measures):
209
- if not self._require("specimen_measures"):
210
+ if not self._require(ServerFeature.SPECIMEN_MEASURES):
210
211
  return None
211
212
  self._record("create_specimen_measures", specimen_reference, measures)
212
213
  return self._ret("create_specimen_measures", {"specimen": reference_json(specimen_reference),
213
214
  "measures": [m.to_dict() for m in measures]})
214
215
 
215
216
  def link_sequencing_sample_extraction(self, sequencing_sample_lookup, extraction_reference):
216
- if not self._require("link_extraction"):
217
+ if not self._require(ServerFeature.LINK_EXTRACTION):
217
218
  return None
218
219
  self._record("link_sequencing_sample_extraction", sequencing_sample_lookup, extraction_reference)
219
220
  return self._ret("link_sequencing_sample_extraction", {
@@ -224,7 +225,7 @@ class MockVariantGridAPI:
224
225
  })
225
226
 
226
227
  def upload_file(self, filename, path=_UNSET, metadata=None, file_type=None):
227
- if metadata and not self.supports("upload_metadata"):
228
+ if metadata and not self.supports(ServerFeature.UPLOAD_METADATA):
228
229
  self._unsupported(f"upload metadata for '{filename}' (server doesn't support feature 'upload_metadata')")
229
230
  metadata = None
230
231
  if file_type and not self._require_upload(file_type):
@@ -240,7 +241,7 @@ class MockVariantGridAPI:
240
241
  "path": path, "status": "ok"})
241
242
 
242
243
  def poll_upload_status(self, uploaded_file_id=None, sha256=None):
243
- if not self._require("upload_status"):
244
+ if not self._require(ServerFeature.UPLOAD_STATUS):
244
245
  return None
245
246
  self._record("poll_upload_status", uploaded_file_id, sha256)
246
247
  return self._ret("poll_upload_status", {
@@ -252,7 +253,7 @@ class MockVariantGridAPI:
252
253
 
253
254
  def wait_for_annotation(self, uploaded_file_id=None, sha256=None,
254
255
  timeout=3600, poll_interval=10, sleep=None, max_transient_errors=5):
255
- if not self._require("upload_status"):
256
+ if not self._require(ServerFeature.UPLOAD_STATUS):
256
257
  return None
257
258
  self._record("wait_for_annotation", uploaded_file_id, sha256,
258
259
  timeout=timeout, poll_interval=poll_interval, sleep=sleep,
@@ -265,7 +266,7 @@ class MockVariantGridAPI:
265
266
 
266
267
  def download_annotated(self, uploaded_file_id=None, sha256=None, export_type="vcf",
267
268
  dest_path=None, timeout=3600, poll_interval=10, sleep=None):
268
- if not self._require("upload_status"):
269
+ if not self._require(ServerFeature.UPLOAD_STATUS):
269
270
  return None
270
271
  self._record("download_annotated", uploaded_file_id, sha256, export_type=export_type,
271
272
  dest_path=dest_path, timeout=timeout, poll_interval=poll_interval, sleep=sleep)
@@ -274,7 +275,7 @@ class MockVariantGridAPI:
274
275
 
275
276
  def annotate_vcf(self, filename, export_type="vcf", dest_path=None,
276
277
  timeout=3600, poll_interval=10, sleep=None):
277
- if not self._require("upload_status"):
278
+ if not self._require(ServerFeature.UPLOAD_STATUS):
278
279
  return None
279
280
  self._record("annotate_vcf", filename, export_type=export_type, dest_path=dest_path,
280
281
  timeout=timeout, poll_interval=poll_interval, sleep=sleep)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: variantgrid_api
3
- Version: 1.5.0
3
+ Version: 1.7.0
4
4
  Summary: A Python API client for VariantGrid
5
5
  Author-email: Dave Lawrence <davmlaw@gmail.com>
6
6
  License: MIT License
@@ -6,7 +6,8 @@ import logging
6
6
  import pytest
7
7
  import responses
8
8
 
9
- from variantgrid_api.api_client import VariantGridAPI, DateTimeEncoder
9
+ from variantgrid_api.api_client import VariantGridAPI, DateTimeEncoder, EmptyInputPolicy
10
+ from variantgrid_api.data_models import BamFile, SequencingFile, SingleSampleVCF, VariantCaller
10
11
 
11
12
 
12
13
  def _last_json():
@@ -66,6 +67,80 @@ def test_create_sequencing_data_fastq_r2_without_r1_raises(api, vg_objects):
66
67
  with pytest.raises(ValueError):
67
68
  api.create_sequencing_data(vg_objects["sample_sheet_lookup"], [sf])
68
69
 
70
+ @pytest.mark.parametrize("changes, name", [
71
+ ({"vcf_files": None}, "vcf_files"),
72
+ ({"vcf_files": []}, "vcf_files"),
73
+ ({"vcf_files": [None]}, r"vcf_files\[0\].path"),
74
+ ({"vcf_files": [SingleSampleVCF(path=None)]}, r"vcf_files\[0\].path"),
75
+ ({"vcf_files": [SingleSampleVCF(path="")]}, r"vcf_files\[0\].path"),
76
+ ({"bam_file": None}, "bam_file.path"),
77
+ ({"bam_file": BamFile(path=None)}, "bam_file.path"),
78
+ ])
79
+ @responses.activate
80
+ def test_create_sequencing_data_missing_path_names_record(api, vg_objects, changes, name):
81
+ """ SACGF/variantgrid_api#23 - caught before sending, naming the record, rather than a 400 for the batch """
82
+ sequencing_files = list(vg_objects["sequencing_files"])
83
+ sequencing_files[1] = dataclasses.replace(sequencing_files[1], **changes)
84
+ with pytest.raises(ValueError, match=f"SequencingFile 'fake_sample_2' {name}"):
85
+ api.create_sequencing_data(vg_objects["sample_sheet_lookup"], sequencing_files)
86
+ assert len(responses.calls) == 0
87
+
88
+ @responses.activate
89
+ def test_create_sequencing_data_missing_vcf_path_warns_and_posts(server, api_token, vg_objects, caplog):
90
+ api = VariantGridAPI(server, api_token, empty_input_policy=EmptyInputPolicy.WARN)
91
+ url = f"{server}/seqauto/api/v1/sequencing_files/bulk_create"
92
+ responses.add(responses.POST, url, json={"created": 2}, status=200)
93
+ sequencing_files = list(vg_objects["sequencing_files"])
94
+ sequencing_files[0] = dataclasses.replace(sequencing_files[0], vcf_files=[SingleSampleVCF(path=None)])
95
+ api.create_sequencing_data(vg_objects["sample_sheet_lookup"], sequencing_files)
96
+ assert len(responses.calls) == 1
97
+ assert any("SequencingFile 'fake_sample_1' vcf_files[0].path" in r.message for r in caplog.records)
98
+
99
+ @responses.activate
100
+ def test_create_sequencing_data_vcf_files_share_the_bam(api, server, vg_objects):
101
+ """ eg DRAGEN TSO 500's CNV VCF beside its small variant VCF - one record per VCF, same BAM and FastQs """
102
+ url = f"{server}/seqauto/api/v1/sequencing_files/bulk_create"
103
+ responses.add(responses.POST, url, json={"created": 3}, status=200)
104
+ cnv_vcf = SingleSampleVCF(path="/data/fake_sample_1.cnv.vcf", variant_caller=VariantCaller(name="cnv", version="1"))
105
+ sequencing_files = list(vg_objects["sequencing_files"])
106
+ sf = sequencing_files[0]
107
+ sequencing_files[0] = dataclasses.replace(sf, vcf_files=sf.vcf_files + [cnv_vcf])
108
+ api.create_sequencing_data(vg_objects["sample_sheet_lookup"], sequencing_files)
109
+
110
+ records = _last_json()["records"]
111
+ assert [r["sample_name"] for r in records] == ["fake_sample_1", "fake_sample_1", "fake_sample_2"]
112
+ first, second = records[0], records[1]
113
+ assert "vcf_files" not in first
114
+ assert first["vcf_file"]["path"] == sf.vcf_files[0].path
115
+ assert second["vcf_file"]["path"] == "/data/fake_sample_1.cnv.vcf"
116
+ assert second["bam_file"] == first["bam_file"]
117
+ assert second["unaligned_reads"] == first["unaligned_reads"]
118
+
119
+ def test_create_sequencing_data_vcf_files_same_caller_raises(api, vg_objects):
120
+ """ The server keeps one VCF per BAM and caller, so the second would silently replace the first's path """
121
+ sf = vg_objects["sequencing_files"][0]
122
+ same_caller = SingleSampleVCF(path="/data/other.vcf", variant_caller=sf.vcf_files[0].variant_caller)
123
+ sf = dataclasses.replace(sf, vcf_files=sf.vcf_files + [same_caller])
124
+ with pytest.raises(ValueError, match="fake_sample_1.*more than one VCF"):
125
+ api.create_sequencing_data(vg_objects["sample_sheet_lookup"], [sf])
126
+
127
+ @responses.activate
128
+ def test_create_sequencing_data_deprecated_vcf_file_still_sent(api, server, vg_objects):
129
+ """ vcf_file is deprecated for vcf_files, but a client still using it sends the same records as before """
130
+ url = f"{server}/seqauto/api/v1/sequencing_files/bulk_create"
131
+ responses.add(responses.POST, url, json={"created": 2}, status=200)
132
+ old_style = []
133
+ for sf in vg_objects["sequencing_files"]:
134
+ with pytest.warns(DeprecationWarning, match="vcf_file is deprecated"):
135
+ old_style.append(SequencingFile(sample_name=sf.sample_name, bam_file=sf.bam_file,
136
+ vcf_file=sf.vcf_files[0], fastq_r1=sf.fastq_r1, fastq_r2=sf.fastq_r2))
137
+ api.create_sequencing_data(vg_objects["sample_sheet_lookup"], old_style)
138
+ old_records = _last_json()["records"]
139
+
140
+ api.create_sequencing_data(vg_objects["sample_sheet_lookup"], vg_objects["sequencing_files"])
141
+ assert old_records == _last_json()["records"]
142
+ assert old_style[0].vcf_file.path == old_style[0].get_vcf_files()[0].path
143
+
69
144
  def assert_post(api_call, url):
70
145
  responses.add(responses.POST, url, json={"ok": True}, status=200)
71
146
  out = api_call()
@@ -6,7 +6,7 @@ import requests
6
6
  import responses
7
7
 
8
8
  from variantgrid_api.api_client import VariantGridAPI, UnsupportedFeaturePolicy, UnsupportedFeatureError
9
- from variantgrid_api.data_models import ServerCapabilities
9
+ from variantgrid_api.data_models import ServerCapabilities, ServerFeature, UploadFileType
10
10
 
11
11
  CVO = "dragen_tso500_combined_variant_output"
12
12
 
@@ -53,6 +53,33 @@ def test_capabilities_parsed_and_fetched_once(api, capabilities_url, capabilitie
53
53
  assert len(_capabilities_calls(capabilities_url)) == 1
54
54
 
55
55
 
56
+ @responses.activate
57
+ def test_capabilities_accept_enums(api, capabilities_url, capabilities_json):
58
+ """ SACGF/variantgrid_api#22 - the enums are str, so they match the plain names the server sends """
59
+ responses.add(responses.GET, capabilities_url, json=capabilities_json, status=200)
60
+
61
+ assert all(api.supports(feature) for feature in ServerFeature)
62
+ assert api.accepts_upload(UploadFileType.DRAGEN_TSO500_COMBINED_VARIANT_OUTPUT)
63
+ assert not api.accepts_upload(UploadFileType.GENE_LIST)
64
+
65
+
66
+ def test_enums_are_their_server_names():
67
+ assert ServerFeature.PATIENTS == "patients"
68
+ assert str(UploadFileType.DRAGEN_TSO500_COMBINED_VARIANT_OUTPUT) == CVO
69
+ assert f"'{ServerFeature.UPLOAD_METADATA}'" == "'upload_metadata'"
70
+ for enum_class in (ServerFeature, UploadFileType):
71
+ for member in enum_class:
72
+ assert member.value == member.name.lower()
73
+
74
+
75
+ @responses.activate
76
+ def test_unsupported_message_names_feature_value(api, capabilities_url, vg_objects):
77
+ responses.add(responses.GET, capabilities_url, json={"detail": "Not found."}, status=404)
78
+
79
+ with pytest.raises(UnsupportedFeatureError, match="feature 'patients'"):
80
+ api.create_patient(vg_objects["patient"])
81
+
82
+
56
83
  @responses.activate
57
84
  def test_capabilities_404_is_legacy(api, capabilities_url):
58
85
  responses.add(responses.GET, capabilities_url, json={"detail": "Not found."}, status=404)
@@ -3,7 +3,7 @@ overrides, and assertion helpers work correctly."""
3
3
  import pytest
4
4
 
5
5
  from variantgrid_api.api_client import UnsupportedFeaturePolicy, UnsupportedFeatureError
6
- from variantgrid_api.data_models import ServerCapabilities
6
+ from variantgrid_api.data_models import ServerCapabilities, ServerFeature, UploadFileType
7
7
  from variantgrid_api.mock_variantgrid_api import MockVariantGridAPI
8
8
 
9
9
 
@@ -240,6 +240,15 @@ def test_mock_default_capabilities_support_everything_gated(mock_api, vg_objects
240
240
  (("cvo.tsv",), {"path": None, "file_type": "dragen_tso500_combined_variant_output"})]
241
241
 
242
242
 
243
+ def test_mock_default_capabilities_accept_enums(mock_api):
244
+ assert all(mock_api.supports(feature) for feature in ServerFeature)
245
+ assert mock_api.supports("upload_status")
246
+ assert mock_api.accepts_upload(UploadFileType.DRAGEN_TSO500_COMBINED_VARIANT_OUTPUT)
247
+ assert not mock_api.accepts_upload(UploadFileType.PED)
248
+ mock_api.upload_file("cvo.tsv", path=None, file_type=UploadFileType.DRAGEN_TSO500_COMBINED_VARIANT_OUTPUT)
249
+ mock_api.assert_called_once("upload_file")
250
+
251
+
243
252
  def test_mock_legacy_skips_under_skip(vg_objects):
244
253
  mock_api = MockVariantGridAPI(capabilities=ServerCapabilities.LEGACY,
245
254
  unsupported_feature_policy=UnsupportedFeaturePolicy.SKIP)
File without changes