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.
Files changed (46) hide show
  1. eva_sub_cli/__init__.py +27 -0
  2. eva_sub_cli/_version.py +21 -0
  3. eva_sub_cli/auth.py +106 -0
  4. eva_sub_cli/date_utils.py +32 -0
  5. eva_sub_cli/etc/EVA_Submission_template.xlsx +0 -0
  6. eva_sub_cli/etc/eva-biosamples.json +174 -0
  7. eva_sub_cli/etc/eva_logo.png +0 -0
  8. eva_sub_cli/etc/eva_schema.json +369 -0
  9. eva_sub_cli/etc/spreadsheet2json_conf.yaml +135 -0
  10. eva_sub_cli/exceptions/__init__.py +0 -0
  11. eva_sub_cli/exceptions/invalid_file_type_exception.py +4 -0
  12. eva_sub_cli/exceptions/submission_not_found_exception.py +4 -0
  13. eva_sub_cli/exceptions/submission_status_exception.py +5 -0
  14. eva_sub_cli/executables/__init__.py +0 -0
  15. eva_sub_cli/executables/check_fasta_insdc.py +159 -0
  16. eva_sub_cli/executables/check_metadata_semantics.py +18 -0
  17. eva_sub_cli/executables/cli.py +115 -0
  18. eva_sub_cli/executables/samples_checker.py +144 -0
  19. eva_sub_cli/executables/trim_down.py +78 -0
  20. eva_sub_cli/executables/xlsx2json.py +335 -0
  21. eva_sub_cli/file_utils.py +117 -0
  22. eva_sub_cli/jinja_templates/fasta_check.html +86 -0
  23. eva_sub_cli/jinja_templates/file_validation.html +93 -0
  24. eva_sub_cli/jinja_templates/html_report.html +142 -0
  25. eva_sub_cli/jinja_templates/metadata_validation.html +52 -0
  26. eva_sub_cli/jinja_templates/project_details.html +37 -0
  27. eva_sub_cli/jinja_templates/sample_name_check.html +60 -0
  28. eva_sub_cli/jinja_templates/shallow_validation.html +29 -0
  29. eva_sub_cli/metadata_utils.py +46 -0
  30. eva_sub_cli/nextflow/validation.nf +303 -0
  31. eva_sub_cli/orchestrator.py +233 -0
  32. eva_sub_cli/report.py +40 -0
  33. eva_sub_cli/semantic_metadata.py +243 -0
  34. eva_sub_cli/submission_ws.py +61 -0
  35. eva_sub_cli/submit.py +111 -0
  36. eva_sub_cli/validators/__init__.py +0 -0
  37. eva_sub_cli/validators/docker_validator.py +205 -0
  38. eva_sub_cli/validators/native_validator.py +68 -0
  39. eva_sub_cli/validators/validation_results_parsers.py +210 -0
  40. eva_sub_cli/validators/validator.py +510 -0
  41. eva_sub_cli-0.4.4.dist-info/METADATA +164 -0
  42. eva_sub_cli-0.4.4.dist-info/RECORD +46 -0
  43. eva_sub_cli-0.4.4.dist-info/WHEEL +5 -0
  44. eva_sub_cli-0.4.4.dist-info/entry_points.txt +7 -0
  45. eva_sub_cli-0.4.4.dist-info/licenses/LICENSE +201 -0
  46. 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