variantgrid-api 0.3.0__tar.gz → 1.1.1__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.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: variantgrid_api
3
- Version: 0.3.0
3
+ Version: 1.1.1
4
4
  Summary: A Python API client for VariantGrid
5
5
  Author-email: Dave Lawrence <davmlaw@gmail.com>
6
6
  License: MIT License
@@ -35,5 +35,41 @@ Description-Content-Type: text/markdown
35
35
  License-File: LICENSE
36
36
  Requires-Dist: requests
37
37
  Requires-Dist: dataclasses-json
38
+ Provides-Extra: test
39
+ Requires-Dist: pytest>=8; extra == "test"
40
+ Requires-Dist: pytest-cov>=5; extra == "test"
41
+ Requires-Dist: responses; extra == "test"
42
+ Dynamic: license-file
38
43
 
39
44
  # variantgrid_api
45
+
46
+ [![PyPi version](https://img.shields.io/pypi/v/variantgrid_api.svg)](https://pypi.org/project/variantgrid_api/) [![Python versions](https://img.shields.io/pypi/pyversions/variantgrid_api.svg)](https://pypi.org/project/variantgrid_api/)
47
+
48
+ Python API client for [VariantGrid](https://github.com/SACGF/variantgrid) Open source Variant database and analysis platform
49
+
50
+ See [changelog](https://github.com/SACGF/variantgrid_api/blob/main/CHANGELOG.md)
51
+
52
+ ## Install
53
+
54
+ ```
55
+ python3 -m pip install variantgrid_api
56
+ ```
57
+
58
+ ## Example
59
+
60
+ ```
61
+ from variantgrid_api.api_client import VariantGridAPI
62
+ from variantgrid_api.data_models import EnrichmentKit
63
+
64
+ api = VariantGridAPI(server="https://variantgrid.com", api_token="YOUR_API_TOKEN")
65
+ enrichment_kit = EnrichmentKit(name="idt_haem", version=1)
66
+ result = api.create_enrichment_kit(enrichment_kit)
67
+ ```
68
+
69
+ ## Testing
70
+
71
+ ```
72
+ # Install required testing packages
73
+ python3 -m pip install -e ".[test]"
74
+ python3 -m pytest --cov=variantgrid_api
75
+ ```
@@ -0,0 +1,32 @@
1
+ # variantgrid_api
2
+
3
+ [![PyPi version](https://img.shields.io/pypi/v/variantgrid_api.svg)](https://pypi.org/project/variantgrid_api/) [![Python versions](https://img.shields.io/pypi/pyversions/variantgrid_api.svg)](https://pypi.org/project/variantgrid_api/)
4
+
5
+ Python API client for [VariantGrid](https://github.com/SACGF/variantgrid) Open source Variant database and analysis platform
6
+
7
+ See [changelog](https://github.com/SACGF/variantgrid_api/blob/main/CHANGELOG.md)
8
+
9
+ ## Install
10
+
11
+ ```
12
+ python3 -m pip install variantgrid_api
13
+ ```
14
+
15
+ ## Example
16
+
17
+ ```
18
+ from variantgrid_api.api_client import VariantGridAPI
19
+ from variantgrid_api.data_models import EnrichmentKit
20
+
21
+ api = VariantGridAPI(server="https://variantgrid.com", api_token="YOUR_API_TOKEN")
22
+ enrichment_kit = EnrichmentKit(name="idt_haem", version=1)
23
+ result = api.create_enrichment_kit(enrichment_kit)
24
+ ```
25
+
26
+ ## Testing
27
+
28
+ ```
29
+ # Install required testing packages
30
+ python3 -m pip install -e ".[test]"
31
+ python3 -m pytest --cov=variantgrid_api
32
+ ```
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "variantgrid_api"
7
- version = "0.3.0"
7
+ version = "1.1.1"
8
8
  description = "A Python API client for VariantGrid"
9
9
  authors = [
10
10
  { name = "Dave Lawrence", email = "davmlaw@gmail.com" }
@@ -20,9 +20,12 @@ classifiers = [
20
20
  ]
21
21
  dependencies = [
22
22
  "requests",
23
- "dataclasses-json"
23
+ "dataclasses-json",
24
24
  ]
25
25
 
26
+ [project.optional-dependencies]
27
+ test = ["pytest>=8", "pytest-cov>=5", "responses"]
28
+
26
29
  [project.urls]
27
30
  "Homepage" = "https://github.com/SACGF/variantgrid_api"
28
31
 
@@ -0,0 +1,251 @@
1
+ import datetime
2
+ import json
3
+ import logging
4
+ import urllib
5
+ from enum import Enum
6
+ from typing import List, Optional, Callable
7
+
8
+ import requests
9
+
10
+ from variantgrid_api.data_models import EnrichmentKit, SequencingRun, SampleSheet, SampleSheetCombinedVCFFile, \
11
+ SampleSheetLookup, SequencingFile, QCGeneList, QCExecStats, QCGeneCoverage, SequencerModel, Sequencer
12
+
13
+
14
+ class DateTimeEncoder(json.JSONEncoder):
15
+ def default(self,o):
16
+ if isinstance(o,(datetime.date, datetime.datetime)):
17
+ return o.isoformat()
18
+ return super().default(o)
19
+
20
+
21
+ class EmptyInputPolicy(Enum):
22
+ IGNORE = "ignore"
23
+ WARN = "warn"
24
+ ERROR = "error"
25
+
26
+
27
+ class VariantGridAPI:
28
+ def __init__(self, server, api_token,
29
+ empty_input_policy=EmptyInputPolicy.ERROR,
30
+ logger: Optional[logging.Logger] = None,
31
+ log_request=False, log_response=False
32
+ ):
33
+ self.server = server
34
+ self.headers = {"Authorization": f"Token {api_token}"}
35
+ if logger is None:
36
+ logger = logging.getLogger(__name__)
37
+ self.logger = logger
38
+ self.validation_handler = self._get_validation_handler(empty_input_policy, logger)
39
+ self.log_request = log_request
40
+ self.log_response = log_response
41
+
42
+ def _get_url(self, url):
43
+ return urllib.parse.urljoin(self.server, url)
44
+
45
+ def _post(self, path, json_data):
46
+ url = self._get_url(path)
47
+ json_string = json.dumps(json_data, cls=DateTimeEncoder)
48
+ if self.log_request:
49
+ self.logger.info("POST to '%s', JSON: %s", url, json_string)
50
+ response = requests.post(url,
51
+ headers={**self.headers, "Content-Type": "application/json"},
52
+ data=json_string)
53
+
54
+ extra_error_message = f"{url=}, {json_string=}"
55
+ return self._handle_json_response(response, extra_error_message)
56
+
57
+ def _handle_json_response(self, response, extra_error_message: Optional[str] = None):
58
+ try:
59
+ json_response = response.json()
60
+ if self.log_response:
61
+ self.logger.info("Response from '%s', JSON: %s", response.url, json_response)
62
+ except Exception as e:
63
+ json_response = f"Couldn't convert JSON: {e}"
64
+ if not response.ok:
65
+ if extra_error_message:
66
+ self.logger.error(extra_error_message)
67
+ self.logger.error("Response: %s", json_response)
68
+ response.raise_for_status()
69
+ return json_response
70
+
71
+ @staticmethod
72
+ def _get_validation_handler(empty_input_policy: EmptyInputPolicy, logger: logging.Logger) -> Callable:
73
+ def _ignore(msg):
74
+ pass
75
+
76
+ def _warn(msg):
77
+ logger.warning(msg)
78
+
79
+ def _error(msg):
80
+ raise ValueError(msg)
81
+
82
+ return {
83
+ EmptyInputPolicy.IGNORE: _ignore,
84
+ EmptyInputPolicy.WARN: _warn,
85
+ EmptyInputPolicy.ERROR: _error,
86
+ }[empty_input_policy]
87
+
88
+ def _validate_string(self, info: str, s: Optional[str]) -> None:
89
+ if s is None:
90
+ self.validation_handler(f"{info}: is None")
91
+ elif s == "":
92
+ self.validation_handler(f"{info}: empty string")
93
+
94
+ def _validate_object(self, info: str, obj):
95
+ if obj is None:
96
+ self.validation_handler(f"{info}: is None")
97
+
98
+ def _validate_list(self, info: str, list_obj: List):
99
+ if not list_obj:
100
+ self.validation_handler(f"{info}: empty list")
101
+
102
+ def create_experiment(self, experiment: str):
103
+ self._validate_string("experiment", experiment)
104
+ json_data = {
105
+ "name": experiment
106
+ }
107
+ return self._post("seqauto/api/v1/experiment/", json_data)
108
+
109
+ def create_enrichment_kit(self, enrichment_kit: EnrichmentKit):
110
+ self._validate_object("enrichment_kit", enrichment_kit)
111
+ return self._post("seqauto/api/v1/enrichment_kit/",
112
+ enrichment_kit.to_dict())
113
+
114
+
115
+ def create_sequencer_model(self, sequencer_model: SequencerModel):
116
+ self._validate_object("sequencer_model", sequencer_model)
117
+ return self._post("seqauto/api/v1/sequencer_model/",
118
+ sequencer_model.to_dict())
119
+
120
+ def create_sequencer(self, sequencer: Sequencer):
121
+ self._validate_object("sequencer", sequencer)
122
+ return self._post("seqauto/api/v1/sequencer/",
123
+ sequencer.to_dict())
124
+
125
+ def create_sequencing_run(self, sequencing_run: SequencingRun):
126
+ self._validate_object("sequencing_run", sequencing_run)
127
+ return self._post("seqauto/api/v1/sequencing_run/",
128
+ sequencing_run.to_dict())
129
+
130
+ def create_sample_sheet(self, sample_sheet: SampleSheet):
131
+ self._validate_object("sample_sheet", sample_sheet)
132
+ json_data = sample_sheet.to_dict()
133
+ # We don't want all sequencing_run just the name
134
+ sequencing_run = json_data.pop("sequencing_run")
135
+ json_data["sequencing_run"] = sequencing_run["name"]
136
+ return self._post("seqauto/api/v1/sample_sheet/",
137
+ json_data)
138
+
139
+ def create_sample_sheet_combined_vcf_file(self, sample_sheet_combined_vcf_file: SampleSheetCombinedVCFFile):
140
+ self._validate_object("sample_sheet_combined_vcf_file", sample_sheet_combined_vcf_file)
141
+ json_data = sample_sheet_combined_vcf_file.to_dict()
142
+ return self._post("seqauto/api/v1/sample_sheet_combined_vcf_file/",
143
+ json_data)
144
+
145
+ def create_sequencing_data(self, sample_sheet_lookup: SampleSheetLookup, sequencing_files: List[SequencingFile]):
146
+ self._validate_object("sample_sheet_lookup", sample_sheet_lookup)
147
+ self._validate_list("sequencing_files", sequencing_files)
148
+ records = []
149
+ for sf in sequencing_files:
150
+ data = sf.to_dict()
151
+ # put into hierarchial JSON DRF expects
152
+ fastq_r1 = data.pop("fastq_r1")
153
+ fastq_r2 = data.pop("fastq_r2")
154
+ data["unaligned_reads"] = {
155
+ "fastq_r1": {"path": fastq_r1},
156
+ "fastq_r2": {"path": fastq_r2}
157
+ }
158
+ records.append(data)
159
+
160
+ json_data = {
161
+ "sample_sheet": sample_sheet_lookup.to_dict(),
162
+ "records": records
163
+ }
164
+ return self._post("seqauto/api/v1/sequencing_files/bulk_create",
165
+ json_data)
166
+
167
+ def create_qc_gene_list(self, qc_gene_list: QCGeneList):
168
+ self._validate_object("qc_gene_list", qc_gene_list)
169
+ json_data = qc_gene_list.to_dict()
170
+ return self._post("seqauto/api/v1/qc_gene_list/",
171
+ json_data)
172
+
173
+
174
+ def create_multiple_qc_gene_lists(self, qc_gene_lists: List[QCGeneList]):
175
+ self._validate_list("qc_gene_lists", qc_gene_lists)
176
+ json_data = {
177
+ "records": [
178
+ qcgl.to_dict() for qcgl in qc_gene_lists
179
+ ]
180
+ }
181
+ return self._post("seqauto/api/v1/qc_gene_list/bulk_create",
182
+ json_data)
183
+
184
+ def create_qc_exec_stats(self, qc_exec_stats: QCExecStats):
185
+ self._validate_object("qc_exec_stats", qc_exec_stats)
186
+ json_data = qc_exec_stats.to_dict()
187
+ return self._post("seqauto/api/v1/qc_exec_summary/",
188
+ json_data)
189
+
190
+ def create_multiple_qc_exec_stats(self, qc_exec_stats: List[QCExecStats]):
191
+ self._validate_list("qc_exec_stats", qc_exec_stats)
192
+ json_data = {
193
+ "records": [
194
+ qces.to_dict() for qces in qc_exec_stats
195
+ ]
196
+ }
197
+ return self._post("seqauto/api/v1/qc_exec_summary/bulk_create",
198
+ json_data)
199
+
200
+ def create_multiple_qc_gene_coverage(self, qc_gene_coverage_list: List[QCGeneCoverage]):
201
+ self._validate_list("qc_gene_coverage_list", qc_gene_coverage_list)
202
+ json_data = {
203
+ "records": [
204
+ qcgc.to_dict() for qcgc in qc_gene_coverage_list
205
+ ]
206
+ }
207
+ return self._post("seqauto/api/v1/qc_gene_coverage/bulk_create",
208
+ json_data)
209
+
210
+ def upload_file(self, filename: str):
211
+ url = self._get_url("upload/api/v1/file_upload")
212
+ with open(filename, "rb") as f:
213
+ kwargs = {
214
+ "files": {"file": f},
215
+ "params": {"path": filename}
216
+ }
217
+ response = requests.post(url, headers=self.headers, **kwargs)
218
+ extra_error_message = f"{filename=}"
219
+ return self._handle_json_response(response, extra_error_message)
220
+
221
+ ###############
222
+ ## Get methods
223
+
224
+ def _get(self, path, params=None):
225
+ url = self._get_url(path)
226
+ response = requests.get(url,
227
+ params,
228
+ headers=self.headers)
229
+
230
+ extra_error_message = f"{url=}, {params=}"
231
+ return self._handle_json_response(response, extra_error_message)
232
+
233
+ def sequencing_run_has_vcf(self, sequencing_run: SequencingRun, path: Optional[str] = None):
234
+ """ Returns whether the SequencingRun has a VCF associated with it. If path is set, VCF must match that path
235
+ otherwise - any VCF present will retur True """
236
+ return self.sequencing_run_name_has_vcf(sequencing_run.name, path)
237
+
238
+ def sequencing_run_name_has_vcf(self, sequencing_run_name: str, path: Optional[str] = None):
239
+ """ Returns whether the SequencingRun has a VCF associated with it. If path is set, VCF must match that path
240
+ otherwise - any VCF present will retur True """
241
+ data = self._get(f"seqauto/api/v1/sequencing_run/{sequencing_run_name}/")
242
+ found_vcf = False
243
+ for vcf in data["vcf_set"]:
244
+ if path is None or path in vcf["path"]:
245
+ found_vcf = True
246
+ break
247
+ return found_vcf
248
+
249
+
250
+
251
+
@@ -1,3 +1,4 @@
1
+ import re
1
2
  from datetime import date, datetime
2
3
  from dataclasses import dataclass, field
3
4
  from typing import Optional, List
@@ -12,6 +13,26 @@ class EnrichmentKit:
12
13
  version: int
13
14
 
14
15
 
16
+ @dataclass_json
17
+ @dataclass
18
+ class Manufacturer:
19
+ name: str
20
+
21
+ @dataclass_json
22
+ @dataclass
23
+ class SequencerModel:
24
+ model: str
25
+ manufacturer: Manufacturer
26
+ data_naming_convention: str # 'M' or 'H' for (HiSeq only)
27
+
28
+
29
+ @dataclass_json
30
+ @dataclass
31
+ class Sequencer:
32
+ name: str
33
+ sequencer_model: SequencerModel
34
+
35
+
15
36
  @dataclass_json
16
37
  @dataclass
17
38
  class SequencingRun:
@@ -22,15 +43,25 @@ class SequencingRun:
22
43
  experiment: str
23
44
  enrichment_kit: EnrichmentKit
24
45
 
46
+ @staticmethod
47
+ def get_date_from_name(name) -> Optional[datetime.date]:
48
+ date = None
49
+ if m := re.match(r".*_?([12]\d{5})_", name):
50
+ date_str = m.group(1)
51
+ dt = datetime.strptime(date_str, "%y%m%d")
52
+ date = dt.date()
53
+ return date
54
+
55
+
25
56
 
26
57
  @dataclass_json
27
58
  @dataclass
28
59
  class SequencingSample:
29
60
  sample_id: str
30
- sample_number: int
31
- lane: int
61
+ sample_number: int # Sample sheet row (to keep in order)
32
62
  barcode: str
33
63
  enrichment_kit: EnrichmentKit
64
+ lane: Optional[int] = None
34
65
  sample_project: Optional[str] = None
35
66
  is_control: bool = False
36
67
  failed: bool = False
@@ -42,7 +73,7 @@ class SequencingSample:
42
73
  class SampleSheet:
43
74
  path: str
44
75
  sequencing_run: SequencingRun
45
- file_last_modified: datetime
76
+ file_last_modified: float
46
77
  hash: str
47
78
  sequencing_samples: List[SequencingSample] = field(metadata=config(field_name="sequencingsample_set"))
48
79
 
@@ -144,34 +175,34 @@ class QCExecStats:
144
175
  modified: datetime
145
176
  hash: str
146
177
  is_valid: bool
147
- deduplicated_reads: int
148
- indels_dbsnp_percent: float
149
178
  mean_coverage_across_genes: float
150
179
  mean_coverage_across_kit: float
151
180
  median_insert: float
152
- number_indels: int
153
- number_snps: int
154
- percent_10x_goi: float
155
- percent_20x_goi: float
156
- percent_20x_kit: float
157
- percent_error_rate: float
158
- percent_map_to_diff_chr: float
159
181
  percent_read_enrichment: float
160
- percent_reads: float
161
- percent_softclip: float
162
182
  percent_duplication: float
163
- reads: int
164
- sample_id_lod: float
165
- sex_match: str
166
- snp_dbsnp_percent: float
167
- ts_to_tv_ratio: float
168
183
  uniformity_of_coverage: float
184
+ deduplicated_reads: Optional[int] = None
185
+ indels_dbsnp_percent: Optional[float] = None
186
+ number_indels: Optional[int] = None
187
+ number_snps: Optional[int] = None
188
+ percent_10x_goi: Optional[float] = None
189
+ percent_20x_goi: Optional[float] = None
190
+ percent_20x_kit: Optional[float] = None
169
191
  percent_100x_goi: Optional[float] = None
170
192
  percent_100x_kit: Optional[float] = None
171
193
  percent_250x_goi: Optional[float] = None
172
194
  percent_250x_kit: Optional[float] = None
173
195
  percent_500x_goi: Optional[float] = None
174
196
  percent_500x_kit: Optional[float] = None
197
+ percent_error_rate: Optional[float] = None
198
+ percent_map_to_diff_chr: Optional[float] = None
199
+ percent_reads: Optional[float] = None
200
+ percent_softclip: Optional[float] = None
201
+ reads: Optional[int] = None
202
+ sample_id_lod: Optional[float] = None
203
+ sex_match: Optional[str] = None
204
+ snp_dbsnp_percent: Optional[float] = None
205
+ ts_to_tv_ratio: Optional[float] = None
175
206
 
176
207
 
177
208
  @dataclass_json
@@ -180,4 +211,4 @@ class QCGeneCoverage:
180
211
  """ We send this up to associate coverage file path with sequencing sample
181
212
  Then, later we upload the coverage file - and match via path """
182
213
  qc: QC
183
- path: str
214
+ path: str
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: variantgrid_api
3
- Version: 0.3.0
3
+ Version: 1.1.1
4
4
  Summary: A Python API client for VariantGrid
5
5
  Author-email: Dave Lawrence <davmlaw@gmail.com>
6
6
  License: MIT License
@@ -35,5 +35,41 @@ Description-Content-Type: text/markdown
35
35
  License-File: LICENSE
36
36
  Requires-Dist: requests
37
37
  Requires-Dist: dataclasses-json
38
+ Provides-Extra: test
39
+ Requires-Dist: pytest>=8; extra == "test"
40
+ Requires-Dist: pytest-cov>=5; extra == "test"
41
+ Requires-Dist: responses; extra == "test"
42
+ Dynamic: license-file
38
43
 
39
44
  # variantgrid_api
45
+
46
+ [![PyPi version](https://img.shields.io/pypi/v/variantgrid_api.svg)](https://pypi.org/project/variantgrid_api/) [![Python versions](https://img.shields.io/pypi/pyversions/variantgrid_api.svg)](https://pypi.org/project/variantgrid_api/)
47
+
48
+ Python API client for [VariantGrid](https://github.com/SACGF/variantgrid) Open source Variant database and analysis platform
49
+
50
+ See [changelog](https://github.com/SACGF/variantgrid_api/blob/main/CHANGELOG.md)
51
+
52
+ ## Install
53
+
54
+ ```
55
+ python3 -m pip install variantgrid_api
56
+ ```
57
+
58
+ ## Example
59
+
60
+ ```
61
+ from variantgrid_api.api_client import VariantGridAPI
62
+ from variantgrid_api.data_models import EnrichmentKit
63
+
64
+ api = VariantGridAPI(server="https://variantgrid.com", api_token="YOUR_API_TOKEN")
65
+ enrichment_kit = EnrichmentKit(name="idt_haem", version=1)
66
+ result = api.create_enrichment_kit(enrichment_kit)
67
+ ```
68
+
69
+ ## Testing
70
+
71
+ ```
72
+ # Install required testing packages
73
+ python3 -m pip install -e ".[test]"
74
+ python3 -m pytest --cov=variantgrid_api
75
+ ```
@@ -7,4 +7,8 @@ src/variantgrid_api.egg-info/PKG-INFO
7
7
  src/variantgrid_api.egg-info/SOURCES.txt
8
8
  src/variantgrid_api.egg-info/dependency_links.txt
9
9
  src/variantgrid_api.egg-info/requires.txt
10
- src/variantgrid_api.egg-info/top_level.txt
10
+ src/variantgrid_api.egg-info/top_level.txt
11
+ tests/test_api_client.py
12
+ tests/test_api_client_bulk.py
13
+ tests/test_api_client_validation.py
14
+ tests/test_data_models.py
@@ -0,0 +1,7 @@
1
+ requests
2
+ dataclasses-json
3
+
4
+ [test]
5
+ pytest>=8
6
+ pytest-cov>=5
7
+ responses
@@ -0,0 +1,228 @@
1
+ import datetime
2
+ import json
3
+ import logging
4
+
5
+ import pytest
6
+ import responses
7
+
8
+ from variantgrid_api.api_client import VariantGridAPI, DateTimeEncoder
9
+
10
+
11
+ def _last_json():
12
+ r = responses.calls[-1].request
13
+ return json.loads(r.body)
14
+
15
+ @responses.activate
16
+ def test_create_experiment(api, server, vg_objects):
17
+ url = f"{server}/seqauto/api/v1/experiment/"
18
+ responses.add(responses.POST, url, json={"id": 1}, status=200)
19
+ out = api.create_experiment(vg_objects["experiment"])
20
+ assert out == {"id": 1}
21
+ assert responses.calls[-1].request.headers["Authorization"] == "Token TKN"
22
+
23
+ @responses.activate
24
+ def test_create_sample_sheet_rewrites_sequencing_run_to_name(api, server, vg_objects):
25
+ url = f"{server}/seqauto/api/v1/sample_sheet/"
26
+ responses.add(responses.POST, url, json={"id": 2}, status=200)
27
+ api.create_sample_sheet(vg_objects["sample_sheet"])
28
+ body = _last_json()
29
+ assert isinstance(body["sequencing_run"], str)
30
+
31
+ @responses.activate
32
+ def test_create_sequencing_data_builds_unaligned_reads(api, server, vg_objects):
33
+ url = f"{server}/seqauto/api/v1/sequencing_files/bulk_create"
34
+ responses.add(responses.POST, url, json={"created": 2}, status=200)
35
+ api.create_sequencing_data(vg_objects["sample_sheet_lookup"], vg_objects["sequencing_files"])
36
+ body = _last_json()
37
+ assert "records" in body and len(body["records"]) == 2
38
+ r0 = body["records"][0]
39
+ assert "unaligned_reads" in r0
40
+ assert "fastq_r1" in r0["unaligned_reads"] and "path" in r0["unaligned_reads"]["fastq_r1"]
41
+
42
+ def assert_post(api_call, url):
43
+ responses.add(responses.POST, url, json={"ok": True}, status=200)
44
+ out = api_call()
45
+ assert out == {"ok": True}
46
+ body = json.loads(responses.calls[-1].request.body)
47
+ assert body
48
+ return body
49
+
50
+ @responses.activate
51
+ def test_create_enrichment_kit_posts_json(api, server, vg_objects):
52
+ url = f"{server}/seqauto/api/v1/enrichment_kit/"
53
+ body = assert_post(
54
+ lambda: api.create_enrichment_kit(vg_objects["enrichment_kit"]),
55
+ url
56
+ )
57
+ assert body["name"] == "idt_haem"
58
+
59
+
60
+ @responses.activate
61
+ def test_create_sequencer_model_posts_json(api, server, vg_objects):
62
+ url = f"{server}/seqauto/api/v1/sequencer_model/"
63
+ body = assert_post(
64
+ lambda: api.create_sequencer_model(vg_objects["sequencer_model"]),
65
+ url
66
+ )
67
+ assert body["model"] == "HiSeq 2500"
68
+
69
+
70
+ @responses.activate
71
+ def test_create_sequencer_posts_json(api, server, vg_objects):
72
+ url = f"{server}/seqauto/api/v1/sequencer/"
73
+ body = assert_post(
74
+ lambda: api.create_sequencer(vg_objects["sequencer"]),
75
+ url
76
+ )
77
+ assert body["name"] == "SN1101"
78
+
79
+
80
+ @responses.activate
81
+ def test_create_sequencing_run_posts_json(api, server, vg_objects):
82
+ url = f"{server}/seqauto/api/v1/sequencing_run/"
83
+ body = assert_post(
84
+ lambda: api.create_sequencing_run(vg_objects["sequencing_run"]),
85
+ url
86
+ )
87
+ assert body["name"] == vg_objects["SEQUENCING_RUN_NAME"]
88
+
89
+
90
+ @responses.activate
91
+ def test_create_sample_sheet_combined_vcf_file_posts_json(api, server, vg_objects):
92
+ url = f"{server}/seqauto/api/v1/sample_sheet_combined_vcf_file/"
93
+ body = assert_post(
94
+ lambda: api.create_sample_sheet_combined_vcf_file(
95
+ vg_objects["sample_sheet_combined_vcf_file"]
96
+ ),
97
+ url
98
+ )
99
+ assert body["path"].endswith(".vcf.gz")
100
+
101
+
102
+ @responses.activate
103
+ def test_upload_file_calls_expected_url_and_query_param(api, server, vg_objects):
104
+ filename = vg_objects["qc_gene_coverage_list"][0].path
105
+ url = f"{server}/upload/api/v1/file_upload"
106
+ responses.add(responses.POST, url, json={"uploaded": True}, status=200)
107
+ out = api.upload_file(filename)
108
+ assert out == {"uploaded": True}
109
+ assert responses.calls[-1].request.url.startswith(url)
110
+ assert "path=" in responses.calls[-1].request.url
111
+
112
+
113
+ @responses.activate
114
+ def test_sequencing_run_has_vcf(api, server, vg_objects):
115
+ sequencing_run = vg_objects["sequencing_run"]
116
+ name = sequencing_run.name
117
+ url = f"{server}/seqauto/api/v1/sequencing_run/{name}/"
118
+ responses.add(responses.GET, url, json={"vcf_set":[{"path":"/x/a.vcf.gz"}]}, status=200)
119
+
120
+ assert api.sequencing_run_has_vcf(sequencing_run) is True
121
+ assert api.sequencing_run_has_vcf(sequencing_run, "a.vcf.gz") is True
122
+ assert api.sequencing_run_has_vcf(sequencing_run, "no_vcf_here") is False
123
+
124
+
125
+ @responses.activate
126
+ def test_create_qc_gene_list_posts(api, server, vg_objects):
127
+ url = f"{server}/seqauto/api/v1/qc_gene_list/"
128
+ responses.add(responses.POST, url, json={"ok": True}, status=200)
129
+
130
+ out = api.create_qc_gene_list(vg_objects["qc_gene_lists"][0])
131
+ assert out == {"ok": True}
132
+ body = _last_json()
133
+ assert "gene_list" in body and len(body["gene_list"]) > 0
134
+
135
+ @responses.activate
136
+ def test_create_qc_exec_stats_posts(api, server, vg_objects):
137
+ url = f"{server}/seqauto/api/v1/qc_exec_summary/"
138
+ responses.add(responses.POST, url, json={"ok": True}, status=200)
139
+
140
+ out = api.create_qc_exec_stats(vg_objects["qc_exec_stats"][0])
141
+ assert out == {"ok": True}
142
+ body = _last_json()
143
+ assert "reads" in body
144
+
145
+ import responses
146
+
147
+ @responses.activate
148
+ def test_log_request_emits_log(server, api_token, vg_objects, caplog):
149
+ logger = logging.getLogger("variantgrid_api.test")
150
+ caplog.set_level(logging.INFO, logger=logger.name)
151
+
152
+ api = VariantGridAPI(
153
+ server, api_token,
154
+ logger=logger,
155
+ log_request=True
156
+ )
157
+ url = f"{server}/seqauto/api/v1/experiment/"
158
+ responses.add(responses.POST, url, json={"ok": True}, status=200)
159
+
160
+ api.create_experiment(vg_objects["experiment"])
161
+
162
+ assert any("POST to" in r.message for r in caplog.records)
163
+
164
+
165
+
166
+ @responses.activate
167
+ def test_log_response_emits_log(server, api_token, vg_objects, caplog):
168
+ logger = logging.getLogger("variantgrid_api.test")
169
+ caplog.set_level(logging.INFO, logger=logger.name)
170
+
171
+ api = VariantGridAPI(
172
+ server, api_token,
173
+ logger=logger,
174
+ log_response=True
175
+ )
176
+ url = f"{server}/seqauto/api/v1/experiment/"
177
+ responses.add(responses.POST, url, json={"ok": True}, status=200)
178
+
179
+ api.create_experiment(vg_objects["experiment"])
180
+
181
+ assert any("Response from" in r.message for r in caplog.records)
182
+
183
+
184
+ @responses.activate
185
+ def test_bad_response_raises_and_logs(server, api_token, vg_objects, caplog):
186
+ logger = logging.getLogger("variantgrid_api.test")
187
+ caplog.set_level(logging.INFO, logger=logger.name)
188
+
189
+ api = VariantGridAPI(server, api_token, logger=logger)
190
+ url = f"{server}/seqauto/api/v1/experiment/"
191
+ responses.add(
192
+ responses.POST,
193
+ url,
194
+ json={"detail": "bad request"},
195
+ status=400
196
+ )
197
+
198
+ with pytest.raises(Exception):
199
+ api.create_experiment(vg_objects["experiment"])
200
+
201
+ assert any("Response:" in r.message for r in caplog.records)
202
+
203
+
204
+ @responses.activate
205
+ def test_bad_response_non_json_body(server, api_token, vg_objects):
206
+ api = VariantGridAPI(server, api_token)
207
+ url = f"{server}/seqauto/api/v1/experiment/"
208
+ responses.add(
209
+ responses.POST,
210
+ url,
211
+ body="not json",
212
+ status=500,
213
+ content_type="text/plain"
214
+ )
215
+
216
+ with pytest.raises(Exception):
217
+ api.create_experiment(vg_objects["experiment"])
218
+
219
+
220
+ def test_datetime_encoder_handles_date_and_rejects_unknown():
221
+ s = json.dumps(
222
+ {"d": datetime.date(2026, 1, 14)},
223
+ cls=DateTimeEncoder
224
+ )
225
+ assert s == '{"d": "2026-01-14"}'
226
+
227
+ with pytest.raises(TypeError):
228
+ json.dumps({"x": object()}, cls=DateTimeEncoder)
@@ -0,0 +1,46 @@
1
+ import json, responses
2
+
3
+ def assert_bulk_post(api_call, url, n):
4
+ responses.add(responses.POST, url, json={"ok": True}, status=200)
5
+ out = api_call()
6
+ assert out == {"ok": True}
7
+ body = json.loads(responses.calls[-1].request.body)
8
+ assert "records" in body
9
+ assert isinstance(body["records"], list)
10
+ assert len(body["records"]) == n
11
+ assert all(isinstance(r, dict) and r for r in body["records"])
12
+ return body["records"]
13
+
14
+
15
+ @responses.activate
16
+ def test_create_multiple_qc_gene_lists(api, server, vg_objects):
17
+ url = f"{server}/seqauto/api/v1/qc_gene_list/bulk_create"
18
+ records = assert_bulk_post(
19
+ lambda: api.create_multiple_qc_gene_lists(vg_objects["qc_gene_lists"]),
20
+ url,
21
+ n=len(vg_objects["qc_gene_lists"])
22
+ )
23
+ assert "gene_list" in records[0]
24
+ assert len(records[0]["gene_list"]) > 0
25
+
26
+
27
+ @responses.activate
28
+ def test_create_multiple_qc_exec_stats(api, server, vg_objects):
29
+ url = f"{server}/seqauto/api/v1/qc_exec_summary/bulk_create"
30
+ records = assert_bulk_post(
31
+ lambda: api.create_multiple_qc_exec_stats(vg_objects["qc_exec_stats"]),
32
+ url,
33
+ n=len(vg_objects["qc_exec_stats"])
34
+ )
35
+ assert "reads" in records[0]
36
+
37
+
38
+ @responses.activate
39
+ def test_create_multiple_qc_gene_coverage(api, server, vg_objects):
40
+ url = f"{server}/seqauto/api/v1/qc_gene_coverage/bulk_create"
41
+ records = assert_bulk_post(
42
+ lambda: api.create_multiple_qc_gene_coverage(vg_objects["qc_gene_coverage_list"]),
43
+ url,
44
+ n=len(vg_objects["qc_gene_coverage_list"])
45
+ )
46
+ assert "path" in records[0]
@@ -0,0 +1,75 @@
1
+ import pytest, responses
2
+ from variantgrid_api.api_client import VariantGridAPI, EmptyInputPolicy
3
+
4
+ import responses
5
+
6
+ def run_validation_call(api_call, url=None, method=responses.POST, response_json={"ok": True}):
7
+ if url:
8
+ responses.add(method, url, json=response_json, status=200)
9
+ return api_call()
10
+
11
+ @responses.activate
12
+ def test_error_policy_empty_list_raises(server, api_token):
13
+ api = VariantGridAPI(server, api_token, empty_input_policy=EmptyInputPolicy.ERROR)
14
+
15
+ with pytest.raises(ValueError):
16
+ run_validation_call(
17
+ lambda: api.create_multiple_qc_gene_lists([])
18
+ )
19
+
20
+ assert len(responses.calls) == 0
21
+
22
+ @responses.activate
23
+ def test_warn_policy_empty_list_logs_and_posts(server, api_token, caplog):
24
+ api = VariantGridAPI(server, api_token, empty_input_policy=EmptyInputPolicy.WARN)
25
+ url = f"{server}/seqauto/api/v1/qc_gene_list/bulk_create"
26
+
27
+ out = run_validation_call(
28
+ lambda: api.create_multiple_qc_gene_lists([]),
29
+ url=url
30
+ )
31
+
32
+ assert out == {"ok": True}
33
+ assert len(responses.calls) == 1
34
+ assert any("empty list" in r.message for r in caplog.records)
35
+
36
+ @responses.activate
37
+ def test_ignore_policy_empty_list_posts_silently(server, api_token, caplog):
38
+ api = VariantGridAPI(server, api_token, empty_input_policy=EmptyInputPolicy.IGNORE)
39
+ url = f"{server}/seqauto/api/v1/qc_gene_list/bulk_create"
40
+
41
+ out = run_validation_call(
42
+ lambda: api.create_multiple_qc_gene_lists([]),
43
+ url=url
44
+ )
45
+
46
+ assert out == {"ok": True}
47
+ assert len(responses.calls) == 1
48
+ assert not caplog.records
49
+
50
+
51
+ import pytest, responses
52
+ from variantgrid_api.api_client import VariantGridAPI, EmptyInputPolicy
53
+
54
+ @responses.activate
55
+ def test_validate_string_empty_raises(server, api_token):
56
+ api = VariantGridAPI(server, api_token, empty_input_policy=EmptyInputPolicy.ERROR)
57
+ with pytest.raises(ValueError):
58
+ api.create_experiment("")
59
+ assert len(responses.calls) == 0
60
+
61
+
62
+ @responses.activate
63
+ def test_validate_string_none_raises(server, api_token):
64
+ api = VariantGridAPI(server, api_token, empty_input_policy=EmptyInputPolicy.ERROR)
65
+ with pytest.raises(ValueError):
66
+ api.create_experiment(None)
67
+ assert len(responses.calls) == 0
68
+
69
+
70
+ @responses.activate
71
+ def test_validate_object_none_raises(server, api_token):
72
+ api = VariantGridAPI(server, api_token, empty_input_policy=EmptyInputPolicy.ERROR)
73
+ with pytest.raises(ValueError):
74
+ api.create_enrichment_kit(None)
75
+ assert len(responses.calls) == 0
@@ -0,0 +1,11 @@
1
+ from variantgrid_api.data_models import SequencingRun, SampleSheetLookup
2
+
3
+ def test_get_date_from_name(vg_objects):
4
+ d = SequencingRun.get_date_from_name(vg_objects["SEQUENCING_RUN_NAME"])
5
+ assert d is not None
6
+
7
+ def test_sample_sheet_lookup_from_sample_sheet(vg_objects):
8
+ ss = vg_objects["sample_sheet"]
9
+ ssl = SampleSheetLookup.from_sample_sheet(ss)
10
+ d = ssl.to_dict()
11
+ assert d
@@ -1 +0,0 @@
1
- # variantgrid_api
@@ -1,126 +0,0 @@
1
- import json
2
- import logging
3
- import os
4
- from typing import List
5
-
6
- import requests
7
-
8
- from variantgrid_api.data_models import EnrichmentKit, SequencingRun, SampleSheet, SampleSheetCombinedVCFFile, \
9
- SampleSheetLookup, SequencingFile, QCGeneList, QCExecStats, QCGeneCoverage
10
-
11
-
12
- class VariantGridAPI:
13
- def __init__(self, server, api_token):
14
- self.server = server
15
- self.headers = {"Authorization": f"Token {api_token}"}
16
-
17
- def _get_url(self, url):
18
- return os.path.join(self.server, url)
19
-
20
- def _post(self, path, json_data):
21
- url = self._get_url(path)
22
- response = requests.post(url, headers=self.headers, json=json_data)
23
- try:
24
- json_response = response.json()
25
- except Exception as e:
26
- json_response = f"Couldn't convert JSON: {e}"
27
- if not response.ok:
28
- logging.info("url='%s', JSON data:", url)
29
- logging.info(json.dumps(json_data))
30
- logging.info("Response: %s", json_response)
31
- response.raise_for_status()
32
- return json_response
33
-
34
- def create_experiment(self, experiment: str):
35
- json_data = {
36
- "name": experiment
37
- }
38
- return self._post("seqauto/api/v1/experiment/", json_data)
39
-
40
- def create_enrichment_kit(self, enrichment_kit: EnrichmentKit):
41
- return self._post("seqauto/api/v1/enrichment_kit/",
42
- enrichment_kit.to_dict())
43
-
44
- def create_sequencing_run(self, sequencing_run: SequencingRun):
45
- return self._post("seqauto/api/v1/sequencing_run/",
46
- sequencing_run.to_dict())
47
-
48
- def create_sample_sheet(self, sample_sheet: SampleSheet):
49
- json_data = sample_sheet.to_dict()
50
- # We don't want all sequencing_run just the name
51
- sequencing_run = json_data.pop("sequencing_run")
52
- json_data["sequencing_run"] = sequencing_run["name"]
53
- return self._post("seqauto/api/v1/sample_sheet/",
54
- json_data)
55
-
56
- def create_sample_sheet_combined_vcf_file(self, sample_sheet_combined_vcf_file: SampleSheetCombinedVCFFile):
57
- json_data = sample_sheet_combined_vcf_file.to_dict()
58
- return self._post("seqauto/api/v1/sample_sheet_combined_vcf_file/",
59
- json_data)
60
-
61
- def create_sequencing_data(self, sample_sheet_lookup: SampleSheetLookup, sequencing_files: List[SequencingFile]):
62
- records = []
63
- for sf in sequencing_files:
64
- data = sf.to_dict()
65
- # put into hierarchial JSON DRF expects
66
- fastq_r1 = data.pop("fastq_r1")
67
- fastq_r2 = data.pop("fastq_r2")
68
- data["unaligned_reads"] = {
69
- "fastq_r1": {"path": fastq_r1},
70
- "fastq_r2": {"path": fastq_r2}
71
- }
72
- records.append(data)
73
-
74
- json_data = {
75
- "sample_sheet": sample_sheet_lookup.to_dict(),
76
- "records": records
77
- }
78
- return self._post("seqauto/api/v1/sequencing_files/bulk_create",
79
- json_data)
80
-
81
- def create_qc_gene_list(self, qc_gene_list: QCGeneList):
82
- json_data = qc_gene_list.to_dict()
83
- return self._post("seqauto/api/v1/qc_gene_list/",
84
- json_data)
85
-
86
-
87
- def create_multiple_qc_gene_lists(self, qc_gene_lists: List[QCGeneList]):
88
- json_data = {
89
- "records": [
90
- qcgl.to_dict() for qcgl in qc_gene_lists
91
- ]
92
- }
93
- return self._post("seqauto/api/v1/qc_gene_list/bulk_create",
94
- json_data)
95
-
96
- def create_qc_exec_stats(self, qc_exec_stats: QCExecStats):
97
- json_data = qc_exec_stats.to_dict()
98
- return self._post("seqauto/api/v1/qc_exec_summary/",
99
- json_data)
100
-
101
- def create_multiple_qc_exec_stats(self, qc_exec_stats: List[QCExecStats]):
102
- json_data = {
103
- "records": [
104
- qces.to_dict() for qces in qc_exec_stats
105
- ]
106
- }
107
- return self._post("seqauto/api/v1/qc_exec_summary/bulk_create",
108
- json_data)
109
-
110
- def create_multiple_qc_gene_coverage(self, qc_gene_coverage_list: List[QCGeneCoverage]):
111
- json_data = {
112
- "records": [
113
- qcgc.to_dict() for qcgc in qc_gene_coverage_list
114
- ]
115
- }
116
- return self._post("seqauto/api/v1/qc_gene_coverage/bulk_create",
117
- json_data)
118
-
119
- def upload_file(self, filename: str):
120
- url = self._get_url("upload/api/v1/file_upload")
121
- with open(filename, "rb") as f:
122
- kwargs = {
123
- "files": {"file": f},
124
- "params": {"path": filename}
125
- }
126
- return requests.post(url, headers=self.headers, **kwargs)
@@ -1,2 +0,0 @@
1
- requests
2
- dataclasses-json
File without changes