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,233 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
import csv
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
from ebi_eva_common_pyutils.config import WritableConfig
|
|
9
|
+
from ebi_eva_common_pyutils.logger import logging_config
|
|
10
|
+
from openpyxl.reader.excel import load_workbook
|
|
11
|
+
|
|
12
|
+
from eva_sub_cli import SUB_CLI_CONFIG_FILE, __version__
|
|
13
|
+
from eva_sub_cli.exceptions.invalid_file_type_exception import InvalidFileTypeError
|
|
14
|
+
from eva_sub_cli.exceptions.submission_not_found_exception import SubmissionNotFoundException
|
|
15
|
+
from eva_sub_cli.exceptions.submission_status_exception import SubmissionStatusException
|
|
16
|
+
from eva_sub_cli.submission_ws import SubmissionWSClient
|
|
17
|
+
from eva_sub_cli.submit import StudySubmitter, SUB_CLI_CONFIG_KEY_SUBMISSION_ID, \
|
|
18
|
+
SUB_CLI_CONFIG_KEY_SUBMISSION_UPLOAD_URL
|
|
19
|
+
from eva_sub_cli.validators.docker_validator import DockerValidator
|
|
20
|
+
from eva_sub_cli.validators.native_validator import NativeValidator
|
|
21
|
+
from eva_sub_cli.validators.validator import READY_FOR_SUBMISSION_TO_EVA
|
|
22
|
+
|
|
23
|
+
VALIDATE = 'validate'
|
|
24
|
+
SUBMIT = 'submit'
|
|
25
|
+
DOCKER = 'docker'
|
|
26
|
+
NATIVE = 'native'
|
|
27
|
+
|
|
28
|
+
logger = logging_config.get_logger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_vcf_files(mapping_file):
|
|
32
|
+
vcf_files = []
|
|
33
|
+
with open(mapping_file) as open_file:
|
|
34
|
+
reader = csv.DictReader(open_file, delimiter=',')
|
|
35
|
+
for row in reader:
|
|
36
|
+
vcf_files.append(row['vcf'])
|
|
37
|
+
return vcf_files
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def get_project_title_and_create_vcf_files_mapping(submission_dir, vcf_files, reference_fasta,
|
|
41
|
+
metadata_json, metadata_xlsx):
|
|
42
|
+
"""
|
|
43
|
+
Get project title and mapping between VCF files and reference FASTA files, from three sources: command line
|
|
44
|
+
arguments, metadata JSON file, or metadata XLSX file.
|
|
45
|
+
|
|
46
|
+
:param submission_dir: Directory where mapping file will be saved
|
|
47
|
+
:param vcf_files: VCF files from command line, if present
|
|
48
|
+
:param reference_fasta: Reference FASTA from command line, if present
|
|
49
|
+
:param metadata_json: Metadata JSON from command line, if present
|
|
50
|
+
:param metadata_xlsx: Metadata XLSX from command line, if present
|
|
51
|
+
:return: Project title and path to the mapping file
|
|
52
|
+
"""
|
|
53
|
+
mapping_file = os.path.join(submission_dir, 'vcf_mapping_file.csv')
|
|
54
|
+
with open(mapping_file, 'w') as open_file:
|
|
55
|
+
writer = csv.writer(open_file, delimiter=',')
|
|
56
|
+
writer.writerow(['vcf', 'fasta', 'report'])
|
|
57
|
+
|
|
58
|
+
vcf_files_mapping = []
|
|
59
|
+
if vcf_files and reference_fasta:
|
|
60
|
+
for vcf_file in vcf_files:
|
|
61
|
+
vcf_files_mapping.append([os.path.abspath(vcf_file), os.path.abspath(reference_fasta), ''])
|
|
62
|
+
if metadata_json:
|
|
63
|
+
project_title, _ = get_project_and_vcf_fasta_mapping_from_metadata_json(metadata_json, False)
|
|
64
|
+
elif metadata_xlsx:
|
|
65
|
+
project_title, _ = get_project_and_vcf_fasta_mapping_from_metadata_xlsx(metadata_xlsx, False)
|
|
66
|
+
elif metadata_json:
|
|
67
|
+
project_title, vcf_files_mapping = get_project_and_vcf_fasta_mapping_from_metadata_json(metadata_json, True)
|
|
68
|
+
elif metadata_xlsx:
|
|
69
|
+
project_title, vcf_files_mapping = get_project_and_vcf_fasta_mapping_from_metadata_xlsx(metadata_xlsx, True)
|
|
70
|
+
|
|
71
|
+
validate_vcf_mapping(vcf_files_mapping)
|
|
72
|
+
for mapping in vcf_files_mapping:
|
|
73
|
+
writer.writerow(mapping)
|
|
74
|
+
|
|
75
|
+
return project_title, mapping_file
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def validate_vcf_mapping(vcf_mapping):
|
|
79
|
+
"""
|
|
80
|
+
Validate that VCF files and FASTA files in the mapping are present and FASTA files are not compressed.
|
|
81
|
+
|
|
82
|
+
:param vcf_mapping: iterable of triples (VCF file path, reference FASTA path, optional assembly report path)
|
|
83
|
+
:return:
|
|
84
|
+
"""
|
|
85
|
+
for vcf_file, fasta_file, report_file in vcf_mapping:
|
|
86
|
+
if not (vcf_file and os.path.isfile(vcf_file)):
|
|
87
|
+
raise FileNotFoundError(f'The variant file {vcf_file} does not exist, please check the file path.')
|
|
88
|
+
if not (fasta_file and os.path.isfile(fasta_file)):
|
|
89
|
+
raise FileNotFoundError(f'The reference fasta {fasta_file} does not exist, please check the file path.')
|
|
90
|
+
if fasta_file.lower().endswith('gz'):
|
|
91
|
+
raise InvalidFileTypeError(f'The reference fasta {fasta_file} is compressed, please uncompress the file.')
|
|
92
|
+
if report_file and not os.path.isfile(report_file):
|
|
93
|
+
raise FileNotFoundError(f'The assembly report file {report_file} does not exist, please check the file '
|
|
94
|
+
f'path.')
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def get_project_and_vcf_fasta_mapping_from_metadata_json(metadata_json, mapping_req=False):
|
|
98
|
+
with open(metadata_json) as file:
|
|
99
|
+
json_metadata = json.load(file)
|
|
100
|
+
|
|
101
|
+
if 'project' in json_metadata:
|
|
102
|
+
if 'title' in json_metadata['project']:
|
|
103
|
+
project_title = json_metadata['project']['title']
|
|
104
|
+
|
|
105
|
+
vcf_fasta_report_mapping = []
|
|
106
|
+
if mapping_req:
|
|
107
|
+
analysis_alias_dict = defaultdict(dict)
|
|
108
|
+
for analysis in json_metadata['analysis']:
|
|
109
|
+
analysis_alias_dict[analysis['analysisAlias']]['referenceFasta'] = analysis['referenceFasta']
|
|
110
|
+
analysis_alias_dict[analysis['analysisAlias']]['assemblyReport'] = analysis['assemblyReport'] \
|
|
111
|
+
if 'assemblyReport' in analysis else ''
|
|
112
|
+
|
|
113
|
+
for file_dict in json_metadata['files']:
|
|
114
|
+
reference_fasta = analysis_alias_dict[file_dict['analysisAlias']]['referenceFasta']
|
|
115
|
+
assembly_report = analysis_alias_dict[file_dict['analysisAlias']]['assemblyReport']
|
|
116
|
+
vcf_fasta_report_mapping.append([os.path.abspath(file_dict['fileName']),
|
|
117
|
+
os.path.abspath(reference_fasta),
|
|
118
|
+
os.path.abspath(assembly_report) if assembly_report else ''])
|
|
119
|
+
|
|
120
|
+
return project_title, vcf_fasta_report_mapping
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def get_project_and_vcf_fasta_mapping_from_metadata_xlsx(metadata_xlsx, mapping_req=False):
|
|
124
|
+
workbook = load_workbook(metadata_xlsx)
|
|
125
|
+
|
|
126
|
+
project_sheet = workbook['Project']
|
|
127
|
+
project_headers = {}
|
|
128
|
+
for cell in project_sheet[1]:
|
|
129
|
+
project_headers[cell.value] = cell.column
|
|
130
|
+
project_title = project_sheet.cell(row=2, column=project_headers['Project Title']).value
|
|
131
|
+
|
|
132
|
+
vcf_fasta_report_mapping = []
|
|
133
|
+
if mapping_req:
|
|
134
|
+
analysis_alias_sheet = workbook['Analysis']
|
|
135
|
+
analysis_headers = {}
|
|
136
|
+
for cell in analysis_alias_sheet[1]:
|
|
137
|
+
analysis_headers[cell.value] = cell.column - 1
|
|
138
|
+
|
|
139
|
+
analysis_alias_dict = {}
|
|
140
|
+
for row in analysis_alias_sheet.iter_rows(min_row=2, values_only=True):
|
|
141
|
+
analysis_alias = row[analysis_headers['Analysis Alias']]
|
|
142
|
+
reference_fasta = row[analysis_headers['Reference Fasta Path']]
|
|
143
|
+
analysis_alias_dict[analysis_alias] = reference_fasta
|
|
144
|
+
|
|
145
|
+
files_sheet = workbook['Files']
|
|
146
|
+
files_headers = {}
|
|
147
|
+
for cell in files_sheet[1]:
|
|
148
|
+
files_headers[cell.value] = cell.column - 1
|
|
149
|
+
|
|
150
|
+
for row in files_sheet.iter_rows(min_row=2, values_only=True):
|
|
151
|
+
file_name = os.path.abspath(row[files_headers['File Name']])
|
|
152
|
+
analysis_alias = row[files_headers['Analysis Alias']]
|
|
153
|
+
reference_fasta = os.path.abspath(analysis_alias_dict[analysis_alias])
|
|
154
|
+
vcf_fasta_report_mapping.append([os.path.abspath(file_name), os.path.abspath(reference_fasta), ''])
|
|
155
|
+
|
|
156
|
+
return project_title, vcf_fasta_report_mapping
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def check_validation_required(tasks, sub_config, username=None, password=None):
|
|
160
|
+
# Validation is mandatory so if submit is requested then VALIDATE must have run before or be requested as well
|
|
161
|
+
if SUBMIT in tasks:
|
|
162
|
+
if not sub_config.get(READY_FOR_SUBMISSION_TO_EVA, False):
|
|
163
|
+
return True
|
|
164
|
+
# If we are working with an existing submission check its status to see if it was submitted and failed before.
|
|
165
|
+
submission_id = sub_config.get(SUB_CLI_CONFIG_KEY_SUBMISSION_ID, None)
|
|
166
|
+
if submission_id:
|
|
167
|
+
try:
|
|
168
|
+
submission_status = SubmissionWSClient(username, password).get_submission_status(submission_id)
|
|
169
|
+
if submission_status == 'FAILED':
|
|
170
|
+
# Reset the submission_id which will force the creation of a new one
|
|
171
|
+
sub_config.set(SUB_CLI_CONFIG_KEY_SUBMISSION_ID, value=None)
|
|
172
|
+
sub_config.set(SUB_CLI_CONFIG_KEY_SUBMISSION_UPLOAD_URL, value=None)
|
|
173
|
+
return True
|
|
174
|
+
else:
|
|
175
|
+
return False
|
|
176
|
+
except requests.HTTPError as ex:
|
|
177
|
+
if ex.response.status_code == 404:
|
|
178
|
+
logger.error(
|
|
179
|
+
f'Submission with id {submission_id} could not be found: '
|
|
180
|
+
f'status code: {ex.response.status_code} response: {ex.response.text}')
|
|
181
|
+
raise SubmissionNotFoundException(f'Submission with id {submission_id} could not be found')
|
|
182
|
+
else:
|
|
183
|
+
logger.error(f'Error occurred while getting status of the submission with Id {submission_id}: '
|
|
184
|
+
f'status code: {ex.response.status_code} response: {ex.response.text}')
|
|
185
|
+
raise SubmissionStatusException(f'Error occurred while getting status of the submission '
|
|
186
|
+
f'with Id {submission_id}')
|
|
187
|
+
|
|
188
|
+
logger.debug(f'submission id not found in config. This might be the first time user is submitting')
|
|
189
|
+
return False
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def orchestrate_process(submission_dir, vcf_files, reference_fasta, metadata_json, metadata_xlsx,
|
|
193
|
+
tasks, executor, username=None, password=None, shallow_validation=False, **kwargs):
|
|
194
|
+
# load config
|
|
195
|
+
config_file_path = os.path.join(submission_dir, SUB_CLI_CONFIG_FILE)
|
|
196
|
+
sub_config = WritableConfig(config_file_path, version=__version__)
|
|
197
|
+
|
|
198
|
+
metadata_file = metadata_json or metadata_xlsx
|
|
199
|
+
if not os.path.exists(os.path.abspath(metadata_file)):
|
|
200
|
+
raise FileNotFoundError(f'The provided metadata file {os.path.abspath(metadata_file)} does not exist')
|
|
201
|
+
|
|
202
|
+
if metadata_json:
|
|
203
|
+
metadata_json = os.path.abspath(metadata_json)
|
|
204
|
+
if metadata_xlsx:
|
|
205
|
+
metadata_xlsx = os.path.abspath(metadata_xlsx)
|
|
206
|
+
|
|
207
|
+
# Get the provided Project Title and VCF files mapping (VCF, Fasta and Report)
|
|
208
|
+
project_title, vcf_files_mapping = get_project_title_and_create_vcf_files_mapping(
|
|
209
|
+
submission_dir, vcf_files, reference_fasta, metadata_json, metadata_xlsx
|
|
210
|
+
)
|
|
211
|
+
vcf_files = get_vcf_files(vcf_files_mapping)
|
|
212
|
+
|
|
213
|
+
if VALIDATE not in tasks and check_validation_required(tasks, sub_config, username, password):
|
|
214
|
+
tasks.append(VALIDATE)
|
|
215
|
+
|
|
216
|
+
if VALIDATE in tasks:
|
|
217
|
+
if executor == DOCKER:
|
|
218
|
+
validator = DockerValidator(vcf_files_mapping, submission_dir, project_title, metadata_json, metadata_xlsx,
|
|
219
|
+
shallow_validation=shallow_validation, submission_config=sub_config)
|
|
220
|
+
# default to native execution
|
|
221
|
+
else:
|
|
222
|
+
validator = NativeValidator(vcf_files_mapping, submission_dir, project_title, metadata_json, metadata_xlsx,
|
|
223
|
+
shallow_validation=shallow_validation, submission_config=sub_config)
|
|
224
|
+
with validator:
|
|
225
|
+
validator.validate_and_report()
|
|
226
|
+
if not metadata_json:
|
|
227
|
+
metadata_json = os.path.join(validator.output_dir, 'metadata.json')
|
|
228
|
+
sub_config.set('metadata_json', value=metadata_json)
|
|
229
|
+
sub_config.set('vcf_files', value=vcf_files)
|
|
230
|
+
|
|
231
|
+
if SUBMIT in tasks:
|
|
232
|
+
with StudySubmitter(submission_dir, submission_config=sub_config, username=username, password=password) as submitter:
|
|
233
|
+
submitter.submit()
|
eva_sub_cli/report.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import os.path
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from jinja2 import Environment, FileSystemLoader
|
|
6
|
+
|
|
7
|
+
import eva_sub_cli
|
|
8
|
+
|
|
9
|
+
current_dir = os.path.dirname(__file__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_logo_data():
|
|
13
|
+
with open(os.path.join(current_dir, "etc/eva_logo.png"), "rb") as f:
|
|
14
|
+
logo_data = base64.b64encode(f.read()).decode("utf-8")
|
|
15
|
+
return logo_data
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def generate_html_report(validation_results, validation_date, submission_dir, vcf_fasta_analysis_mapping,
|
|
19
|
+
project_title=None):
|
|
20
|
+
vcf_files = sorted(set([file_name
|
|
21
|
+
for check in validation_results if check in ["vcf_check", "assembly_check"]
|
|
22
|
+
for file_name in validation_results[check]
|
|
23
|
+
]))
|
|
24
|
+
fasta_files = sorted([file_name for file_name in validation_results['fasta_check']])
|
|
25
|
+
template = Environment(
|
|
26
|
+
loader=FileSystemLoader(os.path.join(current_dir, 'jinja_templates'))
|
|
27
|
+
).get_template('html_report.html')
|
|
28
|
+
rendered_template = template.render(
|
|
29
|
+
cli_version=eva_sub_cli.__version__,
|
|
30
|
+
logo_data=get_logo_data(),
|
|
31
|
+
project_title=project_title,
|
|
32
|
+
validation_date=validation_date,
|
|
33
|
+
vcf_files=vcf_files,
|
|
34
|
+
fasta_files=fasta_files,
|
|
35
|
+
submission_dir=submission_dir,
|
|
36
|
+
vcf_fasta_analysis_mapping=vcf_fasta_analysis_mapping,
|
|
37
|
+
validation_results=validation_results
|
|
38
|
+
)
|
|
39
|
+
return re.sub('\s+\n', '\n', rendered_template)
|
|
40
|
+
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from collections import Counter
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
|
|
5
|
+
import yaml
|
|
6
|
+
|
|
7
|
+
from retry import retry
|
|
8
|
+
from ebi_eva_common_pyutils.biosamples_communicators import NoAuthHALCommunicator
|
|
9
|
+
from ebi_eva_common_pyutils.ena_utils import download_xml_from_ena, get_scientific_name_and_common_name
|
|
10
|
+
from ebi_eva_common_pyutils.logger import AppLogger
|
|
11
|
+
|
|
12
|
+
from eva_sub_cli.date_utils import check_date
|
|
13
|
+
|
|
14
|
+
PROJECT_KEY = 'project'
|
|
15
|
+
ANALYSIS_KEY = 'analysis'
|
|
16
|
+
SAMPLE_KEY = 'sample'
|
|
17
|
+
FILES_KEY = 'files'
|
|
18
|
+
PARENT_PROJECT_KEY = 'parentProject'
|
|
19
|
+
CHILD_PROJECTS_KEY = 'childProjects'
|
|
20
|
+
PEER_PROJECTS_KEY = 'peerProjects'
|
|
21
|
+
BIOSAMPLE_OBJECT_KEY = 'bioSampleObject'
|
|
22
|
+
BIOSAMPLE_ACCESSION_KEY = 'bioSampleAccession'
|
|
23
|
+
CHARACTERISTICS_KEY = 'characteristics'
|
|
24
|
+
TAX_ID_KEY = 'taxId'
|
|
25
|
+
SCI_NAME_KEYS = ['species', 'Species', 'organism', 'Organism']
|
|
26
|
+
ANALYSIS_ALIAS_KEY = 'analysisAlias'
|
|
27
|
+
ANALYSIS_RUNS_KEY = 'runAccessions'
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def cast_list(l, type_to_cast=str):
|
|
31
|
+
for e in l:
|
|
32
|
+
yield type_to_cast(e)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SemanticMetadataChecker(AppLogger):
|
|
36
|
+
|
|
37
|
+
def __init__(self, metadata, sample_checklist='ERC000011'):
|
|
38
|
+
self.sample_checklist = sample_checklist
|
|
39
|
+
self.metadata = metadata
|
|
40
|
+
self.errors = []
|
|
41
|
+
# Caches whether taxonomy code is valid or not, and maps to scientific name if valid
|
|
42
|
+
self.taxonomy_valid = {}
|
|
43
|
+
self.communicator = NoAuthHALCommunicator(bsd_url='https://www.ebi.ac.uk/biosamples')
|
|
44
|
+
|
|
45
|
+
def write_result_yaml(self, output_path):
|
|
46
|
+
with open(output_path, 'w') as open_yaml:
|
|
47
|
+
yaml.safe_dump(data=self.errors, stream=open_yaml)
|
|
48
|
+
|
|
49
|
+
def check_all(self):
|
|
50
|
+
self.check_all_project_accessions()
|
|
51
|
+
self.check_all_taxonomy_codes()
|
|
52
|
+
self.check_all_scientific_names()
|
|
53
|
+
self.check_existing_biosamples()
|
|
54
|
+
self.check_all_analysis_run_accessions()
|
|
55
|
+
self.check_analysis_alias_coherence()
|
|
56
|
+
|
|
57
|
+
def check_all_project_accessions(self):
|
|
58
|
+
"""Check that ENA project accessions exist and are public."""
|
|
59
|
+
project = self.metadata[PROJECT_KEY]
|
|
60
|
+
if PARENT_PROJECT_KEY in project:
|
|
61
|
+
self.check_project_accession(project[PARENT_PROJECT_KEY], f'/{PROJECT_KEY}/{PARENT_PROJECT_KEY}')
|
|
62
|
+
for idx, accession in enumerate(project.get(CHILD_PROJECTS_KEY, [])):
|
|
63
|
+
self.check_project_accession(accession, f'/{PROJECT_KEY}/{CHILD_PROJECTS_KEY}/{idx}')
|
|
64
|
+
for idx, accession in enumerate(project.get(PEER_PROJECTS_KEY, [])):
|
|
65
|
+
self.check_project_accession(accession, f'/{PROJECT_KEY}/{PEER_PROJECTS_KEY}/{idx}')
|
|
66
|
+
|
|
67
|
+
def check_all_taxonomy_codes(self):
|
|
68
|
+
"""Check that taxonomy IDs are valid according to ENA."""
|
|
69
|
+
project = self.metadata[PROJECT_KEY]
|
|
70
|
+
if TAX_ID_KEY in project:
|
|
71
|
+
self.check_taxonomy_code(project[TAX_ID_KEY], f'/{PROJECT_KEY}/{TAX_ID_KEY}')
|
|
72
|
+
# Check sample taxonomies for novel samples
|
|
73
|
+
for idx, sample in enumerate(self.metadata[SAMPLE_KEY]):
|
|
74
|
+
if BIOSAMPLE_OBJECT_KEY in sample and TAX_ID_KEY in sample[BIOSAMPLE_OBJECT_KEY][CHARACTERISTICS_KEY]:
|
|
75
|
+
self.check_taxonomy_code(sample[BIOSAMPLE_OBJECT_KEY][CHARACTERISTICS_KEY][TAX_ID_KEY][0]['text'],
|
|
76
|
+
f'/{SAMPLE_KEY}/{idx}/{BIOSAMPLE_OBJECT_KEY}/{CHARACTERISTICS_KEY}/{TAX_ID_KEY}')
|
|
77
|
+
|
|
78
|
+
def check_uniqueness_analysis_alias(self):
|
|
79
|
+
analyses = self.metadata[ANALYSIS_KEY]
|
|
80
|
+
analysis_aliases = [analysis.get(ANALYSIS_ALIAS_KEY) for analysis in analyses]
|
|
81
|
+
if len(analysis_aliases) != len(set(analysis_aliases)):
|
|
82
|
+
counter = Counter(analysis_aliases)
|
|
83
|
+
for idx, analysis in enumerate(analyses):
|
|
84
|
+
if counter[analysis.get(ANALYSIS_ALIAS_KEY)] > 1:
|
|
85
|
+
self.add_error(
|
|
86
|
+
f'/{ANALYSIS_KEY}/{idx}/{ANALYSIS_ALIAS_KEY}',
|
|
87
|
+
f'Analysis alias {analysis.get(ANALYSIS_ALIAS_KEY)} is present '
|
|
88
|
+
f'{counter[analysis.get(ANALYSIS_ALIAS_KEY)]} times in the Analysis Sheet')
|
|
89
|
+
|
|
90
|
+
def check_all_scientific_names(self):
|
|
91
|
+
"""Check that all scientific names are consistent with taxonomy codes."""
|
|
92
|
+
for idx, sample in enumerate(self.metadata[SAMPLE_KEY]):
|
|
93
|
+
if BIOSAMPLE_OBJECT_KEY in sample and TAX_ID_KEY in sample[BIOSAMPLE_OBJECT_KEY][CHARACTERISTICS_KEY]:
|
|
94
|
+
characteristics = sample[BIOSAMPLE_OBJECT_KEY][CHARACTERISTICS_KEY]
|
|
95
|
+
# Get the scientific name from the taxonomy (if valid)
|
|
96
|
+
tax_code = int(characteristics[TAX_ID_KEY][0]['text'])
|
|
97
|
+
sci_name_from_tax = self.taxonomy_valid[tax_code]
|
|
98
|
+
if not sci_name_from_tax:
|
|
99
|
+
continue
|
|
100
|
+
# Check if scientific name in sample matches
|
|
101
|
+
for sci_name_key in SCI_NAME_KEYS:
|
|
102
|
+
if sci_name_key in characteristics:
|
|
103
|
+
sci_name = characteristics[sci_name_key][0]['text']
|
|
104
|
+
if sci_name_from_tax.lower() != sci_name.lower():
|
|
105
|
+
self.add_error(
|
|
106
|
+
f'/{SAMPLE_KEY}/{idx}/{BIOSAMPLE_OBJECT_KEY}/{CHARACTERISTICS_KEY}/{sci_name_key}',
|
|
107
|
+
f'Species {sci_name} does not match taxonomy {tax_code} ({sci_name_from_tax})')
|
|
108
|
+
|
|
109
|
+
def check_all_analysis_run_accessions(self):
|
|
110
|
+
"""Check that the Run accession are valid and exist in ENA"""
|
|
111
|
+
for idx, analysis in enumerate(self.metadata[ANALYSIS_KEY]):
|
|
112
|
+
json_path = f'/{ANALYSIS_KEY}/{idx}/{ANALYSIS_RUNS_KEY}'
|
|
113
|
+
if ANALYSIS_RUNS_KEY in analysis and analysis[ANALYSIS_RUNS_KEY]:
|
|
114
|
+
for run_acc in analysis[ANALYSIS_RUNS_KEY]:
|
|
115
|
+
self.check_accession_in_ena(run_acc, 'Run', json_path)
|
|
116
|
+
|
|
117
|
+
@retry(tries=4, delay=2, backoff=1.2, jitter=(1, 3))
|
|
118
|
+
def check_accession_in_ena(self, ena_accession, accession_type, json_path):
|
|
119
|
+
try:
|
|
120
|
+
res = download_xml_from_ena(f'https://www.ebi.ac.uk/ena/browser/api/xml/{ena_accession}')
|
|
121
|
+
except Exception:
|
|
122
|
+
self.add_error(json_path, f'{accession_type} {ena_accession} does not exist in ENA or is private')
|
|
123
|
+
|
|
124
|
+
def check_project_accession(self, project_acc, json_path):
|
|
125
|
+
self.check_accession_in_ena(project_acc, 'Project', json_path)
|
|
126
|
+
|
|
127
|
+
@retry(tries=4, delay=2, backoff=1.2, jitter=(1, 3))
|
|
128
|
+
def check_taxonomy_code(self, taxonomy_code, json_path):
|
|
129
|
+
taxonomy_code = int(taxonomy_code)
|
|
130
|
+
if taxonomy_code in self.taxonomy_valid:
|
|
131
|
+
if self.taxonomy_valid[taxonomy_code] is False:
|
|
132
|
+
self.add_error(json_path, f'{taxonomy_code} is not a valid taxonomy code')
|
|
133
|
+
else:
|
|
134
|
+
try:
|
|
135
|
+
sci_name, _ = get_scientific_name_and_common_name(taxonomy_code)
|
|
136
|
+
self.taxonomy_valid[taxonomy_code] = sci_name
|
|
137
|
+
except Exception:
|
|
138
|
+
self.add_error(json_path, f'{taxonomy_code} is not a valid taxonomy code')
|
|
139
|
+
self.taxonomy_valid[taxonomy_code] = False
|
|
140
|
+
|
|
141
|
+
def add_error(self, property, description):
|
|
142
|
+
"""
|
|
143
|
+
Add an error, conforming to the format of biovalidator errors.
|
|
144
|
+
|
|
145
|
+
:param property: JSON property of the error. This will be converted to sheet/row/column in spreadsheet if needed.
|
|
146
|
+
:param description: description of the error.
|
|
147
|
+
"""
|
|
148
|
+
self.errors.append({'property': property, 'description': description})
|
|
149
|
+
|
|
150
|
+
def _get_biosample(self, sample_accession):
|
|
151
|
+
return self.communicator.follows_link('samples', join_url=sample_accession)
|
|
152
|
+
|
|
153
|
+
def check_existing_biosamples(self):
|
|
154
|
+
"""Check that existing BioSamples are accessible and contain the required attributes."""
|
|
155
|
+
for idx, sample in enumerate(self.metadata[SAMPLE_KEY]):
|
|
156
|
+
if BIOSAMPLE_ACCESSION_KEY in sample:
|
|
157
|
+
sample_accession = sample[BIOSAMPLE_ACCESSION_KEY]
|
|
158
|
+
json_path = f'/{SAMPLE_KEY}/{idx}/{BIOSAMPLE_ACCESSION_KEY}'
|
|
159
|
+
try:
|
|
160
|
+
sample_data = self._get_biosample(sample_accession)
|
|
161
|
+
if self.sample_checklist:
|
|
162
|
+
self._validate_biosample_against_checklist(sample_data, json_path, sample_accession)
|
|
163
|
+
else:
|
|
164
|
+
self._local_validate_existing_biosample(sample_data, json_path, sample_accession)
|
|
165
|
+
|
|
166
|
+
except ValueError:
|
|
167
|
+
self.add_error(json_path, f'{sample_accession} does not exist or is private')
|
|
168
|
+
|
|
169
|
+
def _validate_biosample_against_checklist(self, sample_data, json_path, accession=None):
|
|
170
|
+
pre_acceptable_code = self.communicator.acceptable_code
|
|
171
|
+
try:
|
|
172
|
+
# Allow the communicator to accept failure
|
|
173
|
+
self.communicator.acceptable_code = [200, 400]
|
|
174
|
+
sample_data = deepcopy(sample_data)
|
|
175
|
+
# Add checklist to the sample
|
|
176
|
+
if 'characteristics' not in sample_data:
|
|
177
|
+
sample_data['characteristics'] = {}
|
|
178
|
+
if 'checklist' not in sample_data['characteristics']:
|
|
179
|
+
sample_data['characteristics']['checklist'] = []
|
|
180
|
+
sample_data['characteristics']['checklist'].append({'text': self.sample_checklist})
|
|
181
|
+
text = self.communicator.follows_link('samples', join_url='validate', method='POST', json=sample_data,
|
|
182
|
+
text_only=True)
|
|
183
|
+
response = json.loads(text)
|
|
184
|
+
# Successful response returns the sample object, error response has a list of error objects.
|
|
185
|
+
if isinstance(response, list):
|
|
186
|
+
for error_dict in response:
|
|
187
|
+
if accession:
|
|
188
|
+
self.add_error(json_path, f'Existing sample {accession} {error_dict["errors"][0]}')
|
|
189
|
+
else:
|
|
190
|
+
self.add_error(json_path, error_dict["errors"][0])
|
|
191
|
+
finally:
|
|
192
|
+
self.communicator.acceptable_code = pre_acceptable_code
|
|
193
|
+
|
|
194
|
+
def _local_validate_existing_biosample(self, sample_data, json_path, accession):
|
|
195
|
+
"""Check if the existing sample has the expected fields present"""
|
|
196
|
+
found_collection_date = False
|
|
197
|
+
for key in ['collection_date', 'collection date']:
|
|
198
|
+
if key in sample_data['characteristics'] and check_date(sample_data['characteristics'][key][0]['text']):
|
|
199
|
+
found_collection_date = True
|
|
200
|
+
if not found_collection_date:
|
|
201
|
+
self.add_error(json_path, f'Existing sample {accession} does not have a valid collection date')
|
|
202
|
+
|
|
203
|
+
found_geo_loc = False
|
|
204
|
+
for key in ['geographic location (country and/or sea)', 'geo loc name']:
|
|
205
|
+
if key in sample_data['characteristics'] and sample_data['characteristics'][key][0]['text']:
|
|
206
|
+
found_geo_loc = True
|
|
207
|
+
if not found_geo_loc:
|
|
208
|
+
self.add_error(json_path, f'Existing sample {accession} does not have a valid geographic location')
|
|
209
|
+
|
|
210
|
+
def check_analysis_alias_coherence(self):
|
|
211
|
+
"""Check that the same analysis aliases are used in analysis, sample, and files."""
|
|
212
|
+
analysis_aliases = [analysis[ANALYSIS_ALIAS_KEY] for analysis in self.metadata[ANALYSIS_KEY]]
|
|
213
|
+
aliases_in_samples = [alias for sample in self.metadata[SAMPLE_KEY] for alias in sample[ANALYSIS_ALIAS_KEY]]
|
|
214
|
+
aliases_in_files = [file[ANALYSIS_ALIAS_KEY] for file in self.metadata[FILES_KEY]]
|
|
215
|
+
|
|
216
|
+
self.same_set(analysis_aliases, aliases_in_samples, 'Analysis', 'Samples',
|
|
217
|
+
f'/{SAMPLE_KEY}/{ANALYSIS_ALIAS_KEY}')
|
|
218
|
+
self.same_set(analysis_aliases, aliases_in_files, 'Analysis', 'Files',
|
|
219
|
+
f'/{FILES_KEY}/{ANALYSIS_ALIAS_KEY}')
|
|
220
|
+
|
|
221
|
+
def same_set(self, list1, list2, list1_desc, list2_desc, json_path):
|
|
222
|
+
"""
|
|
223
|
+
Compare contents of two lists and add error messages.
|
|
224
|
+
|
|
225
|
+
:param list1: first list to compare
|
|
226
|
+
:param list2: second list to compare
|
|
227
|
+
:param list1_desc: text description of first list, used only in error message
|
|
228
|
+
:param list2_desc: text description of second list, used only in error message
|
|
229
|
+
:param json_path: property where the error message will appear
|
|
230
|
+
"""
|
|
231
|
+
if not set(list1) == set(list2):
|
|
232
|
+
list1_list2 = sorted(cast_list(set(list1).difference(list2)))
|
|
233
|
+
list2_list1 = sorted(cast_list(set(list2).difference(list1)))
|
|
234
|
+
if list1_list2:
|
|
235
|
+
self.add_error(
|
|
236
|
+
property=json_path,
|
|
237
|
+
description=f'{",".join(list1_list2)} present in {list1_desc} not in {list2_desc}'
|
|
238
|
+
)
|
|
239
|
+
if list2_list1:
|
|
240
|
+
self.add_error(
|
|
241
|
+
property=json_path,
|
|
242
|
+
description=f'{",".join(list2_list1)} present in {list2_desc} not in {list1_desc}'
|
|
243
|
+
)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
import requests
|
|
4
|
+
from ebi_eva_common_pyutils.logger import AppLogger
|
|
5
|
+
from requests import HTTPError
|
|
6
|
+
from retry import retry
|
|
7
|
+
|
|
8
|
+
from eva_sub_cli import SUBMISSION_WS_VAR
|
|
9
|
+
from eva_sub_cli.auth import get_auth
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SubmissionWSClient(AppLogger):
|
|
13
|
+
"""
|
|
14
|
+
Python client for interfacing with the Submission WS API.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, username=None, password=None):
|
|
18
|
+
self.auth = get_auth(username, password)
|
|
19
|
+
self.base_url = self._submission_ws_url
|
|
20
|
+
|
|
21
|
+
SUBMISSION_WS_URL = 'https://www.ebi.ac.uk/eva/webservices/submission-ws/v1/'
|
|
22
|
+
SUBMISSION_INITIATE_PATH = 'submission/initiate'
|
|
23
|
+
SUBMISSION_UPLOADED_PATH = 'submission/{submissionId}/uploaded'
|
|
24
|
+
SUBMISSION_STATUS_PATH = 'submission/{submissionId}/status'
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def _submission_ws_url(self):
|
|
28
|
+
"""Retrieve the base URL for the submission web services.
|
|
29
|
+
In order of preference from the environment variable or the hardcoded value."""
|
|
30
|
+
if os.environ.get(SUBMISSION_WS_VAR):
|
|
31
|
+
return os.environ.get(SUBMISSION_WS_VAR)
|
|
32
|
+
else:
|
|
33
|
+
return self.SUBMISSION_WS_URL
|
|
34
|
+
|
|
35
|
+
def _submission_initiate_url(self):
|
|
36
|
+
return os.path.join(self.base_url, self.SUBMISSION_INITIATE_PATH)
|
|
37
|
+
|
|
38
|
+
def _submission_uploaded_url(self, submission_id):
|
|
39
|
+
return os.path.join(self.base_url, self.SUBMISSION_UPLOADED_PATH.format(submissionId=submission_id))
|
|
40
|
+
|
|
41
|
+
def _submission_status_url(self, submission_id):
|
|
42
|
+
return os.path.join(self.base_url, self.SUBMISSION_STATUS_PATH.format(submissionId=submission_id))
|
|
43
|
+
|
|
44
|
+
def mark_submission_uploaded(self, submission_id, metadata_json):
|
|
45
|
+
response = requests.put(self._submission_uploaded_url(submission_id),
|
|
46
|
+
headers={'Accept': 'application/json', 'Authorization': 'Bearer ' + self.auth.token},
|
|
47
|
+
json=metadata_json)
|
|
48
|
+
response.raise_for_status()
|
|
49
|
+
return response.json()
|
|
50
|
+
|
|
51
|
+
def initiate_submission(self):
|
|
52
|
+
response = requests.post(self._submission_initiate_url(), headers={'Accept': 'application/json',
|
|
53
|
+
'Authorization': 'Bearer ' + self.auth.token})
|
|
54
|
+
response.raise_for_status()
|
|
55
|
+
return response.json()
|
|
56
|
+
|
|
57
|
+
@retry(exceptions=(HTTPError,), tries=3, delay=2, backoff=1.2, jitter=(1, 3))
|
|
58
|
+
def get_submission_status(self, submission_id):
|
|
59
|
+
response = requests.get(self._submission_status_url(submission_id))
|
|
60
|
+
response.raise_for_status()
|
|
61
|
+
return response.text
|