From 43db4759634ed62fbfab4098359bee0fa4daf194 Mon Sep 17 00:00:00 2001 From: tcezard Date: Fri, 26 Jan 2024 12:11:44 +0000 Subject: [PATCH 1/7] Extract logic from the executable to put it in the library --- eva_sub_cli/main.py | 64 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100755 eva_sub_cli/main.py diff --git a/eva_sub_cli/main.py b/eva_sub_cli/main.py new file mode 100755 index 0000000..d0f3755 --- /dev/null +++ b/eva_sub_cli/main.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python +import csv +import os +first attempt +from ebi_eva_common_pyutils.config import WritableConfig +from ebi_eva_common_pyutils.logger import logging_config + +from eva_sub_cli import SUB_CLI_CONFIG_FILE, __version__ +from eva_sub_cli.docker_validator import DockerValidator, docker_path, container_image +from eva_sub_cli.reporter import READY_FOR_SUBMISSION_TO_EVA +from eva_sub_cli.submit import StudySubmitter + +VALIDATE = 'validate' +SUBMIT = 'submit' + + +logging_config.add_stdout_handler() + + +def get_vcf_files(mapping_file): + vcf_files = [] + with open(mapping_file) as open_file: + reader = csv.DictReader(open_file, delimiter=',') + for row in reader: + vcf_files.append(row['vcf']) + return vcf_files + + +def orchestrate_process(submission_dir, vcf_files_mapping, metadata_json, metadata_xlsx, task): + # load config + config_file_path = os.path.join(submission_dir, SUB_CLI_CONFIG_FILE) + sub_config = WritableConfig(config_file_path, version=__version__) + + metadata_file = metadata_json or metadata_xlsx + vcf_files = get_vcf_files(vcf_files_mapping) + + + + # Only run validate if it's been requested + if VALIDATE in tasks: + with DockerValidator(vcf_files_mapping, submission_dir, metadata_json, metadata_xlsx, + submission_config=sub_config) as validator: + validator.validate() + validator.create_reports() + validator.update_config_with_validation_result() + + with StudySubmitter(submission_dir, vcf_files, metadata_file, submission_config=sub_config) as submitter: + submitter.upload_submission() + # if validation is not passed, process task submit (validate and submit) + if READY_FOR_SUBMISSION_TO_EVA in sub_config and sub_config[READY_FOR_SUBMISSION_TO_EVA]: + tasks = SUBMIT + else: + # if validation is passed, upload files without validating again + + if task == VALIDATE or task == SUBMIT: + with DockerValidator(vcf_files_mapping, submission_dir, metadata_json, metadata_xlsx, + submission_config=sub_config) as validator: + validator.validate() + validator.create_reports() + validator.update_config_with_validation_result() + + if task == SUBMIT: + with StudySubmitter(submission_dir, vcf_files, metadata_file, submission_config=sub_config) as submitter: + submitter.submit() From da8351344525cc8069edcef20b2e2c0b32c65df4 Mon Sep 17 00:00:00 2001 From: tcezard Date: Mon, 29 Jan 2024 09:11:23 +0000 Subject: [PATCH 2/7] Extract logic from the executable to put it in the library reorganise orchestration to ensure the validation only has to be run when the was not successfully run before --- bin/eva-sub-cli.py | 72 ++++++++------------------------- eva_sub_cli/auth.py | 2 +- eva_sub_cli/main.py | 31 ++++----------- eva_sub_cli/submit.py | 36 ++++++++--------- tests/test_main.py | 93 +++++++++++++++++++++++++++++++++++++++++++ tests/test_submit.py | 10 ++--- 6 files changed, 141 insertions(+), 103 deletions(-) create mode 100644 tests/test_main.py diff --git a/bin/eva-sub-cli.py b/bin/eva-sub-cli.py index 6f20326..26fba4b 100755 --- a/bin/eva-sub-cli.py +++ b/bin/eva-sub-cli.py @@ -1,36 +1,19 @@ #!/usr/bin/env python -import csv -import os + from argparse import ArgumentParser -from ebi_eva_common_pyutils.config import WritableConfig from ebi_eva_common_pyutils.logger import logging_config -from eva_sub_cli import SUB_CLI_CONFIG_FILE, __version__ -from eva_sub_cli.docker_validator import DockerValidator, docker_path, container_image -from eva_sub_cli.reporter import READY_FOR_SUBMISSION_TO_EVA -from eva_sub_cli.submit import StudySubmitter - -VALIDATE = 'validate' -SUBMIT = 'submit' -RESUME_SUBMISSION = 'resume_submission' - -logging_config.add_stdout_handler() - - -def get_vcf_files(mapping_file): - vcf_files = [] - with open(mapping_file) as open_file: - reader = csv.DictReader(open_file, delimiter=',') - for row in reader: - vcf_files.append(row['vcf']) - return vcf_files +from eva_sub_cli import main +from eva_sub_cli.main import VALIDATE, SUBMIT if __name__ == "__main__": argparser = ArgumentParser(description='EVA Submission CLI - validate and submit data to EVA') - argparser.add_argument('--task', required=True, choices=[VALIDATE, SUBMIT, RESUME_SUBMISSION], - help='Select a task to perform') + argparser.add_argument('--tasks', nargs='*', choices=[VALIDATE, SUBMIT], default=[SUBMIT], + help='Select a task to perform. Stating VALIDATE run the validation regardless of the ' + 'previous runs, Stating SUBMIT run validate only if the validation was not performed ' + 'successfully before and run the submission.') argparser.add_argument('--submission_dir', required=True, type=str, help='Full path to the directory where all processing will be done ' 'and submission info is/will be stored') @@ -41,38 +24,17 @@ def get_vcf_files(mapping_file): help="Json file that describe the project, analysis, samples and files") group.add_argument("--metadata_xlsx", help="Excel spreadsheet that describe the project, analysis, samples and files") - group.add_argument("--username", - help="Username used for connecting to the ENA webin account") - group.add_argument("--password", - help="Password used for connecting to the ENA webin account") + argparser.add_argument("--username", + help="Username used for connecting to the ENA webin account") + argparser.add_argument("--password", + help="Password used for connecting to the ENA webin account") + argparser.add_argument("--resume", default=False, action='store_true', + help="Resume the process execution from where it left of. This is only supported for a " + "subset of the tasks") args = argparser.parse_args() - # load config - config_file_path = os.path.join(args.submission_dir, SUB_CLI_CONFIG_FILE) - sub_config = WritableConfig(config_file_path, version=__version__) - - vcf_files = get_vcf_files(args.vcf_files_mapping) - metadata_file = args.metadata_json or args.metadata_xlsx - - if args.task == RESUME_SUBMISSION: - # if validation is not passed, process task submit (validate and submit) - if READY_FOR_SUBMISSION_TO_EVA not in sub_config or not sub_config[READY_FOR_SUBMISSION_TO_EVA]: - args.task = SUBMIT - else: - # if validation is passed, upload files without validating again - with StudySubmitter(args.submission_dir, vcf_files, metadata_file, submission_config=sub_config, - username=args.username, password=args.password) as submitter: - submitter.upload_submission() - - if args.task == VALIDATE or args.task == SUBMIT: - with DockerValidator(args.vcf_files_mapping, args.submission_dir, args.metadata_json, args.metadata_xlsx, - submission_config=sub_config) as validator: - validator.validate() - validator.create_reports() - validator.update_config_with_validation_result() + logging_config.add_stdout_handler() - if args.task == SUBMIT: - with StudySubmitter(args.submission_dir, vcf_files, metadata_file, submission_config=sub_config, - username=args.username, password=args.password) as submitter: - submitter.submit() + main.orchestrate_process(args.submission_dir, args.vcf_files_mapping, args.metadata_json, args.metadata_xlsx, + args.tasks, args.resume) diff --git a/eva_sub_cli/auth.py b/eva_sub_cli/auth.py index ee0f35c..3f8eb9d 100644 --- a/eva_sub_cli/auth.py +++ b/eva_sub_cli/auth.py @@ -102,5 +102,5 @@ def get_auth(username=None, password=None): global auth if auth: return auth - auth = WebinAuth(username, password) + auth = WebinAuth(username=username, password=password) return auth diff --git a/eva_sub_cli/main.py b/eva_sub_cli/main.py index d0f3755..9ff26a3 100755 --- a/eva_sub_cli/main.py +++ b/eva_sub_cli/main.py @@ -1,12 +1,11 @@ #!/usr/bin/env python import csv import os -first attempt from ebi_eva_common_pyutils.config import WritableConfig from ebi_eva_common_pyutils.logger import logging_config from eva_sub_cli import SUB_CLI_CONFIG_FILE, __version__ -from eva_sub_cli.docker_validator import DockerValidator, docker_path, container_image +from eva_sub_cli.docker_validator import DockerValidator from eva_sub_cli.reporter import READY_FOR_SUBMISSION_TO_EVA from eva_sub_cli.submit import StudySubmitter @@ -26,7 +25,7 @@ def get_vcf_files(mapping_file): return vcf_files -def orchestrate_process(submission_dir, vcf_files_mapping, metadata_json, metadata_xlsx, task): +def orchestrate_process(submission_dir, vcf_files_mapping, metadata_json, metadata_xlsx, tasks, resume): # load config config_file_path = os.path.join(submission_dir, SUB_CLI_CONFIG_FILE) sub_config = WritableConfig(config_file_path, version=__version__) @@ -34,31 +33,17 @@ def orchestrate_process(submission_dir, vcf_files_mapping, metadata_json, metada metadata_file = metadata_json or metadata_xlsx vcf_files = get_vcf_files(vcf_files_mapping) + # Validation is mandatory so if submit is requested then VALIDATE must have run before or be requested as well + if SUBMIT in tasks and not sub_config.get(READY_FOR_SUBMISSION_TO_EVA, False): + if VALIDATE not in tasks: + tasks.append(VALIDATE) - - # Only run validate if it's been requested if VALIDATE in tasks: with DockerValidator(vcf_files_mapping, submission_dir, metadata_json, metadata_xlsx, submission_config=sub_config) as validator: validator.validate() validator.create_reports() validator.update_config_with_validation_result() - - with StudySubmitter(submission_dir, vcf_files, metadata_file, submission_config=sub_config) as submitter: - submitter.upload_submission() - # if validation is not passed, process task submit (validate and submit) - if READY_FOR_SUBMISSION_TO_EVA in sub_config and sub_config[READY_FOR_SUBMISSION_TO_EVA]: - tasks = SUBMIT - else: - # if validation is passed, upload files without validating again - - if task == VALIDATE or task == SUBMIT: - with DockerValidator(vcf_files_mapping, submission_dir, metadata_json, metadata_xlsx, - submission_config=sub_config) as validator: - validator.validate() - validator.create_reports() - validator.update_config_with_validation_result() - - if task == SUBMIT: + if SUBMIT in tasks: with StudySubmitter(submission_dir, vcf_files, metadata_file, submission_config=sub_config) as submitter: - submitter.submit() + submitter.submit(resume=resume) diff --git a/eva_sub_cli/submit.py b/eva_sub_cli/submit.py index e652253..7a9b8ad 100644 --- a/eva_sub_cli/submit.py +++ b/eva_sub_cli/submit.py @@ -41,20 +41,19 @@ def update_config_with_submission_id_and_upload_url(self, submission_id, upload_ self.sub_config.set(SUB_CLI_CONFIG_KEY_SUBMISSION_ID, value=submission_id) self.sub_config.set(SUB_CLI_CONFIG_KEY_SUBMISSION_UPLOAD_URL, value=upload_url) - def upload_submission(self, submission_upload_url=None): + def _upload_submission(self): if READY_FOR_SUBMISSION_TO_EVA not in self.sub_config or not self.sub_config[READY_FOR_SUBMISSION_TO_EVA]: raise Exception(f'There are still validation errors that needs to be addressed. ' f'Please review, address and re-validate before uploading.') - if not submission_upload_url: - submission_upload_url = self.sub_config[SUB_CLI_CONFIG_KEY_SUBMISSION_UPLOAD_URL] + submission_upload_url = self.sub_config[SUB_CLI_CONFIG_KEY_SUBMISSION_UPLOAD_URL] for f in self.vcf_files: - self.upload_file(submission_upload_url, f) - self.upload_file(submission_upload_url, self.metadata_file) + self._upload_file(submission_upload_url, f) + self._upload_file(submission_upload_url, self.metadata_file) @retry(tries=5, delay=10, backoff=5) - def upload_file(self, submission_upload_url, input_file): + def _upload_file(self, submission_upload_url, input_file): base_name = os.path.basename(input_file) self.info(f'Transfer {base_name} to EVA FTP') r = requests.put(urljoin(submission_upload_url, base_name), data=open(input_file, 'rb')) @@ -67,21 +66,20 @@ def verify_submission_dir(self, submission_dir): if not os.access(submission_dir, os.W_OK): raise Exception(f"The directory '{submission_dir}' does not have write permissions.") - def submit(self): + def submit(self, resume=False): if READY_FOR_SUBMISSION_TO_EVA not in self.sub_config or not self.sub_config[READY_FOR_SUBMISSION_TO_EVA]: raise Exception(f'There are still validation errors that need to be addressed. ' f'Please review, address and re-validate before submitting.') - - self.verify_submission_dir(self.submission_dir) - response = requests.post(self.submission_initiate_url, - headers={'Accept': 'application/hal+json', - 'Authorization': 'Bearer ' + self.auth.token}) - response.raise_for_status() - response_json = response.json() - self.info("Submission ID {} received!!".format(response_json["submissionId"])) - - # update config with submission id and upload url - self.update_config_with_submission_id_and_upload_url(response_json["submissionId"], response_json["uploadUrl"]) + if not (resume or self.sub_config.get(SUB_CLI_CONFIG_KEY_SUBMISSION_UPLOAD_URL)): + self.verify_submission_dir(self.submission_dir) + response = requests.post(self.submission_initiate_url, + headers={'Accept': 'application/hal+json', + 'Authorization': 'Bearer ' + self.auth.token}) + response.raise_for_status() + response_json = response.json() + self.info("Submission ID {} received!!".format(response_json["submissionId"])) + # update config with submission id and upload url + self.update_config_with_submission_id_and_upload_url(response_json["submissionId"], response_json["uploadUrl"]) # upload submission - self.upload_submission(response_json["uploadUrl"]) + self._upload_submission() diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..2fb4fab --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,93 @@ +import json +import os +import shutil +import unittest +from unittest.mock import MagicMock, patch, Mock + +import yaml +from ebi_eva_common_pyutils.config import WritableConfig + +from eva_sub_cli import SUB_CLI_CONFIG_FILE +from eva_sub_cli.main import orchestrate_process, VALIDATE, SUBMIT +from eva_sub_cli.reporter import READY_FOR_SUBMISSION_TO_EVA +from eva_sub_cli.submit import StudySubmitter, SUB_CLI_CONFIG_KEY_SUBMISSION_ID, SUB_CLI_CONFIG_KEY_SUBMISSION_UPLOAD_URL + + +class TestMain(unittest.TestCase): + resource_dir = os.path.join(os.path.dirname(__file__), 'resources') + test_sub_dir = os.path.join(resource_dir, 'test_sub_dir') + config_file = os.path.join(test_sub_dir, SUB_CLI_CONFIG_FILE) + + mapping_file = os.path.join(test_sub_dir, 'vcf_files_metadata.csv') + metadata_json = os.path.join(test_sub_dir, 'sub_metadata.json') + metadata_xlsx = os.path.join(test_sub_dir, 'sub_metadata.xlsx') + + def test_orchestrate_validate(self): + with patch('eva_sub_cli.main.get_vcf_files') as m_get_vcf, \ + patch('eva_sub_cli.main.WritableConfig') as m_config, \ + patch('eva_sub_cli.main.DockerValidator') as m_docker_validator: + orchestrate_process( + self.test_sub_dir, self.mapping_file, self.metadata_json, self.metadata_xlsx, tasks=[VALIDATE], + resume=False + ) + m_get_vcf.assert_called_once_with(self.mapping_file) + m_docker_validator.assert_any_call( + self.mapping_file, self.test_sub_dir, self.metadata_json, self.metadata_xlsx, + submission_config=m_config.return_value + ) + with m_docker_validator() as validator: + validator.validate.assert_called_once_with() + validator.create_reports.assert_called_once_with() + validator.update_config_with_validation_result.assert_called_once_with() + + + def test_orchestrate_validate_submit(self): + with patch('eva_sub_cli.main.get_vcf_files') as m_get_vcf, \ + patch('eva_sub_cli.main.WritableConfig') as m_config, \ + patch('eva_sub_cli.main.DockerValidator') as m_docker_validator, \ + patch('eva_sub_cli.main.StudySubmitter') as m_submitter: + # Empty config + m_config.return_value = {} + + orchestrate_process( + self.test_sub_dir, self.mapping_file, self.metadata_json, self.metadata_xlsx, tasks=[SUBMIT], + resume=False + ) + m_get_vcf.assert_called_once_with(self.mapping_file) + # Validate was run because the config show it was not run successfully before + m_docker_validator.assert_any_call( + self.mapping_file, self.test_sub_dir, self.metadata_json, self.metadata_xlsx, + submission_config=m_config.return_value + ) + with m_docker_validator() as validator: + validator.validate.assert_called_once_with() + validator.create_reports.assert_called_once_with() + validator.update_config_with_validation_result.assert_called_once_with() + + # Submit was created + m_submitter.assert_any_call(self.test_sub_dir, m_get_vcf.return_value, self.metadata_json, + submission_config=m_config.return_value) + with m_submitter() as submitter: + submitter.submit.assert_called_once_with(resume=False) + + def test_orchestrate_validate_no_submit(self): + with patch('eva_sub_cli.main.get_vcf_files') as m_get_vcf, \ + patch('eva_sub_cli.main.WritableConfig') as m_config, \ + patch('eva_sub_cli.main.DockerValidator') as m_docker_validator, \ + patch('eva_sub_cli.main.StudySubmitter') as m_submitter: + # Empty config + m_config.return_value = {READY_FOR_SUBMISSION_TO_EVA: True} + + orchestrate_process( + self.test_sub_dir, self.mapping_file, self.metadata_json, self.metadata_xlsx, tasks=[SUBMIT], + resume=False + ) + m_get_vcf.assert_called_once_with(self.mapping_file) + # Validate was not run because the config showed it was run successfully before + assert m_docker_validator.call_count == 0 + + # Submit was created + m_submitter.assert_any_call(self.test_sub_dir, m_get_vcf.return_value, self.metadata_json, + submission_config=m_config.return_value) + with m_submitter() as submitter: + submitter.submit.assert_called_once_with(resume=False) diff --git a/tests/test_submit.py b/tests/test_submit.py index 84970ed..c2c1bc5 100644 --- a/tests/test_submit.py +++ b/tests/test_submit.py @@ -40,7 +40,7 @@ def test_submit(self): # Set the side_effect attribute to return different responses with patch('eva_sub_cli.submit.requests.post', return_value=mock_submit_response) as mock_post, \ - patch.object(StudySubmitter, 'upload_submission'), \ + patch.object(StudySubmitter, '_upload_submission'), \ patch.object(StudySubmitter, 'verify_submission_dir'), \ patch.object(StudySubmitter, 'update_config_with_submission_id_and_upload_url'), \ patch.object(self.submitter, 'sub_config', {READY_FOR_SUBMISSION_TO_EVA: True}), \ @@ -63,7 +63,7 @@ def test_submit_with_config(self): sub_config.write() with patch('eva_sub_cli.submit.requests.post', return_value=mock_submit_response) as mock_post, \ - patch.object(StudySubmitter, 'upload_submission'): + patch.object(StudySubmitter, '_upload_submission'): with self.submitter as submitter: submitter.submit() @@ -104,8 +104,8 @@ def test_upload_submission(self): test_url = 'http://example.com/' with patch.object(StudySubmitter, 'upload_file') as mock_upload_file, \ patch.object(self.submitter, 'sub_config', {READY_FOR_SUBMISSION_TO_EVA: True}): - - self.submitter.upload_submission(submission_upload_url=test_url) + self.submitter.sub_config[SUB_CLI_CONFIG_KEY_SUBMISSION_UPLOAD_URL] = test_url + self.submitter._upload_submission() for vcf_file in self.submitter.vcf_files: mock_upload_file.assert_any_call(test_url, vcf_file) mock_upload_file.assert_called_with(test_url, self.submitter.metadata_file) @@ -114,6 +114,6 @@ def test_upload_file(self): test_url = 'http://example.com/' with patch('eva_sub_cli.submit.requests.put') as mock_put: file_to_upload = os.path.join(self.resource_dir, 'EVA_Submission_template.V1.1.4.xlsx') - self.submitter.upload_file(submission_upload_url=test_url, input_file=file_to_upload) + self.submitter._upload_file(submission_upload_url=test_url, input_file=file_to_upload) assert mock_put.mock_calls[0][1][0] == test_url + os.path.basename(file_to_upload) # Cannot test the content of the upload as opening the same file twice give different object From 2c259d8454b1e4055c12e277a1dfba067259d388 Mon Sep 17 00:00:00 2001 From: tcezard Date: Mon, 29 Jan 2024 14:56:25 +0000 Subject: [PATCH 3/7] refactor and add some documentation --- README.md | 55 ++++ eva_sub_cli/etc/EVA_Submission_template.xlsx | Bin 0 -> 39036 bytes eva_sub_cli/native_validator.py | 275 ++++++++++++++++++ ...e.V1.1.4.xlsx => EVA_Submission_test.xlsx} | Bin tests/test_docker_validator.py | 2 +- tests/test_submit.py | 6 +- tests/test_xlsx2json.py | 2 +- 7 files changed, 335 insertions(+), 5 deletions(-) create mode 100644 eva_sub_cli/etc/EVA_Submission_template.xlsx create mode 100644 eva_sub_cli/native_validator.py rename tests/resources/{EVA_Submission_template.V1.1.4.xlsx => EVA_Submission_test.xlsx} (100%) diff --git a/README.md b/README.md index d7f8638..4beeb8f 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,57 @@ # eva-sub-cli EVA Submission Command Line Interface for Validation + + + + +## Installation + + +## input file for the validation and submission tool + +### The VCF file and association with reference genome + +The path to the VCF files are provided via CSV file that links the VCF to their respective fasta sequence. This allows +us to support different assemblies for each VCF file +The CSV file `vcf_mapping.csv` contains the following columns vcf, fasta, report providing respectively: + - The VCF to validatio/upload + - The assembly in fasta format that was used to derive the VCF + - The assembly report associated with the assembly (if available) as found in NCBI assemblies (https://www.ncbi.nlm.nih.gov/genome/doc/ftpfaq/#files) + + +Example: +```shell +vcf,fasta,report +/full/path/to/vcf_file1.vcf,/full/path/to/genome.fa,/full/path/to/genome_assembly_report.txt +/full/path/to/vcf_file2.vcf,/full/path/to/genome.fa,/full/path/to/genome_assembly_report.txt +/full/path/to/vcf_file3.vcf,/full/path/to/genome2.fa,/full/path/to/genome_assembly_report2.txt +``` + +### The metadata spreadsheet + +The metadata template can be found within the etc folder at `eva_sub_cli/etc/EVA_Submission_template.xlsx` +It should be populated following the instruction provided within the template + +## Execution + +### Validate and submit you dataset + +To validate and submit run the following command + +```shell +eva-sub-cli.py --metadata_xlsx metadata_spreadsheet.xlsx \ + --vcf_files_mapping vcf_mapping.csv --submission_dir submission_dir +``` + +### Validate only + +To validate and not submit run the following command + +```shell +eva-sub-cli.py --metadata_xlsx metadata_spreadsheet.xlsx \ + --vcf_files_mapping vcf_mapping.csv --submission_dir submission_dir + --tasks VALIDATE +``` +### Submit only + +All submission must have been validated. You cannot run the submission without validation diff --git a/eva_sub_cli/etc/EVA_Submission_template.xlsx b/eva_sub_cli/etc/EVA_Submission_template.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..6b2d0ef56a92978627f5dbcde71cc4bee0c20feb GIT binary patch literal 39036 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%0D_)6T2*%jo!xO1j$fnk0x358Xo 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 Ir%eL!07dqVI{*Lx literal 0 HcmV?d00001 diff --git a/eva_sub_cli/native_validator.py b/eva_sub_cli/native_validator.py new file mode 100644 index 0000000..3e6ff5e --- /dev/null +++ b/eva_sub_cli/native_validator.py @@ -0,0 +1,275 @@ +import argparse +import csv +import os +import re +import subprocess +import time + +from ebi_eva_common_pyutils.command_utils import run_command_with_output + +from eva_sub_cli import ETC_DIR +from eva_sub_cli.reporter import Reporter +from ebi_eva_common_pyutils.logger import logging_config + +logger = logging_config.get_logger(__name__) + +docker_path = 'docker' +container_image = 'ebivariation/eva-sub-cli' +container_tag = 'v0.0.1.dev2' +container_validation_dir = '/opt/vcf_validation' +container_validation_output_dir = '/opt/vcf_validation/vcf_validation_output' +container_etc_dir = '/opt/eva_sub_cli/etc' + +VALIDATION_OUTPUT_DIR = "validation_output" + + +class NativeValidator(Reporter): + + def __init__(self, mapping_file, output_dir, metadata_json=None, + metadata_xlsx=None, submission_config=None): + self.mapping_file = mapping_file + self.metadata_json = metadata_json + self.metadata_xlsx = metadata_xlsx + + self.spreadsheet2json_conf = os.path.join(ETC_DIR, "spreadsheet2json_conf.yaml") + # validator write to the validation output directory + # If the submission_config is not set it will also be written to the VALIDATION_OUTPUT_DIR + super().__init__(self._find_vcf_file(), os.path.join(output_dir, VALIDATION_OUTPUT_DIR), + submission_config=submission_config) + + def _validate(self): + self.run_docker_validator() + + def _find_vcf_file(self): + vcf_files = [] + with open(self.mapping_file) as open_file: + reader = csv.DictReader(open_file, delimiter=',') + for row in reader: + vcf_files.append(row['vcf']) + return vcf_files + + def get_docker_validation_cmd(self): + if self.metadata_xlsx and not self.metadata_json: + docker_cmd = ( + f"{self.docker_path} exec {self.container_name} nextflow run eva_sub_cli/nextflow/validation.nf " + f"--vcf_files_mapping {container_validation_dir}/{self.mapping_file} " + f"--metadata_xlsx {container_validation_dir}/{self.metadata_xlsx} " + f"--conversion_configuration {container_validation_dir}/{self.spreadsheet2json_conf} " + f"--schema_dir {container_etc_dir} " + f"--output_dir {container_validation_output_dir}" + ) + else: + docker_cmd = ( + f"{self.docker_path} exec {self.container_name} nextflow run eva_sub_cli/nextflow/validation.nf " + f"--vcf_files_mapping {container_validation_dir}/{self.mapping_file} " + f"--metadata_json {container_validation_dir}/{self.metadata_json} " + f"--schema_dir {container_etc_dir} " + f"--output_dir {container_validation_output_dir}" + ) + print(docker_cmd) + return docker_cmd + + def run_docker_validator(self): + # verify mapping file exists + if not os.path.exists(self.mapping_file): + raise RuntimeError(f'Mapping file {self.mapping_file} not found') + + # verify all files mentioned in metadata files exist + files_missing, missing_files_list = self.check_if_file_missing() + if files_missing: + raise RuntimeError(f"some files (vcf/fasta) mentioned in metadata file could not be found. " + f"Missing files list {missing_files_list}") + + # check if docker container is ready for running validation + self.verify_docker_env() + + try: + # remove all existing files from container + run_command_with_output( + "Remove existing files from validation directory in container", + f"{self.docker_path} exec {self.container_name} rm -rf work {container_validation_dir}" + ) + + # copy all required files to container (mapping file, vcf and fasta) + self.copy_files_to_container() + + docker_cmd = self.get_docker_validation_cmd() + # start validation + # FIXME: If nextflow fails in the docker exec still exit with error code 0 + run_command_with_output("Run Validation using Nextflow", docker_cmd) + # copy validation result to user host + run_command_with_output( + "Copy validation output from container to host", + f"{self.docker_path} cp {self.container_name}:{container_validation_output_dir} {self.output_dir}" + ) + except subprocess.CalledProcessError as ex: + logger.error(ex) + + def check_if_file_missing(self): + files_missing = False + missing_files_list = [] + with open(self.mapping_file) as open_file: + reader = csv.DictReader(open_file, delimiter=',') + for row in reader: + if not os.path.exists(row['vcf']): + files_missing = True + missing_files_list.append(row['vcf']) + if not os.path.exists(row['fasta']): + files_missing = True + missing_files_list.append(row['fasta']) + if not os.path.exists(row['report']): + files_missing = True + missing_files_list.append(row['report']) + + return files_missing, missing_files_list + + def verify_docker_is_installed(self): + try: + run_command_with_output( + "check docker is installed and available on the path", + f"{self.docker_path} --version" + ) + except subprocess.CalledProcessError as ex: + logger.error(ex) + raise RuntimeError(f"Please make sure docker ({self.docker_path}) is installed and available on the path") + + def verify_container_is_running(self): + container_run_cmd_ouptut = run_command_with_output("check if container is running", f"{self.docker_path} ps", return_process_output=True) + if container_run_cmd_ouptut is not None and self.container_name in container_run_cmd_ouptut: + logger.info(f"Container ({self.container_name}) is running") + return True + else: + logger.info(f"Container ({self.container_name}) is not running") + return False + + def verify_container_is_stopped(self): + container_stop_cmd_output = run_command_with_output( + "check if container is stopped", + f"{self.docker_path} ps -a" + , return_process_output=True + ) + if container_stop_cmd_output is not None and self.container_name in container_stop_cmd_output: + logger.info(f"Container ({self.container_name}) is in stop state") + return True + else: + logger.info(f"Container ({self.container_name}) is not in stop state") + return False + + def try_restarting_container(self): + logger.info(f"Trying to restart container {self.container_name}") + try: + run_command_with_output("Try restarting container", f"{self.docker_path} start {self.container_name}") + if not self.verify_container_is_running(): + raise RuntimeError(f"Container ({self.container_name}) could not be restarted") + except subprocess.CalledProcessError as ex: + logger.error(ex) + raise RuntimeError(f"Container ({self.container_name}) could not be restarted") + + def verify_image_available_locally(self): + container_images_cmd_ouptut = run_command_with_output( + "Check if validator image is present", + f"{self.docker_path} images", + return_process_output=True + ) + if container_images_cmd_ouptut is not None and re.search(container_image + r'\s+' + container_tag, container_images_cmd_ouptut): + logger.info(f"Container ({container_image}) image is available locally") + return True + else: + logger.info(f"Container ({container_image}) image is not available locally") + return False + + def run_container(self): + logger.info(f"Trying to run container {self.container_name}") + try: + run_command_with_output( + "Try running container", + f"{self.docker_path} run -it --rm -d --name {self.container_name} {container_image}:{container_tag}" + ) + # stopping execution to give some time to container to get up and running + time.sleep(5) + if not self.verify_container_is_running(): + raise RuntimeError(f"Container ({self.container_name}) could not be started") + except subprocess.CalledProcessError as ex: + logger.error(ex) + raise RuntimeError(f"Container ({self.container_name}) could not be started") + + def stop_running_container(self): + if not self.verify_container_is_stopped(): + run_command_with_output( + "Stop the running container", + f"{self.docker_path} stop {self.container_name}" + ) + + def download_container_image(self): + logger.info(f"Pulling container ({container_image}) image") + try: + run_command_with_output("pull container image", f"{self.docker_path} pull {container_image}:{container_tag}") + except subprocess.CalledProcessError as ex: + logger.error(ex) + raise RuntimeError(f"Cannot pull container ({container_image}) image") + # Give the pull command some time to complete + time.sleep(5) + self.run_container() + + def verify_docker_env(self): + self.verify_docker_is_installed() + + if not self.verify_container_is_running(): + if self.verify_container_is_stopped(): + self.try_restarting_container() + else: + if self.verify_image_available_locally(): + self.run_container() + else: + self.download_container_image() + + def copy_files_to_container(self): + def _copy(file_description, file_path): + run_command_with_output( + f"Create directory structure for copying {file_description} into container", + (f"{self.docker_path} exec {self.container_name} " + f"mkdir -p {container_validation_dir}/{os.path.dirname(file_path)}") + ) + run_command_with_output( + f"Copy {file_description} to container", + (f"{self.docker_path} cp {file_path} " + f"{self.container_name}:{container_validation_dir}/{file_path}") + ) + _copy('vcf metadata file', self.mapping_file) + if self.metadata_json: + _copy('json metadata file', self.metadata_json) + if self.metadata_xlsx: + _copy('excel metadata file', self.metadata_xlsx) + _copy('configuration', self.spreadsheet2json_conf) + with open(self.mapping_file) as open_file: + reader = csv.DictReader(open_file, delimiter=',') + for row in reader: + _copy('vcf files', row['vcf']) + _copy('fasta files', row['fasta']) + _copy('assembly report files', row['report']) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description='Run pre-submission validation checks on VCF files', add_help=False) + parser.add_argument("--docker_path", help="Full path to the docker installation, " + "not required if docker is available on path", required=False) + parser.add_argument("--container_name", help="Name of the docker container", required=False) + parser.add_argument("--vcf_files_mapping", + help="csv file with the mappings for vcf files, fasta and assembly report", required=True) + parser.add_argument("--output_dir", help="Directory where the validation output reports will be made available", + required=True) + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument("--metadata_json", + help="Json file that describe the project, analysis, samples and files") + group.add_argument("--metadata_xlsx", + help="Excel spreadsheet that describe the project, analysis, samples and files") + args = parser.parse_args() + + docker_path = args.docker_path if args.docker_path else 'docker' + docker_container_name = args.container_name if args.container_name else container_image + + logging_config.add_stdout_handler() + validator = DockerValidator(args.vcf_files_mapping, args.output_dir, args.metadata_json, args.metadata_xlsx, + docker_container_name, docker_path) + validator.validate() + validator.create_reports() diff --git a/tests/resources/EVA_Submission_template.V1.1.4.xlsx b/tests/resources/EVA_Submission_test.xlsx similarity index 100% rename from tests/resources/EVA_Submission_template.V1.1.4.xlsx rename to tests/resources/EVA_Submission_test.xlsx diff --git a/tests/test_docker_validator.py b/tests/test_docker_validator.py index f75e63e..6863417 100644 --- a/tests/test_docker_validator.py +++ b/tests/test_docker_validator.py @@ -62,7 +62,7 @@ def setUp(self): container_name='eva-sub-cli-test' ) shutil.copyfile( - os.path.join(self.resources_folder, 'EVA_Submission_template.V1.1.4.xlsx'), + os.path.join(self.resources_folder, 'EVA_Submission_test.xlsx'), self.metadata_xlsx ) diff --git a/tests/test_submit.py b/tests/test_submit.py index c2c1bc5..ab7864f 100644 --- a/tests/test_submit.py +++ b/tests/test_submit.py @@ -21,7 +21,7 @@ def setUp(self) -> None: self.token = 'a token' with patch('eva_sub_cli.submit.get_auth', return_value=Mock(token=self.token)): vcf_files = [os.path.join(self.resource_dir, 'vcf_files', 'example2.vcf.gz')] - metadata_file = os.path.join(self.resource_dir, 'EVA_Submission_template.V1.1.4.xlsx') + metadata_file = os.path.join(self.resource_dir, 'EVA_Submission_test.xlsx') self.submitter = StudySubmitter(submission_dir=self.test_sub_dir, vcf_files=vcf_files, metadata_file=metadata_file) @@ -102,7 +102,7 @@ def test_upload_submission(self): mock_submit_response = MagicMock() mock_submit_response.status_code = 200 test_url = 'http://example.com/' - with patch.object(StudySubmitter, 'upload_file') as mock_upload_file, \ + with patch.object(StudySubmitter, '_upload_file') as mock_upload_file, \ patch.object(self.submitter, 'sub_config', {READY_FOR_SUBMISSION_TO_EVA: True}): self.submitter.sub_config[SUB_CLI_CONFIG_KEY_SUBMISSION_UPLOAD_URL] = test_url self.submitter._upload_submission() @@ -113,7 +113,7 @@ def test_upload_submission(self): def test_upload_file(self): test_url = 'http://example.com/' with patch('eva_sub_cli.submit.requests.put') as mock_put: - file_to_upload = os.path.join(self.resource_dir, 'EVA_Submission_template.V1.1.4.xlsx') + file_to_upload = os.path.join(self.resource_dir, 'EVA_Submission_test.xlsx') self.submitter._upload_file(submission_upload_url=test_url, input_file=file_to_upload) assert mock_put.mock_calls[0][1][0] == test_url + os.path.basename(file_to_upload) # Cannot test the content of the upload as opening the same file twice give different object diff --git a/tests/test_xlsx2json.py b/tests/test_xlsx2json.py index b987744..e1bc6e0 100644 --- a/tests/test_xlsx2json.py +++ b/tests/test_xlsx2json.py @@ -15,7 +15,7 @@ class TestXlsReader(TestCase): biosample_schema = os.path.abspath(os.path.join(__file__, "../../eva_sub_cli/etc/eva-biosamples.json", )) def test_conversion_2_json(self) -> None: - xls_filename = os.path.join(self.resource_dir, 'EVA_Submission_template.V1.1.4.xlsx') + xls_filename = os.path.join(self.resource_dir, 'EVA_Submission_test.xlsx') self.parser = XlsxParser(xls_filename, self.conf_filename) output_json = os.path.join(self.resource_dir, 'EVA_Submission_template.V1.1.4.json') self.parser.json(output_json) From 2e104fe6d49c1674e165c7fae5611a532f3d0260 Mon Sep 17 00:00:00 2001 From: tcezard Date: Mon, 29 Jan 2024 15:20:07 +0000 Subject: [PATCH 4/7] remove temp validator --- eva_sub_cli/native_validator.py | 275 -------------------------------- 1 file changed, 275 deletions(-) delete mode 100644 eva_sub_cli/native_validator.py diff --git a/eva_sub_cli/native_validator.py b/eva_sub_cli/native_validator.py deleted file mode 100644 index 3e6ff5e..0000000 --- a/eva_sub_cli/native_validator.py +++ /dev/null @@ -1,275 +0,0 @@ -import argparse -import csv -import os -import re -import subprocess -import time - -from ebi_eva_common_pyutils.command_utils import run_command_with_output - -from eva_sub_cli import ETC_DIR -from eva_sub_cli.reporter import Reporter -from ebi_eva_common_pyutils.logger import logging_config - -logger = logging_config.get_logger(__name__) - -docker_path = 'docker' -container_image = 'ebivariation/eva-sub-cli' -container_tag = 'v0.0.1.dev2' -container_validation_dir = '/opt/vcf_validation' -container_validation_output_dir = '/opt/vcf_validation/vcf_validation_output' -container_etc_dir = '/opt/eva_sub_cli/etc' - -VALIDATION_OUTPUT_DIR = "validation_output" - - -class NativeValidator(Reporter): - - def __init__(self, mapping_file, output_dir, metadata_json=None, - metadata_xlsx=None, submission_config=None): - self.mapping_file = mapping_file - self.metadata_json = metadata_json - self.metadata_xlsx = metadata_xlsx - - self.spreadsheet2json_conf = os.path.join(ETC_DIR, "spreadsheet2json_conf.yaml") - # validator write to the validation output directory - # If the submission_config is not set it will also be written to the VALIDATION_OUTPUT_DIR - super().__init__(self._find_vcf_file(), os.path.join(output_dir, VALIDATION_OUTPUT_DIR), - submission_config=submission_config) - - def _validate(self): - self.run_docker_validator() - - def _find_vcf_file(self): - vcf_files = [] - with open(self.mapping_file) as open_file: - reader = csv.DictReader(open_file, delimiter=',') - for row in reader: - vcf_files.append(row['vcf']) - return vcf_files - - def get_docker_validation_cmd(self): - if self.metadata_xlsx and not self.metadata_json: - docker_cmd = ( - f"{self.docker_path} exec {self.container_name} nextflow run eva_sub_cli/nextflow/validation.nf " - f"--vcf_files_mapping {container_validation_dir}/{self.mapping_file} " - f"--metadata_xlsx {container_validation_dir}/{self.metadata_xlsx} " - f"--conversion_configuration {container_validation_dir}/{self.spreadsheet2json_conf} " - f"--schema_dir {container_etc_dir} " - f"--output_dir {container_validation_output_dir}" - ) - else: - docker_cmd = ( - f"{self.docker_path} exec {self.container_name} nextflow run eva_sub_cli/nextflow/validation.nf " - f"--vcf_files_mapping {container_validation_dir}/{self.mapping_file} " - f"--metadata_json {container_validation_dir}/{self.metadata_json} " - f"--schema_dir {container_etc_dir} " - f"--output_dir {container_validation_output_dir}" - ) - print(docker_cmd) - return docker_cmd - - def run_docker_validator(self): - # verify mapping file exists - if not os.path.exists(self.mapping_file): - raise RuntimeError(f'Mapping file {self.mapping_file} not found') - - # verify all files mentioned in metadata files exist - files_missing, missing_files_list = self.check_if_file_missing() - if files_missing: - raise RuntimeError(f"some files (vcf/fasta) mentioned in metadata file could not be found. " - f"Missing files list {missing_files_list}") - - # check if docker container is ready for running validation - self.verify_docker_env() - - try: - # remove all existing files from container - run_command_with_output( - "Remove existing files from validation directory in container", - f"{self.docker_path} exec {self.container_name} rm -rf work {container_validation_dir}" - ) - - # copy all required files to container (mapping file, vcf and fasta) - self.copy_files_to_container() - - docker_cmd = self.get_docker_validation_cmd() - # start validation - # FIXME: If nextflow fails in the docker exec still exit with error code 0 - run_command_with_output("Run Validation using Nextflow", docker_cmd) - # copy validation result to user host - run_command_with_output( - "Copy validation output from container to host", - f"{self.docker_path} cp {self.container_name}:{container_validation_output_dir} {self.output_dir}" - ) - except subprocess.CalledProcessError as ex: - logger.error(ex) - - def check_if_file_missing(self): - files_missing = False - missing_files_list = [] - with open(self.mapping_file) as open_file: - reader = csv.DictReader(open_file, delimiter=',') - for row in reader: - if not os.path.exists(row['vcf']): - files_missing = True - missing_files_list.append(row['vcf']) - if not os.path.exists(row['fasta']): - files_missing = True - missing_files_list.append(row['fasta']) - if not os.path.exists(row['report']): - files_missing = True - missing_files_list.append(row['report']) - - return files_missing, missing_files_list - - def verify_docker_is_installed(self): - try: - run_command_with_output( - "check docker is installed and available on the path", - f"{self.docker_path} --version" - ) - except subprocess.CalledProcessError as ex: - logger.error(ex) - raise RuntimeError(f"Please make sure docker ({self.docker_path}) is installed and available on the path") - - def verify_container_is_running(self): - container_run_cmd_ouptut = run_command_with_output("check if container is running", f"{self.docker_path} ps", return_process_output=True) - if container_run_cmd_ouptut is not None and self.container_name in container_run_cmd_ouptut: - logger.info(f"Container ({self.container_name}) is running") - return True - else: - logger.info(f"Container ({self.container_name}) is not running") - return False - - def verify_container_is_stopped(self): - container_stop_cmd_output = run_command_with_output( - "check if container is stopped", - f"{self.docker_path} ps -a" - , return_process_output=True - ) - if container_stop_cmd_output is not None and self.container_name in container_stop_cmd_output: - logger.info(f"Container ({self.container_name}) is in stop state") - return True - else: - logger.info(f"Container ({self.container_name}) is not in stop state") - return False - - def try_restarting_container(self): - logger.info(f"Trying to restart container {self.container_name}") - try: - run_command_with_output("Try restarting container", f"{self.docker_path} start {self.container_name}") - if not self.verify_container_is_running(): - raise RuntimeError(f"Container ({self.container_name}) could not be restarted") - except subprocess.CalledProcessError as ex: - logger.error(ex) - raise RuntimeError(f"Container ({self.container_name}) could not be restarted") - - def verify_image_available_locally(self): - container_images_cmd_ouptut = run_command_with_output( - "Check if validator image is present", - f"{self.docker_path} images", - return_process_output=True - ) - if container_images_cmd_ouptut is not None and re.search(container_image + r'\s+' + container_tag, container_images_cmd_ouptut): - logger.info(f"Container ({container_image}) image is available locally") - return True - else: - logger.info(f"Container ({container_image}) image is not available locally") - return False - - def run_container(self): - logger.info(f"Trying to run container {self.container_name}") - try: - run_command_with_output( - "Try running container", - f"{self.docker_path} run -it --rm -d --name {self.container_name} {container_image}:{container_tag}" - ) - # stopping execution to give some time to container to get up and running - time.sleep(5) - if not self.verify_container_is_running(): - raise RuntimeError(f"Container ({self.container_name}) could not be started") - except subprocess.CalledProcessError as ex: - logger.error(ex) - raise RuntimeError(f"Container ({self.container_name}) could not be started") - - def stop_running_container(self): - if not self.verify_container_is_stopped(): - run_command_with_output( - "Stop the running container", - f"{self.docker_path} stop {self.container_name}" - ) - - def download_container_image(self): - logger.info(f"Pulling container ({container_image}) image") - try: - run_command_with_output("pull container image", f"{self.docker_path} pull {container_image}:{container_tag}") - except subprocess.CalledProcessError as ex: - logger.error(ex) - raise RuntimeError(f"Cannot pull container ({container_image}) image") - # Give the pull command some time to complete - time.sleep(5) - self.run_container() - - def verify_docker_env(self): - self.verify_docker_is_installed() - - if not self.verify_container_is_running(): - if self.verify_container_is_stopped(): - self.try_restarting_container() - else: - if self.verify_image_available_locally(): - self.run_container() - else: - self.download_container_image() - - def copy_files_to_container(self): - def _copy(file_description, file_path): - run_command_with_output( - f"Create directory structure for copying {file_description} into container", - (f"{self.docker_path} exec {self.container_name} " - f"mkdir -p {container_validation_dir}/{os.path.dirname(file_path)}") - ) - run_command_with_output( - f"Copy {file_description} to container", - (f"{self.docker_path} cp {file_path} " - f"{self.container_name}:{container_validation_dir}/{file_path}") - ) - _copy('vcf metadata file', self.mapping_file) - if self.metadata_json: - _copy('json metadata file', self.metadata_json) - if self.metadata_xlsx: - _copy('excel metadata file', self.metadata_xlsx) - _copy('configuration', self.spreadsheet2json_conf) - with open(self.mapping_file) as open_file: - reader = csv.DictReader(open_file, delimiter=',') - for row in reader: - _copy('vcf files', row['vcf']) - _copy('fasta files', row['fasta']) - _copy('assembly report files', row['report']) - - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description='Run pre-submission validation checks on VCF files', add_help=False) - parser.add_argument("--docker_path", help="Full path to the docker installation, " - "not required if docker is available on path", required=False) - parser.add_argument("--container_name", help="Name of the docker container", required=False) - parser.add_argument("--vcf_files_mapping", - help="csv file with the mappings for vcf files, fasta and assembly report", required=True) - parser.add_argument("--output_dir", help="Directory where the validation output reports will be made available", - required=True) - group = parser.add_mutually_exclusive_group(required=True) - group.add_argument("--metadata_json", - help="Json file that describe the project, analysis, samples and files") - group.add_argument("--metadata_xlsx", - help="Excel spreadsheet that describe the project, analysis, samples and files") - args = parser.parse_args() - - docker_path = args.docker_path if args.docker_path else 'docker' - docker_container_name = args.container_name if args.container_name else container_image - - logging_config.add_stdout_handler() - validator = DockerValidator(args.vcf_files_mapping, args.output_dir, args.metadata_json, args.metadata_xlsx, - docker_container_name, docker_path) - validator.validate() - validator.create_reports() From 511fbbfe01992138ca9de0c8febe317b873e3c01 Mon Sep 17 00:00:00 2001 From: Timothee Cezard Date: Wed, 31 Jan 2024 12:28:18 +0000 Subject: [PATCH 5/7] Apply suggestions from code review Co-authored-by: April Shen --- README.md | 6 +++--- bin/eva-sub-cli.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 4beeb8f..6cba16a 100644 --- a/README.md +++ b/README.md @@ -7,14 +7,14 @@ EVA Submission Command Line Interface for Validation ## Installation -## input file for the validation and submission tool +## Input files for the validation and submission tool ### The VCF file and association with reference genome The path to the VCF files are provided via CSV file that links the VCF to their respective fasta sequence. This allows us to support different assemblies for each VCF file The CSV file `vcf_mapping.csv` contains the following columns vcf, fasta, report providing respectively: - - The VCF to validatio/upload + - The VCF to validate/upload - The assembly in fasta format that was used to derive the VCF - The assembly report associated with the assembly (if available) as found in NCBI assemblies (https://www.ncbi.nlm.nih.gov/genome/doc/ftpfaq/#files) @@ -34,7 +34,7 @@ It should be populated following the instruction provided within the template ## Execution -### Validate and submit you dataset +### Validate and submit your dataset To validate and submit run the following command diff --git a/bin/eva-sub-cli.py b/bin/eva-sub-cli.py index 26fba4b..f0fe343 100755 --- a/bin/eva-sub-cli.py +++ b/bin/eva-sub-cli.py @@ -11,9 +11,9 @@ if __name__ == "__main__": argparser = ArgumentParser(description='EVA Submission CLI - validate and submit data to EVA') argparser.add_argument('--tasks', nargs='*', choices=[VALIDATE, SUBMIT], default=[SUBMIT], - help='Select a task to perform. Stating VALIDATE run the validation regardless of the ' - 'previous runs, Stating SUBMIT run validate only if the validation was not performed ' - 'successfully before and run the submission.') + help='Select a task to perform. Selecting VALIDATE will run the validation regardless of the outcome of ' + 'previous runs. Selecting SUBMIT will run validate only if the validation was not performed ' + 'successfully before and then run the submission.') argparser.add_argument('--submission_dir', required=True, type=str, help='Full path to the directory where all processing will be done ' 'and submission info is/will be stored') From a01b4f47c1dac9687be20ff43ca2e136e31e940a Mon Sep 17 00:00:00 2001 From: tcezard Date: Wed, 31 Jan 2024 12:37:47 +0000 Subject: [PATCH 6/7] Address review comments --- README.md | 21 ++++++++++++++++++++- bin/eva-sub-cli.py | 4 ++-- eva_sub_cli/main.py | 4 ---- tests/test_main.py | 2 +- 4 files changed, 23 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 6cba16a..c7849cc 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ EVA Submission Command Line Interface for Validation ## Installation +TBD ## Input files for the validation and submission tool @@ -32,6 +33,13 @@ vcf,fasta,report The metadata template can be found within the etc folder at `eva_sub_cli/etc/EVA_Submission_template.xlsx` It should be populated following the instruction provided within the template +### The metadata JSON + +The metadata can also be provided via a JSON file which should conform to the schema located at +`eva_sub_cli/etc/eva_schema.json` + +More detail documentation to follow + ## Execution ### Validate and submit your dataset @@ -54,4 +62,15 @@ eva-sub-cli.py --metadata_xlsx metadata_spreadsheet.xlsx \ ``` ### Submit only -All submission must have been validated. You cannot run the submission without validation +All submission must have been validated. You cannot run the submission without validation. Once validated running + +```shell +eva-sub-cli.py --metadata_xlsx metadata_spreadsheet.xlsx \ + --vcf_files_mapping vcf_mapping.csv --submission_dir submission_dir +``` +or +```shell +eva-sub-cli.py --metadata_xlsx metadata_spreadsheet.xlsx \ + --vcf_files_mapping vcf_mapping.csv --submission_dir submission_dir --tasks SUBMIT +``` +Will only submit the data and not validate. diff --git a/bin/eva-sub-cli.py b/bin/eva-sub-cli.py index f0fe343..cefd2fb 100755 --- a/bin/eva-sub-cli.py +++ b/bin/eva-sub-cli.py @@ -29,8 +29,8 @@ argparser.add_argument("--password", help="Password used for connecting to the ENA webin account") argparser.add_argument("--resume", default=False, action='store_true', - help="Resume the process execution from where it left of. This is only supported for a " - "subset of the tasks") + help="Resume the process execution from where it left of. This is currently only supported " + "for the upload part of the SUBMIT task.") args = argparser.parse_args() diff --git a/eva_sub_cli/main.py b/eva_sub_cli/main.py index 9ff26a3..4f19aa0 100755 --- a/eva_sub_cli/main.py +++ b/eva_sub_cli/main.py @@ -12,10 +12,6 @@ VALIDATE = 'validate' SUBMIT = 'submit' - -logging_config.add_stdout_handler() - - def get_vcf_files(mapping_file): vcf_files = [] with open(mapping_file) as open_file: diff --git a/tests/test_main.py b/tests/test_main.py index 2fb4fab..1e63dd2 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -70,7 +70,7 @@ def test_orchestrate_validate_submit(self): with m_submitter() as submitter: submitter.submit.assert_called_once_with(resume=False) - def test_orchestrate_validate_no_submit(self): + def test_orchestrate_submit_no_validate(self): with patch('eva_sub_cli.main.get_vcf_files') as m_get_vcf, \ patch('eva_sub_cli.main.WritableConfig') as m_config, \ patch('eva_sub_cli.main.DockerValidator') as m_docker_validator, \ From 1dc47a5edf39ce2667f8aecfa76663f43a2e132f Mon Sep 17 00:00:00 2001 From: tcezard Date: Wed, 31 Jan 2024 13:34:16 +0000 Subject: [PATCH 7/7] Fix minify_html to old (0.11.1) version because the new one seems to break the javascript --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 7c69970..5be23ce 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ pyyaml jinja2 -minify_html +minify_html==0.11.1 openpyxl requests jsonschema