Source code for sequana.iem

#
#  This file is part of Sequana software
#
#  Copyright (c) 2018-2022 - Sequana Development Team
#
#  Distributed under the terms of the 3-clause BSD license.
#  The full license is in the LICENSE file, distributed with this software.
#
#  website: https://github.com/sequana/sequana
#  documentation: http://sequana.readthedocs.io
#
##############################################################################
"IEM class"
import collections
import io
import os
import sys

import colorlog

from sequana.lazy import pandas as pd

logger = colorlog.getLogger(__name__)


__all__ = ["SampleSheet", "IEM", "BCLConvert", "get_sample_sheet_version", "SampleSheetFactory"]


[docs] class SampleSheet: """Reader and validator of Illumina samplesheets The Illumina samplesheet reader and validator verifies the correctness of the sections in the samplesheet, which are not case-sensitive and are enclosed within square brackets. Following the closing bracket, no additional characters are permitted, except for commas and the end-of-line marker. For instance [Data]a prevents the [Data] section from being correctly processed. The sections then consist of key-value pairs represented as records, with each line consisting of precisely two fields. An optional [Settings] section can contain key-value pairs, and the [Reads] section specifies the number of cycles per read, which is exclusively required for MiSeq. The [Data] section, which is a table similar to CSV format, is optional. However, without [Data] section all reads are sent to a single 'undetermined' output file. Sample_ID is highly recommended. Example of typical Data section to be used with bcl2fastq:: [Header] [Data] Sample_ID,Sample_Name,I7_Index_ID,index,I5_INdex_ID,index2 A10001,Sample_A,D701,AATACTCG,D501,TATAGCCT A10002,Sample_B,D702,TCCGGAGA,D501,TATAGCCT A10003,Sample_C,D703,CGCTCATT,D501,TATAGCCT A10004,Sample_D,D704,GAGATTCC,D501,TATAGCCT Important: altough we have upper case names as specified in the Illumina specs, the bcl2fastq does not care about the upper case. This is not intuitive since IEM produces keys with upper and lower case names similarly to the specs. **Sequana Standalone** The standalone application **sequana** contains a subcommand based on this class:: sequana samplesheet that can be used to check the correctness of a samplesheet:: sequana samplesheet --check SampleSheet.csv :references: illumina specifications 970-2017-004.pdf """ expected_headers_fields = [ "IEMFileVersion", "Investigator Name", "Instrument Type", "Experiment Name", "Date", "Workflow", "Application", "Assay", "Description", "Chemistry", "Index Adapters", ] expected_data_headers = {"SE": [], "PE": []} #: name of the section holding the tabular sample data (title-cased). Overridden in #: :class:`BCLConvert` where the section is ``[BCLConvert_Data]``. data_section = "Data" #: name of the settings section (title-cased). Overridden in :class:`BCLConvert` #: where the required section is ``[BCLConvert_Settings]``. settings_section = "Settings" def __init__(self, filename): self.filename = filename if not os.path.exists(self.filename): raise IOError(f"{filename} does not exist") # figures out the sections in the sample sheet. # we use a try/except so that even in case of failure, we can still use # quickfix or attributes. try: self._scan_sections() except Exception as err: # pragma: no cover print(err) def _line_cleaner(self, line, line_count): # We can get rid of EOL and spaces line = line.strip() # is it an empty line ? if len(line) == 0: return line # if we are dealing with a section title, we can cleanup the # line. A section must start with '[' and ends with ']' but # there could be spaces and commands. If it ends with a ; then # the section will not be found as expected since this is # sympatomatic of further issues if line.startswith("["): # [Header], ,, ,\n becomes [Header] line = line.strip(", ;") # note the space AND comma return line def _scan_sections(self): # looks for special section Header/Data/Reads/Settings or # any other matching section thst looks like [XXX] # sections can be of any types of cases (lower, upper, mixed) current_section = None data = {} with open(self.filename, "r") as fin: for line_count, line in enumerate(fin.readlines()): line = self._line_cleaner(line, line_count + 1) if len(line) == 0: continue if line.startswith("[") and line.endswith("]"): name = line.lstrip("[").rstrip("]") current_section = name data[current_section] = [] # create empty list just to create the section else: if current_section in data: data[current_section] += [line] else: data[current_section] = [line] self.sections = data # if the sample sheet starts with an incorrect name # e.g section not closed by square brackets or empty lines # the a None key is present and should be ignored or cast # into a string: if None in self.sections: self.sections["None"] = self.sections[None] del self.sections[None] # Some cleanup. Since the sections are case insensitive within # bcl2fastq, we need to convert everything back to titles self.sections = {k.title(): v for k, v in self.sections.items()} def _get_df(self): if self.data_section in self.sections: if self.sections[self.data_section]: # cope with the case of comma or semicolon separators. df1 = pd.read_csv(io.StringIO("\n".join(self.sections[self.data_section])), index_col=False, sep=",") df2 = pd.read_csv(io.StringIO("\n".join(self.sections[self.data_section])), index_col=False, sep=";") if len(df1.columns) > len(df2.columns): # rename all columns to lower case df1.columns = [x.lower() for x in df1.columns] return df1 else: df2.columns = [x.lower() for x in df2.columns] return df2 else: return pd.DataFrame() else: # pragma: no cover return pd.DataFrame() df = property(_get_df, doc="Returns the [Data] section") def _get_samples(self): try: return self.df["sample_id"].values except AttributeError: # pragma: no cover return "No Sample_ID found in the Data header section" samples = property(_get_samples, doc="returns the sample identifiers as a list") def _get_version(self): try: return self.header["IEMFileVersion"] except KeyError: return None version = property(_get_version, doc="return the version of the IEM file")
[docs] def checker(self): from sequana.utils.checker import Checker checks = Checker() if "Reads" in self.sections: pass else: checks.results.append({"msg": f"The optional [Reads] section is missing", "status": "Warning"}) if "Header" in self.sections: pass else: checks.results.append({"msg": f"The optional [Header] section is missing", "status": "Warning"}) if "Data" in self.sections: checks.tryme(self._check_data_section_csv_format) checks.tryme(self._check_optional_data_column_names) checks.tryme(self._check_mandatory_data_columns) checks.tryme(self._check_sample_ID) checks.tryme(self._check_sample_project) checks.tryme(self._check_unique_sample_ID) checks.tryme(self._check_unique_indices) checks.tryme(self._check_nucleotide_indices) checks.tryme(self._check_sample_lane_number) checks.tryme(self._check_alpha_numerical) if "sample_name" in self.df.columns: checks.tryme(self._check_sample_names) checks.tryme(self._check_unique_sample_name) else: checks.results.append( { "name": "check_sample_names", "msg": f"Column Sample_Name not found in the header of the [Data] section. Recommended.", "status": "Warning", } ) # if data section is incorrect, self.df is not accessible so the following validation # will fail. try: if "index" in self.df.columns: checks.tryme(self._check_homogene_I7_length) if "index2" in self.df.columns: checks.tryme(self._check_homogene_I5_length) checks.tryme(self._check_homogene_I5_and_I7_length) except pd.errors.ParserError: # pragma: no cover pass else: checks.results.append({"msg": f"The [Data] section is missing. ", "status": "Error"}) if "Settings" in self.sections: checks.tryme(self._check_settings) else: checks.results.append({"msg": f"The optional [Settings] section is missing", "status": "Warning"}) checks.tryme(self._check_semi_column_presence) return checks.results
def _check_settings(self): # Detect malformed lines that are not valid 'key,value' pairs. _get_settings # tolerates them so downstream callers do not crash; here we surface a clear # error instead of letting a parsing exception leak (e.g. sheets mangled by # spreadsheet software into ';'-separated values or with stray ';;;'). for line in self.sections.get(self.settings_section, []): if line.strip() and "," not in line: return { "name": "check_settings", "msg": ( f"The [Settings] section has a line that is not a valid " f"'key,value' pair: '{line}'. This is often caused by stray " f"';' separators (e.g. added by spreadsheet software)." ), "status": "Error", } for k, v in self.settings.items(): # checks ACGT content (no acgt allowed) if k.lower() in [ x.lower() for x in [ "Adapter", "AdapterRead1", "TrimAdapter", "AdapterRead2", "TrimAdapterRead2", "MaskAdapter", "MaskAdapterRead2", ] ]: allowed_chars = set("ACGT") def is_valid_string(s): return set(s).issubset(allowed_chars) if is_valid_string(v) is False: return { "name": "check_settings", "msg": f"Invalid nucleotide sequence found for {k} (v)", "status": "Error", } elif k.lower() in [ x.lower() for x in ["ReverseComplement", "FindAdaptersWithIndels", "TrimUMI", "CreateFastqForIndexReads"] ]: if v not in ("true", "false", "t", "f", "yes", "no", "y", "n", "1", "0"): return { "name": "check_settings", "msg": f"Invalid valid for {k} ({v}). Must be set to one of : true, false, t, f, yes, no, y, n, 1, 0", "status": "Error", } elif k.lower() in [ x.lower() for x in [ "Read1StartFromCycle", "Read2StartFromCycle", "Read1EndWithCycle", "Read2EndWithCycle", "Read1UMILength", "Read2UMILength", "Read1UMIStartFromCycle", "Read2UMIStartFromCycle", ] ]: allowed_chars = set("0123456789") def is_valid_int(s): return set(s).issubset(allowed_chars) if is_valid_int(v) is False or int(v) < 0: return { "name": "check_settings", "msg": f"Invalid value for {k}. Must be positive. You provided: {v}", "status": "Error", } elif k.lower() in [x.lower() for x in ["ExcludeTiles", "ExcludeTilesLaneX"]]: allowed_chars = set("0123456789") def is_valid_int(s): return set(s).issubset(allowed_chars) # valid is 1101+1102+1103-1110 (only +-,numbers) for item in v.split("+"): for x in item.split("-"): if is_valid_int(x) is False: return { "name": "check_settings", "msg": f"Invalid value for {k} (v). Must be made of integers, + and - signs. e.g. 1101+1105-1110 to exclude 1101 and values in [1105-1110].", "status": "Error", } else: return {"name": "check_settings", "msg": f"The [Settings] section looks good", "status": "Success"} def _check_sample_ID(self): if "sample_id" in self.df.columns: # optional # check that names are not in 'all' or 'undetermined' if (self.df["sample_id"] == "unknown").sum() or (self.df["sample_id"] == "all").sum(): return { "name": "check_sample_ID", "msg": "Sample_ID column contains forbidden name ('all' or 'unknown')", "status": "Error", } else: return { "name": "check_sample_ID", "msg": "Sample_ID column (no unknown/undetermined label). Looks correct", "status": "Success", } else: return { "name": "check_sample_ID", "msg": f"Column Sample_ID not found in the header of the [Data] section. All data will be stored in Undetermined.fastq.gz", "status": "Warning", } def _check_sample_names(self): # check that names are not in 'all' or 'undetermined' if (self.df["sample_name"] == "unknown").sum() or (self.df["sample_name"] == "undetermined").sum(): return { "name": "check_sample_names", "msg": "Sample_Name column contains forbidden name ('all' or 'undetermined')", "status": "Error", } else: return { "name": "check_sample_names", "msg": "Sample_Name column (no unknown/undetermined label). Looks correct", "status": "Success", } def _check_sample_project(self): if "sample_project" in self.df.columns: # optional # check that names are not in 'all' or 'undetermined' if (self.df["sample_project"] == "all").sum() or (self.df["sample_project"] == "default").sum(): return { "name": "check_sample_project", "msg": "Sample_Project column contains forbidden name ('all' or 'default')", "status": "Error", } else: return { "name": "check_sample_project", "msg": "Sample_Project column (no all/default label). Looks correct", "status": "Success", } else: return { "name": "check_sample_project", "msg": f"Column Sample_Project not found in the header of the [Data] section. Recommended.", "status": "Warning", } def _check_mandatory_data_columns(self): # In fact, all columns are optional except index and Sample_Name is optional # Sample_Project is optional. If provided, fastq are saved in that sub directory. for column in ["sample_id", "index"]: if column not in self.df.columns: return { "name": "check_mandatory_data_columns", "msg": f"Mandatory '{column}' column not found in the header of the [Data] section", "status": "Error", } return { "name": "check_mandatory_data_columns", "msg": f"Mandatory columns (index, Sample_ID) found in the header of the [Data] section", "status": "Success", } def _check_unique_sample_name(self): # check that sample names are unique and that sample Names are unique too if self.df["sample_name"].isnull().sum() > 0: return {"name": "check_unique_sample_name", "msg": "Some sample names are empty", "status": "Warning"} elif len(self.df.sample_name) != len(self.df.sample_name.unique()): duplicated = self.df.sample_name[self.df.sample_name.duplicated()].index duplicated = ",".join([str(x + 1) for x in duplicated]) return { "name": "check_unique_sample_name", "msg": f"Sample_Name not unique. Duplicated entries on lines: {duplicated}", "status": "Warning", } else: return {"name": "check_unique_sample_name", "msg": "Sample name uniqueness", "status": "Success"} def _check_unique_sample_ID(self): # check that sample names are unique and that sample Names are unique too if "sample_id" not in self.df.columns: return { "name": "check_unique_sample_ID", "msg": "Sample ID not found in the header of the [Data] section", "status": "Warning", } if len(self.df["sample_id"]) != len(self.df["sample_id"].unique()): duplicated = self.df.sample_id[self.df.sample_id.duplicated()].index duplicated = ",".join([str(x + 1) for x in duplicated]) return { "name": "check_unique_sample_ID", "msg": f"Sample ID not unique. Duplicated entries on lines: {duplicated}", "status": "Error", } else: return {"name": "check_unique_sample_ID", "msg": "Sample ID uniqueness", "status": "Success"} def _check_sample_lane_number(self): if "sample_lane" in self.df.columns: # Define the allowed lanes allowed_chars = set("12345678") def is_valid_lane(s): return set(str(s)).issubset(allowed_chars) # Apply the function to the DataFrame invalid_lanes = list(self.df[~self.df["sample_lane"].apply(is_valid_lane)].index) if len(invalid_lanes): invalid_lanes = [x + 1 for x in invalid_lanes] return { "name": "check_sample_lane_number", "msg": f"Incorrect lane number in these rows: {invalid_lanes}. Must be in the range 1-8", "status": "Error", } return {"name": "check_sample_lane_number", "msg": "Correct lane number range", "status": "Success"} def _check_nucleotide_indices(self): # Define the allowed characters allowed_chars = set("ACGTN") def is_valid_string(s): try: return set(s).issubset(allowed_chars) except: return False # Apply the function to the DataFrame invalid_rows = list(self.df[~self.df["index"].apply(is_valid_string)].index) if "index2" in self.df.columns: invalid_rows += list(self.df[~self.df["index2"].apply(is_valid_string)].index) if len(invalid_rows): invalid_rows = [x + 1 for x in invalid_rows] return { "name": "check_nucleotide_indices", "msg": f"these rows have an index with invalid nucleotides {invalid_rows}", "status": "Error", } return {"msg": "Indices are made of A, C, G, T, N.", "status": "Success"} def _check_unique_indices(self): if "index2" in self.df.columns: indices = self.df["index"] + "," + self.df["index2"] msg = "You have duplicated index I7/I5." elif "index" in self.df.columns: indices = self.df["index"] msg = "You have duplicated index I7." else: return { "name": "check_unique_indices", "msg": f"column 'index' not found in the header of the [Data] section.", "status": "Error", } if indices.duplicated().sum() > 0: duplicated = indices[indices.duplicated()].values try: IDs = self.df[indices.duplicated()].sample_id.values IDs = ", ".join([str(x) for x in IDs]) IDs = f"related to sample IDs: {IDs}" except Exception as err: # pragma: no cover IDs = "" return {"name": "check_unique_indices", "msg": f"{msg} {duplicated} {IDs}", "status": "Error"} else: return {"name": "check_unique_indices", "msg": "Indices are unique.", "status": "Success"} def _check_optional_data_column_names(self): msg = "" warnings = [] # check whether minimal columns are included for x in [ "i7_index_id", "sample_project", "description", "sample_plate", "sample_well", "lane", "index_plate", "index_plate_well", ]: if x not in self.df.columns: warnings.append(x) if len(warnings): warnings = ",".join(warnings) msg = f"Some columns are missing in the [Data] section: {warnings}" return {"msg": msg, "status": "Warning"} else: # pragma: no cover return {"msg": "Columns of the data section looks good", "status": "Success"} def _get_data_length(self): N = len(set([x.count(",") for x in self.sections[self.data_section]])) if len(self.sections[self.data_section]) >= 2 and N == 1: return True else: return False def _check_homogene_I7_length(self): if self._get_data_length(): if len(self.df) == 1: L = len(self.df["index"].values[0].strip()) if L: return {"msg": f"Only one sample. Index length is {L}", "status": "Success"} else: return {"msg": f"Only one sample. Index length is {L}", "status": "Error"} diff = self.df["index"].apply(lambda x: len(x)).std() if diff == 0: return {"msg": "Indices length in I7 have same lengths", "status": "Success"} else: return {"msg": "Indices length in I7 have different lengths", "status": "Error"} else: return {"msg": "Indices length could not be read.", "status": "Warning"} def _check_homogene_I5_length(self): if self._get_data_length(): if len(self.df) == 1: L = len(self.df["index2"].values[0].strip()) if L: return {"msg": f"Only one sample. Index length for I5 is {L}", "status": "Success"} else: return {"msg": f"Only one sample. Index length for I5 is {L}", "status": "Error"} diff = self.df["index2"].apply(lambda x: len(x)).std() if diff == 0: return {"msg": "Indices length in I5 have same lengths", "status": "Success"} else: return {"msg": "Indices length in I5 have different lengths", "status": "Error"} else: return {"msg": "Indices length could not be read. ", "status": "Warning"} def _check_homogene_I5_and_I7_length(self): # this is a warning only since you may have custom index lengths = [len(x) for x in self.df["index"]] + [len(x) for x in self.df["index2"]] lengths = list(set(lengths)) L = lengths[0] if len(lengths) == 1: return {"msg": f"I5 and I7 have coherent length of {L}", "status": "Success"} else: return {"msg": "I5 and I7 have different lengths", "status": "Warning"} return {"msg": "Indices length could not be read. ", "status": "Success"} def _check_csv_format(self, section, name, level="Error"): # Generic CSV-consistency check for a tabular section. ``level`` is the # status used when the section is malformed (Error for the mandatory data # section, Warning for auxiliary sections such as [Cloud_Data]). N = len(set([x.count(",") for x in self.sections[section]])) if N == 1: # looks correct if len(self.sections[section]) == 1: return { "name": name, "msg": f"The [{section}] section CSV format looks empty. Remove if of fill it.", "status": level, } else: return { "name": name, "msg": f"The [{section}] section CSV format looks correct.", "status": "Success", } elif N == 0: return { "name": name, "msg": f"The [{section}] section CSV format looks empty. Remove it of fill it", "status": level, } else: lengths = set([x.count(",") for x in self.sections[section]]) return { "name": name, "msg": f"The [{section}] section has lines with different number of entries {lengths}. Probably missing or extra commas in the [{section}] section.", "status": level, } def _check_data_section_csv_format(self): return self._check_csv_format(self.data_section, "check_data_section_csv_format", level="Error") def _check_semi_column_presence(self): with open(self.filename, "r") as fp: line_count = 1 for line in fp.readlines(): if line.rstrip().endswith(";"): return { "name": "check_semi_column_presence", "msg": f"suspicous ; at the end of line ({line_count})", "status": "Error", } line_count += 1 return {"name": "check_semi_column_presence", "msg": "No extra semi column found.", "status": "Success"} def _check_alpha_numerical(self): for column in ["sample_id", "sample_name", "sample_project"]: if column not in self.df.columns: continue for i, x in enumerate(self.df[column].values): status = str(x).replace("-", "").replace("_", "").isalnum() if status is False: msg = f"type error: invalid {column} name in [Data] section (line {i+1}). Must be made of alphanumeric characters, _, and - only. Found {x}" return {"msg": msg, "name": "check_alpha_numerical", "status": "Error"} return { "name": "check_alpha_numerical", "msg": "sample names and ID looks correct in the Sample_ID, Sample_Name, and Project column (alpha numerical and - or _ characters)", "status": "Success", }
[docs] def validate(self): """This method checks whether the sample sheet is correctly formatted Checks for: * presence of ; at the end of lines indicated an edition with excel that wrongly transformed the data into a pure CSV file * inconsistent numbers of columns in the [DATA] section, which must be CSV-like section * Extra lines at the end are ignored * special characters are forbidden except - and _ * checks for Sample_ID column uniqueness * checks for index uniqueness (if single index) * checks for combo of dual indices uniqueness * checks that sample names are unique and raise a SystemExit error on the first found error. """ # aggregates all checks checks = self.checker() # Stop after first error for check in checks: if check["status"] == "Error": sys.exit("\u274C " + str(check["msg"]))
def _get_settings(self): data = {} for line in self.sections[self.settings_section]: # tolerate malformed / comma-less lines (e.g. Excel-mangled sheets # that use ';' separators or trailing ';;;'). We do not crash here so # that the meaningful error is reported by _check_semi_column_presence. if "," not in line: continue key, value = line.split(",", 1) data[key] = value return data settings = property(_get_settings) def _get_header(self): data = {} for line in self.sections["Header"]: key, value = line.split(",", 1) data[key] = value return data header = property(_get_header) def _get_instrument(self): try: return self.header["Instrument Type"] except KeyError: return None instrument = property(_get_instrument, doc="returns instrument name") def _get_adapter_kit(self): try: return self.header["Index Adapters"] except KeyError: return None index_adapters = property(_get_adapter_kit, doc="returns index adapters")
[docs] def to_fasta(self, adapter_name=""): """Extract adapters from [Adapter] section and print them as a fasta file""" ar1 = self.settings["Adapter"] try: ar2 = self.settings["AdapterRead2"] except KeyError: ar2 = "" for name, index in zip(self.df["i7_index_id"], self.df["index"]): read = f"{ar1}{index}{ar2}" frmt = {"adapter": adapter_name, "name": name, "index": index} print(">{adapter}_index_{name}|name:{name}|seq:{index}".format(**frmt)) print(read) if "index2" in self.df.columns: for name, index in zip(self.df["i5_index_id"], self.df["index2"]): read = f"{ar1}{index}{ar2}" frmt = {"adapter": adapter_name, "name": name, "index": index} print(">{adapter}_index_{name}|name:{name}|seq:{index}".format(**frmt)) print(read)
[docs] def quick_fix(self, output_filename): """Fix sample sheet Tyical error is when users save the samplesheet as CSV file in excel. This may add trailing ; characters at the end of section, which raises error in bcl2fastq. """ found_data = False with open(self.filename) as fin: with open(output_filename, "w") as fout: for line in fin.readlines(): if line.startswith("[Data]"): found_data = True if found_data: line = line.replace(";", ",") else: line = line.strip().rstrip(";") line = line.replace(";", ",") line = line.strip().rstrip(",") fout.write(line.strip("\n") + "\n")
[docs] class BCLConvert(SampleSheet): """Reader and validator of Illumina v2 (BCL Convert) sample sheets. BCL Convert is the replacement for the bcl2fastq software. The sample sheet format (a.k.a. v2) is close to the bcl2fastq one (a.k.a. v1) handled by :class:`SampleSheet` but differs in a few places: - The tabular data lives in a ``[BCLConvert_Data]`` section (required) instead of ``[Data]``. - Settings live in a ``[BCLConvert_Settings]`` section (required). ``[Settings]`` may still appear but is optional. - ``[Reads]`` uses cycle counts (``Read1Cycles``, ``Index1Cycles``, ...) rather than one integer per line. - The header carries ``FileFormatVersion,2``. - ``Sample_ID`` is compulsory (error if absent) and at least one sample is required. ``Sample_Name`` is ignored by BCL Convert (warning if present). - Adapter trimming/masking options moved into the settings section, e.g. ``Adapter`` became ``AdapterRead1`` and command-line options such as ``--minimum-trimmed-read-length`` became ``MinimumTrimmedReadLength``. - Barcode mismatches are set in the settings with ``BarcodeMismatchesIndex1`` and ``BarcodeMismatchesIndex2``. Because sections are parsed generically by :meth:`SampleSheet._scan_sections`, most of the [Data] checks (mandatory columns, unique Sample_ID, unique/valid indices, homogeneous index lengths, ...) are reused as-is by pointing :attr:`data_section` and :attr:`settings_section` to the v2 sections. :references: - https://support.illumina.com/sequencing/sequencing_software/bcl-convert/compatibility.html - https://knowledge.illumina.com/software/general/software-general-reference_material-list/000003710 """ #: the tabular section is [BCLConvert_Data]; title-cased by the parser data_section = "Bclconvert_Data" #: the settings section is [BCLConvert_Settings]; title-cased by the parser settings_section = "Bclconvert_Settings"
[docs] def checker(self): from sequana.utils.checker import Checker checks = Checker() checks.tryme(self._check_file_format_version) # [BCLConvert_Settings] is required in v2 if self.settings_section in self.sections: checks.tryme(self._check_settings) else: checks.results.append({"msg": "The required [BCLConvert_Settings] section is missing", "status": "Error"}) # [BCLConvert_Data] is required in v2 if self.data_section in self.sections: checks.tryme(self._check_data_section_csv_format) checks.tryme(self._check_mandatory_data_columns) checks.tryme(self._check_sample_ID) checks.tryme(self._check_unique_sample_ID) checks.tryme(self._check_unique_indices) checks.tryme(self._check_nucleotide_indices) checks.tryme(self._check_sample_lane_number) checks.tryme(self._check_alpha_numerical) checks.tryme(self._check_sample_name_ignored) try: if "index" in self.df.columns: checks.tryme(self._check_homogene_I7_length) if "index2" in self.df.columns: checks.tryme(self._check_homogene_I5_length) checks.tryme(self._check_homogene_I5_and_I7_length) except pd.errors.ParserError: # pragma: no cover pass else: checks.results.append({"msg": "The required [BCLConvert_Data] section is missing", "status": "Error"}) # [Cloud_Data] is optional BaseSpace metadata not used by BCL Convert demux, # but a malformed row (e.g. missing comma) is still worth flagging (Warning). if "Cloud_Data" in self.sections: checks.tryme(self._check_cloud_data_section_csv_format) checks.tryme(self._check_semi_column_presence) return checks.results
def _check_file_format_version(self): version = self.header.get("FileFormatVersion") if "Header" in self.sections else None if version is None: return { "name": "check_file_format_version", "msg": "FileFormatVersion not found in the [Header] section. Expected FileFormatVersion,2 for BCL Convert.", "status": "Warning", } if str(version).strip() != "2": return { "name": "check_file_format_version", "msg": f"Unexpected FileFormatVersion ({version}). BCL Convert expects FileFormatVersion,2.", "status": "Error", } return { "name": "check_file_format_version", "msg": "FileFormatVersion is 2 (BCL Convert).", "status": "Success", } def _check_cloud_data_section_csv_format(self): return self._check_csv_format("Cloud_Data", "check_cloud_data_section_csv_format", level="Warning") def _check_sample_name_ignored(self): # Sample_Name is ignored by BCL Convert; warn if present to avoid confusion if "sample_name" in self.df.columns: return { "name": "check_sample_name_ignored", "msg": "Sample_Name column is present but is ignored by BCL Convert. Remove it to avoid confusion.", "status": "Warning", } return {"name": "check_sample_name_ignored", "msg": "No ignored Sample_Name column.", "status": "Success"}
[docs] def quick_fix(self, output_filename): """Fix a v2 sample sheet by removing trailing semicolons only. Unlike :meth:`SampleSheet.quick_fix`, internal semicolons are preserved because they are legitimate v2 separators (e.g. ``OverrideCycles`` uses ``R1:Y151;I1:I10;I2:I10;R2:Y151``). Only trailing ``;`` (typical Excel artefact) are stripped. """ with open(self.filename) as fin, open(output_filename, "w") as fout: for line in fin.readlines(): fout.write(line.rstrip().rstrip(";").rstrip() + "\n")
# obsolete v1 settings names (kept for reference): # Adapter, TrimAdapter, MaskAdapter, MaskAdapterRead2, Read1StartFromCycle, Read1EndWithCycle, # Read1UMIStartFromCycle, Read1UMILength, Read2UMIStartFromCycle, Read2UMILength, Read2StartFromCycle
[docs] def get_sample_sheet_version(filename): """Return ``"v2"`` for BCL Convert sample sheets, ``"v1"`` otherwise. Detection relies on the v2 markers: presence of a ``[BCLConvert_Data]`` or ``[BCLConvert_Settings]`` section, or ``FileFormatVersion,2`` in the header. """ ss = SampleSheet(filename) if BCLConvert.data_section in ss.sections or BCLConvert.settings_section in ss.sections: return "v2" try: if str(ss.header.get("FileFormatVersion", "")).strip() == "2": return "v2" except Exception: # pragma: no cover pass return "v1"
[docs] def SampleSheetFactory(filename): """Return a :class:`BCLConvert` or :class:`SampleSheet` instance based on the detected sample sheet version (see :func:`get_sample_sheet_version`).""" if get_sample_sheet_version(filename) == "v2": return BCLConvert(filename) return SampleSheet(filename)
[docs] class IEM(SampleSheet): def __init__(self, filename): super().__init__(filename) logger.warning("IEM class is deprecated. Use SampleShee instead.")