From 3577752c47f17f2d378e61653259e7516165cb36 Mon Sep 17 00:00:00 2001 From: Ulthran Date: Wed, 7 May 2025 17:03:32 -0400 Subject: [PATCH 01/10] Refactor IlluminaFastq class for broad machine type support --- .gitignore | 75 +++++++++++++++++++++++--- seqBackupLib/illumina.py | 113 +++++++++++++++++++++++---------------- 2 files changed, 135 insertions(+), 53 deletions(-) diff --git a/.gitignore b/.gitignore index 1f48344..3e37603 100644 --- a/.gitignore +++ b/.gitignore @@ -1,14 +1,27 @@ +# Editors +.vscode/ +.idea/ + +# Vagrant +.vagrant/ + +# Mac/OSX +.DS_Store + +# Windows +Thumbs.db + +# Source for the following rules: https://raw.githubusercontent.com/github/gitignore/master/Python.gitignore # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] -*~ +*$py.class # C extensions *.so # Distribution / packaging .Python -env/ build/ develop-eggs/ dist/ @@ -20,9 +33,11 @@ lib64/ parts/ sdist/ var/ +wheels/ *.egg-info/ .installed.cfg *.egg +MANIFEST # PyInstaller # Usually these files are written by a python script from a template @@ -37,12 +52,15 @@ pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ +.nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml -*,cover +*.cover +.hypothesis/ +.pytest_cache/ # Translations *.mo @@ -50,6 +68,15 @@ coverage.xml # Django stuff: *.log +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy # Sphinx documentation docs/_build/ @@ -57,6 +84,42 @@ docs/_build/ # PyBuilder target/ -# data files -*.fasta -*.fastq \ No newline at end of file +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json \ No newline at end of file diff --git a/seqBackupLib/illumina.py b/seqBackupLib/illumina.py index ec57b19..0a57cc1 100644 --- a/seqBackupLib/illumina.py +++ b/seqBackupLib/illumina.py @@ -1,14 +1,12 @@ -import gzip -import os.path import re -import warnings +from io import TextIOWrapper +from pathlib import Path +class IlluminaFastq(): + MACHINE_TYPES = {"V": "Illumina-NextSeq", "D": "Illumina-HiSeq", "M": "Illumina-MiSeq", "A": "Illumina-NovaSeq","N": "Illumina-MiniSeq", "LH": "Illumina-NovaSeqX"} -class IlluminaFastq(object): - machine_types = {"V": "Illumina-NextSeq", "D": "Illumina-HiSeq", "M": "Illumina-MiSeq", "A": "Illumina-NovaSeq","N": "Illumina-MiniSeq"} - - def __init__(self, f): + def __init__(self, f: TextIOWrapper): self.file = f self.fastq_info = self._parse_header() self.folder_info = self._parse_folder() @@ -19,13 +17,11 @@ def __str__(self): self.fastq_info["flowcell_id"], self.fastq_info["lane"]]) - def is_same_run(self, other): - run_check = self.fastq_info["run_number"] == other.fastq_info["run_number"] - instrument_check = self.fastq_info["instrument"] == other.fastq_info["instrument"] - flowcell_check = self.fastq_info["flowcell_id"] == other.fastq_info["flowcell_id"] - return (run_check and instrument_check and flowcell_check) + def is_same_run(self, other: "IlluminaFastq") -> bool: + keys = ["run_number", "instrument", "flowcell_id"] + return all(self.fastq_info[k] == other.fastq_info[k] for k in keys) - def _parse_header(self): + def _parse_header(self) -> dict[str, str]: line = next(self.file).strip() if not line.startswith("@"): raise ValueError("Not a FASTQ header line") @@ -42,56 +38,79 @@ def _parse_header(self): vals1.update(vals2) return vals1 - def _parse_folder(self): - matches = re.match("(\\d{6})_([DMANV]B?H?\\d{5,6})_0*(\\d{1,4})_(.*)", self.run_name) - keys1 = ("date", "instrument", "run_number", "flowcell_id") - vals1 = dict((k, v) for k, v in zip(keys1, matches.groups())) - - if self.machine_type == "Illumina-HiSeq" or self.machine_type == "Illumina-NovaSeq" or self.machine_type == "Illumina-MiniSeq": - vals1["flowcell_id"] = vals1["flowcell_id"][1:] + def _parse_folder(self) -> dict[str, str]: + # Extract directory name info + parts = self.run_name.split("_") + + date = parts[0] + if len(date) == 8: + self.date = f"{date[0:4]}-{date[4:6]}-{date[6:8]}" + elif len(date) == 6: + self.date = f"20{date[0:2]}-{date[2:4]}-{date[4:6]}" + else: + raise ValueError(f"Invalid date format in run name: {date}") + + instrument = parts[1] + if self._extract_instrument_code(instrument) not in self.MACHINE_TYPES: + raise ValueError(f"Invalid instrument code in run name: {instrument}") + + run_number = parts[2] + if not run_number.isdigit(): + raise ValueError(f"Invalid run number in run name: {run_number}") + + flowcell_id = parts[3] + # TODO: Add this logic? + # if self.machine_type == "Illumina-HiSeq" or self.machine_type == "Illumina-NovaSeq" or self.machine_type == "Illumina-MiniSeq": + # vals1["flowcell_id"] = vals1["flowcell_id"][1:] - matches = re.match("Undetermined_S0_L00([1-8])_([RI])([12])_001.fastq.gz", os.path.basename(self.filepath)) + if len(parts) > 4: + raise ValueError(f"Unexpected extra parts in run name: {parts[4:]}") + + vals1 = { + "date": date, + "instrument": instrument, + "run_number": run_number, + "flowcell_id": flowcell_id, + } + + # Extract file name info + matches = re.match("Undetermined_S0_L00([1-8])_([RI])([12])_001.fastq.gz", self.filepath.name) keys2 = ("lane", "read_or_index", "read") vals2 = dict((k, v) for k, v in zip(keys2, matches.groups())) vals1.update(vals2) return vals1 - @property - def machine_type(self): - instrument_code = self.fastq_info["instrument"][0] - return self.machine_types[instrument_code] + @staticmethod + def _extract_instrument_code(instrument: str) -> str: + return "".join(filter(lambda x: not x.isdigit(), instrument)) @property - def date(self): - year = self.run_name[0:2] - month = self.run_name[2:4] - day = self.run_name[4:6] - return "20{0}-{1}-{2}".format(year, month, day) + def machine_type(self): + return self.MACHINE_TYPES[self._extract_instrument_code(self.fastq_info["instrument"])] @property - def lane(self): + def lane(self) -> str: return self.fastq_info["lane"] + @property - def filepath(self): - return self.file.name + def filepath(self) -> Path: + return Path(self.file.name) @property - def run_name(self): - dir_split = self.filepath.split(os.sep) - #return(dir_split[-2]) - matches = [re.match("\\d{6}_[DMANV]B?H?\\d{5,6}_\\d{1,4}_[\\dA-Z]{9}", d) for d in dir_split] - matches = [dir_split[i] for i, m in enumerate(matches) if m] - if len(matches) != 1: - raise ValueError("Could not find run name in directory: {0}".format(self.filepath)) - return matches[0] - - def build_archive_dir(self): + def run_name(self) -> str: + for part in self.filepath.parts: + segments = part.split("_") + if len(segments) >= 4 and segments[0].isdigit() and self._extract_instrument_code(segments[1]) in self.MACHINE_TYPES and segments[2].isdigit(): + return part + raise ValueError(f"Run name not found in path: {self.filepath}") + + def build_archive_dir(self) -> str: return '_'.join([self.run_name, 'L{:0>3}'.format(self.lane)]) - def check_fp_vs_content(self): + def check_fp_vs_content(self) -> list[bool]: run_check = self.fastq_info["run_number"] == self.folder_info["run_number"] instrument_check = self.fastq_info["instrument"] == self.folder_info["instrument"] flowcell_check = self.fastq_info["flowcell_id"] == self.folder_info["flowcell_id"] @@ -99,8 +118,8 @@ def check_fp_vs_content(self): read_check = self.fastq_info["read"] == self.folder_info["read"] return ([run_check and instrument_check and flowcell_check and lane_check and read_check, run_check, instrument_check, flowcell_check, lane_check, read_check, self.fastq_info["flowcell_id"], self.folder_info["flowcell_id"]]) - def check_file_size(self, min_file_size): - return os.path.getsize(self.filepath) > min_file_size + def check_file_size(self, min_file_size) -> bool: + return self.filepath.stat().st_size > min_file_size - def check_index_read_exists(self): + def check_index_read_exists(self) -> bool: return len(self.fastq_info["index_reads"]) > 2 From 9966870c30196c768e6c843875146a90c80c974f Mon Sep 17 00:00:00 2001 From: Ulthran Date: Wed, 7 May 2025 17:06:52 -0400 Subject: [PATCH 02/10] Add CI/CD --- .github/workflows/pr.yml | 14 +++++++++++ .github/workflows/test.yml | 50 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 .github/workflows/pr.yml create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 0000000..36fed4b --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,14 @@ +name: Tests + +on: + push: + branches: [ main, master ] + pull_request: + branches: [ main, master ] + + workflow_dispatch: + +jobs: + run-tests: + uses: ./.github/workflows/test.yml + secrets: inherit \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..4902b5d --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,50 @@ +name: Tests + +on: + workflow_call: + + workflow_dispatch: + +jobs: + tests: + name: Run Tests + strategy: + fail-fast: false + matrix: + python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12'] + runs-on: "ubuntu-latest" + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install pytest + python -m pip install . + + - name: Run tests + run: pytest -s -vvvv -l --tb=long tests + + lint: + name: Lint Code Base + runs-on: ubuntu-latest + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: 3.12 + + - name: Install Dependencies + run: pip install black + + - name: Lint Code Base + run: | + black --check . \ No newline at end of file From a6145fc450f8bf802c1da4ff5e7b6ea47775b612 Mon Sep 17 00:00:00 2001 From: Ulthran Date: Wed, 7 May 2025 17:37:37 -0400 Subject: [PATCH 03/10] Fixtures for IlluminaFastq tests --- test/test_illumina.py | 312 ++++++++++++++++-------------------------- 1 file changed, 117 insertions(+), 195 deletions(-) diff --git a/test/test_illumina.py b/test/test_illumina.py index 74a9014..ab767e7 100644 --- a/test/test_illumina.py +++ b/test/test_illumina.py @@ -1,198 +1,120 @@ -import unittest -import os import gzip -from io import StringIO - +import pytest +from pathlib import Path from seqBackupLib.illumina import IlluminaFastq -class IlluminaTests(unittest.TestCase): - - def test_illuminafastq(self): - ##miniseq - fastq_file = StringIO( - u"@NB551353:107:HWJFCAFX2:1:11101:23701:1033 1:N:0:CAAGACGTCC+NNNNTATACT") - fastq_filepath = ( - "incoming/210612_NB551353_0107_AHWJFCAFX2/Data/Intensities/" - "BaseCalls/Undetermined_S0_L001_R1_001.fastq.gz") - folder_info = {"date":"210612", "instrument":"NB551353", "run_number":"107", "flowcell_id":"HWJFCAFX2", "lane":"1", "read_or_index":"R", "read":"1"} - fastq_file.name = fastq_filepath - fq = IlluminaFastq(fastq_file) - ##Miseq - fastq_file = StringIO( - u"@M03543:47:C8LJ2ANXX:1:2209:1084:2044 1:N:0:NNNNNNNN+NNNNNNNN") - fastq_filepath = ( - "Miseq/160511_M03543_0047_000000000-APE6Y/Data/Intensities/" - "BaseCalls/Undetermined_S0_L001_R1_001.fastq.gz") - folder_info = {"date":"160511", "instrument":"M03543", "run_number":"47", "flowcell_id":"000000000-APE6Y", "lane":"1", "read_or_index":"R", "read":"1"} - fastq_file.name = fastq_filepath - fq = IlluminaFastq(fastq_file) - - self.assertEqual(fq.machine_type, "Illumina-MiSeq") - self.assertEqual(fq.date, "2016-05-11") - self.assertEqual(fq.lane, "1") - self.assertEqual(fq.filepath, fastq_filepath) - self.assertEqual(fq.run_name, "160511_M03543_0047_000000000-APE6Y") - - self.assertDictEqual(fq.folder_info, folder_info) - ##Hiseq - fastq_file = StringIO( - u"@D00727:27:CA7HHANXX:1:1105:1243:1992 1:N:0:NGATCAGT+NNAAGGAG") - fastq_filepath = ( - "Hiseq/170330_D00727_0027_ACA7HHANXX/Data/Intensities/" - "BaseCalls/Undetermined_S0_L001_R1_001.fastq.gz") - folder_info = {"date":"170330", "instrument":"D00727", "run_number":"27", "flowcell_id":"CA7HHANXX", "lane":"1", "read_or_index":"R", "read":"1"} - fastq_file.name = fastq_filepath - fq = IlluminaFastq(fastq_file) - - self.assertEqual(fq.machine_type, "Illumina-HiSeq") - self.assertEqual(fq.date, "2017-03-30") - self.assertEqual(fq.lane, "1") - self.assertEqual(fq.filepath, fastq_filepath) - self.assertEqual(fq.run_name, "170330_D00727_0027_ACA7HHANXX") - - self.assertDictEqual(fq.folder_info, folder_info) - - def test_fp_vs_content(self): - # check correct case for Miseq data - fastq_file = StringIO( - u"@M04734:28:000000000-B2MVT:1:2106:17605:1940 1:N:0:TTTTTTTTTTTT+TCTTTCCCTACA") - fastq_filepath = ( - "Miseq/170323_M04734_0028_000000000-B2MVT/Data/Intensities/" - "BaseCalls/Undetermined_S0_L001_R1_001.fastq.gz") - fastq_file.name = fastq_filepath - fq = IlluminaFastq(fastq_file) - self.assertTrue(fq.check_fp_vs_content()) - - # check correct case for Hiseq data - fastq_file = StringIO( - u"@D00727:27:CA7HHANXX:1:1105:1243:1992 1:N:0:NGATCAGT+NNAAGGAG") - fastq_filepath = ( - "Hiseq/170330_D00727_0027_ACA7HHANXX/Data/Intensities/" - "BaseCalls/Undetermined_S0_L001_R1_001.fastq.gz") - fastq_file.name = fastq_filepath - fq = IlluminaFastq(fastq_file) - self.assertTrue(fq.check_fp_vs_content()) - - # case when the lane number doesn't match - fastq_file = StringIO( - u"@M04734:28:000000000-B2MVT:3:2106:17605:1940 1:N:0:TTTTTTTTTTTT+TCTTTCCCTACA") - fastq_filepath = ( - "Miseq/170323_M04734_0028_000000000-B2MVT/Data/Intensities/" - "BaseCalls/Undetermined_S0_L001_R1_001.fastq.gz") - fastq_file.name = fastq_filepath - fq = IlluminaFastq(fastq_file) - self.assertFalse(fq.check_fp_vs_content()) - - # case when the flow cell ID doesn't match - fastq_file = StringIO( - u"@M04734:28:000000000-BBBBB:1:2106:17605:1940 1:N:0:TTTTTTTTTTTT+TCTTTCCCTACA") - fastq_filepath = ( - "Miseq/170323_M04734_0028_000000000-B2MVT/Data/Intensities/" - "BaseCalls/Undetermined_S0_L001_R1_001.fastq.gz") - fastq_file.name = fastq_filepath - fq = IlluminaFastq(fastq_file) - self.assertFalse(fq.check_fp_vs_content()) - - # case when the machine doesn't match - fastq_file = StringIO( - u"@D04734:28:000000000-BBBBB:1:2106:17605:1940 1:N:0:TTTTTTTTTTTT+TCTTTCCCTACA") - fastq_filepath = ( - "Miseq/170323_M04734_0028_000000000-B2MVT/Data/Intensities/" - "BaseCalls/Undetermined_S0_L001_R1_001.fastq.gz") - fastq_file.name = fastq_filepath - fq = IlluminaFastq(fastq_file) - self.assertFalse(fq.check_fp_vs_content()) - - # case when the read doesn't match - ### important: It won't distinguish between R1 and I1. - fastq_file = StringIO( - u"@M04734:28:000000000-B2MVT:1:2106:17605:1940 1:N:0:TTTTTTTTTTTT+TCTTTCCCTACA") - fastq_filepath = ( - "Miseq/170323_M04734_0028_000000000-B2MVT/Data/Intensities/" - "BaseCalls/Undetermined_S0_L001_R2_001.fastq.gz") - fastq_file.name = fastq_filepath - fq = IlluminaFastq(fastq_file) - self.assertFalse(fq.check_fp_vs_content()) - - def test_check_index_read_exists(self): - # test passing - fastq_file = StringIO( - u"@M04734:28:000000000-B2MVT:1:2106:17605:1940 1:N:0:TTTTTTTTTTTT+TCTTTCCCTACA") - fastq_filepath = ( - "Miseq/170323_M04734_0028_000000000-B2MVT/Data/Intensities/" - "BaseCalls/Undetermined_S0_L001_R1_001.fastq.gz") - fastq_file.name = fastq_filepath - fq = IlluminaFastq(fastq_file) - self.assertTrue(fq.check_index_read_exists()) - - # test failing - fastq_file = StringIO( - u"@M04734:28:000000000-B2MVT:1:2106:17605:1940 1:N:0:0") - fastq_filepath = ( - "Miseq/170323_M04734_0028_000000000-B2MVT/Data/Intensities/" - "BaseCalls/Undetermined_S0_L001_R1_001.fastq.gz") - fastq_file.name = fastq_filepath - fq = IlluminaFastq(fastq_file) - self.assertFalse(fq.check_index_read_exists()) - - def test_build_archive_dir(self): - # for MiniSeq - fastq_file=StringIO( - u"@NB551353:107:HWJFCAFX2:1:11101:23701:1033 1:N:0:CAAGACGTCC+NNNNTATACT") - fastq_filepath = ( - "icoming/210621_A00901_0361_BHFWM2DRXY/Data/Intensities/" - "BaseCalls/Undetermined_S0_L001_R1_001.fastq.gz") - fastq_file.name = fastq_filepath - fq = IlluminaFastq(fastq_file) - self.assertEqual(fq.build_archive_dir(), "210621_A00901_0361_BHFWM2DRXY_L001") - - fastq_file = StringIO( - u"@M03543:47:C8LJ2ANXX:1:2209:1084:2044 1:N:0:NNNNNNNN+NNNNNNNN") - fastq_filepath = ( - "Miseq/160511_M03543_0047_000000000-APE6Y/Data/Intensities/" - "BaseCalls/Undetermined_S0_L001_R1_001.fastq.gz") - fastq_file.name = fastq_filepath - fq = IlluminaFastq(fastq_file) - self.assertEqual(fq.build_archive_dir(), "160511_M03543_0047_000000000-APE6Y_L001") - - # for HiSeq - fastq_file = StringIO( - u"@D00727:27:CA7HHANXX:1:1105:1243:1992 1:N:0:NGATCAGT+NNAAGGAG") - fastq_filepath = ( - "Hiseq/170330_D00727_0027_ACA7HHANXX/Data/Intensities/" - "BaseCalls/Undetermined_S0_L001_R1_001.fastq.gz") - fastq_file.name = fastq_filepath - fq = IlluminaFastq(fastq_file) - self.assertEqual(fq.build_archive_dir(), "170330_D00727_0027_ACA7HHANXX_L001") - - def test_check_file_size(self): - curr_dir = os.path.dirname(os.path.abspath(__file__)) - fastq_filepath = os.path.join(curr_dir, "170323_M04734_0028_000000000-B2MVT/Undetermined_S0_L001_R1_001.fastq.gz") - fq = IlluminaFastq(gzip.open(fastq_filepath, mode = 'rt')) - self.assertTrue(fq.check_file_size(50)) - self.assertFalse(fq.check_file_size(50000)) - - def test_is_same_run(self): - fastq_file = StringIO( - u"@NB551353:107:HWJFCAFX2:2:11101:23701:1033 1:N:0:CAAGACGTCC+NNNNTATACT") - fastq_filepath = ( - "incoming/210612_NB551353_0107_AHWJFCAFX2/Data/Intensities/" - "BaseCalls/Undetermined_S0_L001_R1_001.fastq.gz") - fastq_file.name = fastq_filepath - fq = IlluminaFastq(fastq_file) - fastq_file.seek(0) - fq1 = IlluminaFastq(fastq_file) - - fastq_file = StringIO( - u"@D00727:27:CA7HHANXX:1:1105:1243:1992 1:N:0:NGATCAGT+NNAAGGAG") - fastq_filepath = ( - "Hiseq/170330_D00727_0027_ACA7HHANXX/Data/Intensities/" - "BaseCalls/Undetermined_S0_L001_R1_001.fastq.gz") - fastq_file.name = fastq_filepath - fq2 = IlluminaFastq(fastq_file) - - self.assertTrue(fq.is_same_run(fq1)) - self.assertFalse(fq.is_same_run(fq2)) - -if __name__ == "__main__": - unittest.main() + +def setup_illumina_dir(fp: Path, r1: str, r1_lines: list[str]) -> Path: + fp.mkdir(parents=True, exist_ok=True) + + r1 = fp / r1 + with gzip.open(r1, "wt") as f: + f.writelines(r1_lines) + + return fp + + +@pytest.fixture +def novaseq_dir(tmp_path) -> Path: + return setup_illumina_dir( + tmp_path / "250101_A12345_0001_A1234", + "Undetermined_S0_L001_R1_001.fastq.gz", + [ + "@A12345:1:1234:1:1101:1078:1091 R1:Y:0:ATTACTCG\n", + "ACGT\n", + "+\n", + "IIII\n", + ], + ) + + +@pytest.fixture +def hiseq_dir(tmp_path) -> Path: + return setup_illumina_dir( + tmp_path / "250101_D12345_0001_1234", + "Undetermined_S0_L001_R1_001.fastq.gz", + [ + "@D12345:1:1234:1:1101:1078:1091 R1:Y:0:ATTACTCG\n", + "ACGT\n", + "+\n", + "IIII\n", + ], + ) + + +@pytest.fixture +def novaseqx_dir(tmp_path) -> Path: + return setup_illumina_dir( + tmp_path / "250101_LH12345_0001_A1234", + "Undetermined_S0_L001_R1_001.fastq.gz", + [ + "@LH12345:1:1234:1:1101:1078:1091 R1:Y:0:ATTACTCG\n", + "ACGT\n", + "+\n", + "IIII\n", + ], + ) + + +@pytest.fixture +def miseq_dir(tmp_path) -> Path: + return setup_illumina_dir( + tmp_path / "250101_M12345_0001_1234", + "Undetermined_S0_L001_R1_001.fastq.gz", + [ + "@M12345:1:1234:1:1101:1078:1091 R1:Y:0:ATTACTCG\n", + "ACGT\n", + "+\n", + "IIII\n", + ], + ) + + +@pytest.fixture +def miniseq_dir(tmp_path) -> Path: + return setup_illumina_dir( + tmp_path / "250101_N12345_0001_1234", + "Undetermined_S0_L001_R1_001.fastq.gz", + [ + "@N12345:1:1234:1:1101:1078:1091 R1:Y:0:ATTACTCG\n", + "ACGT\n", + "+\n", + "IIII\n", + ], + ) + + +@pytest.fixture +def nextseq_dir(tmp_path) -> Path: + return setup_illumina_dir( + tmp_path / "250101_V12345_0001_1234", + "Undetermined_S0_L001_R1_001.fastq.gz", + [ + "@V12345:1:1234:1:1101:1078:1091 R1:Y:0:ATTACTCG\n", + "ACGT\n", + "+\n", + "IIII\n", + ], + ) + + +machine_fixtures = { + "A": "novaseq_dir", + "D": "hiseq_dir", + "LH": "novaseqx_dir", + "M": "miseq_dir", + "N": "miniseq_dir", + "V": "nextseq_dir", +} + + +@pytest.mark.parametrize("machine_type", IlluminaFastq.MACHINE_TYPES.keys()) +def test_illumina_fastq(machine_type, request): + fixture_name = machine_fixtures.get(machine_type) + if not fixture_name: + raise ValueError(f"All supported machine types must be tested. Missing: {machine_type}") + + fp = request.getfixturevalue(fixture_name) + + with gzip.open(fp / "Undetermined_S0_L001_R1_001.fastq.gz", "rt") as f: + r1 = IlluminaFastq(f) \ No newline at end of file From c372763957a7e27c9e18a3e61af00e74230f7ba8 Mon Sep 17 00:00:00 2001 From: Ulthran Date: Wed, 7 May 2025 18:31:16 -0400 Subject: [PATCH 04/10] Continue building out tests --- scripts/backup_illumina.py | 1 + seqBackupLib/backup.py | 83 +++++++++++----- seqBackupLib/illumina.py | 94 +++++++++++++----- setup.py | 20 ++-- .../Undetermined_S0_L001_I1_001.fastq.gz | Bin 4589 -> 0 bytes .../Undetermined_S0_L001_I2_001.fastq.gz | Bin 3663 -> 0 bytes .../Undetermined_S0_L001_R1_001.fastq.gz | Bin 40439 -> 0 bytes .../Undetermined_S0_L001_R2_001.fastq.gz | Bin 44041 -> 0 bytes .../test_sample_sheet.txt | 5 - test/test_backup.py | 62 +++++++++--- test/test_illumina.py | 25 +++-- 11 files changed, 201 insertions(+), 89 deletions(-) delete mode 100644 test/170323_M04734_0028_000000000-B2MVT/Undetermined_S0_L001_I1_001.fastq.gz delete mode 100644 test/170323_M04734_0028_000000000-B2MVT/Undetermined_S0_L001_I2_001.fastq.gz delete mode 100644 test/170323_M04734_0028_000000000-B2MVT/Undetermined_S0_L001_R1_001.fastq.gz delete mode 100644 test/170323_M04734_0028_000000000-B2MVT/Undetermined_S0_L001_R2_001.fastq.gz delete mode 100644 test/170323_M04734_0028_000000000-B2MVT/test_sample_sheet.txt diff --git a/scripts/backup_illumina.py b/scripts/backup_illumina.py index b1687c5..7fcb1c7 100644 --- a/scripts/backup_illumina.py +++ b/scripts/backup_illumina.py @@ -1,3 +1,4 @@ #!/usr/bin/env python from seqBackupLib.backup import main + main() diff --git a/seqBackupLib/backup.py b/seqBackupLib/backup.py index fe790bd..e29f288 100644 --- a/seqBackupLib/backup.py +++ b/seqBackupLib/backup.py @@ -9,6 +9,7 @@ from seqBackupLib.illumina import IlluminaFastq + def build_fp_to_archive(file_name, has_index, lane): if re.search("R1_001.fastq", file_name) is None: @@ -19,9 +20,12 @@ def build_fp_to_archive(file_name, has_index, lane): label.extend(["I1", "I2"]) rexp = "".join(["(L00", lane, "_)(R1)(_001.fastq.gz)$"]) - modified_fp = [re.sub(rexp, "".join(["\\1", lab, "\\3"]), file_name) for lab in label] + modified_fp = [ + re.sub(rexp, "".join(["\\1", lab, "\\3"]), file_name) for lab in label + ] return [file_name] + modified_fp + def return_md5(fname): # from https://stackoverflow.com/questions/3431825/generating-an-md5-checksum-of-a-file hash_md5 = hashlib.md5() @@ -30,24 +34,33 @@ def return_md5(fname): hash_md5.update(chunk) return hash_md5.hexdigest() + def backup_fastq(forward_reads, dest_dir, sample_sheet_fp, has_index, min_file_size): - - R1 = IlluminaFastq(gzip.open(forward_reads, mode = 'rt')) - # build the strings for the required files + R1 = IlluminaFastq(gzip.open(forward_reads, mode="rt")) + + # build the strings for the required files file_names_RI = build_fp_to_archive(forward_reads, has_index, R1.lane) # create the Illumina objects and check the files illumina_fastqs = [] for fp in file_names_RI: - illumina_temp = IlluminaFastq(gzip.open(fp, mode = 'rt')) + illumina_temp = IlluminaFastq(gzip.open(fp, mode="rt")) if not illumina_temp.check_fp_vs_content()[0]: print(illumina_temp.check_fp_vs_content()[1:]) raise ValueError("The file path and header information don't match") if not illumina_temp.check_file_size(min_file_size): - raise ValueError("File {0} seems suspiciously small. Plese check if you have the correct file or lower the minimum file size threshold".format(fp)) + raise ValueError( + "File {0} seems suspiciously small. Plese check if you have the correct file or lower the minimum file size threshold".format( + fp + ) + ) if not illumina_temp.check_index_read_exists(): - warnings.warn("No barcodes in headers. Were the fastq files generated properly?: {0}".format(fp)) + warnings.warn( + "No barcodes in headers. Were the fastq files generated properly?: {0}".format( + fp + ) + ) illumina_fastqs.append(illumina_temp) # parse the info from the headers in EACH file and check they are consistent within each other @@ -74,42 +87,62 @@ def backup_fastq(forward_reads, dest_dir, sample_sheet_fp, has_index, min_file_s permission = stat.S_IRUSR | stat.S_IRGRP | stat.S_IROTH for fp in file_names_RI: shutil.copyfile(fp, os.path.join(write_dir, os.path.basename(fp))) - os.chmod(os.path.join(write_dir, os.path.basename(fp)), permission) #this doesn't work on isilon + os.chmod( + os.path.join(write_dir, os.path.basename(fp)), permission + ) # this doesn't work on isilon # copy the sample sheet to destination folder - shutil.copyfile(sample_sheet_fp, os.path.join(write_dir, os.path.basename(sample_sheet_fp))) + shutil.copyfile( + sample_sheet_fp, os.path.join(write_dir, os.path.basename(sample_sheet_fp)) + ) # write md5sums to a file md5s = [(os.path.basename(fp), return_md5(fp)) for fp in file_names_RI] - md5out_fp = os.path.join(write_dir, ".".join([illumina_temp.build_archive_dir(), "md5"])) + md5out_fp = os.path.join( + write_dir, ".".join([illumina_temp.build_archive_dir(), "md5"]) + ) with open(md5out_fp, "w") as md5_out: [md5_out.write("\t".join(md5) + "\n") for md5 in md5s] + def main(argv=None): parser = argparse.ArgumentParser(description="Backs up fastq files") + parser.add_argument("--forward-reads", required=True, type=str, help="R1.fastq") parser.add_argument( - "--forward-reads", required=True, - type=str, - help="R1.fastq") - parser.add_argument( - "--destination-dir", required=True, + "--destination-dir", + required=True, type=str, - help="Destination folder to copy the files to.") + help="Destination folder to copy the files to.", + ) parser.add_argument( - "--sample-sheet", required=True, + "--sample-sheet", + required=True, type=str, - help="The sample sheet associated with the run.") + help="The sample sheet associated with the run.", + ) parser.add_argument( - "--has-index", required=False, - type=bool, default=True, - help="Are index reads generated") + "--has-index", + required=False, + type=bool, + default=True, + help="Are index reads generated", + ) parser.add_argument( - "--min-file-size", required=False, - type=int, default=500000000, - help="Minimum file size to register in bytes") + "--min-file-size", + required=False, + type=int, + default=500000000, + help="Minimum file size to register in bytes", + ) args = parser.parse_args(argv) - backup_fastq(args.forward_reads, args.destination_dir, args.sample_sheet, args.has_index, args.min_file_size) + backup_fastq( + args.forward_reads, + args.destination_dir, + args.sample_sheet, + args.has_index, + args.min_file_size, + ) # maybe also ask for single or double reads diff --git a/seqBackupLib/illumina.py b/seqBackupLib/illumina.py index 0a57cc1..b9e9b6e 100644 --- a/seqBackupLib/illumina.py +++ b/seqBackupLib/illumina.py @@ -3,19 +3,30 @@ from pathlib import Path -class IlluminaFastq(): - MACHINE_TYPES = {"V": "Illumina-NextSeq", "D": "Illumina-HiSeq", "M": "Illumina-MiSeq", "A": "Illumina-NovaSeq","N": "Illumina-MiniSeq", "LH": "Illumina-NovaSeqX"} +class IlluminaFastq: + MACHINE_TYPES = { + "VH": "Illumina-NextSeq", + "D": "Illumina-HiSeq", + "M": "Illumina-MiSeq", + "A": "Illumina-NovaSeq", + "N": "Illumina-MiniSeq", + "LH": "Illumina-NovaSeqX", + } def __init__(self, f: TextIOWrapper): self.file = f self.fastq_info = self._parse_header() self.folder_info = self._parse_folder() - + def __str__(self): - return "_".join([self.fastq_info["instrument"], - self.fastq_info["run_number"], - self.fastq_info["flowcell_id"], - self.fastq_info["lane"]]) + return "_".join( + [ + self.fastq_info["instrument"], + self.fastq_info["run_number"], + self.fastq_info["flowcell_id"], + self.fastq_info["lane"], + ] + ) def is_same_run(self, other: "IlluminaFastq") -> bool: keys = ["run_number", "instrument", "flowcell_id"] @@ -28,7 +39,7 @@ def _parse_header(self) -> dict[str, str]: # Remove first character, @ line = line[1:] word1, _, word2 = line.partition(" ") - + keys1 = ("instrument", "run_number", "flowcell_id", "lane") vals1 = dict((k, v) for k, v in zip(keys1, word1.split(":"))) @@ -41,7 +52,7 @@ def _parse_header(self) -> dict[str, str]: def _parse_folder(self) -> dict[str, str]: # Extract directory name info parts = self.run_name.split("_") - + date = parts[0] if len(date) == 8: self.date = f"{date[0:4]}-{date[4:6]}-{date[6:8]}" @@ -49,23 +60,20 @@ def _parse_folder(self) -> dict[str, str]: self.date = f"20{date[0:2]}-{date[2:4]}-{date[4:6]}" else: raise ValueError(f"Invalid date format in run name: {date}") - + instrument = parts[1] if self._extract_instrument_code(instrument) not in self.MACHINE_TYPES: raise ValueError(f"Invalid instrument code in run name: {instrument}") - + run_number = parts[2] if not run_number.isdigit(): raise ValueError(f"Invalid run number in run name: {run_number}") - + flowcell_id = parts[3] - # TODO: Add this logic? - # if self.machine_type == "Illumina-HiSeq" or self.machine_type == "Illumina-NovaSeq" or self.machine_type == "Illumina-MiniSeq": - # vals1["flowcell_id"] = vals1["flowcell_id"][1:] if len(parts) > 4: raise ValueError(f"Unexpected extra parts in run name: {parts[4:]}") - + vals1 = { "date": date, "instrument": instrument, @@ -73,11 +81,21 @@ def _parse_folder(self) -> dict[str, str]: "flowcell_id": flowcell_id, } + if ( + self.machine_type == "Illumina-HiSeq" + or self.machine_type == "Illumina-NovaSeq" + or self.machine_type == "Illumina-MiniSeq" + or self.machine_type == "Illumina-NovaSeqX" + ): + vals1["flowcell_id"] = vals1["flowcell_id"][1:] + # Extract file name info - matches = re.match("Undetermined_S0_L00([1-8])_([RI])([12])_001.fastq.gz", self.filepath.name) + matches = re.match( + "Undetermined_S0_L00([1-8])_([RI])([12])_001.fastq.gz", self.filepath.name + ) keys2 = ("lane", "read_or_index", "read") vals2 = dict((k, v) for k, v in zip(keys2, matches.groups())) - + vals1.update(vals2) return vals1 @@ -87,37 +105,59 @@ def _extract_instrument_code(instrument: str) -> str: @property def machine_type(self): - return self.MACHINE_TYPES[self._extract_instrument_code(self.fastq_info["instrument"])] + return self.MACHINE_TYPES[ + self._extract_instrument_code(self.fastq_info["instrument"]) + ] @property def lane(self) -> str: return self.fastq_info["lane"] - @property def filepath(self) -> Path: return Path(self.file.name) - @property def run_name(self) -> str: for part in self.filepath.parts: segments = part.split("_") - if len(segments) >= 4 and segments[0].isdigit() and self._extract_instrument_code(segments[1]) in self.MACHINE_TYPES and segments[2].isdigit(): + if ( + len(segments) >= 4 + and segments[0].isdigit() + and self._extract_instrument_code(segments[1]) in self.MACHINE_TYPES + and segments[2].isdigit() + ): return part raise ValueError(f"Run name not found in path: {self.filepath}") def build_archive_dir(self) -> str: - return '_'.join([self.run_name, 'L{:0>3}'.format(self.lane)]) + return "_".join([self.run_name, "L{:0>3}".format(self.lane)]) def check_fp_vs_content(self) -> list[bool]: run_check = self.fastq_info["run_number"] == self.folder_info["run_number"] - instrument_check = self.fastq_info["instrument"] == self.folder_info["instrument"] - flowcell_check = self.fastq_info["flowcell_id"] == self.folder_info["flowcell_id"] + instrument_check = ( + self.fastq_info["instrument"] == self.folder_info["instrument"] + ) + flowcell_check = ( + self.fastq_info["flowcell_id"] == self.folder_info["flowcell_id"] + ) lane_check = self.lane == self.folder_info["lane"] read_check = self.fastq_info["read"] == self.folder_info["read"] - return ([run_check and instrument_check and flowcell_check and lane_check and read_check, run_check, instrument_check, flowcell_check, lane_check, read_check, self.fastq_info["flowcell_id"], self.folder_info["flowcell_id"]]) - + return [ + run_check + and instrument_check + and flowcell_check + and lane_check + and read_check, + run_check, + instrument_check, + flowcell_check, + lane_check, + read_check, + self.fastq_info["flowcell_id"], + self.folder_info["flowcell_id"], + ] + def check_file_size(self, min_file_size) -> bool: return self.filepath.stat().st_size > min_file_size diff --git a/setup.py b/setup.py index 66a3ca4..2cfa5dc 100644 --- a/setup.py +++ b/setup.py @@ -3,16 +3,16 @@ from distutils.core import setup # Get version number from package -exec(open('seqBackupLib/version.py').read()) +exec(open("seqBackupLib/version.py").read()) setup( - name='seqBackup', + name="seqBackup", version=__version__, - description='Set of rules to organize our fastq storage on the server.', - author='Ceylan Tanes', - author_email='ctanes@gmail.com', - url='https://github.com/PennChopMicrobiomeProgram', - packages=['seqBackupLib'], - scripts=['scripts/backup_illumina.py']#, - #install_requires=["pandas", "biopython"] - ) + description="Set of rules to organize our fastq storage on the server.", + author="Ceylan Tanes", + author_email="ctanes@gmail.com", + url="https://github.com/PennChopMicrobiomeProgram", + packages=["seqBackupLib"], + scripts=["scripts/backup_illumina.py"], # , + # install_requires=["pandas", "biopython"] +) diff --git a/test/170323_M04734_0028_000000000-B2MVT/Undetermined_S0_L001_I1_001.fastq.gz b/test/170323_M04734_0028_000000000-B2MVT/Undetermined_S0_L001_I1_001.fastq.gz deleted file mode 100644 index 401f4df908e8946ca3b561db596c8a952030de8b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4589 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 X5a~~6Sl}jKI!FHl&J5)Bie>-+M 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 Ds7;eM diff --git a/test/170323_M04734_0028_000000000-B2MVT/Undetermined_S0_L001_R2_001.fastq.gz b/test/170323_M04734_0028_000000000-B2MVT/Undetermined_S0_L001_R2_001.fastq.gz deleted file mode 100644 index 52fde44c423986fecff9d72d63af6313ca7e15e3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 44041 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 diff --git a/test/170323_M04734_0028_000000000-B2MVT/test_sample_sheet.txt b/test/170323_M04734_0028_000000000-B2MVT/test_sample_sheet.txt deleted file mode 100644 index 4af3342..0000000 --- a/test/170323_M04734_0028_000000000-B2MVT/test_sample_sheet.txt +++ /dev/null @@ -1,5 +0,0 @@ -SampleID SampleType study_group -S1 Oral swab healthy -S2 Oral swab active -S3 Feces healthy -S4 Feces active diff --git a/test/test_backup.py b/test/test_backup.py index 0ceea6b..072b9d2 100644 --- a/test/test_backup.py +++ b/test/test_backup.py @@ -6,41 +6,77 @@ from seqBackupLib.illumina import IlluminaFastq from seqBackupLib.backup import * + class BackupTests(unittest.TestCase): def setUp(self): self.curr_dir = os.path.dirname(os.path.abspath(__file__)) - self.fastq_filepath = os.path.join(self.curr_dir, "170323_M04734_0028_000000000-B2MVT/Undetermined_S0_L001_R1_001.fastq.gz") + self.fastq_filepath = os.path.join( + self.curr_dir, + "170323_M04734_0028_000000000-B2MVT/Undetermined_S0_L001_R1_001.fastq.gz", + ) self.temp_out_dir = tempfile.mkdtemp(dir=self.curr_dir) - self.sample_sheet_fp = os.path.join(self.curr_dir, "170323_M04734_0028_000000000-B2MVT/test_sample_sheet.txt") + self.sample_sheet_fp = os.path.join( + self.curr_dir, "170323_M04734_0028_000000000-B2MVT/test_sample_sheet.txt" + ) def tearDown(self): shutil.rmtree(self.temp_out_dir) def test_build_fp_to_archive(self): list1 = build_fp_to_archive("Undetermined_S0_L001_R1_001.fastq.gz", True, "1") - self.assertCountEqual(list1, ["Undetermined_S0_L001_R1_001.fastq.gz", "Undetermined_S0_L001_R2_001.fastq.gz", "Undetermined_S0_L001_I1_001.fastq.gz", "Undetermined_S0_L001_I2_001.fastq.gz"]) - + self.assertCountEqual( + list1, + [ + "Undetermined_S0_L001_R1_001.fastq.gz", + "Undetermined_S0_L001_R2_001.fastq.gz", + "Undetermined_S0_L001_I1_001.fastq.gz", + "Undetermined_S0_L001_I2_001.fastq.gz", + ], + ) + list1 = build_fp_to_archive("Undetermined_S0_L001_R1_001.fastq.gz", False, "1") - self.assertCountEqual(list1, ["Undetermined_S0_L001_R1_001.fastq.gz", "Undetermined_S0_L001_R2_001.fastq.gz"]) + self.assertCountEqual( + list1, + [ + "Undetermined_S0_L001_R1_001.fastq.gz", + "Undetermined_S0_L001_R2_001.fastq.gz", + ], + ) def test_backup_fastq(self): has_index = True min_file_size = 5 - backup_fastq(self.fastq_filepath, self.temp_out_dir, self.sample_sheet_fp, has_index, min_file_size) - + backup_fastq( + self.fastq_filepath, + self.temp_out_dir, + self.sample_sheet_fp, + has_index, + min_file_size, + ) + # check the md5sums of the first fastq is the same - fq = IlluminaFastq(gzip.open(self.fastq_filepath, mode = 'rt')) - out_fp = os.path.join(self.temp_out_dir, fq.build_archive_dir(), os.path.basename(self.fastq_filepath)) + fq = IlluminaFastq(gzip.open(self.fastq_filepath, mode="rt")) + out_fp = os.path.join( + self.temp_out_dir, + fq.build_archive_dir(), + os.path.basename(self.fastq_filepath), + ) md5_orj = return_md5(self.fastq_filepath) md5_trans = return_md5(out_fp) self.assertEqual(md5_orj, md5_trans) - + # check the md5sum of the sample sheet - ss_fp = os.path.join(self.temp_out_dir, fq.build_archive_dir(), os.path.basename(self.sample_sheet_fp)) + ss_fp = os.path.join( + self.temp_out_dir, + fq.build_archive_dir(), + os.path.basename(self.sample_sheet_fp), + ) self.assertEqual(return_md5(ss_fp), "92ef1ca7433cadb5267d822615cd15e2") # check write permissions of the files self.assertEqual(os.stat(out_fp).st_mode, 33060) - + def test_return_md5(self): - self.assertEqual(return_md5(self.fastq_filepath), "13695e47114c02536ae3ca6823a42261") + self.assertEqual( + return_md5(self.fastq_filepath), "13695e47114c02536ae3ca6823a42261" + ) diff --git a/test/test_illumina.py b/test/test_illumina.py index ab767e7..244d071 100644 --- a/test/test_illumina.py +++ b/test/test_illumina.py @@ -31,7 +31,7 @@ def novaseq_dir(tmp_path) -> Path: @pytest.fixture def hiseq_dir(tmp_path) -> Path: return setup_illumina_dir( - tmp_path / "250101_D12345_0001_1234", + tmp_path / "250101_D12345_0001_A1234", "Undetermined_S0_L001_R1_001.fastq.gz", [ "@D12345:1:1234:1:1101:1078:1091 R1:Y:0:ATTACTCG\n", @@ -59,10 +59,10 @@ def novaseqx_dir(tmp_path) -> Path: @pytest.fixture def miseq_dir(tmp_path) -> Path: return setup_illumina_dir( - tmp_path / "250101_M12345_0001_1234", + tmp_path / "250101_M12345_0028_000000000-B2MVT", "Undetermined_S0_L001_R1_001.fastq.gz", [ - "@M12345:1:1234:1:1101:1078:1091 R1:Y:0:ATTACTCG\n", + "@M12345:28:000000000-B2MVT:1:2106:17605:1940 1:N:0:TTTTTTTTTTTT+TCTTTCCCTACA\n", "ACGT\n", "+\n", "IIII\n", @@ -73,7 +73,7 @@ def miseq_dir(tmp_path) -> Path: @pytest.fixture def miniseq_dir(tmp_path) -> Path: return setup_illumina_dir( - tmp_path / "250101_N12345_0001_1234", + tmp_path / "250101_N12345_0001_A1234", "Undetermined_S0_L001_R1_001.fastq.gz", [ "@N12345:1:1234:1:1101:1078:1091 R1:Y:0:ATTACTCG\n", @@ -87,10 +87,10 @@ def miniseq_dir(tmp_path) -> Path: @pytest.fixture def nextseq_dir(tmp_path) -> Path: return setup_illumina_dir( - tmp_path / "250101_V12345_0001_1234", + tmp_path / "250101_VH12345_0022_222C2NYNX", "Undetermined_S0_L001_R1_001.fastq.gz", [ - "@V12345:1:1234:1:1101:1078:1091 R1:Y:0:ATTACTCG\n", + "@VH12345:22:222C2NYNX:1:1101:18286:1000 1:N:0:GGCACTAAGG+GTTGACCTGA\n", "ACGT\n", "+\n", "IIII\n", @@ -104,7 +104,7 @@ def nextseq_dir(tmp_path) -> Path: "LH": "novaseqx_dir", "M": "miseq_dir", "N": "miniseq_dir", - "V": "nextseq_dir", + "VH": "nextseq_dir", } @@ -112,9 +112,16 @@ def nextseq_dir(tmp_path) -> Path: def test_illumina_fastq(machine_type, request): fixture_name = machine_fixtures.get(machine_type) if not fixture_name: - raise ValueError(f"All supported machine types must be tested. Missing: {machine_type}") + raise ValueError( + f"All supported machine types must be tested. Missing: {machine_type}" + ) fp = request.getfixturevalue(fixture_name) with gzip.open(fp / "Undetermined_S0_L001_R1_001.fastq.gz", "rt") as f: - r1 = IlluminaFastq(f) \ No newline at end of file + r1 = IlluminaFastq(f) + + assert r1.machine_type == IlluminaFastq.MACHINE_TYPES[machine_type] + assert r1.check_fp_vs_content()[0] + assert not r1.check_file_size() + assert r1.check_file_size(1000) From 2043ee03229646552d7f10673c0234e116282e16 Mon Sep 17 00:00:00 2001 From: Ulthran Date: Thu, 8 May 2025 13:45:38 -0400 Subject: [PATCH 05/10] Real test data for IlluminaFastq --- seqBackupLib/backup.py | 6 ++-- seqBackupLib/illumina.py | 4 +-- test/test_illumina.py | 62 ++++++++++++++++++++++++++-------------- 3 files changed, 46 insertions(+), 26 deletions(-) diff --git a/seqBackupLib/backup.py b/seqBackupLib/backup.py index e29f288..c4d0fd4 100644 --- a/seqBackupLib/backup.py +++ b/seqBackupLib/backup.py @@ -6,10 +6,12 @@ import gzip import hashlib import warnings - from seqBackupLib.illumina import IlluminaFastq +DEFAULT_MIN_FILE_SIZE = 500000000 # 500MB + + def build_fp_to_archive(file_name, has_index, lane): if re.search("R1_001.fastq", file_name) is None: @@ -132,7 +134,7 @@ def main(argv=None): "--min-file-size", required=False, type=int, - default=500000000, + default=DEFAULT_MIN_FILE_SIZE, help="Minimum file size to register in bytes", ) args = parser.parse_args(argv) diff --git a/seqBackupLib/illumina.py b/seqBackupLib/illumina.py index b9e9b6e..5eb356d 100644 --- a/seqBackupLib/illumina.py +++ b/seqBackupLib/illumina.py @@ -9,7 +9,7 @@ class IlluminaFastq: "D": "Illumina-HiSeq", "M": "Illumina-MiSeq", "A": "Illumina-NovaSeq", - "N": "Illumina-MiniSeq", + "NB": "Illumina-MiniSeq", "LH": "Illumina-NovaSeqX", } @@ -77,7 +77,7 @@ def _parse_folder(self) -> dict[str, str]: vals1 = { "date": date, "instrument": instrument, - "run_number": run_number, + "run_number": str(int(run_number)), "flowcell_id": flowcell_id, } diff --git a/test/test_illumina.py b/test/test_illumina.py index 244d071..18d675b 100644 --- a/test/test_illumina.py +++ b/test/test_illumina.py @@ -1,6 +1,7 @@ import gzip import pytest from pathlib import Path +from seqBackupLib.backup import DEFAULT_MIN_FILE_SIZE from seqBackupLib.illumina import IlluminaFastq @@ -17,13 +18,17 @@ def setup_illumina_dir(fp: Path, r1: str, r1_lines: list[str]) -> Path: @pytest.fixture def novaseq_dir(tmp_path) -> Path: return setup_illumina_dir( - tmp_path / "250101_A12345_0001_A1234", + tmp_path / "250218_A00901_1295_BHTKCGDRX5", "Undetermined_S0_L001_R1_001.fastq.gz", [ - "@A12345:1:1234:1:1101:1078:1091 R1:Y:0:ATTACTCG\n", - "ACGT\n", + "@A00901:1295:HTKCGDRX5:1:2101:1054:1000 1:N:0:NAGTGTTAGG+CGGAACTAGC\n", + "GTAAAAAGCTAGATTTTCGCGATTTACCAGACGAACTANTNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN\n", "+\n", - "IIII\n", + "FFFFF,FFF:FFFFFF,FFFFFFFFFFFFFFFFFFFFF#,###############################################################################################################\n", + "@A00901:1295:HTKCGDRX5:1:2101:1090:1000 1:N:0:AATTCTTGGA+AAGTTGACAA\n", + "GCTGCAATATGCGCCAACAAAACCGGTGGATAAAAAGGTTTCGTAATATAGTCATCNCNGNCNTNTNCNANNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNATTTCTCCGC\n", + "+\n", + ",FFFFFFFF:FFFFFFFFFFFF::FFFFFFFFF,FFFFFFF:F::FFFFFFFFFF:#F#F#F#F#:#F#F#######################################################################FFFFFFFFFF\n", ], ) @@ -31,13 +36,17 @@ def novaseq_dir(tmp_path) -> Path: @pytest.fixture def hiseq_dir(tmp_path) -> Path: return setup_illumina_dir( - tmp_path / "250101_D12345_0001_A1234", + tmp_path / "201118_D00728_0139_ACD5C3ANXX", "Undetermined_S0_L001_R1_001.fastq.gz", [ - "@D12345:1:1234:1:1101:1078:1091 R1:Y:0:ATTACTCG\n", - "ACGT\n", + "@D00728:139:CD5C3ANXX:1:1101:1228:2123 1:N:0:ATCTCAGG+CCTAGAGT\n", + "NTGCGCAGGGGGACCTGCACCGGCATCCCCTGTACCGGCGGGGCGCTCAGGCTGAATGCGCCGTCCTGCATCAGTACCGACTCCGGCTCGATGGCTTTATCCTGTCTCTTATACACATCTCCGAGC\n", "+\n", - "IIII\n", + "#:<>AEBGGGGGGGGGDDG=CGGGGF=FGGGGGGGGGGGGGGGGF Path: @pytest.fixture def novaseqx_dir(tmp_path) -> Path: return setup_illumina_dir( - tmp_path / "250101_LH12345_0001_A1234", + tmp_path / "20250429_LH00732_0028_A22YJWWLT3", "Undetermined_S0_L001_R1_001.fastq.gz", [ - "@LH12345:1:1234:1:1101:1078:1091 R1:Y:0:ATTACTCG\n", + "@LH00732:28:22YJWWLT3:1:1101:1213:1080 1:N:0:CCTCCGTCCA+CACCGATGTG\n", "ACGT\n", "+\n", "IIII\n", @@ -59,13 +68,17 @@ def novaseqx_dir(tmp_path) -> Path: @pytest.fixture def miseq_dir(tmp_path) -> Path: return setup_illumina_dir( - tmp_path / "250101_M12345_0028_000000000-B2MVT", + tmp_path / "250407_M03543_0443_000000000-DTHBL", "Undetermined_S0_L001_R1_001.fastq.gz", [ - "@M12345:28:000000000-B2MVT:1:2106:17605:1940 1:N:0:TTTTTTTTTTTT+TCTTTCCCTACA\n", - "ACGT\n", + "@M03543:443:000000000-DTHBL:1:1101:16223:1348 1:N:0:TTTTTTTTTTTT+TTCTTTTTCCTT\n", + "TCTTCCCTCTTTCTTCTTTCTTCCTCCCTTCCCTTCTCTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTCTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTT\n", "+\n", - "IIII\n", + ">>>>A1C1BB1B3A333BB33B311100BA000BBBE122B110A//AA/>//>///>///<<-9-99--99---999--999@999-9999@>---9------9-9\n", + "@M03543:443:000000000-DTHBL:1:1101:15497:1351 1:N:0:TTTTTTTTTTTT+TTCTTTTTCCTC\n", + "TCTTCCCTCTTTCTTCTTTCTTCCTCCCTTCCCTTCTCTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTCTCTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTCTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTT\n", + "+\n", + ">>>>A1C1BB1B3B333BB33B311100BB000BBCD122B110A//AA/>//>///>////<---9------9-9\n", ], ) @@ -73,13 +86,17 @@ def miseq_dir(tmp_path) -> Path: @pytest.fixture def miniseq_dir(tmp_path) -> Path: return setup_illumina_dir( - tmp_path / "250101_N12345_0001_A1234", + tmp_path / "210612_NB551353_0107_AHWJFCAFX2", "Undetermined_S0_L001_R1_001.fastq.gz", [ - "@N12345:1:1234:1:1101:1078:1091 R1:Y:0:ATTACTCG\n", - "ACGT\n", + "@NB551353:107:HWJFCAFX2:1:11101:1486:1048 1:N:0:TAATTAGCGT+NNNTTAACCA\n", + "GAAATNGACCGCCTCAATGAGGTTGCCAAGAATTTAAATGAATCTCTCATCGATCTCCAAGAACTTGGAAAGTA\n", "+\n", - "IIII\n", + "AAAAA#EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEAEEEEEEEEEEEEEEEAEEEEEEEEEEEEEEEE\n", + "@NB551353:107:HWJFCAFX2:1:11101:6713:1048 1:N:0:GAAGACTAGA+NNNTTCTAGT\n", + "GGCTANATCTGAGGACAAGAGGGCAAAAGTTACTAGTGCTATGCAGACAATGCTTTTCACTATGCTTAGAAAGT\n", + "+\n", + "AAAAA#EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE\n", ], ) @@ -103,7 +120,7 @@ def nextseq_dir(tmp_path) -> Path: "D": "hiseq_dir", "LH": "novaseqx_dir", "M": "miseq_dir", - "N": "miniseq_dir", + "NB": "miniseq_dir", "VH": "nextseq_dir", } @@ -121,7 +138,8 @@ def test_illumina_fastq(machine_type, request): with gzip.open(fp / "Undetermined_S0_L001_R1_001.fastq.gz", "rt") as f: r1 = IlluminaFastq(f) + print("FASTQ info: ", r1.fastq_info, "\nFolder info: ", r1.folder_info) assert r1.machine_type == IlluminaFastq.MACHINE_TYPES[machine_type] - assert r1.check_fp_vs_content()[0] - assert not r1.check_file_size() - assert r1.check_file_size(1000) + assert r1.check_fp_vs_content()[0], r1.check_fp_vs_content() + assert not r1.check_file_size(DEFAULT_MIN_FILE_SIZE) + assert r1.check_file_size(100) From a6de17fbfcf40d1e1995cbd4e238b9bc34dc7e72 Mon Sep 17 00:00:00 2001 From: Ulthran Date: Fri, 9 May 2025 07:38:42 -0400 Subject: [PATCH 06/10] More thorough testing --- .github/workflows/test.yml | 2 +- seqBackupLib/backup.py | 105 ++++++++-------- seqBackupLib/illumina.py | 52 +++++++- test/conftest.py | 237 +++++++++++++++++++++++++++++++++++++ test/test_backup.py | 133 ++++++++++----------- test/test_illumina.py | 111 +---------------- 6 files changed, 404 insertions(+), 236 deletions(-) create mode 100644 test/conftest.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4902b5d..9c8eb48 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,7 +11,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12'] + python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12', '3.13'] runs-on: "ubuntu-latest" steps: diff --git a/seqBackupLib/backup.py b/seqBackupLib/backup.py index c4d0fd4..920f206 100644 --- a/seqBackupLib/backup.py +++ b/seqBackupLib/backup.py @@ -6,64 +6,68 @@ import gzip import hashlib import warnings +from pathlib import Path from seqBackupLib.illumina import IlluminaFastq DEFAULT_MIN_FILE_SIZE = 500000000 # 500MB -def build_fp_to_archive(file_name, has_index, lane): +def build_fp_to_archive(fp: Path, has_index: bool, lane: str) -> list[Path]: - if re.search("R1_001.fastq", file_name) is None: - raise IOError("The file doesn't look like an R1 file: {}".format(file_name)) + if re.search("R1_001.fastq", fp.name) is None: + raise IOError("The file doesn't look like an R1 file: {}".format(fp)) label = ["R2"] if has_index: label.extend(["I1", "I2"]) rexp = "".join(["(L00", lane, "_)(R1)(_001.fastq.gz)$"]) - modified_fp = [ - re.sub(rexp, "".join(["\\1", lab, "\\3"]), file_name) for lab in label - ] - return [file_name] + modified_fp + modified_fp = [re.sub(rexp, "".join(["\\1", lab, "\\3"]), fp.name) for lab in label] + return [fp] + [fp.parent / n for n in modified_fp] -def return_md5(fname): +def return_md5(fp: Path) -> str: # from https://stackoverflow.com/questions/3431825/generating-an-md5-checksum-of-a-file hash_md5 = hashlib.md5() - with open(fname, "rb") as f: + with open(fp, "rb") as f: for chunk in iter(lambda: f.read(4096), b""): hash_md5.update(chunk) return hash_md5.hexdigest() -def backup_fastq(forward_reads, dest_dir, sample_sheet_fp, has_index, min_file_size): +def backup_fastq( + forward_reads: Path, + dest_dir: Path, + sample_sheet_fp: Path, + has_index: bool, + min_file_size: int, +): R1 = IlluminaFastq(gzip.open(forward_reads, mode="rt")) # build the strings for the required files - file_names_RI = build_fp_to_archive(forward_reads, has_index, R1.lane) + RI_fps = build_fp_to_archive(forward_reads, has_index, R1.lane) # create the Illumina objects and check the files - illumina_fastqs = [] - for fp in file_names_RI: - illumina_temp = IlluminaFastq(gzip.open(fp, mode="rt")) - if not illumina_temp.check_fp_vs_content()[0]: - print(illumina_temp.check_fp_vs_content()[1:]) - raise ValueError("The file path and header information don't match") - if not illumina_temp.check_file_size(min_file_size): - raise ValueError( - "File {0} seems suspiciously small. Plese check if you have the correct file or lower the minimum file size threshold".format( - fp - ) - ) - if not illumina_temp.check_index_read_exists(): - warnings.warn( - "No barcodes in headers. Were the fastq files generated properly?: {0}".format( - fp - ) - ) - illumina_fastqs.append(illumina_temp) + illumina_fastqs = [IlluminaFastq(gzip.open(fp, mode="rt")) for fp in RI_fps] + r1 = illumina_fastqs[0] + + if not all([ifq.check_fp_vs_content()[0] for ifq in illumina_fastqs]): + [ifq.check_fp_vs_content(verbose=True) for ifq in illumina_fastqs] + raise ValueError( + "The file path and header information don't match", + [str(ifq) for ifq in illumina_fastqs if not ifq.check_fp_vs_content()[0]], + ) + if not all([ifq.check_file_size(min_file_size) for ifq in illumina_fastqs]): + raise ValueError( + "File seems suspiciously small. Please check if you have the correct file or lower the minimum file size threshold", + [ifq.check_file_size(min_file_size) for ifq in illumina_fastqs], + ) + if not all([ifq.check_index_read_exists() for ifq in illumina_fastqs]): + warnings.warn( + "No barcodes in headers. Were the fastq files generated properly?" + ) # parse the info from the headers in EACH file and check they are consistent within each other if not all([fastq.is_same_run(illumina_fastqs[0]) for fastq in illumina_fastqs]): @@ -72,55 +76,48 @@ def backup_fastq(forward_reads, dest_dir, sample_sheet_fp, has_index, min_file_s ## Archiving steps # make sure the sample sheet exists - if not os.path.isfile(sample_sheet_fp): - raise IOError("Sample sheet does not exist: {}".format(sample_sheet_fp)) + if not sample_sheet_fp.is_file(): + raise IOError("Sample sheet does not exist", str(sample_sheet_fp)) # create the folder to write to - write_dir = os.path.join(dest_dir, illumina_temp.build_archive_dir()) - - # create the folder. If it exists exit - if os.path.isdir(write_dir): - raise IOError("The folder already exists: {}".format(write_dir)) - os.mkdir(write_dir) + write_dir = dest_dir / r1.build_archive_dir() + write_dir.mkdir(parents=True, exist_ok=False) ### All the checks are done and the files are safe to archive! # move the files to the archive location and remove permission permission = stat.S_IRUSR | stat.S_IRGRP | stat.S_IROTH - for fp in file_names_RI: - shutil.copyfile(fp, os.path.join(write_dir, os.path.basename(fp))) - os.chmod( - os.path.join(write_dir, os.path.basename(fp)), permission - ) # this doesn't work on isilon + for fp in RI_fps: + output_fp = write_dir / fp.name + shutil.copyfile(fp, output_fp) + output_fp.chmod(permission) # copy the sample sheet to destination folder - shutil.copyfile( - sample_sheet_fp, os.path.join(write_dir, os.path.basename(sample_sheet_fp)) - ) + shutil.copyfile(sample_sheet_fp, write_dir / sample_sheet_fp.name) # write md5sums to a file - md5s = [(os.path.basename(fp), return_md5(fp)) for fp in file_names_RI] - md5out_fp = os.path.join( - write_dir, ".".join([illumina_temp.build_archive_dir(), "md5"]) - ) - with open(md5out_fp, "w") as md5_out: + md5s = [(fp.name, return_md5(fp)) for fp in RI_fps] + md5_out_fp = write_dir / ".".join([r1.build_archive_dir(), "md5"]) + with open(md5_out_fp, "w") as md5_out: [md5_out.write("\t".join(md5) + "\n") for md5 in md5s] def main(argv=None): parser = argparse.ArgumentParser(description="Backs up fastq files") - parser.add_argument("--forward-reads", required=True, type=str, help="R1.fastq") + parser.add_argument( + "--forward-reads", required=True, type=Path, help="Gzipped R1 fastq file" + ) parser.add_argument( "--destination-dir", required=True, - type=str, + type=Path, help="Destination folder to copy the files to.", ) parser.add_argument( "--sample-sheet", required=True, - type=str, + type=Path, help="The sample sheet associated with the run.", ) parser.add_argument( diff --git a/seqBackupLib/illumina.py b/seqBackupLib/illumina.py index 5eb356d..bc0a913 100644 --- a/seqBackupLib/illumina.py +++ b/seqBackupLib/illumina.py @@ -133,7 +133,7 @@ def run_name(self) -> str: def build_archive_dir(self) -> str: return "_".join([self.run_name, "L{:0>3}".format(self.lane)]) - def check_fp_vs_content(self) -> list[bool]: + def check_fp_vs_content(self, verbose: bool = False) -> list[bool]: run_check = self.fastq_info["run_number"] == self.folder_info["run_number"] instrument_check = ( self.fastq_info["instrument"] == self.folder_info["instrument"] @@ -143,6 +143,56 @@ def check_fp_vs_content(self) -> list[bool]: ) lane_check = self.lane == self.folder_info["lane"] read_check = self.fastq_info["read"] == self.folder_info["read"] + + if verbose: + ( + print( + "Fastq run number: ", + self.fastq_info["run_number"], + "Folder run number: ", + self.folder_info["run_number"], + ) + if not run_check + else None + ) + ( + print( + "Fastq instrument: ", + self.fastq_info["instrument"], + "Folder instrument: ", + self.folder_info["instrument"], + ) + if not instrument_check + else None + ) + ( + print( + "Fastq flowcell id: ", + self.fastq_info["flowcell_id"], + "Folder flowcell id: ", + self.folder_info["flowcell_id"], + ) + if not flowcell_check + else None + ) + ( + print( + "Fastq lane: ", self.lane, "Folder lane: ", self.folder_info["lane"] + ) + if not lane_check + else None + ) + ( + print( + "Fastq read: ", + self.fastq_info["read"], + "Folder read: ", + self.folder_info["read"], + ) + if not read_check + else None + ) + return [ run_check and instrument_check diff --git a/test/conftest.py b/test/conftest.py new file mode 100644 index 0000000..2918247 --- /dev/null +++ b/test/conftest.py @@ -0,0 +1,237 @@ +import gzip +import pytest +from pathlib import Path + + +def setup_illumina_dir(fp: Path, r1: str, r1_lines: list[str]) -> Path: + fp.mkdir(parents=True, exist_ok=True) + + r1_fp = fp / r1 + with gzip.open(r1_fp, "wt") as f: + f.writelines(r1_lines) + + (fp / r1.replace("R1", "R2")).touch() + (fp / r1.replace("R1", "I1")).touch() + (fp / r1.replace("R1", "I2")).touch() + (fp / "sample_sheet.csv").touch() + + return fp + + +@pytest.fixture +def novaseq_dir(tmp_path) -> Path: + return setup_illumina_dir( + tmp_path / "250218_A00901_1295_BHTKCGDRX5", + "Undetermined_S0_L001_R1_001.fastq.gz", + [ + "@A00901:1295:HTKCGDRX5:1:2101:1054:1000 1:N:0:NAGTGTTAGG+CGGAACTAGC\n", + "GTAAAAAGCTAGATTTTCGCGATTTACCAGACGAACTANTNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN\n", + "+\n", + "FFFFF,FFF:FFFFFF,FFFFFFFFFFFFFFFFFFFFF#,###############################################################################################################\n", + "@A00901:1295:HTKCGDRX5:1:2101:1090:1000 1:N:0:AATTCTTGGA+AAGTTGACAA\n", + "GCTGCAATATGCGCCAACAAAACCGGTGGATAAAAAGGTTTCGTAATATAGTCATCNCNGNCNTNTNCNANNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNATTTCTCCGC\n", + "+\n", + ",FFFFFFFF:FFFFFFFFFFFF::FFFFFFFFF,FFFFFFF:F::FFFFFFFFFF:#F#F#F#F#:#F#F#######################################################################FFFFFFFFFF\n", + ], + ) + + +@pytest.fixture +def hiseq_dir(tmp_path) -> Path: + return setup_illumina_dir( + tmp_path / "201118_D00728_0139_ACD5C3ANXX", + "Undetermined_S0_L001_R1_001.fastq.gz", + [ + "@D00728:139:CD5C3ANXX:1:1101:1228:2123 1:N:0:ATCTCAGG+CCTAGAGT\n", + "NTGCGCAGGGGGACCTGCACCGGCATCCCCTGTACCGGCGGGGCGCTCAGGCTGAATGCGCCGTCCTGCATCAGTACCGACTCCGGCTCGATGGCTTTATCCTGTCTCTTATACACATCTCCGAGC\n", + "+\n", + "#:<>AEBGGGGGGGGGDDG=CGGGGF=FGGGGGGGGGGGGGGGGF Path: + return setup_illumina_dir( + tmp_path / "20250429_LH00732_0028_A22YJWWLT3", + "Undetermined_S0_L001_R1_001.fastq.gz", + [ + "@LH00732:28:22YJWWLT3:1:1101:1213:1080 1:N:0:CCTCCGTCCA+CACCGATGTG\n", + "ACGT\n", + "+\n", + "IIII\n", + ], + ) + + +@pytest.fixture +def miseq_dir(tmp_path) -> Path: + return setup_illumina_dir( + tmp_path / "250407_M03543_0443_000000000-DTHBL", + "Undetermined_S0_L001_R1_001.fastq.gz", + [ + "@M03543:443:000000000-DTHBL:1:1101:16223:1348 1:N:0:TTTTTTTTTTTT+TTCTTTTTCCTT\n", + "TCTTCCCTCTTTCTTCTTTCTTCCTCCCTTCCCTTCTCTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTCTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTT\n", + "+\n", + ">>>>A1C1BB1B3A333BB33B311100BA000BBBE122B110A//AA/>//>///>///<<-9-99--99---999--999@999-9999@>---9------9-9\n", + "@M03543:443:000000000-DTHBL:1:1101:15497:1351 1:N:0:TTTTTTTTTTTT+TTCTTTTTCCTC\n", + "TCTTCCCTCTTTCTTCTTTCTTCCTCCCTTCCCTTCTCTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTCTCTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTCTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTT\n", + "+\n", + ">>>>A1C1BB1B3B333BB33B311100BB000BBCD122B110A//AA/>//>///>////<---9------9-9\n", + ], + ) + + +@pytest.fixture +def miniseq_dir(tmp_path) -> Path: + return setup_illumina_dir( + tmp_path / "210612_NB551353_0107_AHWJFCAFX2", + "Undetermined_S0_L001_R1_001.fastq.gz", + [ + "@NB551353:107:HWJFCAFX2:1:11101:1486:1048 1:N:0:TAATTAGCGT+NNNTTAACCA\n", + "GAAATNGACCGCCTCAATGAGGTTGCCAAGAATTTAAATGAATCTCTCATCGATCTCCAAGAACTTGGAAAGTA\n", + "+\n", + "AAAAA#EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEAEEEEEEEEEEEEEEEAEEEEEEEEEEEEEEEE\n", + "@NB551353:107:HWJFCAFX2:1:11101:6713:1048 1:N:0:GAAGACTAGA+NNNTTCTAGT\n", + "GGCTANATCTGAGGACAAGAGGGCAAAAGTTACTAGTGCTATGCAGACAATGCTTTTCACTATGCTTAGAAAGT\n", + "+\n", + "AAAAA#EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE\n", + ], + ) + + +@pytest.fixture +def nextseq_dir(tmp_path) -> Path: + return setup_illumina_dir( + tmp_path / "250101_VH12345_0022_222C2NYNX", + "Undetermined_S0_L001_R1_001.fastq.gz", + [ + "@VH12345:22:222C2NYNX:1:1101:18286:1000 1:N:0:GGCACTAAGG+GTTGACCTGA\n", + "ACGT\n", + "+\n", + "IIII\n", + ], + ) + + +@pytest.fixture +def full_miseq_dir(tmp_path) -> Path: + # Lane 1 + setup_illumina_dir( + tmp_path / "250407_M03543_0443_000000000-DTHBL", + "Undetermined_S0_L001_R1_001.fastq.gz", + [ + "@M03543:443:000000000-DTHBL:1:1101:16223:1348 1:N:0:TTTTTTTTTTTT+TTCTTTTTCCTT\n", + "TCTTCCCTCTTTCTTCTTTCTTCCTCCCTTCCCTTCTCTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTCTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTT\n", + "+\n", + ">>>>A1C1BB1B3A333BB33B311100BA000BBBE122B110A//AA/>//>///>///<<-9-99--99---999--999@999-9999@>---9------9-9\n", + "@M03543:443:000000000-DTHBL:1:1101:15497:1351 1:N:0:TTTTTTTTTTTT+TTCTTTTTCCTC\n", + "TCTTCCCTCTTTCTTCTTTCTTCCTCCCTTCCCTTCTCTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTCTCTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTCTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTT\n", + "+\n", + ">>>>A1C1BB1B3B333BB33B311100BB000BBCD122B110A//AA/>//>///>////<---9------9-9\n", + ], + ) + setup_illumina_dir( + tmp_path / "250407_M03543_0443_000000000-DTHBL", + "Undetermined_S0_L001_R2_001.fastq.gz", + [ + "@M03543:443:000000000-DTHBL:1:1101:16223:1348 2:N:0:TTTTTTTTTTTT+TTCTTTTTCCTT\n", + "TCTTCCCTCTTTCTTCTTTCTTCCTCCCTTCCCTTCTCTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTCTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTT\n", + "+\n", + ">>>>A1C1BB1B3A333BB33B311100BA000BBBE122B110A//AA/>//>///>///<<-9-99--99---999--999@999-9999@>---9------9-9\n", + "@M03543:443:000000000-DTHBL:1:1101:15497:1351 2:N:0:TTTTTTTTTTTT+TTCTTTTTCCTC\n", + "TCTTCCCTCTTTCTTCTTTCTTCCTCCCTTCCCTTCTCTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTCTCTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTCTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTT\n", + "+\n", + ">>>>A1C1BB1B3B333BB33B311100BB000BBCD122B110A//AA/>//>///>////<---9------9-9\n", + ], + ) + setup_illumina_dir( + tmp_path / "250407_M03543_0443_000000000-DTHBL", + "Undetermined_S0_L001_I1_001.fastq.gz", + [ + "@M03543:443:000000000-DTHBL:1:2106:17605:1940 1:N:0:TTTTTTTTTTTT+TCTTTCCCTACA\n", + "TTTTTTTTTTTT\n", + "+\n", + "111>111>0000\n", + "@M03543:443:000000000-DTHBL:1:2106:14807:1943 1:N:0:TTTTTTTTTTTT+TCTTTCCCTACA\n", + "TTTTTTTTTTTT\n", + "+\n", + "1>1111>100>0\n", + ], + ) + setup_illumina_dir( + tmp_path / "250407_M03543_0443_000000000-DTHBL", + "Undetermined_S0_L001_I2_001.fastq.gz", + [ + "@M03543:443:000000000-DTHBL:1:2106:17605:1940 2:N:0:TTTTTTTTTTTT+TCTTTCCCTACA\n", + "TTTTTTTTTTTT\n", + "+\n", + "111>111>0000\n", + "@M03543:443:000000000-DTHBL:1:2106:14807:1943 2:N:0:TTTTTTTTTTTT+TCTTTCCCTACA\n", + "TTTTTTTTTTTT\n", + "+\n", + "1>1111>100>0\n", + ], + ) + + # Lane 2 + setup_illumina_dir( + tmp_path / "250407_M03543_0443_000000000-DTHBL", + "Undetermined_S0_L002_R1_001.fastq.gz", + [ + "@M03543:443:000000000-DTHBL:2:1101:16223:1348 1:N:0:TTTTTTTTTTTT+TTCTTTTTCCTT\n", + "TCTTCCCTCTTTCTTCTTTCTTCCTCCCTTCCCTTCTCTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTCTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTT\n", + "+\n", + ">>>>A1C1BB1B3A333BB33B311100BA000BBBE122B110A//AA/>//>///>///<<-9-99--99---999--999@999-9999@>---9------9-9\n", + "@M03543:443:000000000-DTHBL:2:1101:15497:1351 1:N:0:TTTTTTTTTTTT+TTCTTTTTCCTC\n", + "TCTTCCCTCTTTCTTCTTTCTTCCTCCCTTCCCTTCTCTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTCTCTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTCTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTT\n", + "+\n", + ">>>>A1C1BB1B3B333BB33B311100BB000BBCD122B110A//AA/>//>///>////<---9------9-9\n", + ], + ) + setup_illumina_dir( + tmp_path / "250407_M03543_0443_000000000-DTHBL", + "Undetermined_S0_L002_R2_001.fastq.gz", + [ + "@M03543:443:000000000-DTHBL:2:1101:16223:1348 2:N:0:TTTTTTTTTTTT+TTCTTTTTCCTT\n", + "TCTTCCCTCTTTCTTCTTTCTTCCTCCCTTCCCTTCTCTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTCTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTT\n", + "+\n", + ">>>>A1C1BB1B3A333BB33B311100BA000BBBE122B110A//AA/>//>///>///<<-9-99--99---999--999@999-9999@>---9------9-9\n", + "@M03543:443:000000000-DTHBL:2:1101:15497:1351 2:N:0:TTTTTTTTTTTT+TTCTTTTTCCTC\n", + "TCTTCCCTCTTTCTTCTTTCTTCCTCCCTTCCCTTCTCTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTCTCTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTCTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTT\n", + "+\n", + ">>>>A1C1BB1B3B333BB33B311100BB000BBCD122B110A//AA/>//>///>////<---9------9-9\n", + ], + ) + setup_illumina_dir( + tmp_path / "250407_M03543_0443_000000000-DTHBL", + "Undetermined_S0_L002_I1_001.fastq.gz", + [ + "@M03543:443:000000000-DTHBL:2:2106:17605:1940 1:N:0:TTTTTTTTTTTT+TCTTTCCCTACA\n", + "TTTTTTTTTTTT\n", + "+\n", + "111>111>0000\n", + "@M03543:443:000000000-DTHBL:2:2106:14807:1943 1:N:0:TTTTTTTTTTTT+TCTTTCCCTACA\n", + "TTTTTTTTTTTT\n", + "+\n", + "1>1111>100>0\n", + ], + ) + return setup_illumina_dir( + tmp_path / "250407_M03543_0443_000000000-DTHBL", + "Undetermined_S0_L002_I2_001.fastq.gz", + [ + "@M03543:443:000000000-DTHBL:2:2106:17605:1940 2:N:0:TTTTTTTTTTTT+TCTTTCCCTACA\n", + "TTTTTTTTTTTT\n", + "+\n", + "111>111>0000\n", + "@M03543:443:000000000-DTHBL:2:2106:14807:1943 2:N:0:TTTTTTTTTTTT+TCTTTCCCTACA\n", + "TTTTTTTTTTTT\n", + "+\n", + "1>1111>100>0\n", + ], + ) diff --git a/test/test_backup.py b/test/test_backup.py index 072b9d2..4645a71 100644 --- a/test/test_backup.py +++ b/test/test_backup.py @@ -1,82 +1,75 @@ -import unittest -import gzip -import tempfile -from io import StringIO +import pytest +from pathlib import Path +from seqBackupLib.backup import backup_fastq, build_fp_to_archive, return_md5 -from seqBackupLib.illumina import IlluminaFastq -from seqBackupLib.backup import * +def test_build_fp_to_archive(): + archive = build_fp_to_archive( + Path("Undetermined_S0_L001_R1_001.fastq.gz"), True, "1" + ) + assert archive == [ + Path("Undetermined_S0_L001_R1_001.fastq.gz"), + Path("Undetermined_S0_L001_R2_001.fastq.gz"), + Path("Undetermined_S0_L001_I1_001.fastq.gz"), + Path("Undetermined_S0_L001_I2_001.fastq.gz"), + ] -class BackupTests(unittest.TestCase): - def setUp(self): - self.curr_dir = os.path.dirname(os.path.abspath(__file__)) - self.fastq_filepath = os.path.join( - self.curr_dir, - "170323_M04734_0028_000000000-B2MVT/Undetermined_S0_L001_R1_001.fastq.gz", - ) - self.temp_out_dir = tempfile.mkdtemp(dir=self.curr_dir) - self.sample_sheet_fp = os.path.join( - self.curr_dir, "170323_M04734_0028_000000000-B2MVT/test_sample_sheet.txt" - ) + archive = build_fp_to_archive( + Path("Undetermined_S0_L001_R1_001.fastq.gz"), False, "1" + ) + assert archive == [ + Path("Undetermined_S0_L001_R1_001.fastq.gz"), + Path("Undetermined_S0_L001_R2_001.fastq.gz"), + ] - def tearDown(self): - shutil.rmtree(self.temp_out_dir) + archive = build_fp_to_archive( + Path("Undetermined_S0_L002_R1_001.fastq.gz"), True, "2" + ) + assert archive == [ + Path("Undetermined_S0_L002_R1_001.fastq.gz"), + Path("Undetermined_S0_L002_R2_001.fastq.gz"), + Path("Undetermined_S0_L002_I1_001.fastq.gz"), + Path("Undetermined_S0_L002_I2_001.fastq.gz"), + ] - def test_build_fp_to_archive(self): - list1 = build_fp_to_archive("Undetermined_S0_L001_R1_001.fastq.gz", True, "1") - self.assertCountEqual( - list1, - [ - "Undetermined_S0_L001_R1_001.fastq.gz", - "Undetermined_S0_L001_R2_001.fastq.gz", - "Undetermined_S0_L001_I1_001.fastq.gz", - "Undetermined_S0_L001_I2_001.fastq.gz", - ], - ) + with pytest.raises(IOError): + build_fp_to_archive(Path("Undetermined_S0_L001_R2_001.fastq.gz"), True, "1") - list1 = build_fp_to_archive("Undetermined_S0_L001_R1_001.fastq.gz", False, "1") - self.assertCountEqual( - list1, - [ - "Undetermined_S0_L001_R1_001.fastq.gz", - "Undetermined_S0_L001_R2_001.fastq.gz", - ], - ) - def test_backup_fastq(self): - has_index = True - min_file_size = 5 - backup_fastq( - self.fastq_filepath, - self.temp_out_dir, - self.sample_sheet_fp, - has_index, - min_file_size, - ) +def test_return_md5(tmp_path): + test_file = tmp_path / "test.txt" + with open(test_file, "w") as f: + f.write("Hello, World!") - # check the md5sums of the first fastq is the same - fq = IlluminaFastq(gzip.open(self.fastq_filepath, mode="rt")) - out_fp = os.path.join( - self.temp_out_dir, - fq.build_archive_dir(), - os.path.basename(self.fastq_filepath), - ) - md5_orj = return_md5(self.fastq_filepath) - md5_trans = return_md5(out_fp) - self.assertEqual(md5_orj, md5_trans) + md5_hash = return_md5(test_file) + assert md5_hash == "65a8e27d8879283831b664bd8b7f0ad4" # MD5 hash of "Hello, World!" - # check the md5sum of the sample sheet - ss_fp = os.path.join( - self.temp_out_dir, - fq.build_archive_dir(), - os.path.basename(self.sample_sheet_fp), - ) - self.assertEqual(return_md5(ss_fp), "92ef1ca7433cadb5267d822615cd15e2") - # check write permissions of the files - self.assertEqual(os.stat(out_fp).st_mode, 33060) +def test_backup_fastq(tmp_path, full_miseq_dir): + raw = tmp_path / "raw_reads" + raw.mkdir(parents=True, exist_ok=True) + sample_sheet_fp = full_miseq_dir / "sample_sheet.csv" - def test_return_md5(self): - self.assertEqual( - return_md5(self.fastq_filepath), "13695e47114c02536ae3ca6823a42261" + backup_fastq( + full_miseq_dir / "Undetermined_S0_L001_R1_001.fastq.gz", + raw, + sample_sheet_fp, + True, + 100, + ) + backup_fastq( + full_miseq_dir / "Undetermined_S0_L002_R1_001.fastq.gz", + raw, + sample_sheet_fp, + True, + 100, + ) + + with pytest.raises(FileNotFoundError): + backup_fastq( + full_miseq_dir / "Undetermined_S0_L003_R1_001.fastq.gz", + raw, + sample_sheet_fp, + True, + 100, ) diff --git a/test/test_illumina.py b/test/test_illumina.py index 18d675b..6aa7939 100644 --- a/test/test_illumina.py +++ b/test/test_illumina.py @@ -5,116 +5,6 @@ from seqBackupLib.illumina import IlluminaFastq -def setup_illumina_dir(fp: Path, r1: str, r1_lines: list[str]) -> Path: - fp.mkdir(parents=True, exist_ok=True) - - r1 = fp / r1 - with gzip.open(r1, "wt") as f: - f.writelines(r1_lines) - - return fp - - -@pytest.fixture -def novaseq_dir(tmp_path) -> Path: - return setup_illumina_dir( - tmp_path / "250218_A00901_1295_BHTKCGDRX5", - "Undetermined_S0_L001_R1_001.fastq.gz", - [ - "@A00901:1295:HTKCGDRX5:1:2101:1054:1000 1:N:0:NAGTGTTAGG+CGGAACTAGC\n", - "GTAAAAAGCTAGATTTTCGCGATTTACCAGACGAACTANTNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN\n", - "+\n", - "FFFFF,FFF:FFFFFF,FFFFFFFFFFFFFFFFFFFFF#,###############################################################################################################\n", - "@A00901:1295:HTKCGDRX5:1:2101:1090:1000 1:N:0:AATTCTTGGA+AAGTTGACAA\n", - "GCTGCAATATGCGCCAACAAAACCGGTGGATAAAAAGGTTTCGTAATATAGTCATCNCNGNCNTNTNCNANNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNATTTCTCCGC\n", - "+\n", - ",FFFFFFFF:FFFFFFFFFFFF::FFFFFFFFF,FFFFFFF:F::FFFFFFFFFF:#F#F#F#F#:#F#F#######################################################################FFFFFFFFFF\n", - ], - ) - - -@pytest.fixture -def hiseq_dir(tmp_path) -> Path: - return setup_illumina_dir( - tmp_path / "201118_D00728_0139_ACD5C3ANXX", - "Undetermined_S0_L001_R1_001.fastq.gz", - [ - "@D00728:139:CD5C3ANXX:1:1101:1228:2123 1:N:0:ATCTCAGG+CCTAGAGT\n", - "NTGCGCAGGGGGACCTGCACCGGCATCCCCTGTACCGGCGGGGCGCTCAGGCTGAATGCGCCGTCCTGCATCAGTACCGACTCCGGCTCGATGGCTTTATCCTGTCTCTTATACACATCTCCGAGC\n", - "+\n", - "#:<>AEBGGGGGGGGGDDG=CGGGGF=FGGGGGGGGGGGGGGGGF Path: - return setup_illumina_dir( - tmp_path / "20250429_LH00732_0028_A22YJWWLT3", - "Undetermined_S0_L001_R1_001.fastq.gz", - [ - "@LH00732:28:22YJWWLT3:1:1101:1213:1080 1:N:0:CCTCCGTCCA+CACCGATGTG\n", - "ACGT\n", - "+\n", - "IIII\n", - ], - ) - - -@pytest.fixture -def miseq_dir(tmp_path) -> Path: - return setup_illumina_dir( - tmp_path / "250407_M03543_0443_000000000-DTHBL", - "Undetermined_S0_L001_R1_001.fastq.gz", - [ - "@M03543:443:000000000-DTHBL:1:1101:16223:1348 1:N:0:TTTTTTTTTTTT+TTCTTTTTCCTT\n", - "TCTTCCCTCTTTCTTCTTTCTTCCTCCCTTCCCTTCTCTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTCTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTT\n", - "+\n", - ">>>>A1C1BB1B3A333BB33B311100BA000BBBE122B110A//AA/>//>///>///<<-9-99--99---999--999@999-9999@>---9------9-9\n", - "@M03543:443:000000000-DTHBL:1:1101:15497:1351 1:N:0:TTTTTTTTTTTT+TTCTTTTTCCTC\n", - "TCTTCCCTCTTTCTTCTTTCTTCCTCCCTTCCCTTCTCTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTCTCTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTCTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTT\n", - "+\n", - ">>>>A1C1BB1B3B333BB33B311100BB000BBCD122B110A//AA/>//>///>////<---9------9-9\n", - ], - ) - - -@pytest.fixture -def miniseq_dir(tmp_path) -> Path: - return setup_illumina_dir( - tmp_path / "210612_NB551353_0107_AHWJFCAFX2", - "Undetermined_S0_L001_R1_001.fastq.gz", - [ - "@NB551353:107:HWJFCAFX2:1:11101:1486:1048 1:N:0:TAATTAGCGT+NNNTTAACCA\n", - "GAAATNGACCGCCTCAATGAGGTTGCCAAGAATTTAAATGAATCTCTCATCGATCTCCAAGAACTTGGAAAGTA\n", - "+\n", - "AAAAA#EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEAEEEEEEEEEEEEEEEAEEEEEEEEEEEEEEEE\n", - "@NB551353:107:HWJFCAFX2:1:11101:6713:1048 1:N:0:GAAGACTAGA+NNNTTCTAGT\n", - "GGCTANATCTGAGGACAAGAGGGCAAAAGTTACTAGTGCTATGCAGACAATGCTTTTCACTATGCTTAGAAAGT\n", - "+\n", - "AAAAA#EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE\n", - ], - ) - - -@pytest.fixture -def nextseq_dir(tmp_path) -> Path: - return setup_illumina_dir( - tmp_path / "250101_VH12345_0022_222C2NYNX", - "Undetermined_S0_L001_R1_001.fastq.gz", - [ - "@VH12345:22:222C2NYNX:1:1101:18286:1000 1:N:0:GGCACTAAGG+GTTGACCTGA\n", - "ACGT\n", - "+\n", - "IIII\n", - ], - ) - - machine_fixtures = { "A": "novaseq_dir", "D": "hiseq_dir", @@ -143,3 +33,4 @@ def test_illumina_fastq(machine_type, request): assert r1.check_fp_vs_content()[0], r1.check_fp_vs_content() assert not r1.check_file_size(DEFAULT_MIN_FILE_SIZE) assert r1.check_file_size(100) + assert r1.check_index_read_exists() From b54c813b4c3738bc0f267d185775999fcb3f2cf9 Mon Sep 17 00:00:00 2001 From: Ulthran Date: Fri, 9 May 2025 07:40:11 -0400 Subject: [PATCH 07/10] Fix test dir pointer --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9c8eb48..e0514a7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -28,7 +28,7 @@ jobs: python -m pip install . - name: Run tests - run: pytest -s -vvvv -l --tb=long tests + run: pytest -s -vvvv -l --tb=long test lint: name: Lint Code Base From e263363f7fa60f4a7b367dd666fb23667da9285b Mon Sep 17 00:00:00 2001 From: Ulthran Date: Fri, 9 May 2025 07:42:42 -0400 Subject: [PATCH 08/10] Support Python >=3.9 --- .github/workflows/test.yml | 2 +- setup.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e0514a7..71f87ec 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,7 +11,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12', '3.13'] + python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] runs-on: "ubuntu-latest" steps: diff --git a/setup.py b/setup.py index 2cfa5dc..00df46d 100644 --- a/setup.py +++ b/setup.py @@ -13,6 +13,6 @@ author_email="ctanes@gmail.com", url="https://github.com/PennChopMicrobiomeProgram", packages=["seqBackupLib"], - scripts=["scripts/backup_illumina.py"], # , - # install_requires=["pandas", "biopython"] + scripts=["scripts/backup_illumina.py"], + python_requires=">=3.9", ) From 222f8dadd925cf2df27f5039ba2fa7326162c6d1 Mon Sep 17 00:00:00 2001 From: Ulthran Date: Fri, 9 May 2025 07:56:41 -0400 Subject: [PATCH 09/10] Update README --- README.md | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index a243b07..5842567 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,26 @@ # seqBackup -`python -m unittests` to run tests. +Logic for parsing Illumina headers and folders as well as for archiving reads. -## TODO: +## Dev --make backup_illumina.py able to take relative paths for --forward-reads argument +``` +git clone https://github.com/PennChopMicrobiomeProgram/seqBackup.git +cd seqBackup/ +python -m venv env +source env/bin/activate +pip install -e . +pip install black pytest +``` + +Before commits, make sure everything is well formatted and working: + +``` +black . +pytest test/ +git commit ... +``` + +### Adding a new machine type + +To add a new machine type, add the new machine code to the `MACHINE_TYPES` map in `seqBackuplib/illumina.py`. In some cases, you may have to add machine specific parsing in `_parse_header` or `_parse_folder`. In `test/test_illumina.py`, we have a mechanism for requiring tests for each supported machine type. Add the new machine type to the `machine_fixtures` map and then create the fixture that it points to in `test/conftest.py`. Follow the pattern laid out by other fixtures and try to make the test data as realistic as possible. \ No newline at end of file From 18fd3aeb4f327b1c2baa265d954932131b5f87c1 Mon Sep 17 00:00:00 2001 From: Ulthran Date: Mon, 12 May 2025 14:48:39 -0400 Subject: [PATCH 10/10] Add deploy instructions --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5842567..d1fe92b 100644 --- a/README.md +++ b/README.md @@ -23,4 +23,8 @@ git commit ... ### Adding a new machine type -To add a new machine type, add the new machine code to the `MACHINE_TYPES` map in `seqBackuplib/illumina.py`. In some cases, you may have to add machine specific parsing in `_parse_header` or `_parse_folder`. In `test/test_illumina.py`, we have a mechanism for requiring tests for each supported machine type. Add the new machine type to the `machine_fixtures` map and then create the fixture that it points to in `test/conftest.py`. Follow the pattern laid out by other fixtures and try to make the test data as realistic as possible. \ No newline at end of file +To add a new machine type, add the new machine code to the `MACHINE_TYPES` map in `seqBackuplib/illumina.py`. In some cases, you may have to add machine specific parsing in `_parse_header` or `_parse_folder`. In `test/test_illumina.py`, we have a mechanism for requiring tests for each supported machine type. Add the new machine type to the `machine_fixtures` map and then create the fixture that it points to in `test/conftest.py`. Follow the pattern laid out by other fixtures and try to make the test data as realistic as possible. + +### Incorporting new version + +This software is the "source of truth" for Illumina file handling logic. Other software in our ecosystem depend on this logic including the sample registry and the automation pipeline. When you update this software you will have to then update the installed versions wherever it is deployed as a dependency. We don't bother with official GitHub releases and instead just point directly at the `master` branch, so usually it is a matter of running `pip install git+https://github.com/PennChopMicrobiomeProgram/seqBackup.git@master` from the host machine. \ No newline at end of file