diff --git a/.gitignore b/.gitignore index fcc42a0..3fbe878 100644 --- a/.gitignore +++ b/.gitignore @@ -1,28 +1,8 @@ -# Python __pycache__/ *.py[cod] -*$py.class *.egg-info/ -.eggs/ -build/ +.pytest_cache/ +.coverage dist/ - -# Virtual environments +build/ .venv/ -venv/ -env/ - -# Testing / coverage -.coverage -.coverage.* -htmlcov/ -.pytest_cache/ -coverage.xml - -# IDE -.vscode/ -.idea/ -*.swp - -# OS -.DS_Store diff --git a/README.md b/README.md index a7cb500..29b4990 100644 --- a/README.md +++ b/README.md @@ -3,11 +3,14 @@ [![Tests](https://github.com/sequana/webapp_samplesheet/actions/workflows/tests.yml/badge.svg)](https://github.com/sequana/webapp_samplesheet/actions/workflows/tests.yml) [![Release](https://img.shields.io/github/v/release/sequana/webapp_samplesheet)](https://github.com/sequana/webapp_samplesheet/releases) [![License](https://img.shields.io/badge/license-BSD--3--Clause-blue.svg)](LICENSE) -![Python](https://img.shields.io/badge/python-3.10%20%7C%203.11%20%7C%203.12-blue.svg) +![Python](https://img.shields.io/badge/python-3.9%20%7C%203.10%20%7C%203.11%20%7C%203.12-blue.svg) [![Streamlit App](https://img.shields.io/badge/Streamlit-Live%20Demo-FF4B4B?logo=streamlit&logoColor=white)](https://check-my-sample-sheet.streamlit.app/) ![Visitors](https://api.visitorbadge.io/api/visitors?path=https%3A%2F%2Fcheck-my-sample-sheet.streamlit.app%2F&countColor=%23263759) -This is a streamlit application that uses Sequana (github.com/sequana/sequana) **iem** modules to check Sample Sheet from Illumina sequencers. +This is a streamlit application that uses Sequana (github.com/sequana/sequana) **iem** modules to check Sample Sheets from Illumina sequencers. Both formats are supported and detected automatically: + +- **v1** (bcl2fastq): `[Data]` / `[Settings]` sections +- **v2** (BCL Convert): `[BCLConvert_Data]` / `[BCLConvert_Settings]` sections Running demo is here: https://check-my-sample-sheet.streamlit.app/ @@ -16,17 +19,33 @@ Running demo is here: https://check-my-sample-sheet.streamlit.app/ If you want to contribute to this web application, please provide PR here. Note, however, that the core of the application is within the Sequana project on https://github.com/sequana/sequana/, more specifically in the iem.py module. -The sanity checks implemented are based on experience and the bcl2fastq documentation v2.20 +The sanity checks implemented are based on experience, the bcl2fastq documentation (v2.20) and the BCL Convert specification. + +# Installation + +From PyPI: + + pip install check-my-sample-sheet + +Then launch the app (opens in your browser); extra arguments are forwarded to +`streamlit run` (e.g. `--server.port 8502`): + + check-my-sample-sheet + +# Local instance (from source) + + git clone https://github.com/sequana/webapp_samplesheet + cd webapp_samplesheet -# Local instance + # install the dependencies (sequana, streamlit, ...) + pip install -r requirements.txt - git clone https://github.com/sequana/webapp_samplesheet check_my_sample_sheet - cd check_my_sample_sheet + # run the application locally in your browser + streamlit run check_my_sample_sheet/app.py - # You will need to install requirements (sequana and streamlit) - pip install --file requirements.txt +# Running the tests - # and should ne ready to test the appliction locally in your browser - streamlit run app.py + pip install -r requirements-dev.txt + pytest diff --git a/check_my_sample_sheet/__init__.py b/check_my_sample_sheet/__init__.py new file mode 100644 index 0000000..5becc17 --- /dev/null +++ b/check_my_sample_sheet/__init__.py @@ -0,0 +1 @@ +__version__ = "1.0.0" diff --git a/check_my_sample_sheet/__main__.py b/check_my_sample_sheet/__main__.py new file mode 100644 index 0000000..abab3b6 --- /dev/null +++ b/check_my_sample_sheet/__main__.py @@ -0,0 +1,19 @@ +"""Console entry point: launch the Streamlit sample sheet validator. + +Installed as the ``check-my-sample-sheet`` command (see pyproject.toml). Any +extra arguments are forwarded to ``streamlit run`` (e.g. ``--server.port``). +""" +import sys +from pathlib import Path + +from streamlit.web import cli as stcli + + +def main(): + app = str(Path(__file__).resolve().parent / "app.py") + sys.argv = ["streamlit", "run", app, *sys.argv[1:]] + sys.exit(stcli.main()) + + +if __name__ == "__main__": + main() diff --git a/app.py b/check_my_sample_sheet/app.py similarity index 87% rename from app.py rename to check_my_sample_sheet/app.py index 0c9e7a0..e67da03 100644 --- a/app.py +++ b/check_my_sample_sheet/app.py @@ -18,12 +18,17 @@ import requests import streamlit as st -from sequana.iem import SampleSheet +from sequana.iem import SampleSheetFactory, get_sample_sheet_version from streamlit_option_menu import option_menu +# directory holding this module, used to resolve packaged assets (imgs, examples) +# regardless of the current working directory. +HERE = Path(__file__).resolve().parent +LOGO = str(HERE / "imgs" / "logo_256x256.png") + st.set_page_config( - page_title="Illumina Sample Sheet Validator", - page_icon="imgs/logo_256x256.png", + page_title="Check My Sample Sheet", + page_icon=LOGO, layout="wide", menu_items={"Report a bug": "https://github.com/sequana/webapp_samplesheet/issues/new/choose"}, ) @@ -125,6 +130,11 @@ def add_legend(success, warning, error): if "code_input" not in st.session_state: st.session_state.code_input = "" +# used to reset the file_uploader when an example is loaded: bumping this counter +# changes the widget key, which forces Streamlit to drop any previously uploaded file. +if "uploader_key" not in st.session_state: + st.session_state.uploader_key = 0 + def load_example(filename): """Load example file from examples directory.""" @@ -134,14 +144,19 @@ def load_example(filename): def set_example(filename): - """Callback to load example into the textarea session state.""" + """Callback to load example into the textarea session state. + + Also resets the file_uploader (by bumping its key) so a previously uploaded + file does not silently take precedence over the loaded example. + """ st.session_state.code_input = load_example(filename) + st.session_state.uploader_key += 1 def main(): st.sidebar.write("Provided by the [Sequana team](https://github.com/sequana/sequana)") - st.sidebar.image("imgs/logo_256x256.png") - st.title(f"Sample Sheet and Design Validator (v{version})") + st.sidebar.image(LOGO) + st.title(f"Check My Sample Sheet (v{version})") menu = ["Sample Sheet Validation (Illumina)", "Examples", "About", "How to cite"] @@ -160,7 +175,8 @@ def main(): if choice == "Sample Sheet Validation (Illumina)": st.markdown( - "This tool validates Illumina sample sheets against the bcl2fastq v2.20 specification. " + "This tool validates Illumina sample sheets. Both the **v1** format (bcl2fastq v2.20) and the " + "**v2** format (BCL Convert) are supported; the version is detected automatically. " "It checks the structure, mandatory sections, sample identifiers, indexes, and more. " "Provide a sample sheet below for validation, or load one of the examples to try the tool. " "More examples are available in the **Examples** section of the menu." @@ -171,7 +187,9 @@ def main(): col1, col2, col3 = st.columns([4, 1, 4]) with col1: data_file = st.file_uploader( - "Drop a sample sheet below and press the **Process** button. ", type=["csv", "txt"] + "Drop a sample sheet below and press the **Process** button. ", + type=["csv", "txt"], + key=f"uploader_{st.session_state.uploader_key}", ) with col2: # Centered "OR" text @@ -185,15 +203,18 @@ def main(): st.subheader("Load an Example", divider="blue") st.caption( "Click a button to load a sample sheet into the text area above. " - "Examples 1 and 2 are valid sheets; Example 3 is invalid and demonstrates how errors are reported." + "Examples 1, 2 and 4 are valid sheets; Example 3 is invalid and demonstrates how errors are reported. " + "Example 4 is a v2 (BCL Convert) sheet, the others are v1 (bcl2fastq)." ) - example_col1, example_col2, example_col3 = st.columns(3) + example_col1, example_col2, example_col3, example_col4 = st.columns(4) with example_col1: - st.button("Example 1: Dual indexing", on_click=set_example, args=("sample_sheet.csv",)) + st.button("Example 1: Dual indexing (v1)", on_click=set_example, args=("sample_sheet.csv",)) with example_col2: - st.button("Example 2: Single index + Settings", on_click=set_example, args=("sample_sheet_settings_index.csv",)) + st.button("Example 2: Single index + Settings (v1)", on_click=set_example, args=("sample_sheet_settings_index.csv",)) with example_col3: st.button("Example 3: Invalid (bad sample ID)", on_click=set_example, args=("Bad_SampleSheet_alphanum.csv",)) + with example_col4: + st.button("Example 4: BCL Convert (v2)", on_click=set_example, args=("sample_sheet_v2_bclconvert.csv",)) if st.button(":gear: Process :gear:"): @@ -322,7 +343,13 @@ def process_sample_sheet(data_file, samplesheet): with tempfile.NamedTemporaryFile(delete=False, mode="w") as fout: fout.write(samplesheet) fout.close() - iem = SampleSheet(fout.name) + version = get_sample_sheet_version(fout.name) + iem = SampleSheetFactory(fout.name) + + if version == "v2": + st.info(":information_source: Detected an Illumina **v2** sample sheet: validating against the **BCL Convert** specification.") + else: + st.info(":information_source: Detected an Illumina **v1** sample sheet: validating against the **bcl2fastq v2.20** specification.") try: # st.write(f"This sample sheet contains {len(iem.df)} samples") diff --git a/examples/Bad_SampleSheet_alphanum.csv b/check_my_sample_sheet/examples/Bad_SampleSheet_alphanum.csv similarity index 100% rename from examples/Bad_SampleSheet_alphanum.csv rename to check_my_sample_sheet/examples/Bad_SampleSheet_alphanum.csv diff --git a/examples/Bad_SampleSheet_extra_semicolons.csv b/check_my_sample_sheet/examples/Bad_SampleSheet_extra_semicolons.csv similarity index 100% rename from examples/Bad_SampleSheet_extra_semicolons.csv rename to check_my_sample_sheet/examples/Bad_SampleSheet_extra_semicolons.csv diff --git a/check_my_sample_sheet/examples/case1.csv b/check_my_sample_sheet/examples/case1.csv new file mode 100644 index 0000000..1ea360f --- /dev/null +++ b/check_my_sample_sheet/examples/case1.csv @@ -0,0 +1,4 @@ +[Data] +Sample_ID,index +ID1,TGACCA +ID2,CATTTT diff --git a/check_my_sample_sheet/examples/case2.csv b/check_my_sample_sheet/examples/case2.csv new file mode 100644 index 0000000..2859227 --- /dev/null +++ b/check_my_sample_sheet/examples/case2.csv @@ -0,0 +1,7 @@ +[Settings] +Adapter, ACGTACGTN + +[Data] +Sample_ID,index +ID1,TGACCa +ID1,CATTTT diff --git a/check_my_sample_sheet/examples/case3.csv b/check_my_sample_sheet/examples/case3.csv new file mode 100644 index 0000000..19ff7f6 --- /dev/null +++ b/check_my_sample_sheet/examples/case3.csv @@ -0,0 +1,8 @@ +[Settings];;;; +Adapter;AGATCGGAAGAGCACACGTCTGAACTCCAGTCA;;; +AdapterRead2;AGATCGGAAGAGCGTCGTGTAGGGAAAGAGTGT;;; +;;; +[Data];;; +Sample_ID;Sample_Name;I7_Index_ID;index;Sample_Project +A;;NF01;CGATGT; +B;;NF03;ACAGTG; diff --git a/examples/sample_sheet.csv b/check_my_sample_sheet/examples/sample_sheet.csv similarity index 100% rename from examples/sample_sheet.csv rename to check_my_sample_sheet/examples/sample_sheet.csv diff --git a/examples/sample_sheet_settings_index.csv b/check_my_sample_sheet/examples/sample_sheet_settings_index.csv similarity index 100% rename from examples/sample_sheet_settings_index.csv rename to check_my_sample_sheet/examples/sample_sheet_settings_index.csv diff --git a/check_my_sample_sheet/examples/sample_sheet_v2_bclconvert.csv b/check_my_sample_sheet/examples/sample_sheet_v2_bclconvert.csv new file mode 100644 index 0000000..cfdcaa6 --- /dev/null +++ b/check_my_sample_sheet/examples/sample_sheet_v2_bclconvert.csv @@ -0,0 +1,44 @@ +[Header], +FileFormatVersion,2 +RunName,20260616_B21184-18S-PE-MPdbl-151-10-5M +InstrumentPlatform,MiSeqi100Series +IndexOrientation,Forward +AnalysisLocation,Local + +[Reads] +Read1Cycles,151 +Read2Cycles,151 +Index1Cycles,10 +Index2Cycles,10 + +[Sequencing_Settings] +LibraryPrepKits,NexteraXT + +[BCLConvert_Settings] +SoftwareVersion,4.4.6 +AdapterRead1,CTGTCTCTTATACACATCT +AdapterRead2,CTGTCTCTTATACACATCT +OverrideCycles,R1:Y151;I1:I10;I2:I10;R2:Y151 +FastqCompressionFormat,dragen +NoLaneSplitting,true +GenerateFastqcMetrics,true + +[BCLConvert_Data] +Sample_ID,Index,Index2 +1,AGGTCAGATA,CTACAAGATA +2,CGACATCCGA,TACGTTCATT +3,ATTCCATAAG,TGCCTGGTGG +4,CACAATAGGA,TCCATCCGAG +5,AACATCGCGC,GTCCACTTGT + + +[Cloud_Settings] +GeneratedVersion,1.25.0.202605080250 + +[Cloud_Data] +Sample_ID,ProjectName,LibraryName,LibraryPrepKitName,IndexAdapterKitName +1,BXXXX,1_AGGTCAGATA_CTACAAGATA,NexteraXT,IlluminaDNARNAUDISetABCDTagmentation +2,BXXXX,2_CGACATCCGA_TACGTTCATT,NexteraXT,IlluminaDNARNAUDISetABCDTagmentation +3,BXXXX,3_ATTCCATAAG_TGCCTGGTGG,NexteraXT,IlluminaDNARNAUDISetABCDTagmentation +4,BXXXX,4_CACAATAGGA_TCCATCCGAG,NexteraXT,IlluminaDNARNAUDISetABCDTagmentation +5,BXXXX,5_AACATCGCGC_GTCCACTTGT,NexteraXT,IlluminaDNARNAUDISetABCDTagmentation diff --git a/imgs/logo_256x256.png b/check_my_sample_sheet/imgs/logo_256x256.png similarity index 100% rename from imgs/logo_256x256.png rename to check_my_sample_sheet/imgs/logo_256x256.png diff --git a/pyproject.toml b/pyproject.toml index cd18bc8..9ebfddf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,43 @@ +[tool.poetry] +name = "check-my-sample-sheet" +version = "1.0.0" +description = "Streamlit web application to validate Illumina sample sheets (bcl2fastq v1 and BCL Convert v2)." +authors = ["Thomas Cokelaer "] +license = "BSD-3-Clause" +readme = "README.md" +homepage = "https://github.com/sequana/webapp_samplesheet" +repository = "https://github.com/sequana/webapp_samplesheet" +documentation = "https://github.com/sequana/webapp_samplesheet" +keywords = ["illumina", "samplesheet", "bcl2fastq", "bcl-convert", "sequana", "ngs"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: BSD License", + "Programming Language :: Python :: 3", + "Topic :: Scientific/Engineering :: Bio-Informatics", +] +packages = [{ include = "check_my_sample_sheet" }] +include = ["check_my_sample_sheet/examples/*", "check_my_sample_sheet/imgs/*"] + +[tool.poetry.dependencies] +python = ">=3.9,<4.0" +# sequana >= 0.23.0 ships BCLConvert / SampleSheetFactory / get_sample_sheet_version +sequana = ">=0.23.0" +streamlit = ">=1.28" +streamlit-option-menu = "*" +requests = "*" + +[tool.poetry.group.dev.dependencies] +pytest = "*" +pytest-cov = "*" + +[tool.poetry.scripts] +check-my-sample-sheet = "check_my_sample_sheet.__main__:main" + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" + [tool.pytest.ini_options] -addopts = "--cov=app --cov-report=term-missing" +addopts = "--cov=check_my_sample_sheet --cov-report=term-missing" testpaths = ["tests"] diff --git a/requirements.txt b/requirements.txt index 523882c..f117e28 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ -sequana==0.21.2 +# sequana >= 0.23.0 ships BCLConvert / SampleSheetFactory / get_sample_sheet_version +sequana>=0.23.0 streamlit>=1.28 streamlit_option_menu -setuptools +requests diff --git a/tests/conftest.py b/tests/conftest.py index 173a7be..b1807c8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,19 +5,20 @@ import pytest PROJECT_ROOT = Path(__file__).parent.parent +PACKAGE_DIR = PROJECT_ROOT / "check_my_sample_sheet" sys.path.insert(0, str(PROJECT_ROOT)) @pytest.fixture def app_path(): """Path to the Streamlit app entrypoint.""" - return str(PROJECT_ROOT / "app.py") + return str(PACKAGE_DIR / "app.py") @pytest.fixture def examples_dir(): """Path to the examples directory.""" - return PROJECT_ROOT / "examples" + return PACKAGE_DIR / "examples" @pytest.fixture diff --git a/tests/test_app.py b/tests/test_app.py index 9298488..9ca3982 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -19,7 +19,7 @@ def test_default_menu_is_validation(app_path): at = AppTest.from_file(app_path, default_timeout=APP_TIMEOUT) at.run() # Title rendered on every page - assert any("Sample Sheet and Design Validator" in t.value for t in at.title) + assert any("Check My Sample Sheet" in t.value for t in at.title) # Validation page has the Input subheader subheaders = [s.value for s in at.subheader] assert "Input Sample Sheet" in subheaders diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 3b398cf..51d38f1 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -1,7 +1,7 @@ """Unit tests for helper functions in app.py.""" import pytest -from app import load_example +from check_my_sample_sheet.app import load_example def test_load_example_valid_returns_content():