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
@@ -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