From a3f2a4cf5b906df7daf3e57bdbdfc0ff4526a302 Mon Sep 17 00:00:00 2001 From: eliott Date: Mon, 4 Nov 2024 13:47:42 +0100 Subject: [PATCH 01/80] add MutantFileFormatter class backbone for enhanced file formatting with LIMS metadata --- .../file_formatter/utils/sample_service.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/cg/services/deliver_files/file_formatter/utils/sample_service.py b/cg/services/deliver_files/file_formatter/utils/sample_service.py index f13aa4c30e..da86201d88 100644 --- a/cg/services/deliver_files/file_formatter/utils/sample_service.py +++ b/cg/services/deliver_files/file_formatter/utils/sample_service.py @@ -1,7 +1,15 @@ import os from pathlib import Path + +from cg.apps.lims import LimsAPI from cg.services.deliver_files.file_fetcher.models import SampleFile from cg.services.deliver_files.file_formatter.models import FormattedFile +from cg.services.deliver_files.file_formatter.utils.sample_concatenation_service import ( + SampleFileConcatenationFormatter, +) +from cg.services.fastq_concatenation_service.fastq_concatenation_service import ( + FastqConcatenationService, +) class SampleFileFormatter: @@ -55,3 +63,15 @@ def _get_formatted_files(sample_files: list[SampleFile]) -> list[FormattedFile]: ) ) return formatted_files + + +class MutantFileFormatter(SampleFileConcatenationFormatter): + def __init__(self, lims_api: LimsAPI, concatenation_service: FastqConcatenationService): + self.lims_api: LimsAPI = lims_api + super().__init__(concatenation_service = concatenation_service) + + def format_files() -> list[]: + formatted_files: list[FormattedFile] = super().format_files() + meta_data_files = _add_lims_meta_data(formatted_files) + return meta_data_files + From 15372cbb1cc8f253ce88e7d6a0ad2361398f00dd Mon Sep 17 00:00:00 2001 From: eliott Date: Mon, 4 Nov 2024 15:29:17 +0100 Subject: [PATCH 02/80] add LIMS metadata handling to MutantFileFormatter for enhanced file formatting --- .../file_formatter/utils/sample_service.py | 33 ++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/cg/services/deliver_files/file_formatter/utils/sample_service.py b/cg/services/deliver_files/file_formatter/utils/sample_service.py index da86201d88..b0410acd90 100644 --- a/cg/services/deliver_files/file_formatter/utils/sample_service.py +++ b/cg/services/deliver_files/file_formatter/utils/sample_service.py @@ -69,9 +69,34 @@ class MutantFileFormatter(SampleFileConcatenationFormatter): def __init__(self, lims_api: LimsAPI, concatenation_service: FastqConcatenationService): self.lims_api: LimsAPI = lims_api super().__init__(concatenation_service = concatenation_service) + + def format_files(self, moved_files: list[SampleFile], ticket_dir_path: Path + ) -> list[FormattedFile]: + formatted_files: list[FormattedFile] = super().format_files( + moved_files=moved_files, ticket_dir_path=ticket_dir_path + ) + formatted_files = self._add_lims_metadata(formatted_files) + return self._format_sample_files(formatted_files) + + def _get_lims_naming_metadata(self, sample_id: str)-> str: + + region_code = self.lims_api.get_sample_attribute(lims_id=sample_id, key="region_code").split(" ")[0] + lab_code = self.lims_api.get_sample_attribute(lims_id=sample_id, key="lab_code").split(" ")[0] + + return f"{region_code}_{lab_code}" + + def _add_lims_metadata(self, formatted_files: list[FormattedFile], sample_files: list[SampleFile]) -> list[FormattedFile]: + for formatted_file in formatted_files: + lims_meta_data = self._get_lims_naming_metadata(sample_id = self._get_sample_id_by_original_path(formatted_file.original_path, sample_files)) + formatted_file.original_path = formatted_file.formatted_path + formatted_file.formatted_path = lims_meta_data + formatted_file.formatted_path + return formatted_files + + def _get_sample_id_by_original_path(original_path: Path, sample_files: list[SampleFile])-> str: + for sample_file in sample_files: + if sample_file.file_path == original_path: + return sample_file.sample_id + raise ValueError(f"Could not find sample file with path {original_path}") + - def format_files() -> list[]: - formatted_files: list[FormattedFile] = super().format_files() - meta_data_files = _add_lims_meta_data(formatted_files) - return meta_data_files From c89b05081f30d6b94b5ff2fbe5aacd91419f8595 Mon Sep 17 00:00:00 2001 From: eliott Date: Mon, 4 Nov 2024 16:24:39 +0100 Subject: [PATCH 03/80] add fixture for LIMS naming metadata in tests --- tests/conftest.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 238ed66908..8969cf9f71 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4248,3 +4248,8 @@ def libary_sequencing_method() -> str: @pytest.fixture def capture_kit() -> str: return "panel.bed" + + +@pytest.fixture +def lims_naming_matadata() -> str: + return "01_SE100" From 6f6ebc4d8fc05bcbdd2e6e7039af26abe702b6dd Mon Sep 17 00:00:00 2001 From: eliott Date: Tue, 5 Nov 2024 11:43:00 +0100 Subject: [PATCH 04/80] Move MutantFileFormatter to sample_concatenation_service.py --- .../utils/sample_concatenation_service.py | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py b/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py index 2091204fa2..131d54beeb 100644 --- a/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py +++ b/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py @@ -1,5 +1,6 @@ from pathlib import Path +from cg.apps.lims import LimsAPI from cg.constants.constants import ReadDirection, FileFormat, FileExtensions from cg.services.fastq_concatenation_service.fastq_concatenation_service import ( @@ -109,3 +110,39 @@ def _replace_fastq_paths( direction=ReadDirection.REVERSE, new_path=reverse_path, ) + + +class MutantFileFormatter(SampleFileConcatenationFormatter): + def __init__(self, lims_api: LimsAPI, concatenation_service: FastqConcatenationService): + self.lims_api: LimsAPI = lims_api + super().__init__(concatenation_service = concatenation_service) + + def format_files(self, moved_files: list[SampleFile], ticket_dir_path: Path + ) -> list[FormattedFile]: + formatted_files: list[FormattedFile] = super().format_files( + moved_files=moved_files, ticket_dir_path=ticket_dir_path + ) + formatted_files = self._add_lims_metadata(formatted_files=formatted_files, sample_files=moved_files) + return self._format_sample_files(formatted_files) + + def _get_lims_naming_metadata(self, sample_id: str)-> str: + + region_code = self.lims_api.get_sample_attribute(lims_id=sample_id, key="region_code").split(" ")[0] + lab_code = self.lims_api.get_sample_attribute(lims_id=sample_id, key="lab_code").split(" ")[0] + + return f"{region_code}_{lab_code}_" + + def _add_lims_metadata(self, formatted_files: list[FormattedFile], sample_files: list[SampleFile]) -> list[FormattedFile]: + for formatted_file in formatted_files: + sample_id: str = self._get_sample_id_by_original_path(original_path=formatted_file.original_path, sample_files=sample_files) + lims_meta_data = self._get_lims_naming_metadata(sample_id) + formatted_file.original_path = formatted_file.formatted_path + formatted_file.formatted_path = Path(formatted_file.formatted_path.parent,f"{lims_meta_data}{formatted_file.formatted_path.name}") + return formatted_files + + @staticmethod + def _get_sample_id_by_original_path(original_path: Path, sample_files: list[SampleFile])-> str: + for sample_file in sample_files: + if sample_file.file_path == original_path: + return sample_file.sample_id + raise ValueError(f"Could not find sample file with path {original_path}") \ No newline at end of file From f1e936696162d94eae7acead5a66983a13b9500b Mon Sep 17 00:00:00 2001 From: eliott Date: Tue, 5 Nov 2024 11:44:01 +0100 Subject: [PATCH 05/80] Move MutantFileFormatter and update SampleFileFormatter --- .../file_formatter/utils/sample_service.py | 50 ++----------------- 1 file changed, 4 insertions(+), 46 deletions(-) diff --git a/cg/services/deliver_files/file_formatter/utils/sample_service.py b/cg/services/deliver_files/file_formatter/utils/sample_service.py index b0410acd90..784be1df62 100644 --- a/cg/services/deliver_files/file_formatter/utils/sample_service.py +++ b/cg/services/deliver_files/file_formatter/utils/sample_service.py @@ -1,15 +1,8 @@ import os from pathlib import Path -from cg.apps.lims import LimsAPI from cg.services.deliver_files.file_fetcher.models import SampleFile from cg.services.deliver_files.file_formatter.models import FormattedFile -from cg.services.deliver_files.file_formatter.utils.sample_concatenation_service import ( - SampleFileConcatenationFormatter, -) -from cg.services.fastq_concatenation_service.fastq_concatenation_service import ( - FastqConcatenationService, -) class SampleFileFormatter: @@ -24,7 +17,9 @@ def format_files( """Format the sample files to deliver and return the formatted files.""" sample_names: set[str] = self._get_sample_names(moved_files) self._create_sample_folders(ticket_dir_path=ticket_dir_path, sample_names=sample_names) - return self._format_sample_files(moved_files) + formatted_files: list[FormattedFile] = self._get_formatted_files(moved_files) + + return self._format_sample_files(formatted_files) @staticmethod def _get_sample_names(sample_files: list[SampleFile]) -> set[str]: @@ -36,8 +31,7 @@ def _create_sample_folders(ticket_dir_path: Path, sample_names: set[str]): sample_dir_path = Path(ticket_dir_path, sample_name) sample_dir_path.mkdir(exist_ok=True) - def _format_sample_files(self, sample_files: list[SampleFile]) -> list[FormattedFile]: - formatted_files: list[FormattedFile] = self._get_formatted_files(sample_files) + def _format_sample_files(self, formatted_files: list[FormattedFile]) -> list[FormattedFile]: for formatted_file in formatted_files: os.rename(src=formatted_file.original_path, dst=formatted_file.formatted_path) return formatted_files @@ -64,39 +58,3 @@ def _get_formatted_files(sample_files: list[SampleFile]) -> list[FormattedFile]: ) return formatted_files - -class MutantFileFormatter(SampleFileConcatenationFormatter): - def __init__(self, lims_api: LimsAPI, concatenation_service: FastqConcatenationService): - self.lims_api: LimsAPI = lims_api - super().__init__(concatenation_service = concatenation_service) - - def format_files(self, moved_files: list[SampleFile], ticket_dir_path: Path - ) -> list[FormattedFile]: - formatted_files: list[FormattedFile] = super().format_files( - moved_files=moved_files, ticket_dir_path=ticket_dir_path - ) - formatted_files = self._add_lims_metadata(formatted_files) - return self._format_sample_files(formatted_files) - - def _get_lims_naming_metadata(self, sample_id: str)-> str: - - region_code = self.lims_api.get_sample_attribute(lims_id=sample_id, key="region_code").split(" ")[0] - lab_code = self.lims_api.get_sample_attribute(lims_id=sample_id, key="lab_code").split(" ")[0] - - return f"{region_code}_{lab_code}" - - def _add_lims_metadata(self, formatted_files: list[FormattedFile], sample_files: list[SampleFile]) -> list[FormattedFile]: - for formatted_file in formatted_files: - lims_meta_data = self._get_lims_naming_metadata(sample_id = self._get_sample_id_by_original_path(formatted_file.original_path, sample_files)) - formatted_file.original_path = formatted_file.formatted_path - formatted_file.formatted_path = lims_meta_data + formatted_file.formatted_path - return formatted_files - - def _get_sample_id_by_original_path(original_path: Path, sample_files: list[SampleFile])-> str: - for sample_file in sample_files: - if sample_file.file_path == original_path: - return sample_file.sample_id - raise ValueError(f"Could not find sample file with path {original_path}") - - - From b088b07dccfdb30bc3640c930f7c5058b76ecd99 Mon Sep 17 00:00:00 2001 From: eliott Date: Tue, 5 Nov 2024 11:45:20 +0100 Subject: [PATCH 06/80] Move lims_naming_metadata --- tests/conftest.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 8969cf9f71..589f02d28b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4249,7 +4249,3 @@ def libary_sequencing_method() -> str: def capture_kit() -> str: return "panel.bed" - -@pytest.fixture -def lims_naming_matadata() -> str: - return "01_SE100" From 5a2aeb8a84167e9fafb97cc46b1b46700e0d542f Mon Sep 17 00:00:00 2001 From: eliott Date: Tue, 5 Nov 2024 11:45:51 +0100 Subject: [PATCH 07/80] Add fixtures for LIMS naming metadata and test MutantFileFormatter functionality --- .../delivery_files_models_fixtures.py | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/tests/fixture_plugins/delivery_fixtures/delivery_files_models_fixtures.py b/tests/fixture_plugins/delivery_fixtures/delivery_files_models_fixtures.py index a252c4791c..46b4e3dd07 100644 --- a/tests/fixture_plugins/delivery_fixtures/delivery_files_models_fixtures.py +++ b/tests/fixture_plugins/delivery_fixtures/delivery_files_models_fixtures.py @@ -1,4 +1,4 @@ -from pathlib import Path +from pathlib import Path, PosixPath import pytest @@ -15,6 +15,7 @@ DeliveryMetaData, SampleFile, ) +from cg.services.deliver_files.file_formatter.models import FormattedFile from cg.store.models import Case from cg.store.store import Store @@ -243,3 +244,21 @@ def swap_file_paths_with_inbox_paths( new_file_model.file_path = Path(inbox_dir_path, file_model.file_path.name) new_file_models.append(new_file_model) return new_file_models + + +@pytest.fixture +def lims_naming_matadata() -> str: + return "01_SE100_" + + +@pytest.fixture +def expected_mutant_formatted_files(expected_concatenated_fastq_formatted_files, lims_naming_matadata) -> list[FormattedFile]: + for formatted_file in expected_concatenated_fastq_formatted_files: + formatted_file.original_path = formatted_file.formatted_path + formatted_file.formatted_path = Path(formatted_file.formatted_path.parent, f"{lims_naming_matadata}{formatted_file.formatted_path.name}") + return expected_concatenated_fastq_formatted_files + + +@pytest.fixture +def mutant_moved_files(fastq_concatenation_sample_files) -> list[SampleFile]: + return fastq_concatenation_sample_files \ No newline at end of file From 234ef71a51b25ce231a9bc48e5863e28400be381 Mon Sep 17 00:00:00 2001 From: eliott Date: Tue, 5 Nov 2024 11:46:37 +0100 Subject: [PATCH 08/80] Add test for MutantFileFormatter functionality in test_formatter_utils.py --- .../utils/test_formatter_utils.py | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/services/file_delivery/file_formatter/utils/test_formatter_utils.py b/tests/services/file_delivery/file_formatter/utils/test_formatter_utils.py index 4db3345b83..5e18113747 100644 --- a/tests/services/file_delivery/file_formatter/utils/test_formatter_utils.py +++ b/tests/services/file_delivery/file_formatter/utils/test_formatter_utils.py @@ -1,4 +1,6 @@ import os +from unittest import mock +from unittest.mock import Mock import pytest from pathlib import Path @@ -15,6 +17,7 @@ CaseFileFormatter, ) from cg.services.deliver_files.file_formatter.utils.sample_concatenation_service import ( + MutantFileFormatter, SampleFileConcatenationFormatter, ) from cg.services.deliver_files.file_formatter.utils.sample_service import ( @@ -71,3 +74,34 @@ def test_file_formatter_utils( for file in formatted_files: assert file.formatted_path.exists() assert not file.original_path.exists() + + +def test_mutant_file_formatter( + mutant_moved_files: list[SampleFile], + expected_mutant_formatted_files: list[FormattedFile], + lims_naming_matadata: str, + ): + # GIVEN existing ticket directory path and a customer inbox + ticket_dir_path: Path = mutant_moved_files[0].file_path.parent + + os.makedirs(ticket_dir_path, exist_ok=True) + + for moved_file in mutant_moved_files: + moved_file.file_path.touch() + + # Initialize file_formatter inside the function to avoid multiple values for 'lims_api' + file_formatter = MutantFileFormatter(concatenation_service=FastqConcatenationService(), lims_api=Mock()) # MockLimsAPI()? + + # WHEN formatting the files + with mock.patch.object(MutantFileFormatter, "_get_lims_naming_metadata", return_value=lims_naming_matadata): + + formatted_files: list[FormattedFile] = file_formatter.format_files( + moved_files=mutant_moved_files, + ticket_dir_path=ticket_dir_path, + ) + + # THEN the files should be formatted + assert formatted_files == expected_mutant_formatted_files + for file in formatted_files: + assert file.formatted_path.exists() + assert not file.original_path.exists() From 550d49d173bc2ca5a2aee2af1627633c1bb38ac9 Mon Sep 17 00:00:00 2001 From: eliott Date: Tue, 5 Nov 2024 13:04:36 +0100 Subject: [PATCH 09/80] Refactor test_mutant_file_formatter for clarity and consistency --- .../file_formatter/utils/test_formatter_utils.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/services/file_delivery/file_formatter/utils/test_formatter_utils.py b/tests/services/file_delivery/file_formatter/utils/test_formatter_utils.py index 5e18113747..dff0d7e43e 100644 --- a/tests/services/file_delivery/file_formatter/utils/test_formatter_utils.py +++ b/tests/services/file_delivery/file_formatter/utils/test_formatter_utils.py @@ -86,11 +86,11 @@ def test_mutant_file_formatter( os.makedirs(ticket_dir_path, exist_ok=True) - for moved_file in mutant_moved_files: - moved_file.file_path.touch() + for mutant_moved_file in mutant_moved_files: + mutant_moved_file.file_path.touch() - # Initialize file_formatter inside the function to avoid multiple values for 'lims_api' - file_formatter = MutantFileFormatter(concatenation_service=FastqConcatenationService(), lims_api=Mock()) # MockLimsAPI()? + # Initialize file_formatter + file_formatter = MutantFileFormatter(concatenation_service=FastqConcatenationService(), lims_api=Mock()) # WHEN formatting the files with mock.patch.object(MutantFileFormatter, "_get_lims_naming_metadata", return_value=lims_naming_matadata): From a215190900b3c2f3a5b730bdfe2a96464aee402a Mon Sep 17 00:00:00 2001 From: Christian Oertlin Date: Wed, 6 Nov 2024 09:47:08 +0100 Subject: [PATCH 10/80] add fix --- .../utils/mutant_sample_service.py | 73 +++++++++++++++++++ .../utils/sample_concatenation_service.py | 36 --------- .../file_formatter/utils/sample_service.py | 1 - tests/conftest.py | 1 - .../delivery_files_models_fixtures.py | 16 +++- .../utils/test_formatter_utils.py | 17 +++-- 6 files changed, 95 insertions(+), 49 deletions(-) create mode 100644 cg/services/deliver_files/file_formatter/utils/mutant_sample_service.py diff --git a/cg/services/deliver_files/file_formatter/utils/mutant_sample_service.py b/cg/services/deliver_files/file_formatter/utils/mutant_sample_service.py new file mode 100644 index 0000000000..3441bc1d23 --- /dev/null +++ b/cg/services/deliver_files/file_formatter/utils/mutant_sample_service.py @@ -0,0 +1,73 @@ +from pathlib import Path + +from cg.apps.lims import LimsAPI +from cg.services.deliver_files.file_fetcher.models import SampleFile +from cg.services.deliver_files.file_formatter.models import FormattedFile +from cg.services.deliver_files.file_formatter.utils.sample_concatenation_service import ( + SampleFileConcatenationFormatter, +) +from cg.services.fastq_concatenation_service.fastq_concatenation_service import ( + FastqConcatenationService, +) + + +class MutantFileFormatter(SampleFileConcatenationFormatter): + def __init__(self, lims_api: LimsAPI, concatenation_service: FastqConcatenationService): + self.lims_api: LimsAPI = lims_api + super().__init__(concatenation_service=concatenation_service) + + def format_files( + self, moved_files: list[SampleFile], ticket_dir_path: Path + ) -> list[FormattedFile]: + formatted_files: list[FormattedFile] = super().format_files( + moved_files=moved_files, ticket_dir_path=ticket_dir_path + ) + formatted_files = self._add_lims_metadata( + formatted_files=formatted_files, sample_files=moved_files + ) + unique_formatted_files = self._filter_unique_path_combinations(formatted_files) + return self._format_sample_files(unique_formatted_files) + + def _get_lims_naming_metadata(self, sample_id: str) -> str: + region_code = self.lims_api.get_sample_attribute( + lims_id=sample_id, key="region_code" + ).split(" ")[0] + lab_code = self.lims_api.get_sample_attribute(lims_id=sample_id, key="lab_code").split(" ")[ + 0 + ] + return f"{region_code}_{lab_code}_" + + def _add_lims_metadata( + self, formatted_files: list[FormattedFile], sample_files: list[SampleFile] + ) -> list[FormattedFile]: + for formatted_file in formatted_files: + sample_id: str = self._get_sample_id_by_original_path( + original_path=formatted_file.original_path, sample_files=sample_files + ) + lims_meta_data = self._get_lims_naming_metadata(sample_id) + formatted_file.original_path = formatted_file.formatted_path + formatted_file.formatted_path = Path( + formatted_file.formatted_path.parent, + f"{lims_meta_data}{formatted_file.formatted_path.name}", + ) + return formatted_files + + @staticmethod + def _get_sample_id_by_original_path(original_path: Path, sample_files: list[SampleFile]) -> str: + for sample_file in sample_files: + if sample_file.file_path == original_path: + return sample_file.sample_id + raise ValueError(f"Could not find sample file with path {original_path}") + + @staticmethod + def _filter_unique_path_combinations( + formatted_files: list[FormattedFile], + ) -> list[FormattedFile]: + unique_combinations = set() + unique_files = [] + for formatted_file in formatted_files: + combination = (formatted_file.original_path, formatted_file.formatted_path) + if combination not in unique_combinations: + unique_combinations.add(combination) + unique_files.append(formatted_file) + return unique_files diff --git a/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py b/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py index 131d54beeb..4e97a6f086 100644 --- a/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py +++ b/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py @@ -110,39 +110,3 @@ def _replace_fastq_paths( direction=ReadDirection.REVERSE, new_path=reverse_path, ) - - -class MutantFileFormatter(SampleFileConcatenationFormatter): - def __init__(self, lims_api: LimsAPI, concatenation_service: FastqConcatenationService): - self.lims_api: LimsAPI = lims_api - super().__init__(concatenation_service = concatenation_service) - - def format_files(self, moved_files: list[SampleFile], ticket_dir_path: Path - ) -> list[FormattedFile]: - formatted_files: list[FormattedFile] = super().format_files( - moved_files=moved_files, ticket_dir_path=ticket_dir_path - ) - formatted_files = self._add_lims_metadata(formatted_files=formatted_files, sample_files=moved_files) - return self._format_sample_files(formatted_files) - - def _get_lims_naming_metadata(self, sample_id: str)-> str: - - region_code = self.lims_api.get_sample_attribute(lims_id=sample_id, key="region_code").split(" ")[0] - lab_code = self.lims_api.get_sample_attribute(lims_id=sample_id, key="lab_code").split(" ")[0] - - return f"{region_code}_{lab_code}_" - - def _add_lims_metadata(self, formatted_files: list[FormattedFile], sample_files: list[SampleFile]) -> list[FormattedFile]: - for formatted_file in formatted_files: - sample_id: str = self._get_sample_id_by_original_path(original_path=formatted_file.original_path, sample_files=sample_files) - lims_meta_data = self._get_lims_naming_metadata(sample_id) - formatted_file.original_path = formatted_file.formatted_path - formatted_file.formatted_path = Path(formatted_file.formatted_path.parent,f"{lims_meta_data}{formatted_file.formatted_path.name}") - return formatted_files - - @staticmethod - def _get_sample_id_by_original_path(original_path: Path, sample_files: list[SampleFile])-> str: - for sample_file in sample_files: - if sample_file.file_path == original_path: - return sample_file.sample_id - raise ValueError(f"Could not find sample file with path {original_path}") \ No newline at end of file diff --git a/cg/services/deliver_files/file_formatter/utils/sample_service.py b/cg/services/deliver_files/file_formatter/utils/sample_service.py index 784be1df62..ba26927a0e 100644 --- a/cg/services/deliver_files/file_formatter/utils/sample_service.py +++ b/cg/services/deliver_files/file_formatter/utils/sample_service.py @@ -57,4 +57,3 @@ def _get_formatted_files(sample_files: list[SampleFile]) -> list[FormattedFile]: ) ) return formatted_files - diff --git a/tests/conftest.py b/tests/conftest.py index 589f02d28b..238ed66908 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4248,4 +4248,3 @@ def libary_sequencing_method() -> str: @pytest.fixture def capture_kit() -> str: return "panel.bed" - diff --git a/tests/fixture_plugins/delivery_fixtures/delivery_files_models_fixtures.py b/tests/fixture_plugins/delivery_fixtures/delivery_files_models_fixtures.py index 46b4e3dd07..5d10f4a30a 100644 --- a/tests/fixture_plugins/delivery_fixtures/delivery_files_models_fixtures.py +++ b/tests/fixture_plugins/delivery_fixtures/delivery_files_models_fixtures.py @@ -252,13 +252,21 @@ def lims_naming_matadata() -> str: @pytest.fixture -def expected_mutant_formatted_files(expected_concatenated_fastq_formatted_files, lims_naming_matadata) -> list[FormattedFile]: +def expected_mutant_formatted_files( + expected_concatenated_fastq_formatted_files, lims_naming_matadata +) -> list[FormattedFile]: + unique_combinations = [] for formatted_file in expected_concatenated_fastq_formatted_files: formatted_file.original_path = formatted_file.formatted_path - formatted_file.formatted_path = Path(formatted_file.formatted_path.parent, f"{lims_naming_matadata}{formatted_file.formatted_path.name}") - return expected_concatenated_fastq_formatted_files + formatted_file.formatted_path = Path( + formatted_file.formatted_path.parent, + f"{lims_naming_matadata}{formatted_file.formatted_path.name}", + ) + if formatted_file not in unique_combinations: + unique_combinations.append(formatted_file) + return unique_combinations @pytest.fixture def mutant_moved_files(fastq_concatenation_sample_files) -> list[SampleFile]: - return fastq_concatenation_sample_files \ No newline at end of file + return fastq_concatenation_sample_files diff --git a/tests/services/file_delivery/file_formatter/utils/test_formatter_utils.py b/tests/services/file_delivery/file_formatter/utils/test_formatter_utils.py index 5e18113747..a9f579c4c6 100644 --- a/tests/services/file_delivery/file_formatter/utils/test_formatter_utils.py +++ b/tests/services/file_delivery/file_formatter/utils/test_formatter_utils.py @@ -4,11 +4,11 @@ import pytest from pathlib import Path +from cg.services.deliver_files.file_formatter.utils.mutant_sample_service import MutantFileFormatter from cg.services.fastq_concatenation_service.fastq_concatenation_service import ( FastqConcatenationService, ) from cg.services.deliver_files.file_fetcher.models import ( - DeliveryFiles, CaseFile, SampleFile, ) @@ -17,7 +17,6 @@ CaseFileFormatter, ) from cg.services.deliver_files.file_formatter.utils.sample_concatenation_service import ( - MutantFileFormatter, SampleFileConcatenationFormatter, ) from cg.services.deliver_files.file_formatter.utils.sample_service import ( @@ -80,7 +79,7 @@ def test_mutant_file_formatter( mutant_moved_files: list[SampleFile], expected_mutant_formatted_files: list[FormattedFile], lims_naming_matadata: str, - ): +): # GIVEN existing ticket directory path and a customer inbox ticket_dir_path: Path = mutant_moved_files[0].file_path.parent @@ -88,13 +87,17 @@ def test_mutant_file_formatter( for moved_file in mutant_moved_files: moved_file.file_path.touch() - + # Initialize file_formatter inside the function to avoid multiple values for 'lims_api' - file_formatter = MutantFileFormatter(concatenation_service=FastqConcatenationService(), lims_api=Mock()) # MockLimsAPI()? + file_formatter = MutantFileFormatter( + concatenation_service=FastqConcatenationService(), lims_api=Mock() + ) # WHEN formatting the files - with mock.patch.object(MutantFileFormatter, "_get_lims_naming_metadata", return_value=lims_naming_matadata): - + with mock.patch.object( + MutantFileFormatter, "_get_lims_naming_metadata", return_value=lims_naming_matadata + ): + formatted_files: list[FormattedFile] = file_formatter.format_files( moved_files=mutant_moved_files, ticket_dir_path=ticket_dir_path, From 267d026dd7f0f2f64ffa4ad913891567554a88b9 Mon Sep 17 00:00:00 2001 From: Christian Oertlin Date: Wed, 6 Nov 2024 09:58:36 +0100 Subject: [PATCH 11/80] register components --- cg/models/cg_config.py | 1 + .../deliver_files_service_factory.py | 12 ++++++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/cg/models/cg_config.py b/cg/models/cg_config.py index 10796ef8e7..10d86f7b43 100644 --- a/cg/models/cg_config.py +++ b/cg/models/cg_config.py @@ -752,6 +752,7 @@ def delivery_service_factory(self) -> DeliveryServiceFactory: LOG.debug("Instantiating delivery service factory") factory = DeliveryServiceFactory( store=self.status_db, + lims_api=self.lims_api, hk_api=self.housekeeper_api, tb_service=self.trailblazer_api, rsync_service=self.delivery_rsync_service, diff --git a/cg/services/deliver_files/deliver_files_service/deliver_files_service_factory.py b/cg/services/deliver_files/deliver_files_service/deliver_files_service_factory.py index 9f357abd8c..d709ade192 100644 --- a/cg/services/deliver_files/deliver_files_service/deliver_files_service_factory.py +++ b/cg/services/deliver_files/deliver_files_service/deliver_files_service_factory.py @@ -2,10 +2,12 @@ from typing import Type from cg.apps.housekeeper.hk import HousekeeperAPI +from cg.apps.lims import LimsAPI from cg.apps.tb import TrailblazerAPI from cg.constants import Workflow, DataDelivery from cg.services.analysis_service.analysis_service import AnalysisService from cg.services.deliver_files.file_filter.sample_service import SampleFileFilter +from cg.services.deliver_files.file_formatter.utils.mutant_sample_service import MutantFileFormatter from cg.services.deliver_files.tag_fetcher.bam_service import ( BamDeliveryTagsFetcher, ) @@ -65,12 +67,14 @@ class DeliveryServiceFactory: def __init__( self, store: Store, + lims_api: LimsAPI, hk_api: HousekeeperAPI, rsync_service: DeliveryRsyncService, tb_service: TrailblazerAPI, analysis_service: AnalysisService, ): self.store = store + self.lims_api = lims_api self.hk_api = hk_api self.rsync_service = rsync_service self.tb_service = tb_service @@ -102,13 +106,17 @@ def _get_file_fetcher(self, delivery_type: DataDelivery) -> FetchDeliveryFilesSe tags_fetcher=file_tag_fetcher, ) - @staticmethod def _get_sample_file_formatter( + self, workflow: Workflow, ) -> SampleFileFormatter | SampleFileConcatenationFormatter: """Get the file formatter service based on the workflow.""" - if workflow in [Workflow.MICROSALT]: + if workflow == Workflow.MICROSALT: return SampleFileConcatenationFormatter(FastqConcatenationService()) + if workflow == Workflow.MUTANT: + return MutantFileFormatter( + concatenation_service=FastqConcatenationService(), lims_api=self.lims_api + ) return SampleFileFormatter() @staticmethod From 5f532e04b9dadf253adefd622d084f9ef0687e48 Mon Sep 17 00:00:00 2001 From: eliott Date: Wed, 6 Nov 2024 14:02:31 +0100 Subject: [PATCH 12/80] Add lims_api mock to delivery service builder test --- .../file_delivery/delivery_file_service/test_service_builder.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/services/file_delivery/delivery_file_service/test_service_builder.py b/tests/services/file_delivery/delivery_file_service/test_service_builder.py index bc945ed581..be8e2e38ce 100644 --- a/tests/services/file_delivery/delivery_file_service/test_service_builder.py +++ b/tests/services/file_delivery/delivery_file_service/test_service_builder.py @@ -71,6 +71,7 @@ def test_build_delivery_service( # GIVEN a delivery service builder with mocked store and hk_api builder = DeliveryServiceFactory( store=MagicMock(), + lims_api=MagicMock(), hk_api=MagicMock(), rsync_service=MagicMock(), tb_service=MagicMock(), From 356c33bd87245276f273447f758809c74fd1a151 Mon Sep 17 00:00:00 2001 From: eliott Date: Wed, 6 Nov 2024 14:06:08 +0100 Subject: [PATCH 13/80] Change comment --- .../file_delivery/file_formatter/utils/test_formatter_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/services/file_delivery/file_formatter/utils/test_formatter_utils.py b/tests/services/file_delivery/file_formatter/utils/test_formatter_utils.py index a9f579c4c6..2429b9cba1 100644 --- a/tests/services/file_delivery/file_formatter/utils/test_formatter_utils.py +++ b/tests/services/file_delivery/file_formatter/utils/test_formatter_utils.py @@ -88,7 +88,7 @@ def test_mutant_file_formatter( for moved_file in mutant_moved_files: moved_file.file_path.touch() - # Initialize file_formatter inside the function to avoid multiple values for 'lims_api' + # Initialize file_formatter file_formatter = MutantFileFormatter( concatenation_service=FastqConcatenationService(), lims_api=Mock() ) From decb43957ccbc85ec05a81e74ac8f5b2d4f924f1 Mon Sep 17 00:00:00 2001 From: Christian Oertlin Date: Mon, 25 Nov 2024 12:03:23 +0100 Subject: [PATCH 14/80] fix naming --- .../deliver_files/file_formatter/utils/mutant_sample_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cg/services/deliver_files/file_formatter/utils/mutant_sample_service.py b/cg/services/deliver_files/file_formatter/utils/mutant_sample_service.py index 9c2eda9983..e3b8d4fb4a 100644 --- a/cg/services/deliver_files/file_formatter/utils/mutant_sample_service.py +++ b/cg/services/deliver_files/file_formatter/utils/mutant_sample_service.py @@ -26,7 +26,7 @@ def format_files( formatted_files: list[FormattedFile] = self.file_formatter.format_files( moved_files=moved_files, ticket_dir_path=ticket_dir_path ) - formatted_files = self._add_lims_metadata( + formatted_files = self._add_lims_metadata_to_file_name( formatted_files=formatted_files, sample_files=moved_files ) unique_formatted_files = self._filter_unique_path_combinations(formatted_files) From 3d86914ec15a3e302464e7f379457516ea385cda Mon Sep 17 00:00:00 2001 From: Christian Oertlin Date: Mon, 25 Nov 2024 14:37:41 +0100 Subject: [PATCH 15/80] fix mocking and make function clearer --- .../utils/mutant_sample_service.py | 19 +++++++++++++------ .../utils/test_formatter_utils.py | 18 +++++++++--------- 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/cg/services/deliver_files/file_formatter/utils/mutant_sample_service.py b/cg/services/deliver_files/file_formatter/utils/mutant_sample_service.py index e3b8d4fb4a..2b128fefe0 100644 --- a/cg/services/deliver_files/file_formatter/utils/mutant_sample_service.py +++ b/cg/services/deliver_files/file_formatter/utils/mutant_sample_service.py @@ -26,10 +26,12 @@ def format_files( formatted_files: list[FormattedFile] = self.file_formatter.format_files( moved_files=moved_files, ticket_dir_path=ticket_dir_path ) - formatted_files = self._add_lims_metadata_to_file_name( + appended_formatted_files: list[FormattedFile] = self._add_lims_metadata_to_file_name( formatted_files=formatted_files, sample_files=moved_files ) - unique_formatted_files = self._filter_unique_path_combinations(formatted_files) + unique_formatted_files: list[FormattedFile] = self._filter_unique_path_combinations( + appended_formatted_files + ) for unique_files in unique_formatted_files: self.file_manager.rename_file( src=unique_files.original_path, dst=unique_files.formatted_path @@ -40,17 +42,22 @@ def _add_lims_metadata_to_file_name( self, formatted_files: list[FormattedFile], sample_files: list[SampleFile] ) -> list[FormattedFile]: """This functions adds the region and lab code to the file name of the formatted files.""" + appended_formatted_files: list[FormattedFile] = [] for formatted_file in formatted_files: sample_id: str = self._get_sample_id_by_original_path( original_path=formatted_file.original_path, sample_files=sample_files ) lims_meta_data = self.lims_api.get_sample_region_and_lab_code(sample_id) - formatted_file.original_path = formatted_file.formatted_path - formatted_file.formatted_path = Path( + + new_original_path: Path = formatted_file.formatted_path + new_formatted_path = Path( formatted_file.formatted_path.parent, f"{lims_meta_data}{formatted_file.formatted_path.name}", ) - return formatted_files + appended_formatted_files.append( + FormattedFile(original_path=new_original_path, formatted_path=new_formatted_path) + ) + return appended_formatted_files @staticmethod def _get_sample_id_by_original_path(original_path: Path, sample_files: list[SampleFile]) -> str: @@ -71,7 +78,7 @@ def _filter_unique_path_combinations( which would result in an error the second time since the files is no longer in the original path. """ unique_combinations = set() - unique_files = [] + unique_files: list[FormattedFile] = [] for formatted_file in formatted_files: combination = (formatted_file.original_path, formatted_file.formatted_path) if combination not in unique_combinations: diff --git a/tests/services/file_delivery/file_formatter/utils/test_formatter_utils.py b/tests/services/file_delivery/file_formatter/utils/test_formatter_utils.py index 07e1622941..59cd40a1dc 100644 --- a/tests/services/file_delivery/file_formatter/utils/test_formatter_utils.py +++ b/tests/services/file_delivery/file_formatter/utils/test_formatter_utils.py @@ -4,6 +4,7 @@ import pytest from pathlib import Path +from cg.apps.lims import LimsAPI from cg.services.deliver_files.file_formatter.utils.mutant_sample_service import MutantFileFormatter from cg.services.fastq_concatenation_service.fastq_concatenation_service import ( FastqConcatenationService, @@ -96,6 +97,9 @@ def test_mutant_file_formatter( for moved_file in mutant_moved_files: moved_file.file_path.touch() + lims_mock = Mock() + lims_mock.get_sample_region_and_lab_code.return_value = lims_naming_matadata + # Initialize file_formatter file_formatter = MutantFileFormatter( file_manager=FileManagingService(), @@ -104,18 +108,14 @@ def test_mutant_file_formatter( file_formatter=SampleFileNameFormatter(), concatenation_service=FastqConcatenationService(), ), - lims_api=Mock(), + lims_api=lims_mock, ) # WHEN formatting the files - with mock.patch.object( - MutantFileFormatter, "_get_lims_naming_metadata", return_value=lims_naming_matadata - ): - - formatted_files: list[FormattedFile] = file_formatter.format_files( - moved_files=mutant_moved_files, - ticket_dir_path=ticket_dir_path, - ) + formatted_files: list[FormattedFile] = file_formatter.format_files( + moved_files=mutant_moved_files, + ticket_dir_path=ticket_dir_path, + ) # THEN the files should be formatted assert formatted_files == expected_mutant_formatted_files From 89688a3adf68d1b72e9481e6d2934df0434c5080 Mon Sep 17 00:00:00 2001 From: ChristianOertlin Date: Tue, 26 Nov 2024 11:04:45 +0100 Subject: [PATCH 16/80] remove fastq delivery from mutant (#3972) --- cg/constants/delivery.py | 1 - 1 file changed, 1 deletion(-) diff --git a/cg/constants/delivery.py b/cg/constants/delivery.py index ec492f28f0..f914305e3e 100644 --- a/cg/constants/delivery.py +++ b/cg/constants/delivery.py @@ -144,7 +144,6 @@ ] MUTANT_ANALYSIS_SAMPLE_TAGS: list[set[str]] = [ - {"fastq"}, {"vcf", "vcf-report", "fohm-delivery"}, ] From db0081c9d998f015cec8cc8ce212461b1086e745 Mon Sep 17 00:00:00 2001 From: ChristianOertlin Date: Tue, 26 Nov 2024 13:51:52 +0100 Subject: [PATCH 17/80] fix(fohm upload from sample bundle) (#3970) # Description make FOHM upload use delivery logic fetching and concatenating files from sample bundles in housekeeper --- cg/cli/upload/fohm.py | 30 +++++++++++++--- cg/meta/upload/fohm/fohm.py | 34 ++++++++++--------- .../deliver_files_service.py | 13 +++++++ tests/fixture_plugins/fohm/fohm_fixtures.py | 2 +- 4 files changed, 57 insertions(+), 22 deletions(-) diff --git a/cg/cli/upload/fohm.py b/cg/cli/upload/fohm.py index 194b610cdb..1e7c551dcd 100644 --- a/cg/cli/upload/fohm.py +++ b/cg/cli/upload/fohm.py @@ -41,7 +41,11 @@ def aggregate_delivery( context: CGConfig, cases: list, dry_run: bool = False, datestr: str | None = None ): """Re-aggregates delivery files for FOHM and saves them to default working directory.""" - fohm_api = FOHMUploadAPI(config=context, dry_run=dry_run, datestr=datestr) + fohm_api = FOHMUploadAPI( + config=context, + dry_run=dry_run, + datestr=datestr, + ) try: fohm_api.aggregate_delivery(cases) except (ValidationError, TypeError) as error: @@ -57,7 +61,11 @@ def create_komplettering( context: CGConfig, cases: list, dry_run: bool = False, datestr: str | None = None ): """Re-aggregates komplettering files for FOHM and saves them to default working directory.""" - fohm_api = FOHMUploadAPI(config=context, dry_run=dry_run, datestr=datestr) + fohm_api = FOHMUploadAPI( + config=context, + dry_run=dry_run, + datestr=datestr, + ) try: fohm_api.create_and_write_complementary_report(cases) except ValidationError as error: @@ -73,7 +81,11 @@ def preprocess_all( context: CGConfig, cases: list, dry_run: bool = False, datestr: str | None = None ): """Create all FOHM upload files, upload to GISAID, sync SFTP and mail reports for all provided cases.""" - fohm_api = FOHMUploadAPI(config=context, dry_run=dry_run, datestr=datestr) + fohm_api = FOHMUploadAPI( + config=context, + dry_run=dry_run, + datestr=datestr, + ) gisaid_api = GisaidAPI(config=context) cases = list(cases) upload_cases = [] @@ -105,7 +117,11 @@ def preprocess_all( @click.pass_obj def upload_rawdata(context: CGConfig, dry_run: bool = False, datestr: str | None = None): """Deliver files in daily upload directory via sftp.""" - fohm_api = FOHMUploadAPI(config=context, dry_run=dry_run, datestr=datestr) + fohm_api = FOHMUploadAPI( + config=context, + dry_run=dry_run, + datestr=datestr, + ) fohm_api.sync_files_sftp() @@ -115,5 +131,9 @@ def upload_rawdata(context: CGConfig, dry_run: bool = False, datestr: str | None @click.pass_obj def send_reports(context: CGConfig, dry_run: bool = False, datestr: str | None = None): """Send all komplettering reports found in the current daily directory to target recipients.""" - fohm_api = FOHMUploadAPI(config=context, dry_run=dry_run, datestr=datestr) + fohm_api = FOHMUploadAPI( + config=context, + dry_run=dry_run, + datestr=datestr, + ) fohm_api.send_mail_reports() diff --git a/cg/meta/upload/fohm/fohm.py b/cg/meta/upload/fohm/fohm.py index 8026debda8..6a41a79d10 100644 --- a/cg/meta/upload/fohm/fohm.py +++ b/cg/meta/upload/fohm/fohm.py @@ -3,22 +3,21 @@ import logging import os import re -import shutil from pathlib import Path - import paramiko -from housekeeper.store.models import Version - from cg.apps.housekeeper.hk import HousekeeperAPI from cg.apps.lims import LimsAPI from cg.constants import FileExtensions -from cg.constants.constants import SARS_COV_REGEX +from cg.constants.constants import SARS_COV_REGEX, DataDelivery from cg.constants.housekeeper_tags import FohmTag from cg.exc import CgError from cg.io.csv import read_csv, write_csv_from_dict from cg.models.cg_config import CGConfig from cg.models.email import EmailInfo from cg.models.fohm.reports import FohmComplementaryReport, FohmPangolinReport +from cg.services.deliver_files.deliver_files_service.deliver_files_service_factory import ( + DeliveryServiceFactory, +) from cg.store.models import Case, Sample from cg.store.store import Store from cg.utils.dict import remove_duplicate_dicts @@ -28,7 +27,12 @@ class FOHMUploadAPI: - def __init__(self, config: CGConfig, dry_run: bool = False, datestr: str | None = None): + def __init__( + self, + config: CGConfig, + dry_run: bool = False, + datestr: str | None = None, + ): self.config: CGConfig = config self.housekeeper_api: HousekeeperAPI = config.housekeeper_api self.lims_api: LimsAPI = config.lims_api @@ -44,6 +48,7 @@ def __init__(self, config: CGConfig, dry_run: bool = False, datestr: str | None self._reports_dataframe = None self._pangolin_dataframe = None self._aggregation_dataframe = None + self._delivery_factory: DeliveryServiceFactory = config.delivery_service_factory @property def current_datestr(self) -> str: @@ -196,16 +201,13 @@ def link_sample_raw_data_files( sample: Sample = self.status_db.get_sample_by_internal_id( internal_id=report.internal_id ) - bundle_name: str = sample.links[0].case.internal_id - version: Version = self.housekeeper_api.last_version(bundle=bundle_name) - files = self.housekeeper_api.files(version=version.id, tags={report.internal_id}).all() - for file in files: - if self._dry_run: - LOG.info( - f"Would have copied {file.full_path} to {Path(self.daily_rawdata_path)}" - ) - continue - shutil.copy(file.full_path, Path(self.daily_rawdata_path)) + case: Case = sample.links[0].case + delivery_service = self._delivery_factory.build_delivery_service( + case=case, delivery_type=DataDelivery.FASTQ + ) + delivery_service.deliver_files_for_fohm_upload( + case=case, sample_id=sample.internal_id, delivery_base_path=self.daily_rawdata_path + ) def create_pangolin_report(self, reports: list[FohmPangolinReport]) -> None: LOG.info("Creating aggregate Pangolin report") diff --git a/cg/services/deliver_files/deliver_files_service/deliver_files_service.py b/cg/services/deliver_files/deliver_files_service/deliver_files_service.py index 6b45433885..6ee0f38907 100644 --- a/cg/services/deliver_files/deliver_files_service/deliver_files_service.py +++ b/cg/services/deliver_files/deliver_files_service/deliver_files_service.py @@ -107,6 +107,19 @@ def deliver_files_for_sample( ) self._add_trailblazer_tracking(case=case, job_id=job_id, dry_run=dry_run) + def deliver_files_for_fohm_upload(self, case: Case, sample_id: str, delivery_base_path: Path): + """Deliver the files for a sample to the specified folder.""" + delivery_files: DeliveryFiles = self.file_manager.get_files_to_deliver( + case_id=case.internal_id + ) + filtered_files: DeliveryFiles = self.file_filter.filter_delivery_files( + delivery_files=delivery_files, sample_id=sample_id + ) + moved_files: DeliveryFiles = self.file_mover.move_files( + delivery_files=filtered_files, delivery_base_path=delivery_base_path + ) + self.file_formatter.format_files(moved_files) + def _start_rsync_job(self, case: Case, dry_run: bool, folders_to_deliver: set[Path]) -> int: LOG.debug(f"[RSYNC] Starting rsync job for case {case.internal_id}") job_id: int = self.rsync_service.run_rsync_for_case( diff --git a/tests/fixture_plugins/fohm/fohm_fixtures.py b/tests/fixture_plugins/fohm/fohm_fixtures.py index 17531570e3..eef17e9f4c 100644 --- a/tests/fixture_plugins/fohm/fohm_fixtures.py +++ b/tests/fixture_plugins/fohm/fohm_fixtures.py @@ -109,7 +109,7 @@ def fohm_upload_api( cg_context: CGConfig, mocker: MockFixture, helpers: StoreHelpers ) -> FOHMUploadAPI: """FOHM upload API fixture.""" - fohm_upload_api = FOHMUploadAPI(cg_context) + fohm_upload_api = FOHMUploadAPI(config=cg_context) # Mock getting Sample object from StatusDB mocker.patch.object( From d6a6aeb461cfebbc888758711f50e37f9364130f Mon Sep 17 00:00:00 2001 From: Christian Oertlin Date: Wed, 27 Nov 2024 14:15:31 +0100 Subject: [PATCH 18/80] register formatter --- .../deliver_files_service_factory.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/cg/services/deliver_files/deliver_files_service/deliver_files_service_factory.py b/cg/services/deliver_files/deliver_files_service/deliver_files_service_factory.py index 442b9cba77..e4e9980b2d 100644 --- a/cg/services/deliver_files/deliver_files_service/deliver_files_service_factory.py +++ b/cg/services/deliver_files/deliver_files_service/deliver_files_service_factory.py @@ -22,6 +22,7 @@ from cg.services.deliver_files.file_formatter.abstract import DeliveryFileFormattingService from cg.services.deliver_files.file_formatter.service import DeliveryFileFormatter from cg.services.deliver_files.file_formatter.utils.case_service import CaseFileFormatter +from cg.services.deliver_files.file_formatter.utils.mutant_sample_service import MutantFileFormatter from cg.services.deliver_files.file_formatter.utils.sample_concatenation_service import ( SampleFileConcatenationFormatter, ) @@ -138,7 +139,7 @@ def _convert_workflow(self, case: Case) -> Workflow: def _get_sample_file_formatter( self, case: Case, - ) -> SampleFileFormatter | SampleFileConcatenationFormatter: + ) -> SampleFileFormatter | SampleFileConcatenationFormatter | MutantFileFormatter: """Get the file formatter service based on the workflow.""" converted_workflow: Workflow = self._convert_workflow(case) if converted_workflow in [Workflow.MICROSALT]: @@ -147,6 +148,16 @@ def _get_sample_file_formatter( file_formatter=SampleFileNameFormatter(), concatenation_service=FastqConcatenationService(), ) + if converted_workflow == Workflow.MUTANT: + return MutantFileFormatter( + lims_api=self.lims_api, + file_manager=FileManagingService(), + file_formatter=SampleFileConcatenationFormatter( + file_manager=FileManagingService(), + file_formatter=SampleFileNameFormatter(), + concatenation_service=FastqConcatenationService(), + ), + ) return SampleFileFormatter( file_manager=FileManagingService(), file_name_formatter=SampleFileNameFormatter() ) From bc984bce8c5fcd96fe3a3bbb53732e0d3e3c0479 Mon Sep 17 00:00:00 2001 From: Christian Oertlin Date: Wed, 27 Nov 2024 14:49:08 +0100 Subject: [PATCH 19/80] add debug --- cg/meta/upload/fohm/fohm.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cg/meta/upload/fohm/fohm.py b/cg/meta/upload/fohm/fohm.py index 6a41a79d10..cc73737c8b 100644 --- a/cg/meta/upload/fohm/fohm.py +++ b/cg/meta/upload/fohm/fohm.py @@ -197,6 +197,7 @@ def link_sample_raw_data_files( self, reports: list[FohmComplementaryReport] | list[FohmPangolinReport] ) -> None: """Hardlink samples raw data files to FOHM delivery folder.""" + LOG.debug("Linking sample raw data files to FOHM delivery folder") for report in reports: sample: Sample = self.status_db.get_sample_by_internal_id( internal_id=report.internal_id @@ -205,6 +206,7 @@ def link_sample_raw_data_files( delivery_service = self._delivery_factory.build_delivery_service( case=case, delivery_type=DataDelivery.FASTQ ) + LOG.debug(f"Linking files for sample {sample.internal_id}") delivery_service.deliver_files_for_fohm_upload( case=case, sample_id=sample.internal_id, delivery_base_path=self.daily_rawdata_path ) From 15f5d0f2c80f6ee1859aa678c3f309240b9b6860 Mon Sep 17 00:00:00 2001 From: Christian Oertlin Date: Wed, 27 Nov 2024 15:14:58 +0100 Subject: [PATCH 20/80] fix test --- .../delivery_file_service/test_service_builder.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/services/file_delivery/delivery_file_service/test_service_builder.py b/tests/services/file_delivery/delivery_file_service/test_service_builder.py index 99a850bc7a..80e4cd6424 100644 --- a/tests/services/file_delivery/delivery_file_service/test_service_builder.py +++ b/tests/services/file_delivery/delivery_file_service/test_service_builder.py @@ -17,6 +17,7 @@ ) from cg.services.deliver_files.file_fetcher.analysis_service import AnalysisDeliveryFileFetcher from cg.services.deliver_files.file_fetcher.raw_data_service import RawDataDeliveryFileFetcher +from cg.services.deliver_files.file_formatter.utils.mutant_sample_service import MutantFileFormatter from cg.services.deliver_files.file_formatter.utils.sample_concatenation_service import ( SampleFileConcatenationFormatter, ) @@ -37,7 +38,9 @@ class DeliveryServiceScenario(BaseModel): expected_tag_fetcher: type[FetchDeliveryFileTagsService] expected_file_fetcher: type[FetchDeliveryFilesService] expected_file_mover: type[DeliveryFilesMover] - expected_sample_file_formatter: type[SampleFileFormatter | SampleFileConcatenationFormatter] + expected_sample_file_formatter: type[ + SampleFileFormatter | SampleFileConcatenationFormatter | MutantFileFormatter + ] store_name: str @@ -61,7 +64,7 @@ class DeliveryServiceScenario(BaseModel): expected_tag_fetcher=SampleAndCaseDeliveryTagsFetcher, expected_file_fetcher=AnalysisDeliveryFileFetcher, expected_file_mover=DeliveryFilesMover, - expected_sample_file_formatter=SampleFileFormatter, + expected_sample_file_formatter=MutantFileFormatter, store_name="mutant_store", ), DeliveryServiceScenario( From 491daf6c3eb30088811d950ef1014b1772af945b Mon Sep 17 00:00:00 2001 From: ChristianOertlin Date: Wed, 27 Nov 2024 17:42:14 +0100 Subject: [PATCH 21/80] Update cg/apps/lims/api.py --- cg/apps/lims/api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cg/apps/lims/api.py b/cg/apps/lims/api.py index 77108c1ab3..ed722c219b 100644 --- a/cg/apps/lims/api.py +++ b/cg/apps/lims/api.py @@ -558,6 +558,6 @@ def _get_negative_controls_from_list(samples: list[Sample]) -> list[Sample]: def get_sample_region_and_lab_code(self, sample_id: str) -> str: """Return the reqgion code and lab code for a sample formatted as a suffix string.""" - region_code = self.get_sample_attribute(lims_id=sample_id, key="region_code").split(" ")[0] - lab_code = self.get_sample_attribute(lims_id=sample_id, key="lab_code").split(" ")[0] + region_code: str = self.get_sample_attribute(lims_id=sample_id, key="region_code").split(" ")[0] + lab_code: str = self.get_sample_attribute(lims_id=sample_id, key="lab_code").split(" ")[0] return f"{region_code}_{lab_code}_" From 841fa433f3843a53be96ba993c74d811a36823ed Mon Sep 17 00:00:00 2001 From: Christian Oertlin Date: Mon, 2 Dec 2024 09:48:54 +0100 Subject: [PATCH 22/80] linting --- cg/apps/lims/api.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cg/apps/lims/api.py b/cg/apps/lims/api.py index ed722c219b..86912f3673 100644 --- a/cg/apps/lims/api.py +++ b/cg/apps/lims/api.py @@ -558,6 +558,8 @@ def _get_negative_controls_from_list(samples: list[Sample]) -> list[Sample]: def get_sample_region_and_lab_code(self, sample_id: str) -> str: """Return the reqgion code and lab code for a sample formatted as a suffix string.""" - region_code: str = self.get_sample_attribute(lims_id=sample_id, key="region_code").split(" ")[0] + region_code: str = self.get_sample_attribute(lims_id=sample_id, key="region_code").split( + " " + )[0] lab_code: str = self.get_sample_attribute(lims_id=sample_id, key="lab_code").split(" ")[0] return f"{region_code}_{lab_code}_" From fbd2234a8a461373098fb7602124d57418ae7b4d Mon Sep 17 00:00:00 2001 From: Christian Oertlin Date: Mon, 2 Dec 2024 10:16:43 +0100 Subject: [PATCH 23/80] add another debug --- cg/meta/upload/fohm/fohm.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cg/meta/upload/fohm/fohm.py b/cg/meta/upload/fohm/fohm.py index cc73737c8b..de18ded16b 100644 --- a/cg/meta/upload/fohm/fohm.py +++ b/cg/meta/upload/fohm/fohm.py @@ -202,6 +202,7 @@ def link_sample_raw_data_files( sample: Sample = self.status_db.get_sample_by_internal_id( internal_id=report.internal_id ) + LOG.debug(f"Linking files for sample {sample.internal_id}") case: Case = sample.links[0].case delivery_service = self._delivery_factory.build_delivery_service( case=case, delivery_type=DataDelivery.FASTQ From 5bc0bec593b5ad741a07822844353c25602b96ca Mon Sep 17 00:00:00 2001 From: Christian Oertlin Date: Mon, 2 Dec 2024 10:17:41 +0100 Subject: [PATCH 24/80] debug reports --- cg/meta/upload/fohm/fohm.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cg/meta/upload/fohm/fohm.py b/cg/meta/upload/fohm/fohm.py index de18ded16b..4ac1870050 100644 --- a/cg/meta/upload/fohm/fohm.py +++ b/cg/meta/upload/fohm/fohm.py @@ -198,6 +198,7 @@ def link_sample_raw_data_files( ) -> None: """Hardlink samples raw data files to FOHM delivery folder.""" LOG.debug("Linking sample raw data files to FOHM delivery folder") + LOG.debug(f"NR of Reports: {len(reports)}") for report in reports: sample: Sample = self.status_db.get_sample_by_internal_id( internal_id=report.internal_id From 56c4d406d1537cae50ada81ca135560b7042d004 Mon Sep 17 00:00:00 2001 From: Christian Oertlin Date: Mon, 2 Dec 2024 11:27:56 +0100 Subject: [PATCH 25/80] temp test fix --- cg/constants/constants.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cg/constants/constants.py b/cg/constants/constants.py index 175b9c152a..926992ced3 100644 --- a/cg/constants/constants.py +++ b/cg/constants/constants.py @@ -100,7 +100,7 @@ class SexOptions(StrEnum): UNKNOWN: str = "unknown" -SARS_COV_REGEX = "^[0-9]{2}CS[0-9]{6}$" +SARS_COV_REGEX = "^[0-9]{2}CS[0-9]{6}TEST$" STATUS_OPTIONS = ("affected", "unaffected", "unknown") From 3e79f8ca80564ec97aac4a221eb4708aee687dcd Mon Sep 17 00:00:00 2001 From: Christian Oertlin Date: Mon, 2 Dec 2024 11:51:30 +0100 Subject: [PATCH 26/80] fix --- .../deliver_files_service/deliver_files_service.py | 6 +++++- cg/services/deliver_files/file_formatter/service.py | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/cg/services/deliver_files/deliver_files_service/deliver_files_service.py b/cg/services/deliver_files/deliver_files_service/deliver_files_service.py index 2ae0709d78..cdfa2edfce 100644 --- a/cg/services/deliver_files/deliver_files_service/deliver_files_service.py +++ b/cg/services/deliver_files/deliver_files_service/deliver_files_service.py @@ -1,4 +1,5 @@ import logging +import shutil from pathlib import Path from cg.apps.tb import TrailblazerAPI @@ -118,7 +119,10 @@ def deliver_files_for_fohm_upload(self, case: Case, sample_id: str, delivery_bas moved_files: DeliveryFiles = self.file_mover.move_files( delivery_files=filtered_files, delivery_base_path=delivery_base_path ) - self.file_formatter.format_files(moved_files) + formatted_files = self.file_formatter.format_files(moved_files) + # Move files back to the delivery base path so it conforms to the FOHM upload structure + for formatted_file in formatted_files.files: + shutil.move(src=formatted_file.formatted_path.parent, dst=delivery_base_path) def _start_rsync_job(self, case: Case, dry_run: bool, folders_to_deliver: set[Path]) -> int: LOG.debug(f"[RSYNC] Starting rsync job for case {case.internal_id}") diff --git a/cg/services/deliver_files/file_formatter/service.py b/cg/services/deliver_files/file_formatter/service.py index 2265db4f2e..abdf035fc9 100644 --- a/cg/services/deliver_files/file_formatter/service.py +++ b/cg/services/deliver_files/file_formatter/service.py @@ -6,6 +6,7 @@ from cg.services.deliver_files.file_formatter.abstract import DeliveryFileFormattingService from cg.services.deliver_files.file_formatter.models import FormattedFile, FormattedFiles from cg.services.deliver_files.file_formatter.utils.case_service import CaseFileFormatter +from cg.services.deliver_files.file_formatter.utils.mutant_sample_service import MutantFileFormatter from cg.services.deliver_files.file_formatter.utils.sample_concatenation_service import ( SampleFileConcatenationFormatter, ) @@ -25,7 +26,9 @@ class DeliveryFileFormatter(DeliveryFileFormattingService): def __init__( self, case_file_formatter: CaseFileFormatter, - sample_file_formatter: SampleFileFormatter | SampleFileConcatenationFormatter, + sample_file_formatter: ( + SampleFileFormatter | SampleFileConcatenationFormatter | MutantFileFormatter + ), ): self.case_file_formatter = case_file_formatter self.sample_file_formatter = sample_file_formatter From 62b21830ca0ae20ff5465ce6220b988673e2be5b Mon Sep 17 00:00:00 2001 From: Christian Oertlin Date: Mon, 2 Dec 2024 11:54:03 +0100 Subject: [PATCH 27/80] update docstring --- .../deliver_files_service/deliver_files_service.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/cg/services/deliver_files/deliver_files_service/deliver_files_service.py b/cg/services/deliver_files/deliver_files_service/deliver_files_service.py index cdfa2edfce..77b83bfd96 100644 --- a/cg/services/deliver_files/deliver_files_service/deliver_files_service.py +++ b/cg/services/deliver_files/deliver_files_service/deliver_files_service.py @@ -109,7 +109,14 @@ def deliver_files_for_sample( self._add_trailblazer_tracking(case=case, job_id=job_id, dry_run=dry_run) def deliver_files_for_fohm_upload(self, case: Case, sample_id: str, delivery_base_path: Path): - """Deliver the files for a sample to the specified folder.""" + """ + Deliver the files for a sample to the FOHM upload destination. In addition to the normal delivery, + the files need to be moved back to the delivery base path so it conforms to the FOHM upload structure. + + :param case: The case to deliver files for + :param sample_id: The sample to deliver files for + :param delivery_base_path: The base path to deliver the files to + """ delivery_files: DeliveryFiles = self.file_manager.get_files_to_deliver( case_id=case.internal_id ) From cd8c3da5ab10fe2cf2d274844b3662fac0c745fa Mon Sep 17 00:00:00 2001 From: Christian Oertlin Date: Mon, 2 Dec 2024 12:00:11 +0100 Subject: [PATCH 28/80] debug --- .../deliver_files_service/deliver_files_service.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/cg/services/deliver_files/deliver_files_service/deliver_files_service.py b/cg/services/deliver_files/deliver_files_service/deliver_files_service.py index 77b83bfd96..93c052b80a 100644 --- a/cg/services/deliver_files/deliver_files_service/deliver_files_service.py +++ b/cg/services/deliver_files/deliver_files_service/deliver_files_service.py @@ -127,9 +127,15 @@ def deliver_files_for_fohm_upload(self, case: Case, sample_id: str, delivery_bas delivery_files=filtered_files, delivery_base_path=delivery_base_path ) formatted_files = self.file_formatter.format_files(moved_files) - # Move files back to the delivery base path so it conforms to the FOHM upload structure + for formatted_file in formatted_files.files: + # Move files back to the delivery base path so it conforms to the FOHM upload structure + LOG.debug( + f"Moving files for sample {formatted_file} back to the delivery base path {delivery_base_path}" + ) shutil.move(src=formatted_file.formatted_path.parent, dst=delivery_base_path) + # Delete the formatted folder + shutil.rmtree(path=formatted_file.formatted_path.parent) def _start_rsync_job(self, case: Case, dry_run: bool, folders_to_deliver: set[Path]) -> int: LOG.debug(f"[RSYNC] Starting rsync job for case {case.internal_id}") From f28cc87dcd3f33b1900f10007342cb8e26781302 Mon Sep 17 00:00:00 2001 From: Christian Oertlin Date: Mon, 2 Dec 2024 12:07:37 +0100 Subject: [PATCH 29/80] fix --- .../deliver_files_service/deliver_files_service.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cg/services/deliver_files/deliver_files_service/deliver_files_service.py b/cg/services/deliver_files/deliver_files_service/deliver_files_service.py index 93c052b80a..04eb93cc77 100644 --- a/cg/services/deliver_files/deliver_files_service/deliver_files_service.py +++ b/cg/services/deliver_files/deliver_files_service/deliver_files_service.py @@ -133,9 +133,9 @@ def deliver_files_for_fohm_upload(self, case: Case, sample_id: str, delivery_bas LOG.debug( f"Moving files for sample {formatted_file} back to the delivery base path {delivery_base_path}" ) - shutil.move(src=formatted_file.formatted_path.parent, dst=delivery_base_path) + shutil.move(src=formatted_file.formatted_path, dst=delivery_base_path) # Delete the formatted folder - shutil.rmtree(path=formatted_file.formatted_path.parent) + shutil.rmtree(path=formatted_files[0].formatted_path.parent) def _start_rsync_job(self, case: Case, dry_run: bool, folders_to_deliver: set[Path]) -> int: LOG.debug(f"[RSYNC] Starting rsync job for case {case.internal_id}") From 1fb48878246ca6bb50fedd7380c61d697f91734e Mon Sep 17 00:00:00 2001 From: Christian Oertlin Date: Mon, 2 Dec 2024 12:23:49 +0100 Subject: [PATCH 30/80] lint --- .../deliver_files_service/deliver_files_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cg/services/deliver_files/deliver_files_service/deliver_files_service.py b/cg/services/deliver_files/deliver_files_service/deliver_files_service.py index 04eb93cc77..0e21ba0371 100644 --- a/cg/services/deliver_files/deliver_files_service/deliver_files_service.py +++ b/cg/services/deliver_files/deliver_files_service/deliver_files_service.py @@ -134,7 +134,7 @@ def deliver_files_for_fohm_upload(self, case: Case, sample_id: str, delivery_bas f"Moving files for sample {formatted_file} back to the delivery base path {delivery_base_path}" ) shutil.move(src=formatted_file.formatted_path, dst=delivery_base_path) - # Delete the formatted folder + # Delete the sample folder shutil.rmtree(path=formatted_files[0].formatted_path.parent) def _start_rsync_job(self, case: Case, dry_run: bool, folders_to_deliver: set[Path]) -> int: From 1a28d97ca5ba58a4655fd658d086d90f033e5843 Mon Sep 17 00:00:00 2001 From: Christian Oertlin Date: Mon, 2 Dec 2024 13:27:57 +0100 Subject: [PATCH 31/80] rework filemovers --- .../deliver_files_service.py | 2 +- .../deliver_files_service_factory.py | 12 +- .../utils/mutant_sample_service.py | 4 +- .../utils/sample_concatenation_service.py | 4 +- .../file_formatter/utils/sample_service.py | 26 +--- .../file_mover/delivery_files_mover.py | 63 ++++++++++ .../file_mover/fohm_upload_files_mover.py | 26 ++++ .../deliver_files/file_mover/service.py | 111 ------------------ cg/services/deliver_files/utils.py | 69 +++++++++++ .../delivery_files_models_fixtures.py | 22 ++++ .../delivery_services_fixtures.py | 4 +- .../test_service_builder.py | 2 +- .../utils/test_formatter_utils.py | 10 +- .../file_mover/test_file_mover_service.py | 25 +++- 14 files changed, 222 insertions(+), 158 deletions(-) create mode 100644 cg/services/deliver_files/file_mover/delivery_files_mover.py create mode 100644 cg/services/deliver_files/file_mover/fohm_upload_files_mover.py delete mode 100644 cg/services/deliver_files/file_mover/service.py create mode 100644 cg/services/deliver_files/utils.py diff --git a/cg/services/deliver_files/deliver_files_service/deliver_files_service.py b/cg/services/deliver_files/deliver_files_service/deliver_files_service.py index 0e21ba0371..beec52f463 100644 --- a/cg/services/deliver_files/deliver_files_service/deliver_files_service.py +++ b/cg/services/deliver_files/deliver_files_service/deliver_files_service.py @@ -15,7 +15,7 @@ from cg.services.deliver_files.file_filter.abstract import FilterDeliveryFilesService from cg.services.deliver_files.file_formatter.abstract import DeliveryFileFormattingService from cg.services.deliver_files.file_formatter.models import FormattedFiles -from cg.services.deliver_files.file_mover.service import DeliveryFilesMover +from cg.services.deliver_files.file_mover.delivery_files_mover import DeliveryFilesMover from cg.services.deliver_files.rsync.service import DeliveryRsyncService from cg.store.exc import EntryNotFoundError from cg.store.models import Case diff --git a/cg/services/deliver_files/deliver_files_service/deliver_files_service_factory.py b/cg/services/deliver_files/deliver_files_service/deliver_files_service_factory.py index a6d72a438c..9d2f070b5d 100644 --- a/cg/services/deliver_files/deliver_files_service/deliver_files_service_factory.py +++ b/cg/services/deliver_files/deliver_files_service/deliver_files_service_factory.py @@ -28,10 +28,10 @@ ) from cg.services.deliver_files.file_formatter.utils.sample_service import ( SampleFileFormatter, - FileManagingService, + FileManager, SampleFileNameFormatter, ) -from cg.services.deliver_files.file_mover.service import DeliveryFilesMover +from cg.services.deliver_files.file_mover.delivery_files_mover import DeliveryFilesMover from cg.services.deliver_files.rsync.service import DeliveryRsyncService from cg.services.deliver_files.tag_fetcher.abstract import FetchDeliveryFileTagsService from cg.services.deliver_files.tag_fetcher.bam_service import BamDeliveryTagsFetcher @@ -144,22 +144,22 @@ def _get_sample_file_formatter( converted_workflow: Workflow = self._convert_workflow(case) if converted_workflow in [Workflow.MICROSALT]: return SampleFileConcatenationFormatter( - file_manager=FileManagingService(), + file_manager=FileManager(), file_formatter=SampleFileNameFormatter(), concatenation_service=FastqConcatenationService(), ) if converted_workflow == Workflow.MUTANT: return MutantFileFormatter( lims_api=self.lims_api, - file_manager=FileManagingService(), + file_manager=FileManager(), file_formatter=SampleFileConcatenationFormatter( - file_manager=FileManagingService(), + file_manager=FileManager(), file_formatter=SampleFileNameFormatter(), concatenation_service=FastqConcatenationService(), ), ) return SampleFileFormatter( - file_manager=FileManagingService(), file_name_formatter=SampleFileNameFormatter() + file_manager=FileManager(), file_name_formatter=SampleFileNameFormatter() ) def build_delivery_service( diff --git a/cg/services/deliver_files/file_formatter/utils/mutant_sample_service.py b/cg/services/deliver_files/file_formatter/utils/mutant_sample_service.py index 2b128fefe0..a246212195 100644 --- a/cg/services/deliver_files/file_formatter/utils/mutant_sample_service.py +++ b/cg/services/deliver_files/file_formatter/utils/mutant_sample_service.py @@ -6,7 +6,7 @@ from cg.services.deliver_files.file_formatter.utils.sample_concatenation_service import ( SampleFileConcatenationFormatter, ) -from cg.services.deliver_files.file_formatter.utils.sample_service import FileManagingService +from cg.services.deliver_files.file_formatter.utils.sample_service import FileManager class MutantFileFormatter: @@ -14,7 +14,7 @@ def __init__( self, lims_api: LimsAPI, file_formatter: SampleFileConcatenationFormatter, - file_manager: FileManagingService, + file_manager: FileManager, ): self.lims_api: LimsAPI = lims_api self.file_formatter: SampleFileConcatenationFormatter = file_formatter diff --git a/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py b/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py index 785914cfe5..96171b00bb 100644 --- a/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py +++ b/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py @@ -11,7 +11,7 @@ from cg.services.deliver_files.file_formatter.models import FormattedFile from cg.services.deliver_files.file_formatter.utils.sample_service import ( SampleFileNameFormatter, - FileManagingService, + FileManager, ) @@ -23,7 +23,7 @@ class SampleFileConcatenationFormatter: def __init__( self, - file_manager: FileManagingService, + file_manager: FileManager, file_formatter: SampleFileNameFormatter, concatenation_service: FastqConcatenationService, ): diff --git a/cg/services/deliver_files/file_formatter/utils/sample_service.py b/cg/services/deliver_files/file_formatter/utils/sample_service.py index 51f9964e41..628c0c5991 100644 --- a/cg/services/deliver_files/file_formatter/utils/sample_service.py +++ b/cg/services/deliver_files/file_formatter/utils/sample_service.py @@ -1,31 +1,13 @@ -import os from pathlib import Path from cg.services.deliver_files.file_fetcher.models import SampleFile from cg.services.deliver_files.file_formatter.models import FormattedFile - - -class FileManagingService: - """ - Service to manage files. - Handles operations that create or rename files and directories. - """ - - @staticmethod - def create_directories(base_path: Path, directories: set[str]) -> None: - """Create directories for given names under the base path.""" - for directory in directories: - Path(base_path, directory).mkdir(exist_ok=True) - - @staticmethod - def rename_file(src: Path, dst: Path) -> None: - """Rename a file from src to dst.""" - os.rename(src, dst) +from cg.services.deliver_files.utils import FileManager class SampleFileNameFormatter: """ - Class to format sample file names. + Class to format sample file names and paths. """ @staticmethod @@ -60,9 +42,7 @@ class SampleFileFormatter: Used for all workflows except Microsalt and Mutant. """ - def __init__( - self, file_manager: FileManagingService, file_name_formatter: SampleFileNameFormatter - ): + def __init__(self, file_manager: FileManager, file_name_formatter: SampleFileNameFormatter): self.file_manager = file_manager self.file_name_formatter = file_name_formatter diff --git a/cg/services/deliver_files/file_mover/delivery_files_mover.py b/cg/services/deliver_files/file_mover/delivery_files_mover.py new file mode 100644 index 0000000000..c7e593c8ac --- /dev/null +++ b/cg/services/deliver_files/file_mover/delivery_files_mover.py @@ -0,0 +1,63 @@ +import logging +from pathlib import Path + +from cg.constants.delivery import INBOX_NAME +from cg.services.deliver_files.file_fetcher.models import ( + CaseFile, + DeliveryFiles, + DeliveryMetaData, + SampleFile, +) +from cg.services.deliver_files.utils import FileMover +from cg.utils.files import link_or_overwrite_file + +LOG = logging.getLogger(__name__) + + +class DeliveryFilesMover: + """ + Class to move files to the customer folder. + """ + + def __init__(self, file_mover: FileMover): + self.file_mover = file_mover + + def move_files(self, delivery_files: DeliveryFiles, delivery_base_path: Path) -> DeliveryFiles: + """Move the files to the customer folder.""" + + inbox_ticket_dir_path = self._create_ticket_inbox_dir_path( + delivery_base_path=delivery_base_path, delivery_data=delivery_files.delivery_data + ) + delivery_files.delivery_data.customer_ticket_inbox = inbox_ticket_dir_path + + self.file_mover.create_directories( + base_path=delivery_base_path, + directories={str(inbox_ticket_dir_path.relative_to(delivery_base_path))}, + ) + if delivery_files.case_files: + self.file_mover.move_files_to_directory( + file_models=delivery_files.case_files, target_dir=inbox_ticket_dir_path + ) + delivery_files.case_files = self.file_mover.update_file_paths( + file_models=delivery_files.case_files, target_dir=inbox_ticket_dir_path + ) + self.file_mover.move_files_to_directory( + file_models=delivery_files.sample_files, target_dir=inbox_ticket_dir_path + ) + delivery_files.sample_files = self.file_mover.update_file_paths( + file_models=delivery_files.sample_files, target_dir=inbox_ticket_dir_path + ) + + return delivery_files + + @staticmethod + def _create_ticket_inbox_dir_path( + delivery_base_path: Path, delivery_data: DeliveryMetaData + ) -> Path: + """Generate the path to the ticket inbox directory.""" + return Path( + delivery_base_path, + delivery_data.customer_internal_id, + INBOX_NAME, + delivery_data.ticket_id, + ) diff --git a/cg/services/deliver_files/file_mover/fohm_upload_files_mover.py b/cg/services/deliver_files/file_mover/fohm_upload_files_mover.py new file mode 100644 index 0000000000..b10857068d --- /dev/null +++ b/cg/services/deliver_files/file_mover/fohm_upload_files_mover.py @@ -0,0 +1,26 @@ +from pathlib import Path + +from cg.services.deliver_files.file_fetcher.models import DeliveryFiles +from cg.services.deliver_files.utils import FileMover + + +class GenericFilesMover: + """ + Class to move files directly to the delivery base path. + """ + + def __init__(self, file_mover: FileMover): + self.file_mover = file_mover + + def move_files(self, delivery_files: DeliveryFiles, delivery_base_path: Path) -> DeliveryFiles: + """Move the files directly to the delivery base path.""" + if delivery_files.case_files: + self.file_mover.move_files_to_directory(delivery_files.case_files, delivery_base_path) + delivery_files.case_files = self.file_mover.update_file_paths( + delivery_files.case_files, delivery_base_path + ) + self.file_mover.move_files_to_directory(delivery_files.sample_files, delivery_base_path) + delivery_files.sample_files = self.file_mover.update_file_paths( + delivery_files.sample_files, delivery_base_path + ) + return delivery_files diff --git a/cg/services/deliver_files/file_mover/service.py b/cg/services/deliver_files/file_mover/service.py deleted file mode 100644 index d02d55d6be..0000000000 --- a/cg/services/deliver_files/file_mover/service.py +++ /dev/null @@ -1,111 +0,0 @@ -import logging -from pathlib import Path - -from cg.constants.delivery import INBOX_NAME -from cg.services.deliver_files.file_fetcher.models import ( - CaseFile, - DeliveryFiles, - DeliveryMetaData, - SampleFile, -) -from cg.utils.files import link_or_overwrite_file - -LOG = logging.getLogger(__name__) - - -class DeliveryFilesMover: - """ - Class that encapsulates the logic required for moving files to the customer folder. - """ - - def move_files(self, delivery_files: DeliveryFiles, delivery_base_path: Path) -> DeliveryFiles: - """Move the files to the customer folder.""" - inbox_ticket_dir_path: Path = self._create_ticket_inbox_dir_path( - delivery_base_path=delivery_base_path, delivery_data=delivery_files.delivery_data - ) - delivery_files.delivery_data.customer_ticket_inbox = inbox_ticket_dir_path - self._create_ticket_inbox_folder(inbox_ticket_dir_path) - self._create_hard_links_for_delivery_files( - delivery_files=delivery_files, inbox_dir_path=inbox_ticket_dir_path - ) - return self._replace_file_paths_with_inbox_dir_paths( - delivery_files=delivery_files, inbox_dir_path=inbox_ticket_dir_path - ) - - @staticmethod - def _create_ticket_inbox_folder( - inbox_ticket_dir_path: Path, - ) -> Path: - """Create a ticket inbox folder in the customer folder, overwrites if already present.""" - LOG.debug(f"[MOVE SERVICE] Creating ticket inbox folder: {inbox_ticket_dir_path}") - inbox_ticket_dir_path.mkdir(parents=True, exist_ok=True) - return inbox_ticket_dir_path - - @staticmethod - def _create_ticket_inbox_dir_path( - delivery_base_path: Path, delivery_data: DeliveryMetaData - ) -> Path: - """Create the path to the ticket inbox folder.""" - return Path( - delivery_base_path, - delivery_data.customer_internal_id, - INBOX_NAME, - delivery_data.ticket_id, - ) - - @staticmethod - def _create_inbox_file_path(file_path: Path, inbox_dir_path: Path) -> Path: - """Create the path to the inbox file.""" - return Path(inbox_dir_path, file_path.name) - - def _create_hard_link_file_paths( - self, file_models: list[SampleFile | CaseFile], inbox_dir_path: Path - ) -> None: - """Create hard links to the sample files in the customer folder.""" - for file_model in file_models: - inbox_file_path: Path = self._create_inbox_file_path( - file_path=file_model.file_path, inbox_dir_path=inbox_dir_path - ) - link_or_overwrite_file(src=file_model.file_path, dst=inbox_file_path) - - def _create_hard_links_for_delivery_files( - self, delivery_files: DeliveryFiles, inbox_dir_path: Path - ) -> None: - """Create hard links to the files in the customer folder.""" - LOG.debug(f"[MOVE SERVICE] Creating hard links for delivery files in: {inbox_dir_path}") - if delivery_files.case_files: - self._create_hard_link_file_paths( - file_models=delivery_files.case_files, inbox_dir_path=inbox_dir_path - ) - self._create_hard_link_file_paths( - file_models=delivery_files.sample_files, inbox_dir_path=inbox_dir_path - ) - - def _replace_file_path_with_inbox_dir_path( - self, file_models: list[SampleFile | CaseFile], inbox_dir_path: Path - ) -> list[SampleFile | CaseFile]: - """Replace the file path with the inbox path.""" - for file_model in file_models: - inbox_file_path: Path = self._create_inbox_file_path( - file_path=file_model.file_path, inbox_dir_path=inbox_dir_path - ) - file_model.file_path = inbox_file_path - return file_models - - def _replace_file_paths_with_inbox_dir_paths( - self, - delivery_files: DeliveryFiles, - inbox_dir_path: Path, - ) -> DeliveryFiles: - """ - Replace to original file paths in the delivery files with the customer inbox file paths. - """ - LOG.debug(f"[MOVE SERVICE] Replacing file paths with inbox dir path: {inbox_dir_path}") - if delivery_files.case_files: - delivery_files.case_files = self._replace_file_path_with_inbox_dir_path( - file_models=delivery_files.case_files, inbox_dir_path=inbox_dir_path - ) - delivery_files.sample_files = self._replace_file_path_with_inbox_dir_path( - file_models=delivery_files.sample_files, inbox_dir_path=inbox_dir_path - ) - return delivery_files diff --git a/cg/services/deliver_files/utils.py b/cg/services/deliver_files/utils.py new file mode 100644 index 0000000000..0c5f5e3dae --- /dev/null +++ b/cg/services/deliver_files/utils.py @@ -0,0 +1,69 @@ +import logging +import os +from pathlib import Path + +from cg.services.deliver_files.file_fetcher.models import SampleFile, CaseFile + +LOG = logging.getLogger(__name__) + + +class FileManager: + """ + Service to manage files. + Handles operations that create or rename files and directories. + """ + + @staticmethod + def create_directories(base_path: Path, directories: set[str]) -> None: + """Create directories for given names under the base path.""" + for directory in directories: + Path(base_path, directory).mkdir(parents=True, exist_ok=True) + + @staticmethod + def rename_file(src: Path, dst: Path) -> None: + """Rename a file from src to dst.""" + os.rename(src=src, dst=dst) + + @staticmethod + def create_hard_link(src: Path, dst: Path) -> None: + """Create a hard link from src to dst.""" + os.link(src=src, dst=dst) + + +class FileMover: + """ + Service class to move files. + Requires a file management service to perform file operations. + """ + + def __init__(self, file_manager): + """ + :param file_manager: Service for file operations (e.g., create directories, move files). + """ + self.file_management_service = file_manager + + def create_directories(self, base_path: Path, directories: set[str]) -> None: + """Create required directories.""" + self.file_management_service.create_directories(base_path, directories) + + def move_files_to_directory(self, file_models: list, target_dir: Path) -> None: + """Move files to the target directory.""" + for file_model in file_models: + target_path = Path(target_dir, file_model.file_path.name) + self._move_or_link_file(src=file_model.file_path, dst=target_path) + + @staticmethod + def update_file_paths( + file_models: list[CaseFile | SampleFile], target_dir: Path + ) -> list[CaseFile | SampleFile]: + """Update file paths to point to the target directory.""" + for file_model in file_models: + file_model.file_path = Path(target_dir, file_model.file_path.name) + return file_models + + def _move_or_link_file(self, src: Path, dst: Path) -> None: + """Move or create a hard link for a file.""" + if dst.exists(): + LOG.debug(f"Overwriting existing file: {dst}") + dst.unlink() + self.file_management_service.create_hard_link(src=src, dst=dst) diff --git a/tests/fixture_plugins/delivery_fixtures/delivery_files_models_fixtures.py b/tests/fixture_plugins/delivery_fixtures/delivery_files_models_fixtures.py index 5d10f4a30a..e169eaebb3 100644 --- a/tests/fixture_plugins/delivery_fixtures/delivery_files_models_fixtures.py +++ b/tests/fixture_plugins/delivery_fixtures/delivery_files_models_fixtures.py @@ -270,3 +270,25 @@ def expected_mutant_formatted_files( @pytest.fixture def mutant_moved_files(fastq_concatenation_sample_files) -> list[SampleFile]: return fastq_concatenation_sample_files + + +@pytest.fixture +def expected_upload_files(expected_analysis_delivery_files: DeliveryFiles): + return expected_analysis_delivery_files + + +@pytest.fixture +def expected_moved_upload_files(expected_analysis_delivery_files: DeliveryFiles, tmp_path: Path): + delivery_files = DeliveryFiles(**expected_analysis_delivery_files.model_dump()) + new_case_files: list[CaseFile] = swap_file_paths_with_inbox_paths( + file_models=delivery_files.case_files, inbox_dir_path=tmp_path + ) + new_sample_files: list[SampleFile] = swap_file_paths_with_inbox_paths( + file_models=delivery_files.sample_files, inbox_dir_path=tmp_path + ) + + return DeliveryFiles( + delivery_data=delivery_files.delivery_data, + case_files=new_case_files, + sample_files=new_sample_files, + ) diff --git a/tests/fixture_plugins/delivery_fixtures/delivery_services_fixtures.py b/tests/fixture_plugins/delivery_fixtures/delivery_services_fixtures.py index 5d81346d36..8ef3b94cd4 100644 --- a/tests/fixture_plugins/delivery_fixtures/delivery_services_fixtures.py +++ b/tests/fixture_plugins/delivery_fixtures/delivery_services_fixtures.py @@ -27,7 +27,7 @@ ) from cg.services.deliver_files.file_formatter.utils.sample_service import ( SampleFileFormatter, - FileManagingService, + FileManager, SampleFileNameFormatter, ) from cg.store.store import Store @@ -122,7 +122,7 @@ def generic_delivery_file_formatter() -> DeliveryFileFormatter: """Fixture to get an instance of GenericDeliveryFileFormatter.""" return DeliveryFileFormatter( sample_file_formatter=SampleFileFormatter( - file_manager=FileManagingService(), file_name_formatter=SampleFileNameFormatter() + file_manager=FileManager(), file_name_formatter=SampleFileNameFormatter() ), case_file_formatter=CaseFileFormatter(), ) diff --git a/tests/services/file_delivery/delivery_file_service/test_service_builder.py b/tests/services/file_delivery/delivery_file_service/test_service_builder.py index 80e4cd6424..cd7475394a 100644 --- a/tests/services/file_delivery/delivery_file_service/test_service_builder.py +++ b/tests/services/file_delivery/delivery_file_service/test_service_builder.py @@ -22,7 +22,7 @@ SampleFileConcatenationFormatter, ) from cg.services.deliver_files.file_formatter.utils.sample_service import SampleFileFormatter -from cg.services.deliver_files.file_mover.service import DeliveryFilesMover +from cg.services.deliver_files.file_mover.delivery_files_mover import DeliveryFilesMover from cg.services.deliver_files.tag_fetcher.abstract import FetchDeliveryFileTagsService from cg.services.deliver_files.tag_fetcher.sample_and_case_service import ( SampleAndCaseDeliveryTagsFetcher, diff --git a/tests/services/file_delivery/file_formatter/utils/test_formatter_utils.py b/tests/services/file_delivery/file_formatter/utils/test_formatter_utils.py index 59cd40a1dc..096b755bf0 100644 --- a/tests/services/file_delivery/file_formatter/utils/test_formatter_utils.py +++ b/tests/services/file_delivery/file_formatter/utils/test_formatter_utils.py @@ -22,7 +22,7 @@ ) from cg.services.deliver_files.file_formatter.utils.sample_service import ( SampleFileFormatter, - FileManagingService, + FileManager, SampleFileNameFormatter, ) @@ -39,14 +39,14 @@ "expected_moved_analysis_sample_delivery_files", "expected_formatted_analysis_sample_files", SampleFileFormatter( - file_manager=FileManagingService(), file_name_formatter=SampleFileNameFormatter() + file_manager=FileManager(), file_name_formatter=SampleFileNameFormatter() ), ), ( "fastq_concatenation_sample_files", "expected_concatenated_fastq_formatted_files", SampleFileConcatenationFormatter( - file_manager=FileManagingService(), + file_manager=FileManager(), file_formatter=SampleFileNameFormatter(), concatenation_service=FastqConcatenationService(), ), @@ -102,9 +102,9 @@ def test_mutant_file_formatter( # Initialize file_formatter file_formatter = MutantFileFormatter( - file_manager=FileManagingService(), + file_manager=FileManager(), file_formatter=SampleFileConcatenationFormatter( - file_manager=FileManagingService(), + file_manager=FileManager(), file_formatter=SampleFileNameFormatter(), concatenation_service=FastqConcatenationService(), ), diff --git a/tests/services/file_delivery/file_mover/test_file_mover_service.py b/tests/services/file_delivery/file_mover/test_file_mover_service.py index 068a771835..27083124ab 100644 --- a/tests/services/file_delivery/file_mover/test_file_mover_service.py +++ b/tests/services/file_delivery/file_mover/test_file_mover_service.py @@ -3,21 +3,37 @@ import pytest from cg.services.deliver_files.file_fetcher.models import DeliveryFiles -from cg.services.deliver_files.file_mover.service import ( +from cg.services.deliver_files.file_mover.delivery_files_mover import ( DeliveryFilesMover, ) +from cg.services.deliver_files.file_mover.fohm_upload_files_mover import GenericFilesMover +from cg.services.deliver_files.utils import FileMover, FileManager @pytest.mark.parametrize( - "expected_moved_delivery_files,delivery_files", + "expected_moved_delivery_files,delivery_files,move_files_service", [ - ("expected_moved_fastq_delivery_files", "expected_fastq_delivery_files"), - ("expected_moved_analysis_delivery_files", "expected_analysis_delivery_files"), + ( + "expected_moved_fastq_delivery_files", + "expected_fastq_delivery_files", + DeliveryFilesMover(FileMover(FileManager())), + ), + ( + "expected_moved_analysis_delivery_files", + "expected_analysis_delivery_files", + DeliveryFilesMover(FileMover(FileManager())), + ), + ( + "expected_moved_upload_files", + "expected_upload_files", + GenericFilesMover(FileMover(FileManager())), + ), ], ) def test_move_files( expected_moved_delivery_files: DeliveryFiles, delivery_files: DeliveryFiles, + move_files_service: DeliveryFilesMover, tmp_path, request, ): @@ -28,7 +44,6 @@ def test_move_files( delivery_files: DeliveryFiles = request.getfixturevalue(delivery_files) # WHEN moving the delivery files - move_files_service = DeliveryFilesMover() moved_delivery_files: DeliveryFiles = move_files_service.move_files( delivery_files=delivery_files, delivery_base_path=tmp_path ) From 090862e73efe8606b74c2a2986c8823b3c8d5dc8 Mon Sep 17 00:00:00 2001 From: Christian Oertlin Date: Mon, 2 Dec 2024 13:31:22 +0100 Subject: [PATCH 32/80] add dependencies in factory --- .../deliver_files_service/deliver_files_service_factory.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cg/services/deliver_files/deliver_files_service/deliver_files_service_factory.py b/cg/services/deliver_files/deliver_files_service/deliver_files_service_factory.py index 9d2f070b5d..9fc7304195 100644 --- a/cg/services/deliver_files/deliver_files_service/deliver_files_service_factory.py +++ b/cg/services/deliver_files/deliver_files_service/deliver_files_service_factory.py @@ -38,6 +38,7 @@ from cg.services.deliver_files.tag_fetcher.sample_and_case_service import ( SampleAndCaseDeliveryTagsFetcher, ) +from cg.services.deliver_files.utils import FileMover from cg.services.fastq_concatenation_service.fastq_concatenation_service import ( FastqConcatenationService, ) @@ -179,7 +180,7 @@ def build_delivery_service( ) return DeliverFilesService( delivery_file_manager_service=file_fetcher, - move_file_service=DeliveryFilesMover(), + move_file_service=DeliveryFilesMover(FileMover(FileManager())), file_filter=SampleFileFilter(), file_formatter_service=file_formatter, status_db=self.store, From f9a5eb41267ecf51a4aa4e467d999ca3d6d45551 Mon Sep 17 00:00:00 2001 From: Christian Oertlin Date: Mon, 2 Dec 2024 14:09:06 +0100 Subject: [PATCH 33/80] register things in factory add new parameter --- cg/cli/deliver/base.py | 9 +-- cg/cli/deliver/utils.py | 3 +- cg/meta/upload/fohm/fohm.py | 5 +- cg/meta/upload/upload_api.py | 3 +- cg/services/deliver_files/constants.py | 6 ++ .../deliver_files_service_factory.py | 63 ++++++++++++++++--- .../utils/sample_concatenation_service.py | 5 +- .../file_formatter/utils/sample_service.py | 36 ++++++++++- .../delivery_services_fixtures.py | 4 +- .../utils/test_formatter_utils.py | 8 +-- 10 files changed, 111 insertions(+), 31 deletions(-) create mode 100644 cg/services/deliver_files/constants.py diff --git a/cg/cli/deliver/base.py b/cg/cli/deliver/base.py index 265fba2f8f..1e7e2505cb 100644 --- a/cg/cli/deliver/base.py +++ b/cg/cli/deliver/base.py @@ -88,8 +88,7 @@ def deliver_case( LOG.error(f"Could not find case with id {case_id}") return delivery_service: DeliverFilesService = service_builder.build_delivery_service( - case=case, - delivery_type=delivery_type, + case=case, delivery_type=delivery_type ) delivery_service.deliver_files_for_case( case=case, delivery_base_path=Path(inbox), dry_run=dry_run @@ -124,8 +123,7 @@ def deliver_ticket( LOG.error(f"Could not find case connected to ticket {ticket}") return delivery_service: DeliverFilesService = service_builder.build_delivery_service( - case=cases[0], - delivery_type=delivery_type, + case=cases[0], delivery_type=delivery_type ) delivery_service.deliver_files_for_ticket( ticket_id=ticket, delivery_base_path=Path(inbox), dry_run=dry_run @@ -172,8 +170,7 @@ def deliver_sample_raw_data( LOG.error(f"Could not find case with id {case_id}") return delivery_service: DeliverFilesService = service_builder.build_delivery_service( - case=case, - delivery_type=delivery_type, + case=case, delivery_type=delivery_type ) delivery_service.deliver_files_for_sample( case=case, sample_id=sample_id, delivery_base_path=Path(inbox), dry_run=dry_run diff --git a/cg/cli/deliver/utils.py b/cg/cli/deliver/utils.py index 14e8255c51..97552efe18 100644 --- a/cg/cli/deliver/utils.py +++ b/cg/cli/deliver/utils.py @@ -26,8 +26,7 @@ def deliver_raw_data_for_analyses( try: case: Case = analysis.case delivery_service: DeliverFilesService = service_builder.build_delivery_service( - case=case, - delivery_type=case.data_delivery, + case=case, delivery_type=case.data_delivery ) delivery_service.deliver_files_for_case( diff --git a/cg/meta/upload/fohm/fohm.py b/cg/meta/upload/fohm/fohm.py index 4ac1870050..894c8e8546 100644 --- a/cg/meta/upload/fohm/fohm.py +++ b/cg/meta/upload/fohm/fohm.py @@ -15,6 +15,7 @@ from cg.models.cg_config import CGConfig from cg.models.email import EmailInfo from cg.models.fohm.reports import FohmComplementaryReport, FohmPangolinReport +from cg.services.deliver_files.constants import DeliveryDestination from cg.services.deliver_files.deliver_files_service.deliver_files_service_factory import ( DeliveryServiceFactory, ) @@ -206,7 +207,9 @@ def link_sample_raw_data_files( LOG.debug(f"Linking files for sample {sample.internal_id}") case: Case = sample.links[0].case delivery_service = self._delivery_factory.build_delivery_service( - case=case, delivery_type=DataDelivery.FASTQ + case=case, + delivery_type=DataDelivery.FASTQ, + delivery_destination=DeliveryDestination.UPLOAD, ) LOG.debug(f"Linking files for sample {sample.internal_id}") delivery_service.deliver_files_for_fohm_upload( diff --git a/cg/meta/upload/upload_api.py b/cg/meta/upload/upload_api.py index d455079f92..a02e90cd98 100644 --- a/cg/meta/upload/upload_api.py +++ b/cg/meta/upload/upload_api.py @@ -97,8 +97,7 @@ def upload_files_to_customer_inbox(self, case: Case) -> None: """Uploads the analysis files to the customer inbox.""" factory_service: DeliveryServiceFactory = self.config.delivery_service_factory delivery_service: DeliverFilesService = factory_service.build_delivery_service( - case=case, - delivery_type=case.data_delivery, + case=case, delivery_type=case.data_delivery ) delivery_service.deliver_files_for_case( case=case, delivery_base_path=Path(self.config.delivery_path) diff --git a/cg/services/deliver_files/constants.py b/cg/services/deliver_files/constants.py new file mode 100644 index 0000000000..913c71ceb2 --- /dev/null +++ b/cg/services/deliver_files/constants.py @@ -0,0 +1,6 @@ +from enum import Enum + + +class DeliveryDestination(Enum): + UPLOAD = "upload" + CUSTOMER = "customer" diff --git a/cg/services/deliver_files/deliver_files_service/deliver_files_service_factory.py b/cg/services/deliver_files/deliver_files_service/deliver_files_service_factory.py index 9fc7304195..843acce6fa 100644 --- a/cg/services/deliver_files/deliver_files_service/deliver_files_service_factory.py +++ b/cg/services/deliver_files/deliver_files_service/deliver_files_service_factory.py @@ -8,6 +8,7 @@ from cg.constants import DataDelivery, Workflow from cg.constants.sequencing import SeqLibraryPrepCategory from cg.services.analysis_service.analysis_service import AnalysisService +from cg.services.deliver_files.constants import DeliveryDestination from cg.services.deliver_files.deliver_files_service.deliver_files_service import ( DeliverFilesService, ) @@ -29,9 +30,11 @@ from cg.services.deliver_files.file_formatter.utils.sample_service import ( SampleFileFormatter, FileManager, - SampleFileNameFormatter, + NestedSampleFileNameFormatter, + FlatSampleFileNameFormatter, ) from cg.services.deliver_files.file_mover.delivery_files_mover import DeliveryFilesMover +from cg.services.deliver_files.file_mover.fohm_upload_files_mover import GenericFilesMover from cg.services.deliver_files.rsync.service import DeliveryRsyncService from cg.services.deliver_files.tag_fetcher.abstract import FetchDeliveryFileTagsService from cg.services.deliver_files.tag_fetcher.bam_service import BamDeliveryTagsFetcher @@ -47,7 +50,11 @@ class DeliveryServiceFactory: - """Class to build the delivery services based on workflow and delivery type.""" + """ + Class to build the delivery services based on workflow, delivery type, delivery destination. + The delivery destination is used to specify delivery to the customer or for upload. + It determines how the delivery_base_path is managed and its underlying folder structure. + """ def __init__( self, @@ -140,47 +147,83 @@ def _convert_workflow(self, case: Case) -> Workflow: def _get_sample_file_formatter( self, case: Case, + delivery_destination: str, ) -> SampleFileFormatter | SampleFileConcatenationFormatter | MutantFileFormatter: """Get the file formatter service based on the workflow.""" converted_workflow: Workflow = self._convert_workflow(case) if converted_workflow in [Workflow.MICROSALT]: return SampleFileConcatenationFormatter( file_manager=FileManager(), - file_formatter=SampleFileNameFormatter(), + file_formatter=NestedSampleFileNameFormatter(), concatenation_service=FastqConcatenationService(), ) if converted_workflow == Workflow.MUTANT: + if delivery_destination == "upload": + return MutantFileFormatter( + lims_api=self.lims_api, + file_manager=FileManager(), + file_formatter=SampleFileConcatenationFormatter( + file_manager=FileManager(), + file_formatter=FlatSampleFileNameFormatter(), + concatenation_service=FastqConcatenationService(), + ), + ) return MutantFileFormatter( lims_api=self.lims_api, file_manager=FileManager(), file_formatter=SampleFileConcatenationFormatter( file_manager=FileManager(), - file_formatter=SampleFileNameFormatter(), + file_formatter=NestedSampleFileNameFormatter(), concatenation_service=FastqConcatenationService(), ), ) return SampleFileFormatter( - file_manager=FileManager(), file_name_formatter=SampleFileNameFormatter() + file_manager=FileManager(), file_name_formatter=NestedSampleFileNameFormatter() ) + @staticmethod + def _get_file_mover( + delivery_destination: DeliveryDestination, + ) -> DeliveryFilesMover | GenericFilesMover: + """Get the file mover based on the delivery type. + + Args: + delivery_destination: The destination of the delivery defaults to customer. + """ + if delivery_destination == DeliveryDestination.UPLOAD: + return GenericFilesMover(FileMover(FileManager())) + return DeliveryFilesMover(FileMover(FileManager())) + def build_delivery_service( - self, case: Case, delivery_type: DataDelivery | None = None + self, + case: Case, + delivery_type: DataDelivery | None = None, + delivery_destination: DeliveryDestination = DeliveryDestination.CUSTOMER, ) -> DeliverFilesService: - """Build a delivery service based on a case.""" + """Build a delivery service based on a case. + + Args: + case: The case to deliver files for. + delivery_type: The type of delivery to perform. + delivery_destination: The destination of the delivery defaults to customer. + """ delivery_type: DataDelivery = self._sanitise_delivery_type( delivery_type if delivery_type else case.data_delivery ) self._validate_delivery_type(delivery_type) file_fetcher: FetchDeliveryFilesService = self._get_file_fetcher(delivery_type) - sample_file_formatter: SampleFileFormatter | SampleFileConcatenationFormatter = ( - self._get_sample_file_formatter(case) + file_move_service: DeliveryFilesMover | GenericFilesMover = self._get_file_mover( + delivery_destination=delivery_destination ) + sample_file_formatter: ( + SampleFileFormatter | SampleFileConcatenationFormatter | MutantFileFormatter + ) = self._get_sample_file_formatter(case) file_formatter: DeliveryFileFormattingService = DeliveryFileFormatter( case_file_formatter=CaseFileFormatter(), sample_file_formatter=sample_file_formatter ) return DeliverFilesService( delivery_file_manager_service=file_fetcher, - move_file_service=DeliveryFilesMover(FileMover(FileManager())), + move_file_service=file_move_service, file_filter=SampleFileFilter(), file_formatter_service=file_formatter, status_db=self.store, diff --git a/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py b/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py index 96171b00bb..c25bdf86d6 100644 --- a/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py +++ b/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py @@ -10,8 +10,9 @@ from cg.services.deliver_files.file_fetcher.models import SampleFile from cg.services.deliver_files.file_formatter.models import FormattedFile from cg.services.deliver_files.file_formatter.utils.sample_service import ( - SampleFileNameFormatter, + NestedSampleFileNameFormatter, FileManager, + FlatSampleFileNameFormatter, ) @@ -24,7 +25,7 @@ class SampleFileConcatenationFormatter: def __init__( self, file_manager: FileManager, - file_formatter: SampleFileNameFormatter, + file_formatter: NestedSampleFileNameFormatter | FlatSampleFileNameFormatter, concatenation_service: FastqConcatenationService, ): self.file_manager = file_manager diff --git a/cg/services/deliver_files/file_formatter/utils/sample_service.py b/cg/services/deliver_files/file_formatter/utils/sample_service.py index 628c0c5991..55961663cb 100644 --- a/cg/services/deliver_files/file_formatter/utils/sample_service.py +++ b/cg/services/deliver_files/file_formatter/utils/sample_service.py @@ -5,7 +5,7 @@ from cg.services.deliver_files.utils import FileManager -class SampleFileNameFormatter: +class NestedSampleFileNameFormatter: """ Class to format sample file names and paths. """ @@ -36,13 +36,45 @@ def format_sample_file_names(sample_files: list[SampleFile]) -> list[FormattedFi return formatted_files +class FlatSampleFileNameFormatter: + """ + Class to format sample file names. + """ + + @staticmethod + def get_sample_names(sample_files: list[SampleFile]) -> set[str]: + """Extract sample names from the sample files.""" + return {sample_file.sample_name for sample_file in sample_files} + + @staticmethod + def format_sample_file_names(sample_files: list[SampleFile]) -> list[FormattedFile]: + """ + Returns formatted files with original and formatted file names: + Replaces sample id by sample name. + """ + formatted_files = [] + for sample_file in sample_files: + replaced_name = sample_file.file_path.name.replace( + sample_file.sample_id, sample_file.sample_name + ) + formatted_path = Path(sample_file.file_path.parent, replaced_name) + formatted_files.append( + FormattedFile(original_path=sample_file.file_path, formatted_path=formatted_path) + ) + return formatted_files + + class SampleFileFormatter: """ Format the sample files to deliver. Used for all workflows except Microsalt and Mutant. """ - def __init__(self, file_manager: FileManager, file_name_formatter: SampleFileNameFormatter): + def __init__( + self, + file_manager: FileManager, + file_name_formatter: NestedSampleFileNameFormatter | FlatSampleFileNameFormatter, + ): self.file_manager = file_manager self.file_name_formatter = file_name_formatter diff --git a/tests/fixture_plugins/delivery_fixtures/delivery_services_fixtures.py b/tests/fixture_plugins/delivery_fixtures/delivery_services_fixtures.py index 8ef3b94cd4..e1bc25fcb9 100644 --- a/tests/fixture_plugins/delivery_fixtures/delivery_services_fixtures.py +++ b/tests/fixture_plugins/delivery_fixtures/delivery_services_fixtures.py @@ -28,7 +28,7 @@ from cg.services.deliver_files.file_formatter.utils.sample_service import ( SampleFileFormatter, FileManager, - SampleFileNameFormatter, + NestedSampleFileNameFormatter, ) from cg.store.store import Store @@ -122,7 +122,7 @@ def generic_delivery_file_formatter() -> DeliveryFileFormatter: """Fixture to get an instance of GenericDeliveryFileFormatter.""" return DeliveryFileFormatter( sample_file_formatter=SampleFileFormatter( - file_manager=FileManager(), file_name_formatter=SampleFileNameFormatter() + file_manager=FileManager(), file_name_formatter=NestedSampleFileNameFormatter() ), case_file_formatter=CaseFileFormatter(), ) diff --git a/tests/services/file_delivery/file_formatter/utils/test_formatter_utils.py b/tests/services/file_delivery/file_formatter/utils/test_formatter_utils.py index 096b755bf0..ce8aeb0f5a 100644 --- a/tests/services/file_delivery/file_formatter/utils/test_formatter_utils.py +++ b/tests/services/file_delivery/file_formatter/utils/test_formatter_utils.py @@ -23,7 +23,7 @@ from cg.services.deliver_files.file_formatter.utils.sample_service import ( SampleFileFormatter, FileManager, - SampleFileNameFormatter, + NestedSampleFileNameFormatter, ) @@ -39,7 +39,7 @@ "expected_moved_analysis_sample_delivery_files", "expected_formatted_analysis_sample_files", SampleFileFormatter( - file_manager=FileManager(), file_name_formatter=SampleFileNameFormatter() + file_manager=FileManager(), file_name_formatter=NestedSampleFileNameFormatter() ), ), ( @@ -47,7 +47,7 @@ "expected_concatenated_fastq_formatted_files", SampleFileConcatenationFormatter( file_manager=FileManager(), - file_formatter=SampleFileNameFormatter(), + file_formatter=NestedSampleFileNameFormatter(), concatenation_service=FastqConcatenationService(), ), ), @@ -105,7 +105,7 @@ def test_mutant_file_formatter( file_manager=FileManager(), file_formatter=SampleFileConcatenationFormatter( file_manager=FileManager(), - file_formatter=SampleFileNameFormatter(), + file_formatter=NestedSampleFileNameFormatter(), concatenation_service=FastqConcatenationService(), ), lims_api=lims_mock, From 12d6236e3bffe719ffb0174d10625e88b816c6a2 Mon Sep 17 00:00:00 2001 From: Christian Oertlin Date: Mon, 2 Dec 2024 14:10:20 +0100 Subject: [PATCH 34/80] pass param --- .../deliver_files_service/deliver_files_service_factory.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cg/services/deliver_files/deliver_files_service/deliver_files_service_factory.py b/cg/services/deliver_files/deliver_files_service/deliver_files_service_factory.py index 843acce6fa..a7aeefc917 100644 --- a/cg/services/deliver_files/deliver_files_service/deliver_files_service_factory.py +++ b/cg/services/deliver_files/deliver_files_service/deliver_files_service_factory.py @@ -147,7 +147,7 @@ def _convert_workflow(self, case: Case) -> Workflow: def _get_sample_file_formatter( self, case: Case, - delivery_destination: str, + delivery_destination: DeliveryDestination, ) -> SampleFileFormatter | SampleFileConcatenationFormatter | MutantFileFormatter: """Get the file formatter service based on the workflow.""" converted_workflow: Workflow = self._convert_workflow(case) @@ -217,7 +217,7 @@ def build_delivery_service( ) sample_file_formatter: ( SampleFileFormatter | SampleFileConcatenationFormatter | MutantFileFormatter - ) = self._get_sample_file_formatter(case) + ) = self._get_sample_file_formatter(case=case, delivery_destination=delivery_destination) file_formatter: DeliveryFileFormattingService = DeliveryFileFormatter( case_file_formatter=CaseFileFormatter(), sample_file_formatter=sample_file_formatter ) From 7a92203fe8d7d169e2cb3bd019a9307b6552bbc0 Mon Sep 17 00:00:00 2001 From: Christian Oertlin Date: Mon, 2 Dec 2024 16:34:09 +0100 Subject: [PATCH 35/80] fix --- .../deliver_files_service_factory.py | 29 ++++++--- ...{service.py => delivery_file_formatter.py} | 14 ++--- .../file_formatter/upload_file_formatter.py | 60 +++++++++++++++++++ .../file_formatter/utils/case_service.py | 8 +-- .../utils/mutant_sample_service.py | 4 +- .../utils/sample_concatenation_service.py | 6 +- .../file_formatter/utils/sample_service.py | 6 +- .../file_mover/delivery_files_mover.py | 3 - .../delivery_files_models_fixtures.py | 19 ++++++ .../delivery_formatted_files_fixtures.py | 24 +++++++- .../delivery_services_fixtures.py | 2 +- .../utils/test_formatter_utils.py | 18 ++++-- 12 files changed, 150 insertions(+), 43 deletions(-) rename cg/services/deliver_files/file_formatter/{service.py => delivery_file_formatter.py} (85%) create mode 100644 cg/services/deliver_files/file_formatter/upload_file_formatter.py diff --git a/cg/services/deliver_files/deliver_files_service/deliver_files_service_factory.py b/cg/services/deliver_files/deliver_files_service/deliver_files_service_factory.py index a7aeefc917..e15919749b 100644 --- a/cg/services/deliver_files/deliver_files_service/deliver_files_service_factory.py +++ b/cg/services/deliver_files/deliver_files_service/deliver_files_service_factory.py @@ -21,7 +21,7 @@ from cg.services.deliver_files.file_fetcher.raw_data_service import RawDataDeliveryFileFetcher from cg.services.deliver_files.file_filter.sample_service import SampleFileFilter from cg.services.deliver_files.file_formatter.abstract import DeliveryFileFormattingService -from cg.services.deliver_files.file_formatter.service import DeliveryFileFormatter +from cg.services.deliver_files.file_formatter.delivery_file_formatter import DeliveryFileFormatter from cg.services.deliver_files.file_formatter.utils.case_service import CaseFileFormatter from cg.services.deliver_files.file_formatter.utils.mutant_sample_service import MutantFileFormatter from cg.services.deliver_files.file_formatter.utils.sample_concatenation_service import ( @@ -158,7 +158,7 @@ def _get_sample_file_formatter( concatenation_service=FastqConcatenationService(), ) if converted_workflow == Workflow.MUTANT: - if delivery_destination == "upload": + if delivery_destination == DeliveryDestination.UPLOAD: return MutantFileFormatter( lims_api=self.lims_api, file_manager=FileManager(), @@ -194,6 +194,24 @@ def _get_file_mover( return GenericFilesMover(FileMover(FileManager())) return DeliveryFilesMover(FileMover(FileManager())) + def _get_file_formatter( + self, + delivery_destination: DeliveryDestination, + case: Case, + ) -> DeliveryFileFormattingService: + """Get the file formatter service based on the delivery destination.""" + sample_file_formatter: ( + SampleFileFormatter | SampleFileConcatenationFormatter | MutantFileFormatter + ) = self._get_sample_file_formatter(case=case, delivery_destination=delivery_destination) + if delivery_destination == DeliveryDestination.UPLOAD: + return DeliveryFileFormatter( + case_file_formatter=CaseFileFormatter(), + sample_file_formatter=sample_file_formatter, + ) + return DeliveryFileFormatter( + case_file_formatter=CaseFileFormatter(), sample_file_formatter=sample_file_formatter + ) + def build_delivery_service( self, case: Case, @@ -215,11 +233,8 @@ def build_delivery_service( file_move_service: DeliveryFilesMover | GenericFilesMover = self._get_file_mover( delivery_destination=delivery_destination ) - sample_file_formatter: ( - SampleFileFormatter | SampleFileConcatenationFormatter | MutantFileFormatter - ) = self._get_sample_file_formatter(case=case, delivery_destination=delivery_destination) - file_formatter: DeliveryFileFormattingService = DeliveryFileFormatter( - case_file_formatter=CaseFileFormatter(), sample_file_formatter=sample_file_formatter + file_formatter: DeliveryFileFormattingService = self._get_file_formatter( + case=case, delivery_destination=delivery_destination ) return DeliverFilesService( delivery_file_manager_service=file_fetcher, diff --git a/cg/services/deliver_files/file_formatter/service.py b/cg/services/deliver_files/file_formatter/delivery_file_formatter.py similarity index 85% rename from cg/services/deliver_files/file_formatter/service.py rename to cg/services/deliver_files/file_formatter/delivery_file_formatter.py index abdf035fc9..b807695fe3 100644 --- a/cg/services/deliver_files/file_formatter/service.py +++ b/cg/services/deliver_files/file_formatter/delivery_file_formatter.py @@ -17,7 +17,7 @@ class DeliveryFileFormatter(DeliveryFileFormattingService): """ - Format the files to be delivered in the generic format. + Format the files to be delivered in the customer inbox format. Expected structure: /inbox/// /inbox/// @@ -34,10 +34,9 @@ def __init__( self.sample_file_formatter = sample_file_formatter def format_files(self, delivery_files: DeliveryFiles) -> FormattedFiles: - """Format the files to be delivered and return the formatted files in the generic format.""" + """Format the files to be delivered and return the formatted files in the customer inbox format.""" LOG.debug("[FORMAT SERVICE] Formatting files for delivery") ticket_dir_path: Path = delivery_files.delivery_data.customer_ticket_inbox - self._create_ticket_dir(ticket_dir_path) formatted_files: list[FormattedFile] = self._format_sample_and_case_files( sample_files=delivery_files.sample_files, case_files=delivery_files.case_files, @@ -51,17 +50,12 @@ def _format_sample_and_case_files( """Helper method to format both sample and case files.""" formatted_files: list[FormattedFile] = self.sample_file_formatter.format_files( moved_files=sample_files, - ticket_dir_path=ticket_dir_path, + delivery_path=ticket_dir_path, ) if case_files: formatted_case_files: list[FormattedFile] = self.case_file_formatter.format_files( moved_files=case_files, - ticket_dir_path=ticket_dir_path, + delivery_path=ticket_dir_path, ) formatted_files.extend(formatted_case_files) return formatted_files - - @staticmethod - def _create_ticket_dir(ticket_dir_path: Path) -> None: - """Create the ticket directory if it does not exist.""" - os.makedirs(ticket_dir_path, exist_ok=True) diff --git a/cg/services/deliver_files/file_formatter/upload_file_formatter.py b/cg/services/deliver_files/file_formatter/upload_file_formatter.py new file mode 100644 index 0000000000..20fec51036 --- /dev/null +++ b/cg/services/deliver_files/file_formatter/upload_file_formatter.py @@ -0,0 +1,60 @@ +import logging +import os +from pathlib import Path + +from cg.services.deliver_files.file_fetcher.models import CaseFile, DeliveryFiles, SampleFile +from cg.services.deliver_files.file_formatter.abstract import DeliveryFileFormattingService +from cg.services.deliver_files.file_formatter.models import FormattedFile, FormattedFiles +from cg.services.deliver_files.file_formatter.utils.case_service import CaseFileFormatter +from cg.services.deliver_files.file_formatter.utils.mutant_sample_service import MutantFileFormatter +from cg.services.deliver_files.file_formatter.utils.sample_concatenation_service import ( + SampleFileConcatenationFormatter, +) +from cg.services.deliver_files.file_formatter.utils.sample_service import SampleFileFormatter + +LOG = logging.getLogger(__name__) + + +class UploadFileFormatter(DeliveryFileFormattingService): + """ + Format the files to be delivered in the generic format. + Expected structure: + base_path/ + base_path/ + """ + + def __init__( + self, + case_file_formatter: CaseFileFormatter, + sample_file_formatter: ( + SampleFileFormatter | SampleFileConcatenationFormatter | MutantFileFormatter + ), + ): + self.case_file_formatter = case_file_formatter + self.sample_file_formatter = sample_file_formatter + + def format_files(self, delivery_files: DeliveryFiles) -> FormattedFiles: + """Format the files to be delivered and return the formatted files in the generic format.""" + LOG.debug("[FORMAT SERVICE] Formatting files for delivery") + formatted_files: list[FormattedFile] = self._format_sample_and_case_files( + sample_files=delivery_files.sample_files, + case_files=delivery_files.case_files, + base_dir_path=delivery_files.sample_files[0].file_path.parent, + ) + return FormattedFiles(files=formatted_files) + + def _format_sample_and_case_files( + self, sample_files: list[SampleFile], case_files: list[CaseFile], base_dir_path: Path + ) -> list[FormattedFile]: + """Helper method to format both sample and case files.""" + formatted_files: list[FormattedFile] = self.sample_file_formatter.format_files( + moved_files=sample_files, + delivery_path=base_dir_path, + ) + if case_files: + formatted_case_files: list[FormattedFile] = self.case_file_formatter.format_files( + moved_files=case_files, + delivery_path=base_dir_path, + ) + formatted_files.extend(formatted_case_files) + return formatted_files diff --git a/cg/services/deliver_files/file_formatter/utils/case_service.py b/cg/services/deliver_files/file_formatter/utils/case_service.py index ccc4f656e6..106f8a413a 100644 --- a/cg/services/deliver_files/file_formatter/utils/case_service.py +++ b/cg/services/deliver_files/file_formatter/utils/case_service.py @@ -7,13 +7,9 @@ class CaseFileFormatter: - def format_files( - self, moved_files: list[CaseFile], ticket_dir_path: Path - ) -> list[FormattedFile]: + def format_files(self, moved_files: list[CaseFile], delivery_path: Path) -> list[FormattedFile]: """Format the case files to deliver and return the formatted files.""" - self._create_case_name_folder( - ticket_path=ticket_dir_path, case_name=moved_files[0].case_name - ) + self._create_case_name_folder(ticket_path=delivery_path, case_name=moved_files[0].case_name) return self._format_case_files(moved_files) def _format_case_files(self, case_files: list[CaseFile]) -> list[FormattedFile]: diff --git a/cg/services/deliver_files/file_formatter/utils/mutant_sample_service.py b/cg/services/deliver_files/file_formatter/utils/mutant_sample_service.py index a246212195..63f31c6744 100644 --- a/cg/services/deliver_files/file_formatter/utils/mutant_sample_service.py +++ b/cg/services/deliver_files/file_formatter/utils/mutant_sample_service.py @@ -21,10 +21,10 @@ def __init__( self.file_manager = file_manager def format_files( - self, moved_files: list[SampleFile], ticket_dir_path: Path + self, moved_files: list[SampleFile], delivery_path: Path ) -> list[FormattedFile]: formatted_files: list[FormattedFile] = self.file_formatter.format_files( - moved_files=moved_files, ticket_dir_path=ticket_dir_path + moved_files=moved_files, delivery_path=delivery_path ) appended_formatted_files: list[FormattedFile] = self._add_lims_metadata_to_file_name( formatted_files=formatted_files, sample_files=moved_files diff --git a/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py b/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py index c25bdf86d6..f1875a44ef 100644 --- a/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py +++ b/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py @@ -33,14 +33,12 @@ def __init__( self.concatenation_service = concatenation_service def format_files( - self, moved_files: list[SampleFile], ticket_dir_path: Path + self, moved_files: list[SampleFile], delivery_path: Path ) -> list[FormattedFile]: """Format the sample files to deliver, concatenate fastq files and return the formatted files.""" sample_names: set[str] = self.file_name_formatter.get_sample_names(sample_files=moved_files) for sample_name in sample_names: - self.file_manager.create_directories( - base_path=ticket_dir_path, directories={sample_name} - ) + self.file_manager.create_directories(base_path=delivery_path, directories={sample_name}) formatted_files: list[FormattedFile] = self.file_name_formatter.format_sample_file_names( sample_files=moved_files ) diff --git a/cg/services/deliver_files/file_formatter/utils/sample_service.py b/cg/services/deliver_files/file_formatter/utils/sample_service.py index 55961663cb..d06b1f69e2 100644 --- a/cg/services/deliver_files/file_formatter/utils/sample_service.py +++ b/cg/services/deliver_files/file_formatter/utils/sample_service.py @@ -79,14 +79,12 @@ def __init__( self.file_name_formatter = file_name_formatter def format_files( - self, moved_files: list[SampleFile], ticket_dir_path: Path + self, moved_files: list[SampleFile], delivery_path: Path ) -> list[FormattedFile]: """Format the sample files to deliver and return the formatted files.""" sample_names: set[str] = self.file_name_formatter.get_sample_names(sample_files=moved_files) for sample_name in sample_names: - self.file_manager.create_directories( - base_path=ticket_dir_path, directories={sample_name} - ) + self.file_manager.create_directories(base_path=delivery_path, directories={sample_name}) formatted_files: list[FormattedFile] = self.file_name_formatter.format_sample_file_names( sample_files=moved_files ) diff --git a/cg/services/deliver_files/file_mover/delivery_files_mover.py b/cg/services/deliver_files/file_mover/delivery_files_mover.py index c7e593c8ac..8d7c5533f9 100644 --- a/cg/services/deliver_files/file_mover/delivery_files_mover.py +++ b/cg/services/deliver_files/file_mover/delivery_files_mover.py @@ -3,13 +3,10 @@ from cg.constants.delivery import INBOX_NAME from cg.services.deliver_files.file_fetcher.models import ( - CaseFile, DeliveryFiles, DeliveryMetaData, - SampleFile, ) from cg.services.deliver_files.utils import FileMover -from cg.utils.files import link_or_overwrite_file LOG = logging.getLogger(__name__) diff --git a/tests/fixture_plugins/delivery_fixtures/delivery_files_models_fixtures.py b/tests/fixture_plugins/delivery_fixtures/delivery_files_models_fixtures.py index e169eaebb3..c535d711ad 100644 --- a/tests/fixture_plugins/delivery_fixtures/delivery_files_models_fixtures.py +++ b/tests/fixture_plugins/delivery_fixtures/delivery_files_models_fixtures.py @@ -234,6 +234,25 @@ def fastq_concatenation_sample_files(tmp_path: Path) -> list[SampleFile]: ] +@pytest.fixture +def fastq_concatenation_sample_files_flat(tmp_path: Path) -> list[SampleFile]: + fastq_paths: list[Path] = [ + Path(tmp_path, "S1_1_R1_1.fastq.gz"), + Path(tmp_path, "S1_2_R1_1.fastq.gz"), + Path(tmp_path, "S1_1_R2_1.fastq.gz"), + Path(tmp_path, "S1_2_R2_1.fastq.gz"), + ] + return [ + SampleFile( + sample_id="S1", + case_id="Case1", + sample_name="Sample1", + file_path=fastq_path, + ) + for fastq_path in fastq_paths + ] + + def swap_file_paths_with_inbox_paths( file_models: list[CaseFile | SampleFile], inbox_dir_path: Path ) -> list[CaseFile | SampleFile]: diff --git a/tests/fixture_plugins/delivery_fixtures/delivery_formatted_files_fixtures.py b/tests/fixture_plugins/delivery_fixtures/delivery_formatted_files_fixtures.py index 89b614b584..c7e4f08c3d 100644 --- a/tests/fixture_plugins/delivery_fixtures/delivery_formatted_files_fixtures.py +++ b/tests/fixture_plugins/delivery_fixtures/delivery_formatted_files_fixtures.py @@ -62,10 +62,10 @@ def expected_formatted_fastq_sample_files( @pytest.fixture def expected_concatenated_fastq_formatted_files( - fastq_concatenation_sample_files, + fastq_concatenation_sample_files_flat, ) -> list[FormattedFile]: formatted_files: list[FormattedFile] = [] - for sample_file in fastq_concatenation_sample_files: + for sample_file in fastq_concatenation_sample_files_flat: replaced_sample_file_name: str = sample_file.file_path.name.replace( sample_file.sample_id, sample_file.sample_name ) @@ -82,6 +82,26 @@ def expected_concatenated_fastq_formatted_files( return formatted_files +@pytest.fixture +def expected_concatenated_fastq_flat_formatted_files( + fastq_concatenation_sample_files, +) -> list[FormattedFile]: + formatted_files: list[FormattedFile] = [] + for sample_file in fastq_concatenation_sample_files: + replaced_sample_file_name: str = sample_file.file_path.name.replace( + sample_file.sample_id, sample_file.sample_name + ) + replaced_sample_file_name = replaced_sample_file_name.replace("1_R1_1", "1") + replaced_sample_file_name = replaced_sample_file_name.replace("2_R1_1", "1") + replaced_sample_file_name = replaced_sample_file_name.replace("1_R2_1", "2") + replaced_sample_file_name = replaced_sample_file_name.replace("2_R2_1", "2") + formatted_file_path = Path(sample_file.file_path.parent, replaced_sample_file_name) + formatted_files.append( + FormattedFile(original_path=sample_file.file_path, formatted_path=formatted_file_path) + ) + return formatted_files + + @pytest.fixture def empty_case_files() -> list: return [] diff --git a/tests/fixture_plugins/delivery_fixtures/delivery_services_fixtures.py b/tests/fixture_plugins/delivery_fixtures/delivery_services_fixtures.py index e1bc25fcb9..003dad9856 100644 --- a/tests/fixture_plugins/delivery_fixtures/delivery_services_fixtures.py +++ b/tests/fixture_plugins/delivery_fixtures/delivery_services_fixtures.py @@ -16,7 +16,7 @@ from cg.services.deliver_files.file_fetcher.raw_data_service import ( RawDataDeliveryFileFetcher, ) -from cg.services.deliver_files.file_formatter.service import ( +from cg.services.deliver_files.file_formatter.delivery_file_formatter import ( DeliveryFileFormatter, ) from cg.services.deliver_files.file_formatter.utils.case_service import ( diff --git a/tests/services/file_delivery/file_formatter/utils/test_formatter_utils.py b/tests/services/file_delivery/file_formatter/utils/test_formatter_utils.py index ce8aeb0f5a..2a29d56d02 100644 --- a/tests/services/file_delivery/file_formatter/utils/test_formatter_utils.py +++ b/tests/services/file_delivery/file_formatter/utils/test_formatter_utils.py @@ -24,6 +24,7 @@ SampleFileFormatter, FileManager, NestedSampleFileNameFormatter, + FlatSampleFileNameFormatter, ) @@ -51,6 +52,15 @@ concatenation_service=FastqConcatenationService(), ), ), + ( + "fastq_concatenation_sample_files_flat", + "expected_concatenated_fastq_flat_formatted_files", + SampleFileConcatenationFormatter( + file_manager=FileManager(), + file_formatter=FlatSampleFileNameFormatter(), + concatenation_service=FastqConcatenationService(), + ), + ), ], ) def test_file_formatter_utils( @@ -64,9 +74,9 @@ def test_file_formatter_utils( expected_formatted_files: list[FormattedFile] = request.getfixturevalue( expected_formatted_files ) - ticket_dir_path: Path = moved_files[0].file_path.parent + delivery_path: Path = moved_files[0].file_path.parent - os.makedirs(ticket_dir_path, exist_ok=True) + os.makedirs(delivery_path, exist_ok=True) for moved_file in moved_files: moved_file.file_path.touch() @@ -74,7 +84,7 @@ def test_file_formatter_utils( # WHEN formatting the case files formatted_files: list[FormattedFile] = file_formatter.format_files( moved_files=moved_files, - ticket_dir_path=ticket_dir_path, + delivery_path=delivery_path, ) # THEN the case files should be formatted @@ -114,7 +124,7 @@ def test_mutant_file_formatter( # WHEN formatting the files formatted_files: list[FormattedFile] = file_formatter.format_files( moved_files=mutant_moved_files, - ticket_dir_path=ticket_dir_path, + delivery_path=ticket_dir_path, ) # THEN the files should be formatted From 899e92264186b94689f323182fcab8860353d90e Mon Sep 17 00:00:00 2001 From: Christian Oertlin Date: Wed, 4 Dec 2024 08:15:19 +0100 Subject: [PATCH 36/80] fix --- .../file_formatter/utils/models.py | 7 ++ .../utils/sample_concatenation_service.py | 93 ++++++++++++++++--- .../file_formatter/utils/sample_service.py | 4 +- .../delivery_formatted_files_fixtures.py | 24 ++++- .../utils/test_formatter_utils.py | 41 +++++++- 5 files changed, 143 insertions(+), 26 deletions(-) create mode 100644 cg/services/deliver_files/file_formatter/utils/models.py diff --git a/cg/services/deliver_files/file_formatter/utils/models.py b/cg/services/deliver_files/file_formatter/utils/models.py new file mode 100644 index 0000000000..a26a392ae8 --- /dev/null +++ b/cg/services/deliver_files/file_formatter/utils/models.py @@ -0,0 +1,7 @@ +from pathlib import Path +from pydantic import BaseModel + + +class FastqFile(BaseModel): + sample_name: str + fastq_file_path: Path diff --git a/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py b/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py index f1875a44ef..e6a35c0072 100644 --- a/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py +++ b/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py @@ -2,6 +2,7 @@ from cg.apps.lims import LimsAPI from cg.constants.constants import ReadDirection, FileFormat, FileExtensions +from cg.services.deliver_files.file_formatter.utils.models import FastqFile from cg.services.fastq_concatenation_service.fastq_concatenation_service import ( FastqConcatenationService, @@ -14,6 +15,7 @@ FileManager, FlatSampleFileNameFormatter, ) +from cg.utils.files import get_all_files_in_directory_tree class SampleFileConcatenationFormatter: @@ -37,8 +39,7 @@ def format_files( ) -> list[FormattedFile]: """Format the sample files to deliver, concatenate fastq files and return the formatted files.""" sample_names: set[str] = self.file_name_formatter.get_sample_names(sample_files=moved_files) - for sample_name in sample_names: - self.file_manager.create_directories(base_path=delivery_path, directories={sample_name}) + self._create_sample_directories(delivery_path=delivery_path, sample_names=sample_names) formatted_files: list[FormattedFile] = self.file_name_formatter.format_sample_file_names( sample_files=moved_files ) @@ -46,7 +47,10 @@ def format_files( self.file_manager.rename_file( src=formatted_file.original_path, dst=formatted_file.formatted_path ) - forward_paths, reverse_path = self._concatenate_fastq_files(formatted_files=formatted_files) + forward_paths, reverse_path = self._concatenate_fastq_files( + delivery_path=delivery_path, + sample_names=sample_names, + ) self._replace_fastq_paths( reverse_paths=reverse_path, forward_paths=forward_paths, @@ -54,26 +58,41 @@ def format_files( ) return formatted_files + def _create_sample_directories(self, sample_names: set[str], delivery_path: Path) -> None: + """Create directories for each sample name only if the file name formatter is the NestedSampleFileFormatter. + args: + sample_names: set[str]: Set of sample names. + delivery_path: Path: Path to the delivery directory. + """ + if not isinstance(self.file_name_formatter, NestedSampleFileNameFormatter): + return + for sample_name in sample_names: + self.file_manager.create_directories(base_path=delivery_path, directories={sample_name}) + def _concatenate_fastq_files( - self, formatted_files: list[FormattedFile] + self, delivery_path: Path, sample_names: set[str] ) -> tuple[list[Path], list[Path]]: - unique_sample_dir_paths: set[Path] = self._get_unique_sample_paths( - sample_files=formatted_files + fastq_files: list[FastqFile] = self._get_unique_sample_fastq_paths( + sample_names=sample_names, delivery_path=delivery_path + ) + grouped_fastq_files: dict[str, list[FastqFile]] = self._group_fastq_files_per_sample( + sample_names=sample_names, fastq_files=fastq_files ) forward_paths: list[Path] = [] reverse_paths: list[Path] = [] - for fastq_directory in unique_sample_dir_paths: - sample_name: str = fastq_directory.name + # Generate one forward and one reverse path for each sample + for sample in grouped_fastq_files.keys(): + fastq_directory: Path = grouped_fastq_files[sample][0].fastq_file_path.parent forward_path: Path = generate_concatenated_fastq_delivery_path( fastq_directory=fastq_directory, - sample_name=sample_name, + sample_name=sample, direction=ReadDirection.FORWARD, ) forward_paths.append(forward_path) reverse_path: Path = generate_concatenated_fastq_delivery_path( fastq_directory=fastq_directory, - sample_name=sample_name, + sample_name=sample, direction=ReadDirection.REVERSE, ) reverse_paths.append(reverse_path) @@ -86,11 +105,36 @@ def _concatenate_fastq_files( return forward_paths, reverse_paths @staticmethod - def _get_unique_sample_paths(sample_files: list[FormattedFile]) -> set[Path]: - sample_paths: list[Path] = [] - for sample_file in sample_files: - sample_paths.append(sample_file.formatted_path.parent) - return set(sample_paths) + def _get_unique_sample_fastq_paths( + sample_names: set[str], delivery_path: Path + ) -> list[FastqFile]: + """Get a list of unique sample fastq file paths given a delivery path.""" + sample_paths: list[FastqFile] = [] + list_of_files: list[Path] = get_all_files_in_directory_tree(delivery_path) + for sample_name in sample_names: + for file in list_of_files: + if ( + sample_name in file.as_posix() + and f"{FileFormat.FASTQ}{FileExtensions.GZIP}" in file.as_posix() + ): + sample_paths.append( + FastqFile( + fastq_file_path=Path(delivery_path, file), sample_name=sample_name + ) + ) + return sample_paths + + def _group_fastq_files_per_sample( + self, sample_names: set[str], fastq_files: list[FastqFile] + ) -> dict[str, list[FastqFile]]: + """Group fastq files per sample.""" + sample_fastq_files: dict[str, list[FastqFile]] = { + sample_name: [] for sample_name in sample_names + } + for fastq_file in fastq_files: + sample_fastq_files[fastq_file.sample_name].append(fastq_file) + self._all_sample_fastq_file_share_same_directory(sample_fastq_files=sample_fastq_files) + return sample_fastq_files @staticmethod def _replace_fastq_formatted_file_path( @@ -126,3 +170,22 @@ def _replace_fastq_paths( direction=ReadDirection.REVERSE, new_path=reverse_path, ) + + @staticmethod + def _all_sample_fastq_file_share_same_directory( + sample_fastq_files: dict[str, list[FastqFile]] + ) -> None: + """ + Assert that all fastq files for a sample share the same directory. + args: + sample_fastq_files: dict[str, list[FastqFile]]: Dictionary of sample names and their fastq files. + """ + for sample_name in sample_fastq_files.keys(): + fastq_files: list[FastqFile] = sample_fastq_files[sample_name] + parent_dir: Path = fastq_files[0].fastq_file_path.parent + for fastq_file in fastq_files: + if fastq_file.fastq_file_path.parent != parent_dir: + raise ValueError( + f"Sample {sample_name} fastq files are not in the same directory. " + f"Cannot concatenate. It will would result in sporadic file paths." + ) diff --git a/cg/services/deliver_files/file_formatter/utils/sample_service.py b/cg/services/deliver_files/file_formatter/utils/sample_service.py index d06b1f69e2..1a7ac9fe50 100644 --- a/cg/services/deliver_files/file_formatter/utils/sample_service.py +++ b/cg/services/deliver_files/file_formatter/utils/sample_service.py @@ -7,7 +7,7 @@ class NestedSampleFileNameFormatter: """ - Class to format sample file names and paths. + Class to format sample file names and paths in a nested format used to deliver files to a customer inbox. """ @staticmethod @@ -38,7 +38,7 @@ def format_sample_file_names(sample_files: list[SampleFile]) -> list[FormattedFi class FlatSampleFileNameFormatter: """ - Class to format sample file names. + Class to format sample file names in place. """ @staticmethod diff --git a/tests/fixture_plugins/delivery_fixtures/delivery_formatted_files_fixtures.py b/tests/fixture_plugins/delivery_fixtures/delivery_formatted_files_fixtures.py index c7e4f08c3d..9d16cc965f 100644 --- a/tests/fixture_plugins/delivery_fixtures/delivery_formatted_files_fixtures.py +++ b/tests/fixture_plugins/delivery_fixtures/delivery_formatted_files_fixtures.py @@ -42,6 +42,22 @@ def expected_formatted_analysis_sample_files( return formatted_files +@pytest.fixture +def expected_flat_formatted_analysis_sample_files( + expected_moved_analysis_delivery_files: DeliveryFiles, +) -> list[FormattedFile]: + formatted_files: list[FormattedFile] = [] + for sample_file in expected_moved_analysis_delivery_files.sample_files: + replaced_sample_file_name: str = sample_file.file_path.name.replace( + sample_file.sample_id, sample_file.sample_name + ) + formatted_file_path = Path(sample_file.file_path.parent, replaced_sample_file_name) + formatted_files.append( + FormattedFile(original_path=sample_file.file_path, formatted_path=formatted_file_path) + ) + return formatted_files + + @pytest.fixture def expected_formatted_fastq_sample_files( expected_moved_fastq_delivery_files: DeliveryFiles, @@ -62,10 +78,10 @@ def expected_formatted_fastq_sample_files( @pytest.fixture def expected_concatenated_fastq_formatted_files( - fastq_concatenation_sample_files_flat, + fastq_concatenation_sample_files, ) -> list[FormattedFile]: formatted_files: list[FormattedFile] = [] - for sample_file in fastq_concatenation_sample_files_flat: + for sample_file in fastq_concatenation_sample_files: replaced_sample_file_name: str = sample_file.file_path.name.replace( sample_file.sample_id, sample_file.sample_name ) @@ -84,10 +100,10 @@ def expected_concatenated_fastq_formatted_files( @pytest.fixture def expected_concatenated_fastq_flat_formatted_files( - fastq_concatenation_sample_files, + fastq_concatenation_sample_files_flat, ) -> list[FormattedFile]: formatted_files: list[FormattedFile] = [] - for sample_file in fastq_concatenation_sample_files: + for sample_file in fastq_concatenation_sample_files_flat: replaced_sample_file_name: str = sample_file.file_path.name.replace( sample_file.sample_id, sample_file.sample_name ) diff --git a/tests/services/file_delivery/file_formatter/utils/test_formatter_utils.py b/tests/services/file_delivery/file_formatter/utils/test_formatter_utils.py index 2a29d56d02..bcbced4a6f 100644 --- a/tests/services/file_delivery/file_formatter/utils/test_formatter_utils.py +++ b/tests/services/file_delivery/file_formatter/utils/test_formatter_utils.py @@ -1,10 +1,7 @@ import os -from unittest import mock from unittest.mock import Mock import pytest from pathlib import Path - -from cg.apps.lims import LimsAPI from cg.services.deliver_files.file_formatter.utils.mutant_sample_service import MutantFileFormatter from cg.services.fastq_concatenation_service.fastq_concatenation_service import ( FastqConcatenationService, @@ -109,8 +106,6 @@ def test_mutant_file_formatter( lims_mock = Mock() lims_mock.get_sample_region_and_lab_code.return_value = lims_naming_matadata - - # Initialize file_formatter file_formatter = MutantFileFormatter( file_manager=FileManager(), file_formatter=SampleFileConcatenationFormatter( @@ -132,3 +127,39 @@ def test_mutant_file_formatter( for file in formatted_files: assert file.formatted_path.exists() assert not file.original_path.exists() + + +@pytest.mark.parametrize( + "sample_files,expected_formatted_files,sample_file_formatter", + [ + ( + "expected_moved_analysis_sample_delivery_files", + "expected_formatted_analysis_sample_files", + NestedSampleFileNameFormatter(), + ), + ( + "expected_moved_analysis_sample_delivery_files", + "expected_flat_formatted_analysis_sample_files", + FlatSampleFileNameFormatter(), + ), + ], +) +def test_sample_file_name_formatters( + sample_files: list[SampleFile], + expected_formatted_files: list[FormattedFile], + sample_file_formatter: NestedSampleFileNameFormatter | FlatSampleFileNameFormatter, + request, +): + # GIVEN existing sample files and a sample file formatter + sample_files: list[SampleFile] = request.getfixturevalue(sample_files) + expected_formatted_files: list[FormattedFile] = request.getfixturevalue( + expected_formatted_files + ) + + # WHEN formatting the sample files + formatted_files: list[FormattedFile] = sample_file_formatter.format_sample_file_names( + sample_files=sample_files + ) + + # THEN the sample files should be formatted + assert formatted_files == expected_formatted_files From fbf7284f181523ec22fecf8b6da95e3e1f486837 Mon Sep 17 00:00:00 2001 From: Christian Oertlin Date: Wed, 4 Dec 2024 12:13:03 +0100 Subject: [PATCH 37/80] revert some fixture changes --- .../deliver_files_service.py | 2 + tests/conftest.py | 1 - .../delivery_fixtures/context_fixtures.py | 5 ++- .../delivery_files_models_fixtures.py | 21 ++++++--- .../delivery_file_service/test_service.py | 43 +++++++++++++++++++ tests/store/crud/conftest.py | 1 - tests/store_helpers.py | 1 - 7 files changed, 63 insertions(+), 11 deletions(-) diff --git a/cg/services/deliver_files/deliver_files_service/deliver_files_service.py b/cg/services/deliver_files/deliver_files_service/deliver_files_service.py index beec52f463..2c3dfdc22e 100644 --- a/cg/services/deliver_files/deliver_files_service/deliver_files_service.py +++ b/cg/services/deliver_files/deliver_files_service/deliver_files_service.py @@ -66,6 +66,8 @@ def deliver_files_for_case( delivery_files=delivery_files, delivery_base_path=delivery_base_path ) formatted_files: FormattedFiles = self.file_formatter.format_files(moved_files) + for formatted_file in formatted_files.files: + assert formatted_file.formatted_path.exists() folders_to_deliver: set[Path] = set( [formatted_file.formatted_path.parent for formatted_file in formatted_files.files] ) diff --git a/tests/conftest.py b/tests/conftest.py index 5350c0438f..25f29c807d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4049,7 +4049,6 @@ def store_with_case_and_sample_with_reads( customer_id=case.customer_id, ticket_id=case.latest_ticket, order_date=case.ordered_at, - workflow=case.data_analysis, ) case.orders.append(order) for sample_internal_id in [downsample_sample_internal_id_1, downsample_sample_internal_id_2]: diff --git a/tests/fixture_plugins/delivery_fixtures/context_fixtures.py b/tests/fixture_plugins/delivery_fixtures/context_fixtures.py index 3c217896c0..7d35372a20 100644 --- a/tests/fixture_plugins/delivery_fixtures/context_fixtures.py +++ b/tests/fixture_plugins/delivery_fixtures/context_fixtures.py @@ -7,7 +7,7 @@ from cg.apps.housekeeper.hk import HousekeeperAPI from cg.constants import DataDelivery, Workflow from cg.models.cg_config import CGConfig -from cg.store.models import Case, Sample +from cg.store.models import Case, Sample, Order from cg.store.store import Store from tests.store_helpers import StoreHelpers @@ -112,7 +112,8 @@ def delivery_store_microsalt( data_analysis=Workflow.MICROSALT, data_delivery=DataDelivery.FASTQ_QC, ) - + order: Order = helpers.add_order(store=status_db, customer_id=case.customer.id, ticket_id=1) + case.orders.append(order) # MicroSALT samples sample: Sample = helpers.add_sample( store=status_db, diff --git a/tests/fixture_plugins/delivery_fixtures/delivery_files_models_fixtures.py b/tests/fixture_plugins/delivery_fixtures/delivery_files_models_fixtures.py index c535d711ad..ef793521b3 100644 --- a/tests/fixture_plugins/delivery_fixtures/delivery_files_models_fixtures.py +++ b/tests/fixture_plugins/delivery_fixtures/delivery_files_models_fixtures.py @@ -215,13 +215,22 @@ def expected_moved_analysis_case_delivery_files( @pytest.fixture -def fastq_concatenation_sample_files(tmp_path: Path) -> list[SampleFile]: - some_ticket: str = "some_ticket" +def fastq_concatenation_sample_files( + tmp_path: Path, expected_fastq_delivery_files: DeliveryFiles +) -> list[SampleFile]: + """ + Return a list of sample files that are to be concatenated. + """ + inbox = Path( + expected_fastq_delivery_files.delivery_data.customer_internal_id, + INBOX_NAME, + expected_fastq_delivery_files.delivery_data.ticket_id, + ) fastq_paths: list[Path] = [ - Path(tmp_path, some_ticket, "S1_1_R1_1.fastq.gz"), - Path(tmp_path, some_ticket, "S1_2_R1_1.fastq.gz"), - Path(tmp_path, some_ticket, "S1_1_R2_1.fastq.gz"), - Path(tmp_path, some_ticket, "S1_2_R2_1.fastq.gz"), + Path(tmp_path, inbox, "S1_1_R1_1.fastq.gz"), + Path(tmp_path, inbox, "S1_2_R1_1.fastq.gz"), + Path(tmp_path, inbox, "S1_1_R2_1.fastq.gz"), + Path(tmp_path, inbox, "S1_2_R2_1.fastq.gz"), ] return [ SampleFile( diff --git a/tests/services/file_delivery/delivery_file_service/test_service.py b/tests/services/file_delivery/delivery_file_service/test_service.py index 256869e81e..72d2a9e7ad 100644 --- a/tests/services/file_delivery/delivery_file_service/test_service.py +++ b/tests/services/file_delivery/delivery_file_service/test_service.py @@ -1,11 +1,20 @@ +from pathlib import Path from unittest import mock from unittest.mock import Mock +from cg.apps.housekeeper.hk import HousekeeperAPI +from cg.constants import Workflow from cg.services.deliver_files.deliver_files_service.deliver_files_service import ( DeliverFilesService, ) +from cg.services.deliver_files.deliver_files_service.deliver_files_service_factory import ( + DeliveryServiceFactory, +) from cg.services.deliver_files.file_fetcher.exc import NoDeliveryFilesError from cg.services.deliver_files.file_fetcher.models import DeliveryFiles +from cg.services.deliver_files.file_formatter.models import FormattedFile +from cg.store.models import Case +from cg.store.store import Store def test_file_delivery_service_no_files(empty_delivery_files: DeliveryFiles): @@ -36,3 +45,37 @@ def test_file_delivery_service_no_files(empty_delivery_files: DeliveryFiles): assert not file_delivery_service.rsync_service.rsync_files.called assert not file_delivery_service.tb_service.add_rsync_job.called assert not file_delivery_service.analysis_service.update_status.called + + +def test_deliver_files_for_case( + case_id: str, + delivery_store_microsalt: Store, + tmp_path: Path, + expected_fastq_delivery_files: DeliveryFiles, + expected_concatenated_fastq_formatted_files: list[FormattedFile], +): + # GIVEN a case with samples that are present in Housekeeper and the Store + case: Case = delivery_store_microsalt.get_case_by_internal_id(case_id) + case.data_analysis = Workflow.RAW_DATA + factory = DeliveryServiceFactory( + lims_api=Mock(), + store=delivery_store_microsalt, + hk_api=Mock(), + rsync_service=Mock(), + tb_service=Mock(), + analysis_service=Mock(), + ) + delivery_service = factory.build_delivery_service(case=case) + + # GIVEN that the correct files are fetched + with mock.patch.object( + delivery_service.file_manager, + "get_files_to_deliver", + return_value=expected_fastq_delivery_files, + ): + # WHEN delivering files for the case + delivery_service.deliver_files_for_case(case=case, delivery_base_path=tmp_path) + + # THEN assert that the files are moved and formatted + for formatted_file in expected_concatenated_fastq_formatted_files: + assert formatted_file.formatted_path.exists() diff --git a/tests/store/crud/conftest.py b/tests/store/crud/conftest.py index 7cc7c61389..d884fc947d 100644 --- a/tests/store/crud/conftest.py +++ b/tests/store/crud/conftest.py @@ -501,7 +501,6 @@ def order_balsamic(helpers: StoreHelpers, store: Store) -> Order: customer_id=2, ticket_id=3, order_date=datetime.now(), - workflow=Workflow.BALSAMIC, ) order.cases.append(case) return order diff --git a/tests/store_helpers.py b/tests/store_helpers.py index 16fc12df91..19ac29ddff 100644 --- a/tests/store_helpers.py +++ b/tests/store_helpers.py @@ -515,7 +515,6 @@ def add_order( customer_id: int, ticket_id: int, order_date: datetime = datetime(year=2023, month=12, day=24), - workflow: Workflow = Workflow.MIP_DNA, ) -> Order: order = Order( customer_id=customer_id, From ca5b52e9fbd264dce47fecda845580815ee18548 Mon Sep 17 00:00:00 2001 From: Christian Oertlin Date: Wed, 4 Dec 2024 12:30:16 +0100 Subject: [PATCH 38/80] fix --- .../delivery_file_service/test_service.py | 44 ------------------- 1 file changed, 44 deletions(-) diff --git a/tests/services/file_delivery/delivery_file_service/test_service.py b/tests/services/file_delivery/delivery_file_service/test_service.py index 72d2a9e7ad..0dcd3da047 100644 --- a/tests/services/file_delivery/delivery_file_service/test_service.py +++ b/tests/services/file_delivery/delivery_file_service/test_service.py @@ -1,20 +1,10 @@ -from pathlib import Path from unittest import mock from unittest.mock import Mock - -from cg.apps.housekeeper.hk import HousekeeperAPI -from cg.constants import Workflow from cg.services.deliver_files.deliver_files_service.deliver_files_service import ( DeliverFilesService, ) -from cg.services.deliver_files.deliver_files_service.deliver_files_service_factory import ( - DeliveryServiceFactory, -) from cg.services.deliver_files.file_fetcher.exc import NoDeliveryFilesError from cg.services.deliver_files.file_fetcher.models import DeliveryFiles -from cg.services.deliver_files.file_formatter.models import FormattedFile -from cg.store.models import Case -from cg.store.store import Store def test_file_delivery_service_no_files(empty_delivery_files: DeliveryFiles): @@ -45,37 +35,3 @@ def test_file_delivery_service_no_files(empty_delivery_files: DeliveryFiles): assert not file_delivery_service.rsync_service.rsync_files.called assert not file_delivery_service.tb_service.add_rsync_job.called assert not file_delivery_service.analysis_service.update_status.called - - -def test_deliver_files_for_case( - case_id: str, - delivery_store_microsalt: Store, - tmp_path: Path, - expected_fastq_delivery_files: DeliveryFiles, - expected_concatenated_fastq_formatted_files: list[FormattedFile], -): - # GIVEN a case with samples that are present in Housekeeper and the Store - case: Case = delivery_store_microsalt.get_case_by_internal_id(case_id) - case.data_analysis = Workflow.RAW_DATA - factory = DeliveryServiceFactory( - lims_api=Mock(), - store=delivery_store_microsalt, - hk_api=Mock(), - rsync_service=Mock(), - tb_service=Mock(), - analysis_service=Mock(), - ) - delivery_service = factory.build_delivery_service(case=case) - - # GIVEN that the correct files are fetched - with mock.patch.object( - delivery_service.file_manager, - "get_files_to_deliver", - return_value=expected_fastq_delivery_files, - ): - # WHEN delivering files for the case - delivery_service.deliver_files_for_case(case=case, delivery_base_path=tmp_path) - - # THEN assert that the files are moved and formatted - for formatted_file in expected_concatenated_fastq_formatted_files: - assert formatted_file.formatted_path.exists() From 89731ad0098e45e58572edebcb2f8c0201c0bdd4 Mon Sep 17 00:00:00 2001 From: ChristianOertlin Date: Wed, 4 Dec 2024 12:30:54 +0100 Subject: [PATCH 39/80] Update cg/constants/constants.py --- cg/constants/constants.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cg/constants/constants.py b/cg/constants/constants.py index 926992ced3..175b9c152a 100644 --- a/cg/constants/constants.py +++ b/cg/constants/constants.py @@ -100,7 +100,7 @@ class SexOptions(StrEnum): UNKNOWN: str = "unknown" -SARS_COV_REGEX = "^[0-9]{2}CS[0-9]{6}TEST$" +SARS_COV_REGEX = "^[0-9]{2}CS[0-9]{6}$" STATUS_OPTIONS = ("affected", "unaffected", "unknown") From f03d56b4a8089de69770a0b14336cf2dd23591aa Mon Sep 17 00:00:00 2001 From: Christian Oertlin Date: Wed, 4 Dec 2024 16:27:59 +0100 Subject: [PATCH 40/80] make concatenation sample specific --- .../utils/sample_concatenation_service.py | 1 + .../fastq_concatenation_service.py | 27 +++++-- .../fastq_concatenation_service/utils.py | 75 ++++++++----------- .../delivery_files_models_fixtures.py | 70 ++++++++++------- tests/services/fastq_file_service/conftest.py | 16 ++-- .../test_fastq_file_service.py | 16 +++- 6 files changed, 116 insertions(+), 89 deletions(-) diff --git a/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py b/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py index e6a35c0072..231a1f8889 100644 --- a/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py +++ b/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py @@ -97,6 +97,7 @@ def _concatenate_fastq_files( ) reverse_paths.append(reverse_path) self.concatenation_service.concatenate( + sample_id=sample, fastq_directory=fastq_directory, forward_output_path=forward_path, reverse_output_path=reverse_path, diff --git a/cg/services/fastq_concatenation_service/fastq_concatenation_service.py b/cg/services/fastq_concatenation_service/fastq_concatenation_service.py index c36673cee6..40f653114a 100644 --- a/cg/services/fastq_concatenation_service/fastq_concatenation_service.py +++ b/cg/services/fastq_concatenation_service/fastq_concatenation_service.py @@ -1,28 +1,45 @@ import logging from pathlib import Path +from cg.constants.constants import ReadDirection from cg.services.fastq_concatenation_service.utils import ( - concatenate_forward_reads, - concatenate_reverse_reads, remove_raw_fastqs, + concatenate_fastq_reads_for_direction, ) LOG = logging.getLogger(__name__) class FastqConcatenationService: + """Fastq file concatenation service.""" + + @staticmethod def concatenate( - self, + sample_id: str, fastq_directory: Path, forward_output_path: Path, reverse_output_path: Path, remove_raw: bool = False, ): - temp_forward: Path | None = concatenate_forward_reads(fastq_directory) - temp_reverse: Path | None = concatenate_reverse_reads(fastq_directory) + """Concatenate fastq files for a given sample in a directory and write the concatenated files to the output path. + + Args: + sample_id: The identifier to identify the samples by it should be a unique identifier in the file name. + fastq_directory: The directory containing the fastq files. + forward_output_path: The path where the concatenated forward reads will be written. + reverse_output_path: The path where the concatenated reverse reads will be written. + remove_raw: If True, remove the raw fastq files after concatenation. + """ + temp_forward: Path | None = concatenate_fastq_reads_for_direction( + directory=fastq_directory, sample_id=sample_id, direction=ReadDirection.FORWARD + ) + temp_reverse: Path | None = concatenate_fastq_reads_for_direction( + directory=fastq_directory, sample_id=sample_id, direction=ReadDirection.REVERSE + ) if remove_raw: remove_raw_fastqs( + sample_id=sample_id, fastq_directory=fastq_directory, forward_file=temp_forward, reverse_file=temp_reverse, diff --git a/cg/services/fastq_concatenation_service/utils.py b/cg/services/fastq_concatenation_service/utils.py index b3196cd7cf..bfeb12c39e 100644 --- a/cg/services/fastq_concatenation_service/utils.py +++ b/cg/services/fastq_concatenation_service/utils.py @@ -8,8 +8,12 @@ from cg.constants import FileExtensions -def concatenate_forward_reads(directory: Path) -> Path | None: - fastqs: list[Path] = get_forward_read_fastqs(directory) +def concatenate_fastq_reads_for_direction( + directory: Path, sample_id: str, direction: ReadDirection +) -> Path | None: + fastqs: list[Path] = get_fastqs_by_direction( + fastq_directory=directory, direction=direction, sample_id=sample_id + ) if not fastqs: return output_file: Path = get_new_unique_file(directory) @@ -18,31 +22,19 @@ def concatenate_forward_reads(directory: Path) -> Path | None: return output_file -def concatenate_reverse_reads(directory: Path) -> Path | None: - fastqs: list[Path] = get_reverse_read_fastqs(directory) - if not fastqs: - return - file: Path = get_new_unique_file(directory) - concatenate(input_files=fastqs, output_file=file) - validate_concatenation(input_files=fastqs, output_file=file) - return file - - def get_new_unique_file(directory: Path) -> Path: unique_id = uuid.uuid4() return Path(directory, f"{unique_id}{FileExtensions.FASTQ}{FileExtensions.GZIP}") -def get_forward_read_fastqs(fastq_directory: Path) -> list[Path]: - return get_fastqs_by_direction(fastq_directory=fastq_directory, direction=ReadDirection.FORWARD) - - -def get_reverse_read_fastqs(fastq_directory: Path) -> list[Path]: - return get_fastqs_by_direction(fastq_directory=fastq_directory, direction=ReadDirection.REVERSE) - - -def get_fastqs_by_direction(fastq_directory: Path, direction: int) -> list[Path]: - pattern = f".+_R{direction}_[0-9]+{FileExtensions.FASTQ}{FileExtensions.GZIP}" +def get_fastqs_by_direction(fastq_directory: Path, direction: int, sample_id: str) -> list[Path]: + """Get fastq files by direction and sample id in a given directory. + args: + fastq_directory: Path: The directory containing the fastq files. + direction: int: The direction of the reads. + sample_id: str: The identifier to identify the samples by it should be a unique identifier in the file name. + """ + pattern = f".*{sample_id}.*_R{direction}_[0-9]+{FileExtensions.FASTQ}{FileExtensions.GZIP}" fastqs: list[Path] = [] for file in fastq_directory.iterdir(): if re.match(pattern, file.name): @@ -72,17 +64,30 @@ def sort_files_by_name(files: list[Path]) -> list[Path]: return sorted(files, key=lambda file: file.name) -def file_can_be_removed(file: Path, forward_file: Path, reverse_file: Path) -> bool: +def file_can_be_removed(file: Path, forward_file: Path, reverse_file: Path, sample_id: str) -> bool: + """ + Check if a file can be removed. + args: + file: Path: The file to check. + forward_file: Path: The forward file. + reverse_file: Path: The reverse file. + sample_id: str: The identifier to identify the samples by it should be a unique identifier in the file name. + """ return ( f"{FileFormat.FASTQ}{FileExtensions.GZIP}" in file.name + and sample_id in file.name and file != forward_file and file != reverse_file ) -def remove_raw_fastqs(fastq_directory: Path, forward_file: Path, reverse_file: Path) -> None: +def remove_raw_fastqs( + fastq_directory: Path, forward_file: Path, reverse_file: Path, sample_id: str +) -> None: for file in fastq_directory.iterdir(): - if file_can_be_removed(file=file, forward_file=forward_file, reverse_file=reverse_file): + if file_can_be_removed( + file=file, forward_file=forward_file, reverse_file=reverse_file, sample_id=sample_id + ): file.unlink() @@ -92,23 +97,3 @@ def generate_concatenated_fastq_delivery_path( return Path( fastq_directory, f"{sample_name}_{direction}{FileExtensions.FASTQ}{FileExtensions.GZIP}" ) - - -def generate_forward_concatenated_fastq_delivery_path( - fastq_directory: Path, sample_name: str -) -> Path: - return generate_concatenated_fastq_delivery_path( - fastq_directory=fastq_directory, - sample_name=sample_name, - direction=ReadDirection.FORWARD, - ) - - -def generate_reverse_concatenated_fastq_delivery_path( - fastq_directory: Path, sample_name: str -) -> Path: - return generate_concatenated_fastq_delivery_path( - fastq_directory=fastq_directory, - sample_name=sample_name, - direction=ReadDirection.REVERSE, - ) diff --git a/tests/fixture_plugins/delivery_fixtures/delivery_files_models_fixtures.py b/tests/fixture_plugins/delivery_fixtures/delivery_files_models_fixtures.py index ef793521b3..cd2067ffff 100644 --- a/tests/fixture_plugins/delivery_fixtures/delivery_files_models_fixtures.py +++ b/tests/fixture_plugins/delivery_fixtures/delivery_files_models_fixtures.py @@ -226,40 +226,54 @@ def fastq_concatenation_sample_files( INBOX_NAME, expected_fastq_delivery_files.delivery_data.ticket_id, ) - fastq_paths: list[Path] = [ - Path(tmp_path, inbox, "S1_1_R1_1.fastq.gz"), - Path(tmp_path, inbox, "S1_2_R1_1.fastq.gz"), - Path(tmp_path, inbox, "S1_1_R2_1.fastq.gz"), - Path(tmp_path, inbox, "S1_2_R2_1.fastq.gz"), - ] - return [ - SampleFile( - sample_id="S1", - case_id="Case1", - sample_name="Sample1", - file_path=fastq_path, + sample_data = [("Sample_ID1", "Sample_Name1"), ("Sample_ID2", "Sample_Name2")] + sample_files = [] + for sample_id, sample_name in sample_data: + fastq_paths: list[Path] = [ + Path(tmp_path, inbox, f"{sample_id}_1_R1_1.fastq.gz"), + Path(tmp_path, inbox, f"{sample_id}_2_R1_1.fastq.gz"), + Path(tmp_path, inbox, f"{sample_id}_1_R2_1.fastq.gz"), + Path(tmp_path, inbox, f"{sample_id}_2_R2_1.fastq.gz"), + ] + + sample_files.extend( + [ + SampleFile( + sample_id=sample_id, + case_id="Case1", + sample_name=sample_name, + file_path=fastq_path, + ) + for fastq_path in fastq_paths + ] ) - for fastq_path in fastq_paths - ] + return sample_files @pytest.fixture def fastq_concatenation_sample_files_flat(tmp_path: Path) -> list[SampleFile]: - fastq_paths: list[Path] = [ - Path(tmp_path, "S1_1_R1_1.fastq.gz"), - Path(tmp_path, "S1_2_R1_1.fastq.gz"), - Path(tmp_path, "S1_1_R2_1.fastq.gz"), - Path(tmp_path, "S1_2_R2_1.fastq.gz"), - ] - return [ - SampleFile( - sample_id="S1", - case_id="Case1", - sample_name="Sample1", - file_path=fastq_path, + sample_data = [("Sample_ID1", "Sample_Name1"), ("Sample_ID2", "Sample_Name2")] + sample_files = [] + for sample_id, sample_name in sample_data: + fastq_paths: list[Path] = [ + Path(tmp_path, f"{sample_id}_1_R1_1.fastq.gz"), + Path(tmp_path, f"{sample_id}_2_R1_1.fastq.gz"), + Path(tmp_path, f"{sample_id}_1_R2_1.fastq.gz"), + Path(tmp_path, f"{sample_id}_2_R2_1.fastq.gz"), + ] + + sample_files.extend( + [ + SampleFile( + sample_id=sample_id, + case_id="Case1", + sample_name=sample_name, + file_path=fastq_path, + ) + for fastq_path in fastq_paths + ] ) - for fastq_path in fastq_paths - ] + return sample_files def swap_file_paths_with_inbox_paths( diff --git a/tests/services/fastq_file_service/conftest.py b/tests/services/fastq_file_service/conftest.py index 4f5b20a92f..bdb15c08c3 100644 --- a/tests/services/fastq_file_service/conftest.py +++ b/tests/services/fastq_file_service/conftest.py @@ -11,29 +11,31 @@ def fastq_file_service(): return FastqConcatenationService() -def create_fastqs_directory(number_forward_reads, number_reverse_reads, tmp_path): +def create_fastqs_directory( + number_forward_reads: int, number_reverse_reads: int, tmp_path: Path, sample_id: str +): fastq_dir = Path(tmp_path, "fastqs") fastq_dir.mkdir() for i in range(number_forward_reads): - file = Path(fastq_dir, f"sample_R1_{i}.fastq.gz") + file = Path(fastq_dir, f"{sample_id}_R1_{i}.fastq.gz") file.write_text(f"forward read {i}") for i in range(number_reverse_reads): - file = Path(fastq_dir, f"sample_R2_{i}.fastq.gz") + file = Path(fastq_dir, f"{sample_id}_R2_{i}.fastq.gz") file.write_text(f"reverse read {i}") return fastq_dir @pytest.fixture -def fastqs_dir(tmp_path) -> Path: +def fastqs_dir(tmp_path: Path, sample_id: str) -> Path: return create_fastqs_directory( - number_forward_reads=3, number_reverse_reads=3, tmp_path=tmp_path + number_forward_reads=3, number_reverse_reads=3, tmp_path=tmp_path, sample_id=sample_id ) @pytest.fixture -def fastqs_forward(tmp_path) -> Path: +def fastqs_forward(tmp_path: Path, sample_id: str) -> Path: """Return a directory with only forward reads.""" return create_fastqs_directory( - number_forward_reads=3, number_reverse_reads=0, tmp_path=tmp_path + number_forward_reads=3, number_reverse_reads=0, tmp_path=tmp_path, sample_id=sample_id ) diff --git a/tests/services/fastq_file_service/test_fastq_file_service.py b/tests/services/fastq_file_service/test_fastq_file_service.py index a4dc9e25d1..22cdd65278 100644 --- a/tests/services/fastq_file_service/test_fastq_file_service.py +++ b/tests/services/fastq_file_service/test_fastq_file_service.py @@ -9,7 +9,9 @@ from cg.services.fastq_concatenation_service.utils import generate_concatenated_fastq_delivery_path -def test_empty_directory(fastq_file_service: FastqConcatenationService, tmp_path): +def test_empty_directory( + fastq_file_service: FastqConcatenationService, tmp_path: Path, sample_id: str +): # GIVEN an empty directory # GIVEN output files @@ -18,6 +20,7 @@ def test_empty_directory(fastq_file_service: FastqConcatenationService, tmp_path # WHEN concatenating the reads fastq_file_service.concatenate( + sample_id=sample_id, fastq_directory=tmp_path, forward_output_path=forward_output_path, reverse_output_path=reverse_output_path, @@ -28,7 +31,9 @@ def test_empty_directory(fastq_file_service: FastqConcatenationService, tmp_path assert not reverse_output_path.exists() -def test_concatenate(fastq_file_service: FastqConcatenationService, fastqs_dir: Path): +def test_concatenate( + fastq_file_service: FastqConcatenationService, fastqs_dir: Path, sample_id: str +): # GIVEN a directory with forward and reverse reads # GIVEN output files for the concatenated reads @@ -37,6 +42,7 @@ def test_concatenate(fastq_file_service: FastqConcatenationService, fastqs_dir: # WHEN concatenating the reads fastq_file_service.concatenate( + sample_id=sample_id, fastq_directory=fastqs_dir, forward_output_path=forward_output_path, reverse_output_path=reverse_output_path, @@ -57,7 +63,7 @@ def test_concatenate(fastq_file_service: FastqConcatenationService, fastqs_dir: def test_concatenate_when_output_exists( - fastq_file_service: FastqConcatenationService, fastqs_dir: Path + fastq_file_service: FastqConcatenationService, fastqs_dir: Path, sample_id: str ): # GIVEN a directory with forward and reverse reads existing_fastq_files = list(fastqs_dir.iterdir()) @@ -69,6 +75,7 @@ def test_concatenate_when_output_exists( # WHEN concatenating the reads fastq_file_service.concatenate( + sample_id=sample_id, fastq_directory=fastqs_dir, forward_output_path=forward_output_path, reverse_output_path=reverse_output_path, @@ -89,7 +96,7 @@ def test_concatenate_when_output_exists( def test_concatenate_missing_reverse( - fastq_file_service: FastqConcatenationService, fastqs_forward: Path, tmp_path + fastq_file_service: FastqConcatenationService, fastqs_forward: Path, tmp_path, sample_id: str ): # GIVEN a directory with forward reads only @@ -99,6 +106,7 @@ def test_concatenate_missing_reverse( # WHEN concatenating the reads fastq_file_service.concatenate( + sample_id=sample_id, fastq_directory=fastqs_forward, forward_output_path=forward_output_path, reverse_output_path=reverse_output_path, From fb3950a37c170581718d181e04d30431d47b6664 Mon Sep 17 00:00:00 2001 From: Christian Oertlin Date: Thu, 5 Dec 2024 11:03:53 +0100 Subject: [PATCH 41/80] add test for concatenation fastq multiple sample in same dir --- tests/services/fastq_file_service/conftest.py | 43 ++++++++++++++----- .../test_fastq_file_service.py | 42 ++++++++++++++++++ 2 files changed, 75 insertions(+), 10 deletions(-) diff --git a/tests/services/fastq_file_service/conftest.py b/tests/services/fastq_file_service/conftest.py index bdb15c08c3..21352cb248 100644 --- a/tests/services/fastq_file_service/conftest.py +++ b/tests/services/fastq_file_service/conftest.py @@ -11,31 +11,54 @@ def fastq_file_service(): return FastqConcatenationService() -def create_fastqs_directory( - number_forward_reads: int, number_reverse_reads: int, tmp_path: Path, sample_id: str -): +def create_fastqs_directory(tmp_path: Path): fastq_dir = Path(tmp_path, "fastqs") fastq_dir.mkdir() + return fastq_dir + + +def create_fastq_files( + fastq_dir: Path, number_forward_reads: int, number_reverse_reads: int, sample_id: str +): for i in range(number_forward_reads): file = Path(fastq_dir, f"{sample_id}_R1_{i}.fastq.gz") - file.write_text(f"forward read {i}") + file.write_text(f"{sample_id} forward read {i}") for i in range(number_reverse_reads): file = Path(fastq_dir, f"{sample_id}_R2_{i}.fastq.gz") - file.write_text(f"reverse read {i}") - return fastq_dir + file.write_text(f"{sample_id} reverse read {i}") @pytest.fixture def fastqs_dir(tmp_path: Path, sample_id: str) -> Path: - return create_fastqs_directory( - number_forward_reads=3, number_reverse_reads=3, tmp_path=tmp_path, sample_id=sample_id + fastq_dir: Path = create_fastqs_directory(tmp_path=tmp_path) + create_fastq_files( + fastq_dir=fastq_dir, number_forward_reads=3, number_reverse_reads=3, sample_id=sample_id ) + return fastq_dir @pytest.fixture def fastqs_forward(tmp_path: Path, sample_id: str) -> Path: """Return a directory with only forward reads.""" - return create_fastqs_directory( - number_forward_reads=3, number_reverse_reads=0, tmp_path=tmp_path, sample_id=sample_id + fastq_dir: Path = create_fastqs_directory(tmp_path=tmp_path) + create_fastq_files( + fastq_dir=fastq_dir, number_forward_reads=3, number_reverse_reads=0, sample_id=sample_id ) + return fastq_dir + + +@pytest.fixture +def fastqs_multiple_samples(tmp_path: Path, sample_id: str, another_sample_id: str) -> Path: + """Return a directory with fastq files for multiple samples.""" + fastq_dir: Path = create_fastqs_directory(tmp_path=tmp_path) + create_fastq_files( + fastq_dir=fastq_dir, number_forward_reads=3, number_reverse_reads=3, sample_id=sample_id + ) + create_fastq_files( + fastq_dir=fastq_dir, + number_forward_reads=3, + number_reverse_reads=3, + sample_id=another_sample_id, + ) + return fastq_dir diff --git a/tests/services/fastq_file_service/test_fastq_file_service.py b/tests/services/fastq_file_service/test_fastq_file_service.py index 22cdd65278..bd6b707175 100644 --- a/tests/services/fastq_file_service/test_fastq_file_service.py +++ b/tests/services/fastq_file_service/test_fastq_file_service.py @@ -119,6 +119,48 @@ def test_concatenate_missing_reverse( assert not reverse_output_path.exists() +def test_concatenate_fastqs_multiple_samples_in_dir( + fastqs_multiple_samples: Path, + fastq_file_service: FastqConcatenationService, + sample_id: str, + another_sample_id: str, + tmp_path: Path, +): + # GIVEN a fastq directory with fastq files for multiple samples that should be concatenated + samples: list[str] = [sample_id, another_sample_id] + + # GIVEN output files for the concatenated reads + for fastq_sample in samples: + forward_output_path = Path(tmp_path, f"{fastq_sample}_forward.fastq.gz") + reverse_output_path = Path(tmp_path, f"{fastq_sample}_reverse.fastq.gz") + + # WHEN concatenating the reads + fastq_file_service.concatenate( + sample_id=fastq_sample, + fastq_directory=fastqs_multiple_samples, + forward_output_path=forward_output_path, + reverse_output_path=reverse_output_path, + remove_raw=True, + ) + + not_current_sample: str = another_sample_id if fastq_sample == sample_id else sample_id + # THEN the output files should exist + assert forward_output_path.exists() + assert reverse_output_path.exists() + + # THEN the concatenated forward reads only contain forward reads + assert "forward" in forward_output_path.read_text() + assert "reverse" not in forward_output_path.read_text() + assert fastq_sample in forward_output_path.read_text() + assert not_current_sample not in forward_output_path.read_text() + + # THEN the concatenated reverse reads only contain reverse reads + assert "reverse" in reverse_output_path.read_text() + assert "forward" not in reverse_output_path.read_text() + assert fastq_sample in reverse_output_path.read_text() + assert not_current_sample not in reverse_output_path.read_text() + + @pytest.mark.parametrize( "fastq_directory, sample_name, direction, expected_output_path", [ From 3349fa393609c3add334be522cc41f9fe515b46b Mon Sep 17 00:00:00 2001 From: Christian Oertlin Date: Fri, 6 Dec 2024 10:32:34 +0100 Subject: [PATCH 42/80] eureka --- .../file_formatter/utils/models.py | 5 + .../utils/sample_concatenation_service.py | 126 +++++++++++------- .../delivery_files_models_fixtures.py | 2 +- .../utils/test_formatter_utils.py | 42 +++--- 4 files changed, 103 insertions(+), 72 deletions(-) diff --git a/cg/services/deliver_files/file_formatter/utils/models.py b/cg/services/deliver_files/file_formatter/utils/models.py index a26a392ae8..52c6db156a 100644 --- a/cg/services/deliver_files/file_formatter/utils/models.py +++ b/cg/services/deliver_files/file_formatter/utils/models.py @@ -1,7 +1,12 @@ from pathlib import Path from pydantic import BaseModel +from cg.constants.constants import ReadDirection + class FastqFile(BaseModel): + """A fastq file with a sample name, file path and read direction.""" + sample_name: str fastq_file_path: Path + read_direction: ReadDirection diff --git a/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py b/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py index 231a1f8889..de2f0da6a9 100644 --- a/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py +++ b/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py @@ -1,6 +1,4 @@ from pathlib import Path - -from cg.apps.lims import LimsAPI from cg.constants.constants import ReadDirection, FileFormat, FileExtensions from cg.services.deliver_files.file_formatter.utils.models import FastqFile @@ -43,21 +41,28 @@ def format_files( formatted_files: list[FormattedFile] = self.file_name_formatter.format_sample_file_names( sample_files=moved_files ) - for formatted_file in formatted_files: - self.file_manager.rename_file( - src=formatted_file.original_path, dst=formatted_file.formatted_path - ) - forward_paths, reverse_path = self._concatenate_fastq_files( + self._rename_original_files(formatted_files) + concatenation_map: dict[Path, Path] = self._concatenate_fastq_files( delivery_path=delivery_path, sample_names=sample_names, ) self._replace_fastq_paths( - reverse_paths=reverse_path, - forward_paths=forward_paths, + concatenation_maps=concatenation_map, formatted_files=formatted_files, ) return formatted_files + def _rename_original_files(self, formatted_files: list[FormattedFile]) -> None: + """ + Rename the formatted files. + args: + formatted_files: list[FormattedFile]: List of formatted files. + """ + for formatted_file in formatted_files: + self.file_manager.rename_file( + src=formatted_file.original_path, dst=formatted_file.formatted_path + ) + def _create_sample_directories(self, sample_names: set[str], delivery_path: Path) -> None: """Create directories for each sample name only if the file name formatter is the NestedSampleFileFormatter. args: @@ -71,31 +76,29 @@ def _create_sample_directories(self, sample_names: set[str], delivery_path: Path def _concatenate_fastq_files( self, delivery_path: Path, sample_names: set[str] - ) -> tuple[list[Path], list[Path]]: + ) -> dict[Path, Path]: + """Concatenate fastq files for each sample and return the forward and reverse concatenated paths.""" fastq_files: list[FastqFile] = self._get_unique_sample_fastq_paths( sample_names=sample_names, delivery_path=delivery_path ) grouped_fastq_files: dict[str, list[FastqFile]] = self._group_fastq_files_per_sample( sample_names=sample_names, fastq_files=fastq_files ) - forward_paths: list[Path] = [] - reverse_paths: list[Path] = [] - + concatenation_maps: dict[Path, Path] = {} # Generate one forward and one reverse path for each sample for sample in grouped_fastq_files.keys(): + # The parent is dependent on the nested or flat structure within the delivery path. fastq_directory: Path = grouped_fastq_files[sample][0].fastq_file_path.parent forward_path: Path = generate_concatenated_fastq_delivery_path( fastq_directory=fastq_directory, sample_name=sample, direction=ReadDirection.FORWARD, ) - forward_paths.append(forward_path) reverse_path: Path = generate_concatenated_fastq_delivery_path( fastq_directory=fastq_directory, sample_name=sample, direction=ReadDirection.REVERSE, ) - reverse_paths.append(reverse_path) self.concatenation_service.concatenate( sample_id=sample, fastq_directory=fastq_directory, @@ -103,11 +106,17 @@ def _concatenate_fastq_files( reverse_output_path=reverse_path, remove_raw=True, ) - return forward_paths, reverse_paths + concatenation_maps.update( + self._get_concatenation_map( + forward_path=forward_path, + reverse_path=reverse_path, + fastq_files=grouped_fastq_files[sample], + ) + ) + return concatenation_maps - @staticmethod def _get_unique_sample_fastq_paths( - sample_names: set[str], delivery_path: Path + self, sample_names: set[str], delivery_path: Path ) -> list[FastqFile]: """Get a list of unique sample fastq file paths given a delivery path.""" sample_paths: list[FastqFile] = [] @@ -120,15 +129,53 @@ def _get_unique_sample_fastq_paths( ): sample_paths.append( FastqFile( - fastq_file_path=Path(delivery_path, file), sample_name=sample_name + fastq_file_path=Path(delivery_path, file), + sample_name=sample_name, + read_direction=self._determine_read_direction(file), ) ) return sample_paths + @staticmethod + def _get_concatenation_map( + forward_path: Path, reverse_path: Path, fastq_files: list[FastqFile] + ) -> dict[Path, Path]: + """ + Get a list of ConcatenationMap objects for a sample. + NOTE: the fastq_files must be grouped by sample name. + args: + forward_path: Path: Path to the forward concatenated file. + reverse_path: Path: Path to the reverse concatenated file. + fastq_files: list[FastqFile]: List of fastq files for a single ample. + """ + concatenation_map: dict[Path, Path] = {} + for fastq_file in fastq_files: + concatenation_map[fastq_file.fastq_file_path] = ( + forward_path if fastq_file.read_direction == ReadDirection.FORWARD else reverse_path + ) + return concatenation_map + + @staticmethod + def _determine_read_direction(fastq_path: Path) -> ReadDirection: + """Determine the read direction of a fastq file. + Assumes that the fastq file path contains 'R1' or 'R2' to determine the read direction. + args: + fastq_path: Path: Path to the fastq file. + """ + if f"R{ReadDirection.FORWARD}" in fastq_path.as_posix(): + return ReadDirection.FORWARD + return ReadDirection.REVERSE + def _group_fastq_files_per_sample( self, sample_names: set[str], fastq_files: list[FastqFile] ) -> dict[str, list[FastqFile]]: - """Group fastq files per sample.""" + """Group fastq files per sample. + returns a dictionary with sample names as keys and a list of fastq files as values. + args: + sample_names: set[str]: Set of sample names. + fastq_files: list[FastqFile]: List of fastq files. + """ + sample_fastq_files: dict[str, list[FastqFile]] = { sample_name: [] for sample_name in sample_names } @@ -137,40 +184,18 @@ def _group_fastq_files_per_sample( self._all_sample_fastq_file_share_same_directory(sample_fastq_files=sample_fastq_files) return sample_fastq_files - @staticmethod - def _replace_fastq_formatted_file_path( - formatted_files: list[FormattedFile], - direction: ReadDirection, - new_path: Path, - ) -> None: - """Replace the formatted file path with the new path.""" - for formatted_file in formatted_files: - if ( - formatted_file.formatted_path.parent == new_path.parent - and f"{FileFormat.FASTQ}{FileExtensions.GZIP}" in formatted_file.formatted_path.name - and f"R{direction}" in formatted_file.formatted_path.name - ): - formatted_file.formatted_path = new_path - def _replace_fastq_paths( self, - forward_paths: list[Path], - reverse_paths: list[Path], + concatenation_maps: dict[Path, Path], formatted_files: list[FormattedFile], ) -> None: - """Replace the fastq file paths with the new concatenated fastq file paths.""" - for forward_path in forward_paths: - self._replace_fastq_formatted_file_path( - formatted_files=formatted_files, - direction=ReadDirection.FORWARD, - new_path=forward_path, - ) - for reverse_path in reverse_paths: - self._replace_fastq_formatted_file_path( - formatted_files=formatted_files, - direction=ReadDirection.REVERSE, - new_path=reverse_path, - ) + """Replace the fastq file paths with the new concatenated fastq file paths. + args: + concatenation_maps: list[ConcatenationMap]: List of ConcatenationMap objects. + formatted_files: list[FormattedFile]: List of formatted files. + """ + for formatted_file in formatted_files: + formatted_file.formatted_path = concatenation_maps[formatted_file.formatted_path] @staticmethod def _all_sample_fastq_file_share_same_directory( @@ -178,6 +203,7 @@ def _all_sample_fastq_file_share_same_directory( ) -> None: """ Assert that all fastq files for a sample share the same directory. + This is to ensure that the files are concatenated within the expected directory path. args: sample_fastq_files: dict[str, list[FastqFile]]: Dictionary of sample names and their fastq files. """ diff --git a/tests/fixture_plugins/delivery_fixtures/delivery_files_models_fixtures.py b/tests/fixture_plugins/delivery_fixtures/delivery_files_models_fixtures.py index cd2067ffff..580c8f0d5f 100644 --- a/tests/fixture_plugins/delivery_fixtures/delivery_files_models_fixtures.py +++ b/tests/fixture_plugins/delivery_fixtures/delivery_files_models_fixtures.py @@ -252,7 +252,7 @@ def fastq_concatenation_sample_files( @pytest.fixture def fastq_concatenation_sample_files_flat(tmp_path: Path) -> list[SampleFile]: - sample_data = [("Sample_ID1", "Sample_Name1"), ("Sample_ID2", "Sample_Name2")] + sample_data = [("Sample_ID2", "Sample_Name2"), ("Sample_ID1", "Sample_Name1")] sample_files = [] for sample_id, sample_name in sample_data: fastq_paths: list[Path] = [ diff --git a/tests/services/file_delivery/file_formatter/utils/test_formatter_utils.py b/tests/services/file_delivery/file_formatter/utils/test_formatter_utils.py index bcbced4a6f..ef7e6d8211 100644 --- a/tests/services/file_delivery/file_formatter/utils/test_formatter_utils.py +++ b/tests/services/file_delivery/file_formatter/utils/test_formatter_utils.py @@ -28,27 +28,27 @@ @pytest.mark.parametrize( "moved_files,expected_formatted_files,file_formatter", [ - ( - "expected_moved_analysis_case_delivery_files", - "expected_formatted_analysis_case_files", - CaseFileFormatter(), - ), - ( - "expected_moved_analysis_sample_delivery_files", - "expected_formatted_analysis_sample_files", - SampleFileFormatter( - file_manager=FileManager(), file_name_formatter=NestedSampleFileNameFormatter() - ), - ), - ( - "fastq_concatenation_sample_files", - "expected_concatenated_fastq_formatted_files", - SampleFileConcatenationFormatter( - file_manager=FileManager(), - file_formatter=NestedSampleFileNameFormatter(), - concatenation_service=FastqConcatenationService(), - ), - ), + # ( + # "expected_moved_analysis_case_delivery_files", + # "expected_formatted_analysis_case_files", + # CaseFileFormatter(), + # ), + # ( + # "expected_moved_analysis_sample_delivery_files", + # "expected_formatted_analysis_sample_files", + # SampleFileFormatter( + # file_manager=FileManager(), file_name_formatter=NestedSampleFileNameFormatter() + # ), + # ), + # ( + # "fastq_concatenation_sample_files", + # "expected_concatenated_fastq_formatted_files", + # SampleFileConcatenationFormatter( + # file_manager=FileManager(), + # file_formatter=NestedSampleFileNameFormatter(), + # concatenation_service=FastqConcatenationService(), + # ), + # ), ( "fastq_concatenation_sample_files_flat", "expected_concatenated_fastq_flat_formatted_files", From e53dadc38aeaa79876e8b62b5e2c7a558fc2acbb Mon Sep 17 00:00:00 2001 From: Christian Oertlin Date: Fri, 6 Dec 2024 13:56:26 +0100 Subject: [PATCH 43/80] docstrings --- .../utils/sample_concatenation_service.py | 29 +++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py b/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py index de2f0da6a9..0a2ad9c050 100644 --- a/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py +++ b/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py @@ -35,7 +35,12 @@ def __init__( def format_files( self, moved_files: list[SampleFile], delivery_path: Path ) -> list[FormattedFile]: - """Format the sample files to deliver, concatenate fastq files and return the formatted files.""" + """ + Format the sample files to deliver, concatenate fastq files and return the formatted files. + args: + moved_files: list[SampleFile]: List of sample files to deliver. + delivery_path: Path: Path to the delivery directory. + """ sample_names: set[str] = self.file_name_formatter.get_sample_names(sample_files=moved_files) self._create_sample_directories(delivery_path=delivery_path, sample_names=sample_names) formatted_files: list[FormattedFile] = self.file_name_formatter.format_sample_file_names( @@ -77,7 +82,13 @@ def _create_sample_directories(self, sample_names: set[str], delivery_path: Path def _concatenate_fastq_files( self, delivery_path: Path, sample_names: set[str] ) -> dict[Path, Path]: - """Concatenate fastq files for each sample and return the forward and reverse concatenated paths.""" + """Concatenate fastq files for each sample and return the forward and reverse concatenated paths. + args: + delivery_path: Path: Path to the delivery directory. + sample_names: set[str]: Set of sample names. + returns: + dict[Path, Path]: Dictionary with the original fastq file path as key and the concatenated path as value. + """ fastq_files: list[FastqFile] = self._get_unique_sample_fastq_paths( sample_names=sample_names, delivery_path=delivery_path ) @@ -85,7 +96,6 @@ def _concatenate_fastq_files( sample_names=sample_names, fastq_files=fastq_files ) concatenation_maps: dict[Path, Path] = {} - # Generate one forward and one reverse path for each sample for sample in grouped_fastq_files.keys(): # The parent is dependent on the nested or flat structure within the delivery path. fastq_directory: Path = grouped_fastq_files[sample][0].fastq_file_path.parent @@ -118,7 +128,14 @@ def _concatenate_fastq_files( def _get_unique_sample_fastq_paths( self, sample_names: set[str], delivery_path: Path ) -> list[FastqFile]: - """Get a list of unique sample fastq file paths given a delivery path.""" + """ + Get a list of unique sample fastq file paths given a delivery path. + args: + sample_names: set[str]: Set of sample names. + delivery_path: Path: Path to the delivery directory + returns: + list[FastqFile]: List of FastqFile objects. + """ sample_paths: list[FastqFile] = [] list_of_files: list[Path] = get_all_files_in_directory_tree(delivery_path) for sample_name in sample_names: @@ -189,7 +206,9 @@ def _replace_fastq_paths( concatenation_maps: dict[Path, Path], formatted_files: list[FormattedFile], ) -> None: - """Replace the fastq file paths with the new concatenated fastq file paths. + """ + Replace the fastq file paths with the new concatenated fastq file paths. + Uses the concatenation map with the formatted file path as key and the concatenated path as value. args: concatenation_maps: list[ConcatenationMap]: List of ConcatenationMap objects. formatted_files: list[FormattedFile]: List of formatted files. From 840361729ac3a79c717801e33c20b86b2c945d0e Mon Sep 17 00:00:00 2001 From: Christian Oertlin Date: Fri, 6 Dec 2024 14:05:37 +0100 Subject: [PATCH 44/80] add debug --- .../fastq_concatenation_service/fastq_concatenation_service.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cg/services/fastq_concatenation_service/fastq_concatenation_service.py b/cg/services/fastq_concatenation_service/fastq_concatenation_service.py index 40f653114a..4aaec3cf02 100644 --- a/cg/services/fastq_concatenation_service/fastq_concatenation_service.py +++ b/cg/services/fastq_concatenation_service/fastq_concatenation_service.py @@ -30,6 +30,9 @@ def concatenate( reverse_output_path: The path where the concatenated reverse reads will be written. remove_raw: If True, remove the raw fastq files after concatenation. """ + LOG.debug( + f"[Concatenation Service] Concatenating fastq files for {sample_id} in {fastq_directory}" + ) temp_forward: Path | None = concatenate_fastq_reads_for_direction( directory=fastq_directory, sample_id=sample_id, direction=ReadDirection.FORWARD ) From f4c4b5c723c50f55a50c01b04c09904dd49b542c Mon Sep 17 00:00:00 2001 From: Christian Oertlin Date: Fri, 6 Dec 2024 14:21:32 +0100 Subject: [PATCH 45/80] add debugging --- .../deliver_files/file_formatter/upload_file_formatter.py | 2 +- .../deliver_files/file_formatter/utils/case_service.py | 4 ++++ .../file_formatter/utils/mutant_sample_service.py | 4 ++++ .../file_formatter/utils/sample_concatenation_service.py | 4 ++++ .../deliver_files/file_formatter/utils/sample_service.py | 4 ++++ 5 files changed, 17 insertions(+), 1 deletion(-) diff --git a/cg/services/deliver_files/file_formatter/upload_file_formatter.py b/cg/services/deliver_files/file_formatter/upload_file_formatter.py index 20fec51036..fa152ae4a6 100644 --- a/cg/services/deliver_files/file_formatter/upload_file_formatter.py +++ b/cg/services/deliver_files/file_formatter/upload_file_formatter.py @@ -35,7 +35,7 @@ def __init__( def format_files(self, delivery_files: DeliveryFiles) -> FormattedFiles: """Format the files to be delivered and return the formatted files in the generic format.""" - LOG.debug("[FORMAT SERVICE] Formatting files for delivery") + LOG.debug("[FORMAT SERVICE] Formatting files for Upload") formatted_files: list[FormattedFile] = self._format_sample_and_case_files( sample_files=delivery_files.sample_files, case_files=delivery_files.case_files, diff --git a/cg/services/deliver_files/file_formatter/utils/case_service.py b/cg/services/deliver_files/file_formatter/utils/case_service.py index 106f8a413a..fe9861708f 100644 --- a/cg/services/deliver_files/file_formatter/utils/case_service.py +++ b/cg/services/deliver_files/file_formatter/utils/case_service.py @@ -1,13 +1,17 @@ +import logging import os from pathlib import Path from cg.services.deliver_files.file_fetcher.models import CaseFile from cg.services.deliver_files.file_formatter.models import FormattedFile +LOG = logging.getLogger(__name__) + class CaseFileFormatter: def format_files(self, moved_files: list[CaseFile], delivery_path: Path) -> list[FormattedFile]: + LOG.debug("[FORMAT SERVICE] Formatting case files") """Format the case files to deliver and return the formatted files.""" self._create_case_name_folder(ticket_path=delivery_path, case_name=moved_files[0].case_name) return self._format_case_files(moved_files) diff --git a/cg/services/deliver_files/file_formatter/utils/mutant_sample_service.py b/cg/services/deliver_files/file_formatter/utils/mutant_sample_service.py index 63f31c6744..61d7df52d0 100644 --- a/cg/services/deliver_files/file_formatter/utils/mutant_sample_service.py +++ b/cg/services/deliver_files/file_formatter/utils/mutant_sample_service.py @@ -1,3 +1,4 @@ +import logging from pathlib import Path from cg.apps.lims import LimsAPI @@ -8,6 +9,8 @@ ) from cg.services.deliver_files.file_formatter.utils.sample_service import FileManager +LOG = logging.getLogger(__name__) + class MutantFileFormatter: def __init__( @@ -23,6 +26,7 @@ def __init__( def format_files( self, moved_files: list[SampleFile], delivery_path: Path ) -> list[FormattedFile]: + LOG.debug("[FORMAT SERVICE] Formatting and concatenating mutant files") formatted_files: list[FormattedFile] = self.file_formatter.format_files( moved_files=moved_files, delivery_path=delivery_path ) diff --git a/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py b/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py index 0a2ad9c050..04e23c1779 100644 --- a/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py +++ b/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py @@ -1,3 +1,4 @@ +import logging from pathlib import Path from cg.constants.constants import ReadDirection, FileFormat, FileExtensions from cg.services.deliver_files.file_formatter.utils.models import FastqFile @@ -15,6 +16,8 @@ ) from cg.utils.files import get_all_files_in_directory_tree +LOG = logging.getLogger(__name__) + class SampleFileConcatenationFormatter: """ @@ -41,6 +44,7 @@ def format_files( moved_files: list[SampleFile]: List of sample files to deliver. delivery_path: Path: Path to the delivery directory. """ + LOG.debug("[FORMAT SERVICE] Formatting and concatenating sample files") sample_names: set[str] = self.file_name_formatter.get_sample_names(sample_files=moved_files) self._create_sample_directories(delivery_path=delivery_path, sample_names=sample_names) formatted_files: list[FormattedFile] = self.file_name_formatter.format_sample_file_names( diff --git a/cg/services/deliver_files/file_formatter/utils/sample_service.py b/cg/services/deliver_files/file_formatter/utils/sample_service.py index 1a7ac9fe50..60ea6c871f 100644 --- a/cg/services/deliver_files/file_formatter/utils/sample_service.py +++ b/cg/services/deliver_files/file_formatter/utils/sample_service.py @@ -1,9 +1,12 @@ +import logging from pathlib import Path from cg.services.deliver_files.file_fetcher.models import SampleFile from cg.services.deliver_files.file_formatter.models import FormattedFile from cg.services.deliver_files.utils import FileManager +LOG = logging.getLogger(__name__) + class NestedSampleFileNameFormatter: """ @@ -82,6 +85,7 @@ def format_files( self, moved_files: list[SampleFile], delivery_path: Path ) -> list[FormattedFile]: """Format the sample files to deliver and return the formatted files.""" + LOG.debug("[FORMAT SERVICE] Formatting sample files") sample_names: set[str] = self.file_name_formatter.get_sample_names(sample_files=moved_files) for sample_name in sample_names: self.file_manager.create_directories(base_path=delivery_path, directories={sample_name}) From 12f7d76eeba2cb613d0b2f8374f8e55222529fbd Mon Sep 17 00:00:00 2001 From: Christian Oertlin Date: Fri, 6 Dec 2024 14:34:47 +0100 Subject: [PATCH 46/80] add more debug --- .../deliver_files/file_formatter/utils/mutant_sample_service.py | 2 +- .../file_formatter/utils/sample_concatenation_service.py | 2 ++ .../deliver_files/file_formatter/utils/sample_service.py | 2 ++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/cg/services/deliver_files/file_formatter/utils/mutant_sample_service.py b/cg/services/deliver_files/file_formatter/utils/mutant_sample_service.py index 61d7df52d0..c25009bfed 100644 --- a/cg/services/deliver_files/file_formatter/utils/mutant_sample_service.py +++ b/cg/services/deliver_files/file_formatter/utils/mutant_sample_service.py @@ -26,10 +26,10 @@ def __init__( def format_files( self, moved_files: list[SampleFile], delivery_path: Path ) -> list[FormattedFile]: - LOG.debug("[FORMAT SERVICE] Formatting and concatenating mutant files") formatted_files: list[FormattedFile] = self.file_formatter.format_files( moved_files=moved_files, delivery_path=delivery_path ) + LOG.debug("[FORMAT SERVICE] Formatting and concatenating mutant files") appended_formatted_files: list[FormattedFile] = self._add_lims_metadata_to_file_name( formatted_files=formatted_files, sample_files=moved_files ) diff --git a/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py b/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py index 04e23c1779..9118fabd5e 100644 --- a/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py +++ b/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py @@ -67,6 +67,7 @@ def _rename_original_files(self, formatted_files: list[FormattedFile]) -> None: args: formatted_files: list[FormattedFile]: List of formatted files. """ + LOG.debug("[FORMAT SERVICE] Renaming original files") for formatted_file in formatted_files: self.file_manager.rename_file( src=formatted_file.original_path, dst=formatted_file.formatted_path @@ -217,6 +218,7 @@ def _replace_fastq_paths( concatenation_maps: list[ConcatenationMap]: List of ConcatenationMap objects. formatted_files: list[FormattedFile]: List of formatted files. """ + for formatted_file in formatted_files: formatted_file.formatted_path = concatenation_maps[formatted_file.formatted_path] diff --git a/cg/services/deliver_files/file_formatter/utils/sample_service.py b/cg/services/deliver_files/file_formatter/utils/sample_service.py index 60ea6c871f..049ddb54cf 100644 --- a/cg/services/deliver_files/file_formatter/utils/sample_service.py +++ b/cg/services/deliver_files/file_formatter/utils/sample_service.py @@ -25,6 +25,7 @@ def format_sample_file_names(sample_files: list[SampleFile]) -> list[FormattedFi 1. Adds a folder with sample name to the path of the sample files. 2. Replaces sample id by sample name. """ + LOG.debug("[FORMAT SERVICE] Formatting sample file names with nested structure.") formatted_files = [] for sample_file in sample_files: replaced_name = sample_file.file_path.name.replace( @@ -55,6 +56,7 @@ def format_sample_file_names(sample_files: list[SampleFile]) -> list[FormattedFi Returns formatted files with original and formatted file names: Replaces sample id by sample name. """ + LOG.debug("[FORMAT SERVICE] Formatting sample file names with flat structure.") formatted_files = [] for sample_file in sample_files: replaced_name = sample_file.file_path.name.replace( From 7103ae9843bbf36f3705bdec4d3b922f6320c5e8 Mon Sep 17 00:00:00 2001 From: Christian Oertlin Date: Fri, 6 Dec 2024 14:42:37 +0100 Subject: [PATCH 47/80] add debug --- cg/services/deliver_files/utils.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cg/services/deliver_files/utils.py b/cg/services/deliver_files/utils.py index 0c5f5e3dae..1a58713801 100644 --- a/cg/services/deliver_files/utils.py +++ b/cg/services/deliver_files/utils.py @@ -16,17 +16,21 @@ class FileManager: @staticmethod def create_directories(base_path: Path, directories: set[str]) -> None: """Create directories for given names under the base path.""" + for directory in directories: + LOG.debug(f"[FileManager] Creating directory or file: {base_path}/{directory}") Path(base_path, directory).mkdir(parents=True, exist_ok=True) @staticmethod def rename_file(src: Path, dst: Path) -> None: """Rename a file from src to dst.""" + LOG.debug(f"[FileManager] Renaming file: {src} -> {dst}") os.rename(src=src, dst=dst) @staticmethod def create_hard_link(src: Path, dst: Path) -> None: """Create a hard link from src to dst.""" + LOG.debug(f"[FileManager] Creating hard link: {src} -> {dst}") os.link(src=src, dst=dst) From a07705a661d11d02fa199285448301892584be8d Mon Sep 17 00:00:00 2001 From: Christian Oertlin Date: Fri, 6 Dec 2024 16:45:33 +0100 Subject: [PATCH 48/80] add errr --- cg/services/deliver_files/utils.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cg/services/deliver_files/utils.py b/cg/services/deliver_files/utils.py index 1a58713801..52ae9cccbd 100644 --- a/cg/services/deliver_files/utils.py +++ b/cg/services/deliver_files/utils.py @@ -25,6 +25,8 @@ def create_directories(base_path: Path, directories: set[str]) -> None: def rename_file(src: Path, dst: Path) -> None: """Rename a file from src to dst.""" LOG.debug(f"[FileManager] Renaming file: {src} -> {dst}") + if not src.exists(): + raise FileNotFoundError(f"Source file {src} does not exist.") os.rename(src=src, dst=dst) @staticmethod From 7312b32f4a49656209d150b117141f22bc6f1880 Mon Sep 17 00:00:00 2001 From: Christian Oertlin Date: Mon, 9 Dec 2024 09:31:06 +0100 Subject: [PATCH 49/80] add debug --- cg/services/deliver_files/utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cg/services/deliver_files/utils.py b/cg/services/deliver_files/utils.py index 52ae9cccbd..5456ffab74 100644 --- a/cg/services/deliver_files/utils.py +++ b/cg/services/deliver_files/utils.py @@ -69,6 +69,7 @@ def update_file_paths( def _move_or_link_file(self, src: Path, dst: Path) -> None: """Move or create a hard link for a file.""" + LOG.debug(f"[FileMover] Moving file: {src} -> {dst}") if dst.exists(): LOG.debug(f"Overwriting existing file: {dst}") dst.unlink() From 10c755183de8199b4a5ee1bde7fc83a28a251fca Mon Sep 17 00:00:00 2001 From: Christian Oertlin Date: Mon, 9 Dec 2024 09:44:40 +0100 Subject: [PATCH 50/80] debugs --- .../file_formatter/utils/sample_concatenation_service.py | 3 +++ cg/services/deliver_files/utils.py | 2 ++ 2 files changed, 5 insertions(+) diff --git a/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py b/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py index 9118fabd5e..1db895d127 100644 --- a/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py +++ b/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py @@ -50,6 +50,9 @@ def format_files( formatted_files: list[FormattedFile] = self.file_name_formatter.format_sample_file_names( sample_files=moved_files ) + LOG.debug( + f"[FORMAT SERVICE] number of formatted files: {len(formatted_files)}, number of moved files: {len(moved_files)}" + ) self._rename_original_files(formatted_files) concatenation_map: dict[Path, Path] = self._concatenate_fastq_files( delivery_path=delivery_path, diff --git a/cg/services/deliver_files/utils.py b/cg/services/deliver_files/utils.py index 5456ffab74..64ad1b1342 100644 --- a/cg/services/deliver_files/utils.py +++ b/cg/services/deliver_files/utils.py @@ -24,6 +24,8 @@ def create_directories(base_path: Path, directories: set[str]) -> None: @staticmethod def rename_file(src: Path, dst: Path) -> None: """Rename a file from src to dst.""" + if not src or not dst: + raise ValueError("Source and destination paths cannot be None.") LOG.debug(f"[FileManager] Renaming file: {src} -> {dst}") if not src.exists(): raise FileNotFoundError(f"Source file {src} does not exist.") From dd50ff567555f49c04055cc887e8704bea97b16a Mon Sep 17 00:00:00 2001 From: Christian Oertlin Date: Mon, 9 Dec 2024 09:57:21 +0100 Subject: [PATCH 51/80] fix --- .../deliver_files_service/deliver_files_service.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/cg/services/deliver_files/deliver_files_service/deliver_files_service.py b/cg/services/deliver_files/deliver_files_service/deliver_files_service.py index 2c3dfdc22e..0f4494009e 100644 --- a/cg/services/deliver_files/deliver_files_service/deliver_files_service.py +++ b/cg/services/deliver_files/deliver_files_service/deliver_files_service.py @@ -128,16 +128,7 @@ def deliver_files_for_fohm_upload(self, case: Case, sample_id: str, delivery_bas moved_files: DeliveryFiles = self.file_mover.move_files( delivery_files=filtered_files, delivery_base_path=delivery_base_path ) - formatted_files = self.file_formatter.format_files(moved_files) - - for formatted_file in formatted_files.files: - # Move files back to the delivery base path so it conforms to the FOHM upload structure - LOG.debug( - f"Moving files for sample {formatted_file} back to the delivery base path {delivery_base_path}" - ) - shutil.move(src=formatted_file.formatted_path, dst=delivery_base_path) - # Delete the sample folder - shutil.rmtree(path=formatted_files[0].formatted_path.parent) + self.file_formatter.format_files(moved_files) def _start_rsync_job(self, case: Case, dry_run: bool, folders_to_deliver: set[Path]) -> int: LOG.debug(f"[RSYNC] Starting rsync job for case {case.internal_id}") From 62ecc4163b2f9151278f7c0f7554933a506aa8c0 Mon Sep 17 00:00:00 2001 From: Christian Oertlin Date: Mon, 9 Dec 2024 10:07:46 +0100 Subject: [PATCH 52/80] debug --- .../deliver_files_service/deliver_files_service.py | 3 +-- .../file_formatter/utils/sample_concatenation_service.py | 3 +++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/cg/services/deliver_files/deliver_files_service/deliver_files_service.py b/cg/services/deliver_files/deliver_files_service/deliver_files_service.py index 0f4494009e..e12ae1a150 100644 --- a/cg/services/deliver_files/deliver_files_service/deliver_files_service.py +++ b/cg/services/deliver_files/deliver_files_service/deliver_files_service.py @@ -112,8 +112,7 @@ def deliver_files_for_sample( def deliver_files_for_fohm_upload(self, case: Case, sample_id: str, delivery_base_path: Path): """ - Deliver the files for a sample to the FOHM upload destination. In addition to the normal delivery, - the files need to be moved back to the delivery base path so it conforms to the FOHM upload structure. + Deliver the files for a sample to the FOHM upload destination. :param case: The case to deliver files for :param sample_id: The sample to deliver files for diff --git a/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py b/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py index 1db895d127..8456283979 100644 --- a/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py +++ b/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py @@ -152,6 +152,9 @@ def _get_unique_sample_fastq_paths( sample_name in file.as_posix() and f"{FileFormat.FASTQ}{FileExtensions.GZIP}" in file.as_posix() ): + LOG.debug( + f"[CONCATENATION SERVICE] Found fastq file: {file} for sample: {sample_name}" + ) sample_paths.append( FastqFile( fastq_file_path=Path(delivery_path, file), From 1a7510d949fe2fb80ca30f66fef41ce9bf7ce069 Mon Sep 17 00:00:00 2001 From: Christian Oertlin Date: Mon, 9 Dec 2024 10:08:53 +0100 Subject: [PATCH 53/80] more debug --- .../file_formatter/utils/sample_concatenation_service.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py b/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py index 8456283979..66f068d928 100644 --- a/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py +++ b/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py @@ -145,6 +145,9 @@ def _get_unique_sample_fastq_paths( list[FastqFile]: List of FastqFile objects. """ sample_paths: list[FastqFile] = [] + LOG.debug( + f"[CONCATENATION SERVICE] Getting unique sample fastq file paths in {delivery_path}" + ) list_of_files: list[Path] = get_all_files_in_directory_tree(delivery_path) for sample_name in sample_names: for file in list_of_files: From 853f315ae71942be16b5fe0cd8cb71ae93e15d55 Mon Sep 17 00:00:00 2001 From: Christian Oertlin Date: Mon, 9 Dec 2024 10:21:33 +0100 Subject: [PATCH 54/80] trace delivery path --- .../file_formatter/upload_file_formatter.py | 9 +++++---- .../file_formatter/utils/sample_concatenation_service.py | 3 +++ 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/cg/services/deliver_files/file_formatter/upload_file_formatter.py b/cg/services/deliver_files/file_formatter/upload_file_formatter.py index fa152ae4a6..bb15483bd7 100644 --- a/cg/services/deliver_files/file_formatter/upload_file_formatter.py +++ b/cg/services/deliver_files/file_formatter/upload_file_formatter.py @@ -39,22 +39,23 @@ def format_files(self, delivery_files: DeliveryFiles) -> FormattedFiles: formatted_files: list[FormattedFile] = self._format_sample_and_case_files( sample_files=delivery_files.sample_files, case_files=delivery_files.case_files, - base_dir_path=delivery_files.sample_files[0].file_path.parent, + delivery_path=delivery_files.sample_files[0].file_path.parent, ) return FormattedFiles(files=formatted_files) def _format_sample_and_case_files( - self, sample_files: list[SampleFile], case_files: list[CaseFile], base_dir_path: Path + self, sample_files: list[SampleFile], case_files: list[CaseFile], delivery_path: Path ) -> list[FormattedFile]: """Helper method to format both sample and case files.""" + LOG.debug(f"[FORMAT SERVICE] delivery_path: {delivery_path}") formatted_files: list[FormattedFile] = self.sample_file_formatter.format_files( moved_files=sample_files, - delivery_path=base_dir_path, + delivery_path=delivery_path, ) if case_files: formatted_case_files: list[FormattedFile] = self.case_file_formatter.format_files( moved_files=case_files, - delivery_path=base_dir_path, + delivery_path=delivery_path, ) formatted_files.extend(formatted_case_files) return formatted_files diff --git a/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py b/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py index 66f068d928..2eca16f581 100644 --- a/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py +++ b/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py @@ -45,6 +45,7 @@ def format_files( delivery_path: Path: Path to the delivery directory. """ LOG.debug("[FORMAT SERVICE] Formatting and concatenating sample files") + LOG.debug(f"[FORMAT SERVICE] delivery_path: {delivery_path}") sample_names: set[str] = self.file_name_formatter.get_sample_names(sample_files=moved_files) self._create_sample_directories(delivery_path=delivery_path, sample_names=sample_names) formatted_files: list[FormattedFile] = self.file_name_formatter.format_sample_file_names( @@ -54,6 +55,7 @@ def format_files( f"[FORMAT SERVICE] number of formatted files: {len(formatted_files)}, number of moved files: {len(moved_files)}" ) self._rename_original_files(formatted_files) + LOG.debug(f"[FORMAT SERVICE] delivery_path: {delivery_path}") concatenation_map: dict[Path, Path] = self._concatenate_fastq_files( delivery_path=delivery_path, sample_names=sample_names, @@ -97,6 +99,7 @@ def _concatenate_fastq_files( returns: dict[Path, Path]: Dictionary with the original fastq file path as key and the concatenated path as value. """ + LOG.debug(f"[FORMAT SERVICE] delivery_path: {delivery_path}") fastq_files: list[FastqFile] = self._get_unique_sample_fastq_paths( sample_names=sample_names, delivery_path=delivery_path ) From ae142af37acbb81fa9845f04db79d9c8f510eba9 Mon Sep 17 00:00:00 2001 From: Christian Oertlin Date: Mon, 9 Dec 2024 10:46:20 +0100 Subject: [PATCH 55/80] fix path passing --- .../deliver_files_service.py | 22 ++++++++++++------- .../deliver_files/file_formatter/abstract.py | 3 ++- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/cg/services/deliver_files/deliver_files_service/deliver_files_service.py b/cg/services/deliver_files/deliver_files_service/deliver_files_service.py index e12ae1a150..db933e7965 100644 --- a/cg/services/deliver_files/deliver_files_service/deliver_files_service.py +++ b/cg/services/deliver_files/deliver_files_service/deliver_files_service.py @@ -65,7 +65,9 @@ def deliver_files_for_case( moved_files: DeliveryFiles = self.file_mover.move_files( delivery_files=delivery_files, delivery_base_path=delivery_base_path ) - formatted_files: FormattedFiles = self.file_formatter.format_files(moved_files) + formatted_files: FormattedFiles = self.file_formatter.format_files( + delivery_files=moved_files, delivery_path=delivery_base_path + ) for formatted_file in formatted_files.files: assert formatted_file.formatted_path.exists() folders_to_deliver: set[Path] = set( @@ -101,7 +103,9 @@ def deliver_files_for_sample( moved_files: DeliveryFiles = self.file_mover.move_files( delivery_files=filtered_files, delivery_base_path=delivery_base_path ) - formatted_files: FormattedFiles = self.file_formatter.format_files(moved_files) + formatted_files: FormattedFiles = self.file_formatter.format_files( + delivery_files=moved_files, delivery_path=delivery_base_path + ) folders_to_deliver: set[Path] = set( [formatted_file.formatted_path.parent for formatted_file in formatted_files.files] ) @@ -112,11 +116,11 @@ def deliver_files_for_sample( def deliver_files_for_fohm_upload(self, case: Case, sample_id: str, delivery_base_path: Path): """ - Deliver the files for a sample to the FOHM upload destination. - - :param case: The case to deliver files for - :param sample_id: The sample to deliver files for - :param delivery_base_path: The base path to deliver the files to + Deliver the files for a sample to the FOHM upload destination. Does not perform rsync. + args: + case: The case to deliver files for + sample_id: The sample to deliver files for + delivery_base_path: The base path to deliver the files to """ delivery_files: DeliveryFiles = self.file_manager.get_files_to_deliver( case_id=case.internal_id @@ -127,7 +131,9 @@ def deliver_files_for_fohm_upload(self, case: Case, sample_id: str, delivery_bas moved_files: DeliveryFiles = self.file_mover.move_files( delivery_files=filtered_files, delivery_base_path=delivery_base_path ) - self.file_formatter.format_files(moved_files) + self.file_formatter.format_files( + delivery_files=moved_files, delivery_base_path=delivery_base_path + ) def _start_rsync_job(self, case: Case, dry_run: bool, folders_to_deliver: set[Path]) -> int: LOG.debug(f"[RSYNC] Starting rsync job for case {case.internal_id}") diff --git a/cg/services/deliver_files/file_formatter/abstract.py b/cg/services/deliver_files/file_formatter/abstract.py index 31eb12f582..e6f4f473eb 100644 --- a/cg/services/deliver_files/file_formatter/abstract.py +++ b/cg/services/deliver_files/file_formatter/abstract.py @@ -1,4 +1,5 @@ from abc import abstractmethod, ABC +from pathlib import Path from cg.services.deliver_files.file_fetcher.models import DeliveryFiles from cg.services.deliver_files.file_formatter.models import FormattedFiles @@ -10,6 +11,6 @@ class DeliveryFileFormattingService(ABC): """ @abstractmethod - def format_files(self, delivery_files: DeliveryFiles) -> FormattedFiles: + def format_files(self, delivery_files: DeliveryFiles, delivery_path: Path) -> FormattedFiles: """Format the files to deliver.""" pass From 71c996c1103839f6812eb2fc786350780eb3bc4c Mon Sep 17 00:00:00 2001 From: Christian Oertlin Date: Mon, 9 Dec 2024 10:57:12 +0100 Subject: [PATCH 56/80] fix --- .../deliver_files_service/deliver_files_service.py | 2 +- .../deliver_files/file_formatter/upload_file_formatter.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cg/services/deliver_files/deliver_files_service/deliver_files_service.py b/cg/services/deliver_files/deliver_files_service/deliver_files_service.py index db933e7965..8ccf77e667 100644 --- a/cg/services/deliver_files/deliver_files_service/deliver_files_service.py +++ b/cg/services/deliver_files/deliver_files_service/deliver_files_service.py @@ -132,7 +132,7 @@ def deliver_files_for_fohm_upload(self, case: Case, sample_id: str, delivery_bas delivery_files=filtered_files, delivery_base_path=delivery_base_path ) self.file_formatter.format_files( - delivery_files=moved_files, delivery_base_path=delivery_base_path + delivery_files=moved_files, delivery_path=delivery_base_path ) def _start_rsync_job(self, case: Case, dry_run: bool, folders_to_deliver: set[Path]) -> int: diff --git a/cg/services/deliver_files/file_formatter/upload_file_formatter.py b/cg/services/deliver_files/file_formatter/upload_file_formatter.py index bb15483bd7..86b86ab764 100644 --- a/cg/services/deliver_files/file_formatter/upload_file_formatter.py +++ b/cg/services/deliver_files/file_formatter/upload_file_formatter.py @@ -33,13 +33,13 @@ def __init__( self.case_file_formatter = case_file_formatter self.sample_file_formatter = sample_file_formatter - def format_files(self, delivery_files: DeliveryFiles) -> FormattedFiles: + def format_files(self, delivery_files: DeliveryFiles, delivery_path: Path) -> FormattedFiles: """Format the files to be delivered and return the formatted files in the generic format.""" LOG.debug("[FORMAT SERVICE] Formatting files for Upload") formatted_files: list[FormattedFile] = self._format_sample_and_case_files( sample_files=delivery_files.sample_files, case_files=delivery_files.case_files, - delivery_path=delivery_files.sample_files[0].file_path.parent, + delivery_path=delivery_path, ) return FormattedFiles(files=formatted_files) From 264bc73bd6983a6ff8a0561f4f57912b8e11f255 Mon Sep 17 00:00:00 2001 From: Christian Oertlin Date: Mon, 9 Dec 2024 12:43:29 +0100 Subject: [PATCH 57/80] fix param --- .../deliver_files/file_formatter/delivery_file_formatter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cg/services/deliver_files/file_formatter/delivery_file_formatter.py b/cg/services/deliver_files/file_formatter/delivery_file_formatter.py index b807695fe3..f802f8107f 100644 --- a/cg/services/deliver_files/file_formatter/delivery_file_formatter.py +++ b/cg/services/deliver_files/file_formatter/delivery_file_formatter.py @@ -33,7 +33,7 @@ def __init__( self.case_file_formatter = case_file_formatter self.sample_file_formatter = sample_file_formatter - def format_files(self, delivery_files: DeliveryFiles) -> FormattedFiles: + def format_files(self, delivery_files: DeliveryFiles, delivery_path: Path) -> FormattedFiles: """Format the files to be delivered and return the formatted files in the customer inbox format.""" LOG.debug("[FORMAT SERVICE] Formatting files for delivery") ticket_dir_path: Path = delivery_files.delivery_data.customer_ticket_inbox From 1557a7bc9796caea7b3604433f3febec6b17688c Mon Sep 17 00:00:00 2001 From: Christian Oertlin Date: Mon, 9 Dec 2024 12:52:07 +0100 Subject: [PATCH 58/80] don't ask --- .../deliver_files_service/deliver_files_service_factory.py | 3 ++- .../deliver_files/file_formatter/delivery_file_formatter.py | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/cg/services/deliver_files/deliver_files_service/deliver_files_service_factory.py b/cg/services/deliver_files/deliver_files_service/deliver_files_service_factory.py index e15919749b..626fba8e39 100644 --- a/cg/services/deliver_files/deliver_files_service/deliver_files_service_factory.py +++ b/cg/services/deliver_files/deliver_files_service/deliver_files_service_factory.py @@ -22,6 +22,7 @@ from cg.services.deliver_files.file_filter.sample_service import SampleFileFilter from cg.services.deliver_files.file_formatter.abstract import DeliveryFileFormattingService from cg.services.deliver_files.file_formatter.delivery_file_formatter import DeliveryFileFormatter +from cg.services.deliver_files.file_formatter.upload_file_formatter import UploadFileFormatter from cg.services.deliver_files.file_formatter.utils.case_service import CaseFileFormatter from cg.services.deliver_files.file_formatter.utils.mutant_sample_service import MutantFileFormatter from cg.services.deliver_files.file_formatter.utils.sample_concatenation_service import ( @@ -204,7 +205,7 @@ def _get_file_formatter( SampleFileFormatter | SampleFileConcatenationFormatter | MutantFileFormatter ) = self._get_sample_file_formatter(case=case, delivery_destination=delivery_destination) if delivery_destination == DeliveryDestination.UPLOAD: - return DeliveryFileFormatter( + return UploadFileFormatter( case_file_formatter=CaseFileFormatter(), sample_file_formatter=sample_file_formatter, ) diff --git a/cg/services/deliver_files/file_formatter/delivery_file_formatter.py b/cg/services/deliver_files/file_formatter/delivery_file_formatter.py index f802f8107f..cab479049d 100644 --- a/cg/services/deliver_files/file_formatter/delivery_file_formatter.py +++ b/cg/services/deliver_files/file_formatter/delivery_file_formatter.py @@ -33,7 +33,9 @@ def __init__( self.case_file_formatter = case_file_formatter self.sample_file_formatter = sample_file_formatter - def format_files(self, delivery_files: DeliveryFiles, delivery_path: Path) -> FormattedFiles: + def format_files( + self, delivery_files: DeliveryFiles, delivery_path: Path = None + ) -> FormattedFiles: """Format the files to be delivered and return the formatted files in the customer inbox format.""" LOG.debug("[FORMAT SERVICE] Formatting files for delivery") ticket_dir_path: Path = delivery_files.delivery_data.customer_ticket_inbox From 0652fc44cf40825e4b1569a72ba52531f414143d Mon Sep 17 00:00:00 2001 From: ChristianOertlin Date: Tue, 10 Dec 2024 09:37:57 +0100 Subject: [PATCH 59/80] feat(File fetching sample specific) (#4011) * remove file filter add single sample fetching * remove broken imports * remove file filter from test as param * Update cg/services/deliver_files/file_fetcher/analysis_service.py Co-authored-by: Sebastian Diaz --------- Co-authored-by: Sebastian Diaz --- cg/meta/upload/fohm/fohm.py | 4 ---- .../deliver_files_service.py | 10 ++------- .../deliver_files_service_factory.py | 2 -- .../deliver_files/file_fetcher/abstract.py | 2 +- .../file_fetcher/analysis_raw_data_service.py | 12 +++++----- .../file_fetcher/analysis_service.py | 12 ++++++---- .../file_fetcher/raw_data_service.py | 4 ++-- .../deliver_files/file_filter/abstract.py | 10 --------- .../file_filter/sample_service.py | 13 ----------- .../delivery_files_models_fixtures.py | 17 ++++++++++++++ .../delivery_file_service/test_service.py | 1 - .../test_file_fetching_service.py | 15 ++++++++----- .../file_filter/test_sample_filter_service.py | 22 ------------------- 13 files changed, 47 insertions(+), 77 deletions(-) delete mode 100644 cg/services/deliver_files/file_filter/abstract.py delete mode 100644 cg/services/deliver_files/file_filter/sample_service.py delete mode 100644 tests/services/file_delivery/file_filter/test_sample_filter_service.py diff --git a/cg/meta/upload/fohm/fohm.py b/cg/meta/upload/fohm/fohm.py index 894c8e8546..6e507113ad 100644 --- a/cg/meta/upload/fohm/fohm.py +++ b/cg/meta/upload/fohm/fohm.py @@ -198,20 +198,16 @@ def link_sample_raw_data_files( self, reports: list[FohmComplementaryReport] | list[FohmPangolinReport] ) -> None: """Hardlink samples raw data files to FOHM delivery folder.""" - LOG.debug("Linking sample raw data files to FOHM delivery folder") - LOG.debug(f"NR of Reports: {len(reports)}") for report in reports: sample: Sample = self.status_db.get_sample_by_internal_id( internal_id=report.internal_id ) - LOG.debug(f"Linking files for sample {sample.internal_id}") case: Case = sample.links[0].case delivery_service = self._delivery_factory.build_delivery_service( case=case, delivery_type=DataDelivery.FASTQ, delivery_destination=DeliveryDestination.UPLOAD, ) - LOG.debug(f"Linking files for sample {sample.internal_id}") delivery_service.deliver_files_for_fohm_upload( case=case, sample_id=sample.internal_id, delivery_base_path=self.daily_rawdata_path ) diff --git a/cg/services/deliver_files/deliver_files_service/deliver_files_service.py b/cg/services/deliver_files/deliver_files_service/deliver_files_service.py index 8ccf77e667..1448a8e42d 100644 --- a/cg/services/deliver_files/deliver_files_service/deliver_files_service.py +++ b/cg/services/deliver_files/deliver_files_service/deliver_files_service.py @@ -12,7 +12,6 @@ ) from cg.services.deliver_files.file_fetcher.abstract import FetchDeliveryFilesService from cg.services.deliver_files.file_fetcher.models import DeliveryFiles -from cg.services.deliver_files.file_filter.abstract import FilterDeliveryFilesService from cg.services.deliver_files.file_formatter.abstract import DeliveryFileFormattingService from cg.services.deliver_files.file_formatter.models import FormattedFiles from cg.services.deliver_files.file_mover.delivery_files_mover import DeliveryFilesMover @@ -37,7 +36,6 @@ class DeliverFilesService: def __init__( self, delivery_file_manager_service: FetchDeliveryFilesService, - file_filter: FilterDeliveryFilesService, move_file_service: DeliveryFilesMover, file_formatter_service: DeliveryFileFormattingService, rsync_service: DeliveryRsyncService, @@ -46,7 +44,6 @@ def __init__( status_db: Store, ): self.file_manager = delivery_file_manager_service - self.file_filter = file_filter self.file_mover = move_file_service self.file_formatter = file_formatter_service self.status_db = status_db @@ -95,13 +92,10 @@ def deliver_files_for_sample( ): """Deliver the files for a sample to the customer folder.""" delivery_files: DeliveryFiles = self.file_manager.get_files_to_deliver( - case_id=case.internal_id - ) - filtered_files: DeliveryFiles = self.file_filter.filter_delivery_files( - delivery_files=delivery_files, sample_id=sample_id + case_id=case.internal_id, sample_id=sample_id ) moved_files: DeliveryFiles = self.file_mover.move_files( - delivery_files=filtered_files, delivery_base_path=delivery_base_path + delivery_files=delivery_files, delivery_base_path=delivery_base_path ) formatted_files: FormattedFiles = self.file_formatter.format_files( delivery_files=moved_files, delivery_path=delivery_base_path diff --git a/cg/services/deliver_files/deliver_files_service/deliver_files_service_factory.py b/cg/services/deliver_files/deliver_files_service/deliver_files_service_factory.py index 626fba8e39..a6fd01ec33 100644 --- a/cg/services/deliver_files/deliver_files_service/deliver_files_service_factory.py +++ b/cg/services/deliver_files/deliver_files_service/deliver_files_service_factory.py @@ -19,7 +19,6 @@ ) from cg.services.deliver_files.file_fetcher.analysis_service import AnalysisDeliveryFileFetcher from cg.services.deliver_files.file_fetcher.raw_data_service import RawDataDeliveryFileFetcher -from cg.services.deliver_files.file_filter.sample_service import SampleFileFilter from cg.services.deliver_files.file_formatter.abstract import DeliveryFileFormattingService from cg.services.deliver_files.file_formatter.delivery_file_formatter import DeliveryFileFormatter from cg.services.deliver_files.file_formatter.upload_file_formatter import UploadFileFormatter @@ -240,7 +239,6 @@ def build_delivery_service( return DeliverFilesService( delivery_file_manager_service=file_fetcher, move_file_service=file_move_service, - file_filter=SampleFileFilter(), file_formatter_service=file_formatter, status_db=self.store, rsync_service=self.rsync_service, diff --git a/cg/services/deliver_files/file_fetcher/abstract.py b/cg/services/deliver_files/file_fetcher/abstract.py index 95c5d78a98..65fe83e6d5 100644 --- a/cg/services/deliver_files/file_fetcher/abstract.py +++ b/cg/services/deliver_files/file_fetcher/abstract.py @@ -29,6 +29,6 @@ def __init__( self.tags_fetcher = tags_fetcher @abstractmethod - def get_files_to_deliver(self, case_id: str) -> DeliveryFiles: + def get_files_to_deliver(self, case_id: str, sample_id: str | None) -> DeliveryFiles: """Get the files to deliver.""" pass diff --git a/cg/services/deliver_files/file_fetcher/analysis_raw_data_service.py b/cg/services/deliver_files/file_fetcher/analysis_raw_data_service.py index 4eafa0210d..c7489b9c73 100644 --- a/cg/services/deliver_files/file_fetcher/analysis_raw_data_service.py +++ b/cg/services/deliver_files/file_fetcher/analysis_raw_data_service.py @@ -28,13 +28,13 @@ def __init__( self.hk_api = hk_api self.tags_fetcher = tags_fetcher - def get_files_to_deliver(self, case_id: str) -> DeliveryFiles: + def get_files_to_deliver(self, case_id: str, sample_id: str | None = None) -> DeliveryFiles: case: Case = self.status_db.get_case_by_internal_id(internal_id=case_id) fastq_files: DeliveryFiles = self._fetch_files( - service_class=RawDataDeliveryFileFetcher, case_id=case_id + service_class=RawDataDeliveryFileFetcher, case_id=case_id, sample_id=sample_id ) analysis_files: DeliveryFiles = self._fetch_files( - service_class=AnalysisDeliveryFileFetcher, case_id=case_id + service_class=AnalysisDeliveryFileFetcher, case_id=case_id, sample_id=sample_id ) delivery_data = DeliveryMetaData( case_id=case.internal_id, @@ -48,7 +48,9 @@ def get_files_to_deliver(self, case_id: str) -> DeliveryFiles: sample_files=analysis_files.sample_files + fastq_files.sample_files, ) - def _fetch_files(self, service_class: type, case_id: str) -> DeliveryFiles: + def _fetch_files( + self, service_class: type, case_id: str, sample_id: str | None + ) -> DeliveryFiles: """Fetch files using the provided service class.""" service = service_class(self.status_db, self.hk_api, tags_fetcher=self.tags_fetcher) - return service.get_files_to_deliver(case_id) + return service.get_files_to_deliver(case_id=case_id, sample_id=sample_id) diff --git a/cg/services/deliver_files/file_fetcher/analysis_service.py b/cg/services/deliver_files/file_fetcher/analysis_service.py index ed0072cb2b..207ad5b1e8 100644 --- a/cg/services/deliver_files/file_fetcher/analysis_service.py +++ b/cg/services/deliver_files/file_fetcher/analysis_service.py @@ -38,12 +38,14 @@ def __init__( self.hk_api = hk_api self.tags_fetcher = tags_fetcher - def get_files_to_deliver(self, case_id: str) -> DeliveryFiles: + def get_files_to_deliver(self, case_id: str, sample_id: str | None = None) -> DeliveryFiles: """Return a list of analysis files to be delivered for a case.""" LOG.debug(f"[FETCH SERVICE] Fetching analysis files for case: {case_id}") case: Case = self.status_db.get_case_by_internal_id(internal_id=case_id) analysis_case_files: list[CaseFile] = self._get_analysis_case_delivery_files(case) - analysis_sample_files: list[SampleFile] = self._get_analysis_sample_delivery_files(case) + analysis_sample_files: list[SampleFile] = self._get_analysis_sample_delivery_files( + case=case, sample_id=sample_id + ) delivery_data = DeliveryMetaData( case_id=case.internal_id, customer_internal_id=case.customer.internal_id, @@ -89,9 +91,11 @@ def _get_sample_files_from_case_bundle( for sample_file in sample_files ] - def _get_analysis_sample_delivery_files(self, case: Case) -> list[SampleFile] | None: + def _get_analysis_sample_delivery_files( + self, case: Case, sample_id: str | None + ) -> list[SampleFile] | None: """Return a all sample files to deliver for a case.""" - sample_ids: list[str] = case.sample_ids + sample_ids: list[str] = [sample_id] if sample_id else case.sample_ids delivery_files: list[SampleFile] = [] for sample_id in sample_ids: sample_files: list[SampleFile] = self._get_sample_files_from_case_bundle( diff --git a/cg/services/deliver_files/file_fetcher/raw_data_service.py b/cg/services/deliver_files/file_fetcher/raw_data_service.py index bdc99cf1ca..d6d5d8c59a 100644 --- a/cg/services/deliver_files/file_fetcher/raw_data_service.py +++ b/cg/services/deliver_files/file_fetcher/raw_data_service.py @@ -43,11 +43,11 @@ def __init__( self.hk_api = hk_api self.tags_fetcher = tags_fetcher - def get_files_to_deliver(self, case_id: str) -> DeliveryFiles: + def get_files_to_deliver(self, case_id: str, sample_id: str | None = None) -> DeliveryFiles: """Return a list of raw data files to be delivered for a case and its samples.""" LOG.debug(f"[FETCH SERVICE] Fetching raw data files for case: {case_id}") case: Case = self.status_db.get_case_by_internal_id(internal_id=case_id) - sample_ids: list[str] = case.sample_ids + sample_ids: list[str] = [sample_id] if sample_id else case.sample_ids raw_data_files: list[SampleFile] = [] for sample_id in sample_ids: raw_data_files.extend( diff --git a/cg/services/deliver_files/file_filter/abstract.py b/cg/services/deliver_files/file_filter/abstract.py deleted file mode 100644 index a0d846b544..0000000000 --- a/cg/services/deliver_files/file_filter/abstract.py +++ /dev/null @@ -1,10 +0,0 @@ -from abc import abstractmethod, ABC - -from cg.services.deliver_files.file_fetcher.models import DeliveryFiles - - -class FilterDeliveryFilesService(ABC): - - @abstractmethod - def filter_delivery_files(self, delivery_files: DeliveryFiles, sample_id: str) -> DeliveryFiles: - pass diff --git a/cg/services/deliver_files/file_filter/sample_service.py b/cg/services/deliver_files/file_filter/sample_service.py deleted file mode 100644 index 3f4ed9e56c..0000000000 --- a/cg/services/deliver_files/file_filter/sample_service.py +++ /dev/null @@ -1,13 +0,0 @@ -from cg.services.deliver_files.file_fetcher.models import DeliveryFiles -from cg.services.deliver_files.file_filter.abstract import FilterDeliveryFilesService - - -class SampleFileFilter(FilterDeliveryFilesService): - - def filter_delivery_files(self, delivery_files: DeliveryFiles, sample_id: str) -> DeliveryFiles: - delivery_files.sample_files = [ - sample_file - for sample_file in delivery_files.sample_files - if sample_file.sample_id == sample_id - ] - return delivery_files diff --git a/tests/fixture_plugins/delivery_fixtures/delivery_files_models_fixtures.py b/tests/fixture_plugins/delivery_fixtures/delivery_files_models_fixtures.py index 580c8f0d5f..cf226c9215 100644 --- a/tests/fixture_plugins/delivery_fixtures/delivery_files_models_fixtures.py +++ b/tests/fixture_plugins/delivery_fixtures/delivery_files_models_fixtures.py @@ -90,6 +90,18 @@ def expected_bam_delivery_files( return DeliveryFiles(delivery_data=delivery_meta_data, case_files=[], sample_files=sample_files) +@pytest.fixture() +def expected_bam_delivery_files_single_sample( + expected_bam_delivery_files: DeliveryFiles, sample_id: str +) -> DeliveryFiles: + expected_bam_delivery_files.sample_files = [ + sample_file + for sample_file in expected_bam_delivery_files.sample_files + if sample_file.sample_id == sample_id + ] + return expected_bam_delivery_files + + @pytest.fixture def expected_analysis_delivery_files( delivery_housekeeper_api: HousekeeperAPI, @@ -334,3 +346,8 @@ def expected_moved_upload_files(expected_analysis_delivery_files: DeliveryFiles, case_files=new_case_files, sample_files=new_sample_files, ) + + +@pytest.fixture +def empty_sample() -> None: + return None diff --git a/tests/services/file_delivery/delivery_file_service/test_service.py b/tests/services/file_delivery/delivery_file_service/test_service.py index 0dcd3da047..7315fa4514 100644 --- a/tests/services/file_delivery/delivery_file_service/test_service.py +++ b/tests/services/file_delivery/delivery_file_service/test_service.py @@ -12,7 +12,6 @@ def test_file_delivery_service_no_files(empty_delivery_files: DeliveryFiles): file_delivery_service = DeliverFilesService( delivery_file_manager_service=Mock(), move_file_service=Mock(), - file_filter=Mock(), file_formatter_service=Mock(), rsync_service=Mock(), tb_service=Mock(), diff --git a/tests/services/file_delivery/file_fetcher/test_file_fetching_service.py b/tests/services/file_delivery/file_fetcher/test_file_fetching_service.py index 50b770bcfc..878688bfb3 100644 --- a/tests/services/file_delivery/file_fetcher/test_file_fetching_service.py +++ b/tests/services/file_delivery/file_fetcher/test_file_fetching_service.py @@ -8,16 +8,18 @@ @pytest.mark.parametrize( - "expected_delivery_files,delivery_file_service", + "expected_delivery_files,delivery_file_service,sample_id", [ - ("expected_fastq_delivery_files", "raw_data_delivery_service"), - ("expected_analysis_delivery_files", "analysis_delivery_service"), - ("expected_bam_delivery_files", "bam_data_delivery_service"), + ("expected_fastq_delivery_files", "raw_data_delivery_service", "empty_sample"), + ("expected_analysis_delivery_files", "analysis_delivery_service", "empty_sample"), + ("expected_bam_delivery_files", "bam_data_delivery_service", "empty_sample"), + ("expected_bam_delivery_files_single_sample", "bam_data_delivery_service", "sample_id"), ], ) def test_get_files_to_deliver( expected_delivery_files: DeliveryFiles, delivery_file_service: FetchDeliveryFilesService, + sample_id: str | None, case_id: str, request, ): @@ -25,9 +27,12 @@ def test_get_files_to_deliver( # GIVEN a case id, samples that are present in Housekeeper and a delivery service delivery_file_service = request.getfixturevalue(delivery_file_service) expected_delivery_files = request.getfixturevalue(expected_delivery_files) + sample_id = request.getfixturevalue(sample_id) # WHEN getting the files to deliver - delivery_files: DeliveryFiles = delivery_file_service.get_files_to_deliver(case_id) + delivery_files: DeliveryFiles = delivery_file_service.get_files_to_deliver( + case_id=case_id, sample_id=sample_id + ) # THEN assert that the files to deliver are fetched assert delivery_files == expected_delivery_files diff --git a/tests/services/file_delivery/file_filter/test_sample_filter_service.py b/tests/services/file_delivery/file_filter/test_sample_filter_service.py deleted file mode 100644 index 200f43c0ad..0000000000 --- a/tests/services/file_delivery/file_filter/test_sample_filter_service.py +++ /dev/null @@ -1,22 +0,0 @@ -from cg.services.deliver_files.file_fetcher.models import DeliveryFiles -from cg.services.deliver_files.file_filter.sample_service import SampleFileFilter - - -def test_filter_delivery_files(expected_fastq_delivery_files: DeliveryFiles, sample_id: str): - """Test to filter delivery files.""" - - # GIVEN a delivery files object with multiple sample ids and a filter delivery files service - filter_service = SampleFileFilter() - samples_ids: list[str] = [ - sample.sample_id for sample in expected_fastq_delivery_files.sample_files - ] - assert len(set(samples_ids)) > 1 - - # WHEN filtering the delivery files - filtered_delivery_files = filter_service.filter_delivery_files( - expected_fastq_delivery_files, sample_id - ) - - # THEN assert that the delivery files only contains the sample with the given sample id - for sample_file in filtered_delivery_files.sample_files: - assert sample_file.sample_id == sample_id From d41c4e3546769cc9197fe3f856f8640a45856166 Mon Sep 17 00:00:00 2001 From: ChristianOertlin Date: Wed, 11 Dec 2024 09:36:59 +0100 Subject: [PATCH 60/80] add mutant upload api (#4017) --- cg/cli/upload/base.py | 3 +++ cg/meta/upload/mutant/mutant.py | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+) create mode 100644 cg/meta/upload/mutant/mutant.py diff --git a/cg/cli/upload/base.py b/cg/cli/upload/base.py index 4bf46d6303..46ffa1ac0d 100644 --- a/cg/cli/upload/base.py +++ b/cg/cli/upload/base.py @@ -39,6 +39,7 @@ from cg.meta.upload.microsalt.microsalt_upload_api import MicrosaltUploadAPI from cg.meta.upload.mip.mip_dna import MipDNAUploadAPI from cg.meta.upload.mip.mip_rna import MipRNAUploadAPI +from cg.meta.upload.mutant.mutant import MutantUploadAPI from cg.meta.upload.nf_analysis import NfAnalysisUploadAPI from cg.meta.upload.tomte.tomte import TomteUploadAPI from cg.meta.upload.raredisease.raredisease import RarediseaseUploadAPI @@ -94,6 +95,8 @@ def upload(context: click.Context, case_id: str | None, restart: bool): Workflow.TAXPROFILER, }: upload_api = NfAnalysisUploadAPI(config_object, case.data_analysis) + elif case.data_analysis == Workflow.MUTANT: + upload_api = MutantUploadAPI(config_object) context.obj.meta_apis["upload_api"] = upload_api upload_api.upload(ctx=context, case=case, restart=restart) diff --git a/cg/meta/upload/mutant/mutant.py b/cg/meta/upload/mutant/mutant.py new file mode 100644 index 0000000000..1f08d72708 --- /dev/null +++ b/cg/meta/upload/mutant/mutant.py @@ -0,0 +1,19 @@ +from click import Context + +from cg.meta.upload.upload_api import UploadAPI +from cg.meta.workflow.mutant import MutantAnalysisAPI +from cg.models.cg_config import CGConfig +from cg.store.models import Analysis, Case + + +class MutantUploadAPI(UploadAPI): + + def __init__(self, config: CGConfig): + self.analysis_api: MutantAnalysisAPI = MutantAnalysisAPI(config) + super().__init__(config=config, analysis_api=self.analysis_api) + + def upload(self, ctx: Context, case: Case, restart: bool) -> None: + latest_analysis: Analysis = case.analyses[0] + self.update_upload_started_at(latest_analysis) + self.upload_files_to_customer_inbox(case) + self.update_uploaded_at(latest_analysis) From bb2f560b14be5f89f861b70b56bbe88fc16e72ef Mon Sep 17 00:00:00 2001 From: ChristianOertlin Date: Wed, 11 Dec 2024 11:16:49 +0100 Subject: [PATCH 61/80] refactor and document formatters (#4014) * refactoring * all tests pass * test passing * refactor tests * refactor factory * conflict * refactoring and documentation * unused import * cleanup * Update cg/services/deliver_files/file_formatter/component_files/case_service.py --- cg/cli/deliver/base.py | 2 +- cg/cli/deliver/utils.py | 2 +- cg/meta/upload/fohm/fohm.py | 4 +- cg/meta/upload/upload_api.py | 2 +- cg/models/cg_config.py | 2 +- cg/services/deliver_files/constants.py | 7 +- .../deliver_files_service.py | 28 ++-- ...er_files_service_factory.py => factory.py} | 120 ++++++++++++------ .../deliver_files/file_fetcher/models.py | 2 +- .../{utils => component_files}/__init__.py | 0 .../component_files/abstract.py | 15 +++ .../component_files/case_service.py | 77 +++++++++++ .../concatenation_service.py} | 52 ++++++-- .../{utils => component_files}/models.py | 0 .../mutant_service.py} | 53 ++++++-- .../component_files/sample_service.py | 68 ++++++++++ .../file_formatter/destination/__init__.py | 0 .../{ => destination}/abstract.py | 6 +- .../base_service.py} | 28 ++-- .../customer_inbox_service.py} | 31 +++-- .../{ => destination}/models.py | 0 .../file_formatter/path_name/__init__.py | 0 .../file_formatter/path_name/abstract.py | 13 ++ .../path_name/flat_structure.py | 24 ++++ .../path_name/nested_structure.py | 26 ++++ .../file_formatter/utils/case_service.py | 48 ------- .../file_formatter/utils/sample_service.py | 101 --------------- .../deliver_files/file_mover/abstract.py | 11 ++ .../deliver_files/file_mover/base_service.py | 30 +++++ ...les_mover.py => customer_inbox_service.py} | 37 +++--- .../file_mover/fohm_upload_files_mover.py | 26 ---- cg/services/deliver_files/utils.py | 38 +++++- .../delivery_files_models_fixtures.py | 9 +- .../delivery_formatted_files_fixtures.py | 4 +- .../delivery_services_fixtures.py | 29 ++--- tests/services/__init__.py | 0 .../test_service_builder.py | 24 ++-- .../file_delivery/file_formatter/__init__.py | 0 .../component_files/__init__.py | 0 .../test_formatter_utils.py | 110 ++++++---------- .../file_formatter/destination/__init__.py | 0 .../test_formatting_service.py | 12 +- .../path_name_formatters/__init__.py | 0 .../test_path_name_formatters.py | 54 ++++++++ .../file_mover/test_file_mover_service.py | 14 +- 45 files changed, 684 insertions(+), 425 deletions(-) rename cg/services/deliver_files/{deliver_files_service/deliver_files_service_factory.py => factory.py} (71%) rename cg/services/deliver_files/file_formatter/{utils => component_files}/__init__.py (100%) create mode 100644 cg/services/deliver_files/file_formatter/component_files/abstract.py create mode 100644 cg/services/deliver_files/file_formatter/component_files/case_service.py rename cg/services/deliver_files/file_formatter/{utils/sample_concatenation_service.py => component_files/concatenation_service.py} (84%) rename cg/services/deliver_files/file_formatter/{utils => component_files}/models.py (100%) rename cg/services/deliver_files/file_formatter/{utils/mutant_sample_service.py => component_files/mutant_service.py} (62%) create mode 100644 cg/services/deliver_files/file_formatter/component_files/sample_service.py create mode 100644 cg/services/deliver_files/file_formatter/destination/__init__.py rename cg/services/deliver_files/file_formatter/{ => destination}/abstract.py (59%) rename cg/services/deliver_files/file_formatter/{upload_file_formatter.py => destination/base_service.py} (69%) rename cg/services/deliver_files/file_formatter/{delivery_file_formatter.py => destination/customer_inbox_service.py} (68%) rename cg/services/deliver_files/file_formatter/{ => destination}/models.py (100%) create mode 100644 cg/services/deliver_files/file_formatter/path_name/__init__.py create mode 100644 cg/services/deliver_files/file_formatter/path_name/abstract.py create mode 100644 cg/services/deliver_files/file_formatter/path_name/flat_structure.py create mode 100644 cg/services/deliver_files/file_formatter/path_name/nested_structure.py delete mode 100644 cg/services/deliver_files/file_formatter/utils/case_service.py delete mode 100644 cg/services/deliver_files/file_formatter/utils/sample_service.py create mode 100644 cg/services/deliver_files/file_mover/abstract.py create mode 100644 cg/services/deliver_files/file_mover/base_service.py rename cg/services/deliver_files/file_mover/{delivery_files_mover.py => customer_inbox_service.py} (55%) delete mode 100644 cg/services/deliver_files/file_mover/fohm_upload_files_mover.py create mode 100644 tests/services/__init__.py create mode 100644 tests/services/file_delivery/file_formatter/__init__.py create mode 100644 tests/services/file_delivery/file_formatter/component_files/__init__.py rename tests/services/file_delivery/file_formatter/{utils => component_files}/test_formatter_utils.py (54%) create mode 100644 tests/services/file_delivery/file_formatter/destination/__init__.py rename tests/services/file_delivery/file_formatter/{ => destination}/test_formatting_service.py (84%) create mode 100644 tests/services/file_delivery/file_formatter/path_name_formatters/__init__.py create mode 100644 tests/services/file_delivery/file_formatter/path_name_formatters/test_path_name_formatters.py diff --git a/cg/cli/deliver/base.py b/cg/cli/deliver/base.py index 1e7e2505cb..8762c8c555 100644 --- a/cg/cli/deliver/base.py +++ b/cg/cli/deliver/base.py @@ -15,7 +15,7 @@ from cg.services.deliver_files.deliver_files_service.deliver_files_service import ( DeliverFilesService, ) -from cg.services.deliver_files.deliver_files_service.deliver_files_service_factory import ( +from cg.services.deliver_files.factory import ( DeliveryServiceFactory, ) from cg.services.deliver_files.rsync.service import DeliveryRsyncService diff --git a/cg/cli/deliver/utils.py b/cg/cli/deliver/utils.py index 97552efe18..f4b0040112 100644 --- a/cg/cli/deliver/utils.py +++ b/cg/cli/deliver/utils.py @@ -5,7 +5,7 @@ from cg.services.deliver_files.deliver_files_service.deliver_files_service import ( DeliverFilesService, ) -from cg.services.deliver_files.deliver_files_service.deliver_files_service_factory import ( +from cg.services.deliver_files.factory import ( DeliveryServiceFactory, ) from cg.store.models import Analysis, Case diff --git a/cg/meta/upload/fohm/fohm.py b/cg/meta/upload/fohm/fohm.py index 6e507113ad..a3ad426ea5 100644 --- a/cg/meta/upload/fohm/fohm.py +++ b/cg/meta/upload/fohm/fohm.py @@ -16,7 +16,7 @@ from cg.models.email import EmailInfo from cg.models.fohm.reports import FohmComplementaryReport, FohmPangolinReport from cg.services.deliver_files.constants import DeliveryDestination -from cg.services.deliver_files.deliver_files_service.deliver_files_service_factory import ( +from cg.services.deliver_files.factory import ( DeliveryServiceFactory, ) from cg.store.models import Case, Sample @@ -206,7 +206,7 @@ def link_sample_raw_data_files( delivery_service = self._delivery_factory.build_delivery_service( case=case, delivery_type=DataDelivery.FASTQ, - delivery_destination=DeliveryDestination.UPLOAD, + delivery_destination=DeliveryDestination.BASE, ) delivery_service.deliver_files_for_fohm_upload( case=case, sample_id=sample.internal_id, delivery_base_path=self.daily_rawdata_path diff --git a/cg/meta/upload/upload_api.py b/cg/meta/upload/upload_api.py index a02e90cd98..ce52c9d913 100644 --- a/cg/meta/upload/upload_api.py +++ b/cg/meta/upload/upload_api.py @@ -15,7 +15,7 @@ from cg.services.deliver_files.deliver_files_service.deliver_files_service import ( DeliverFilesService, ) -from cg.services.deliver_files.deliver_files_service.deliver_files_service_factory import ( +from cg.services.deliver_files.factory import ( DeliveryServiceFactory, ) from cg.store.models import Analysis, Case diff --git a/cg/models/cg_config.py b/cg/models/cg_config.py index 87ddee0e67..bab7e52a26 100644 --- a/cg/models/cg_config.py +++ b/cg/models/cg_config.py @@ -27,7 +27,7 @@ from cg.meta.delivery.delivery import DeliveryAPI from cg.services.analysis_service.analysis_service import AnalysisService from cg.services.decompression_service.decompressor import Decompressor -from cg.services.deliver_files.deliver_files_service.deliver_files_service_factory import ( +from cg.services.deliver_files.factory import ( DeliveryServiceFactory, ) from cg.services.deliver_files.rsync.models import RsyncDeliveryConfig diff --git a/cg/services/deliver_files/constants.py b/cg/services/deliver_files/constants.py index 913c71ceb2..0a86a00763 100644 --- a/cg/services/deliver_files/constants.py +++ b/cg/services/deliver_files/constants.py @@ -2,5 +2,10 @@ class DeliveryDestination(Enum): - UPLOAD = "upload" + """Enum for the DeliveryDestination + BASE: Deliver to the base folder provided in the call + CUSTOMER: Deliver to the customer folder on hasta + """ + + BASE = "base" CUSTOMER = "customer" diff --git a/cg/services/deliver_files/deliver_files_service/deliver_files_service.py b/cg/services/deliver_files/deliver_files_service/deliver_files_service.py index 5336d268ba..c315178307 100644 --- a/cg/services/deliver_files/deliver_files_service/deliver_files_service.py +++ b/cg/services/deliver_files/deliver_files_service/deliver_files_service.py @@ -1,5 +1,4 @@ import logging -import shutil from pathlib import Path from cg.apps.tb import TrailblazerAPI @@ -13,9 +12,11 @@ ) from cg.services.deliver_files.file_fetcher.abstract import FetchDeliveryFilesService from cg.services.deliver_files.file_fetcher.models import DeliveryFiles -from cg.services.deliver_files.file_formatter.abstract import DeliveryFileFormattingService -from cg.services.deliver_files.file_formatter.models import FormattedFiles -from cg.services.deliver_files.file_mover.delivery_files_mover import DeliveryFilesMover +from cg.services.deliver_files.file_formatter.destination.abstract import ( + DeliveryDestinationFormatter, +) +from cg.services.deliver_files.file_formatter.destination.models import FormattedFiles +from cg.services.deliver_files.file_mover.abstract import DestinationFilesMover from cg.services.deliver_files.rsync.service import DeliveryRsyncService from cg.store.exc import EntryNotFoundError from cg.store.models import Case @@ -37,8 +38,8 @@ class DeliverFilesService: def __init__( self, delivery_file_manager_service: FetchDeliveryFilesService, - move_file_service: DeliveryFilesMover, - file_formatter_service: DeliveryFileFormattingService, + move_file_service: DestinationFilesMover, + file_formatter_service: DeliveryDestinationFormatter, rsync_service: DeliveryRsyncService, tb_service: TrailblazerAPI, analysis_service: AnalysisService, @@ -64,7 +65,7 @@ def deliver_files_for_case( delivery_files=delivery_files, delivery_base_path=delivery_base_path ) formatted_files: FormattedFiles = self.file_formatter.format_files( - delivery_files=moved_files, delivery_path=delivery_base_path + delivery_files=moved_files ) for formatted_file in formatted_files.files: assert formatted_file.formatted_path.exists() @@ -99,7 +100,7 @@ def deliver_files_for_sample( delivery_files=delivery_files, delivery_base_path=delivery_base_path ) formatted_files: FormattedFiles = self.file_formatter.format_files( - delivery_files=moved_files, delivery_path=delivery_base_path + delivery_files=moved_files ) folders_to_deliver: set[Path] = set( [formatted_file.formatted_path.parent for formatted_file in formatted_files.files] @@ -118,17 +119,12 @@ def deliver_files_for_fohm_upload(self, case: Case, sample_id: str, delivery_bas delivery_base_path: The base path to deliver the files to """ delivery_files: DeliveryFiles = self.file_manager.get_files_to_deliver( - case_id=case.internal_id - ) - filtered_files: DeliveryFiles = self.file_filter.filter_delivery_files( - delivery_files=delivery_files, sample_id=sample_id + case_id=case.internal_id, sample_id=sample_id ) moved_files: DeliveryFiles = self.file_mover.move_files( - delivery_files=filtered_files, delivery_base_path=delivery_base_path - ) - self.file_formatter.format_files( - delivery_files=moved_files, delivery_path=delivery_base_path + delivery_files=delivery_files, delivery_base_path=delivery_base_path ) + self.file_formatter.format_files(delivery_files=moved_files) def _start_rsync_job(self, case: Case, dry_run: bool, folders_to_deliver: set[Path]) -> int: LOG.debug(f"[RSYNC] Starting rsync job for case {case.internal_id}") diff --git a/cg/services/deliver_files/deliver_files_service/deliver_files_service_factory.py b/cg/services/deliver_files/factory.py similarity index 71% rename from cg/services/deliver_files/deliver_files_service/deliver_files_service_factory.py rename to cg/services/deliver_files/factory.py index a6fd01ec33..47f0b91b48 100644 --- a/cg/services/deliver_files/deliver_files_service/deliver_files_service_factory.py +++ b/cg/services/deliver_files/factory.py @@ -19,22 +19,37 @@ ) from cg.services.deliver_files.file_fetcher.analysis_service import AnalysisDeliveryFileFetcher from cg.services.deliver_files.file_fetcher.raw_data_service import RawDataDeliveryFileFetcher -from cg.services.deliver_files.file_formatter.abstract import DeliveryFileFormattingService -from cg.services.deliver_files.file_formatter.delivery_file_formatter import DeliveryFileFormatter -from cg.services.deliver_files.file_formatter.upload_file_formatter import UploadFileFormatter -from cg.services.deliver_files.file_formatter.utils.case_service import CaseFileFormatter -from cg.services.deliver_files.file_formatter.utils.mutant_sample_service import MutantFileFormatter -from cg.services.deliver_files.file_formatter.utils.sample_concatenation_service import ( +from cg.services.deliver_files.file_formatter.destination.abstract import ( + DeliveryDestinationFormatter, +) +from cg.services.deliver_files.file_formatter.destination.customer_inbox_service import ( + CustomerInboxDeliveryFormatter, +) +from cg.services.deliver_files.file_formatter.destination.base_service import ( + BaseDeliveryFormatter, +) +from cg.services.deliver_files.file_formatter.component_files.case_service import CaseFileFormatter +from cg.services.deliver_files.file_formatter.component_files.mutant_service import ( + MutantFileFormatter, +) +from cg.services.deliver_files.file_formatter.component_files.concatenation_service import ( SampleFileConcatenationFormatter, ) -from cg.services.deliver_files.file_formatter.utils.sample_service import ( +from cg.services.deliver_files.file_formatter.component_files.sample_service import ( SampleFileFormatter, FileManager, - NestedSampleFileNameFormatter, - FlatSampleFileNameFormatter, ) -from cg.services.deliver_files.file_mover.delivery_files_mover import DeliveryFilesMover -from cg.services.deliver_files.file_mover.fohm_upload_files_mover import GenericFilesMover +from cg.services.deliver_files.file_formatter.path_name.abstract import PathNameFormatter +from cg.services.deliver_files.file_formatter.path_name.flat_structure import ( + FlatStructurePathFormatter, +) +from cg.services.deliver_files.file_formatter.path_name.nested_structure import ( + NestedStructurePathFormatter, +) +from cg.services.deliver_files.file_mover.customer_inbox_service import ( + CustomerInboxDestinationFilesMover, +) +from cg.services.deliver_files.file_mover.base_service import BaseDestinationFilesMover from cg.services.deliver_files.rsync.service import DeliveryRsyncService from cg.services.deliver_files.tag_fetcher.abstract import FetchDeliveryFileTagsService from cg.services.deliver_files.tag_fetcher.bam_service import BamDeliveryTagsFetcher @@ -88,7 +103,9 @@ def _sanitise_delivery_type(delivery_type: DataDelivery) -> DataDelivery: @staticmethod def _validate_delivery_type(delivery_type: DataDelivery): - """Check if the delivery type is supported. Raises DeliveryTypeNotSupported error.""" + """ + Check if the delivery type is supported. Raises DeliveryTypeNotSupported error. + """ if delivery_type in [ DataDelivery.FASTQ, DataDelivery.ANALYSIS_FILES, @@ -132,7 +149,10 @@ def _convert_workflow(self, case: Case) -> Workflow: """Converts a workflow with the introduction of the microbial-fastq delivery type an unsupported combination of delivery type and workflow setup is required. This function makes sure that a raw data workflow with microbial fastq delivery type is treated as a - microsalt workflow so that the microbial-fastq sample files can be concatenated.""" + microsalt workflow so that the microbial-fastq sample files can be concatenated. + args: + case: The case to convert the workflow for + """ tag: str = case.samples[0].application_version.application.tag microbial_tags: list[str] = [ application.tag @@ -149,67 +169,83 @@ def _get_sample_file_formatter( case: Case, delivery_destination: DeliveryDestination, ) -> SampleFileFormatter | SampleFileConcatenationFormatter | MutantFileFormatter: - """Get the file formatter service based on the workflow.""" + """Get the file formatter service based on the workflow. + Depending on the delivery destination the path name formatter will be different. + Args: + case: The case to deliver files for. + delivery_destination: The destination of the delivery defaults to customer. + """ + converted_workflow: Workflow = self._convert_workflow(case) if converted_workflow in [Workflow.MICROSALT]: return SampleFileConcatenationFormatter( file_manager=FileManager(), - file_formatter=NestedSampleFileNameFormatter(), + path_name_formatter=self._get_path_name_formatter(delivery_destination), concatenation_service=FastqConcatenationService(), ) if converted_workflow == Workflow.MUTANT: - if delivery_destination == DeliveryDestination.UPLOAD: - return MutantFileFormatter( - lims_api=self.lims_api, - file_manager=FileManager(), - file_formatter=SampleFileConcatenationFormatter( - file_manager=FileManager(), - file_formatter=FlatSampleFileNameFormatter(), - concatenation_service=FastqConcatenationService(), - ), - ) return MutantFileFormatter( lims_api=self.lims_api, file_manager=FileManager(), file_formatter=SampleFileConcatenationFormatter( file_manager=FileManager(), - file_formatter=NestedSampleFileNameFormatter(), + path_name_formatter=self._get_path_name_formatter(delivery_destination), concatenation_service=FastqConcatenationService(), ), ) return SampleFileFormatter( - file_manager=FileManager(), file_name_formatter=NestedSampleFileNameFormatter() + file_manager=FileManager(), + path_name_formatter=self._get_path_name_formatter(delivery_destination), ) + @staticmethod + def _get_path_name_formatter( + delivery_destination: DeliveryDestination, + ) -> PathNameFormatter: + """ + Get the path name formatter based on the delivery destination + Args: + delivery_destination: The destination of the . + """ + if delivery_destination == DeliveryDestination.BASE: + return FlatStructurePathFormatter() + return NestedStructurePathFormatter() + @staticmethod def _get_file_mover( delivery_destination: DeliveryDestination, - ) -> DeliveryFilesMover | GenericFilesMover: + ) -> CustomerInboxDestinationFilesMover | BaseDestinationFilesMover: """Get the file mover based on the delivery type. - Args: - delivery_destination: The destination of the delivery defaults to customer. + delivery_destination: The destination of the delivery. """ - if delivery_destination == DeliveryDestination.UPLOAD: - return GenericFilesMover(FileMover(FileManager())) - return DeliveryFilesMover(FileMover(FileManager())) + if delivery_destination == DeliveryDestination.BASE: + return BaseDestinationFilesMover(FileMover(FileManager())) + return CustomerInboxDestinationFilesMover(FileMover(FileManager())) def _get_file_formatter( self, delivery_destination: DeliveryDestination, case: Case, - ) -> DeliveryFileFormattingService: + ) -> DeliveryDestinationFormatter: """Get the file formatter service based on the delivery destination.""" sample_file_formatter: ( SampleFileFormatter | SampleFileConcatenationFormatter | MutantFileFormatter ) = self._get_sample_file_formatter(case=case, delivery_destination=delivery_destination) - if delivery_destination == DeliveryDestination.UPLOAD: - return UploadFileFormatter( - case_file_formatter=CaseFileFormatter(), + if delivery_destination == DeliveryDestination.BASE: + return BaseDeliveryFormatter( + case_file_formatter=CaseFileFormatter( + file_manager=FileManager(), + path_name_formatter=self._get_path_name_formatter(delivery_destination), + ), sample_file_formatter=sample_file_formatter, ) - return DeliveryFileFormatter( - case_file_formatter=CaseFileFormatter(), sample_file_formatter=sample_file_formatter + return CustomerInboxDeliveryFormatter( + case_file_formatter=CaseFileFormatter( + file_manager=FileManager(), + path_name_formatter=self._get_path_name_formatter(delivery_destination), + ), + sample_file_formatter=sample_file_formatter, ) def build_delivery_service( @@ -230,10 +266,10 @@ def build_delivery_service( ) self._validate_delivery_type(delivery_type) file_fetcher: FetchDeliveryFilesService = self._get_file_fetcher(delivery_type) - file_move_service: DeliveryFilesMover | GenericFilesMover = self._get_file_mover( - delivery_destination=delivery_destination + file_move_service: CustomerInboxDestinationFilesMover | BaseDestinationFilesMover = ( + self._get_file_mover(delivery_destination=delivery_destination) ) - file_formatter: DeliveryFileFormattingService = self._get_file_formatter( + file_formatter: DeliveryDestinationFormatter = self._get_file_formatter( case=case, delivery_destination=delivery_destination ) return DeliverFilesService( diff --git a/cg/services/deliver_files/file_fetcher/models.py b/cg/services/deliver_files/file_fetcher/models.py index ef38780862..f22a491d43 100644 --- a/cg/services/deliver_files/file_fetcher/models.py +++ b/cg/services/deliver_files/file_fetcher/models.py @@ -7,7 +7,7 @@ class DeliveryMetaData(BaseModel): case_id: str customer_internal_id: str ticket_id: str - customer_ticket_inbox: Path | None = None + delivery_path: Path | None = None class CaseFile(BaseModel): diff --git a/cg/services/deliver_files/file_formatter/utils/__init__.py b/cg/services/deliver_files/file_formatter/component_files/__init__.py similarity index 100% rename from cg/services/deliver_files/file_formatter/utils/__init__.py rename to cg/services/deliver_files/file_formatter/component_files/__init__.py diff --git a/cg/services/deliver_files/file_formatter/component_files/abstract.py b/cg/services/deliver_files/file_formatter/component_files/abstract.py new file mode 100644 index 0000000000..cad774286f --- /dev/null +++ b/cg/services/deliver_files/file_formatter/component_files/abstract.py @@ -0,0 +1,15 @@ +from abc import abstractmethod, ABC +from pathlib import Path + +from cg.services.deliver_files.file_fetcher.models import SampleFile, CaseFile +from cg.services.deliver_files.file_formatter.destination.models import FormattedFile + + +class ComponentFormatter(ABC): + + @abstractmethod + def format_files( + self, moved_files: list[CaseFile | SampleFile], delivery_path: Path + ) -> list[FormattedFile]: + """Format the files to deliver.""" + pass diff --git a/cg/services/deliver_files/file_formatter/component_files/case_service.py b/cg/services/deliver_files/file_formatter/component_files/case_service.py new file mode 100644 index 0000000000..d9d55344eb --- /dev/null +++ b/cg/services/deliver_files/file_formatter/component_files/case_service.py @@ -0,0 +1,77 @@ +import logging +from pathlib import Path + +from cg.services.deliver_files.file_fetcher.models import CaseFile +from cg.services.deliver_files.file_formatter.component_files.abstract import ComponentFormatter +from cg.services.deliver_files.file_formatter.destination.models import FormattedFile +from cg.services.deliver_files.file_formatter.path_name.abstract import PathNameFormatter +from cg.services.deliver_files.file_formatter.path_name.nested_structure import ( + NestedStructurePathFormatter, +) +from cg.services.deliver_files.utils import FileManager + +LOG = logging.getLogger(__name__) + + +class CaseFileFormatter(ComponentFormatter): + + def __init__( + self, + path_name_formatter: PathNameFormatter, + file_manager: FileManager, + ): + self.path_name_formatter = path_name_formatter + self.file_manager = file_manager + + def format_files(self, moved_files: list[CaseFile], delivery_path: Path) -> list[FormattedFile]: + """Format the case files to deliver and return the formatted files. + args: + moved_files: The case files to format + delivery_path: The path to deliver the files to + """ + LOG.debug("[FORMAT SERVICE] Formatting case files") + self._create_case_name_folder( + delivery_path=delivery_path, case_name=moved_files[0].case_name + ) + return self._format_case_files(moved_files) + + def _format_case_files(self, case_files: list[CaseFile]) -> list[FormattedFile]: + """Format the case files to deliver and return the formatted files. + args: + case_files: The case files to format + """ + formatted_files: list[FormattedFile] = self._get_formatted_paths(case_files) + for formatted_file in formatted_files: + self.file_manager.rename_file( + src=formatted_file.original_path, dst=formatted_file.formatted_path + ) + return formatted_files + + def _create_case_name_folder(self, delivery_path: Path, case_name: str) -> None: + """ + Create a folder for the case in the delivery path. + The folder is only created if the provided PathStructureFormatter is a NestedStructurePathFormatter. + args: + delivery_path: The path to deliver the files to + case_name: The name of the case + """ + LOG.debug(f"[FORMAT SERVICE] Creating folder for case: {case_name}") + if isinstance(self.path_name_formatter, NestedStructurePathFormatter): + self.file_manager.create_directories(base_path=delivery_path, directories={case_name}) + + def _get_formatted_paths(self, case_files: list[CaseFile]) -> list[FormattedFile]: + """Return a list of formatted case files. + args: + case_files: The case files to format + """ + formatted_files: list[FormattedFile] = [] + for case_file in case_files: + formatted_path = self.path_name_formatter.format_file_path( + file_path=case_file.file_path, + provided_id=case_file.case_id, + provided_name=case_file.case_name, + ) + formatted_files.append( + FormattedFile(original_path=case_file.file_path, formatted_path=formatted_path) + ) + return formatted_files diff --git a/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py b/cg/services/deliver_files/file_formatter/component_files/concatenation_service.py similarity index 84% rename from cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py rename to cg/services/deliver_files/file_formatter/component_files/concatenation_service.py index 2eca16f581..6652165a13 100644 --- a/cg/services/deliver_files/file_formatter/utils/sample_concatenation_service.py +++ b/cg/services/deliver_files/file_formatter/component_files/concatenation_service.py @@ -1,25 +1,28 @@ import logging from pathlib import Path from cg.constants.constants import ReadDirection, FileFormat, FileExtensions -from cg.services.deliver_files.file_formatter.utils.models import FastqFile +from cg.services.deliver_files.file_formatter.component_files.abstract import ComponentFormatter +from cg.services.deliver_files.file_formatter.component_files.models import FastqFile +from cg.services.deliver_files.file_formatter.path_name.abstract import PathNameFormatter from cg.services.fastq_concatenation_service.fastq_concatenation_service import ( FastqConcatenationService, ) from cg.services.fastq_concatenation_service.utils import generate_concatenated_fastq_delivery_path from cg.services.deliver_files.file_fetcher.models import SampleFile -from cg.services.deliver_files.file_formatter.models import FormattedFile -from cg.services.deliver_files.file_formatter.utils.sample_service import ( - NestedSampleFileNameFormatter, +from cg.services.deliver_files.file_formatter.destination.models import FormattedFile +from cg.services.deliver_files.file_formatter.component_files.sample_service import ( FileManager, - FlatSampleFileNameFormatter, +) +from cg.services.deliver_files.file_formatter.path_name.nested_structure import ( + NestedStructurePathFormatter, ) from cg.utils.files import get_all_files_in_directory_tree LOG = logging.getLogger(__name__) -class SampleFileConcatenationFormatter: +class SampleFileConcatenationFormatter(ComponentFormatter): """ Format the sample files to deliver, concatenate fastq files and return the formatted files. Used for workflows: Microsalt. @@ -28,11 +31,11 @@ class SampleFileConcatenationFormatter: def __init__( self, file_manager: FileManager, - file_formatter: NestedSampleFileNameFormatter | FlatSampleFileNameFormatter, + path_name_formatter: PathNameFormatter, concatenation_service: FastqConcatenationService, ): self.file_manager = file_manager - self.file_name_formatter = file_formatter + self.path_name_formatter = path_name_formatter self.concatenation_service = concatenation_service def format_files( @@ -42,15 +45,13 @@ def format_files( Format the sample files to deliver, concatenate fastq files and return the formatted files. args: moved_files: list[SampleFile]: List of sample files to deliver. + These are files that have been moved from housekeeper to the delivery path. delivery_path: Path: Path to the delivery directory. """ LOG.debug("[FORMAT SERVICE] Formatting and concatenating sample files") - LOG.debug(f"[FORMAT SERVICE] delivery_path: {delivery_path}") - sample_names: set[str] = self.file_name_formatter.get_sample_names(sample_files=moved_files) + sample_names: set[str] = self._get_sample_names(sample_files=moved_files) self._create_sample_directories(delivery_path=delivery_path, sample_names=sample_names) - formatted_files: list[FormattedFile] = self.file_name_formatter.format_sample_file_names( - sample_files=moved_files - ) + formatted_files: list[FormattedFile] = self._format_sample_file_paths(moved_files) LOG.debug( f"[FORMAT SERVICE] number of formatted files: {len(formatted_files)}, number of moved files: {len(moved_files)}" ) @@ -66,6 +67,29 @@ def format_files( ) return formatted_files + @staticmethod + def _get_sample_names(sample_files: list[SampleFile]) -> set[str]: + """Extract sample names from the sample files.""" + return {sample_file.sample_name for sample_file in sample_files} + + def _format_sample_file_paths(self, sample_files: list[SampleFile]) -> list[FormattedFile]: + """ + Return a list of formatted sample files. + args: + sample_files: The sample files to format + """ + return [ + FormattedFile( + original_path=sample_file.file_path, + formatted_path=self.path_name_formatter.format_file_path( + file_path=sample_file.file_path, + provided_id=sample_file.sample_id, + provided_name=sample_file.sample_name, + ), + ) + for sample_file in sample_files + ] + def _rename_original_files(self, formatted_files: list[FormattedFile]) -> None: """ Rename the formatted files. @@ -84,7 +108,7 @@ def _create_sample_directories(self, sample_names: set[str], delivery_path: Path sample_names: set[str]: Set of sample names. delivery_path: Path: Path to the delivery directory. """ - if not isinstance(self.file_name_formatter, NestedSampleFileNameFormatter): + if not isinstance(self.path_name_formatter, NestedStructurePathFormatter): return for sample_name in sample_names: self.file_manager.create_directories(base_path=delivery_path, directories={sample_name}) diff --git a/cg/services/deliver_files/file_formatter/utils/models.py b/cg/services/deliver_files/file_formatter/component_files/models.py similarity index 100% rename from cg/services/deliver_files/file_formatter/utils/models.py rename to cg/services/deliver_files/file_formatter/component_files/models.py diff --git a/cg/services/deliver_files/file_formatter/utils/mutant_sample_service.py b/cg/services/deliver_files/file_formatter/component_files/mutant_service.py similarity index 62% rename from cg/services/deliver_files/file_formatter/utils/mutant_sample_service.py rename to cg/services/deliver_files/file_formatter/component_files/mutant_service.py index c25009bfed..81ae9da6af 100644 --- a/cg/services/deliver_files/file_formatter/utils/mutant_sample_service.py +++ b/cg/services/deliver_files/file_formatter/component_files/mutant_service.py @@ -3,16 +3,21 @@ from cg.apps.lims import LimsAPI from cg.services.deliver_files.file_fetcher.models import SampleFile -from cg.services.deliver_files.file_formatter.models import FormattedFile -from cg.services.deliver_files.file_formatter.utils.sample_concatenation_service import ( +from cg.services.deliver_files.file_formatter.component_files.abstract import ComponentFormatter +from cg.services.deliver_files.file_formatter.destination.models import FormattedFile +from cg.services.deliver_files.file_formatter.component_files.concatenation_service import ( SampleFileConcatenationFormatter, ) -from cg.services.deliver_files.file_formatter.utils.sample_service import FileManager +from cg.services.deliver_files.file_formatter.component_files.sample_service import FileManager LOG = logging.getLogger(__name__) -class MutantFileFormatter: +class MutantFileFormatter(ComponentFormatter): + """ + Formatter for file to deliver or upload for the Mutant workflow. + """ + def __init__( self, lims_api: LimsAPI, @@ -26,10 +31,17 @@ def __init__( def format_files( self, moved_files: list[SampleFile], delivery_path: Path ) -> list[FormattedFile]: + """ + Format the mutant files to deliver and return the formatted files. + args: + moved_files: The sample files to format + delivery_path: The path to deliver the files + + """ + LOG.debug("[FORMAT SERVICE] Formatting and concatenating mutant files") formatted_files: list[FormattedFile] = self.file_formatter.format_files( moved_files=moved_files, delivery_path=delivery_path ) - LOG.debug("[FORMAT SERVICE] Formatting and concatenating mutant files") appended_formatted_files: list[FormattedFile] = self._add_lims_metadata_to_file_name( formatted_files=formatted_files, sample_files=moved_files ) @@ -45,7 +57,14 @@ def format_files( def _add_lims_metadata_to_file_name( self, formatted_files: list[FormattedFile], sample_files: list[SampleFile] ) -> list[FormattedFile]: - """This functions adds the region and lab code to the file name of the formatted files.""" + """ + This functions adds the region and lab code to the file name of the formatted files. + Note: The region and lab code is fetched from LIMS using the sample id. It is required for delivery of the files. + + args: + formatted_files: The formatted files to add the metadata to + sample_files: The sample files to get the metadata from + """ appended_formatted_files: list[FormattedFile] = [] for formatted_file in formatted_files: sample_id: str = self._get_sample_id_by_original_path( @@ -65,6 +84,11 @@ def _add_lims_metadata_to_file_name( @staticmethod def _get_sample_id_by_original_path(original_path: Path, sample_files: list[SampleFile]) -> str: + """Get the sample id by the original path of the sample file. + args: + original_path: The original path of the sample file + sample_files: The list of sample files to search in + """ for sample_file in sample_files: if sample_file.file_path == original_path: return sample_file.sample_id @@ -75,11 +99,18 @@ def _filter_unique_path_combinations( formatted_files: list[FormattedFile], ) -> list[FormattedFile]: """ - During fastq concatenation Sample_R1 and Sample_R2 files are concatenated and moved to the same file Concat_Sample. - This mean that there can be multiple entries for the same concatenated file in the formatted_files list coming - from the SampleFileConcatenationService. - This function filters out the duplicates to avoid moving the same file multiple times - which would result in an error the second time since the files is no longer in the original path. + Filter out duplicates from the formatted files list. + + note: + During fastq concatenation Sample_R1 and Sample_R2 files are concatenated + and moved to the same file Concat_Sample. This mean that there can be multiple entries + for the same concatenated file in the formatted_files list + coming from the SampleFileConcatenationService. + This function filters out the duplicates to avoid moving the same file multiple times + which would result in an error the second time since the files is no longer in the original path. + + args: + formatted_files: The formatted files to filter """ unique_combinations = set() unique_files: list[FormattedFile] = [] diff --git a/cg/services/deliver_files/file_formatter/component_files/sample_service.py b/cg/services/deliver_files/file_formatter/component_files/sample_service.py new file mode 100644 index 0000000000..7557766c22 --- /dev/null +++ b/cg/services/deliver_files/file_formatter/component_files/sample_service.py @@ -0,0 +1,68 @@ +import logging +from pathlib import Path + +from cg.services.deliver_files.file_fetcher.models import SampleFile +from cg.services.deliver_files.file_formatter.component_files.abstract import ComponentFormatter +from cg.services.deliver_files.file_formatter.destination.models import FormattedFile +from cg.services.deliver_files.file_formatter.path_name.abstract import PathNameFormatter +from cg.services.deliver_files.utils import FileManager + +LOG = logging.getLogger(__name__) + + +class SampleFileFormatter(ComponentFormatter): + """ + Format the sample files to deliver. + Used for all workflows except Microsalt and Mutant. + """ + + def __init__( + self, + file_manager: FileManager, + path_name_formatter: PathNameFormatter, + ): + self.file_manager = file_manager + self.path_name_formatter = path_name_formatter + + def format_files( + self, moved_files: list[SampleFile], delivery_path: Path + ) -> list[FormattedFile]: + """ + Format the sample files to deliver and return the formatted files. + args: + moved_sample_files: The sample files to format. These are files that have been moved from housekeeper to the delivery path. + delivery_path: The path to deliver the files to + """ + LOG.debug("[FORMAT SERVICE] Formatting sample files") + sample_names: set[str] = self._get_sample_names(sample_files=moved_files) + for sample_name in sample_names: + self.file_manager.create_directories(base_path=delivery_path, directories={sample_name}) + formatted_files: list[FormattedFile] = self._format_sample_file_paths(moved_files) + for formatted_file in formatted_files: + self.file_manager.rename_file( + src=formatted_file.original_path, dst=formatted_file.formatted_path + ) + return formatted_files + + @staticmethod + def _get_sample_names(sample_files: list[SampleFile]) -> set[str]: + """Extract sample names from the sample files.""" + return {sample_file.sample_name for sample_file in sample_files} + + def _format_sample_file_paths(self, sample_files: list[SampleFile]) -> list[FormattedFile]: + """ + Return a list of formatted sample files. + args: + sample_files: The sample files to format + """ + return [ + FormattedFile( + original_path=sample_file.file_path, + formatted_path=self.path_name_formatter.format_file_path( + file_path=sample_file.file_path, + provided_id=sample_file.sample_id, + provided_name=sample_file.sample_name, + ), + ) + for sample_file in sample_files + ] diff --git a/cg/services/deliver_files/file_formatter/destination/__init__.py b/cg/services/deliver_files/file_formatter/destination/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cg/services/deliver_files/file_formatter/abstract.py b/cg/services/deliver_files/file_formatter/destination/abstract.py similarity index 59% rename from cg/services/deliver_files/file_formatter/abstract.py rename to cg/services/deliver_files/file_formatter/destination/abstract.py index e6f4f473eb..559f553e55 100644 --- a/cg/services/deliver_files/file_formatter/abstract.py +++ b/cg/services/deliver_files/file_formatter/destination/abstract.py @@ -2,15 +2,15 @@ from pathlib import Path from cg.services.deliver_files.file_fetcher.models import DeliveryFiles -from cg.services.deliver_files.file_formatter.models import FormattedFiles +from cg.services.deliver_files.file_formatter.destination.models import FormattedFiles -class DeliveryFileFormattingService(ABC): +class DeliveryDestinationFormatter(ABC): """ Abstract class that encapsulates the logic required for formatting files to deliver. """ @abstractmethod - def format_files(self, delivery_files: DeliveryFiles, delivery_path: Path) -> FormattedFiles: + def format_files(self, delivery_files: DeliveryFiles) -> FormattedFiles: """Format the files to deliver.""" pass diff --git a/cg/services/deliver_files/file_formatter/upload_file_formatter.py b/cg/services/deliver_files/file_formatter/destination/base_service.py similarity index 69% rename from cg/services/deliver_files/file_formatter/upload_file_formatter.py rename to cg/services/deliver_files/file_formatter/destination/base_service.py index 86b86ab764..234dffcdc5 100644 --- a/cg/services/deliver_files/file_formatter/upload_file_formatter.py +++ b/cg/services/deliver_files/file_formatter/destination/base_service.py @@ -1,21 +1,29 @@ import logging -import os from pathlib import Path from cg.services.deliver_files.file_fetcher.models import CaseFile, DeliveryFiles, SampleFile -from cg.services.deliver_files.file_formatter.abstract import DeliveryFileFormattingService -from cg.services.deliver_files.file_formatter.models import FormattedFile, FormattedFiles -from cg.services.deliver_files.file_formatter.utils.case_service import CaseFileFormatter -from cg.services.deliver_files.file_formatter.utils.mutant_sample_service import MutantFileFormatter -from cg.services.deliver_files.file_formatter.utils.sample_concatenation_service import ( +from cg.services.deliver_files.file_formatter.destination.abstract import ( + DeliveryDestinationFormatter, +) +from cg.services.deliver_files.file_formatter.destination.models import ( + FormattedFile, + FormattedFiles, +) +from cg.services.deliver_files.file_formatter.component_files.case_service import CaseFileFormatter +from cg.services.deliver_files.file_formatter.component_files.mutant_service import ( + MutantFileFormatter, +) +from cg.services.deliver_files.file_formatter.component_files.concatenation_service import ( SampleFileConcatenationFormatter, ) -from cg.services.deliver_files.file_formatter.utils.sample_service import SampleFileFormatter +from cg.services.deliver_files.file_formatter.component_files.sample_service import ( + SampleFileFormatter, +) LOG = logging.getLogger(__name__) -class UploadFileFormatter(DeliveryFileFormattingService): +class BaseDeliveryFormatter(DeliveryDestinationFormatter): """ Format the files to be delivered in the generic format. Expected structure: @@ -33,13 +41,13 @@ def __init__( self.case_file_formatter = case_file_formatter self.sample_file_formatter = sample_file_formatter - def format_files(self, delivery_files: DeliveryFiles, delivery_path: Path) -> FormattedFiles: + def format_files(self, delivery_files: DeliveryFiles) -> FormattedFiles: """Format the files to be delivered and return the formatted files in the generic format.""" LOG.debug("[FORMAT SERVICE] Formatting files for Upload") formatted_files: list[FormattedFile] = self._format_sample_and_case_files( sample_files=delivery_files.sample_files, case_files=delivery_files.case_files, - delivery_path=delivery_path, + delivery_path=delivery_files.delivery_data.delivery_path, ) return FormattedFiles(files=formatted_files) diff --git a/cg/services/deliver_files/file_formatter/delivery_file_formatter.py b/cg/services/deliver_files/file_formatter/destination/customer_inbox_service.py similarity index 68% rename from cg/services/deliver_files/file_formatter/delivery_file_formatter.py rename to cg/services/deliver_files/file_formatter/destination/customer_inbox_service.py index cab479049d..84cde3094d 100644 --- a/cg/services/deliver_files/file_formatter/delivery_file_formatter.py +++ b/cg/services/deliver_files/file_formatter/destination/customer_inbox_service.py @@ -1,21 +1,29 @@ import logging -import os from pathlib import Path from cg.services.deliver_files.file_fetcher.models import CaseFile, DeliveryFiles, SampleFile -from cg.services.deliver_files.file_formatter.abstract import DeliveryFileFormattingService -from cg.services.deliver_files.file_formatter.models import FormattedFile, FormattedFiles -from cg.services.deliver_files.file_formatter.utils.case_service import CaseFileFormatter -from cg.services.deliver_files.file_formatter.utils.mutant_sample_service import MutantFileFormatter -from cg.services.deliver_files.file_formatter.utils.sample_concatenation_service import ( +from cg.services.deliver_files.file_formatter.destination.abstract import ( + DeliveryDestinationFormatter, +) +from cg.services.deliver_files.file_formatter.destination.models import ( + FormattedFile, + FormattedFiles, +) +from cg.services.deliver_files.file_formatter.component_files.case_service import CaseFileFormatter +from cg.services.deliver_files.file_formatter.component_files.mutant_service import ( + MutantFileFormatter, +) +from cg.services.deliver_files.file_formatter.component_files.concatenation_service import ( SampleFileConcatenationFormatter, ) -from cg.services.deliver_files.file_formatter.utils.sample_service import SampleFileFormatter +from cg.services.deliver_files.file_formatter.component_files.sample_service import ( + SampleFileFormatter, +) LOG = logging.getLogger(__name__) -class DeliveryFileFormatter(DeliveryFileFormattingService): +class CustomerInboxDeliveryFormatter(DeliveryDestinationFormatter): """ Format the files to be delivered in the customer inbox format. Expected structure: @@ -33,16 +41,13 @@ def __init__( self.case_file_formatter = case_file_formatter self.sample_file_formatter = sample_file_formatter - def format_files( - self, delivery_files: DeliveryFiles, delivery_path: Path = None - ) -> FormattedFiles: + def format_files(self, delivery_files: DeliveryFiles) -> FormattedFiles: """Format the files to be delivered and return the formatted files in the customer inbox format.""" LOG.debug("[FORMAT SERVICE] Formatting files for delivery") - ticket_dir_path: Path = delivery_files.delivery_data.customer_ticket_inbox formatted_files: list[FormattedFile] = self._format_sample_and_case_files( sample_files=delivery_files.sample_files, case_files=delivery_files.case_files, - ticket_dir_path=ticket_dir_path, + ticket_dir_path=delivery_files.delivery_data.delivery_path, ) return FormattedFiles(files=formatted_files) diff --git a/cg/services/deliver_files/file_formatter/models.py b/cg/services/deliver_files/file_formatter/destination/models.py similarity index 100% rename from cg/services/deliver_files/file_formatter/models.py rename to cg/services/deliver_files/file_formatter/destination/models.py diff --git a/cg/services/deliver_files/file_formatter/path_name/__init__.py b/cg/services/deliver_files/file_formatter/path_name/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cg/services/deliver_files/file_formatter/path_name/abstract.py b/cg/services/deliver_files/file_formatter/path_name/abstract.py new file mode 100644 index 0000000000..5fc0409843 --- /dev/null +++ b/cg/services/deliver_files/file_formatter/path_name/abstract.py @@ -0,0 +1,13 @@ +from abc import abstractmethod, ABC +from pathlib import Path + + +class PathNameFormatter(ABC): + """ + Abstract class that encapsulates the logic required for formatting the path name. + """ + + @abstractmethod + def format_file_path(self, file_path: Path, provided_id: str, provided_name: str) -> Path: + """Format the file path.""" + pass diff --git a/cg/services/deliver_files/file_formatter/path_name/flat_structure.py b/cg/services/deliver_files/file_formatter/path_name/flat_structure.py new file mode 100644 index 0000000000..5be3a5f391 --- /dev/null +++ b/cg/services/deliver_files/file_formatter/path_name/flat_structure.py @@ -0,0 +1,24 @@ +from pathlib import Path + +from cg.services.deliver_files.file_formatter.component_files.sample_service import LOG +from cg.services.deliver_files.file_formatter.path_name.abstract import PathNameFormatter + + +class FlatStructurePathFormatter(PathNameFormatter): + """ + Class to format sample file names in place. + """ + + def format_file_path(self, file_path: Path, provided_id: str, provided_name: str) -> Path: + """ + Returns formatted files with original and formatted file names: + Replaces id by name. + args: + file_path: The path to the file + provided_id: The id to replace + provided_name: The name to replace the id with + """ + LOG.debug("[FORMAT SERVICE] Formatting sample file names with flat structure.") + replaced_name = file_path.name.replace(provided_id, provided_name) + formatted_path = Path(file_path.parent, replaced_name) + return formatted_path diff --git a/cg/services/deliver_files/file_formatter/path_name/nested_structure.py b/cg/services/deliver_files/file_formatter/path_name/nested_structure.py new file mode 100644 index 0000000000..66a2e61b1b --- /dev/null +++ b/cg/services/deliver_files/file_formatter/path_name/nested_structure.py @@ -0,0 +1,26 @@ +from pathlib import Path + +from cg.services.deliver_files.file_formatter.component_files.sample_service import LOG +from cg.services.deliver_files.file_formatter.path_name.abstract import PathNameFormatter + + +class NestedStructurePathFormatter(PathNameFormatter): + """ + Class to format sample file names and paths in a nested format used to deliver files to a customer inbox. + """ + + def format_file_path(self, file_path: Path, provided_id: str, provided_name: str) -> Path: + """ + Returns formatted files with original and formatted file names: + 1. Adds a folder with provided name to the path of the files. + 2. Replaces id by name. + + args: + file_path: The path to the file + provided_id: The id to replace + provided_name: The name to replace the id with + """ + LOG.debug("[FORMAT SERVICE] Formatting sample file names with nested structure.") + replaced_name = file_path.name.replace(provided_id, provided_name) + formatted_path = Path(file_path.parent, provided_name, replaced_name) + return formatted_path diff --git a/cg/services/deliver_files/file_formatter/utils/case_service.py b/cg/services/deliver_files/file_formatter/utils/case_service.py deleted file mode 100644 index fe9861708f..0000000000 --- a/cg/services/deliver_files/file_formatter/utils/case_service.py +++ /dev/null @@ -1,48 +0,0 @@ -import logging -import os -from pathlib import Path - -from cg.services.deliver_files.file_fetcher.models import CaseFile -from cg.services.deliver_files.file_formatter.models import FormattedFile - -LOG = logging.getLogger(__name__) - - -class CaseFileFormatter: - - def format_files(self, moved_files: list[CaseFile], delivery_path: Path) -> list[FormattedFile]: - LOG.debug("[FORMAT SERVICE] Formatting case files") - """Format the case files to deliver and return the formatted files.""" - self._create_case_name_folder(ticket_path=delivery_path, case_name=moved_files[0].case_name) - return self._format_case_files(moved_files) - - def _format_case_files(self, case_files: list[CaseFile]) -> list[FormattedFile]: - formatted_files: list[FormattedFile] = self._get_formatted_files(case_files) - for formatted_file in formatted_files: - os.rename(src=formatted_file.original_path, dst=formatted_file.formatted_path) - return formatted_files - - @staticmethod - def _create_case_name_folder(ticket_path: Path, case_name: str) -> None: - case_dir_path = Path(ticket_path, case_name) - case_dir_path.mkdir(exist_ok=True) - - @staticmethod - def _get_formatted_files(case_files: list[CaseFile]) -> list[FormattedFile]: - """ - Returns formatted files: - 1. Adds a folder with case name to the path of the case files. - 2. Replaces case id by case name. - """ - formatted_files: list[FormattedFile] = [] - for case_file in case_files: - replaced_case_file_name: str = case_file.file_path.name.replace( - case_file.case_id, case_file.case_name - ) - formatted_file_path = Path( - case_file.file_path.parent, case_file.case_name, replaced_case_file_name - ) - formatted_files.append( - FormattedFile(original_path=case_file.file_path, formatted_path=formatted_file_path) - ) - return formatted_files diff --git a/cg/services/deliver_files/file_formatter/utils/sample_service.py b/cg/services/deliver_files/file_formatter/utils/sample_service.py deleted file mode 100644 index 049ddb54cf..0000000000 --- a/cg/services/deliver_files/file_formatter/utils/sample_service.py +++ /dev/null @@ -1,101 +0,0 @@ -import logging -from pathlib import Path - -from cg.services.deliver_files.file_fetcher.models import SampleFile -from cg.services.deliver_files.file_formatter.models import FormattedFile -from cg.services.deliver_files.utils import FileManager - -LOG = logging.getLogger(__name__) - - -class NestedSampleFileNameFormatter: - """ - Class to format sample file names and paths in a nested format used to deliver files to a customer inbox. - """ - - @staticmethod - def get_sample_names(sample_files: list[SampleFile]) -> set[str]: - """Extract sample names from the sample files.""" - return {sample_file.sample_name for sample_file in sample_files} - - @staticmethod - def format_sample_file_names(sample_files: list[SampleFile]) -> list[FormattedFile]: - """ - Returns formatted files with original and formatted file names: - 1. Adds a folder with sample name to the path of the sample files. - 2. Replaces sample id by sample name. - """ - LOG.debug("[FORMAT SERVICE] Formatting sample file names with nested structure.") - formatted_files = [] - for sample_file in sample_files: - replaced_name = sample_file.file_path.name.replace( - sample_file.sample_id, sample_file.sample_name - ) - formatted_path = Path( - sample_file.file_path.parent, sample_file.sample_name, replaced_name - ) - formatted_files.append( - FormattedFile(original_path=sample_file.file_path, formatted_path=formatted_path) - ) - return formatted_files - - -class FlatSampleFileNameFormatter: - """ - Class to format sample file names in place. - """ - - @staticmethod - def get_sample_names(sample_files: list[SampleFile]) -> set[str]: - """Extract sample names from the sample files.""" - return {sample_file.sample_name for sample_file in sample_files} - - @staticmethod - def format_sample_file_names(sample_files: list[SampleFile]) -> list[FormattedFile]: - """ - Returns formatted files with original and formatted file names: - Replaces sample id by sample name. - """ - LOG.debug("[FORMAT SERVICE] Formatting sample file names with flat structure.") - formatted_files = [] - for sample_file in sample_files: - replaced_name = sample_file.file_path.name.replace( - sample_file.sample_id, sample_file.sample_name - ) - formatted_path = Path(sample_file.file_path.parent, replaced_name) - formatted_files.append( - FormattedFile(original_path=sample_file.file_path, formatted_path=formatted_path) - ) - return formatted_files - - -class SampleFileFormatter: - """ - Format the sample files to deliver. - Used for all workflows except Microsalt and Mutant. - """ - - def __init__( - self, - file_manager: FileManager, - file_name_formatter: NestedSampleFileNameFormatter | FlatSampleFileNameFormatter, - ): - self.file_manager = file_manager - self.file_name_formatter = file_name_formatter - - def format_files( - self, moved_files: list[SampleFile], delivery_path: Path - ) -> list[FormattedFile]: - """Format the sample files to deliver and return the formatted files.""" - LOG.debug("[FORMAT SERVICE] Formatting sample files") - sample_names: set[str] = self.file_name_formatter.get_sample_names(sample_files=moved_files) - for sample_name in sample_names: - self.file_manager.create_directories(base_path=delivery_path, directories={sample_name}) - formatted_files: list[FormattedFile] = self.file_name_formatter.format_sample_file_names( - sample_files=moved_files - ) - for formatted_file in formatted_files: - self.file_manager.rename_file( - src=formatted_file.original_path, dst=formatted_file.formatted_path - ) - return formatted_files diff --git a/cg/services/deliver_files/file_mover/abstract.py b/cg/services/deliver_files/file_mover/abstract.py new file mode 100644 index 0000000000..54bf7638a6 --- /dev/null +++ b/cg/services/deliver_files/file_mover/abstract.py @@ -0,0 +1,11 @@ +from abc import ABC, abstractmethod +from pathlib import Path + +from cg.services.deliver_files.file_fetcher.models import DeliveryFiles + + +class DestinationFilesMover(ABC): + @abstractmethod + def move_files(self, delivery_files: DeliveryFiles, delivery_base_path: Path) -> DeliveryFiles: + """Move files to the delivery folder.""" + pass diff --git a/cg/services/deliver_files/file_mover/base_service.py b/cg/services/deliver_files/file_mover/base_service.py new file mode 100644 index 0000000000..67ad1827b1 --- /dev/null +++ b/cg/services/deliver_files/file_mover/base_service.py @@ -0,0 +1,30 @@ +from pathlib import Path + +from cg.services.deliver_files.file_fetcher.models import DeliveryFiles, SampleFile, CaseFile +from cg.services.deliver_files.file_mover.abstract import DestinationFilesMover +from cg.services.deliver_files.utils import FileMover + + +class BaseDestinationFilesMover(DestinationFilesMover): + """ + Class to move files directly to the delivery base path. + """ + + def __init__(self, file_mover: FileMover): + self.file_mover = file_mover + + def move_files(self, delivery_files: DeliveryFiles, delivery_base_path: Path) -> DeliveryFiles: + """ + Move the files directly to the delivery base path. + args: + delivery_files: DeliveryFiles: The files to move. + delivery_base_path: Path: The path to move the files to. + """ + delivery_files.delivery_data.delivery_path = delivery_base_path + delivery_files.case_files = self.file_mover.move_and_update_files( + file_models=delivery_files.case_files, target_dir=delivery_base_path + ) + delivery_files.sample_files = self.file_mover.move_and_update_files( + file_models=delivery_files.sample_files, target_dir=delivery_base_path + ) + return delivery_files diff --git a/cg/services/deliver_files/file_mover/delivery_files_mover.py b/cg/services/deliver_files/file_mover/customer_inbox_service.py similarity index 55% rename from cg/services/deliver_files/file_mover/delivery_files_mover.py rename to cg/services/deliver_files/file_mover/customer_inbox_service.py index 8d7c5533f9..bb16d62ebf 100644 --- a/cg/services/deliver_files/file_mover/delivery_files_mover.py +++ b/cg/services/deliver_files/file_mover/customer_inbox_service.py @@ -5,13 +5,16 @@ from cg.services.deliver_files.file_fetcher.models import ( DeliveryFiles, DeliveryMetaData, + CaseFile, + SampleFile, ) +from cg.services.deliver_files.file_mover.abstract import DestinationFilesMover from cg.services.deliver_files.utils import FileMover LOG = logging.getLogger(__name__) -class DeliveryFilesMover: +class CustomerInboxDestinationFilesMover(DestinationFilesMover): """ Class to move files to the customer folder. """ @@ -20,38 +23,38 @@ def __init__(self, file_mover: FileMover): self.file_mover = file_mover def move_files(self, delivery_files: DeliveryFiles, delivery_base_path: Path) -> DeliveryFiles: - """Move the files to the customer folder.""" - - inbox_ticket_dir_path = self._create_ticket_inbox_dir_path( + """ + Move the files to the customer folder. + args: + delivery_files: DeliveryFiles: The files to move. + delivery_base_path: Path: The path to move the files to. + """ + inbox_ticket_dir_path: Path = self._create_ticket_inbox_dir_path( delivery_base_path=delivery_base_path, delivery_data=delivery_files.delivery_data ) - delivery_files.delivery_data.customer_ticket_inbox = inbox_ticket_dir_path + delivery_files.delivery_data.delivery_path = inbox_ticket_dir_path self.file_mover.create_directories( base_path=delivery_base_path, directories={str(inbox_ticket_dir_path.relative_to(delivery_base_path))}, ) - if delivery_files.case_files: - self.file_mover.move_files_to_directory( - file_models=delivery_files.case_files, target_dir=inbox_ticket_dir_path - ) - delivery_files.case_files = self.file_mover.update_file_paths( - file_models=delivery_files.case_files, target_dir=inbox_ticket_dir_path - ) - self.file_mover.move_files_to_directory( - file_models=delivery_files.sample_files, target_dir=inbox_ticket_dir_path + delivery_files.case_files = self.file_mover.move_and_update_files( + file_models=delivery_files.case_files, target_dir=inbox_ticket_dir_path ) - delivery_files.sample_files = self.file_mover.update_file_paths( + delivery_files.sample_files = self.file_mover.move_and_update_files( file_models=delivery_files.sample_files, target_dir=inbox_ticket_dir_path ) - return delivery_files @staticmethod def _create_ticket_inbox_dir_path( delivery_base_path: Path, delivery_data: DeliveryMetaData ) -> Path: - """Generate the path to the ticket inbox directory.""" + """Generate the path to the ticket inbox directory. + args: + delivery_base_path: The base path to the delivery folder. + delivery_data: The delivery data containing the customer internal id and ticket id. + """ return Path( delivery_base_path, delivery_data.customer_internal_id, diff --git a/cg/services/deliver_files/file_mover/fohm_upload_files_mover.py b/cg/services/deliver_files/file_mover/fohm_upload_files_mover.py deleted file mode 100644 index b10857068d..0000000000 --- a/cg/services/deliver_files/file_mover/fohm_upload_files_mover.py +++ /dev/null @@ -1,26 +0,0 @@ -from pathlib import Path - -from cg.services.deliver_files.file_fetcher.models import DeliveryFiles -from cg.services.deliver_files.utils import FileMover - - -class GenericFilesMover: - """ - Class to move files directly to the delivery base path. - """ - - def __init__(self, file_mover: FileMover): - self.file_mover = file_mover - - def move_files(self, delivery_files: DeliveryFiles, delivery_base_path: Path) -> DeliveryFiles: - """Move the files directly to the delivery base path.""" - if delivery_files.case_files: - self.file_mover.move_files_to_directory(delivery_files.case_files, delivery_base_path) - delivery_files.case_files = self.file_mover.update_file_paths( - delivery_files.case_files, delivery_base_path - ) - self.file_mover.move_files_to_directory(delivery_files.sample_files, delivery_base_path) - delivery_files.sample_files = self.file_mover.update_file_paths( - delivery_files.sample_files, delivery_base_path - ) - return delivery_files diff --git a/cg/services/deliver_files/utils.py b/cg/services/deliver_files/utils.py index 64ad1b1342..08e5bd0a7f 100644 --- a/cg/services/deliver_files/utils.py +++ b/cg/services/deliver_files/utils.py @@ -46,16 +46,25 @@ class FileMover: def __init__(self, file_manager): """ - :param file_manager: Service for file operations (e.g., create directories, move files). + args: + file_manager: Service for file operations (e.g., create directories, move files). """ self.file_management_service = file_manager def create_directories(self, base_path: Path, directories: set[str]) -> None: - """Create required directories.""" + """Create required directories. + args: + base_path: The base path to create the directories under. + directories: The directories to create. + """ self.file_management_service.create_directories(base_path, directories) def move_files_to_directory(self, file_models: list, target_dir: Path) -> None: - """Move files to the target directory.""" + """Move files to the target directory. + args: + file_models: The file models that contain the files to move. + target_dir: The directory to move the files to. + """ for file_model in file_models: target_path = Path(target_dir, file_model.file_path.name) self._move_or_link_file(src=file_model.file_path, dst=target_path) @@ -64,13 +73,32 @@ def move_files_to_directory(self, file_models: list, target_dir: Path) -> None: def update_file_paths( file_models: list[CaseFile | SampleFile], target_dir: Path ) -> list[CaseFile | SampleFile]: - """Update file paths to point to the target directory.""" + """Update file paths to point to the target directory. + args: + file_models: The file models to update. + target_dir: The target directory to point the file paths to. + """ for file_model in file_models: file_model.file_path = Path(target_dir, file_model.file_path.name) return file_models + def move_and_update_files(self, file_models: list[CaseFile | SampleFile], target_dir): + """Move files to the target directory and update the file paths. + args: + file_models: The file models that contain the files to move. + target_dir: The directory to move the files to. + """ + if file_models: + self.move_files_to_directory(file_models=file_models, target_dir=target_dir) + return self.update_file_paths(file_models=file_models, target_dir=target_dir) + return file_models + def _move_or_link_file(self, src: Path, dst: Path) -> None: - """Move or create a hard link for a file.""" + """Move or create a hard link for a file. + args: + src: The source file path + dst: The destination file path + """ LOG.debug(f"[FileMover] Moving file: {src} -> {dst}") if dst.exists(): LOG.debug(f"Overwriting existing file: {dst}") diff --git a/tests/fixture_plugins/delivery_fixtures/delivery_files_models_fixtures.py b/tests/fixture_plugins/delivery_fixtures/delivery_files_models_fixtures.py index cf226c9215..9c85f3a331 100644 --- a/tests/fixture_plugins/delivery_fixtures/delivery_files_models_fixtures.py +++ b/tests/fixture_plugins/delivery_fixtures/delivery_files_models_fixtures.py @@ -1,4 +1,4 @@ -from pathlib import Path, PosixPath +from pathlib import Path import pytest @@ -15,7 +15,7 @@ DeliveryMetaData, SampleFile, ) -from cg.services.deliver_files.file_formatter.models import FormattedFile +from cg.services.deliver_files.file_formatter.destination.models import FormattedFile from cg.store.models import Case from cg.store.store import Store @@ -165,7 +165,7 @@ def expected_moved_fastq_delivery_files( INBOX_NAME, delivery_files.delivery_data.ticket_id, ) - delivery_files.delivery_data.customer_ticket_inbox = inbox_dir_path + delivery_files.delivery_data.delivery_path = inbox_dir_path new_sample_files: list[SampleFile] = swap_file_paths_with_inbox_paths( file_models=delivery_files.sample_files, inbox_dir_path=inbox_dir_path ) @@ -188,7 +188,7 @@ def expected_moved_analysis_delivery_files( INBOX_NAME, delivery_files.delivery_data.ticket_id, ) - delivery_files.delivery_data.customer_ticket_inbox = inbox_dir_path + delivery_files.delivery_data.delivery_path = inbox_dir_path new_case_files: list[CaseFile] = swap_file_paths_with_inbox_paths( file_models=delivery_files.case_files, inbox_dir_path=inbox_dir_path ) @@ -334,6 +334,7 @@ def expected_upload_files(expected_analysis_delivery_files: DeliveryFiles): @pytest.fixture def expected_moved_upload_files(expected_analysis_delivery_files: DeliveryFiles, tmp_path: Path): delivery_files = DeliveryFiles(**expected_analysis_delivery_files.model_dump()) + delivery_files.delivery_data.delivery_path = tmp_path new_case_files: list[CaseFile] = swap_file_paths_with_inbox_paths( file_models=delivery_files.case_files, inbox_dir_path=tmp_path ) diff --git a/tests/fixture_plugins/delivery_fixtures/delivery_formatted_files_fixtures.py b/tests/fixture_plugins/delivery_fixtures/delivery_formatted_files_fixtures.py index 9d16cc965f..c80f9b2f1e 100644 --- a/tests/fixture_plugins/delivery_fixtures/delivery_formatted_files_fixtures.py +++ b/tests/fixture_plugins/delivery_fixtures/delivery_formatted_files_fixtures.py @@ -2,8 +2,8 @@ import pytest -from cg.services.deliver_files.file_fetcher.models import DeliveryFiles, SampleFile -from cg.services.deliver_files.file_formatter.models import FormattedFile +from cg.services.deliver_files.file_fetcher.models import DeliveryFiles +from cg.services.deliver_files.file_formatter.destination.models import FormattedFile @pytest.fixture diff --git a/tests/fixture_plugins/delivery_fixtures/delivery_services_fixtures.py b/tests/fixture_plugins/delivery_fixtures/delivery_services_fixtures.py index 003dad9856..094c4bd6de 100644 --- a/tests/fixture_plugins/delivery_fixtures/delivery_services_fixtures.py +++ b/tests/fixture_plugins/delivery_fixtures/delivery_services_fixtures.py @@ -4,9 +4,6 @@ from cg.services.deliver_files.tag_fetcher.bam_service import ( BamDeliveryTagsFetcher, ) -from cg.services.fastq_concatenation_service.fastq_concatenation_service import ( - FastqConcatenationService, -) from cg.services.deliver_files.tag_fetcher.sample_and_case_service import ( SampleAndCaseDeliveryTagsFetcher, ) @@ -16,19 +13,18 @@ from cg.services.deliver_files.file_fetcher.raw_data_service import ( RawDataDeliveryFileFetcher, ) -from cg.services.deliver_files.file_formatter.delivery_file_formatter import ( - DeliveryFileFormatter, +from cg.services.deliver_files.file_formatter.destination.customer_inbox_service import ( + CustomerInboxDeliveryFormatter, ) -from cg.services.deliver_files.file_formatter.utils.case_service import ( +from cg.services.deliver_files.file_formatter.component_files.case_service import ( CaseFileFormatter, ) -from cg.services.deliver_files.file_formatter.utils.sample_concatenation_service import ( - SampleFileConcatenationFormatter, -) -from cg.services.deliver_files.file_formatter.utils.sample_service import ( +from cg.services.deliver_files.file_formatter.component_files.sample_service import ( SampleFileFormatter, FileManager, - NestedSampleFileNameFormatter, +) +from cg.services.deliver_files.file_formatter.path_name.nested_structure import ( + NestedStructurePathFormatter, ) from cg.store.store import Store @@ -118,11 +114,14 @@ def analysis_delivery_service_no_housekeeper_bundle( @pytest.fixture -def generic_delivery_file_formatter() -> DeliveryFileFormatter: +def generic_delivery_file_formatter() -> CustomerInboxDeliveryFormatter: """Fixture to get an instance of GenericDeliveryFileFormatter.""" - return DeliveryFileFormatter( + return CustomerInboxDeliveryFormatter( sample_file_formatter=SampleFileFormatter( - file_manager=FileManager(), file_name_formatter=NestedSampleFileNameFormatter() + file_manager=FileManager(), path_name_formatter=NestedStructurePathFormatter() + ), + case_file_formatter=CaseFileFormatter( + file_manager=FileManager(), + path_name_formatter=NestedStructurePathFormatter(), ), - case_file_formatter=CaseFileFormatter(), ) diff --git a/tests/services/__init__.py b/tests/services/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/services/file_delivery/delivery_file_service/test_service_builder.py b/tests/services/file_delivery/delivery_file_service/test_service_builder.py index cd7475394a..565bb167f0 100644 --- a/tests/services/file_delivery/delivery_file_service/test_service_builder.py +++ b/tests/services/file_delivery/delivery_file_service/test_service_builder.py @@ -8,7 +8,7 @@ from cg.services.deliver_files.deliver_files_service.deliver_files_service import ( DeliverFilesService, ) -from cg.services.deliver_files.deliver_files_service.deliver_files_service_factory import ( +from cg.services.deliver_files.factory import ( DeliveryServiceFactory, ) from cg.services.deliver_files.file_fetcher.abstract import FetchDeliveryFilesService @@ -17,12 +17,18 @@ ) from cg.services.deliver_files.file_fetcher.analysis_service import AnalysisDeliveryFileFetcher from cg.services.deliver_files.file_fetcher.raw_data_service import RawDataDeliveryFileFetcher -from cg.services.deliver_files.file_formatter.utils.mutant_sample_service import MutantFileFormatter -from cg.services.deliver_files.file_formatter.utils.sample_concatenation_service import ( +from cg.services.deliver_files.file_formatter.component_files.mutant_service import ( + MutantFileFormatter, +) +from cg.services.deliver_files.file_formatter.component_files.concatenation_service import ( SampleFileConcatenationFormatter, ) -from cg.services.deliver_files.file_formatter.utils.sample_service import SampleFileFormatter -from cg.services.deliver_files.file_mover.delivery_files_mover import DeliveryFilesMover +from cg.services.deliver_files.file_formatter.component_files.sample_service import ( + SampleFileFormatter, +) +from cg.services.deliver_files.file_mover.customer_inbox_service import ( + CustomerInboxDestinationFilesMover, +) from cg.services.deliver_files.tag_fetcher.abstract import FetchDeliveryFileTagsService from cg.services.deliver_files.tag_fetcher.sample_and_case_service import ( SampleAndCaseDeliveryTagsFetcher, @@ -37,7 +43,7 @@ class DeliveryServiceScenario(BaseModel): delivery_type: DataDelivery expected_tag_fetcher: type[FetchDeliveryFileTagsService] expected_file_fetcher: type[FetchDeliveryFilesService] - expected_file_mover: type[DeliveryFilesMover] + expected_file_mover: type[CustomerInboxDestinationFilesMover] expected_sample_file_formatter: type[ SampleFileFormatter | SampleFileConcatenationFormatter | MutantFileFormatter ] @@ -53,7 +59,7 @@ class DeliveryServiceScenario(BaseModel): delivery_type=DataDelivery.FASTQ, expected_tag_fetcher=SampleAndCaseDeliveryTagsFetcher, expected_file_fetcher=RawDataDeliveryFileFetcher, - expected_file_mover=DeliveryFilesMover, + expected_file_mover=CustomerInboxDestinationFilesMover, expected_sample_file_formatter=SampleFileConcatenationFormatter, store_name="microbial_store", ), @@ -63,7 +69,7 @@ class DeliveryServiceScenario(BaseModel): delivery_type=DataDelivery.ANALYSIS_FILES, expected_tag_fetcher=SampleAndCaseDeliveryTagsFetcher, expected_file_fetcher=AnalysisDeliveryFileFetcher, - expected_file_mover=DeliveryFilesMover, + expected_file_mover=CustomerInboxDestinationFilesMover, expected_sample_file_formatter=MutantFileFormatter, store_name="mutant_store", ), @@ -73,7 +79,7 @@ class DeliveryServiceScenario(BaseModel): delivery_type=DataDelivery.FASTQ_ANALYSIS, expected_tag_fetcher=SampleAndCaseDeliveryTagsFetcher, expected_file_fetcher=RawDataAndAnalysisDeliveryFileFetcher, - expected_file_mover=DeliveryFilesMover, + expected_file_mover=CustomerInboxDestinationFilesMover, expected_sample_file_formatter=SampleFileFormatter, store_name="applications_store", ), diff --git a/tests/services/file_delivery/file_formatter/__init__.py b/tests/services/file_delivery/file_formatter/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/services/file_delivery/file_formatter/component_files/__init__.py b/tests/services/file_delivery/file_formatter/component_files/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/services/file_delivery/file_formatter/utils/test_formatter_utils.py b/tests/services/file_delivery/file_formatter/component_files/test_formatter_utils.py similarity index 54% rename from tests/services/file_delivery/file_formatter/utils/test_formatter_utils.py rename to tests/services/file_delivery/file_formatter/component_files/test_formatter_utils.py index ef7e6d8211..34cbf79f67 100644 --- a/tests/services/file_delivery/file_formatter/utils/test_formatter_utils.py +++ b/tests/services/file_delivery/file_formatter/component_files/test_formatter_utils.py @@ -2,7 +2,11 @@ from unittest.mock import Mock import pytest from pathlib import Path -from cg.services.deliver_files.file_formatter.utils.mutant_sample_service import MutantFileFormatter + +from cg.services.deliver_files.file_formatter.component_files.abstract import ComponentFormatter +from cg.services.deliver_files.file_formatter.component_files.mutant_service import ( + MutantFileFormatter, +) from cg.services.fastq_concatenation_service.fastq_concatenation_service import ( FastqConcatenationService, ) @@ -10,60 +14,66 @@ CaseFile, SampleFile, ) -from cg.services.deliver_files.file_formatter.models import FormattedFile -from cg.services.deliver_files.file_formatter.utils.case_service import ( +from cg.services.deliver_files.file_formatter.destination.models import FormattedFile +from cg.services.deliver_files.file_formatter.component_files.case_service import ( CaseFileFormatter, ) -from cg.services.deliver_files.file_formatter.utils.sample_concatenation_service import ( +from cg.services.deliver_files.file_formatter.component_files.concatenation_service import ( SampleFileConcatenationFormatter, ) -from cg.services.deliver_files.file_formatter.utils.sample_service import ( +from cg.services.deliver_files.file_formatter.component_files.sample_service import ( SampleFileFormatter, FileManager, - NestedSampleFileNameFormatter, - FlatSampleFileNameFormatter, +) +from cg.services.deliver_files.file_formatter.path_name.flat_structure import ( + FlatStructurePathFormatter, +) +from cg.services.deliver_files.file_formatter.path_name.nested_structure import ( + NestedStructurePathFormatter, ) @pytest.mark.parametrize( "moved_files,expected_formatted_files,file_formatter", [ - # ( - # "expected_moved_analysis_case_delivery_files", - # "expected_formatted_analysis_case_files", - # CaseFileFormatter(), - # ), - # ( - # "expected_moved_analysis_sample_delivery_files", - # "expected_formatted_analysis_sample_files", - # SampleFileFormatter( - # file_manager=FileManager(), file_name_formatter=NestedSampleFileNameFormatter() - # ), - # ), - # ( - # "fastq_concatenation_sample_files", - # "expected_concatenated_fastq_formatted_files", - # SampleFileConcatenationFormatter( - # file_manager=FileManager(), - # file_formatter=NestedSampleFileNameFormatter(), - # concatenation_service=FastqConcatenationService(), - # ), - # ), + ( + "expected_moved_analysis_case_delivery_files", + "expected_formatted_analysis_case_files", + CaseFileFormatter( + file_manager=FileManager(), path_name_formatter=NestedStructurePathFormatter() + ), + ), + ( + "expected_moved_analysis_sample_delivery_files", + "expected_formatted_analysis_sample_files", + SampleFileFormatter( + file_manager=FileManager(), path_name_formatter=NestedStructurePathFormatter() + ), + ), + ( + "fastq_concatenation_sample_files", + "expected_concatenated_fastq_formatted_files", + SampleFileConcatenationFormatter( + file_manager=FileManager(), + path_name_formatter=NestedStructurePathFormatter(), + concatenation_service=FastqConcatenationService(), + ), + ), ( "fastq_concatenation_sample_files_flat", "expected_concatenated_fastq_flat_formatted_files", SampleFileConcatenationFormatter( file_manager=FileManager(), - file_formatter=FlatSampleFileNameFormatter(), + path_name_formatter=FlatStructurePathFormatter(), concatenation_service=FastqConcatenationService(), ), ), ], ) -def test_file_formatter_utils( +def test_component_formatters( moved_files: list[CaseFile | SampleFile], expected_formatted_files: list[FormattedFile], - file_formatter: CaseFileFormatter | SampleFileFormatter | SampleFileConcatenationFormatter, + file_formatter: ComponentFormatter, request, ): # GIVEN existing case files, a case file formatter and a ticket directory path and a customer inbox @@ -110,7 +120,7 @@ def test_mutant_file_formatter( file_manager=FileManager(), file_formatter=SampleFileConcatenationFormatter( file_manager=FileManager(), - file_formatter=NestedSampleFileNameFormatter(), + path_name_formatter=NestedStructurePathFormatter(), concatenation_service=FastqConcatenationService(), ), lims_api=lims_mock, @@ -127,39 +137,3 @@ def test_mutant_file_formatter( for file in formatted_files: assert file.formatted_path.exists() assert not file.original_path.exists() - - -@pytest.mark.parametrize( - "sample_files,expected_formatted_files,sample_file_formatter", - [ - ( - "expected_moved_analysis_sample_delivery_files", - "expected_formatted_analysis_sample_files", - NestedSampleFileNameFormatter(), - ), - ( - "expected_moved_analysis_sample_delivery_files", - "expected_flat_formatted_analysis_sample_files", - FlatSampleFileNameFormatter(), - ), - ], -) -def test_sample_file_name_formatters( - sample_files: list[SampleFile], - expected_formatted_files: list[FormattedFile], - sample_file_formatter: NestedSampleFileNameFormatter | FlatSampleFileNameFormatter, - request, -): - # GIVEN existing sample files and a sample file formatter - sample_files: list[SampleFile] = request.getfixturevalue(sample_files) - expected_formatted_files: list[FormattedFile] = request.getfixturevalue( - expected_formatted_files - ) - - # WHEN formatting the sample files - formatted_files: list[FormattedFile] = sample_file_formatter.format_sample_file_names( - sample_files=sample_files - ) - - # THEN the sample files should be formatted - assert formatted_files == expected_formatted_files diff --git a/tests/services/file_delivery/file_formatter/destination/__init__.py b/tests/services/file_delivery/file_formatter/destination/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/services/file_delivery/file_formatter/test_formatting_service.py b/tests/services/file_delivery/file_formatter/destination/test_formatting_service.py similarity index 84% rename from tests/services/file_delivery/file_formatter/test_formatting_service.py rename to tests/services/file_delivery/file_formatter/destination/test_formatting_service.py index d7a012842a..fe36730694 100644 --- a/tests/services/file_delivery/file_formatter/test_formatting_service.py +++ b/tests/services/file_delivery/file_formatter/destination/test_formatting_service.py @@ -8,12 +8,12 @@ CaseFile, DeliveryMetaData, ) -from cg.services.deliver_files.file_formatter.abstract import ( - DeliveryFileFormattingService, +from cg.services.deliver_files.file_formatter.destination.abstract import ( + DeliveryDestinationFormatter, ) import pytest -from cg.services.deliver_files.file_formatter.models import ( +from cg.services.deliver_files.file_formatter.destination.models import ( FormattedFiles, FormattedFile, ) @@ -39,7 +39,7 @@ ], ) def test_reformat_files( - formatter_service: DeliveryFileFormattingService, + formatter_service: DeliveryDestinationFormatter, formatted_case_files: list[FormattedFile], formatted_sample_files: list[FormattedFile], case_files: list[CaseFile], @@ -70,10 +70,10 @@ def test_reformat_files( expected_formatted_files = FormattedFiles(files=files) with mock.patch( - "cg.services.deliver_files.file_formatter.utils.sample_service.SampleFileFormatter.format_files", + "cg.services.deliver_files.file_formatter.component_files.sample_service.SampleFileFormatter.format_files", return_value=formatted_sample_files, ), mock.patch( - "cg.services.deliver_files.file_formatter.utils.case_service.CaseFileFormatter.format_files", + "cg.services.deliver_files.file_formatter.component_files.case_service.CaseFileFormatter.format_files", return_value=formatted_case_files, ): # WHEN reformatting the delivery files diff --git a/tests/services/file_delivery/file_formatter/path_name_formatters/__init__.py b/tests/services/file_delivery/file_formatter/path_name_formatters/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/services/file_delivery/file_formatter/path_name_formatters/test_path_name_formatters.py b/tests/services/file_delivery/file_formatter/path_name_formatters/test_path_name_formatters.py new file mode 100644 index 0000000000..c43e3aa9f7 --- /dev/null +++ b/tests/services/file_delivery/file_formatter/path_name_formatters/test_path_name_formatters.py @@ -0,0 +1,54 @@ +import pytest + +from cg.services.deliver_files.file_fetcher.models import SampleFile +from cg.services.deliver_files.file_formatter.destination.models import FormattedFile +from cg.services.deliver_files.file_formatter.path_name.flat_structure import ( + FlatStructurePathFormatter, +) +from cg.services.deliver_files.file_formatter.path_name.nested_structure import ( + NestedStructurePathFormatter, +) + + +@pytest.mark.parametrize( + "sample_files,expected_formatted_files,path_name_formatter", + [ + ( + "expected_moved_analysis_sample_delivery_files", + "expected_formatted_analysis_sample_files", + NestedStructurePathFormatter(), + ), + ( + "expected_moved_analysis_sample_delivery_files", + "expected_flat_formatted_analysis_sample_files", + FlatStructurePathFormatter(), + ), + ], +) +def test_path_name_formatters( + sample_files: list[SampleFile], + expected_formatted_files: list[FormattedFile], + path_name_formatter, + request, +): + # GIVEN existing sample files and a sample file formatter + sample_files: list[SampleFile] = request.getfixturevalue(sample_files) + expected_formatted_files: list[FormattedFile] = request.getfixturevalue( + expected_formatted_files + ) + + # WHEN formatting the sample files + formatted_files: list[FormattedFile] = [ + FormattedFile( + formatted_path=path_name_formatter.format_file_path( + file_path=sample_file.file_path, + provided_name=sample_file.sample_name, + provided_id=sample_file.sample_id, + ), + original_path=sample_file.file_path, + ) + for sample_file in sample_files + ] + + # THEN the sample files should be formatted + assert formatted_files == expected_formatted_files diff --git a/tests/services/file_delivery/file_mover/test_file_mover_service.py b/tests/services/file_delivery/file_mover/test_file_mover_service.py index 27083124ab..4b8ab40c93 100644 --- a/tests/services/file_delivery/file_mover/test_file_mover_service.py +++ b/tests/services/file_delivery/file_mover/test_file_mover_service.py @@ -3,10 +3,10 @@ import pytest from cg.services.deliver_files.file_fetcher.models import DeliveryFiles -from cg.services.deliver_files.file_mover.delivery_files_mover import ( - DeliveryFilesMover, +from cg.services.deliver_files.file_mover.customer_inbox_service import ( + CustomerInboxDestinationFilesMover, ) -from cg.services.deliver_files.file_mover.fohm_upload_files_mover import GenericFilesMover +from cg.services.deliver_files.file_mover.base_service import BaseDestinationFilesMover from cg.services.deliver_files.utils import FileMover, FileManager @@ -16,24 +16,24 @@ ( "expected_moved_fastq_delivery_files", "expected_fastq_delivery_files", - DeliveryFilesMover(FileMover(FileManager())), + CustomerInboxDestinationFilesMover(FileMover(FileManager())), ), ( "expected_moved_analysis_delivery_files", "expected_analysis_delivery_files", - DeliveryFilesMover(FileMover(FileManager())), + CustomerInboxDestinationFilesMover(FileMover(FileManager())), ), ( "expected_moved_upload_files", "expected_upload_files", - GenericFilesMover(FileMover(FileManager())), + BaseDestinationFilesMover(FileMover(FileManager())), ), ], ) def test_move_files( expected_moved_delivery_files: DeliveryFiles, delivery_files: DeliveryFiles, - move_files_service: DeliveryFilesMover, + move_files_service: CustomerInboxDestinationFilesMover, tmp_path, request, ): From 41fd39272fb05c5654d6bc092a63e3a6dd0757b7 Mon Sep 17 00:00:00 2001 From: Christian Oertlin Date: Wed, 11 Dec 2024 12:06:51 +0100 Subject: [PATCH 62/80] remove redundant formatter --- .../deliver_files_service.py | 15 ++-- cg/services/deliver_files/factory.py | 13 +--- .../component_files/case_service.py | 6 ++ .../component_files/concatenation_service.py | 4 ++ .../component_files/mutant_service.py | 5 ++ .../component_files/sample_service.py | 3 + .../destination/base_service.py | 8 +-- .../destination/customer_inbox_service.py | 68 ------------------- .../delivery_services_fixtures.py | 8 +-- 9 files changed, 35 insertions(+), 95 deletions(-) delete mode 100644 cg/services/deliver_files/file_formatter/destination/customer_inbox_service.py diff --git a/cg/services/deliver_files/deliver_files_service/deliver_files_service.py b/cg/services/deliver_files/deliver_files_service/deliver_files_service.py index c315178307..b13f4e3f4d 100644 --- a/cg/services/deliver_files/deliver_files_service/deliver_files_service.py +++ b/cg/services/deliver_files/deliver_files_service/deliver_files_service.py @@ -27,12 +27,15 @@ class DeliverFilesService: """ - Deliver files to the customer inbox on the HPC and Rsync them to the inbox folder on the delivery server. - 1. Get the files to deliver from Housekeeper based on workflow and data delivery - 2. Create a delivery folder structure in the customer folder on Hasta and move the files there - 3. Reformatting of output / renaming of files - 4. Rsync the files to the customer inbox on the delivery server - 5. Add the rsync job to Trailblazer + Deliver files for a case, cases in a ticket or a sample to a specified destination or upload location. + Requires: + - FetchDeliveryFilesService: Service to fetch the files to deliver from housekeeper + - DestinationFilesMover: Service to move the files to the destination of delivery or upload + - DeliveryDestinationFormatter: Service to format the files to the destination format + - DeliveryRsyncService: Service to run rsync for the delivery + - TrailblazerAPI: Service to interact with Trailblazer + - AnalysisService: Service to interact with the analysis + - Store: Store to interact with the database """ def __init__( diff --git a/cg/services/deliver_files/factory.py b/cg/services/deliver_files/factory.py index 47f0b91b48..97335f109d 100644 --- a/cg/services/deliver_files/factory.py +++ b/cg/services/deliver_files/factory.py @@ -22,9 +22,6 @@ from cg.services.deliver_files.file_formatter.destination.abstract import ( DeliveryDestinationFormatter, ) -from cg.services.deliver_files.file_formatter.destination.customer_inbox_service import ( - CustomerInboxDeliveryFormatter, -) from cg.services.deliver_files.file_formatter.destination.base_service import ( BaseDeliveryFormatter, ) @@ -232,15 +229,7 @@ def _get_file_formatter( sample_file_formatter: ( SampleFileFormatter | SampleFileConcatenationFormatter | MutantFileFormatter ) = self._get_sample_file_formatter(case=case, delivery_destination=delivery_destination) - if delivery_destination == DeliveryDestination.BASE: - return BaseDeliveryFormatter( - case_file_formatter=CaseFileFormatter( - file_manager=FileManager(), - path_name_formatter=self._get_path_name_formatter(delivery_destination), - ), - sample_file_formatter=sample_file_formatter, - ) - return CustomerInboxDeliveryFormatter( + return BaseDeliveryFormatter( case_file_formatter=CaseFileFormatter( file_manager=FileManager(), path_name_formatter=self._get_path_name_formatter(delivery_destination), diff --git a/cg/services/deliver_files/file_formatter/component_files/case_service.py b/cg/services/deliver_files/file_formatter/component_files/case_service.py index d9d55344eb..85d00518e3 100644 --- a/cg/services/deliver_files/file_formatter/component_files/case_service.py +++ b/cg/services/deliver_files/file_formatter/component_files/case_service.py @@ -14,6 +14,12 @@ class CaseFileFormatter(ComponentFormatter): + """ + Format the case files to deliver and return the formatted files. + args: + path_name_formatter: The path name formatter to format paths to either a flat or nested structure in the delivery destination + file_manager: The file manager + """ def __init__( self, diff --git a/cg/services/deliver_files/file_formatter/component_files/concatenation_service.py b/cg/services/deliver_files/file_formatter/component_files/concatenation_service.py index 6652165a13..2f05668db8 100644 --- a/cg/services/deliver_files/file_formatter/component_files/concatenation_service.py +++ b/cg/services/deliver_files/file_formatter/component_files/concatenation_service.py @@ -26,6 +26,10 @@ class SampleFileConcatenationFormatter(ComponentFormatter): """ Format the sample files to deliver, concatenate fastq files and return the formatted files. Used for workflows: Microsalt. + args: + file_manager: The file manager + path_name_formatter: The path name formatter to format paths to either a flat or nested structure in the delivery destination + concatenation_service: The fastq concatenation service to concatenate fastq files. """ def __init__( diff --git a/cg/services/deliver_files/file_formatter/component_files/mutant_service.py b/cg/services/deliver_files/file_formatter/component_files/mutant_service.py index 81ae9da6af..bbaeaced74 100644 --- a/cg/services/deliver_files/file_formatter/component_files/mutant_service.py +++ b/cg/services/deliver_files/file_formatter/component_files/mutant_service.py @@ -16,6 +16,11 @@ class MutantFileFormatter(ComponentFormatter): """ Formatter for file to deliver or upload for the Mutant workflow. + Args: + lims_api: The LIMS API + file_formatter: The SampleFileConcatenationFormatter. This is used to format the files and concatenate the fastq files. + file_manager: The FileManager + """ def __init__( diff --git a/cg/services/deliver_files/file_formatter/component_files/sample_service.py b/cg/services/deliver_files/file_formatter/component_files/sample_service.py index 7557766c22..3320c75acb 100644 --- a/cg/services/deliver_files/file_formatter/component_files/sample_service.py +++ b/cg/services/deliver_files/file_formatter/component_files/sample_service.py @@ -14,6 +14,9 @@ class SampleFileFormatter(ComponentFormatter): """ Format the sample files to deliver. Used for all workflows except Microsalt and Mutant. + args: + file_manager: The file manager + path_name_formatter: The path name formatter to format paths to either a flat or nested structure in the delivery destination """ def __init__( diff --git a/cg/services/deliver_files/file_formatter/destination/base_service.py b/cg/services/deliver_files/file_formatter/destination/base_service.py index 234dffcdc5..f7ba70a0a0 100644 --- a/cg/services/deliver_files/file_formatter/destination/base_service.py +++ b/cg/services/deliver_files/file_formatter/destination/base_service.py @@ -26,9 +26,9 @@ class BaseDeliveryFormatter(DeliveryDestinationFormatter): """ Format the files to be delivered in the generic format. - Expected structure: - base_path/ - base_path/ + args: + case_file_formatter: The case file formatter + sample_file_formatter: The sample file formatter. This can be a SampleFileFormatter, SampleFileConcatenationFormatter or MutantFileFormatter. """ def __init__( @@ -43,7 +43,7 @@ def __init__( def format_files(self, delivery_files: DeliveryFiles) -> FormattedFiles: """Format the files to be delivered and return the formatted files in the generic format.""" - LOG.debug("[FORMAT SERVICE] Formatting files for Upload") + LOG.debug("[FORMAT SERVICE] Formatting files for delivery") formatted_files: list[FormattedFile] = self._format_sample_and_case_files( sample_files=delivery_files.sample_files, case_files=delivery_files.case_files, diff --git a/cg/services/deliver_files/file_formatter/destination/customer_inbox_service.py b/cg/services/deliver_files/file_formatter/destination/customer_inbox_service.py deleted file mode 100644 index 84cde3094d..0000000000 --- a/cg/services/deliver_files/file_formatter/destination/customer_inbox_service.py +++ /dev/null @@ -1,68 +0,0 @@ -import logging -from pathlib import Path - -from cg.services.deliver_files.file_fetcher.models import CaseFile, DeliveryFiles, SampleFile -from cg.services.deliver_files.file_formatter.destination.abstract import ( - DeliveryDestinationFormatter, -) -from cg.services.deliver_files.file_formatter.destination.models import ( - FormattedFile, - FormattedFiles, -) -from cg.services.deliver_files.file_formatter.component_files.case_service import CaseFileFormatter -from cg.services.deliver_files.file_formatter.component_files.mutant_service import ( - MutantFileFormatter, -) -from cg.services.deliver_files.file_formatter.component_files.concatenation_service import ( - SampleFileConcatenationFormatter, -) -from cg.services.deliver_files.file_formatter.component_files.sample_service import ( - SampleFileFormatter, -) - -LOG = logging.getLogger(__name__) - - -class CustomerInboxDeliveryFormatter(DeliveryDestinationFormatter): - """ - Format the files to be delivered in the customer inbox format. - Expected structure: - /inbox/// - /inbox/// - """ - - def __init__( - self, - case_file_formatter: CaseFileFormatter, - sample_file_formatter: ( - SampleFileFormatter | SampleFileConcatenationFormatter | MutantFileFormatter - ), - ): - self.case_file_formatter = case_file_formatter - self.sample_file_formatter = sample_file_formatter - - def format_files(self, delivery_files: DeliveryFiles) -> FormattedFiles: - """Format the files to be delivered and return the formatted files in the customer inbox format.""" - LOG.debug("[FORMAT SERVICE] Formatting files for delivery") - formatted_files: list[FormattedFile] = self._format_sample_and_case_files( - sample_files=delivery_files.sample_files, - case_files=delivery_files.case_files, - ticket_dir_path=delivery_files.delivery_data.delivery_path, - ) - return FormattedFiles(files=formatted_files) - - def _format_sample_and_case_files( - self, sample_files: list[SampleFile], case_files: list[CaseFile], ticket_dir_path: Path - ) -> list[FormattedFile]: - """Helper method to format both sample and case files.""" - formatted_files: list[FormattedFile] = self.sample_file_formatter.format_files( - moved_files=sample_files, - delivery_path=ticket_dir_path, - ) - if case_files: - formatted_case_files: list[FormattedFile] = self.case_file_formatter.format_files( - moved_files=case_files, - delivery_path=ticket_dir_path, - ) - formatted_files.extend(formatted_case_files) - return formatted_files diff --git a/tests/fixture_plugins/delivery_fixtures/delivery_services_fixtures.py b/tests/fixture_plugins/delivery_fixtures/delivery_services_fixtures.py index 094c4bd6de..6568ac4f1b 100644 --- a/tests/fixture_plugins/delivery_fixtures/delivery_services_fixtures.py +++ b/tests/fixture_plugins/delivery_fixtures/delivery_services_fixtures.py @@ -1,6 +1,7 @@ import pytest from cg.apps.housekeeper.hk import HousekeeperAPI +from cg.services.deliver_files.file_formatter.destination.base_service import BaseDeliveryFormatter from cg.services.deliver_files.tag_fetcher.bam_service import ( BamDeliveryTagsFetcher, ) @@ -13,9 +14,6 @@ from cg.services.deliver_files.file_fetcher.raw_data_service import ( RawDataDeliveryFileFetcher, ) -from cg.services.deliver_files.file_formatter.destination.customer_inbox_service import ( - CustomerInboxDeliveryFormatter, -) from cg.services.deliver_files.file_formatter.component_files.case_service import ( CaseFileFormatter, ) @@ -114,9 +112,9 @@ def analysis_delivery_service_no_housekeeper_bundle( @pytest.fixture -def generic_delivery_file_formatter() -> CustomerInboxDeliveryFormatter: +def generic_delivery_file_formatter() -> BaseDeliveryFormatter: """Fixture to get an instance of GenericDeliveryFileFormatter.""" - return CustomerInboxDeliveryFormatter( + return BaseDeliveryFormatter( sample_file_formatter=SampleFileFormatter( file_manager=FileManager(), path_name_formatter=NestedStructurePathFormatter() ), From 5600714d1de47768620b2e125a619d3f757bef15 Mon Sep 17 00:00:00 2001 From: Christian Oertlin Date: Thu, 12 Dec 2024 10:15:57 +0100 Subject: [PATCH 63/80] change delivery type fohm --- cg/meta/upload/fohm/fohm.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cg/meta/upload/fohm/fohm.py b/cg/meta/upload/fohm/fohm.py index a3ad426ea5..381513e1f0 100644 --- a/cg/meta/upload/fohm/fohm.py +++ b/cg/meta/upload/fohm/fohm.py @@ -205,7 +205,7 @@ def link_sample_raw_data_files( case: Case = sample.links[0].case delivery_service = self._delivery_factory.build_delivery_service( case=case, - delivery_type=DataDelivery.FASTQ, + delivery_type=DataDelivery.FASTQ_ANALYSIS, delivery_destination=DeliveryDestination.BASE, ) delivery_service.deliver_files_for_fohm_upload( From 8d4a900cf0912e60305020b0c18e1e0173490f81 Mon Sep 17 00:00:00 2001 From: Christian Oertlin Date: Thu, 12 Dec 2024 10:39:31 +0100 Subject: [PATCH 64/80] add fastq file check to concatenation map --- .../component_files/concatenation_service.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/cg/services/deliver_files/file_formatter/component_files/concatenation_service.py b/cg/services/deliver_files/file_formatter/component_files/concatenation_service.py index 2f05668db8..54676aa60b 100644 --- a/cg/services/deliver_files/file_formatter/component_files/concatenation_service.py +++ b/cg/services/deliver_files/file_formatter/component_files/concatenation_service.py @@ -182,10 +182,7 @@ def _get_unique_sample_fastq_paths( list_of_files: list[Path] = get_all_files_in_directory_tree(delivery_path) for sample_name in sample_names: for file in list_of_files: - if ( - sample_name in file.as_posix() - and f"{FileFormat.FASTQ}{FileExtensions.GZIP}" in file.as_posix() - ): + if sample_name in file.as_posix() and self._is_fastq_file(file): LOG.debug( f"[CONCATENATION SERVICE] Found fastq file: {file} for sample: {sample_name}" ) @@ -258,9 +255,9 @@ def _replace_fastq_paths( concatenation_maps: list[ConcatenationMap]: List of ConcatenationMap objects. formatted_files: list[FormattedFile]: List of formatted files. """ - for formatted_file in formatted_files: - formatted_file.formatted_path = concatenation_maps[formatted_file.formatted_path] + if self._is_fastq_file(formatted_file.formatted_path): + formatted_file.formatted_path = concatenation_maps[formatted_file.formatted_path] @staticmethod def _all_sample_fastq_file_share_same_directory( @@ -281,3 +278,7 @@ def _all_sample_fastq_file_share_same_directory( f"Sample {sample_name} fastq files are not in the same directory. " f"Cannot concatenate. It will would result in sporadic file paths." ) + + @staticmethod + def _is_fastq_file(file_path: Path) -> bool: + return f"{FileFormat.FASTQ}{FileExtensions.GZIP}" in file_path.as_posix() From 7713a8f8126fc1ecaef60f5574febee801c39b52 Mon Sep 17 00:00:00 2001 From: Christian Oertlin Date: Thu, 12 Dec 2024 10:45:56 +0100 Subject: [PATCH 65/80] make FOHM fastq delivery again --- cg/meta/upload/fohm/fohm.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cg/meta/upload/fohm/fohm.py b/cg/meta/upload/fohm/fohm.py index 381513e1f0..a3ad426ea5 100644 --- a/cg/meta/upload/fohm/fohm.py +++ b/cg/meta/upload/fohm/fohm.py @@ -205,7 +205,7 @@ def link_sample_raw_data_files( case: Case = sample.links[0].case delivery_service = self._delivery_factory.build_delivery_service( case=case, - delivery_type=DataDelivery.FASTQ_ANALYSIS, + delivery_type=DataDelivery.FASTQ, delivery_destination=DeliveryDestination.BASE, ) delivery_service.deliver_files_for_fohm_upload( From 5ddce779d40cb7c2fb921f9ae9815d0bc143e5cd Mon Sep 17 00:00:00 2001 From: ChristianOertlin Date: Mon, 16 Dec 2024 14:30:51 +0100 Subject: [PATCH 66/80] add(FOHM upload tags fetcher) (#4021) ## Description add specific FOHM upload tags --- cg/meta/upload/fohm/fohm.py | 7 +- cg/services/deliver_files/constants.py | 12 +++ cg/services/deliver_files/factory.py | 101 ++++++++++++------ .../file_fetcher/analysis_service.py | 17 ++- .../component_files/concatenation_service.py | 31 +++++- .../component_files/mutant_service.py | 46 +++++--- .../tag_fetcher/fohm_upload_service.py | 46 ++++++++ .../delivery_fixtures/bundle_fixtures.py | 56 ++++++++++ .../delivery_fixtures/context_fixtures.py | 87 ++++++++++++++- .../delivery_files_models_fixtures.py | 83 ++++++++++++-- .../delivery_formatted_files_fixtures.py | 16 +-- .../delivery_services_fixtures.py | 18 ++++ .../delivery_fixtures/path_fixtures.py | 54 +++++++++- tests/services/fastq_file_service/conftest.py | 13 +++ .../test_fastq_file_service.py | 19 ++-- .../test_service_builder.py | 68 +++++++++++- .../test_file_fetching_service.py | 7 +- .../component_files/test_formatter_utils.py | 4 +- .../tag_fetcher/test_tag_service.py | 13 +++ 19 files changed, 603 insertions(+), 95 deletions(-) create mode 100644 cg/services/deliver_files/tag_fetcher/fohm_upload_service.py diff --git a/cg/meta/upload/fohm/fohm.py b/cg/meta/upload/fohm/fohm.py index a3ad426ea5..95ceab74ab 100644 --- a/cg/meta/upload/fohm/fohm.py +++ b/cg/meta/upload/fohm/fohm.py @@ -15,7 +15,7 @@ from cg.models.cg_config import CGConfig from cg.models.email import EmailInfo from cg.models.fohm.reports import FohmComplementaryReport, FohmPangolinReport -from cg.services.deliver_files.constants import DeliveryDestination +from cg.services.deliver_files.constants import DeliveryDestination, DeliveryStructure from cg.services.deliver_files.factory import ( DeliveryServiceFactory, ) @@ -205,8 +205,9 @@ def link_sample_raw_data_files( case: Case = sample.links[0].case delivery_service = self._delivery_factory.build_delivery_service( case=case, - delivery_type=DataDelivery.FASTQ, - delivery_destination=DeliveryDestination.BASE, + delivery_type=DataDelivery.FASTQ_ANALYSIS, + delivery_destination=DeliveryDestination.FOHM, + delivery_structure=DeliveryStructure.FLAT, ) delivery_service.deliver_files_for_fohm_upload( case=case, sample_id=sample.internal_id, delivery_base_path=self.daily_rawdata_path diff --git a/cg/services/deliver_files/constants.py b/cg/services/deliver_files/constants.py index 0a86a00763..b126b7cf09 100644 --- a/cg/services/deliver_files/constants.py +++ b/cg/services/deliver_files/constants.py @@ -5,7 +5,19 @@ class DeliveryDestination(Enum): """Enum for the DeliveryDestination BASE: Deliver to the base folder provided in the call CUSTOMER: Deliver to the customer folder on hasta + FOHM: Deliver to the FOHM folder on hasta """ BASE = "base" CUSTOMER = "customer" + FOHM = "fohm" + + +class DeliveryStructure(Enum): + """Enum for the DeliveryStructure + FLAT: Deliver the files in a flat structure, i.e. all files in the same folder + NESTED: Deliver the files in a nested structure, i.e. files in folders for each sample/case + """ + + FLAT: str = "flat" + NESTED: str = "nested" diff --git a/cg/services/deliver_files/factory.py b/cg/services/deliver_files/factory.py index 97335f109d..f1f7f205f3 100644 --- a/cg/services/deliver_files/factory.py +++ b/cg/services/deliver_files/factory.py @@ -8,7 +8,7 @@ from cg.constants import DataDelivery, Workflow from cg.constants.sequencing import SeqLibraryPrepCategory from cg.services.analysis_service.analysis_service import AnalysisService -from cg.services.deliver_files.constants import DeliveryDestination +from cg.services.deliver_files.constants import DeliveryDestination, DeliveryStructure from cg.services.deliver_files.deliver_files_service.deliver_files_service import ( DeliverFilesService, ) @@ -43,6 +43,7 @@ from cg.services.deliver_files.file_formatter.path_name.nested_structure import ( NestedStructurePathFormatter, ) +from cg.services.deliver_files.file_mover.abstract import DestinationFilesMover from cg.services.deliver_files.file_mover.customer_inbox_service import ( CustomerInboxDestinationFilesMover, ) @@ -50,6 +51,7 @@ from cg.services.deliver_files.rsync.service import DeliveryRsyncService from cg.services.deliver_files.tag_fetcher.abstract import FetchDeliveryFileTagsService from cg.services.deliver_files.tag_fetcher.bam_service import BamDeliveryTagsFetcher +from cg.services.deliver_files.tag_fetcher.fohm_upload_service import FOHMUploadTagsFetcher from cg.services.deliver_files.tag_fetcher.sample_and_case_service import ( SampleAndCaseDeliveryTagsFetcher, ) @@ -117,8 +119,20 @@ def _validate_delivery_type(delivery_type: DataDelivery): ) @staticmethod - def _get_file_tag_fetcher(delivery_type: DataDelivery) -> FetchDeliveryFileTagsService: - """Get the file tag fetcher based on the delivery type.""" + def _get_file_tag_fetcher( + delivery_type: DataDelivery, delivery_destination: DeliveryDestination + ) -> FetchDeliveryFileTagsService: + """ + Get the file tag fetcher based on the delivery type or delivery destination. + NOTE: added complexity to handle the FOHM delivery type as it requires a special set of tags as compared to customer delivery. + It overrides the default behaviour of the delivery type given by the case. + args: + delivery_type: The type of delivery to perform. + delivery_destination: The destination of the delivery defaults to customer. + + """ + if delivery_destination == DeliveryDestination.FOHM: + return FOHMUploadTagsFetcher() service_map: dict[DataDelivery, Type[FetchDeliveryFileTagsService]] = { DataDelivery.FASTQ: SampleAndCaseDeliveryTagsFetcher, DataDelivery.ANALYSIS_FILES: SampleAndCaseDeliveryTagsFetcher, @@ -127,7 +141,9 @@ def _get_file_tag_fetcher(delivery_type: DataDelivery) -> FetchDeliveryFileTagsS } return service_map[delivery_type]() - def _get_file_fetcher(self, delivery_type: DataDelivery) -> FetchDeliveryFilesService: + def _get_file_fetcher( + self, delivery_type: DataDelivery, delivery_destination: DeliveryDestination + ) -> FetchDeliveryFilesService: """Get the file fetcher based on the delivery type.""" service_map: dict[DataDelivery, Type[FetchDeliveryFilesService]] = { DataDelivery.FASTQ: RawDataDeliveryFileFetcher, @@ -135,7 +151,9 @@ def _get_file_fetcher(self, delivery_type: DataDelivery) -> FetchDeliveryFilesSe DataDelivery.FASTQ_ANALYSIS: RawDataAndAnalysisDeliveryFileFetcher, DataDelivery.BAM: RawDataDeliveryFileFetcher, } - file_tag_fetcher: FetchDeliveryFileTagsService = self._get_file_tag_fetcher(delivery_type) + file_tag_fetcher: FetchDeliveryFileTagsService = self._get_file_tag_fetcher( + delivery_type=delivery_type, delivery_destination=delivery_destination + ) return service_map[delivery_type]( status_db=self.store, hk_api=self.hk_api, @@ -164,20 +182,20 @@ def _convert_workflow(self, case: Case) -> Workflow: def _get_sample_file_formatter( self, case: Case, - delivery_destination: DeliveryDestination, + delivery_structure: DeliveryStructure = DeliveryStructure.NESTED, ) -> SampleFileFormatter | SampleFileConcatenationFormatter | MutantFileFormatter: """Get the file formatter service based on the workflow. - Depending on the delivery destination the path name formatter will be different. + Depending on the delivery structure the path name formatter will be different. Args: case: The case to deliver files for. - delivery_destination: The destination of the delivery defaults to customer. + delivery_structure: The structure of the delivery. See DeliveryStructure enum for explanation. Defaults to nested. """ converted_workflow: Workflow = self._convert_workflow(case) if converted_workflow in [Workflow.MICROSALT]: return SampleFileConcatenationFormatter( file_manager=FileManager(), - path_name_formatter=self._get_path_name_formatter(delivery_destination), + path_name_formatter=self._get_path_name_formatter(delivery_structure), concatenation_service=FastqConcatenationService(), ) if converted_workflow == Workflow.MUTANT: @@ -186,25 +204,36 @@ def _get_sample_file_formatter( file_manager=FileManager(), file_formatter=SampleFileConcatenationFormatter( file_manager=FileManager(), - path_name_formatter=self._get_path_name_formatter(delivery_destination), + path_name_formatter=self._get_path_name_formatter(delivery_structure), concatenation_service=FastqConcatenationService(), ), ) return SampleFileFormatter( file_manager=FileManager(), - path_name_formatter=self._get_path_name_formatter(delivery_destination), + path_name_formatter=self._get_path_name_formatter(delivery_structure), + ) + + def _get_case_file_formatter(self, delivery_structure: DeliveryStructure) -> CaseFileFormatter: + """ + Get the case file formatter based on the delivery structure. + args: + delivery_structure: The structure of the delivery. See DeliveryStructure enum for explanation. + """ + return CaseFileFormatter( + file_manager=FileManager(), + path_name_formatter=self._get_path_name_formatter(delivery_structure), ) @staticmethod def _get_path_name_formatter( - delivery_destination: DeliveryDestination, + delivery_structure: DeliveryStructure, ) -> PathNameFormatter: """ Get the path name formatter based on the delivery destination - Args: - delivery_destination: The destination of the . + args: + delivery_structure: The structure of the delivery. See DeliveryStructure enum for explanation. """ - if delivery_destination == DeliveryDestination.BASE: + if delivery_structure == DeliveryStructure.FLAT: return FlatStructurePathFormatter() return NestedStructurePathFormatter() @@ -213,27 +242,32 @@ def _get_file_mover( delivery_destination: DeliveryDestination, ) -> CustomerInboxDestinationFilesMover | BaseDestinationFilesMover: """Get the file mover based on the delivery type. - Args: - delivery_destination: The destination of the delivery. + args: + delivery_destination: The destination of the delivery. See DeliveryDestination enum for explanation. """ - if delivery_destination == DeliveryDestination.BASE: + if delivery_destination in [DeliveryDestination.BASE, DeliveryDestination.FOHM]: return BaseDestinationFilesMover(FileMover(FileManager())) return CustomerInboxDestinationFilesMover(FileMover(FileManager())) def _get_file_formatter( self, - delivery_destination: DeliveryDestination, + delivery_structure: DeliveryStructure, case: Case, ) -> DeliveryDestinationFormatter: - """Get the file formatter service based on the delivery destination.""" + """ + Get the file formatter service based on the delivery destination. + args: + delivery_structure: The structure of the delivery. See DeliveryStructure enum for explanation. + case: The case to deliver files for. + """ sample_file_formatter: ( SampleFileFormatter | SampleFileConcatenationFormatter | MutantFileFormatter - ) = self._get_sample_file_formatter(case=case, delivery_destination=delivery_destination) + ) = self._get_sample_file_formatter(case=case, delivery_structure=delivery_structure) + case_file_formatter: CaseFileFormatter = self._get_case_file_formatter( + delivery_structure=delivery_structure + ) return BaseDeliveryFormatter( - case_file_formatter=CaseFileFormatter( - file_manager=FileManager(), - path_name_formatter=self._get_path_name_formatter(delivery_destination), - ), + case_file_formatter=case_file_formatter, sample_file_formatter=sample_file_formatter, ) @@ -242,24 +276,27 @@ def build_delivery_service( case: Case, delivery_type: DataDelivery | None = None, delivery_destination: DeliveryDestination = DeliveryDestination.CUSTOMER, + delivery_structure: DeliveryStructure = DeliveryStructure.NESTED, ) -> DeliverFilesService: """Build a delivery service based on a case. - - Args: + args: case: The case to deliver files for. delivery_type: The type of delivery to perform. - delivery_destination: The destination of the delivery defaults to customer. + delivery_destination: The destination of the delivery defaults to customer. See DeliveryDestination enum for explanation. + delivery_structure: The structure of the delivery defaults to nested. See DeliveryStructure enum for explanation. """ delivery_type: DataDelivery = self._sanitise_delivery_type( delivery_type if delivery_type else case.data_delivery ) self._validate_delivery_type(delivery_type) - file_fetcher: FetchDeliveryFilesService = self._get_file_fetcher(delivery_type) - file_move_service: CustomerInboxDestinationFilesMover | BaseDestinationFilesMover = ( - self._get_file_mover(delivery_destination=delivery_destination) + file_fetcher: FetchDeliveryFilesService = self._get_file_fetcher( + delivery_type=delivery_type, delivery_destination=delivery_destination + ) + file_move_service: DestinationFilesMover = self._get_file_mover( + delivery_destination=delivery_destination ) file_formatter: DeliveryDestinationFormatter = self._get_file_formatter( - case=case, delivery_destination=delivery_destination + case=case, delivery_structure=delivery_structure ) return DeliverFilesService( delivery_file_manager_service=file_fetcher, diff --git a/cg/services/deliver_files/file_fetcher/analysis_service.py b/cg/services/deliver_files/file_fetcher/analysis_service.py index 207ad5b1e8..3f31fdb437 100644 --- a/cg/services/deliver_files/file_fetcher/analysis_service.py +++ b/cg/services/deliver_files/file_fetcher/analysis_service.py @@ -42,7 +42,10 @@ def get_files_to_deliver(self, case_id: str, sample_id: str | None = None) -> De """Return a list of analysis files to be delivered for a case.""" LOG.debug(f"[FETCH SERVICE] Fetching analysis files for case: {case_id}") case: Case = self.status_db.get_case_by_internal_id(internal_id=case_id) - analysis_case_files: list[CaseFile] = self._get_analysis_case_delivery_files(case) + analysis_case_files: list[CaseFile] = self._get_analysis_case_delivery_files( + case=case, sample_id=sample_id + ) + analysis_sample_files: list[SampleFile] = self._get_analysis_sample_delivery_files( case=case, sample_id=sample_id ) @@ -73,9 +76,11 @@ def _validate_delivery_has_content(delivery_files: DeliveryFiles) -> DeliveryFil @handle_missing_bundle_errors def _get_sample_files_from_case_bundle( self, workflow: Workflow, sample_id: str, case_id: str - ) -> list[SampleFile]: + ) -> list[SampleFile] | None: """Return a list of files from a case bundle with a sample id as tag.""" sample_tags: list[set[str]] = self.tags_fetcher.fetch_tags(workflow).sample_tags + if not sample_tags: + return [] sample_tags_with_sample_id: list[set[str]] = [tag | {sample_id} for tag in sample_tags] sample_files: list[File] = self.hk_api.get_files_from_latest_version_containing_tags( bundle_name=case_id, tags=sample_tags_with_sample_id @@ -105,13 +110,17 @@ def _get_analysis_sample_delivery_files( return delivery_files @handle_missing_bundle_errors - def _get_analysis_case_delivery_files(self, case: Case) -> list[CaseFile]: + def _get_analysis_case_delivery_files( + self, case: Case, sample_id: str | None + ) -> list[CaseFile] | None: """ Return a complete list of analysis case files to be delivered and ignore analysis sample files. """ case_tags: list[set[str]] = self.tags_fetcher.fetch_tags(case.data_analysis).case_tags - sample_id_tags: list[str] = case.sample_ids + if not case_tags: + return [] + sample_id_tags: list[str] = [sample_id] if sample_id else case.sample_ids case_files: list[File] = self.hk_api.get_files_from_latest_version_containing_tags( bundle_name=case.internal_id, tags=case_tags, excluded_tags=sample_id_tags ) diff --git a/cg/services/deliver_files/file_formatter/component_files/concatenation_service.py b/cg/services/deliver_files/file_formatter/component_files/concatenation_service.py index 54676aa60b..78aee8de2e 100644 --- a/cg/services/deliver_files/file_formatter/component_files/concatenation_service.py +++ b/cg/services/deliver_files/file_formatter/component_files/concatenation_service.py @@ -1,5 +1,7 @@ import logging from pathlib import Path +import re + from cg.constants.constants import ReadDirection, FileFormat, FileExtensions from cg.services.deliver_files.file_formatter.component_files.abstract import ComponentFormatter from cg.services.deliver_files.file_formatter.component_files.models import FastqFile @@ -182,7 +184,7 @@ def _get_unique_sample_fastq_paths( list_of_files: list[Path] = get_all_files_in_directory_tree(delivery_path) for sample_name in sample_names: for file in list_of_files: - if sample_name in file.as_posix() and self._is_fastq_file(file): + if sample_name in file.as_posix() and self._is_lane_fastq_file(file): LOG.debug( f"[CONCATENATION SERVICE] Found fastq file: {file} for sample: {sample_name}" ) @@ -193,6 +195,10 @@ def _get_unique_sample_fastq_paths( read_direction=self._determine_read_direction(file), ) ) + if not sample_paths: + raise FileNotFoundError( + f"Could not find any fastq files to concatenate in {delivery_path}." + ) return sample_paths @staticmethod @@ -256,7 +262,7 @@ def _replace_fastq_paths( formatted_files: list[FormattedFile]: List of formatted files. """ for formatted_file in formatted_files: - if self._is_fastq_file(formatted_file.formatted_path): + if self._is_lane_fastq_file(formatted_file.formatted_path): formatted_file.formatted_path = concatenation_maps[formatted_file.formatted_path] @staticmethod @@ -280,5 +286,22 @@ def _all_sample_fastq_file_share_same_directory( ) @staticmethod - def _is_fastq_file(file_path: Path) -> bool: - return f"{FileFormat.FASTQ}{FileExtensions.GZIP}" in file_path.as_posix() + def _is_lane_fastq_file(file_path: Path) -> bool: + """Check if a fastq file is a from a lane and read direction. + Note pattern: *_L[0-9]{3}_R[1-2]_[0-9]{3}.fastq.gz + *_ is a wildcard for the flow cell id followed by sample name. + L[0-9]{3} is the lane number, i.e. L001, L002 etc. + R[1-2] is the read direction, i.e. R1 or R2. + [0-9]{3} is the trailing three digits after read direction. + args: + file_path: Path: Path to the fastq file. + """ + + pattern = f".*_L[0-9]{{3}}_R[1-2]_[0-9]{{3}}{FileExtensions.FASTQ}{FileExtensions.GZIP}" + return ( + re.fullmatch( + pattern=pattern, + string=file_path.name, + ) + is not None + ) diff --git a/cg/services/deliver_files/file_formatter/component_files/mutant_service.py b/cg/services/deliver_files/file_formatter/component_files/mutant_service.py index bbaeaced74..5179d48322 100644 --- a/cg/services/deliver_files/file_formatter/component_files/mutant_service.py +++ b/cg/services/deliver_files/file_formatter/component_files/mutant_service.py @@ -1,6 +1,6 @@ import logging from pathlib import Path - +import re from cg.apps.lims import LimsAPI from cg.services.deliver_files.file_fetcher.models import SampleFile from cg.services.deliver_files.file_formatter.component_files.abstract import ComponentFormatter @@ -59,12 +59,27 @@ def format_files( ) return unique_formatted_files + @staticmethod + def _is_concatenated_file(file_path: Path) -> bool: + """Check if the file is a concatenated file. + Returns True if the file is a concatenated file, otherwise False. + regex pattern: *._[1,2].fastq.gz + *. is the sample id + _[1,2] is the read direction + .fastq.gz is the file extension + args: + file_path: The file path to check + """ + pattern = ".*_[1,2].fastq.gz" + return re.fullmatch(pattern, file_path.name) is not None + def _add_lims_metadata_to_file_name( self, formatted_files: list[FormattedFile], sample_files: list[SampleFile] ) -> list[FormattedFile]: """ This functions adds the region and lab code to the file name of the formatted files. Note: The region and lab code is fetched from LIMS using the sample id. It is required for delivery of the files. + This should only be done for concatenated fastq files. args: formatted_files: The formatted files to add the metadata to @@ -72,19 +87,24 @@ def _add_lims_metadata_to_file_name( """ appended_formatted_files: list[FormattedFile] = [] for formatted_file in formatted_files: - sample_id: str = self._get_sample_id_by_original_path( - original_path=formatted_file.original_path, sample_files=sample_files - ) - lims_meta_data = self.lims_api.get_sample_region_and_lab_code(sample_id) + if self._is_concatenated_file(formatted_file.formatted_path): + sample_id: str = self._get_sample_id_by_original_path( + original_path=formatted_file.original_path, sample_files=sample_files + ) + lims_meta_data = self.lims_api.get_sample_region_and_lab_code(sample_id) - new_original_path: Path = formatted_file.formatted_path - new_formatted_path = Path( - formatted_file.formatted_path.parent, - f"{lims_meta_data}{formatted_file.formatted_path.name}", - ) - appended_formatted_files.append( - FormattedFile(original_path=new_original_path, formatted_path=new_formatted_path) - ) + new_original_path: Path = formatted_file.formatted_path + new_formatted_path = Path( + formatted_file.formatted_path.parent, + f"{lims_meta_data}{formatted_file.formatted_path.name}", + ) + appended_formatted_files.append( + FormattedFile( + original_path=new_original_path, formatted_path=new_formatted_path + ) + ) + else: + appended_formatted_files.append(formatted_file) return appended_formatted_files @staticmethod diff --git a/cg/services/deliver_files/tag_fetcher/fohm_upload_service.py b/cg/services/deliver_files/tag_fetcher/fohm_upload_service.py new file mode 100644 index 0000000000..55a0eee950 --- /dev/null +++ b/cg/services/deliver_files/tag_fetcher/fohm_upload_service.py @@ -0,0 +1,46 @@ +from cg.constants import Workflow, SequencingFileTag +from cg.services.deliver_files.tag_fetcher.abstract import FetchDeliveryFileTagsService +from cg.services.deliver_files.tag_fetcher.error_handling import handle_tag_errors +from cg.services.deliver_files.tag_fetcher.models import DeliveryFileTags + + +class FOHMUploadTagsFetcher(FetchDeliveryFileTagsService): + """Class to fetch tags for FOHM upload files.""" + + @handle_tag_errors + def fetch_tags(self, workflow: Workflow) -> DeliveryFileTags: + """ + Fetch the tags for the bam files to deliver. + NOTE: workflow raw data here is required to fit the implementation of the raw data delivery file fetcher. + if workflow is MUTANT, return tags for consensus-sample and vcf-report to fetch sample files from the case bundle. + if workflow is RAW_DATA, return tags for fastq to fetch fastq files from the sample bundle. + Required since some of the sample specific files are stored on the case bundle, but also fastq files. + Not separating these would cause fetching of case bundle fastq files if present. + + args: + workflow: Workflow: The workflow to fetch tags + """ + self._validate_workflow(workflow=workflow) + return ( + DeliveryFileTags( + case_tags=None, + sample_tags=[{"consensus-sample"}, {"vcf-report"}], + ) + if workflow == Workflow.MUTANT + else DeliveryFileTags( + case_tags=None, + sample_tags=[{SequencingFileTag.FASTQ}], + ) + ) + + @staticmethod + def _validate_workflow(workflow: Workflow): + """ + Validate the workflow. + NOTE: workflow raw data here is required to fit the implementation of the raw data delivery file fetcher. + args: + workflow: Workflow: The workflow to validate. + """ + if workflow not in [Workflow.MUTANT, Workflow.RAW_DATA]: + raise ValueError(f"Workflow {workflow} is not supported for FOHM upload file delivery.") + return workflow diff --git a/tests/fixture_plugins/delivery_fixtures/bundle_fixtures.py b/tests/fixture_plugins/delivery_fixtures/bundle_fixtures.py index 4196daa0ba..489a3f5a99 100644 --- a/tests/fixture_plugins/delivery_fixtures/bundle_fixtures.py +++ b/tests/fixture_plugins/delivery_fixtures/bundle_fixtures.py @@ -100,3 +100,59 @@ def hk_delivery_case_bundle( }, ] return case_hk_bundle + + +@pytest.fixture +def hk_delivery_case_bundle_fohm_upload( + case_hk_bundle_no_files: dict[str, Any], + case_id: str, + sample_id: str, + another_sample_id: str, + delivery_report_file: Path, + delivery_case_fastq_file: Path, + delivery_another_case_fastq_file: Path, + delivery_consensus_sample_file: Path, + delivery_another_consensus_sample_file: Path, + delivery_vcf_report_file: Path, + delivery_another_vcf_report_file: Path, +) -> dict: + case_hk_bundle: dict[str, Any] = deepcopy(case_hk_bundle_no_files) + case_hk_bundle["name"] = case_id + case_hk_bundle["files"] = [ + { + "archive": False, + "path": delivery_report_file.as_posix(), + "tags": [HK_DELIVERY_REPORT_TAG, case_id], + }, + { + "archive": False, + "path": delivery_case_fastq_file.as_posix(), + "tags": ["fastq", sample_id], + }, + { + "archive": False, + "path": delivery_another_case_fastq_file.as_posix(), + "tags": ["fastq", another_sample_id], + }, + { + "archive": False, + "path": delivery_consensus_sample_file.as_posix(), + "tags": ["consensus-sample", sample_id], + }, + { + "archive": False, + "path": delivery_another_consensus_sample_file.as_posix(), + "tags": ["consensus-sample", another_sample_id], + }, + { + "archive": False, + "path": delivery_vcf_report_file.as_posix(), + "tags": ["vcf-report", sample_id], + }, + { + "archive": False, + "path": delivery_another_vcf_report_file.as_posix(), + "tags": ["vcf-report", another_sample_id], + }, + ] + return case_hk_bundle diff --git a/tests/fixture_plugins/delivery_fixtures/context_fixtures.py b/tests/fixture_plugins/delivery_fixtures/context_fixtures.py index 7d35372a20..95a8e576be 100644 --- a/tests/fixture_plugins/delivery_fixtures/context_fixtures.py +++ b/tests/fixture_plugins/delivery_fixtures/context_fixtures.py @@ -21,16 +21,33 @@ def delivery_housekeeper_api( hk_delivery_case_bundle: dict[str, Any], ) -> HousekeeperAPI: """Delivery API Housekeeper context.""" + hk_api: HousekeeperAPI = real_housekeeper_api + helpers.ensure_hk_bundle(store=hk_api, bundle_data=hk_delivery_sample_bundle, include=True) helpers.ensure_hk_bundle( - store=real_housekeeper_api, bundle_data=hk_delivery_sample_bundle, include=True + store=hk_api, bundle_data=hk_delivery_another_sample_bundle, include=True ) + helpers.ensure_hk_bundle(store=hk_api, bundle_data=hk_delivery_case_bundle, include=True) + return hk_api + + +@pytest.fixture +def delivery_fohm_upload_housekeeper_api( + real_housekeeper_api: HousekeeperAPI, + helpers: StoreHelpers, + hk_delivery_case_bundle_fohm_upload: dict[str, Any], + hk_delivery_sample_bundle: dict[str, Any], + hk_delivery_another_sample_bundle: dict[str, Any], +) -> HousekeeperAPI: + """Delivery API Housekeeper context.""" + hk_api: HousekeeperAPI = real_housekeeper_api + helpers.ensure_hk_bundle(store=hk_api, bundle_data=hk_delivery_sample_bundle, include=True) helpers.ensure_hk_bundle( - store=real_housekeeper_api, bundle_data=hk_delivery_another_sample_bundle, include=True + store=hk_api, bundle_data=hk_delivery_another_sample_bundle, include=True ) helpers.ensure_hk_bundle( - store=real_housekeeper_api, bundle_data=hk_delivery_case_bundle, include=True + store=hk_api, bundle_data=hk_delivery_case_bundle_fohm_upload, include=True ) - return real_housekeeper_api + return hk_api @pytest.fixture @@ -144,6 +161,68 @@ def delivery_store_microsalt( return status_db +@pytest.fixture +def delivery_store_mutant( + cg_context: CGConfig, + helpers: StoreHelpers, + case_id: str, + no_sample_case_id: str, + case_name: str, + sample_id: str, + another_sample_id: str, + sample_id_not_enough_reads: str, + total_sequenced_reads_pass: int, + total_sequenced_reads_not_pass: int, + sample_name: str, + another_sample_name: str, + microbial_application_tag: str, +) -> Store: + """Delivery API StatusDB context for Mutant.""" + status_db: Store = cg_context.status_db + + # Error case without samples + helpers.add_case(store=status_db, internal_id=no_sample_case_id, name=no_sample_case_id) + + # Mutant case with fastq-analysis as data delivery + case: Case = helpers.add_case( + store=status_db, + internal_id=case_id, + name=case_name, + data_analysis=Workflow.MUTANT, + data_delivery=DataDelivery.FASTQ_ANALYSIS, + ) + order: Order = helpers.add_order(store=status_db, customer_id=case.customer.id, ticket_id=1) + case.orders.append(order) + # Mutant samples + sample: Sample = helpers.add_sample( + store=status_db, + application_tag=microbial_application_tag, + internal_id=sample_id, + name=sample_name, + reads=total_sequenced_reads_pass, + ) + + another_sample: Sample = helpers.add_sample( + store=status_db, + application_tag=microbial_application_tag, + internal_id=another_sample_id, + name=another_sample_name, + reads=total_sequenced_reads_pass, + ) + + sample_not_enough_reads: Sample = helpers.add_sample( + store=status_db, + application_tag=microbial_application_tag, + internal_id=sample_id_not_enough_reads, + reads=total_sequenced_reads_not_pass, + ) + + for sample_mutant in [sample, another_sample, sample_not_enough_reads]: + helpers.add_relationship(store=status_db, case=case, sample=sample_mutant) + + return status_db + + @pytest.fixture def delivery_context_balsamic( cg_context: CGConfig, diff --git a/tests/fixture_plugins/delivery_fixtures/delivery_files_models_fixtures.py b/tests/fixture_plugins/delivery_fixtures/delivery_files_models_fixtures.py index 9c85f3a331..ea2b2e8337 100644 --- a/tests/fixture_plugins/delivery_fixtures/delivery_files_models_fixtures.py +++ b/tests/fixture_plugins/delivery_fixtures/delivery_files_models_fixtures.py @@ -1,3 +1,4 @@ +import os from pathlib import Path import pytest @@ -102,6 +103,66 @@ def expected_bam_delivery_files_single_sample( return expected_bam_delivery_files +@pytest.fixture +def expected_fohm_delivery_files( + delivery_fohm_upload_housekeeper_api: HousekeeperAPI, + case_id: str, + case_name: str, + sample_id: str, + sample_name: str, + another_sample_id: str, + another_sample_name: str, + delivery_store_mutant: Store, +) -> DeliveryFiles: + """Return the expected fastq delivery files.""" + sample_info: list[tuple[str, str]] = [ + (sample_id, sample_name), + (another_sample_id, another_sample_name), + ] + sample_files: list[SampleFile] = [ + SampleFile( + case_id=case_id, + sample_id=sample[0], + sample_name=sample[1], + file_path=delivery_fohm_upload_housekeeper_api.get_files_from_latest_version( + bundle_name=sample[0], tags=[SequencingFileTag.FASTQ] + )[0].full_path, + ) + for sample in sample_info + ] + case_sample_info: list[tuple[str, str, str]] = [ + (sample_id, sample_name, "consensus-sample"), + (sample_id, sample_name, "vcf-report"), + (another_sample_id, another_sample_name, "consensus-sample"), + (another_sample_id, another_sample_name, "vcf-report"), + ] + case_sample_files: list[SampleFile] = [ + SampleFile( + case_id=case_id, + sample_id=sample[0], + sample_name=sample[1], + file_path=delivery_fohm_upload_housekeeper_api.get_files_from_latest_version_containing_tags( + bundle_name=case_id, tags=[{sample[2], sample[0]}] + )[ + 0 + ].full_path, + ) + for sample in case_sample_info + ] + + case: Case = delivery_store_mutant.get_case_by_internal_id(case_id) + delivery_meta_data = DeliveryMetaData( + case_id=case.internal_id, + customer_internal_id=case.customer.internal_id, + ticket_id=case.latest_ticket, + ) + return DeliveryFiles( + delivery_data=delivery_meta_data, + case_files=[], + sample_files=case_sample_files + sample_files, + ) + + @pytest.fixture def expected_analysis_delivery_files( delivery_housekeeper_api: HousekeeperAPI, @@ -242,10 +303,10 @@ def fastq_concatenation_sample_files( sample_files = [] for sample_id, sample_name in sample_data: fastq_paths: list[Path] = [ - Path(tmp_path, inbox, f"{sample_id}_1_R1_1.fastq.gz"), - Path(tmp_path, inbox, f"{sample_id}_2_R1_1.fastq.gz"), - Path(tmp_path, inbox, f"{sample_id}_1_R2_1.fastq.gz"), - Path(tmp_path, inbox, f"{sample_id}_2_R2_1.fastq.gz"), + Path(tmp_path, inbox, f"{sample_id}_L001_R1_001.fastq.gz"), + Path(tmp_path, inbox, f"{sample_id}_L002_R1_001.fastq.gz"), + Path(tmp_path, inbox, f"{sample_id}_L001_R2_001.fastq.gz"), + Path(tmp_path, inbox, f"{sample_id}_L002_R2_001.fastq.gz"), ] sample_files.extend( @@ -268,10 +329,10 @@ def fastq_concatenation_sample_files_flat(tmp_path: Path) -> list[SampleFile]: sample_files = [] for sample_id, sample_name in sample_data: fastq_paths: list[Path] = [ - Path(tmp_path, f"{sample_id}_1_R1_1.fastq.gz"), - Path(tmp_path, f"{sample_id}_2_R1_1.fastq.gz"), - Path(tmp_path, f"{sample_id}_1_R2_1.fastq.gz"), - Path(tmp_path, f"{sample_id}_2_R2_1.fastq.gz"), + Path(tmp_path, f"{sample_id}_L001_R1_001.fastq.gz"), + Path(tmp_path, f"{sample_id}_L002_R1_001.fastq.gz"), + Path(tmp_path, f"{sample_id}_L001_R2_001.fastq.gz"), + Path(tmp_path, f"{sample_id}_L002_R2_001.fastq.gz"), ] sample_files.extend( @@ -301,20 +362,20 @@ def swap_file_paths_with_inbox_paths( @pytest.fixture -def lims_naming_matadata() -> str: +def lims_naming_metadata() -> str: return "01_SE100_" @pytest.fixture def expected_mutant_formatted_files( - expected_concatenated_fastq_formatted_files, lims_naming_matadata + expected_concatenated_fastq_formatted_files, lims_naming_metadata ) -> list[FormattedFile]: unique_combinations = [] for formatted_file in expected_concatenated_fastq_formatted_files: formatted_file.original_path = formatted_file.formatted_path formatted_file.formatted_path = Path( formatted_file.formatted_path.parent, - f"{lims_naming_matadata}{formatted_file.formatted_path.name}", + f"{lims_naming_metadata}{formatted_file.formatted_path.name}", ) if formatted_file not in unique_combinations: unique_combinations.append(formatted_file) diff --git a/tests/fixture_plugins/delivery_fixtures/delivery_formatted_files_fixtures.py b/tests/fixture_plugins/delivery_fixtures/delivery_formatted_files_fixtures.py index c80f9b2f1e..2e90df0f80 100644 --- a/tests/fixture_plugins/delivery_fixtures/delivery_formatted_files_fixtures.py +++ b/tests/fixture_plugins/delivery_fixtures/delivery_formatted_files_fixtures.py @@ -85,10 +85,10 @@ def expected_concatenated_fastq_formatted_files( replaced_sample_file_name: str = sample_file.file_path.name.replace( sample_file.sample_id, sample_file.sample_name ) - replaced_sample_file_name = replaced_sample_file_name.replace("1_R1_1", "1") - replaced_sample_file_name = replaced_sample_file_name.replace("2_R1_1", "1") - replaced_sample_file_name = replaced_sample_file_name.replace("1_R2_1", "2") - replaced_sample_file_name = replaced_sample_file_name.replace("2_R2_1", "2") + replaced_sample_file_name = replaced_sample_file_name.replace("L001_R1_001", "1") + replaced_sample_file_name = replaced_sample_file_name.replace("L002_R1_001", "1") + replaced_sample_file_name = replaced_sample_file_name.replace("L001_R2_001", "2") + replaced_sample_file_name = replaced_sample_file_name.replace("L002_R2_001", "2") formatted_file_path = Path( sample_file.file_path.parent, sample_file.sample_name, replaced_sample_file_name ) @@ -107,10 +107,10 @@ def expected_concatenated_fastq_flat_formatted_files( replaced_sample_file_name: str = sample_file.file_path.name.replace( sample_file.sample_id, sample_file.sample_name ) - replaced_sample_file_name = replaced_sample_file_name.replace("1_R1_1", "1") - replaced_sample_file_name = replaced_sample_file_name.replace("2_R1_1", "1") - replaced_sample_file_name = replaced_sample_file_name.replace("1_R2_1", "2") - replaced_sample_file_name = replaced_sample_file_name.replace("2_R2_1", "2") + replaced_sample_file_name = replaced_sample_file_name.replace("L001_R1_001", "1") + replaced_sample_file_name = replaced_sample_file_name.replace("L002_R1_001", "1") + replaced_sample_file_name = replaced_sample_file_name.replace("L001_R2_001", "2") + replaced_sample_file_name = replaced_sample_file_name.replace("L002_R2_001", "2") formatted_file_path = Path(sample_file.file_path.parent, replaced_sample_file_name) formatted_files.append( FormattedFile(original_path=sample_file.file_path, formatted_path=formatted_file_path) diff --git a/tests/fixture_plugins/delivery_fixtures/delivery_services_fixtures.py b/tests/fixture_plugins/delivery_fixtures/delivery_services_fixtures.py index 6568ac4f1b..95a8f752f7 100644 --- a/tests/fixture_plugins/delivery_fixtures/delivery_services_fixtures.py +++ b/tests/fixture_plugins/delivery_fixtures/delivery_services_fixtures.py @@ -1,10 +1,14 @@ import pytest from cg.apps.housekeeper.hk import HousekeeperAPI +from cg.services.deliver_files.file_fetcher.analysis_raw_data_service import ( + RawDataAndAnalysisDeliveryFileFetcher, +) from cg.services.deliver_files.file_formatter.destination.base_service import BaseDeliveryFormatter from cg.services.deliver_files.tag_fetcher.bam_service import ( BamDeliveryTagsFetcher, ) +from cg.services.deliver_files.tag_fetcher.fohm_upload_service import FOHMUploadTagsFetcher from cg.services.deliver_files.tag_fetcher.sample_and_case_service import ( SampleAndCaseDeliveryTagsFetcher, ) @@ -83,6 +87,20 @@ def bam_data_delivery_service_no_housekeeper_bundle( ) +@pytest.fixture +def fohm_data_delivery_service( + delivery_fohm_upload_housekeeper_api: HousekeeperAPI, + delivery_store_mutant: Store, +) -> RawDataAndAnalysisDeliveryFileFetcher: + """Fixture to get an instance of FetchFastqDeliveryFilesService.""" + tag_service = FOHMUploadTagsFetcher() + return RawDataAndAnalysisDeliveryFileFetcher( + hk_api=delivery_fohm_upload_housekeeper_api, + status_db=delivery_store_mutant, + tags_fetcher=tag_service, + ) + + @pytest.fixture def analysis_delivery_service( delivery_housekeeper_api: HousekeeperAPI, diff --git a/tests/fixture_plugins/delivery_fixtures/path_fixtures.py b/tests/fixture_plugins/delivery_fixtures/path_fixtures.py index 06b77d6959..22d682b014 100644 --- a/tests/fixture_plugins/delivery_fixtures/path_fixtures.py +++ b/tests/fixture_plugins/delivery_fixtures/path_fixtures.py @@ -9,7 +9,18 @@ @pytest.fixture def delivery_fastq_file(tmp_path: Path, sample_id: str) -> Path: - file = Path(tmp_path, f"{sample_id}_R1_001{FileExtensions.FASTQ_GZ}") + file = Path(tmp_path, f"{sample_id}_L001_R1_001{FileExtensions.FASTQ_GZ}") + file.touch() + return file + + +@pytest.fixture +def delivery_case_fastq_file(tmp_path: Path, sample_id: str) -> Path: + """ + This represents a fastq file stored on a case bundle. Mutant stored file like this in the past. + This fixture servers the purpose to make sure these files are not fetched during delivery. + """ + file = Path(tmp_path, f"{sample_id}_concat_{FileExtensions.FASTQ_GZ}") file.touch() return file @@ -23,7 +34,18 @@ def delivery_bam_file(tmp_path: Path, sample_id: str) -> Path: @pytest.fixture def delivery_another_fastq_file(tmp_path: Path, another_sample_id: str) -> Path: - file = Path(tmp_path, f"{another_sample_id}_R1_001{FileExtensions.FASTQ_GZ}") + file = Path(tmp_path, f"{another_sample_id}L001_R1_001{FileExtensions.FASTQ_GZ}") + file.touch() + return file + + +@pytest.fixture +def delivery_another_case_fastq_file(tmp_path: Path, another_sample_id: str) -> Path: + """ + This represents a fastq file stored on a case bundle. Mutant stored file like this in the past. + This fixture servers the purpose to make sure these files are not fetched during delivery. + """ + file = Path(tmp_path, f"{another_sample_id}_concat_{FileExtensions.FASTQ_GZ}") file.touch() return file @@ -73,3 +95,31 @@ def delivery_another_cram_file(tmp_path: Path, another_sample_id: str) -> Path: @pytest.fixture def delivery_ticket_dir_path(tmp_path: Path, ticket_id: str) -> Path: return Path(tmp_path, ticket_id) + + +@pytest.fixture +def delivery_consensus_sample_file(tmp_path: Path, sample_id: str) -> Path: + file = Path(tmp_path, f"{sample_id}_consensus_sample{FileExtensions.VCF}") + file.touch() + return file + + +@pytest.fixture +def delivery_another_consensus_sample_file(tmp_path: Path, another_sample_id: str) -> Path: + file = Path(tmp_path, f"{another_sample_id}_consensus_sample{FileExtensions.VCF}") + file.touch() + return file + + +@pytest.fixture +def delivery_vcf_report_file(tmp_path: Path, sample_id: str) -> Path: + file = Path(tmp_path, f"{sample_id}_vcf_report{FileExtensions.VCF}") + file.touch() + return file + + +@pytest.fixture +def delivery_another_vcf_report_file(tmp_path: Path, another_sample_id: str) -> Path: + file = Path(tmp_path, f"{another_sample_id}_vcf_report{FileExtensions.VCF}") + file.touch() + return file diff --git a/tests/services/fastq_file_service/conftest.py b/tests/services/fastq_file_service/conftest.py index 21352cb248..06860373a7 100644 --- a/tests/services/fastq_file_service/conftest.py +++ b/tests/services/fastq_file_service/conftest.py @@ -38,6 +38,19 @@ def fastqs_dir(tmp_path: Path, sample_id: str) -> Path: return fastq_dir +@pytest.fixture +def fastq_dir_existing_concatenated_files(tmp_path: Path, sample_id: str) -> Path: + fastq_dir: Path = create_fastqs_directory(tmp_path=tmp_path) + create_fastq_files( + fastq_dir=fastq_dir, number_forward_reads=3, number_reverse_reads=3, sample_id=sample_id + ) + forward_output_path = Path(fastq_dir, "forward.fastq.gz") + reverse_output_path = Path(fastq_dir, "reverse.fastq.gz") + forward_output_path.write_text("Existing concatenated forward reads") + reverse_output_path.write_text("Existing concatenated reverse reads") + return fastq_dir + + @pytest.fixture def fastqs_forward(tmp_path: Path, sample_id: str) -> Path: """Return a directory with only forward reads.""" diff --git a/tests/services/fastq_file_service/test_fastq_file_service.py b/tests/services/fastq_file_service/test_fastq_file_service.py index bd6b707175..546438c6d9 100644 --- a/tests/services/fastq_file_service/test_fastq_file_service.py +++ b/tests/services/fastq_file_service/test_fastq_file_service.py @@ -63,20 +63,25 @@ def test_concatenate( def test_concatenate_when_output_exists( - fastq_file_service: FastqConcatenationService, fastqs_dir: Path, sample_id: str + fastq_file_service: FastqConcatenationService, + fastq_dir_existing_concatenated_files: Path, + sample_id: str, ): + """Test that existing concatenated files are overwritten when already existing.""" # GIVEN a directory with forward and reverse reads - existing_fastq_files = list(fastqs_dir.iterdir()) - existing_forward: Path = existing_fastq_files[0] + forward_output_path = Path(fastq_dir_existing_concatenated_files, "forward.fastq.gz") + reverse_output_path = Path(fastq_dir_existing_concatenated_files, "reverse.fastq.gz") # GIVEN that the forward output file already exists - forward_output_path = existing_forward - reverse_output_path = Path(fastqs_dir, "reverse.fastq.gz") + assert forward_output_path.exists() + assert reverse_output_path.exists() + assert "Existing" in forward_output_path.read_text() + assert "Existing" in reverse_output_path.read_text() # WHEN concatenating the reads fastq_file_service.concatenate( sample_id=sample_id, - fastq_directory=fastqs_dir, + fastq_directory=fastq_dir_existing_concatenated_files, forward_output_path=forward_output_path, reverse_output_path=reverse_output_path, remove_raw=True, @@ -89,10 +94,12 @@ def test_concatenate_when_output_exists( # THEN the concatenated forward reads only contain forward reads assert "forward" in forward_output_path.read_text() assert "reverse" not in forward_output_path.read_text() + assert "Existing" not in forward_output_path.read_text() # THEN the concatenated reverse reads only contain reverse reads assert "reverse" in reverse_output_path.read_text() assert "forward" not in reverse_output_path.read_text() + assert "Existing" not in reverse_output_path.read_text() def test_concatenate_missing_reverse( diff --git a/tests/services/file_delivery/delivery_file_service/test_service_builder.py b/tests/services/file_delivery/delivery_file_service/test_service_builder.py index 565bb167f0..283f93f7d2 100644 --- a/tests/services/file_delivery/delivery_file_service/test_service_builder.py +++ b/tests/services/file_delivery/delivery_file_service/test_service_builder.py @@ -5,6 +5,7 @@ from pydantic import BaseModel from cg.constants import DataDelivery, Workflow +from cg.services.deliver_files.constants import DeliveryDestination, DeliveryStructure from cg.services.deliver_files.deliver_files_service.deliver_files_service import ( DeliverFilesService, ) @@ -26,10 +27,20 @@ from cg.services.deliver_files.file_formatter.component_files.sample_service import ( SampleFileFormatter, ) +from cg.services.deliver_files.file_formatter.path_name.abstract import PathNameFormatter +from cg.services.deliver_files.file_formatter.path_name.flat_structure import ( + FlatStructurePathFormatter, +) +from cg.services.deliver_files.file_formatter.path_name.nested_structure import ( + NestedStructurePathFormatter, +) +from cg.services.deliver_files.file_mover.abstract import DestinationFilesMover +from cg.services.deliver_files.file_mover.base_service import BaseDestinationFilesMover from cg.services.deliver_files.file_mover.customer_inbox_service import ( CustomerInboxDestinationFilesMover, ) from cg.services.deliver_files.tag_fetcher.abstract import FetchDeliveryFileTagsService +from cg.services.deliver_files.tag_fetcher.fohm_upload_service import FOHMUploadTagsFetcher from cg.services.deliver_files.tag_fetcher.sample_and_case_service import ( SampleAndCaseDeliveryTagsFetcher, ) @@ -43,11 +54,14 @@ class DeliveryServiceScenario(BaseModel): delivery_type: DataDelivery expected_tag_fetcher: type[FetchDeliveryFileTagsService] expected_file_fetcher: type[FetchDeliveryFilesService] - expected_file_mover: type[CustomerInboxDestinationFilesMover] + expected_file_mover: type[DestinationFilesMover] expected_sample_file_formatter: type[ SampleFileFormatter | SampleFileConcatenationFormatter | MutantFileFormatter ] + expected_path_name_formatter: type[PathNameFormatter] store_name: str + delivery_destination: DeliveryDestination + delivery_structure: DeliveryStructure @pytest.mark.parametrize( @@ -61,7 +75,10 @@ class DeliveryServiceScenario(BaseModel): expected_file_fetcher=RawDataDeliveryFileFetcher, expected_file_mover=CustomerInboxDestinationFilesMover, expected_sample_file_formatter=SampleFileConcatenationFormatter, + expected_path_name_formatter=NestedStructurePathFormatter, store_name="microbial_store", + delivery_destination=DeliveryDestination.CUSTOMER, + delivery_structure=DeliveryStructure.NESTED, ), DeliveryServiceScenario( app_tag="VWGDPTR001", @@ -71,7 +88,10 @@ class DeliveryServiceScenario(BaseModel): expected_file_fetcher=AnalysisDeliveryFileFetcher, expected_file_mover=CustomerInboxDestinationFilesMover, expected_sample_file_formatter=MutantFileFormatter, + expected_path_name_formatter=NestedStructurePathFormatter, store_name="mutant_store", + delivery_destination=DeliveryDestination.CUSTOMER, + delivery_structure=DeliveryStructure.NESTED, ), DeliveryServiceScenario( app_tag="PANKTTR020", @@ -81,10 +101,39 @@ class DeliveryServiceScenario(BaseModel): expected_file_fetcher=RawDataAndAnalysisDeliveryFileFetcher, expected_file_mover=CustomerInboxDestinationFilesMover, expected_sample_file_formatter=SampleFileFormatter, + expected_path_name_formatter=NestedStructurePathFormatter, store_name="applications_store", + delivery_destination=DeliveryDestination.CUSTOMER, + delivery_structure=DeliveryStructure.NESTED, + ), + DeliveryServiceScenario( + app_tag="VWGDPTR001", + data_analysis=Workflow.MUTANT, + delivery_type=DataDelivery.ANALYSIS_FILES, + expected_tag_fetcher=FOHMUploadTagsFetcher, + expected_file_fetcher=AnalysisDeliveryFileFetcher, + expected_file_mover=BaseDestinationFilesMover, + expected_sample_file_formatter=MutantFileFormatter, + expected_path_name_formatter=FlatStructurePathFormatter, + store_name="mutant_store", + delivery_destination=DeliveryDestination.FOHM, + delivery_structure=DeliveryStructure.FLAT, + ), + DeliveryServiceScenario( + app_tag="VWGDPTR001", + data_analysis=Workflow.MUTANT, + delivery_type=DataDelivery.ANALYSIS_FILES, + expected_tag_fetcher=SampleAndCaseDeliveryTagsFetcher, + expected_file_fetcher=AnalysisDeliveryFileFetcher, + expected_file_mover=BaseDestinationFilesMover, + expected_sample_file_formatter=MutantFileFormatter, + expected_path_name_formatter=FlatStructurePathFormatter, + store_name="mutant_store", + delivery_destination=DeliveryDestination.BASE, + delivery_structure=DeliveryStructure.FLAT, ), ], - ids=["microbial-fastq", "SARS-COV2", "Targeted"], + ids=["microbial-fastq", "SARS-COV2", "Targeted", "FOHM Upload", "base"], ) def test_build_delivery_service(scenario: DeliveryServiceScenario, request: FixtureRequest): # GIVEN a delivery service builder with mocked store and hk_api @@ -106,7 +155,11 @@ def test_build_delivery_service(scenario: DeliveryServiceScenario, request: Fixt ] # WHEN building a delivery service - delivery_service: DeliverFilesService = builder.build_delivery_service(case=case_mock) + delivery_service: DeliverFilesService = builder.build_delivery_service( + case=case_mock, + delivery_destination=scenario.delivery_destination, + delivery_structure=scenario.delivery_structure, + ) # THEN the correct file formatter and file fetcher services are used assert isinstance(delivery_service.file_manager.tags_fetcher, scenario.expected_tag_fetcher) @@ -116,3 +169,12 @@ def test_build_delivery_service(scenario: DeliveryServiceScenario, request: Fixt delivery_service.file_formatter.sample_file_formatter, scenario.expected_sample_file_formatter, ) + if not isinstance(delivery_service.file_formatter.sample_file_formatter, MutantFileFormatter): + assert isinstance( + delivery_service.file_formatter.sample_file_formatter.path_name_formatter, + scenario.expected_path_name_formatter, + ) + assert isinstance( + delivery_service.file_formatter.case_file_formatter.path_name_formatter, + scenario.expected_path_name_formatter, + ) diff --git a/tests/services/file_delivery/file_fetcher/test_file_fetching_service.py b/tests/services/file_delivery/file_fetcher/test_file_fetching_service.py index 878688bfb3..2974d6aa66 100644 --- a/tests/services/file_delivery/file_fetcher/test_file_fetching_service.py +++ b/tests/services/file_delivery/file_fetcher/test_file_fetching_service.py @@ -8,8 +8,9 @@ @pytest.mark.parametrize( - "expected_delivery_files,delivery_file_service,sample_id", + "expected_delivery_files,delivery_file_service,sample_id_to_fetch", [ + ("expected_fohm_delivery_files", "fohm_data_delivery_service", "empty_sample"), ("expected_fastq_delivery_files", "raw_data_delivery_service", "empty_sample"), ("expected_analysis_delivery_files", "analysis_delivery_service", "empty_sample"), ("expected_bam_delivery_files", "bam_data_delivery_service", "empty_sample"), @@ -19,7 +20,7 @@ def test_get_files_to_deliver( expected_delivery_files: DeliveryFiles, delivery_file_service: FetchDeliveryFilesService, - sample_id: str | None, + sample_id_to_fetch: str | None, case_id: str, request, ): @@ -27,7 +28,7 @@ def test_get_files_to_deliver( # GIVEN a case id, samples that are present in Housekeeper and a delivery service delivery_file_service = request.getfixturevalue(delivery_file_service) expected_delivery_files = request.getfixturevalue(expected_delivery_files) - sample_id = request.getfixturevalue(sample_id) + sample_id: str | None = request.getfixturevalue(sample_id_to_fetch) # WHEN getting the files to deliver delivery_files: DeliveryFiles = delivery_file_service.get_files_to_deliver( diff --git a/tests/services/file_delivery/file_formatter/component_files/test_formatter_utils.py b/tests/services/file_delivery/file_formatter/component_files/test_formatter_utils.py index 34cbf79f67..c42c8b92cd 100644 --- a/tests/services/file_delivery/file_formatter/component_files/test_formatter_utils.py +++ b/tests/services/file_delivery/file_formatter/component_files/test_formatter_utils.py @@ -104,7 +104,7 @@ def test_component_formatters( def test_mutant_file_formatter( mutant_moved_files: list[SampleFile], expected_mutant_formatted_files: list[FormattedFile], - lims_naming_matadata: str, + lims_naming_metadata: str, ): # GIVEN existing ticket directory path and a customer inbox ticket_dir_path: Path = mutant_moved_files[0].file_path.parent @@ -115,7 +115,7 @@ def test_mutant_file_formatter( moved_file.file_path.touch() lims_mock = Mock() - lims_mock.get_sample_region_and_lab_code.return_value = lims_naming_matadata + lims_mock.get_sample_region_and_lab_code.return_value = lims_naming_metadata file_formatter = MutantFileFormatter( file_manager=FileManager(), file_formatter=SampleFileConcatenationFormatter( diff --git a/tests/services/file_delivery/tag_fetcher/test_tag_service.py b/tests/services/file_delivery/tag_fetcher/test_tag_service.py index 6e54fdc73f..e1b541b15f 100644 --- a/tests/services/file_delivery/tag_fetcher/test_tag_service.py +++ b/tests/services/file_delivery/tag_fetcher/test_tag_service.py @@ -10,6 +10,7 @@ from cg.services.deliver_files.tag_fetcher.exc import ( FetchDeliveryFileTagsError, ) +from cg.services.deliver_files.tag_fetcher.fohm_upload_service import FOHMUploadTagsFetcher from cg.services.deliver_files.tag_fetcher.models import DeliveryFileTags from cg.services.deliver_files.tag_fetcher.sample_and_case_service import ( SampleAndCaseDeliveryTagsFetcher, @@ -64,3 +65,15 @@ def test_bam_delivery_tags_fetcher(): # THEN assert that the tags are fetched assert tags.case_tags is None assert tags.sample_tags == [{"bam"}] + + +def test_fohm_upload_tags_fetcher(): + # GIVEN a tag fetcher + test_fetcher = FOHMUploadTagsFetcher() + + # WHEN fetching the tags for the files to deliver + tags: DeliveryFileTags = test_fetcher.fetch_tags(Workflow.MUTANT) + + # THEN assert that the tags are fetched + assert tags.case_tags is None + assert tags.sample_tags == [{"consensus-sample"}, {"vcf-report"}] From 9054b1b670f2a72b65e98e5018270b8054b7729a Mon Sep 17 00:00:00 2001 From: ChristianOertlin Date: Tue, 17 Dec 2024 11:18:26 +0100 Subject: [PATCH 67/80] add(FOHM and GSAID to upload API) (#4028) # Description Adds GSAID and FOHM upload to mutantuploadapi --- cg/meta/upload/fohm/fohm.py | 10 +++++++--- cg/meta/upload/mutant/mutant.py | 8 ++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/cg/meta/upload/fohm/fohm.py b/cg/meta/upload/fohm/fohm.py index 95ceab74ab..3fa1342da4 100644 --- a/cg/meta/upload/fohm/fohm.py +++ b/cg/meta/upload/fohm/fohm.py @@ -368,9 +368,13 @@ def parse_and_write_pangolin_report(self) -> list[FohmPangolinReport]: self.create_pangolin_report(sars_cov_pangolin_reports) return sars_cov_pangolin_reports - def aggregate_delivery(self, cases: list[str]) -> None: - """Aggregate and hardlink reports.""" - self.set_cases_to_aggregate(cases) + def aggregate_delivery(self, case_ids: list[str]) -> None: + """ + Aggregate and hardlink reports. + args: + case_ids: The internal ids for cases to aggregate. + """ + self.set_cases_to_aggregate(case_ids) self.create_daily_delivery_folders() sars_cov_complementary_reports: list[FohmComplementaryReport] = ( self.parse_and_write_complementary_report() diff --git a/cg/meta/upload/mutant/mutant.py b/cg/meta/upload/mutant/mutant.py index 1f08d72708..a61398ebe1 100644 --- a/cg/meta/upload/mutant/mutant.py +++ b/cg/meta/upload/mutant/mutant.py @@ -1,5 +1,7 @@ from click import Context +from cg.meta.upload.fohm.fohm import FOHMUploadAPI +from cg.meta.upload.gisaid import GisaidAPI from cg.meta.upload.upload_api import UploadAPI from cg.meta.workflow.mutant import MutantAnalysisAPI from cg.models.cg_config import CGConfig @@ -10,10 +12,16 @@ class MutantUploadAPI(UploadAPI): def __init__(self, config: CGConfig): self.analysis_api: MutantAnalysisAPI = MutantAnalysisAPI(config) + self.fohm_api = FOHMUploadAPI(config) + self.gsaid_api = GisaidAPI(config) + super().__init__(config=config, analysis_api=self.analysis_api) def upload(self, ctx: Context, case: Case, restart: bool) -> None: latest_analysis: Analysis = case.analyses[0] self.update_upload_started_at(latest_analysis) self.upload_files_to_customer_inbox(case) + self.gsaid_api.upload(case.internal_id) + self.fohm_api.aggregate_delivery(case_ids=[case.internal_id]) + self.fohm_api.sync_files_sftp() self.update_uploaded_at(latest_analysis) From 4962b8b75bdc12773ba920c30a1b4cf187a36697 Mon Sep 17 00:00:00 2001 From: ChristianOertlin Date: Tue, 17 Dec 2024 12:45:19 +0100 Subject: [PATCH 68/80] update(covid orderform) (#4020) # description Update covid orderform --- cg/constants/orderforms.py | 2 +- .../fixtures/orderforms/2184.10.sarscov2.xlsx | Bin 0 -> 214456 bytes tests/fixtures/orderforms/2184.9.sarscov2.xlsx | Bin 223184 -> 0 bytes 3 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 tests/fixtures/orderforms/2184.10.sarscov2.xlsx delete mode 100644 tests/fixtures/orderforms/2184.9.sarscov2.xlsx diff --git a/cg/constants/orderforms.py b/cg/constants/orderforms.py index 8c69f97073..f906d899b2 100644 --- a/cg/constants/orderforms.py +++ b/cg/constants/orderforms.py @@ -39,7 +39,7 @@ def get_current_orderform_version(order_form: str) -> str: Orderform.MIP_DNA: "32", Orderform.RML: "19", Orderform.MICROSALT: "11", - Orderform.SARS_COV_2: "9", + Orderform.SARS_COV_2: "10", Orderform.MICROBIAL_FASTQ: "1", Orderform.PACBIO_LONG_READ: "1", } diff --git a/tests/fixtures/orderforms/2184.10.sarscov2.xlsx b/tests/fixtures/orderforms/2184.10.sarscov2.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..80b49b58a300717f8f17d2494e6223bd4e7f14e8 GIT binary patch literal 214456 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≈gdPflw5#4;UUT{6PfB` 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 literal 0 HcmV?d00001 diff --git a/tests/fixtures/orderforms/2184.9.sarscov2.xlsx b/tests/fixtures/orderforms/2184.9.sarscov2.xlsx deleted file mode 100644 index 6f4de53e5dfe9f1961c6cfff9aeeaa1cb5e3801d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 223184 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⪼6fu3WAvOjZ;cto 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 DRkY!q From f6ca8b3d6c4bb8206d65696701a077ff81359035 Mon Sep 17 00:00:00 2001 From: Christian Oertlin Date: Tue, 17 Dec 2024 13:37:09 +0100 Subject: [PATCH 69/80] complete docstrings --- .../deliver_files_service.py | 26 +++++++++++++-- cg/services/deliver_files/factory.py | 21 ++++++++++-- .../file_fetcher/analysis_raw_data_service.py | 14 +++++++- .../file_fetcher/analysis_service.py | 33 ++++++++++++++++--- .../file_fetcher/raw_data_service.py | 20 +++++++++-- .../file_mover/customer_inbox_service.py | 2 -- .../deliver_files/tag_fetcher/bam_service.py | 7 +++- .../tag_fetcher/fohm_upload_service.py | 1 + .../deliver_files/tag_fetcher/models.py | 6 ++++ .../tag_fetcher/sample_and_case_service.py | 5 ++- cg/services/deliver_files/utils.py | 21 ++++++++++-- 11 files changed, 135 insertions(+), 21 deletions(-) diff --git a/cg/services/deliver_files/deliver_files_service/deliver_files_service.py b/cg/services/deliver_files/deliver_files_service/deliver_files_service.py index b13f4e3f4d..bb9ad885f3 100644 --- a/cg/services/deliver_files/deliver_files_service/deliver_files_service.py +++ b/cg/services/deliver_files/deliver_files_service/deliver_files_service.py @@ -60,7 +60,12 @@ def __init__( def deliver_files_for_case( self, case: Case, delivery_base_path: Path, dry_run: bool = False ) -> None: - """Deliver the files for a case to the customer folder.""" + """Deliver the files for a case to the customer folder. + args: + case: The case to deliver files for + delivery_base_path: The base path to deliver the files to + dry_run: Whether to perform a dry run or not + """ delivery_files: DeliveryFiles = self.file_manager.get_files_to_deliver( case_id=case.internal_id ) @@ -83,7 +88,12 @@ def deliver_files_for_case( def deliver_files_for_ticket( self, ticket_id: str, delivery_base_path: Path, dry_run: bool = False ) -> None: - """Deliver the files for all cases in a ticket to the customer folder.""" + """Deliver the files for all cases in a ticket to the customer folder. + args: + ticket_id: The ticket id to deliver files for + delivery_base_path: The base path to deliver the files to + dry_run: Whether to perform a dry run or not + """ cases: list[Case] = self.status_db.get_cases_by_ticket_id(ticket_id) if not cases: raise EntryNotFoundError(f"No cases found for ticket {ticket_id}") @@ -130,6 +140,12 @@ def deliver_files_for_fohm_upload(self, case: Case, sample_id: str, delivery_bas self.file_formatter.format_files(delivery_files=moved_files) def _start_rsync_job(self, case: Case, dry_run: bool, folders_to_deliver: set[Path]) -> int: + """Start a rsync job for the case. + args: + case: The case to start the rsync job for + dry_run: Whether to perform a dry run or not + folders_to_deliver: The folders to deliver + """ LOG.debug(f"[RSYNC] Starting rsync job for case {case.internal_id}") job_id: int = self.rsync_service.run_rsync_for_case( case=case, @@ -144,6 +160,12 @@ def _start_rsync_job(self, case: Case, dry_run: bool, folders_to_deliver: set[Pa return job_id def _add_trailblazer_tracking(self, case: Case, job_id: int, dry_run: bool) -> None: + """Add the rsync job to Trailblazer for tracking. + args: + case: The case to add the job for + job_id: The job id to add for trailblazer tracking + dry_run: Whether to perform a dry run or not + """ if dry_run: LOG.info(f"Would have added the analysis for case {case.internal_id} to Trailblazer") else: diff --git a/cg/services/deliver_files/factory.py b/cg/services/deliver_files/factory.py index f1f7f205f3..a2dfc353c5 100644 --- a/cg/services/deliver_files/factory.py +++ b/cg/services/deliver_files/factory.py @@ -65,9 +65,12 @@ class DeliveryServiceFactory: """ - Class to build the delivery services based on workflow, delivery type, delivery destination. + Class to build the delivery services based on case, workflow, delivery type, delivery destination and delivery structure. The delivery destination is used to specify delivery to the customer or for upload. It determines how the delivery_base_path is managed and its underlying folder structure. + Delivery type is used to specify the type of delivery to perform. + Delivery structure is used to specify the structure of the delivery. + """ def __init__( @@ -88,7 +91,12 @@ def __init__( @staticmethod def _sanitise_delivery_type(delivery_type: DataDelivery) -> DataDelivery: - """Sanitise the delivery type.""" + """Sanitise the delivery type. + We have multiple delivery types that are a combination of other delivery types or uploads. + Here we make sure to convert unsupported delivery types to supported ones. + args: + delivery_type: The type of delivery to perform. + """ if delivery_type in [DataDelivery.FASTQ_QC, DataDelivery.FASTQ_SCOUT]: return DataDelivery.FASTQ if delivery_type in [DataDelivery.ANALYSIS_SCOUT]: @@ -104,6 +112,8 @@ def _sanitise_delivery_type(delivery_type: DataDelivery) -> DataDelivery: def _validate_delivery_type(delivery_type: DataDelivery): """ Check if the delivery type is supported. Raises DeliveryTypeNotSupported error. + args: + delivery_type: The type of delivery to perform. """ if delivery_type in [ DataDelivery.FASTQ, @@ -144,7 +154,12 @@ def _get_file_tag_fetcher( def _get_file_fetcher( self, delivery_type: DataDelivery, delivery_destination: DeliveryDestination ) -> FetchDeliveryFilesService: - """Get the file fetcher based on the delivery type.""" + """Get the file fetcher based on the delivery type. + args: + delivery_type: The type of delivery to perform. + delivery_destination: The destination of the delivery defaults to customer. See DeliveryDestination enum for explanation. + + """ service_map: dict[DataDelivery, Type[FetchDeliveryFilesService]] = { DataDelivery.FASTQ: RawDataDeliveryFileFetcher, DataDelivery.ANALYSIS_FILES: AnalysisDeliveryFileFetcher, diff --git a/cg/services/deliver_files/file_fetcher/analysis_raw_data_service.py b/cg/services/deliver_files/file_fetcher/analysis_raw_data_service.py index c7489b9c73..28b90b26e7 100644 --- a/cg/services/deliver_files/file_fetcher/analysis_raw_data_service.py +++ b/cg/services/deliver_files/file_fetcher/analysis_raw_data_service.py @@ -29,6 +29,12 @@ def __init__( self.tags_fetcher = tags_fetcher def get_files_to_deliver(self, case_id: str, sample_id: str | None = None) -> DeliveryFiles: + """ + Get files to deliver for a case or sample for both analysis and raw data. + args: + case_id: The case id to deliver files for + sample_id: The sample id to deliver files for + """ case: Case = self.status_db.get_case_by_internal_id(internal_id=case_id) fastq_files: DeliveryFiles = self._fetch_files( service_class=RawDataDeliveryFileFetcher, case_id=case_id, sample_id=sample_id @@ -51,6 +57,12 @@ def get_files_to_deliver(self, case_id: str, sample_id: str | None = None) -> De def _fetch_files( self, service_class: type, case_id: str, sample_id: str | None ) -> DeliveryFiles: - """Fetch files using the provided service class.""" + """Fetch files using the provided service class. + Wrapper to fetch files using the provided service class this is either the RawDataDeliveryFileFetcher or the AnalysisDeliveryFileFetcher. + args: + service_class: The service class to use to fetch the files + case_id: The case id to fetch files for + sample_id: The sample id to fetch files for + """ service = service_class(self.status_db, self.hk_api, tags_fetcher=self.tags_fetcher) return service.get_files_to_deliver(case_id=case_id, sample_id=sample_id) diff --git a/cg/services/deliver_files/file_fetcher/analysis_service.py b/cg/services/deliver_files/file_fetcher/analysis_service.py index 3f31fdb437..9a6e344e5a 100644 --- a/cg/services/deliver_files/file_fetcher/analysis_service.py +++ b/cg/services/deliver_files/file_fetcher/analysis_service.py @@ -39,7 +39,11 @@ def __init__( self.tags_fetcher = tags_fetcher def get_files_to_deliver(self, case_id: str, sample_id: str | None = None) -> DeliveryFiles: - """Return a list of analysis files to be delivered for a case.""" + """Return a list of analysis files to be delivered for a case. + args: + case_id: The case id to deliver files for + sample_id: The sample id to deliver files for + """ LOG.debug(f"[FETCH SERVICE] Fetching analysis files for case: {case_id}") case: Case = self.status_db.get_case_by_internal_id(internal_id=case_id) analysis_case_files: list[CaseFile] = self._get_analysis_case_delivery_files( @@ -65,7 +69,12 @@ def get_files_to_deliver(self, case_id: str, sample_id: str | None = None) -> De @staticmethod def _validate_delivery_has_content(delivery_files: DeliveryFiles) -> DeliveryFiles: - """Check if the delivery files has files to deliver.""" + """ + Check if the delivery files has files to deliver. + raise NoDeliveryFilesError if no files to deliver. + args: + delivery_files: The delivery files to check + """ if delivery_files.case_files or delivery_files.sample_files: return delivery_files LOG.info( @@ -77,7 +86,13 @@ def _validate_delivery_has_content(delivery_files: DeliveryFiles) -> DeliveryFil def _get_sample_files_from_case_bundle( self, workflow: Workflow, sample_id: str, case_id: str ) -> list[SampleFile] | None: - """Return a list of files from a case bundle with a sample id as tag.""" + """Return a list of files from a case bundle with a sample id as tag. + This is to fetch sample specific analysis files that are stored on the case level. + args: + workflow: The workflow to fetch files for + sample_id: The sample id to fetch files for + case_id: The case id to fetch files for + """ sample_tags: list[set[str]] = self.tags_fetcher.fetch_tags(workflow).sample_tags if not sample_tags: return [] @@ -99,7 +114,12 @@ def _get_sample_files_from_case_bundle( def _get_analysis_sample_delivery_files( self, case: Case, sample_id: str | None ) -> list[SampleFile] | None: - """Return a all sample files to deliver for a case.""" + """Return all sample files to deliver for a case. + Write a list of sample files to deliver for a case. + args: + case: The case to deliver files for + sample_id: The sample id to deliver files for + """ sample_ids: list[str] = [sample_id] if sample_id else case.sample_ids delivery_files: list[SampleFile] = [] for sample_id in sample_ids: @@ -115,7 +135,10 @@ def _get_analysis_case_delivery_files( ) -> list[CaseFile] | None: """ Return a complete list of analysis case files to be delivered and ignore analysis sample - files. + files. This is to ensure that only case level analysis files are delivered. + args: + case: The case to deliver files for + sample_id: The sample id to deliver files for """ case_tags: list[set[str]] = self.tags_fetcher.fetch_tags(case.data_analysis).case_tags if not case_tags: diff --git a/cg/services/deliver_files/file_fetcher/raw_data_service.py b/cg/services/deliver_files/file_fetcher/raw_data_service.py index d6d5d8c59a..2e337f3889 100644 --- a/cg/services/deliver_files/file_fetcher/raw_data_service.py +++ b/cg/services/deliver_files/file_fetcher/raw_data_service.py @@ -44,7 +44,12 @@ def __init__( self.tags_fetcher = tags_fetcher def get_files_to_deliver(self, case_id: str, sample_id: str | None = None) -> DeliveryFiles: - """Return a list of raw data files to be delivered for a case and its samples.""" + """ + Return a list of raw data files to be delivered for a case and its samples. + args: + case_id: The case id to deliver files for + sample_id: The sample id to deliver files for + """ LOG.debug(f"[FETCH SERVICE] Fetching raw data files for case: {case_id}") case: Case = self.status_db.get_case_by_internal_id(internal_id=case_id) sample_ids: list[str] = [sample_id] if sample_id else case.sample_ids @@ -68,7 +73,11 @@ def get_files_to_deliver(self, case_id: str, sample_id: str | None = None) -> De @staticmethod def _validate_delivery_has_content(delivery_files: DeliveryFiles) -> DeliveryFiles: - """Check if the delivery files has files to deliver.""" + """Check if the delivery files has files to deliver. + raise NoDeliveryFilesError if no files to deliver. + args: + delivery_files: The delivery files to check + """ for sample_file in delivery_files.sample_files: LOG.debug( f"Found file to deliver: {sample_file.file_path} for sample: {sample_file.sample_id}" @@ -82,7 +91,12 @@ def _validate_delivery_has_content(delivery_files: DeliveryFiles) -> DeliveryFil @handle_missing_bundle_errors def _get_raw_data_files_for_sample(self, case_id: str, sample_id: str) -> list[SampleFile]: - """Get the RawData files for a sample.""" + """ + Get the RawData files for a sample. Hardcoded tags to fetch from the raw data workflow. + args: + case_id: The case id to get the raw data files for + sample_id: The sample id to get the raw data files for + """ file_tags: list[set[str]] = self.tags_fetcher.fetch_tags(Workflow.RAW_DATA).sample_tags raw_data_files: list[File] = self.hk_api.get_files_from_latest_version_containing_tags( bundle_name=sample_id, tags=file_tags diff --git a/cg/services/deliver_files/file_mover/customer_inbox_service.py b/cg/services/deliver_files/file_mover/customer_inbox_service.py index bb16d62ebf..d613bca4bf 100644 --- a/cg/services/deliver_files/file_mover/customer_inbox_service.py +++ b/cg/services/deliver_files/file_mover/customer_inbox_service.py @@ -5,8 +5,6 @@ from cg.services.deliver_files.file_fetcher.models import ( DeliveryFiles, DeliveryMetaData, - CaseFile, - SampleFile, ) from cg.services.deliver_files.file_mover.abstract import DestinationFilesMover from cg.services.deliver_files.utils import FileMover diff --git a/cg/services/deliver_files/tag_fetcher/bam_service.py b/cg/services/deliver_files/tag_fetcher/bam_service.py index 571cf265df..6abf3a2830 100644 --- a/cg/services/deliver_files/tag_fetcher/bam_service.py +++ b/cg/services/deliver_files/tag_fetcher/bam_service.py @@ -14,7 +14,12 @@ class BamDeliveryTagsFetcher(FetchDeliveryFileTagsService): @handle_tag_errors def fetch_tags(self, workflow: Workflow) -> DeliveryFileTags: - """Fetch the tags for the bam files to deliver.""" + """ + Fetch the tags for the bam files to deliver. + Hardcoded to only return the BAM tag. + args: + workflow: The workflow to fetch tags for + """ self._validate_workflow(workflow=workflow) return DeliveryFileTags( case_tags=None, diff --git a/cg/services/deliver_files/tag_fetcher/fohm_upload_service.py b/cg/services/deliver_files/tag_fetcher/fohm_upload_service.py index 55a0eee950..e0a42e393f 100644 --- a/cg/services/deliver_files/tag_fetcher/fohm_upload_service.py +++ b/cg/services/deliver_files/tag_fetcher/fohm_upload_service.py @@ -17,6 +17,7 @@ def fetch_tags(self, workflow: Workflow) -> DeliveryFileTags: Required since some of the sample specific files are stored on the case bundle, but also fastq files. Not separating these would cause fetching of case bundle fastq files if present. + Hardcoded to only return the tags for the files to deliver. args: workflow: Workflow: The workflow to fetch tags """ diff --git a/cg/services/deliver_files/tag_fetcher/models.py b/cg/services/deliver_files/tag_fetcher/models.py index 580e95c663..791b7b767e 100644 --- a/cg/services/deliver_files/tag_fetcher/models.py +++ b/cg/services/deliver_files/tag_fetcher/models.py @@ -2,5 +2,11 @@ class DeliveryFileTags(BaseModel): + """ + Model to hold the tags for the files to deliver. + case_tags: The tags for the case files to deliver + sample_tags: The tags for the sample files to deliver + """ + case_tags: list[set[str]] | None sample_tags: list[set[str]] diff --git a/cg/services/deliver_files/tag_fetcher/sample_and_case_service.py b/cg/services/deliver_files/tag_fetcher/sample_and_case_service.py index 14bc032266..fe822b9b2b 100644 --- a/cg/services/deliver_files/tag_fetcher/sample_and_case_service.py +++ b/cg/services/deliver_files/tag_fetcher/sample_and_case_service.py @@ -13,7 +13,10 @@ class SampleAndCaseDeliveryTagsFetcher(FetchDeliveryFileTagsService): @handle_tag_errors def fetch_tags(self, workflow: Workflow) -> DeliveryFileTags: - """Get the case tags for the files that need to be delivered for a workflow.""" + """Get the case tags for the files that need to be delivered for a workflow. + args: + workflow: The workflow to fetch tags for + """ self._validate_workflow(workflow) return DeliveryFileTags( case_tags=PIPELINE_ANALYSIS_TAG_MAP[workflow]["case_tags"], diff --git a/cg/services/deliver_files/utils.py b/cg/services/deliver_files/utils.py index 08e5bd0a7f..f20fb34cdd 100644 --- a/cg/services/deliver_files/utils.py +++ b/cg/services/deliver_files/utils.py @@ -15,7 +15,11 @@ class FileManager: @staticmethod def create_directories(base_path: Path, directories: set[str]) -> None: - """Create directories for given names under the base path.""" + """Create directories for given names under the base path. + args: + base_path: The base path to create the directories under. + directories: The directories to create within the given base path. Can be a list of one. + """ for directory in directories: LOG.debug(f"[FileManager] Creating directory or file: {base_path}/{directory}") @@ -23,7 +27,13 @@ def create_directories(base_path: Path, directories: set[str]) -> None: @staticmethod def rename_file(src: Path, dst: Path) -> None: - """Rename a file from src to dst.""" + """ + Rename a file from src to dst. + raise ValueError if src does not exist. + args: + src: The source file path. + dst: The destination file path. + """ if not src or not dst: raise ValueError("Source and destination paths cannot be None.") LOG.debug(f"[FileManager] Renaming file: {src} -> {dst}") @@ -33,7 +43,12 @@ def rename_file(src: Path, dst: Path) -> None: @staticmethod def create_hard_link(src: Path, dst: Path) -> None: - """Create a hard link from src to dst.""" + """ + Create a hard link from src to dst. + args: + src: The source file path. + dst: The destination file path. + """ LOG.debug(f"[FileManager] Creating hard link: {src} -> {dst}") os.link(src=src, dst=dst) From 1fbff5f7f88e28181bdd2f43348a47289c3a16c5 Mon Sep 17 00:00:00 2001 From: ChristianOertlin Date: Tue, 17 Dec 2024 15:43:09 +0100 Subject: [PATCH 70/80] Update cg/services/deliver_files/deliver_files_service/deliver_files_service.py --- .../deliver_files_service/deliver_files_service.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cg/services/deliver_files/deliver_files_service/deliver_files_service.py b/cg/services/deliver_files/deliver_files_service/deliver_files_service.py index bb9ad885f3..0bbab6bc75 100644 --- a/cg/services/deliver_files/deliver_files_service/deliver_files_service.py +++ b/cg/services/deliver_files/deliver_files_service/deliver_files_service.py @@ -75,8 +75,7 @@ def deliver_files_for_case( formatted_files: FormattedFiles = self.file_formatter.format_files( delivery_files=moved_files ) - for formatted_file in formatted_files.files: - assert formatted_file.formatted_path.exists() + folders_to_deliver: set[Path] = set( [formatted_file.formatted_path.parent for formatted_file in formatted_files.files] ) From 82c5ae147b81fb76db0bcfb020db3357677e9a54 Mon Sep 17 00:00:00 2001 From: ChristianOertlin Date: Wed, 18 Dec 2024 09:17:57 +0100 Subject: [PATCH 71/80] Apply suggestions from code review type hint Co-authored-by: Vincent Janvid <69356202+Vince-janv@users.noreply.github.com> --- cg/services/deliver_files/file_fetcher/analysis_service.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cg/services/deliver_files/file_fetcher/analysis_service.py b/cg/services/deliver_files/file_fetcher/analysis_service.py index 9a6e344e5a..8154bd7549 100644 --- a/cg/services/deliver_files/file_fetcher/analysis_service.py +++ b/cg/services/deliver_files/file_fetcher/analysis_service.py @@ -113,7 +113,7 @@ def _get_sample_files_from_case_bundle( def _get_analysis_sample_delivery_files( self, case: Case, sample_id: str | None - ) -> list[SampleFile] | None: + ) -> list[SampleFile]: """Return all sample files to deliver for a case. Write a list of sample files to deliver for a case. args: @@ -132,7 +132,7 @@ def _get_analysis_sample_delivery_files( @handle_missing_bundle_errors def _get_analysis_case_delivery_files( self, case: Case, sample_id: str | None - ) -> list[CaseFile] | None: + ) -> list[CaseFile]: """ Return a complete list of analysis case files to be delivered and ignore analysis sample files. This is to ensure that only case level analysis files are delivered. From 0a82127e78e408ec65d67a88571dd8722db56081 Mon Sep 17 00:00:00 2001 From: Christian Oertlin Date: Wed, 18 Dec 2024 09:31:19 +0100 Subject: [PATCH 72/80] vincent review --- .../deliver_files_service/deliver_files_service.py | 2 +- cg/services/deliver_files/factory.py | 8 ++++---- .../file_formatter/destination/base_service.py | 8 ++++---- .../{component_files => files}/__init__.py | 0 .../{component_files => files}/abstract.py | 4 ++-- .../{component_files => files}/case_service.py | 4 ++-- .../concatenation_service.py | 8 ++++---- .../{component_files => files}/models.py | 0 .../{component_files => files}/mutant_service.py | 10 +++++----- .../{component_files => files}/sample_service.py | 4 ++-- .../file_formatter/path_name/flat_structure.py | 2 +- .../file_formatter/path_name/nested_structure.py | 2 +- .../delivery_fixtures/delivery_services_fixtures.py | 4 ++-- .../delivery_file_service/test_service_builder.py | 6 +++--- .../destination/test_formatting_service.py | 4 ++-- .../{component_files => files}/__init__.py | 0 .../test_formatter_utils.py | 12 ++++++------ 17 files changed, 39 insertions(+), 39 deletions(-) rename cg/services/deliver_files/file_formatter/{component_files => files}/__init__.py (100%) rename cg/services/deliver_files/file_formatter/{component_files => files}/abstract.py (88%) rename cg/services/deliver_files/file_formatter/{component_files => files}/case_service.py (96%) rename cg/services/deliver_files/file_formatter/{component_files => files}/concatenation_service.py (97%) rename cg/services/deliver_files/file_formatter/{component_files => files}/models.py (100%) rename cg/services/deliver_files/file_formatter/{component_files => files}/mutant_service.py (93%) rename cg/services/deliver_files/file_formatter/{component_files => files}/sample_service.py (95%) rename tests/services/file_delivery/file_formatter/{component_files => files}/__init__.py (100%) rename tests/services/file_delivery/file_formatter/{component_files => files}/test_formatter_utils.py (90%) diff --git a/cg/services/deliver_files/deliver_files_service/deliver_files_service.py b/cg/services/deliver_files/deliver_files_service/deliver_files_service.py index 0bbab6bc75..299f412ef8 100644 --- a/cg/services/deliver_files/deliver_files_service/deliver_files_service.py +++ b/cg/services/deliver_files/deliver_files_service/deliver_files_service.py @@ -75,7 +75,7 @@ def deliver_files_for_case( formatted_files: FormattedFiles = self.file_formatter.format_files( delivery_files=moved_files ) - + folders_to_deliver: set[Path] = set( [formatted_file.formatted_path.parent for formatted_file in formatted_files.files] ) diff --git a/cg/services/deliver_files/factory.py b/cg/services/deliver_files/factory.py index a2dfc353c5..829c597b90 100644 --- a/cg/services/deliver_files/factory.py +++ b/cg/services/deliver_files/factory.py @@ -25,14 +25,14 @@ from cg.services.deliver_files.file_formatter.destination.base_service import ( BaseDeliveryFormatter, ) -from cg.services.deliver_files.file_formatter.component_files.case_service import CaseFileFormatter -from cg.services.deliver_files.file_formatter.component_files.mutant_service import ( +from cg.services.deliver_files.file_formatter.files.case_service import CaseFileFormatter +from cg.services.deliver_files.file_formatter.files.mutant_service import ( MutantFileFormatter, ) -from cg.services.deliver_files.file_formatter.component_files.concatenation_service import ( +from cg.services.deliver_files.file_formatter.files.concatenation_service import ( SampleFileConcatenationFormatter, ) -from cg.services.deliver_files.file_formatter.component_files.sample_service import ( +from cg.services.deliver_files.file_formatter.files.sample_service import ( SampleFileFormatter, FileManager, ) diff --git a/cg/services/deliver_files/file_formatter/destination/base_service.py b/cg/services/deliver_files/file_formatter/destination/base_service.py index f7ba70a0a0..5b5b3493e8 100644 --- a/cg/services/deliver_files/file_formatter/destination/base_service.py +++ b/cg/services/deliver_files/file_formatter/destination/base_service.py @@ -9,14 +9,14 @@ FormattedFile, FormattedFiles, ) -from cg.services.deliver_files.file_formatter.component_files.case_service import CaseFileFormatter -from cg.services.deliver_files.file_formatter.component_files.mutant_service import ( +from cg.services.deliver_files.file_formatter.files.case_service import CaseFileFormatter +from cg.services.deliver_files.file_formatter.files.mutant_service import ( MutantFileFormatter, ) -from cg.services.deliver_files.file_formatter.component_files.concatenation_service import ( +from cg.services.deliver_files.file_formatter.files.concatenation_service import ( SampleFileConcatenationFormatter, ) -from cg.services.deliver_files.file_formatter.component_files.sample_service import ( +from cg.services.deliver_files.file_formatter.files.sample_service import ( SampleFileFormatter, ) diff --git a/cg/services/deliver_files/file_formatter/component_files/__init__.py b/cg/services/deliver_files/file_formatter/files/__init__.py similarity index 100% rename from cg/services/deliver_files/file_formatter/component_files/__init__.py rename to cg/services/deliver_files/file_formatter/files/__init__.py diff --git a/cg/services/deliver_files/file_formatter/component_files/abstract.py b/cg/services/deliver_files/file_formatter/files/abstract.py similarity index 88% rename from cg/services/deliver_files/file_formatter/component_files/abstract.py rename to cg/services/deliver_files/file_formatter/files/abstract.py index cad774286f..07fc768122 100644 --- a/cg/services/deliver_files/file_formatter/component_files/abstract.py +++ b/cg/services/deliver_files/file_formatter/files/abstract.py @@ -5,8 +5,8 @@ from cg.services.deliver_files.file_formatter.destination.models import FormattedFile -class ComponentFormatter(ABC): - +class FileFormatter(ABC): + # rename to file formatter @abstractmethod def format_files( self, moved_files: list[CaseFile | SampleFile], delivery_path: Path diff --git a/cg/services/deliver_files/file_formatter/component_files/case_service.py b/cg/services/deliver_files/file_formatter/files/case_service.py similarity index 96% rename from cg/services/deliver_files/file_formatter/component_files/case_service.py rename to cg/services/deliver_files/file_formatter/files/case_service.py index 85d00518e3..deb1a1e4b0 100644 --- a/cg/services/deliver_files/file_formatter/component_files/case_service.py +++ b/cg/services/deliver_files/file_formatter/files/case_service.py @@ -2,7 +2,7 @@ from pathlib import Path from cg.services.deliver_files.file_fetcher.models import CaseFile -from cg.services.deliver_files.file_formatter.component_files.abstract import ComponentFormatter +from cg.services.deliver_files.file_formatter.files.abstract import FileFormatter from cg.services.deliver_files.file_formatter.destination.models import FormattedFile from cg.services.deliver_files.file_formatter.path_name.abstract import PathNameFormatter from cg.services.deliver_files.file_formatter.path_name.nested_structure import ( @@ -13,7 +13,7 @@ LOG = logging.getLogger(__name__) -class CaseFileFormatter(ComponentFormatter): +class CaseFileFormatter(FileFormatter): """ Format the case files to deliver and return the formatted files. args: diff --git a/cg/services/deliver_files/file_formatter/component_files/concatenation_service.py b/cg/services/deliver_files/file_formatter/files/concatenation_service.py similarity index 97% rename from cg/services/deliver_files/file_formatter/component_files/concatenation_service.py rename to cg/services/deliver_files/file_formatter/files/concatenation_service.py index 78aee8de2e..9eb294edd3 100644 --- a/cg/services/deliver_files/file_formatter/component_files/concatenation_service.py +++ b/cg/services/deliver_files/file_formatter/files/concatenation_service.py @@ -3,8 +3,8 @@ import re from cg.constants.constants import ReadDirection, FileFormat, FileExtensions -from cg.services.deliver_files.file_formatter.component_files.abstract import ComponentFormatter -from cg.services.deliver_files.file_formatter.component_files.models import FastqFile +from cg.services.deliver_files.file_formatter.files.abstract import FileFormatter +from cg.services.deliver_files.file_formatter.files.models import FastqFile from cg.services.deliver_files.file_formatter.path_name.abstract import PathNameFormatter from cg.services.fastq_concatenation_service.fastq_concatenation_service import ( @@ -13,7 +13,7 @@ from cg.services.fastq_concatenation_service.utils import generate_concatenated_fastq_delivery_path from cg.services.deliver_files.file_fetcher.models import SampleFile from cg.services.deliver_files.file_formatter.destination.models import FormattedFile -from cg.services.deliver_files.file_formatter.component_files.sample_service import ( +from cg.services.deliver_files.file_formatter.files.sample_service import ( FileManager, ) from cg.services.deliver_files.file_formatter.path_name.nested_structure import ( @@ -24,7 +24,7 @@ LOG = logging.getLogger(__name__) -class SampleFileConcatenationFormatter(ComponentFormatter): +class SampleFileConcatenationFormatter(FileFormatter): """ Format the sample files to deliver, concatenate fastq files and return the formatted files. Used for workflows: Microsalt. diff --git a/cg/services/deliver_files/file_formatter/component_files/models.py b/cg/services/deliver_files/file_formatter/files/models.py similarity index 100% rename from cg/services/deliver_files/file_formatter/component_files/models.py rename to cg/services/deliver_files/file_formatter/files/models.py diff --git a/cg/services/deliver_files/file_formatter/component_files/mutant_service.py b/cg/services/deliver_files/file_formatter/files/mutant_service.py similarity index 93% rename from cg/services/deliver_files/file_formatter/component_files/mutant_service.py rename to cg/services/deliver_files/file_formatter/files/mutant_service.py index 5179d48322..910a72bd70 100644 --- a/cg/services/deliver_files/file_formatter/component_files/mutant_service.py +++ b/cg/services/deliver_files/file_formatter/files/mutant_service.py @@ -3,17 +3,17 @@ import re from cg.apps.lims import LimsAPI from cg.services.deliver_files.file_fetcher.models import SampleFile -from cg.services.deliver_files.file_formatter.component_files.abstract import ComponentFormatter +from cg.services.deliver_files.file_formatter.files.abstract import FileFormatter from cg.services.deliver_files.file_formatter.destination.models import FormattedFile -from cg.services.deliver_files.file_formatter.component_files.concatenation_service import ( +from cg.services.deliver_files.file_formatter.files.concatenation_service import ( SampleFileConcatenationFormatter, ) -from cg.services.deliver_files.file_formatter.component_files.sample_service import FileManager +from cg.services.deliver_files.file_formatter.files.sample_service import FileManager LOG = logging.getLogger(__name__) -class MutantFileFormatter(ComponentFormatter): +class MutantFileFormatter(FileFormatter): """ Formatter for file to deliver or upload for the Mutant workflow. Args: @@ -127,7 +127,7 @@ def _filter_unique_path_combinations( Filter out duplicates from the formatted files list. note: - During fastq concatenation Sample_R1 and Sample_R2 files are concatenated + During fastq concatenation Sample_L1_R1 and Sample_L2_R1 files are concatenated and moved to the same file Concat_Sample. This mean that there can be multiple entries for the same concatenated file in the formatted_files list coming from the SampleFileConcatenationService. diff --git a/cg/services/deliver_files/file_formatter/component_files/sample_service.py b/cg/services/deliver_files/file_formatter/files/sample_service.py similarity index 95% rename from cg/services/deliver_files/file_formatter/component_files/sample_service.py rename to cg/services/deliver_files/file_formatter/files/sample_service.py index 3320c75acb..276a3b2649 100644 --- a/cg/services/deliver_files/file_formatter/component_files/sample_service.py +++ b/cg/services/deliver_files/file_formatter/files/sample_service.py @@ -2,7 +2,7 @@ from pathlib import Path from cg.services.deliver_files.file_fetcher.models import SampleFile -from cg.services.deliver_files.file_formatter.component_files.abstract import ComponentFormatter +from cg.services.deliver_files.file_formatter.files.abstract import FileFormatter from cg.services.deliver_files.file_formatter.destination.models import FormattedFile from cg.services.deliver_files.file_formatter.path_name.abstract import PathNameFormatter from cg.services.deliver_files.utils import FileManager @@ -10,7 +10,7 @@ LOG = logging.getLogger(__name__) -class SampleFileFormatter(ComponentFormatter): +class SampleFileFormatter(FileFormatter): """ Format the sample files to deliver. Used for all workflows except Microsalt and Mutant. diff --git a/cg/services/deliver_files/file_formatter/path_name/flat_structure.py b/cg/services/deliver_files/file_formatter/path_name/flat_structure.py index 5be3a5f391..f851a6bf7b 100644 --- a/cg/services/deliver_files/file_formatter/path_name/flat_structure.py +++ b/cg/services/deliver_files/file_formatter/path_name/flat_structure.py @@ -1,6 +1,6 @@ from pathlib import Path -from cg.services.deliver_files.file_formatter.component_files.sample_service import LOG +from cg.services.deliver_files.file_formatter.files.sample_service import LOG from cg.services.deliver_files.file_formatter.path_name.abstract import PathNameFormatter diff --git a/cg/services/deliver_files/file_formatter/path_name/nested_structure.py b/cg/services/deliver_files/file_formatter/path_name/nested_structure.py index 66a2e61b1b..26ede0ea99 100644 --- a/cg/services/deliver_files/file_formatter/path_name/nested_structure.py +++ b/cg/services/deliver_files/file_formatter/path_name/nested_structure.py @@ -1,6 +1,6 @@ from pathlib import Path -from cg.services.deliver_files.file_formatter.component_files.sample_service import LOG +from cg.services.deliver_files.file_formatter.files.sample_service import LOG from cg.services.deliver_files.file_formatter.path_name.abstract import PathNameFormatter diff --git a/tests/fixture_plugins/delivery_fixtures/delivery_services_fixtures.py b/tests/fixture_plugins/delivery_fixtures/delivery_services_fixtures.py index 95a8f752f7..60d898ed81 100644 --- a/tests/fixture_plugins/delivery_fixtures/delivery_services_fixtures.py +++ b/tests/fixture_plugins/delivery_fixtures/delivery_services_fixtures.py @@ -18,10 +18,10 @@ from cg.services.deliver_files.file_fetcher.raw_data_service import ( RawDataDeliveryFileFetcher, ) -from cg.services.deliver_files.file_formatter.component_files.case_service import ( +from cg.services.deliver_files.file_formatter.files.case_service import ( CaseFileFormatter, ) -from cg.services.deliver_files.file_formatter.component_files.sample_service import ( +from cg.services.deliver_files.file_formatter.files.sample_service import ( SampleFileFormatter, FileManager, ) diff --git a/tests/services/file_delivery/delivery_file_service/test_service_builder.py b/tests/services/file_delivery/delivery_file_service/test_service_builder.py index 283f93f7d2..29fe6d17af 100644 --- a/tests/services/file_delivery/delivery_file_service/test_service_builder.py +++ b/tests/services/file_delivery/delivery_file_service/test_service_builder.py @@ -18,13 +18,13 @@ ) from cg.services.deliver_files.file_fetcher.analysis_service import AnalysisDeliveryFileFetcher from cg.services.deliver_files.file_fetcher.raw_data_service import RawDataDeliveryFileFetcher -from cg.services.deliver_files.file_formatter.component_files.mutant_service import ( +from cg.services.deliver_files.file_formatter.files.mutant_service import ( MutantFileFormatter, ) -from cg.services.deliver_files.file_formatter.component_files.concatenation_service import ( +from cg.services.deliver_files.file_formatter.files.concatenation_service import ( SampleFileConcatenationFormatter, ) -from cg.services.deliver_files.file_formatter.component_files.sample_service import ( +from cg.services.deliver_files.file_formatter.files.sample_service import ( SampleFileFormatter, ) from cg.services.deliver_files.file_formatter.path_name.abstract import PathNameFormatter diff --git a/tests/services/file_delivery/file_formatter/destination/test_formatting_service.py b/tests/services/file_delivery/file_formatter/destination/test_formatting_service.py index fe36730694..218ad69c58 100644 --- a/tests/services/file_delivery/file_formatter/destination/test_formatting_service.py +++ b/tests/services/file_delivery/file_formatter/destination/test_formatting_service.py @@ -70,10 +70,10 @@ def test_reformat_files( expected_formatted_files = FormattedFiles(files=files) with mock.patch( - "cg.services.deliver_files.file_formatter.component_files.sample_service.SampleFileFormatter.format_files", + "cg.services.deliver_files.file_formatter.files.sample_service.SampleFileFormatter.format_files", return_value=formatted_sample_files, ), mock.patch( - "cg.services.deliver_files.file_formatter.component_files.case_service.CaseFileFormatter.format_files", + "cg.services.deliver_files.file_formatter.files.case_service.CaseFileFormatter.format_files", return_value=formatted_case_files, ): # WHEN reformatting the delivery files diff --git a/tests/services/file_delivery/file_formatter/component_files/__init__.py b/tests/services/file_delivery/file_formatter/files/__init__.py similarity index 100% rename from tests/services/file_delivery/file_formatter/component_files/__init__.py rename to tests/services/file_delivery/file_formatter/files/__init__.py diff --git a/tests/services/file_delivery/file_formatter/component_files/test_formatter_utils.py b/tests/services/file_delivery/file_formatter/files/test_formatter_utils.py similarity index 90% rename from tests/services/file_delivery/file_formatter/component_files/test_formatter_utils.py rename to tests/services/file_delivery/file_formatter/files/test_formatter_utils.py index c42c8b92cd..2dab8edda7 100644 --- a/tests/services/file_delivery/file_formatter/component_files/test_formatter_utils.py +++ b/tests/services/file_delivery/file_formatter/files/test_formatter_utils.py @@ -3,8 +3,8 @@ import pytest from pathlib import Path -from cg.services.deliver_files.file_formatter.component_files.abstract import ComponentFormatter -from cg.services.deliver_files.file_formatter.component_files.mutant_service import ( +from cg.services.deliver_files.file_formatter.files.abstract import FileFormatter +from cg.services.deliver_files.file_formatter.files.mutant_service import ( MutantFileFormatter, ) from cg.services.fastq_concatenation_service.fastq_concatenation_service import ( @@ -15,13 +15,13 @@ SampleFile, ) from cg.services.deliver_files.file_formatter.destination.models import FormattedFile -from cg.services.deliver_files.file_formatter.component_files.case_service import ( +from cg.services.deliver_files.file_formatter.files.case_service import ( CaseFileFormatter, ) -from cg.services.deliver_files.file_formatter.component_files.concatenation_service import ( +from cg.services.deliver_files.file_formatter.files.concatenation_service import ( SampleFileConcatenationFormatter, ) -from cg.services.deliver_files.file_formatter.component_files.sample_service import ( +from cg.services.deliver_files.file_formatter.files.sample_service import ( SampleFileFormatter, FileManager, ) @@ -73,7 +73,7 @@ def test_component_formatters( moved_files: list[CaseFile | SampleFile], expected_formatted_files: list[FormattedFile], - file_formatter: ComponentFormatter, + file_formatter: FileFormatter, request, ): # GIVEN existing case files, a case file formatter and a ticket directory path and a customer inbox From ba9c4f5038911d335eb59ea14769d75468c54e6c Mon Sep 17 00:00:00 2001 From: ChristianOertlin Date: Wed, 18 Dec 2024 10:02:40 +0100 Subject: [PATCH 73/80] Apply suggestions from code review Sebastian review Co-authored-by: Sebastian Diaz --- cg/services/deliver_files/factory.py | 12 ++++++------ .../file_fetcher/analysis_raw_data_service.py | 2 +- .../deliver_files/file_fetcher/analysis_service.py | 3 ++- .../deliver_files/file_fetcher/raw_data_service.py | 3 ++- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/cg/services/deliver_files/factory.py b/cg/services/deliver_files/factory.py index 829c597b90..509d87a31d 100644 --- a/cg/services/deliver_files/factory.py +++ b/cg/services/deliver_files/factory.py @@ -66,7 +66,7 @@ class DeliveryServiceFactory: """ Class to build the delivery services based on case, workflow, delivery type, delivery destination and delivery structure. - The delivery destination is used to specify delivery to the customer or for upload. + The delivery destination is used to specify delivery to the customer or for external upload. It determines how the delivery_base_path is managed and its underlying folder structure. Delivery type is used to specify the type of delivery to perform. Delivery structure is used to specify the structure of the delivery. @@ -176,10 +176,10 @@ def _get_file_fetcher( ) def _convert_workflow(self, case: Case) -> Workflow: - """Converts a workflow with the introduction of the microbial-fastq delivery type an - unsupported combination of delivery type and workflow setup is required. This function - makes sure that a raw data workflow with microbial fastq delivery type is treated as a - microsalt workflow so that the microbial-fastq sample files can be concatenated. + """Change the workflow of a Microbial Fastq case to Microsalt to allow the concatenation of fastq files. + With the introduction of the microbial-fastq delivery type, an unsupported combination of delivery type and + workflow setup is required. This function makes sure that a raw data workflow with microbial fastq delivery + type is treated as a microsalt workflow so that the microbial-fastq sample files can be concatenated. args: case: The case to convert the workflow for """ @@ -296,7 +296,7 @@ def build_delivery_service( """Build a delivery service based on a case. args: case: The case to deliver files for. - delivery_type: The type of delivery to perform. + delivery_type: The type of data delivery to perform. See DataDelivery enum for explanation. delivery_destination: The destination of the delivery defaults to customer. See DeliveryDestination enum for explanation. delivery_structure: The structure of the delivery defaults to nested. See DeliveryStructure enum for explanation. """ diff --git a/cg/services/deliver_files/file_fetcher/analysis_raw_data_service.py b/cg/services/deliver_files/file_fetcher/analysis_raw_data_service.py index 28b90b26e7..daceca0fb4 100644 --- a/cg/services/deliver_files/file_fetcher/analysis_raw_data_service.py +++ b/cg/services/deliver_files/file_fetcher/analysis_raw_data_service.py @@ -58,7 +58,7 @@ def _fetch_files( self, service_class: type, case_id: str, sample_id: str | None ) -> DeliveryFiles: """Fetch files using the provided service class. - Wrapper to fetch files using the provided service class this is either the RawDataDeliveryFileFetcher or the AnalysisDeliveryFileFetcher. + Wrapper to fetch files using the provided service class. This is either the RawDataDeliveryFileFetcher or the AnalysisDeliveryFileFetcher. args: service_class: The service class to use to fetch the files case_id: The case id to fetch files for diff --git a/cg/services/deliver_files/file_fetcher/analysis_service.py b/cg/services/deliver_files/file_fetcher/analysis_service.py index 8154bd7549..57b0982ca1 100644 --- a/cg/services/deliver_files/file_fetcher/analysis_service.py +++ b/cg/services/deliver_files/file_fetcher/analysis_service.py @@ -71,7 +71,8 @@ def get_files_to_deliver(self, case_id: str, sample_id: str | None = None) -> De def _validate_delivery_has_content(delivery_files: DeliveryFiles) -> DeliveryFiles: """ Check if the delivery files has files to deliver. - raise NoDeliveryFilesError if no files to deliver. + raises: + NoDeliveryFilesError if no files to deliver. args: delivery_files: The delivery files to check """ diff --git a/cg/services/deliver_files/file_fetcher/raw_data_service.py b/cg/services/deliver_files/file_fetcher/raw_data_service.py index 2e337f3889..a38dedd8d0 100644 --- a/cg/services/deliver_files/file_fetcher/raw_data_service.py +++ b/cg/services/deliver_files/file_fetcher/raw_data_service.py @@ -74,7 +74,8 @@ def get_files_to_deliver(self, case_id: str, sample_id: str | None = None) -> De @staticmethod def _validate_delivery_has_content(delivery_files: DeliveryFiles) -> DeliveryFiles: """Check if the delivery files has files to deliver. - raise NoDeliveryFilesError if no files to deliver. + raises: + NoDeliveryFilesError if no files to deliver. args: delivery_files: The delivery files to check """ From fcf9ef91000977f669fb8ee18882716e4c81f5c0 Mon Sep 17 00:00:00 2001 From: ChristianOertlin Date: Wed, 18 Dec 2024 10:27:15 +0100 Subject: [PATCH 74/80] Update cg/apps/lims/api.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Isak Ohlsson Ångnell <40887124+islean@users.noreply.github.com> --- cg/apps/lims/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cg/apps/lims/api.py b/cg/apps/lims/api.py index 86912f3673..33b6f3130d 100644 --- a/cg/apps/lims/api.py +++ b/cg/apps/lims/api.py @@ -557,7 +557,7 @@ def _get_negative_controls_from_list(samples: list[Sample]) -> list[Sample]: return negative_controls def get_sample_region_and_lab_code(self, sample_id: str) -> str: - """Return the reqgion code and lab code for a sample formatted as a suffix string.""" + """Return the region code and lab code for a sample formatted as a prefix string.""" region_code: str = self.get_sample_attribute(lims_id=sample_id, key="region_code").split( " " )[0] From 95ab0b958e63e0974b649efe54074882985a4f15 Mon Sep 17 00:00:00 2001 From: ChristianOertlin Date: Wed, 18 Dec 2024 10:27:32 +0100 Subject: [PATCH 75/80] Update cg/services/deliver_files/file_fetcher/analysis_service.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Isak Ohlsson Ångnell <40887124+islean@users.noreply.github.com> --- cg/services/deliver_files/file_fetcher/analysis_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cg/services/deliver_files/file_fetcher/analysis_service.py b/cg/services/deliver_files/file_fetcher/analysis_service.py index 57b0982ca1..3a55f4726c 100644 --- a/cg/services/deliver_files/file_fetcher/analysis_service.py +++ b/cg/services/deliver_files/file_fetcher/analysis_service.py @@ -44,7 +44,7 @@ def get_files_to_deliver(self, case_id: str, sample_id: str | None = None) -> De case_id: The case id to deliver files for sample_id: The sample id to deliver files for """ - LOG.debug(f"[FETCH SERVICE] Fetching analysis files for case: {case_id}") + LOG.debug(f"[FETCH SERVICE] Fetching analysis files for case: {case_id}, sample: {sample_id}") case: Case = self.status_db.get_case_by_internal_id(internal_id=case_id) analysis_case_files: list[CaseFile] = self._get_analysis_case_delivery_files( case=case, sample_id=sample_id From c92ee47394ea29eff0b70ef045121a8912aceaac Mon Sep 17 00:00:00 2001 From: ChristianOertlin Date: Wed, 18 Dec 2024 10:27:42 +0100 Subject: [PATCH 76/80] Update cg/services/deliver_files/utils.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Isak Ohlsson Ångnell <40887124+islean@users.noreply.github.com> --- cg/services/deliver_files/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cg/services/deliver_files/utils.py b/cg/services/deliver_files/utils.py index f20fb34cdd..615c1f9d06 100644 --- a/cg/services/deliver_files/utils.py +++ b/cg/services/deliver_files/utils.py @@ -97,7 +97,7 @@ def update_file_paths( file_model.file_path = Path(target_dir, file_model.file_path.name) return file_models - def move_and_update_files(self, file_models: list[CaseFile | SampleFile], target_dir): + def move_and_update_files(self, file_models: list[CaseFile | SampleFile], target_dir: Path) -> list[CaseFile | SampleFile]: """Move files to the target directory and update the file paths. args: file_models: The file models that contain the files to move. From 6f6e3b01104f33f55eb5437d07d40ca18b1a4994 Mon Sep 17 00:00:00 2001 From: ChristianOertlin Date: Wed, 18 Dec 2024 10:27:54 +0100 Subject: [PATCH 77/80] Update cg/services/deliver_files/file_formatter/files/abstract.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Isak Ohlsson Ångnell <40887124+islean@users.noreply.github.com> --- cg/services/deliver_files/file_formatter/files/abstract.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cg/services/deliver_files/file_formatter/files/abstract.py b/cg/services/deliver_files/file_formatter/files/abstract.py index 07fc768122..435922d424 100644 --- a/cg/services/deliver_files/file_formatter/files/abstract.py +++ b/cg/services/deliver_files/file_formatter/files/abstract.py @@ -6,7 +6,7 @@ class FileFormatter(ABC): - # rename to file formatter + @abstractmethod def format_files( self, moved_files: list[CaseFile | SampleFile], delivery_path: Path From dc6c7adb0f4c7b7d5f398aba94e1023e505066d8 Mon Sep 17 00:00:00 2001 From: Christian Oertlin Date: Wed, 18 Dec 2024 10:29:48 +0100 Subject: [PATCH 78/80] improve factory docstring --- cg/services/deliver_files/factory.py | 7 +++-- .../file_fetcher/analysis_service.py | 4 ++- .../file_formatter/files/abstract.py | 2 +- .../files/concatenation_service.py | 27 ++++++++++--------- cg/services/deliver_files/utils.py | 4 ++- .../files/test_formatter_utils.py | 2 +- 6 files changed, 25 insertions(+), 21 deletions(-) diff --git a/cg/services/deliver_files/factory.py b/cg/services/deliver_files/factory.py index 509d87a31d..1701cba5fb 100644 --- a/cg/services/deliver_files/factory.py +++ b/cg/services/deliver_files/factory.py @@ -67,10 +67,9 @@ class DeliveryServiceFactory: """ Class to build the delivery services based on case, workflow, delivery type, delivery destination and delivery structure. The delivery destination is used to specify delivery to the customer or for external upload. - It determines how the delivery_base_path is managed and its underlying folder structure. + Workflow is used to specify the workflow of the case and is required for the tag fetcher. Delivery type is used to specify the type of delivery to perform. Delivery structure is used to specify the structure of the delivery. - """ def __init__( @@ -177,8 +176,8 @@ def _get_file_fetcher( def _convert_workflow(self, case: Case) -> Workflow: """Change the workflow of a Microbial Fastq case to Microsalt to allow the concatenation of fastq files. - With the introduction of the microbial-fastq delivery type, an unsupported combination of delivery type and - workflow setup is required. This function makes sure that a raw data workflow with microbial fastq delivery + With the introduction of the microbial-fastq delivery type, an unsupported combination of delivery type and + workflow setup is required. This function makes sure that a raw data workflow with microbial fastq delivery type is treated as a microsalt workflow so that the microbial-fastq sample files can be concatenated. args: case: The case to convert the workflow for diff --git a/cg/services/deliver_files/file_fetcher/analysis_service.py b/cg/services/deliver_files/file_fetcher/analysis_service.py index 3a55f4726c..43e0b2f920 100644 --- a/cg/services/deliver_files/file_fetcher/analysis_service.py +++ b/cg/services/deliver_files/file_fetcher/analysis_service.py @@ -44,7 +44,9 @@ def get_files_to_deliver(self, case_id: str, sample_id: str | None = None) -> De case_id: The case id to deliver files for sample_id: The sample id to deliver files for """ - LOG.debug(f"[FETCH SERVICE] Fetching analysis files for case: {case_id}, sample: {sample_id}") + LOG.debug( + f"[FETCH SERVICE] Fetching analysis files for case: {case_id}, sample: {sample_id}" + ) case: Case = self.status_db.get_case_by_internal_id(internal_id=case_id) analysis_case_files: list[CaseFile] = self._get_analysis_case_delivery_files( case=case, sample_id=sample_id diff --git a/cg/services/deliver_files/file_formatter/files/abstract.py b/cg/services/deliver_files/file_formatter/files/abstract.py index 435922d424..bb1f241b3d 100644 --- a/cg/services/deliver_files/file_formatter/files/abstract.py +++ b/cg/services/deliver_files/file_formatter/files/abstract.py @@ -6,7 +6,7 @@ class FileFormatter(ABC): - + @abstractmethod def format_files( self, moved_files: list[CaseFile | SampleFile], delivery_path: Path diff --git a/cg/services/deliver_files/file_formatter/files/concatenation_service.py b/cg/services/deliver_files/file_formatter/files/concatenation_service.py index 9eb294edd3..05c9b3a520 100644 --- a/cg/services/deliver_files/file_formatter/files/concatenation_service.py +++ b/cg/services/deliver_files/file_formatter/files/concatenation_service.py @@ -78,6 +78,17 @@ def _get_sample_names(sample_files: list[SampleFile]) -> set[str]: """Extract sample names from the sample files.""" return {sample_file.sample_name for sample_file in sample_files} + def _create_sample_directories(self, sample_names: set[str], delivery_path: Path) -> None: + """Create directories for each sample name only if the file name formatter is the NestedSampleFileFormatter. + args: + sample_names: set[str]: Set of sample names. + delivery_path: Path: Path to the delivery directory. + """ + if not isinstance(self.path_name_formatter, NestedStructurePathFormatter): + return + for sample_name in sample_names: + self.file_manager.create_directories(base_path=delivery_path, directories={sample_name}) + def _format_sample_file_paths(self, sample_files: list[SampleFile]) -> list[FormattedFile]: """ Return a list of formatted sample files. @@ -108,17 +119,6 @@ def _rename_original_files(self, formatted_files: list[FormattedFile]) -> None: src=formatted_file.original_path, dst=formatted_file.formatted_path ) - def _create_sample_directories(self, sample_names: set[str], delivery_path: Path) -> None: - """Create directories for each sample name only if the file name formatter is the NestedSampleFileFormatter. - args: - sample_names: set[str]: Set of sample names. - delivery_path: Path: Path to the delivery directory. - """ - if not isinstance(self.path_name_formatter, NestedStructurePathFormatter): - return - for sample_name in sample_names: - self.file_manager.create_directories(base_path=delivery_path, directories={sample_name}) - def _concatenate_fastq_files( self, delivery_path: Path, sample_names: set[str] ) -> dict[Path, Path]: @@ -246,7 +246,7 @@ def _group_fastq_files_per_sample( } for fastq_file in fastq_files: sample_fastq_files[fastq_file.sample_name].append(fastq_file) - self._all_sample_fastq_file_share_same_directory(sample_fastq_files=sample_fastq_files) + self._validate_sample_fastq_file_share_same_directory(sample_fastq_files=sample_fastq_files) return sample_fastq_files def _replace_fastq_paths( @@ -266,12 +266,13 @@ def _replace_fastq_paths( formatted_file.formatted_path = concatenation_maps[formatted_file.formatted_path] @staticmethod - def _all_sample_fastq_file_share_same_directory( + def _validate_sample_fastq_file_share_same_directory( sample_fastq_files: dict[str, list[FastqFile]] ) -> None: """ Assert that all fastq files for a sample share the same directory. This is to ensure that the files are concatenated within the expected directory path. + raises: ValueError if the fastq files are not in the same directory. args: sample_fastq_files: dict[str, list[FastqFile]]: Dictionary of sample names and their fastq files. """ diff --git a/cg/services/deliver_files/utils.py b/cg/services/deliver_files/utils.py index 615c1f9d06..69452ef988 100644 --- a/cg/services/deliver_files/utils.py +++ b/cg/services/deliver_files/utils.py @@ -97,7 +97,9 @@ def update_file_paths( file_model.file_path = Path(target_dir, file_model.file_path.name) return file_models - def move_and_update_files(self, file_models: list[CaseFile | SampleFile], target_dir: Path) -> list[CaseFile | SampleFile]: + def move_and_update_files( + self, file_models: list[CaseFile | SampleFile], target_dir: Path + ) -> list[CaseFile | SampleFile]: """Move files to the target directory and update the file paths. args: file_models: The file models that contain the files to move. diff --git a/tests/services/file_delivery/file_formatter/files/test_formatter_utils.py b/tests/services/file_delivery/file_formatter/files/test_formatter_utils.py index 2dab8edda7..ce440e10d7 100644 --- a/tests/services/file_delivery/file_formatter/files/test_formatter_utils.py +++ b/tests/services/file_delivery/file_formatter/files/test_formatter_utils.py @@ -70,7 +70,7 @@ ), ], ) -def test_component_formatters( +def test_file_formatters( moved_files: list[CaseFile | SampleFile], expected_formatted_files: list[FormattedFile], file_formatter: FileFormatter, From 7e3893a39159d213521e955b28df979f6031dbe0 Mon Sep 17 00:00:00 2001 From: Christian Oertlin Date: Wed, 18 Dec 2024 10:30:58 +0100 Subject: [PATCH 79/80] fix function name --- cg/meta/upload/fohm/fohm.py | 2 +- .../deliver_files_service/deliver_files_service.py | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/cg/meta/upload/fohm/fohm.py b/cg/meta/upload/fohm/fohm.py index 3fa1342da4..f416777a48 100644 --- a/cg/meta/upload/fohm/fohm.py +++ b/cg/meta/upload/fohm/fohm.py @@ -209,7 +209,7 @@ def link_sample_raw_data_files( delivery_destination=DeliveryDestination.FOHM, delivery_structure=DeliveryStructure.FLAT, ) - delivery_service.deliver_files_for_fohm_upload( + delivery_service.deliver_files_for_sample_no_rsync( case=case, sample_id=sample.internal_id, delivery_base_path=self.daily_rawdata_path ) diff --git a/cg/services/deliver_files/deliver_files_service/deliver_files_service.py b/cg/services/deliver_files/deliver_files_service/deliver_files_service.py index 299f412ef8..46cfe38a65 100644 --- a/cg/services/deliver_files/deliver_files_service/deliver_files_service.py +++ b/cg/services/deliver_files/deliver_files_service/deliver_files_service.py @@ -122,9 +122,11 @@ def deliver_files_for_sample( ) self._add_trailblazer_tracking(case=case, job_id=job_id, dry_run=dry_run) - def deliver_files_for_fohm_upload(self, case: Case, sample_id: str, delivery_base_path: Path): + def deliver_files_for_sample_no_rsync( + self, case: Case, sample_id: str, delivery_base_path: Path + ): """ - Deliver the files for a sample to the FOHM upload destination. Does not perform rsync. + Deliver the files for a sample to the delivery base path. Does not perform rsync. args: case: The case to deliver files for sample_id: The sample to deliver files for From 7133948a11f886a8d0e59405434a8a0e1a652f18 Mon Sep 17 00:00:00 2001 From: Christian Oertlin Date: Wed, 18 Dec 2024 10:31:57 +0100 Subject: [PATCH 80/80] remove comment --- .../deliver_files/file_formatter/files/concatenation_service.py | 1 - 1 file changed, 1 deletion(-) diff --git a/cg/services/deliver_files/file_formatter/files/concatenation_service.py b/cg/services/deliver_files/file_formatter/files/concatenation_service.py index 05c9b3a520..b2f20b6e57 100644 --- a/cg/services/deliver_files/file_formatter/files/concatenation_service.py +++ b/cg/services/deliver_files/file_formatter/files/concatenation_service.py @@ -138,7 +138,6 @@ def _concatenate_fastq_files( ) concatenation_maps: dict[Path, Path] = {} for sample in grouped_fastq_files.keys(): - # The parent is dependent on the nested or flat structure within the delivery path. fastq_directory: Path = grouped_fastq_files[sample][0].fastq_file_path.parent forward_path: Path = generate_concatenated_fastq_delivery_path( fastq_directory=fastq_directory,