eva-sub-cli 0.4.4__py3-none-any.whl
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.
- eva_sub_cli/__init__.py +27 -0
- eva_sub_cli/_version.py +21 -0
- eva_sub_cli/auth.py +106 -0
- eva_sub_cli/date_utils.py +32 -0
- eva_sub_cli/etc/EVA_Submission_template.xlsx +0 -0
- eva_sub_cli/etc/eva-biosamples.json +174 -0
- eva_sub_cli/etc/eva_logo.png +0 -0
- eva_sub_cli/etc/eva_schema.json +369 -0
- eva_sub_cli/etc/spreadsheet2json_conf.yaml +135 -0
- eva_sub_cli/exceptions/__init__.py +0 -0
- eva_sub_cli/exceptions/invalid_file_type_exception.py +4 -0
- eva_sub_cli/exceptions/submission_not_found_exception.py +4 -0
- eva_sub_cli/exceptions/submission_status_exception.py +5 -0
- eva_sub_cli/executables/__init__.py +0 -0
- eva_sub_cli/executables/check_fasta_insdc.py +159 -0
- eva_sub_cli/executables/check_metadata_semantics.py +18 -0
- eva_sub_cli/executables/cli.py +115 -0
- eva_sub_cli/executables/samples_checker.py +144 -0
- eva_sub_cli/executables/trim_down.py +78 -0
- eva_sub_cli/executables/xlsx2json.py +335 -0
- eva_sub_cli/file_utils.py +117 -0
- eva_sub_cli/jinja_templates/fasta_check.html +86 -0
- eva_sub_cli/jinja_templates/file_validation.html +93 -0
- eva_sub_cli/jinja_templates/html_report.html +142 -0
- eva_sub_cli/jinja_templates/metadata_validation.html +52 -0
- eva_sub_cli/jinja_templates/project_details.html +37 -0
- eva_sub_cli/jinja_templates/sample_name_check.html +60 -0
- eva_sub_cli/jinja_templates/shallow_validation.html +29 -0
- eva_sub_cli/metadata_utils.py +46 -0
- eva_sub_cli/nextflow/validation.nf +303 -0
- eva_sub_cli/orchestrator.py +233 -0
- eva_sub_cli/report.py +40 -0
- eva_sub_cli/semantic_metadata.py +243 -0
- eva_sub_cli/submission_ws.py +61 -0
- eva_sub_cli/submit.py +111 -0
- eva_sub_cli/validators/__init__.py +0 -0
- eva_sub_cli/validators/docker_validator.py +205 -0
- eva_sub_cli/validators/native_validator.py +68 -0
- eva_sub_cli/validators/validation_results_parsers.py +210 -0
- eva_sub_cli/validators/validator.py +510 -0
- eva_sub_cli-0.4.4.dist-info/METADATA +164 -0
- eva_sub_cli-0.4.4.dist-info/RECORD +46 -0
- eva_sub_cli-0.4.4.dist-info/WHEEL +5 -0
- eva_sub_cli-0.4.4.dist-info/entry_points.txt +7 -0
- eva_sub_cli-0.4.4.dist-info/licenses/LICENSE +201 -0
- eva_sub_cli-0.4.4.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import datetime
|
|
3
|
+
import json
|
|
4
|
+
from json import JSONEncoder
|
|
5
|
+
|
|
6
|
+
import openpyxl
|
|
7
|
+
import yaml
|
|
8
|
+
from ebi_eva_common_pyutils.logger import logging_config
|
|
9
|
+
from openpyxl.reader.excel import load_workbook
|
|
10
|
+
from openpyxl.workbook import Workbook
|
|
11
|
+
|
|
12
|
+
WORKSHEETS_KEY_NAME = 'worksheets'
|
|
13
|
+
REQUIRED_HEADERS_KEY_NAME = 'required'
|
|
14
|
+
OPTIONAL_HEADERS_KEY_NAME = 'optional'
|
|
15
|
+
HEADERS_KEY_ROW = 'header_row'
|
|
16
|
+
CAST_KEY_NAME = 'cast'
|
|
17
|
+
PROJECT = 'Project'
|
|
18
|
+
SAMPLE = 'Sample'
|
|
19
|
+
ANALYSIS_ALIAS_KEY = 'Analysis Alias'
|
|
20
|
+
SAMPLE_NAME_IN_VCF_KEY = 'Sample Name in VCF'
|
|
21
|
+
SAMPLE_ACCESSION_KEY = 'Sample Accession'
|
|
22
|
+
SAMPLE_NAME_KEY = 'BioSample Name'
|
|
23
|
+
SCIENTIFIC_NAME_KEY = 'Scientific Name'
|
|
24
|
+
SPECIES = 'species'
|
|
25
|
+
|
|
26
|
+
logger = logging_config.get_logger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class DateTimeEncoder(JSONEncoder):
|
|
30
|
+
# Override the default method
|
|
31
|
+
def default(self, obj):
|
|
32
|
+
if isinstance(obj, (datetime.date, datetime.datetime)):
|
|
33
|
+
return obj.isoformat()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class XlsxParser:
|
|
37
|
+
"""
|
|
38
|
+
Base parser for Excel file for the fields from worksheets defined in a configuration file.
|
|
39
|
+
It implements the base functionality for opening and validating the spreadsheet.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, xlsx_filename, conf_filename):
|
|
43
|
+
"""
|
|
44
|
+
Constructor
|
|
45
|
+
:param xlsx_filename: Excel file path
|
|
46
|
+
:type xlsx_filename: basestring
|
|
47
|
+
:param conf_filename: configuration file path
|
|
48
|
+
:type conf_filename: basestring
|
|
49
|
+
"""
|
|
50
|
+
self.errors = []
|
|
51
|
+
with open(conf_filename, 'r') as conf_file:
|
|
52
|
+
self.xlsx_conf = yaml.safe_load(conf_file)
|
|
53
|
+
try:
|
|
54
|
+
self.workbook = load_workbook(xlsx_filename, read_only=True)
|
|
55
|
+
except Exception as e:
|
|
56
|
+
self.add_error(f'Error loading {xlsx_filename}: {repr(e)}')
|
|
57
|
+
self.file_loaded = False
|
|
58
|
+
return
|
|
59
|
+
self.worksheets = []
|
|
60
|
+
self._active_worksheet = None
|
|
61
|
+
self.row_offset = {}
|
|
62
|
+
self.headers = {}
|
|
63
|
+
self.file_loaded = True
|
|
64
|
+
self.valid_worksheets()
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def active_worksheet(self):
|
|
68
|
+
return self._active_worksheet
|
|
69
|
+
|
|
70
|
+
@active_worksheet.setter
|
|
71
|
+
def active_worksheet(self, worksheet):
|
|
72
|
+
if self.worksheets is None:
|
|
73
|
+
self.valid_worksheets()
|
|
74
|
+
if worksheet not in self.worksheets:
|
|
75
|
+
self.add_error(f'Tried to access an invalid worksheet {worksheet}', sheet=worksheet)
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
self._active_worksheet = worksheet
|
|
79
|
+
|
|
80
|
+
def valid_worksheets(self):
|
|
81
|
+
"""
|
|
82
|
+
Get the list of the names of worksheets which have the expected title and header row.
|
|
83
|
+
"""
|
|
84
|
+
sheet_titles = self.workbook.sheetnames
|
|
85
|
+
|
|
86
|
+
for title in self.xlsx_conf[WORKSHEETS_KEY_NAME]:
|
|
87
|
+
# Check worksheet exists
|
|
88
|
+
if title not in sheet_titles:
|
|
89
|
+
continue
|
|
90
|
+
|
|
91
|
+
# Check number of rows
|
|
92
|
+
worksheet = self.workbook[title]
|
|
93
|
+
header_row = self.xlsx_conf[title].get(HEADERS_KEY_ROW, 1)
|
|
94
|
+
if worksheet.max_row < header_row + 1:
|
|
95
|
+
continue
|
|
96
|
+
# Store headers and worksheet title
|
|
97
|
+
self.headers[title] = [cell.value if cell.value is None else cell.value.strip()
|
|
98
|
+
for cell in worksheet[header_row]]
|
|
99
|
+
self.worksheets.append(title)
|
|
100
|
+
|
|
101
|
+
@staticmethod
|
|
102
|
+
def cast_value(value, type_name):
|
|
103
|
+
# Do not cast None values
|
|
104
|
+
if type_name and value is not None:
|
|
105
|
+
if type_name == 'string':
|
|
106
|
+
return str(value)
|
|
107
|
+
if type_name == 'boolean':
|
|
108
|
+
return str(value).lower() in ['true', '1', 't', 'y', 'yes']
|
|
109
|
+
if type_name == 'list':
|
|
110
|
+
# split and remove empty values
|
|
111
|
+
return [XlsxParser.trim_value(v)
|
|
112
|
+
for v in value.split(',') if v]
|
|
113
|
+
if type_name == 'date':
|
|
114
|
+
return XlsxParser.serialize(value)
|
|
115
|
+
return value
|
|
116
|
+
|
|
117
|
+
@staticmethod
|
|
118
|
+
def trim_value(value):
|
|
119
|
+
"""Remove whitespace from the start and end of cells"""
|
|
120
|
+
if isinstance(value, str):
|
|
121
|
+
return value.strip()
|
|
122
|
+
return value
|
|
123
|
+
|
|
124
|
+
def base_row_offset(self, worksheet):
|
|
125
|
+
return self.xlsx_conf[worksheet].get(HEADERS_KEY_ROW, 1)
|
|
126
|
+
|
|
127
|
+
def get_rows(self):
|
|
128
|
+
"""
|
|
129
|
+
Retrieve all the data rows.
|
|
130
|
+
:return: list of hash containing all the REQUIRED and OPTIONAL fields as keys
|
|
131
|
+
and the corresponding data as values
|
|
132
|
+
:rtype: list
|
|
133
|
+
"""
|
|
134
|
+
worksheet = self.active_worksheet
|
|
135
|
+
if worksheet is None:
|
|
136
|
+
logger.warning('No worksheet is specified!')
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
if worksheet not in self.row_offset:
|
|
140
|
+
self.row_offset[worksheet] = self.base_row_offset(worksheet)
|
|
141
|
+
self.row_offset[worksheet] += 1
|
|
142
|
+
|
|
143
|
+
required_headers = self.xlsx_conf[worksheet].get(REQUIRED_HEADERS_KEY_NAME, {})
|
|
144
|
+
optional_headers = self.xlsx_conf[worksheet].get(OPTIONAL_HEADERS_KEY_NAME, {})
|
|
145
|
+
|
|
146
|
+
rows = []
|
|
147
|
+
|
|
148
|
+
for row in self.workbook[worksheet].iter_rows(min_row=self.row_offset[worksheet]):
|
|
149
|
+
num_cells = 0
|
|
150
|
+
for cell in row:
|
|
151
|
+
num_cells += 1
|
|
152
|
+
|
|
153
|
+
data = {}
|
|
154
|
+
has_notnull = False
|
|
155
|
+
for header in list(required_headers.keys()) + list(optional_headers.keys()):
|
|
156
|
+
header_index = num_cells
|
|
157
|
+
if header in self.headers[worksheet]:
|
|
158
|
+
header_index = self.headers[worksheet].index(header)
|
|
159
|
+
if header_index >= num_cells:
|
|
160
|
+
data[header] = None
|
|
161
|
+
continue
|
|
162
|
+
|
|
163
|
+
cell = row[header_index]
|
|
164
|
+
if cell.value is not None:
|
|
165
|
+
has_notnull = True
|
|
166
|
+
data[header] = self.trim_value(self.cast_value(
|
|
167
|
+
cell.value, self.xlsx_conf[worksheet].get(CAST_KEY_NAME, {}).get(header)
|
|
168
|
+
))
|
|
169
|
+
|
|
170
|
+
if has_notnull:
|
|
171
|
+
data['row_num'] = self.row_offset[worksheet]
|
|
172
|
+
rows.append(data)
|
|
173
|
+
|
|
174
|
+
# no data on this row, continue to next
|
|
175
|
+
self.row_offset[worksheet] += 1
|
|
176
|
+
|
|
177
|
+
return rows
|
|
178
|
+
|
|
179
|
+
def get_project_json_data(self):
|
|
180
|
+
project_data = self.get_rows()
|
|
181
|
+
if len(project_data) > 1:
|
|
182
|
+
logger.warning(f"{PROJECT} worksheet expects a single row of info but more than one found in the file. "
|
|
183
|
+
f"Only the first row's data will be taken into consideration")
|
|
184
|
+
|
|
185
|
+
first_row = project_data[0]
|
|
186
|
+
first_row.pop('row_num')
|
|
187
|
+
|
|
188
|
+
json_key = self.xlsx_conf[WORKSHEETS_KEY_NAME][PROJECT]
|
|
189
|
+
json_value = {self.translate_header(PROJECT, k): v for k, v in first_row.items() if v is not None}
|
|
190
|
+
return {json_key: json_value}
|
|
191
|
+
|
|
192
|
+
def get_biosample_object(self, data):
|
|
193
|
+
sample_name = self.xlsx_conf[SAMPLE][OPTIONAL_HEADERS_KEY_NAME][SAMPLE_NAME_KEY]
|
|
194
|
+
scientific_name = self.xlsx_conf[SAMPLE][OPTIONAL_HEADERS_KEY_NAME][SCIENTIFIC_NAME_KEY]
|
|
195
|
+
|
|
196
|
+
# BioSample expects any of organism or species field
|
|
197
|
+
if scientific_name in data:
|
|
198
|
+
data[SPECIES] = data[scientific_name]
|
|
199
|
+
# BioSample name goes in its own attribute, not part of characteristics
|
|
200
|
+
biosample_name = data.pop(sample_name, None)
|
|
201
|
+
|
|
202
|
+
# For all characteristics, BioSample expects value in arrays of objects
|
|
203
|
+
biosample_object = {
|
|
204
|
+
'characteristics': {k: [{'text': self.serialize(v)}] for k, v in data.items()}
|
|
205
|
+
}
|
|
206
|
+
if biosample_name is not None:
|
|
207
|
+
biosample_object['name'] = biosample_name
|
|
208
|
+
|
|
209
|
+
return biosample_object
|
|
210
|
+
|
|
211
|
+
@staticmethod
|
|
212
|
+
def serialize(value):
|
|
213
|
+
"""Create a text representation of the value provided"""
|
|
214
|
+
if isinstance(value, datetime.date):
|
|
215
|
+
return value.strftime('%Y-%m-%d')
|
|
216
|
+
return str(value)
|
|
217
|
+
|
|
218
|
+
def get_sample_data_with_split_analysis_alias(self, data, analysis_alias, sample_name_in_vcf):
|
|
219
|
+
analysis_alias_list = data[analysis_alias].split(',')
|
|
220
|
+
sample_in_vcf_val = data[sample_name_in_vcf]
|
|
221
|
+
return {analysis_alias: analysis_alias_list, sample_name_in_vcf: sample_in_vcf_val}
|
|
222
|
+
|
|
223
|
+
def get_sample_json_data(self):
|
|
224
|
+
json_key = self.xlsx_conf[WORKSHEETS_KEY_NAME][SAMPLE]
|
|
225
|
+
sample_json = {json_key: []}
|
|
226
|
+
for row in self.get_rows():
|
|
227
|
+
row_num = row.pop('row_num')
|
|
228
|
+
json_value = {self.translate_header(SAMPLE, k): v for k, v in row.items() if v is not None}
|
|
229
|
+
bio_sample_acc = self.xlsx_conf[SAMPLE][OPTIONAL_HEADERS_KEY_NAME][SAMPLE_ACCESSION_KEY]
|
|
230
|
+
|
|
231
|
+
analysis_alias = self.xlsx_conf[SAMPLE][REQUIRED_HEADERS_KEY_NAME][ANALYSIS_ALIAS_KEY]
|
|
232
|
+
sample_name_in_vcf = self.xlsx_conf[SAMPLE][REQUIRED_HEADERS_KEY_NAME][SAMPLE_NAME_IN_VCF_KEY]
|
|
233
|
+
sample_data = self.get_sample_data_with_split_analysis_alias(json_value, analysis_alias, sample_name_in_vcf)
|
|
234
|
+
|
|
235
|
+
if bio_sample_acc in json_value and json_value[bio_sample_acc]:
|
|
236
|
+
sample_data.update(bioSampleAccession=json_value[bio_sample_acc])
|
|
237
|
+
sample_json[json_key].append(sample_data)
|
|
238
|
+
else:
|
|
239
|
+
json_value.pop(analysis_alias)
|
|
240
|
+
json_value.pop(sample_name_in_vcf)
|
|
241
|
+
|
|
242
|
+
biosample_obj = self.get_biosample_object(json_value)
|
|
243
|
+
sample_data.update(bioSampleObject=biosample_obj)
|
|
244
|
+
sample_json[json_key].append(sample_data)
|
|
245
|
+
|
|
246
|
+
return sample_json
|
|
247
|
+
|
|
248
|
+
def json(self, output_json_file):
|
|
249
|
+
# If the file could not be loaded at all, return without generating JSON.
|
|
250
|
+
if not self.file_loaded:
|
|
251
|
+
return
|
|
252
|
+
json_data = {}
|
|
253
|
+
for title in self.xlsx_conf[WORKSHEETS_KEY_NAME]:
|
|
254
|
+
self.active_worksheet = title
|
|
255
|
+
if title == PROJECT:
|
|
256
|
+
json_data.update(self.get_project_json_data())
|
|
257
|
+
elif title == SAMPLE:
|
|
258
|
+
sample_data = self.get_sample_json_data()
|
|
259
|
+
json_data.update(sample_data)
|
|
260
|
+
else:
|
|
261
|
+
json_data[self.xlsx_conf[WORKSHEETS_KEY_NAME][title]] = []
|
|
262
|
+
for row in self.get_rows():
|
|
263
|
+
# Remove the row number
|
|
264
|
+
row.pop('row_num')
|
|
265
|
+
# Remove any None and translate header name
|
|
266
|
+
json_data[self.xlsx_conf[WORKSHEETS_KEY_NAME][title]].append(
|
|
267
|
+
{self.translate_header(title, k): v for k, v in row.items() if v is not None}
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
with open(output_json_file, 'w') as open_file:
|
|
271
|
+
json.dump(json_data, open_file, cls=DateTimeEncoder)
|
|
272
|
+
|
|
273
|
+
def translate_header(self, title, header):
|
|
274
|
+
if header in self.xlsx_conf[title].get(REQUIRED_HEADERS_KEY_NAME, {}):
|
|
275
|
+
return self.xlsx_conf[title][REQUIRED_HEADERS_KEY_NAME][header]
|
|
276
|
+
if header in self.xlsx_conf[title].get(OPTIONAL_HEADERS_KEY_NAME, {}):
|
|
277
|
+
return self.xlsx_conf[title][OPTIONAL_HEADERS_KEY_NAME][header]
|
|
278
|
+
logger.warning(
|
|
279
|
+
f'Header {header} in {title} sheet does not have translation in the config file. Leave it as is')
|
|
280
|
+
return header
|
|
281
|
+
|
|
282
|
+
def add_error(self, message, sheet='', row='', column=''):
|
|
283
|
+
"""Adds a conversion error using the same structure as other validation errors,
|
|
284
|
+
and marks the spreadsheet as invalid."""
|
|
285
|
+
self.errors.append({'sheet': sheet, 'row': row, 'column': column, 'description': message})
|
|
286
|
+
|
|
287
|
+
def save_errors(self, errors_yaml_file):
|
|
288
|
+
with open(errors_yaml_file, 'w') as open_file:
|
|
289
|
+
yaml.safe_dump(self.errors, stream=open_file)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def create_xls_template_from_yaml(xlsx_filename, conf_filename):
|
|
293
|
+
"""
|
|
294
|
+
Create a XLS empty template with the expected worksheet and header based on the configuration provided
|
|
295
|
+
"""
|
|
296
|
+
with open(conf_filename, 'r') as conf_file:
|
|
297
|
+
xlsx_conf = yaml.safe_load(conf_file)
|
|
298
|
+
workbook = Workbook()
|
|
299
|
+
workbook.remove(workbook.active)
|
|
300
|
+
|
|
301
|
+
for title in xlsx_conf[WORKSHEETS_KEY_NAME]:
|
|
302
|
+
# Create worksheet
|
|
303
|
+
workbook.create_sheet(title)
|
|
304
|
+
# For each Worksheet create the header row
|
|
305
|
+
header_row = xlsx_conf[title].get(HEADERS_KEY_ROW, 1)
|
|
306
|
+
header_col_index = 0
|
|
307
|
+
for header_name in xlsx_conf[title].get(REQUIRED_HEADERS_KEY_NAME, []):
|
|
308
|
+
header_col_index += 1
|
|
309
|
+
cell = workbook[title].cell(column=header_col_index, row=header_row, value=header_name)
|
|
310
|
+
cell.font = openpyxl.styles.Font(bold=True)
|
|
311
|
+
for header_name in xlsx_conf[title].get(OPTIONAL_HEADERS_KEY_NAME, []):
|
|
312
|
+
header_col_index += 1
|
|
313
|
+
workbook[title].cell(column=header_col_index, row=header_row, value=header_name)
|
|
314
|
+
workbook.save(xlsx_filename)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def main():
|
|
318
|
+
arg_parser = argparse.ArgumentParser(
|
|
319
|
+
description='Convert an xlsx spreadsheet containing expected sheets to json prior to validation')
|
|
320
|
+
arg_parser.add_argument('--metadata_json', required=True, dest='metadata_json',
|
|
321
|
+
help='Path to output EVA metadata json file')
|
|
322
|
+
arg_parser.add_argument('--metadata_xlsx', required=True, dest='metadata_xlsx', help='EVA metadata Excel file')
|
|
323
|
+
arg_parser.add_argument('--conversion_configuration', dest='conversion_configuration',
|
|
324
|
+
help='Configuration file describing the expected content of the Excel spreadsheet')
|
|
325
|
+
arg_parser.add_argument('--errors_yaml', required=True, dest='errors_yaml', help='Path to output errors as YAML')
|
|
326
|
+
|
|
327
|
+
args = arg_parser.parse_args()
|
|
328
|
+
logging_config.add_stdout_handler()
|
|
329
|
+
parser = XlsxParser(args.metadata_xlsx, args.conversion_configuration)
|
|
330
|
+
try:
|
|
331
|
+
parser.json(args.metadata_json)
|
|
332
|
+
except Exception as e:
|
|
333
|
+
parser.add_error(f'An Error was raised while converting the spreadsheet to JSON: {repr(e)}')
|
|
334
|
+
finally:
|
|
335
|
+
parser.save_errors(args.errors_yaml)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import glob
|
|
2
|
+
import gzip
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import time
|
|
6
|
+
from itertools import groupby
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def resolve_single_file_path(file_path):
|
|
10
|
+
files = glob.glob(file_path)
|
|
11
|
+
if len(files) == 0:
|
|
12
|
+
return None
|
|
13
|
+
elif len(files) > 0:
|
|
14
|
+
return files[0]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def is_submission_dir_writable(submission_dir):
|
|
18
|
+
if not os.path.exists(submission_dir):
|
|
19
|
+
os.makedirs(submission_dir)
|
|
20
|
+
if not os.path.isdir(submission_dir):
|
|
21
|
+
return False
|
|
22
|
+
if not os.access(submission_dir, os.W_OK):
|
|
23
|
+
return False
|
|
24
|
+
return True
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def backup_file_or_directory(file_name, max_backups=None):
|
|
28
|
+
"""
|
|
29
|
+
Rename a file or directory by adding a '.1' at the end. If the '.1' file exists it move it to a '.2' and so on.
|
|
30
|
+
Keep at most the specified number of backups, if None will keep all.
|
|
31
|
+
"""
|
|
32
|
+
suffix = 1
|
|
33
|
+
backup_name = f'{file_name}.{suffix}'
|
|
34
|
+
while os.path.exists(backup_name):
|
|
35
|
+
suffix += 1
|
|
36
|
+
backup_name = f'{file_name}.{suffix}'
|
|
37
|
+
|
|
38
|
+
for i in range(suffix, 1, -1):
|
|
39
|
+
if max_backups and i > max_backups:
|
|
40
|
+
if os.path.isfile(file_name):
|
|
41
|
+
os.remove(f'{file_name}.{i - 1}')
|
|
42
|
+
else:
|
|
43
|
+
shutil.rmtree(f'{file_name}.{i - 1}')
|
|
44
|
+
else:
|
|
45
|
+
os.rename(f'{file_name}.{i - 1}', f'{file_name}.{i}')
|
|
46
|
+
os.rename(file_name, file_name + '.1')
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def open_gzip_if_required(input_file):
|
|
50
|
+
"""Open a file in read mode using gzip if the file extension says .gz"""
|
|
51
|
+
if input_file.endswith('.gz'):
|
|
52
|
+
return gzip.open(input_file, 'rt')
|
|
53
|
+
else:
|
|
54
|
+
return open(input_file, 'r')
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def fasta_iter(input_fasta):
|
|
58
|
+
"""
|
|
59
|
+
Given a fasta file. yield tuples of header, sequence
|
|
60
|
+
"""
|
|
61
|
+
# first open the file outside
|
|
62
|
+
with open_gzip_if_required(input_fasta) as open_file:
|
|
63
|
+
# ditch the boolean (x[0]) and just keep the header or sequence since
|
|
64
|
+
# we know they alternate.
|
|
65
|
+
faiter = (x[1] for x in groupby(open_file, lambda line: line[0] == ">"))
|
|
66
|
+
|
|
67
|
+
for header in faiter:
|
|
68
|
+
# drop the ">"
|
|
69
|
+
headerStr = header.__next__()[1:].strip()
|
|
70
|
+
|
|
71
|
+
# join all sequence lines to one.
|
|
72
|
+
seq = "".join(s.strip() for s in faiter.__next__())
|
|
73
|
+
yield (headerStr, seq)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class DirLockError(Exception):
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class DirLock(object):
|
|
81
|
+
|
|
82
|
+
_SPIN_PERIOD_SECONDS = 0.05
|
|
83
|
+
|
|
84
|
+
def __init__(self, dirname, timeout=3):
|
|
85
|
+
"""Prepare a file lock to protect access to dirname. timeout is the
|
|
86
|
+
period (in seconds) after an acquisition attempt is aborted.
|
|
87
|
+
The directory must exist, otherwise aquire() will timeout.
|
|
88
|
+
"""
|
|
89
|
+
self._lockfilename = os.path.join(dirname, ".lock")
|
|
90
|
+
self._timeout = timeout
|
|
91
|
+
|
|
92
|
+
def acquire(self):
|
|
93
|
+
start_time = time.time()
|
|
94
|
+
while True:
|
|
95
|
+
try:
|
|
96
|
+
# O_EXCL: fail if file exists or create it (atomically)
|
|
97
|
+
os.close(os.open(self._lockfilename,
|
|
98
|
+
os.O_CREAT | os.O_EXCL | os.O_RDWR))
|
|
99
|
+
break
|
|
100
|
+
except OSError:
|
|
101
|
+
if (time.time() - start_time) > self._timeout:
|
|
102
|
+
raise DirLockError(f"could not create {self._lockfilename} after {self._timeout} seconds")
|
|
103
|
+
else:
|
|
104
|
+
time.sleep(self._SPIN_PERIOD_SECONDS)
|
|
105
|
+
|
|
106
|
+
def release(self):
|
|
107
|
+
try:
|
|
108
|
+
os.remove(self._lockfilename)
|
|
109
|
+
except OSError:
|
|
110
|
+
pass
|
|
111
|
+
|
|
112
|
+
def __enter__(self):
|
|
113
|
+
self.acquire()
|
|
114
|
+
return self
|
|
115
|
+
|
|
116
|
+
def __exit__(self, type_, value, traceback):
|
|
117
|
+
self.release()
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
|
|
2
|
+
{% macro fasta_check_report(validation_results, file_name) -%}
|
|
3
|
+
{% set results_for_fasta = validation_results['fasta_check'][file_name] %}
|
|
4
|
+
|
|
5
|
+
{% if results_for_fasta.get('connection_error') %}
|
|
6
|
+
<div class="report-section fail">
|
|
7
|
+
<strong>Warning:</strong> The following results may be incomplete due to problems with external services. Please try again later for
|
|
8
|
+
complete results.
|
|
9
|
+
<br><strong>Error message:</strong> {{ results_for_fasta.get('connection_error') }}
|
|
10
|
+
</div>
|
|
11
|
+
{% endif %}
|
|
12
|
+
|
|
13
|
+
<!-- All INSDC check results -->
|
|
14
|
+
{% set expand_icon = "" %}
|
|
15
|
+
{% if results_for_fasta.get('all_insdc') %}
|
|
16
|
+
{% set icon = "✔" %}
|
|
17
|
+
{% set row_class = "report-section pass" %}
|
|
18
|
+
{% set text = "All sequences are INSDC accessioned" %}
|
|
19
|
+
{% else %}
|
|
20
|
+
{% set expand_icon = "▶" %}
|
|
21
|
+
{% set icon = "❌" %}
|
|
22
|
+
{% set row_class = "report-section fail collapsible" %}
|
|
23
|
+
{% set text = "Some sequences are not INSDC accessioned" %}
|
|
24
|
+
{% endif %}
|
|
25
|
+
<div class='{{ row_class }}'><span class="expand_icon">{{ expand_icon }}</span> {{ icon }} {{ text }} </div>
|
|
26
|
+
{% if not results_for_fasta.get('all_insdc') %}
|
|
27
|
+
<div class="error-list">
|
|
28
|
+
<div class="error-description">First 10 sequences not in INSDC. <strong>Full report:</strong> {{ results_for_fasta.get('report_path', '') }}</div>
|
|
29
|
+
<table>
|
|
30
|
+
<tr>
|
|
31
|
+
<th>Sequence name</th><th>Refget md5</th>
|
|
32
|
+
</tr>
|
|
33
|
+
{% set sequence_info_list = results_for_fasta.get('sequences', [])|rejectattr("insdc")|list %}
|
|
34
|
+
{% for sequence_info in sequence_info_list[:10] %}
|
|
35
|
+
<tr>
|
|
36
|
+
<td><strong>{{sequence_info.get('sequence_name') }}</strong></td><td> {{ sequence_info.get('sequence_md5') }}</td>
|
|
37
|
+
</tr>
|
|
38
|
+
{% endfor %}
|
|
39
|
+
</table>
|
|
40
|
+
</div>
|
|
41
|
+
{% endif %}
|
|
42
|
+
|
|
43
|
+
<!-- INSDC concordance check results (optional) -->
|
|
44
|
+
{% if 'possible_assemblies' in results_for_fasta %}
|
|
45
|
+
{% if 'metadata_assembly_compatible' in results_for_fasta %}
|
|
46
|
+
<!-- found assembly in metadata, so definitely know the analysis -->
|
|
47
|
+
{% set analysis_text = results_for_fasta.get('associated_analyses')|join(", ") %}
|
|
48
|
+
{% set expand_icon = "" %}
|
|
49
|
+
{% if results_for_fasta.get('metadata_assembly_compatible') %}
|
|
50
|
+
{% set icon = "✔" %}
|
|
51
|
+
{% set row_class = "report-section pass" %}
|
|
52
|
+
{% set text = analysis_text + ": Assembly accession in metadata is compatible" %}
|
|
53
|
+
{% else %}
|
|
54
|
+
{% set expand_icon = "▶" %}
|
|
55
|
+
{% set icon = "❌" %}
|
|
56
|
+
{% set row_class = "report-section fail collapsible" %}
|
|
57
|
+
{% set text = analysis_text + ": Assembly accession in metadata is not compatible" %}
|
|
58
|
+
{% endif %}
|
|
59
|
+
{% else %}
|
|
60
|
+
<!-- has possible assemblies but no metadata assembly -->
|
|
61
|
+
{% set expand_icon = "▶" %}
|
|
62
|
+
{% set icon = "❌" %}
|
|
63
|
+
{% set row_class = "report-section fail collapsible" %}
|
|
64
|
+
{% set text = "No assembly accession found in metadata" %}
|
|
65
|
+
{% endif %}
|
|
66
|
+
<div class='{{ row_class }}'><span class="expand_icon">{{ expand_icon }}</span> {{ icon }} {{ text }} </div>
|
|
67
|
+
{% if 'metadata_assembly_compatible' not in results_for_fasta or not results_for_fasta['metadata_assembly_compatible'] %}
|
|
68
|
+
<div class="error-list">
|
|
69
|
+
<div class="error-description"><strong>Full report:</strong> {{ results_for_fasta.get('report_path', '') }}</div>
|
|
70
|
+
<table>
|
|
71
|
+
<tr>
|
|
72
|
+
<th>Category</th><th>Accessions</th>
|
|
73
|
+
</tr>
|
|
74
|
+
<tr>
|
|
75
|
+
<td><strong>Assembly accession found in metadata</strong></td>
|
|
76
|
+
<td>{{ results_for_fasta.get('assembly_in_metadata', 'Not found') }}</td>
|
|
77
|
+
</tr>
|
|
78
|
+
<tr>
|
|
79
|
+
<td><strong>Assembly accession(s) compatible with FASTA</strong></td>
|
|
80
|
+
<td>{{ results_for_fasta.get('possible_assemblies')|join(", ") }}</td>
|
|
81
|
+
</tr>
|
|
82
|
+
</table>
|
|
83
|
+
</div>
|
|
84
|
+
{% endif %}
|
|
85
|
+
{% endif %}
|
|
86
|
+
{%- endmacro %}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
|
|
2
|
+
{% macro file_validation_report(validation_results, file_name) -%}
|
|
3
|
+
{% for check_type, check_per_file in validation_results.items() %}
|
|
4
|
+
{% set result = check_per_file.get(file_name, {}) %}
|
|
5
|
+
{% if check_type == "assembly_check" %}
|
|
6
|
+
{{ assembly_check(result) }}
|
|
7
|
+
{% elif check_type == "vcf_check" %}
|
|
8
|
+
{{ vcf_check(result) }}
|
|
9
|
+
{% endif %}
|
|
10
|
+
{% endfor %}
|
|
11
|
+
{%- endmacro %}
|
|
12
|
+
|
|
13
|
+
{% macro vcf_check(vcf_check_result) %}
|
|
14
|
+
{% set critical_count = vcf_check_result.get("critical_count", 0) %}
|
|
15
|
+
{% set error_count = vcf_check_result.get("error_count", 0) %}
|
|
16
|
+
{% set expand_icon = "" %}
|
|
17
|
+
{% if critical_count > 0 %}
|
|
18
|
+
{% set expand_icon = "▶" %}
|
|
19
|
+
{% set icon = "❌" %}
|
|
20
|
+
{% set row_class = "report-section fail collapsible" %}
|
|
21
|
+
{% elif error_count > 0 %}
|
|
22
|
+
{% set expand_icon = "▶" %}
|
|
23
|
+
{% set icon = "❌" %}
|
|
24
|
+
{% set row_class = "report-section warn collapsible" %}
|
|
25
|
+
{% else %}
|
|
26
|
+
{% set icon = "✔" %}
|
|
27
|
+
{% set row_class = "report-section pass" %}
|
|
28
|
+
{% endif %}
|
|
29
|
+
<div class='{{ row_class }}'><span class="expand_icon">{{ expand_icon }}</span> {{ icon }} VCF check: {{ critical_count }} critical errors, {{ error_count }} non-critical errors </div>
|
|
30
|
+
{% set critical_list = vcf_check_result.get("critical_list") %}
|
|
31
|
+
{% set error_list = vcf_check_result.get("error_list") %}
|
|
32
|
+
|
|
33
|
+
{% if critical_list or error_list%}
|
|
34
|
+
<div class="error-list">
|
|
35
|
+
<div class="error-description">First 10 errors per category are below. <strong>Full report:</strong> {{ vcf_check_result.get('report_path', '') }}</div>
|
|
36
|
+
<table>
|
|
37
|
+
<tr>
|
|
38
|
+
<th>Category</th><th>Error</th>
|
|
39
|
+
</tr>
|
|
40
|
+
{% for error in critical_list[:10] %}
|
|
41
|
+
<tr>
|
|
42
|
+
<td><strong>critical error</strong></td><td> {{ error }}</td>
|
|
43
|
+
</tr>
|
|
44
|
+
{% endfor %}
|
|
45
|
+
{% for error in error_list[:10] %}
|
|
46
|
+
<tr>
|
|
47
|
+
<td><strong>non-critical error</strong></td><td> {{ error }}</td>
|
|
48
|
+
</tr>
|
|
49
|
+
{% endfor %}
|
|
50
|
+
</table>
|
|
51
|
+
</div>
|
|
52
|
+
{% endif %}
|
|
53
|
+
{%- endmacro %}
|
|
54
|
+
|
|
55
|
+
{% macro assembly_check(assembly_check_result) %}
|
|
56
|
+
{% set nb_match = assembly_check_result.get("match", 0) %}
|
|
57
|
+
{% set nb_total = assembly_check_result.get("total", 0) %}
|
|
58
|
+
{% set match_percentage = nb_match / nb_total * 100 if nb_total else 0 %}
|
|
59
|
+
{% set expand_icon = "" %}
|
|
60
|
+
{% if assembly_check_result.get("nb_mismatch", 0) > 0 or nb_total == 0 %}
|
|
61
|
+
{% set expand_icon = "▶" %}
|
|
62
|
+
{% set icon = "❌" %}
|
|
63
|
+
{% set row_class = "report-section fail collapsible" %}
|
|
64
|
+
{% else %}
|
|
65
|
+
{% set icon = "✔" %}
|
|
66
|
+
{% set row_class = "report-section pass" %}
|
|
67
|
+
{% endif %}
|
|
68
|
+
<div class='{{ row_class }}'><span class="expand_icon">{{ expand_icon }}</span> {{ icon }} Assembly check: {{ nb_match }}/{{ nb_total }} ({{ match_percentage|round(2) }}%)</div>
|
|
69
|
+
{% set mismatch_list = assembly_check_result.get("mismatch_list") %}
|
|
70
|
+
{% set error_list = assembly_check_result.get("error_list") %}
|
|
71
|
+
{% if mismatch_list or error_list %}
|
|
72
|
+
<div class="error-list">
|
|
73
|
+
<div class="error-description">First 10 errors per category are below. <strong>Full report:</strong> {{ assembly_check_result.get('report_path', '') }}</div>
|
|
74
|
+
<table>
|
|
75
|
+
<tr>
|
|
76
|
+
<th>Category</th><th>Error</th>
|
|
77
|
+
</tr>
|
|
78
|
+
{% for error in error_list[:10] %}
|
|
79
|
+
<tr>
|
|
80
|
+
<td><strong>Parsing Error</strong></td><td> {{ error }}</td>
|
|
81
|
+
</tr>
|
|
82
|
+
{% endfor %}
|
|
83
|
+
{% for error in mismatch_list[:10] %}
|
|
84
|
+
<tr>
|
|
85
|
+
<td><strong>mismatch error</strong></td><td> {{ error }}</td>
|
|
86
|
+
</tr>
|
|
87
|
+
{% endfor %}
|
|
88
|
+
</table>
|
|
89
|
+
</div>
|
|
90
|
+
{% endif %}
|
|
91
|
+
{%- endmacro %}
|
|
92
|
+
|
|
93
|
+
|