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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. {variantgrid_api-1.9.0/src/variantgrid_api.egg-info → variantgrid_api-1.10.0}/PKG-INFO +27 -10
  2. {variantgrid_api-1.9.0 → variantgrid_api-1.10.0}/README.md +26 -9
  3. {variantgrid_api-1.9.0 → variantgrid_api-1.10.0}/pyproject.toml +1 -1
  4. {variantgrid_api-1.9.0 → variantgrid_api-1.10.0}/src/variantgrid_api/api_client.py +39 -1
  5. {variantgrid_api-1.9.0 → variantgrid_api-1.10.0}/src/variantgrid_api/data_models.py +1 -0
  6. {variantgrid_api-1.9.0 → variantgrid_api-1.10.0}/src/variantgrid_api/mock_variantgrid_api.py +20 -1
  7. {variantgrid_api-1.9.0 → variantgrid_api-1.10.0/src/variantgrid_api.egg-info}/PKG-INFO +27 -10
  8. {variantgrid_api-1.9.0 → variantgrid_api-1.10.0}/tests/test_api_client_capabilities.py +3 -0
  9. {variantgrid_api-1.9.0 → variantgrid_api-1.10.0}/tests/test_api_client_patients.py +125 -1
  10. {variantgrid_api-1.9.0 → variantgrid_api-1.10.0}/tests/test_mock_variantgrid_api.py +31 -0
  11. {variantgrid_api-1.9.0 → variantgrid_api-1.10.0}/LICENSE +0 -0
  12. {variantgrid_api-1.9.0 → variantgrid_api-1.10.0}/setup.cfg +0 -0
  13. {variantgrid_api-1.9.0 → variantgrid_api-1.10.0}/src/variantgrid_api/cli.py +0 -0
  14. {variantgrid_api-1.9.0 → variantgrid_api-1.10.0}/src/variantgrid_api.egg-info/SOURCES.txt +0 -0
  15. {variantgrid_api-1.9.0 → variantgrid_api-1.10.0}/src/variantgrid_api.egg-info/dependency_links.txt +0 -0
  16. {variantgrid_api-1.9.0 → variantgrid_api-1.10.0}/src/variantgrid_api.egg-info/entry_points.txt +0 -0
  17. {variantgrid_api-1.9.0 → variantgrid_api-1.10.0}/src/variantgrid_api.egg-info/requires.txt +0 -0
  18. {variantgrid_api-1.9.0 → variantgrid_api-1.10.0}/src/variantgrid_api.egg-info/top_level.txt +0 -0
  19. {variantgrid_api-1.9.0 → variantgrid_api-1.10.0}/tests/test_alignment_files.py +0 -0
  20. {variantgrid_api-1.9.0 → variantgrid_api-1.10.0}/tests/test_api_client.py +0 -0
  21. {variantgrid_api-1.9.0 → variantgrid_api-1.10.0}/tests/test_api_client_annotation.py +0 -0
  22. {variantgrid_api-1.9.0 → variantgrid_api-1.10.0}/tests/test_api_client_bulk.py +0 -0
  23. {variantgrid_api-1.9.0 → variantgrid_api-1.10.0}/tests/test_api_client_validation.py +0 -0
  24. {variantgrid_api-1.9.0 → variantgrid_api-1.10.0}/tests/test_cli.py +0 -0
  25. {variantgrid_api-1.9.0 → variantgrid_api-1.10.0}/tests/test_data_models.py +0 -0
  26. {variantgrid_api-1.9.0 → variantgrid_api-1.10.0}/tests/test_sequencer_model_from_name.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: variantgrid_api
3
- Version: 1.9.0
3
+ Version: 1.10.0
4
4
  Summary: A Python API client for VariantGrid
5
5
  Author-email: Dave Lawrence <davmlaw@gmail.com>
6
6
  License-Expression: MIT
@@ -90,6 +90,21 @@ api.upload_file("sample.vcf.gz", path=None,
90
90
  A bare string names a record by its local reference. Use `ExternalReference(code=..., external_type=...)` to
91
91
  name it by a LIMS identifier instead. See `examples/example_tso500.py` for a full run.
92
92
 
93
+ Once the sample sheet is registered, link its sequencing samples to their extractions in one call, keyed on
94
+ sample name (what `SequencingSampleLookup.sample_name` holds):
95
+
96
+ ```
97
+ api.link_sequencing_sample_extractions(sample_sheet_lookup, {"sample_1": "2600000001C",
98
+ "sample_2": "2600000002C"})
99
+ ```
100
+
101
+ It returns a list with one `{"sequencing_sample", "match_status", "match_error", "extraction"}` per sample. An
102
+ extraction the server doesn't have yet is `Pending` for that sample only (HTTP 202) and attaches itself once the
103
+ extraction is created; a sample name that isn't on the sheet is a 400 for the whole batch. On a server from
104
+ before SACGF/variantgrid#2176 (no `link_extractions_bulk` feature) the client makes one
105
+ `link_sequencing_sample_extraction` call per sample instead, so pipelines can use the bulk call unconditionally.
106
+ `link_sequencing_sample_extraction` is still there for a single late link. See `examples/example_haem_20_999.py`.
107
+
93
108
  ## BAMs and CRAMs
94
109
 
95
110
  A sequencing sample can have several alignment files, eg a BAM and its recalibrated BAM, or a BAM plus a CRAM.
@@ -114,13 +129,15 @@ file as `bam_file`, and a QC's first alignment file as its `bam_file`. A CRAM th
114
129
  Servers of different ages accept different calls. The client asks the server which features it has
115
130
  (`GET api/v1/capabilities`, fetched once on first use), and each call that needs a newer server
116
131
  checks that list first: the patient / specimen / extraction calls, specimen measures,
117
- `link_sequencing_sample_extraction`, the annotate flow (`poll_upload_status`, `wait_for_annotation`,
118
- `download_annotated`, `annotate_vcf`), and `upload_file` with `metadata` or `file_type`. A server without the
119
- capabilities endpoint counts as legacy and reports no features.
132
+ `link_sequencing_sample_extraction` and `link_sequencing_sample_extractions`, the annotate flow
133
+ (`poll_upload_status`, `wait_for_annotation`, `download_annotated`, `annotate_vcf`), and `upload_file` with
134
+ `metadata` or `file_type`. A server without the capabilities endpoint counts as legacy and reports no features.
120
135
 
121
136
  By default an unsupported call raises `UnsupportedFeatureError`. To run the same code against old and new
122
- servers, skip those calls instead: they log a warning and return `None`. `upload_file` is the exception: on a
123
- server without upload metadata it drops the `metadata` and still uploads the file.
137
+ servers, skip those calls instead: they log a warning and return `None`. Two calls downgrade rather than skip
138
+ when the server has the older form of the feature: `upload_file` on a server without upload metadata drops the
139
+ `metadata` and still uploads the file, and `link_sequencing_sample_extractions` on a server with only
140
+ `link_extraction` makes one call per sample.
124
141
 
125
142
  ```
126
143
  from variantgrid_api.api_client import VariantGridAPI, UnsupportedFeaturePolicy
@@ -163,10 +180,10 @@ specimen measures. See `examples/example_tso500.py` for a full run.
163
180
  ## Permission to write sequencing data
164
181
 
165
182
  Posts to the server's `seqauto/api/` endpoints - the sequencing `create_*` calls from `create_experiment` through
166
- `create_sequencing_data` and the QC calls, and `link_sequencing_sample_extraction` - need a superuser's API token,
167
- or one belonging to a member of the server's SeqAuto write group (`seqauto_api_write` unless the server's
168
- `SEQAUTO_API_WRITE_GROUP` setting says otherwise). Anyone else gets a 403 (`requests.HTTPError`). Reads, such as
169
- `sequencing_run_has_vcf()`, need only a valid token.
183
+ `create_sequencing_data` and the QC calls, and the `link_sequencing_sample_extraction(s)` calls - need a
184
+ superuser's API token, or one belonging to a member of the server's SeqAuto write group (`seqauto_api_write`
185
+ unless the server's `SEQAUTO_API_WRITE_GROUP` setting says otherwise). Anyone else gets a 403
186
+ (`requests.HTTPError`). Reads, such as `sequencing_run_has_vcf()`, need only a valid token.
170
187
 
171
188
  ## Testing
172
189
 
@@ -69,6 +69,21 @@ api.upload_file("sample.vcf.gz", path=None,
69
69
  A bare string names a record by its local reference. Use `ExternalReference(code=..., external_type=...)` to
70
70
  name it by a LIMS identifier instead. See `examples/example_tso500.py` for a full run.
71
71
 
72
+ Once the sample sheet is registered, link its sequencing samples to their extractions in one call, keyed on
73
+ sample name (what `SequencingSampleLookup.sample_name` holds):
74
+
75
+ ```
76
+ api.link_sequencing_sample_extractions(sample_sheet_lookup, {"sample_1": "2600000001C",
77
+ "sample_2": "2600000002C"})
78
+ ```
79
+
80
+ It returns a list with one `{"sequencing_sample", "match_status", "match_error", "extraction"}` per sample. An
81
+ extraction the server doesn't have yet is `Pending` for that sample only (HTTP 202) and attaches itself once the
82
+ extraction is created; a sample name that isn't on the sheet is a 400 for the whole batch. On a server from
83
+ before SACGF/variantgrid#2176 (no `link_extractions_bulk` feature) the client makes one
84
+ `link_sequencing_sample_extraction` call per sample instead, so pipelines can use the bulk call unconditionally.
85
+ `link_sequencing_sample_extraction` is still there for a single late link. See `examples/example_haem_20_999.py`.
86
+
72
87
  ## BAMs and CRAMs
73
88
 
74
89
  A sequencing sample can have several alignment files, eg a BAM and its recalibrated BAM, or a BAM plus a CRAM.
@@ -93,13 +108,15 @@ file as `bam_file`, and a QC's first alignment file as its `bam_file`. A CRAM th
93
108
  Servers of different ages accept different calls. The client asks the server which features it has
94
109
  (`GET api/v1/capabilities`, fetched once on first use), and each call that needs a newer server
95
110
  checks that list first: the patient / specimen / extraction calls, specimen measures,
96
- `link_sequencing_sample_extraction`, the annotate flow (`poll_upload_status`, `wait_for_annotation`,
97
- `download_annotated`, `annotate_vcf`), and `upload_file` with `metadata` or `file_type`. A server without the
98
- capabilities endpoint counts as legacy and reports no features.
111
+ `link_sequencing_sample_extraction` and `link_sequencing_sample_extractions`, the annotate flow
112
+ (`poll_upload_status`, `wait_for_annotation`, `download_annotated`, `annotate_vcf`), and `upload_file` with
113
+ `metadata` or `file_type`. A server without the capabilities endpoint counts as legacy and reports no features.
99
114
 
100
115
  By default an unsupported call raises `UnsupportedFeatureError`. To run the same code against old and new
101
- servers, skip those calls instead: they log a warning and return `None`. `upload_file` is the exception: on a
102
- server without upload metadata it drops the `metadata` and still uploads the file.
116
+ servers, skip those calls instead: they log a warning and return `None`. Two calls downgrade rather than skip
117
+ when the server has the older form of the feature: `upload_file` on a server without upload metadata drops the
118
+ `metadata` and still uploads the file, and `link_sequencing_sample_extractions` on a server with only
119
+ `link_extraction` makes one call per sample.
103
120
 
104
121
  ```
105
122
  from variantgrid_api.api_client import VariantGridAPI, UnsupportedFeaturePolicy
@@ -142,10 +159,10 @@ specimen measures. See `examples/example_tso500.py` for a full run.
142
159
  ## Permission to write sequencing data
143
160
 
144
161
  Posts to the server's `seqauto/api/` endpoints - the sequencing `create_*` calls from `create_experiment` through
145
- `create_sequencing_data` and the QC calls, and `link_sequencing_sample_extraction` - need a superuser's API token,
146
- or one belonging to a member of the server's SeqAuto write group (`seqauto_api_write` unless the server's
147
- `SEQAUTO_API_WRITE_GROUP` setting says otherwise). Anyone else gets a 403 (`requests.HTTPError`). Reads, such as
148
- `sequencing_run_has_vcf()`, need only a valid token.
162
+ `create_sequencing_data` and the QC calls, and the `link_sequencing_sample_extraction(s)` calls - need a
163
+ superuser's API token, or one belonging to a member of the server's SeqAuto write group (`seqauto_api_write`
164
+ unless the server's `SEQAUTO_API_WRITE_GROUP` setting says otherwise). Anyone else gets a 403
165
+ (`requests.HTTPError`). Reads, such as `sequencing_run_has_vcf()`, need only a valid token.
149
166
 
150
167
  ## Testing
151
168
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "variantgrid_api"
7
- version = "1.9.0"
7
+ version = "1.10.0"
8
8
  description = "A Python API client for VariantGrid"
9
9
  authors = [
10
10
  { name = "Dave Lawrence", email = "davmlaw@gmail.com" }
@@ -7,7 +7,7 @@ import urllib
7
7
  import warnings
8
8
  from enum import Enum
9
9
  from pathlib import Path
10
- from typing import List, Optional, Callable, Union
10
+ from typing import Dict, List, Optional, Callable, Union
11
11
 
12
12
  import requests
13
13
 
@@ -438,6 +438,44 @@ class VariantGridAPI:
438
438
  }
439
439
  return self._post("seqauto/api/v1/sequencing_sample/link_extraction", json_data)
440
440
 
441
+ def link_sequencing_sample_extractions(self, sample_sheet_lookup: SampleSheetLookup,
442
+ extractions: Dict[str, ReferenceLike]) -> Optional[List[dict]]:
443
+ """ link_sequencing_sample_extraction() for a whole sample sheet in one call. extractions maps each
444
+ sequencing sample's name (SequencingSampleLookup.sample_name) to its extraction reference.
445
+
446
+ Returns one {"sequencing_sample", "match_status", "match_error", "extraction"} per sample, in the
447
+ order given. match_status is per sample: an extraction the server doesn't have yet is 'Pending'
448
+ (the response is a 202) and attaches itself once created, without affecting the others. A sample
449
+ name that isn't on the sample sheet is a 400 for the whole batch - nothing is linked.
450
+
451
+ Needs ServerFeature.LINK_EXTRACTIONS_BULK (SACGF/variantgrid#2176). A server with only
452
+ ServerFeature.LINK_EXTRACTION gets one link_sequencing_sample_extraction() call per sample and
453
+ the results are collected, so pipelines can call this unconditionally. unsupported_feature_policy
454
+ applies only when the server has neither feature. """
455
+ if not self.supports(ServerFeature.LINK_EXTRACTIONS_BULK) and not self._require(ServerFeature.LINK_EXTRACTION):
456
+ return None
457
+ self._validate_object("sample_sheet_lookup", sample_sheet_lookup)
458
+ if not extractions:
459
+ self.validation_handler("extractions: empty")
460
+ for sample_name, extraction_reference in extractions.items():
461
+ self._validate_string("extractions: sample name", sample_name)
462
+ self._validate_reference(f"extractions['{sample_name}']", extraction_reference)
463
+
464
+ if not self.supports(ServerFeature.LINK_EXTRACTIONS_BULK):
465
+ self.logger.info("Server doesn't support feature '%s' - linking %d sequencing sample(s) one at a time",
466
+ ServerFeature.LINK_EXTRACTIONS_BULK, len(extractions))
467
+ return [self.link_sequencing_sample_extraction(
468
+ SequencingSampleLookup(sample_sheet_lookup=sample_sheet_lookup, sample_name=sample_name),
469
+ extraction_reference)
470
+ for sample_name, extraction_reference in extractions.items()]
471
+
472
+ json_data = {
473
+ "sample_sheet": sample_sheet_lookup.to_dict(),
474
+ "links": [{"sample_name": sample_name, "extraction": reference_json(extraction_reference)}
475
+ for sample_name, extraction_reference in extractions.items()],
476
+ }
477
+ return self._post("seqauto/api/v1/sequencing_sample/link_extractions", json_data)
478
+
441
479
  def upload_file(self, filename: str, path=_UNSET, metadata: Optional[dict] = None,
442
480
  file_type: Optional[Union[UploadFileType, str]] = None):
443
481
  """ Upload a file via multipart POST to upload/api/v1/file_upload.
@@ -520,6 +520,7 @@ class ServerFeature(_ServerName):
520
520
  variantgrid/views_rest.py). An older server just doesn't list a newer one """
521
521
  PATIENTS = "patients"
522
522
  LINK_EXTRACTION = "link_extraction"
523
+ LINK_EXTRACTIONS_BULK = "link_extractions_bulk" # sequencing_sample/link_extractions (SACGF/variantgrid#2176)
523
524
  UPLOAD_STATUS = "upload_status"
524
525
  JOINT_CALLED_VCF_CROSS_RUN = "joint_called_vcf_cross_run"
525
526
  UPLOAD_METADATA = "upload_metadata"
@@ -19,7 +19,8 @@ from pathlib import Path
19
19
  from typing import Any, List, Optional, Tuple, Union
20
20
 
21
21
  from variantgrid_api.api_client import UnsupportedFeaturePolicy, UnsupportedFeatureError
22
- from variantgrid_api.data_models import reference_json, ServerCapabilities, ServerFeature, UploadFileType
22
+ from variantgrid_api.data_models import reference_json, SequencingSampleLookup, ServerCapabilities, \
23
+ ServerFeature, UploadFileType
23
24
 
24
25
  _UNSET = object()
25
26
 
@@ -219,6 +220,24 @@ class MockVariantGridAPI:
219
220
  "extraction": str(reference_json(extraction_reference)),
220
221
  })
221
222
 
223
+ def link_sequencing_sample_extractions(self, sample_sheet_lookup, extractions):
224
+ """ As the real client, a server without 'link_extractions_bulk' gets one
225
+ link_sequencing_sample_extraction call per sample - those are what's recorded, not this call """
226
+ if not self.supports(ServerFeature.LINK_EXTRACTIONS_BULK):
227
+ if not self._require(ServerFeature.LINK_EXTRACTION):
228
+ return None
229
+ return [self.link_sequencing_sample_extraction(
230
+ SequencingSampleLookup(sample_sheet_lookup=sample_sheet_lookup, sample_name=sample_name),
231
+ extraction_reference)
232
+ for sample_name, extraction_reference in extractions.items()]
233
+ self._record("link_sequencing_sample_extractions", sample_sheet_lookup, extractions)
234
+ return self._ret("link_sequencing_sample_extractions", [{
235
+ "sequencing_sample": sample_name,
236
+ "match_status": "Matched",
237
+ "match_error": None,
238
+ "extraction": str(reference_json(extraction_reference)),
239
+ } for sample_name, extraction_reference in extractions.items()])
240
+
222
241
  def upload_file(self, filename, path=_UNSET, metadata=None, file_type=None):
223
242
  if metadata and not self.supports(ServerFeature.UPLOAD_METADATA):
224
243
  self._unsupported(f"upload metadata for '{filename}' (server doesn't support feature 'upload_metadata')")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: variantgrid_api
3
- Version: 1.9.0
3
+ Version: 1.10.0
4
4
  Summary: A Python API client for VariantGrid
5
5
  Author-email: Dave Lawrence <davmlaw@gmail.com>
6
6
  License-Expression: MIT
@@ -90,6 +90,21 @@ api.upload_file("sample.vcf.gz", path=None,
90
90
  A bare string names a record by its local reference. Use `ExternalReference(code=..., external_type=...)` to
91
91
  name it by a LIMS identifier instead. See `examples/example_tso500.py` for a full run.
92
92
 
93
+ Once the sample sheet is registered, link its sequencing samples to their extractions in one call, keyed on
94
+ sample name (what `SequencingSampleLookup.sample_name` holds):
95
+
96
+ ```
97
+ api.link_sequencing_sample_extractions(sample_sheet_lookup, {"sample_1": "2600000001C",
98
+ "sample_2": "2600000002C"})
99
+ ```
100
+
101
+ It returns a list with one `{"sequencing_sample", "match_status", "match_error", "extraction"}` per sample. An
102
+ extraction the server doesn't have yet is `Pending` for that sample only (HTTP 202) and attaches itself once the
103
+ extraction is created; a sample name that isn't on the sheet is a 400 for the whole batch. On a server from
104
+ before SACGF/variantgrid#2176 (no `link_extractions_bulk` feature) the client makes one
105
+ `link_sequencing_sample_extraction` call per sample instead, so pipelines can use the bulk call unconditionally.
106
+ `link_sequencing_sample_extraction` is still there for a single late link. See `examples/example_haem_20_999.py`.
107
+
93
108
  ## BAMs and CRAMs
94
109
 
95
110
  A sequencing sample can have several alignment files, eg a BAM and its recalibrated BAM, or a BAM plus a CRAM.
@@ -114,13 +129,15 @@ file as `bam_file`, and a QC's first alignment file as its `bam_file`. A CRAM th
114
129
  Servers of different ages accept different calls. The client asks the server which features it has
115
130
  (`GET api/v1/capabilities`, fetched once on first use), and each call that needs a newer server
116
131
  checks that list first: the patient / specimen / extraction calls, specimen measures,
117
- `link_sequencing_sample_extraction`, the annotate flow (`poll_upload_status`, `wait_for_annotation`,
118
- `download_annotated`, `annotate_vcf`), and `upload_file` with `metadata` or `file_type`. A server without the
119
- capabilities endpoint counts as legacy and reports no features.
132
+ `link_sequencing_sample_extraction` and `link_sequencing_sample_extractions`, the annotate flow
133
+ (`poll_upload_status`, `wait_for_annotation`, `download_annotated`, `annotate_vcf`), and `upload_file` with
134
+ `metadata` or `file_type`. A server without the capabilities endpoint counts as legacy and reports no features.
120
135
 
121
136
  By default an unsupported call raises `UnsupportedFeatureError`. To run the same code against old and new
122
- servers, skip those calls instead: they log a warning and return `None`. `upload_file` is the exception: on a
123
- server without upload metadata it drops the `metadata` and still uploads the file.
137
+ servers, skip those calls instead: they log a warning and return `None`. Two calls downgrade rather than skip
138
+ when the server has the older form of the feature: `upload_file` on a server without upload metadata drops the
139
+ `metadata` and still uploads the file, and `link_sequencing_sample_extractions` on a server with only
140
+ `link_extraction` makes one call per sample.
124
141
 
125
142
  ```
126
143
  from variantgrid_api.api_client import VariantGridAPI, UnsupportedFeaturePolicy
@@ -163,10 +180,10 @@ specimen measures. See `examples/example_tso500.py` for a full run.
163
180
  ## Permission to write sequencing data
164
181
 
165
182
  Posts to the server's `seqauto/api/` endpoints - the sequencing `create_*` calls from `create_experiment` through
166
- `create_sequencing_data` and the QC calls, and `link_sequencing_sample_extraction` - need a superuser's API token,
167
- or one belonging to a member of the server's SeqAuto write group (`seqauto_api_write` unless the server's
168
- `SEQAUTO_API_WRITE_GROUP` setting says otherwise). Anyone else gets a 403 (`requests.HTTPError`). Reads, such as
169
- `sequencing_run_has_vcf()`, need only a valid token.
183
+ `create_sequencing_data` and the QC calls, and the `link_sequencing_sample_extraction(s)` calls - need a
184
+ superuser's API token, or one belonging to a member of the server's SeqAuto write group (`seqauto_api_write`
185
+ unless the server's `SEQAUTO_API_WRITE_GROUP` setting says otherwise). Anyone else gets a 403
186
+ (`requests.HTTPError`). Reads, such as `sequencing_run_has_vcf()`, need only a valid token.
170
187
 
171
188
  ## Testing
172
189
 
@@ -140,6 +140,8 @@ def _gated_calls(vg_objects, vcf):
140
140
  "create_extraction": lambda api: api.create_extraction(vg_objects["dna_extraction"]),
141
141
  "link_sequencing_sample_extraction": lambda api: api.link_sequencing_sample_extraction(
142
142
  vg_objects["sequencing_sample_lookup_1"], "2600000001C"),
143
+ "link_sequencing_sample_extractions": lambda api: api.link_sequencing_sample_extractions(
144
+ vg_objects["sample_sheet_lookup"], {"fake_sample_1": "2600000001C"}),
143
145
  "upload_file_file_type": lambda api: api.upload_file(vcf, path=None, file_type=CVO),
144
146
  "poll_upload_status": lambda api: api.poll_upload_status(uploaded_file_id=1),
145
147
  "wait_for_annotation": lambda api: api.wait_for_annotation(uploaded_file_id=1, sleep=lambda _: None),
@@ -149,6 +151,7 @@ def _gated_calls(vg_objects, vcf):
149
151
 
150
152
 
151
153
  GATED = ["create_patient", "create_specimen", "create_extraction", "link_sequencing_sample_extraction",
154
+ "link_sequencing_sample_extractions",
152
155
  "upload_file_file_type",
153
156
  "poll_upload_status", "wait_for_annotation", "download_annotated", "annotate_vcf"]
154
157
 
@@ -1,12 +1,16 @@
1
1
  """Patient / specimen / extraction, specimen measures, extraction links and upload metadata
2
2
  (SACGF/variantgrid_api#20)"""
3
3
  import json
4
+ import logging
5
+ import re
4
6
  from urllib.parse import urlparse, parse_qs
5
7
 
6
8
  import pytest
7
9
  import responses
8
10
 
9
- from variantgrid_api.data_models import ExternalReference, ExternalPK, Extraction
11
+ from variantgrid_api.api_client import VariantGridAPI, UnsupportedFeaturePolicy, UnsupportedFeatureError
12
+ from variantgrid_api.data_models import ExternalReference, ExternalPK, Extraction, ServerCapabilities, \
13
+ ServerFeature
10
14
 
11
15
 
12
16
  @pytest.fixture
@@ -141,6 +145,126 @@ def test_link_sequencing_sample_extraction_pending_is_not_an_error(api, server,
141
145
  assert out == pending
142
146
 
143
147
 
148
+ # ------------------------------------------------------------------ #
149
+ # Linking a sample sheet's sequencing samples in one call (#32) #
150
+ # ------------------------------------------------------------------ #
151
+
152
+ def _bulk_url(server):
153
+ return f"{server}/seqauto/api/v1/sequencing_sample/link_extractions"
154
+
155
+
156
+ def _single_url(server):
157
+ return f"{server}/seqauto/api/v1/sequencing_sample/link_extraction"
158
+
159
+
160
+ def _link_result(sample_name, extraction, match_status="Matched"):
161
+ return {"sequencing_sample": sample_name, "match_status": match_status, "match_error": None,
162
+ "extraction": extraction if match_status == "Matched" else None}
163
+
164
+
165
+ EXTRACTIONS = {"fake_sample_1": "2600000001C", "fake_sample_2": "2600000002C"}
166
+
167
+
168
+ @responses.activate
169
+ def test_link_sequencing_sample_extractions(api, server, vg_objects):
170
+ result = [_link_result("fake_sample_1", "2600000001C"), _link_result("fake_sample_2", "2600000002C")]
171
+ responses.add(responses.POST, _bulk_url(server), json=result, status=200)
172
+
173
+ out = api.link_sequencing_sample_extractions(vg_objects["sample_sheet_lookup"], EXTRACTIONS)
174
+ assert out == result
175
+ assert len(responses.calls) == 1
176
+ assert _last_json() == {
177
+ "sample_sheet": vg_objects["sample_sheet_lookup"].to_dict(),
178
+ "links": [
179
+ {"sample_name": "fake_sample_1", "extraction": "2600000001C"},
180
+ {"sample_name": "fake_sample_2", "extraction": "2600000002C"},
181
+ ],
182
+ }
183
+
184
+
185
+ @responses.activate
186
+ def test_link_sequencing_sample_extractions_external_reference(api, server, vg_objects):
187
+ """ Each link takes the same reference forms as the single call """
188
+ responses.add(responses.POST, _bulk_url(server), json=[], status=200)
189
+ lims = ExternalReference(code="LIMS-1", external_type="LabNo")
190
+ api.link_sequencing_sample_extractions(vg_objects["sample_sheet_lookup"], {"fake_sample_1": lims})
191
+ assert _last_json()["links"] == [{"sample_name": "fake_sample_1", "extraction": lims.to_json_value()}]
192
+
193
+
194
+ @responses.activate
195
+ def test_link_sequencing_sample_extractions_pending_is_not_an_error(api, server, vg_objects):
196
+ """ match_status is per sample - a 202 with one 'Pending' still links the others """
197
+ result = [_link_result("fake_sample_1", "2600000001C"),
198
+ _link_result("fake_sample_2", None, match_status="Pending")]
199
+ responses.add(responses.POST, _bulk_url(server), json=result, status=202)
200
+ assert api.link_sequencing_sample_extractions(vg_objects["sample_sheet_lookup"], EXTRACTIONS) == result
201
+
202
+
203
+ @pytest.fixture
204
+ def link_extraction_only_api(server, api_token):
205
+ """ A server from before the bulk endpoint, with just the single-sample one """
206
+ api = VariantGridAPI(server, api_token)
207
+ api._capabilities = ServerCapabilities(version="old", features=frozenset([ServerFeature.LINK_EXTRACTION]))
208
+ return api
209
+
210
+
211
+ @responses.activate
212
+ def test_link_sequencing_sample_extractions_falls_back_to_one_call_per_sample(link_extraction_only_api, server,
213
+ vg_objects):
214
+ """ So pipelines can use the bulk call unconditionally - the results are collected in order """
215
+ api = link_extraction_only_api
216
+ responses.add(responses.POST, _single_url(server), json=_link_result("fake_sample_1", "2600000001C"), status=200)
217
+ responses.add(responses.POST, _single_url(server), json=_link_result("fake_sample_2", None, "Pending"), status=202)
218
+
219
+ out = api.link_sequencing_sample_extractions(vg_objects["sample_sheet_lookup"], EXTRACTIONS)
220
+ assert out == [_link_result("fake_sample_1", "2600000001C"), _link_result("fake_sample_2", None, "Pending")]
221
+ assert [c.request.url for c in responses.calls] == [_single_url(server)] * 2
222
+ sample_sheet = vg_objects["sample_sheet_lookup"].to_dict()
223
+ assert [json.loads(c.request.body) for c in responses.calls] == [
224
+ {"sequencing_sample": {"sample_sheet": sample_sheet, "sample_name": "fake_sample_1"},
225
+ "extraction": "2600000001C"},
226
+ {"sequencing_sample": {"sample_sheet": sample_sheet, "sample_name": "fake_sample_2"},
227
+ "extraction": "2600000002C"},
228
+ ]
229
+
230
+
231
+ @responses.activate
232
+ def test_link_sequencing_sample_extractions_policy_applies_only_without_link_extraction(server, api_token,
233
+ vg_objects, caplog):
234
+ """ unsupported_feature_policy applies only when the server has neither feature """
235
+ legacy = ServerCapabilities.LEGACY
236
+ api = VariantGridAPI(server, api_token)
237
+ api._capabilities = legacy
238
+ with pytest.raises(UnsupportedFeatureError):
239
+ api.link_sequencing_sample_extractions(vg_objects["sample_sheet_lookup"], EXTRACTIONS)
240
+
241
+ skip_api = VariantGridAPI(server, api_token, unsupported_feature_policy=UnsupportedFeaturePolicy.SKIP)
242
+ skip_api._capabilities = legacy
243
+ with caplog.at_level(logging.WARNING):
244
+ assert skip_api.link_sequencing_sample_extractions(vg_objects["sample_sheet_lookup"], EXTRACTIONS) is None
245
+ assert "Skipping" in caplog.text
246
+ assert not responses.calls
247
+
248
+
249
+ @pytest.mark.parametrize("extractions, message", [
250
+ ({}, "extractions: empty"),
251
+ ({"fake_sample_1": "2600000001C", "fake_sample_2": None}, "extractions['fake_sample_2']: is None"),
252
+ ({"fake_sample_1": ""}, "extractions['fake_sample_1']: empty string"),
253
+ ({"": "2600000001C"}, "extractions: sample name: empty string"),
254
+ ])
255
+ @responses.activate
256
+ def test_link_sequencing_sample_extractions_validation_names_the_sample(api, vg_objects, extractions, message):
257
+ with pytest.raises(ValueError, match=re.escape(message)):
258
+ api.link_sequencing_sample_extractions(vg_objects["sample_sheet_lookup"], extractions)
259
+ assert not responses.calls
260
+
261
+
262
+ @responses.activate
263
+ def test_link_sequencing_sample_extractions_requires_sample_sheet_lookup(api, vg_objects):
264
+ with pytest.raises(ValueError, match="sample_sheet_lookup: is None"):
265
+ api.link_sequencing_sample_extractions(None, EXTRACTIONS)
266
+
267
+
144
268
  # ------------------------------------------------------------------ #
145
269
  # Upload metadata #
146
270
  # ------------------------------------------------------------------ #
@@ -208,6 +208,34 @@ def test_mock_link_sequencing_sample_extraction_pending_override(mock_api, vg_ob
208
208
  mock_api.assert_called_once("link_sequencing_sample_extraction")
209
209
 
210
210
 
211
+ def test_mock_link_sequencing_sample_extractions(mock_api, vg_objects):
212
+ extractions = {"fake_sample_1": "2600000001C", "fake_sample_2": "2600000002C"}
213
+ result = mock_api.link_sequencing_sample_extractions(vg_objects["sample_sheet_lookup"], extractions)
214
+ assert [r["sequencing_sample"] for r in result] == ["fake_sample_1", "fake_sample_2"]
215
+ assert all(r["match_status"] == "Matched" for r in result)
216
+ mock_api.assert_called_once("link_sequencing_sample_extractions")
217
+ mock_api.assert_not_called("link_sequencing_sample_extraction")
218
+ assert mock_api.get_calls("link_sequencing_sample_extractions") == [
219
+ ((vg_objects["sample_sheet_lookup"], extractions), {})]
220
+
221
+ mock_api.set_return("link_sequencing_sample_extractions", [{"match_status": "Pending"}])
222
+ assert mock_api.link_sequencing_sample_extractions(vg_objects["sample_sheet_lookup"], extractions) == [
223
+ {"match_status": "Pending"}]
224
+
225
+
226
+ def test_mock_link_sequencing_sample_extractions_falls_back_without_bulk_feature(vg_objects):
227
+ """ A server with only link_extraction gets (and records) one single-sample call per sample """
228
+ mock_api = MockVariantGridAPI(capabilities=ServerCapabilities(
229
+ version="old", features=frozenset([ServerFeature.LINK_EXTRACTION])))
230
+ extractions = {"fake_sample_1": "2600000001C", "fake_sample_2": "2600000002C"}
231
+ result = mock_api.link_sequencing_sample_extractions(vg_objects["sample_sheet_lookup"], extractions)
232
+ assert [r["sequencing_sample"] for r in result] == ["fake_sample_1", "fake_sample_2"]
233
+ mock_api.assert_not_called("link_sequencing_sample_extractions")
234
+ calls = mock_api.get_calls("link_sequencing_sample_extraction")
235
+ assert [(lookup.sample_name, extraction) for (lookup, extraction), _ in calls] == list(extractions.items())
236
+ assert all(lookup.sample_sheet_lookup == vg_objects["sample_sheet_lookup"] for (lookup, _), _ in calls)
237
+
238
+
211
239
  def test_mock_upload_file_records_metadata_only_when_sent(mock_api):
212
240
  mock_api.upload_file("a.vcf")
213
241
  mock_api.upload_file("b.vcf", path=None, metadata={"genome_build": "GRCh37"})
@@ -246,9 +274,12 @@ def test_mock_legacy_skips_under_skip(vg_objects):
246
274
  assert not mock_api.supports("patients")
247
275
  assert mock_api.create_patient(vg_objects["patient"]) is None
248
276
  assert mock_api.link_sequencing_sample_extraction(vg_objects["sequencing_sample_lookup_1"], "2600000001C") is None
277
+ assert mock_api.link_sequencing_sample_extractions(vg_objects["sample_sheet_lookup"],
278
+ {"fake_sample_1": "2600000001C"}) is None
249
279
  assert mock_api.upload_file("cvo.tsv", path=None, file_type="dragen_tso500_combined_variant_output") is None
250
280
  mock_api.assert_not_called("create_patient")
251
281
  mock_api.assert_not_called("link_sequencing_sample_extraction")
282
+ mock_api.assert_not_called("link_sequencing_sample_extractions")
252
283
  mock_api.assert_not_called("upload_file")
253
284
 
254
285
  # Metadata is dropped but the file still uploads