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
eva_sub_cli/submit.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
from ebi_eva_common_pyutils.config import WritableConfig
|
|
7
|
+
from ebi_eva_common_pyutils.logger import AppLogger
|
|
8
|
+
from retry import retry
|
|
9
|
+
|
|
10
|
+
from eva_sub_cli import SUB_CLI_CONFIG_FILE, __version__
|
|
11
|
+
from eva_sub_cli.submission_ws import SubmissionWSClient
|
|
12
|
+
from eva_sub_cli.validators.validator import READY_FOR_SUBMISSION_TO_EVA
|
|
13
|
+
|
|
14
|
+
SUB_CLI_CONFIG_KEY_SUBMISSION_ID = "submission_id"
|
|
15
|
+
SUB_CLI_CONFIG_KEY_SUBMISSION_UPLOAD_URL = "submission_upload_url"
|
|
16
|
+
SUB_CLI_CONFIG_KEY_COMPLETE = "submission_complete"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class StudySubmitter(AppLogger):
|
|
20
|
+
def __init__(self, submission_dir, submission_config: WritableConfig = None, username=None, password=None):
|
|
21
|
+
self.submission_ws_client = SubmissionWSClient(username, password)
|
|
22
|
+
self.submission_dir = submission_dir
|
|
23
|
+
if submission_config:
|
|
24
|
+
self.sub_config = submission_config
|
|
25
|
+
else:
|
|
26
|
+
config_file = os.path.join(submission_dir, SUB_CLI_CONFIG_FILE)
|
|
27
|
+
self.sub_config = WritableConfig(config_file, version=__version__)
|
|
28
|
+
|
|
29
|
+
def __enter__(self):
|
|
30
|
+
return self
|
|
31
|
+
|
|
32
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
33
|
+
self.sub_config.backup()
|
|
34
|
+
self.sub_config.write()
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def metadata_json_file(self):
|
|
38
|
+
return self.sub_config.get('metadata_json')
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def metadata_json(self):
|
|
42
|
+
with open(self.metadata_json_file) as open_file:
|
|
43
|
+
return json.load(open_file)
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def vcf_files(self):
|
|
47
|
+
return self.sub_config.get('vcf_files')
|
|
48
|
+
|
|
49
|
+
def update_config_with_submission_id_and_upload_url(self, submission_id, upload_url):
|
|
50
|
+
self.sub_config.set(SUB_CLI_CONFIG_KEY_SUBMISSION_ID, value=submission_id)
|
|
51
|
+
self.sub_config.set(SUB_CLI_CONFIG_KEY_SUBMISSION_UPLOAD_URL, value=upload_url)
|
|
52
|
+
|
|
53
|
+
def _upload_submission(self):
|
|
54
|
+
if READY_FOR_SUBMISSION_TO_EVA not in self.sub_config or not self.sub_config[READY_FOR_SUBMISSION_TO_EVA]:
|
|
55
|
+
raise Exception(f'There are still validation errors that needs to be addressed. '
|
|
56
|
+
f'Please review, address and re-validate before uploading.')
|
|
57
|
+
|
|
58
|
+
submission_upload_url = self.sub_config[SUB_CLI_CONFIG_KEY_SUBMISSION_UPLOAD_URL]
|
|
59
|
+
|
|
60
|
+
for f in self.vcf_files:
|
|
61
|
+
self._upload_file(submission_upload_url, f)
|
|
62
|
+
|
|
63
|
+
@retry(tries=5, delay=10, backoff=5)
|
|
64
|
+
def _upload_file(self, submission_upload_url, input_file):
|
|
65
|
+
base_name = os.path.basename(input_file)
|
|
66
|
+
self.debug(f'Transfer {base_name} to EVA FTP')
|
|
67
|
+
with open(input_file, 'rb') as f:
|
|
68
|
+
r = requests.put(os.path.join(submission_upload_url, base_name), data=f)
|
|
69
|
+
r.raise_for_status()
|
|
70
|
+
self.debug(f'Upload of {base_name} completed')
|
|
71
|
+
|
|
72
|
+
def _initiate_submission(self):
|
|
73
|
+
response_json = self.submission_ws_client.initiate_submission()
|
|
74
|
+
self.debug(f'Submission ID {response_json["submissionId"]} received!!')
|
|
75
|
+
# update config with submission id and upload url
|
|
76
|
+
self.update_config_with_submission_id_and_upload_url(response_json["submissionId"], response_json["uploadUrl"])
|
|
77
|
+
|
|
78
|
+
def _complete_submission(self):
|
|
79
|
+
self.submission_ws_client.mark_submission_uploaded(self.sub_config.get(SUB_CLI_CONFIG_KEY_SUBMISSION_ID), self.metadata_json)
|
|
80
|
+
self.debug("Submission ID {} Complete".format(self.sub_config.get(SUB_CLI_CONFIG_KEY_SUBMISSION_ID)))
|
|
81
|
+
# update config with completion of the submission
|
|
82
|
+
self.sub_config.set(SUB_CLI_CONFIG_KEY_COMPLETE, value=True)
|
|
83
|
+
|
|
84
|
+
def is_submission_status_open(self):
|
|
85
|
+
submission_id = self.sub_config.get(SUB_CLI_CONFIG_KEY_SUBMISSION_ID)
|
|
86
|
+
if submission_id:
|
|
87
|
+
status = self.submission_ws_client.get_submission_status(submission_id)
|
|
88
|
+
return status == 'OPEN'
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
def submit(self):
|
|
92
|
+
if READY_FOR_SUBMISSION_TO_EVA not in self.sub_config or not self.sub_config[READY_FOR_SUBMISSION_TO_EVA]:
|
|
93
|
+
raise Exception(f'There are still validation errors that need to be addressed. '
|
|
94
|
+
f'Please review, address and re-validate before submitting.')
|
|
95
|
+
if not self.sub_config.get(SUB_CLI_CONFIG_KEY_SUBMISSION_UPLOAD_URL):
|
|
96
|
+
self.info(f'Initiate submission')
|
|
97
|
+
self._initiate_submission()
|
|
98
|
+
|
|
99
|
+
if not self.is_submission_status_open():
|
|
100
|
+
self.warning(f'You requested the submission using {self.submission_dir}. '
|
|
101
|
+
f'This directory contains an already completed submission. '
|
|
102
|
+
f'Please create a new submission directory to resubmit.')
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
# upload submission
|
|
106
|
+
self.info(f'Upload data')
|
|
107
|
+
self._upload_submission()
|
|
108
|
+
|
|
109
|
+
# Complete the submission
|
|
110
|
+
self.info(f'Complete submission')
|
|
111
|
+
self._complete_submission()
|
|
File without changes
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import csv
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
import subprocess
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
from ebi_eva_common_pyutils.logger import logging_config
|
|
8
|
+
from retry import retry
|
|
9
|
+
|
|
10
|
+
from eva_sub_cli.validators.validator import Validator
|
|
11
|
+
|
|
12
|
+
logger = logging_config.get_logger(__name__)
|
|
13
|
+
|
|
14
|
+
container_image = 'ebivariation/eva-sub-cli'
|
|
15
|
+
container_tag = 'v0.0.2'
|
|
16
|
+
container_validation_dir = '/opt/vcf_validation'
|
|
17
|
+
container_validation_output_dir = 'vcf_validation_output'
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class DockerValidator(Validator):
|
|
21
|
+
|
|
22
|
+
def __init__(self, mapping_file, submission_dir, project_title, metadata_json=None,
|
|
23
|
+
metadata_xlsx=None, shallow_validation=False, docker_path='docker', submission_config=None):
|
|
24
|
+
super().__init__(mapping_file, submission_dir, project_title,
|
|
25
|
+
metadata_json=metadata_json, metadata_xlsx=metadata_xlsx,
|
|
26
|
+
shallow_validation=shallow_validation, submission_config=submission_config)
|
|
27
|
+
self.docker_path = docker_path
|
|
28
|
+
submission_basename = re.sub('[^a-zA-Z0-9]', '', os.path.basename(submission_dir))
|
|
29
|
+
self.container_name = f'{container_image.split("/")[1]}.{container_tag}.{submission_basename}'
|
|
30
|
+
|
|
31
|
+
def _validate(self):
|
|
32
|
+
self.run_docker_validator()
|
|
33
|
+
|
|
34
|
+
@staticmethod
|
|
35
|
+
def _validation_file_path_for(file_path):
|
|
36
|
+
return f'{container_validation_dir}/{file_path}'
|
|
37
|
+
|
|
38
|
+
def get_docker_validation_cmd(self):
|
|
39
|
+
docker_cmd = ''.join([
|
|
40
|
+
f"{self.docker_path} exec {self.container_name} nextflow run eva_sub_cli/nextflow/validation.nf ",
|
|
41
|
+
f"--base_dir {container_validation_dir} ",
|
|
42
|
+
f"--vcf_files_mapping {self.mapping_file} ",
|
|
43
|
+
f"--metadata_xlsx {self.metadata_xlsx} " if self.metadata_xlsx and not self.metadata_json
|
|
44
|
+
else f"--metadata_json {self.metadata_json} ",
|
|
45
|
+
f"--shallow_validation true " if self.shallow_validation else "",
|
|
46
|
+
f"--output_dir {container_validation_output_dir}"
|
|
47
|
+
])
|
|
48
|
+
return docker_cmd
|
|
49
|
+
|
|
50
|
+
def run_docker_validator(self):
|
|
51
|
+
# check if docker container is ready for running validation
|
|
52
|
+
self.verify_docker_is_installed()
|
|
53
|
+
self.download_container_image_if_needed()
|
|
54
|
+
self.run_container_if_required()
|
|
55
|
+
try:
|
|
56
|
+
# remove all existing files from container
|
|
57
|
+
self._run_quiet_command(
|
|
58
|
+
"Remove existing files from validation directory in container",
|
|
59
|
+
f"{self.docker_path} exec {self.container_name} rm -rf work {container_validation_dir}"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# copy all required files to container (mapping file, vcf and fasta)
|
|
63
|
+
self.copy_files_to_container()
|
|
64
|
+
# create the output directory
|
|
65
|
+
self._run_quiet_command(
|
|
66
|
+
"Create output directory in container",
|
|
67
|
+
f"{self.docker_path} exec {self.container_name} mkdir -p {container_validation_dir}/{container_validation_output_dir}"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
docker_cmd = self.get_docker_validation_cmd()
|
|
71
|
+
# start validation
|
|
72
|
+
self._run_quiet_command("Run Validation using Nextflow", docker_cmd)
|
|
73
|
+
except subprocess.CalledProcessError as ex:
|
|
74
|
+
logger.error(ex)
|
|
75
|
+
finally:
|
|
76
|
+
# copy validation result to user host
|
|
77
|
+
self._run_quiet_command(
|
|
78
|
+
"Copy validation output from container to host",
|
|
79
|
+
f"{self.docker_path} cp {self.container_name}:{container_validation_dir}/{container_validation_output_dir}/. {self.output_dir}"
|
|
80
|
+
)
|
|
81
|
+
self.stop_running_container()
|
|
82
|
+
|
|
83
|
+
def verify_docker_is_installed(self):
|
|
84
|
+
try:
|
|
85
|
+
self._run_quiet_command(
|
|
86
|
+
"check docker is installed and available on the path",
|
|
87
|
+
f"{self.docker_path} --version"
|
|
88
|
+
)
|
|
89
|
+
except subprocess.CalledProcessError as ex:
|
|
90
|
+
logger.error(ex)
|
|
91
|
+
raise RuntimeError(f"Please make sure docker ({self.docker_path}) is installed and available on the path")
|
|
92
|
+
|
|
93
|
+
def verify_container_is_running(self):
|
|
94
|
+
try:
|
|
95
|
+
container_run_cmd_output = self._run_quiet_command("check if container is running", f"{self.docker_path} ps", return_process_output=True)
|
|
96
|
+
if container_run_cmd_output is not None and self.container_name in container_run_cmd_output:
|
|
97
|
+
logger.debug(f"Container ({self.container_name}) is running")
|
|
98
|
+
return True
|
|
99
|
+
else:
|
|
100
|
+
logger.debug(f"Container ({self.container_name}) is not running")
|
|
101
|
+
return False
|
|
102
|
+
except subprocess.CalledProcessError as ex:
|
|
103
|
+
logger.error(ex)
|
|
104
|
+
raise RuntimeError(f"Please make sure docker ({self.docker_path}) is installed and running in the background")
|
|
105
|
+
|
|
106
|
+
def verify_container_is_stopped(self):
|
|
107
|
+
container_stop_cmd_output = self._run_quiet_command(
|
|
108
|
+
"check if container is stopped", f"{self.docker_path} ps -a", return_process_output=True
|
|
109
|
+
)
|
|
110
|
+
if container_stop_cmd_output is not None and self.container_name in container_stop_cmd_output:
|
|
111
|
+
logger.debug(f"Container ({self.container_name}) is in stop state")
|
|
112
|
+
return True
|
|
113
|
+
else:
|
|
114
|
+
logger.debug(f"Container ({self.container_name}) is not in stop state")
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
def try_restarting_container(self):
|
|
118
|
+
logger.info(f"Trying to restart container {self.container_name}")
|
|
119
|
+
try:
|
|
120
|
+
self._run_quiet_command("Try restarting container", f"{self.docker_path} start {self.container_name}")
|
|
121
|
+
if not self.verify_container_is_running():
|
|
122
|
+
raise RuntimeError(f"Container ({self.container_name}) could not be restarted")
|
|
123
|
+
except subprocess.CalledProcessError as ex:
|
|
124
|
+
logger.error(ex)
|
|
125
|
+
raise RuntimeError(f"Container ({self.container_name}) could not be restarted")
|
|
126
|
+
|
|
127
|
+
def verify_image_available_locally(self):
|
|
128
|
+
container_images_cmd_ouptut = self._run_quiet_command(
|
|
129
|
+
"Check if validator image is present",
|
|
130
|
+
f"{self.docker_path} images",
|
|
131
|
+
return_process_output=True
|
|
132
|
+
)
|
|
133
|
+
if container_images_cmd_ouptut is not None and re.search(container_image + r'\s+' + container_tag, container_images_cmd_ouptut):
|
|
134
|
+
logger.debug(f"Container ({container_image}) image is available locally")
|
|
135
|
+
return True
|
|
136
|
+
else:
|
|
137
|
+
logger.debug(f"Container ({container_image}) image is not available locally")
|
|
138
|
+
return False
|
|
139
|
+
|
|
140
|
+
def run_container_if_required(self):
|
|
141
|
+
if self.verify_container_is_running():
|
|
142
|
+
raise RuntimeError(f"Container ({self.container_name}) is already running. "
|
|
143
|
+
f"Did you start multiple validation for the same directory ?")
|
|
144
|
+
if self.verify_container_is_stopped():
|
|
145
|
+
logger.warn(f"Container {self.container_name} was stopped but not cleaned up before.")
|
|
146
|
+
self.try_restarting_container()
|
|
147
|
+
else:
|
|
148
|
+
logger.debug(f"Trying to start container {self.container_name}")
|
|
149
|
+
try:
|
|
150
|
+
self._run_quiet_command(
|
|
151
|
+
"Running container",
|
|
152
|
+
f"{self.docker_path} run -it --rm -d --name {self.container_name} {container_image}:{container_tag}"
|
|
153
|
+
)
|
|
154
|
+
# Wait to give some time to container to get up and running
|
|
155
|
+
time.sleep(5)
|
|
156
|
+
if not self.verify_container_is_running():
|
|
157
|
+
raise RuntimeError(f"Container ({self.container_name}) could not be started")
|
|
158
|
+
except subprocess.CalledProcessError as ex:
|
|
159
|
+
logger.error(ex)
|
|
160
|
+
raise RuntimeError(f"Container ({self.container_name}) could not be started")
|
|
161
|
+
|
|
162
|
+
def stop_running_container(self):
|
|
163
|
+
if self.verify_container_is_running():
|
|
164
|
+
self._run_quiet_command(
|
|
165
|
+
"Stop the running container",
|
|
166
|
+
f"{self.docker_path} stop {self.container_name}"
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
@retry(RuntimeError, tries=3, delay=5, backoff=1, jitter=2, logger=logger)
|
|
170
|
+
def download_container_image_if_needed(self):
|
|
171
|
+
if not self.verify_image_available_locally():
|
|
172
|
+
logger.debug(f"Pulling container ({container_image}) image")
|
|
173
|
+
try:
|
|
174
|
+
self._run_quiet_command("pull container image", f"{self.docker_path} pull {container_image}:{container_tag}")
|
|
175
|
+
except subprocess.CalledProcessError as ex:
|
|
176
|
+
logger.error(ex)
|
|
177
|
+
raise RuntimeError(f"Cannot pull container ({container_image}) image")
|
|
178
|
+
# Give the pull command some time to complete
|
|
179
|
+
time.sleep(5)
|
|
180
|
+
|
|
181
|
+
def copy_files_to_container(self):
|
|
182
|
+
def _copy(file_description, file_path):
|
|
183
|
+
self._run_quiet_command(
|
|
184
|
+
f"Create directory structure for copying {file_description} into container",
|
|
185
|
+
(f"{self.docker_path} exec {self.container_name} "
|
|
186
|
+
f"mkdir -p {container_validation_dir}/{os.path.dirname(file_path)}")
|
|
187
|
+
)
|
|
188
|
+
self._run_quiet_command(
|
|
189
|
+
f"Copy {file_description} to container",
|
|
190
|
+
(f"{self.docker_path} cp {file_path} "
|
|
191
|
+
f"{self.container_name}:{container_validation_dir}/{file_path}")
|
|
192
|
+
)
|
|
193
|
+
_copy('vcf metadata file', self.mapping_file)
|
|
194
|
+
if self.metadata_json:
|
|
195
|
+
_copy('json metadata file', self.metadata_json)
|
|
196
|
+
if self.metadata_xlsx:
|
|
197
|
+
_copy('excel metadata file', self.metadata_xlsx)
|
|
198
|
+
with open(self.mapping_file) as open_file:
|
|
199
|
+
reader = csv.DictReader(open_file, delimiter=',')
|
|
200
|
+
for row in reader:
|
|
201
|
+
_copy('vcf files', row['vcf'])
|
|
202
|
+
_copy('fasta files', row['fasta'])
|
|
203
|
+
# report is optional
|
|
204
|
+
if row.get('report'):
|
|
205
|
+
_copy('assembly report files', row['report'])
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import subprocess
|
|
3
|
+
|
|
4
|
+
from ebi_eva_common_pyutils.logger import logging_config
|
|
5
|
+
|
|
6
|
+
from eva_sub_cli.validators.validator import Validator
|
|
7
|
+
|
|
8
|
+
logger = logging_config.get_logger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class NativeValidator(Validator):
|
|
12
|
+
|
|
13
|
+
def __init__(self, mapping_file, submission_dir, project_title, metadata_json=None, metadata_xlsx=None,
|
|
14
|
+
shallow_validation=False, vcf_validator_path='vcf_validator',
|
|
15
|
+
assembly_checker_path='vcf_assembly_checker', biovalidator_path='biovalidator',
|
|
16
|
+
submission_config=None):
|
|
17
|
+
super().__init__(mapping_file, submission_dir, project_title, metadata_json=metadata_json,
|
|
18
|
+
metadata_xlsx=metadata_xlsx, shallow_validation=shallow_validation,
|
|
19
|
+
submission_config=submission_config)
|
|
20
|
+
self.vcf_validator_path = vcf_validator_path
|
|
21
|
+
self.assembly_checker_path = assembly_checker_path
|
|
22
|
+
self.biovalidator_path = biovalidator_path
|
|
23
|
+
|
|
24
|
+
def _validate(self):
|
|
25
|
+
self.run_validator()
|
|
26
|
+
|
|
27
|
+
def run_validator(self):
|
|
28
|
+
self.verify_executables_installed()
|
|
29
|
+
curr_wd = os.getcwd()
|
|
30
|
+
try:
|
|
31
|
+
command = self.get_validation_cmd()
|
|
32
|
+
os.chdir(self.submission_dir)
|
|
33
|
+
self._run_quiet_command("Run Validation using Nextflow", command)
|
|
34
|
+
except subprocess.CalledProcessError as ex:
|
|
35
|
+
logger.error(ex)
|
|
36
|
+
finally:
|
|
37
|
+
os.chdir(curr_wd)
|
|
38
|
+
|
|
39
|
+
def get_validation_cmd(self):
|
|
40
|
+
if self.metadata_xlsx and not self.metadata_json:
|
|
41
|
+
metadata_flag = f"--metadata_xlsx {self.metadata_xlsx}"
|
|
42
|
+
else:
|
|
43
|
+
metadata_flag = f"--metadata_json {self.metadata_json}"
|
|
44
|
+
path_to_workflow = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
|
45
|
+
'nextflow/validation.nf')
|
|
46
|
+
return ''.join([
|
|
47
|
+
f"nextflow run {path_to_workflow} ",
|
|
48
|
+
f"--vcf_files_mapping {self.mapping_file} ",
|
|
49
|
+
f"{metadata_flag} ",
|
|
50
|
+
f"--output_dir {self.output_dir} ",
|
|
51
|
+
f"--shallow_validation true " if self.shallow_validation else "",
|
|
52
|
+
f"--executable.vcf_validator {self.vcf_validator_path} ",
|
|
53
|
+
f"--executable.vcf_assembly_checker {self.assembly_checker_path} ",
|
|
54
|
+
f"--executable.biovalidator {self.biovalidator_path}"
|
|
55
|
+
])
|
|
56
|
+
|
|
57
|
+
def verify_executables_installed(self):
|
|
58
|
+
for name, path in [('vcf-validator', self.vcf_validator_path),
|
|
59
|
+
('vcf-assembly-checker', self.assembly_checker_path),
|
|
60
|
+
('biovalidator', self.biovalidator_path)]:
|
|
61
|
+
try:
|
|
62
|
+
self._run_quiet_command(
|
|
63
|
+
f"Check {name} is installed and available on the path",
|
|
64
|
+
f"{path} --version"
|
|
65
|
+
)
|
|
66
|
+
except subprocess.CalledProcessError as ex:
|
|
67
|
+
logger.error(ex)
|
|
68
|
+
raise RuntimeError(f"Please make sure {name} ({path}) is installed and available on the path")
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
from ebi_eva_common_pyutils.logger import logging_config
|
|
4
|
+
|
|
5
|
+
logger = logging_config.get_logger(__name__)
|
|
6
|
+
|
|
7
|
+
def parse_assembly_check_log(assembly_check_log):
|
|
8
|
+
error_list = []
|
|
9
|
+
nb_error, nb_mismatch = 0, 0
|
|
10
|
+
match = total = None
|
|
11
|
+
with open(assembly_check_log) as open_file:
|
|
12
|
+
for line in open_file:
|
|
13
|
+
if line.startswith('[error]'):
|
|
14
|
+
nb_error += 1
|
|
15
|
+
if nb_error < 11:
|
|
16
|
+
error_list.append(line.strip()[len('[error] '):])
|
|
17
|
+
elif line.startswith('[info] Number of matches:'):
|
|
18
|
+
match, total = line.strip()[len('[info] Number of matches: '):].split('/')
|
|
19
|
+
match = int(match)
|
|
20
|
+
total = int(total)
|
|
21
|
+
return error_list, nb_error, match, total
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def parse_assembly_check_report(assembly_check_report):
|
|
25
|
+
mismatch_list = []
|
|
26
|
+
nb_mismatch = 0
|
|
27
|
+
nb_error = 0
|
|
28
|
+
error_list = []
|
|
29
|
+
with open(assembly_check_report) as open_file:
|
|
30
|
+
for line in open_file:
|
|
31
|
+
if 'does not match the reference sequence' in line:
|
|
32
|
+
nb_mismatch += 1
|
|
33
|
+
if nb_mismatch < 11:
|
|
34
|
+
mismatch_list.append(line.strip())
|
|
35
|
+
elif 'Multiple synonyms' in line:
|
|
36
|
+
nb_error += 1
|
|
37
|
+
if nb_error < 11:
|
|
38
|
+
error_list.append(line.strip())
|
|
39
|
+
# Contig not found in FASTA is reported here rather than in logs when no assembly report is used.
|
|
40
|
+
# Count and report once per contig name rather than once per line, to avoid redundant errors.
|
|
41
|
+
elif 'is not present in FASTA file' in line:
|
|
42
|
+
line_num, error_msg = line.split(': ')
|
|
43
|
+
error_msg = error_msg.strip()
|
|
44
|
+
if error_msg not in error_list:
|
|
45
|
+
nb_error += 1
|
|
46
|
+
if nb_error < 11:
|
|
47
|
+
error_list.append(error_msg)
|
|
48
|
+
return mismatch_list, nb_mismatch, error_list, nb_error
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def parse_vcf_check_report(vcf_check_report):
|
|
52
|
+
valid = True
|
|
53
|
+
max_error_reported = 10
|
|
54
|
+
error_list, critical_list = [], []
|
|
55
|
+
warning_count = error_count = critical_count = 0
|
|
56
|
+
with open(vcf_check_report) as open_file:
|
|
57
|
+
for line in open_file:
|
|
58
|
+
if 'warning' in line:
|
|
59
|
+
warning_count = 1
|
|
60
|
+
elif line.startswith('According to the VCF specification'):
|
|
61
|
+
if 'not' in line:
|
|
62
|
+
valid = False
|
|
63
|
+
elif vcf_check_errors_is_critical(line.strip()):
|
|
64
|
+
critical_count += 1
|
|
65
|
+
if critical_count <= max_error_reported:
|
|
66
|
+
critical_list.append(line.strip())
|
|
67
|
+
else:
|
|
68
|
+
error_count += 1
|
|
69
|
+
if error_count <= max_error_reported:
|
|
70
|
+
error_list.append(line.strip())
|
|
71
|
+
|
|
72
|
+
return valid, warning_count, error_count, critical_count, error_list, critical_list
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def vcf_check_errors_is_critical(error):
|
|
76
|
+
"""
|
|
77
|
+
This function identify VCF check errors that are not critical for the processing of the VCF within EVA.
|
|
78
|
+
They affect specific INFO or FORMAT fields that are used in the variant detection but less so in the downstream
|
|
79
|
+
analysis.
|
|
80
|
+
Critical:
|
|
81
|
+
Reference and alternate alleles must not be the same.
|
|
82
|
+
Requested evidence presence with --require-evidence. Please provide genotypes (GT field in FORMAT and samples),
|
|
83
|
+
or allele frequencies (AF field in INFO), or allele counts (AC and AN fields in INFO)..
|
|
84
|
+
Contig is not sorted by position. Contig chr10 position 41695506 found after 41883113.
|
|
85
|
+
Duplicated variant chr1A:1106203:A>G found.
|
|
86
|
+
Metadata description string is not valid.
|
|
87
|
+
|
|
88
|
+
Error
|
|
89
|
+
Sample #10, field PL does not match the meta specification Number=G (expected 2 value(s)). PL=.. It must derive
|
|
90
|
+
its number of values from the ploidy of GT (if present), or assume diploidy. Contains 1 value(s), expected 2
|
|
91
|
+
(derived from ploidy 1).
|
|
92
|
+
Sample #102, field AD does not match the meta specification Number=R (expected 3 value(s)). AD=..
|
|
93
|
+
"""
|
|
94
|
+
non_critical_format_fields = ['PL', 'AD', 'AC', 'GQ']
|
|
95
|
+
non_critical_info_fields = ['AC']
|
|
96
|
+
regexes = {
|
|
97
|
+
r'^INFO (\w+) does not match the specification Number': non_critical_info_fields,
|
|
98
|
+
r'^INFO (\w+) metadata Number is not ': non_critical_info_fields,
|
|
99
|
+
r'^Line \d+: Sample #\d+, field (\w+) does not match the meta specification Number=': non_critical_format_fields,
|
|
100
|
+
r'^Line \d+: FORMAT (\w+) metadata Type is not ': non_critical_format_fields,
|
|
101
|
+
r'^Line \d+: FORMAT (\w+) metadata Number is not ': non_critical_format_fields,
|
|
102
|
+
r'^Line \d+: INFO SVLEN must be equal to "length of ALT - length of REF" for non-symbolic alternate alleles. SVLEN=': None
|
|
103
|
+
}
|
|
104
|
+
for regex in regexes:
|
|
105
|
+
match = re.match(regex, error)
|
|
106
|
+
if match:
|
|
107
|
+
if regexes[regex] is None:
|
|
108
|
+
# No list of value to match against
|
|
109
|
+
return False
|
|
110
|
+
field_affected = match.group(1)
|
|
111
|
+
if field_affected in regexes[regex]:
|
|
112
|
+
return False
|
|
113
|
+
return True
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def parse_biovalidator_validation_results(metadata_check_file):
|
|
117
|
+
"""
|
|
118
|
+
Read the biovalidator's report and extract the list of validation errors
|
|
119
|
+
"""
|
|
120
|
+
ansi_escape = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')
|
|
121
|
+
|
|
122
|
+
def clean_read(ifile):
|
|
123
|
+
l = ifile.readline()
|
|
124
|
+
if l:
|
|
125
|
+
return ansi_escape.sub('', l).strip()
|
|
126
|
+
|
|
127
|
+
if not metadata_check_file:
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
errors = []
|
|
131
|
+
|
|
132
|
+
with open(metadata_check_file) as open_file:
|
|
133
|
+
collect = False
|
|
134
|
+
while True:
|
|
135
|
+
line = clean_read(open_file)
|
|
136
|
+
if line is None:
|
|
137
|
+
break # EOF
|
|
138
|
+
elif not line:
|
|
139
|
+
continue # Empty line
|
|
140
|
+
if not collect:
|
|
141
|
+
if line.startswith('Validation failed with following error(s):'):
|
|
142
|
+
collect = True
|
|
143
|
+
else:
|
|
144
|
+
while line and not line.startswith('/'):
|
|
145
|
+
# Sometimes there are multiple (possibly redundant) errors listed under a single property,
|
|
146
|
+
# we only report the first
|
|
147
|
+
line = clean_read(open_file)
|
|
148
|
+
line2 = clean_read(open_file)
|
|
149
|
+
if line is None or line2 is None:
|
|
150
|
+
break # EOF
|
|
151
|
+
errors.append({'property': line, 'description': line2})
|
|
152
|
+
return errors
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def convert_metadata_sheet(json_attribute, xls2json_conf):
|
|
156
|
+
if json_attribute is None:
|
|
157
|
+
return None
|
|
158
|
+
for sheet_name in xls2json_conf['worksheets']:
|
|
159
|
+
if xls2json_conf['worksheets'][sheet_name] == json_attribute:
|
|
160
|
+
return sheet_name
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def convert_metadata_row(sheet, json_row, xls2json_conf):
|
|
164
|
+
if json_row is None:
|
|
165
|
+
return ''
|
|
166
|
+
if 'header_row' in xls2json_conf[sheet]:
|
|
167
|
+
return int(json_row) + xls2json_conf[sheet]['header_row']
|
|
168
|
+
else:
|
|
169
|
+
return int(json_row) + 2
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def convert_metadata_attribute(sheet, json_attribute, xls2json_conf):
|
|
173
|
+
if json_attribute is None:
|
|
174
|
+
return ''
|
|
175
|
+
attributes_dict = {}
|
|
176
|
+
attributes_dict.update(xls2json_conf[sheet].get('required', {}))
|
|
177
|
+
attributes_dict.update(xls2json_conf[sheet].get('optional', {}))
|
|
178
|
+
attributes_dict['Scientific Name'] = 'species'
|
|
179
|
+
attributes_dict['BioSample Name'] = 'name'
|
|
180
|
+
|
|
181
|
+
for attribute in attributes_dict:
|
|
182
|
+
if attributes_dict[attribute] == json_attribute:
|
|
183
|
+
return attribute
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def parse_metadata_property(property_str):
|
|
187
|
+
if property_str.startswith('.'):
|
|
188
|
+
return property_str.strip('./'), None, None
|
|
189
|
+
# First attempt to parse as BioSample object
|
|
190
|
+
sheet, row, col = parse_sample_metadata_property(property_str)
|
|
191
|
+
if sheet is not None and row is not None and col is not None:
|
|
192
|
+
return sheet, row, col
|
|
193
|
+
match = re.match(r'/(\w+)(/(\d+))?([./](\w+))?', property_str)
|
|
194
|
+
if match:
|
|
195
|
+
return match.group(1), match.group(3), match.group(5)
|
|
196
|
+
else:
|
|
197
|
+
logger.error(f'Cannot parse {property_str} in JSON metadata error')
|
|
198
|
+
return None, None, None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def parse_sample_metadata_property(property_str):
|
|
202
|
+
# Check characteristics
|
|
203
|
+
match = re.match(r'/sample/(\d+)/bioSampleObject/characteristics/(\w+)', property_str)
|
|
204
|
+
if match:
|
|
205
|
+
return 'sample', match.group(1), match.group(2)
|
|
206
|
+
# Check name
|
|
207
|
+
match = re.match(r'/sample/(\d+)/bioSampleObject/name', property_str)
|
|
208
|
+
if match:
|
|
209
|
+
return 'sample', match.group(1), 'name'
|
|
210
|
+
return None, None, None
|