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.
- {variantgrid_api-1.5.0/src/variantgrid_api.egg-info → variantgrid_api-1.7.0}/PKG-INFO +1 -1
- {variantgrid_api-1.5.0 → variantgrid_api-1.7.0}/pyproject.toml +1 -1
- {variantgrid_api-1.5.0 → variantgrid_api-1.7.0}/src/variantgrid_api/api_client.py +38 -21
- {variantgrid_api-1.5.0 → variantgrid_api-1.7.0}/src/variantgrid_api/data_models.py +64 -2
- {variantgrid_api-1.5.0 → variantgrid_api-1.7.0}/src/variantgrid_api/mock_variantgrid_api.py +22 -21
- {variantgrid_api-1.5.0 → variantgrid_api-1.7.0/src/variantgrid_api.egg-info}/PKG-INFO +1 -1
- {variantgrid_api-1.5.0 → variantgrid_api-1.7.0}/tests/test_api_client.py +76 -1
- {variantgrid_api-1.5.0 → variantgrid_api-1.7.0}/tests/test_api_client_capabilities.py +28 -1
- {variantgrid_api-1.5.0 → variantgrid_api-1.7.0}/tests/test_mock_variantgrid_api.py +10 -1
- {variantgrid_api-1.5.0 → variantgrid_api-1.7.0}/LICENSE +0 -0
- {variantgrid_api-1.5.0 → variantgrid_api-1.7.0}/README.md +0 -0
- {variantgrid_api-1.5.0 → variantgrid_api-1.7.0}/setup.cfg +0 -0
- {variantgrid_api-1.5.0 → variantgrid_api-1.7.0}/src/variantgrid_api/cli.py +0 -0
- {variantgrid_api-1.5.0 → variantgrid_api-1.7.0}/src/variantgrid_api.egg-info/SOURCES.txt +0 -0
- {variantgrid_api-1.5.0 → variantgrid_api-1.7.0}/src/variantgrid_api.egg-info/dependency_links.txt +0 -0
- {variantgrid_api-1.5.0 → variantgrid_api-1.7.0}/src/variantgrid_api.egg-info/entry_points.txt +0 -0
- {variantgrid_api-1.5.0 → variantgrid_api-1.7.0}/src/variantgrid_api.egg-info/requires.txt +0 -0
- {variantgrid_api-1.5.0 → variantgrid_api-1.7.0}/src/variantgrid_api.egg-info/top_level.txt +0 -0
- {variantgrid_api-1.5.0 → variantgrid_api-1.7.0}/tests/test_api_client_annotation.py +0 -0
- {variantgrid_api-1.5.0 → variantgrid_api-1.7.0}/tests/test_api_client_bulk.py +0 -0
- {variantgrid_api-1.5.0 → variantgrid_api-1.7.0}/tests/test_api_client_patients.py +0 -0
- {variantgrid_api-1.5.0 → variantgrid_api-1.7.0}/tests/test_api_client_validation.py +0 -0
- {variantgrid_api-1.5.0 → variantgrid_api-1.7.0}/tests/test_cli.py +0 -0
- {variantgrid_api-1.5.0 → variantgrid_api-1.7.0}/tests/test_data_models.py +0 -0
- {variantgrid_api-1.5.0 → variantgrid_api-1.7.0}/tests/test_sequencer_model_from_name.py +0 -0
|
@@ -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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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)
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{variantgrid_api-1.5.0 → variantgrid_api-1.7.0}/src/variantgrid_api.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{variantgrid_api-1.5.0 → variantgrid_api-1.7.0}/src/variantgrid_api.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|