From 8996f20e4b8a93cfb2eb973a12ab8a08a4092e3b Mon Sep 17 00:00:00 2001 From: Alex Tate <0xalextate@gmail.com> Date: Thu, 6 Oct 2022 18:08:34 -0700 Subject: [PATCH 01/23] After further consideration, I've decided to remove the --multi-id option from tiny-count. If multiple ID values are listed, they are now concatenated rather than selecting the first. I think this will be much more intuitive and it also releases ReferenceTables.get_figure_id() so that it can be used without a constructed ReferenceTables object. I've also converted the argparse output in tiny-count to a read-only dictionary. This prefs object is being passed around to a LOT of classes in tiny-count, and in doing so we risk accidentally changing preferences. This "bug" was previously leveraged by the StepVector routine; it has been refactored to no longer rely on the mutibility of prefs. --- START_HERE/run_config.yml | 4 --- tiny/cwl/tools/tiny-count.cwl | 5 ---- tiny/cwl/workflows/tinyrna_wf.cwl | 2 -- tiny/rna/counter/counter.py | 17 +++++------- tiny/rna/counter/hts_parsing.py | 37 +++++++++++++------------- tiny/rna/util.py | 15 ++++++++++- tiny/templates/run_config_template.yml | 4 --- 7 files changed, 40 insertions(+), 44 deletions(-) diff --git a/START_HERE/run_config.yml b/START_HERE/run_config.yml index 9de0e9cc..fc48ef7c 100644 --- a/START_HERE/run_config.yml +++ b/START_HERE/run_config.yml @@ -247,10 +247,6 @@ counter_type_filter: [] ##-- Select the StepVector implementation that is used. Options: HTSeq or Cython --## counter_stepvector: 'Cython' -##-- If False: a feature with multiple values in its ID attribute is treated as an error --## -##-- If True: multiple ID values are allowed, but only the first value is used --## -counter_allow_multi_id: True - ##-- If True: produce diagnostic logs to indicate what was eliminated and why --## counter_diags: False diff --git a/tiny/cwl/tools/tiny-count.cwl b/tiny/cwl/tools/tiny-count.cwl index cfaf77b4..419aa730 100644 --- a/tiny/cwl/tools/tiny-count.cwl +++ b/tiny/cwl/tools/tiny-count.cwl @@ -58,11 +58,6 @@ inputs: inputBinding: prefix: -sv - multi_id: - type: boolean? - inputBinding: - prefix: -md - all_features: type: boolean? inputBinding: diff --git a/tiny/cwl/workflows/tinyrna_wf.cwl b/tiny/cwl/workflows/tinyrna_wf.cwl index ccd59926..c1f67ada 100644 --- a/tiny/cwl/workflows/tinyrna_wf.cwl +++ b/tiny/cwl/workflows/tinyrna_wf.cwl @@ -87,7 +87,6 @@ inputs: counter_all_features: boolean? counter_type_filter: string[]? counter_source_filter: string[]? - counter_allow_multi_id: boolean? counter_normalize_by_hits: boolean? # deseq inputs @@ -215,7 +214,6 @@ steps: source: counter_normalize_by_hits valueFrom: $(String(self)) # convert boolean -> string decollapse: counter_decollapse - multi_id: counter_allow_multi_id stepvector: counter_stepvector is_pipeline: {default: true} diagnostics: counter_diags diff --git a/tiny/rna/counter/counter.py b/tiny/rna/counter/counter.py index c81336d9..c5381119 100644 --- a/tiny/rna/counter/counter.py +++ b/tiny/rna/counter/counter.py @@ -15,7 +15,7 @@ from tiny.rna.counter.features import Features, FeatureCounter from tiny.rna.counter.statistics import MergedStatsManager -from tiny.rna.util import report_execution_time, from_here +from tiny.rna.util import report_execution_time, from_here, ReadOnlyDict from tiny.rna.configuration import CSVReader # Global variables for multiprocessing @@ -54,9 +54,6 @@ def get_args(): optional_args.add_argument('-sv', '--stepvector', choices=['Cython', 'HTSeq'], default='Cython', help='Select which StepVector implementation is used to find ' 'features overlapping an interval.') - optional_args.add_argument('-md', '--multi-id', action='store_true', - help="Don't treat features with multiple ID values as an error. " - "Only the first value will be used as the feature's ID.") optional_args.add_argument('-a', '--all-features', action='store_true', help='Represent all features in output counts table, ' 'even if they did not match a Select for / with value.') @@ -70,7 +67,7 @@ def get_args(): args = arg_parser.parse_args() setattr(args, 'normalize_by_hits', args.normalize_by_hits.lower() in ['t', 'true']) - return args + return ReadOnlyDict(vars(args)) def load_samples(samples_csv: str, is_pipeline: bool) -> List[Dict[str, str]]: @@ -189,23 +186,23 @@ def main(): try: # Determine SAM inputs and their associated library names - libraries = load_samples(args.samples_csv, args.is_pipeline) + libraries = load_samples(args['samples_csv'], args['is_pipeline']) # Load selection rules and feature sources from the Features Sheet - selection_rules, gff_file_set = load_config(args.features_csv, args.is_pipeline) + selection_rules, gff_file_set = load_config(args['features_csv'], args['is_pipeline']) # global for multiprocessing global counter - counter = FeatureCounter(gff_file_set, selection_rules, **vars(args)) + counter = FeatureCounter(gff_file_set, selection_rules, **args) # Assign and count features using multiprocessing and merge results - merged_counts = map_and_reduce(libraries, vars(args)) + merged_counts = map_and_reduce(libraries, args) # Write final outputs merged_counts.write_report_files() except: traceback.print_exception(*sys.exc_info()) - if args.is_pipeline: + if args['is_pipeline']: print("\n\ntiny-count encountered an error. Don't worry! You don't have to start over.\n" "You can resume the pipeline at tiny-count. To do so:\n\t" "1. cd into your Run Directory\n\t" diff --git a/tiny/rna/counter/hts_parsing.py b/tiny/rna/counter/hts_parsing.py index cccbdce4..e5a106e6 100644 --- a/tiny/rna/counter/hts_parsing.py +++ b/tiny/rna/counter/hts_parsing.py @@ -441,25 +441,12 @@ class ReferenceTables: def __init__(self, gff_files: Dict[str, list], feature_selector, **prefs): self.all_features = prefs.get('all_features', False) - self.allow_multi_id = prefs.get('multi_id', False) + self.stepvector = prefs.get('stepvector', 'HTSeq') self.selector = feature_selector self._set_filters(**prefs) self.gff_files = gff_files # ----------------------------------------------------------- Primary Key: - if prefs['stepvector'] == 'Cython': - try: - from tiny.rna.counter.stepvector import StepVector - setattr(HTSeq.StepVector, 'StepVector', StepVector) - self.feats = HTSeq.GenomicArray("auto", stranded=False) # Root Match ID - except ModuleNotFoundError: - prefs['stepvector'] = 'HTSeq' - print("Failed to import Cython StepVector\n" - "Falling back to HTSeq's StepVector", - file=sys.stderr) - - if prefs['stepvector'] == 'HTSeq': - self.feats = HTSeq.GenomicArrayOfSets("auto", stranded=False) # Root Match ID - + self.feats = self._init_genomic_array() # Root Match ID self.parents, self.filtered = {}, set() # Original Feature ID self.intervals = defaultdict(list) # Root Feature ID self.matches = defaultdict(set) # Root Match ID @@ -709,12 +696,26 @@ def get_feature_id(self, row): raise ValueError(f"Feature {row.name} does not contain an ID attribute.") if len(id_collection) == 0: raise ValueError("A feature's ID attribute cannot be empty. This value is required.") - if len(id_collection) > 1 and not self.allow_multi_id: - err_msg = "A feature's ID attribute cannot contain multiple values. Only one ID per feature is allowed." - raise ValueError(err_msg) + if len(id_collection) > 1: + return ','.join(id_collection) return id_collection[0] + def _init_genomic_array(self): + if self.stepvector == 'Cython': + try: + from tiny.rna.counter.stepvector import StepVector + setattr(HTSeq.StepVector, 'StepVector', StepVector) + return HTSeq.GenomicArray("auto", stranded=False) + except ModuleNotFoundError: + self.stepvector = 'HTSeq' + print("Failed to import Cython StepVector\n" + "Falling back to HTSeq's StepVector", + file=sys.stderr) + + if self.stepvector == 'HTSeq': + return HTSeq.GenomicArrayOfSets("auto", stranded=False) + @classmethod def _set_filters(cls, **kwargs): """Assigns inclusive filter values""" diff --git a/tiny/rna/util.py b/tiny/rna/util.py index ab5431b3..fe04e07f 100644 --- a/tiny/rna/util.py +++ b/tiny/rna/util.py @@ -68,4 +68,17 @@ def get_r_safename(name: str) -> str: leading_char = lambda x: re.sub(r"^(?=[^a-zA-Z.]+|\.\d)", "X", x) special_char = lambda x: re.sub(r"[^a-zA-Z0-9_.]", ".", x) - return special_char(leading_char(name)) \ No newline at end of file + return special_char(leading_char(name)) + + +class ReadOnlyDict(dict): + """A very simple "read-only" wrapper for dictionaries. This will primarily be used + for passing around argparse's command line args as a dictionary, but ensuring that + preferences cannot be accidentally modified as they are passed around to a menagerie + of class constructors. All methods except __setitem__() are deferred to the base class.""" + + def __init__(self, rw_dict): + super().__init__(rw_dict) + + def __setitem__(self, *_): + raise RuntimeError("Attempted to modify read-only dictionary after construction.") \ No newline at end of file diff --git a/tiny/templates/run_config_template.yml b/tiny/templates/run_config_template.yml index fd6f1817..71d85e99 100644 --- a/tiny/templates/run_config_template.yml +++ b/tiny/templates/run_config_template.yml @@ -247,10 +247,6 @@ counter_type_filter: [] ##-- Select the StepVector implementation that is used. Options: HTSeq or Cython --## counter_stepvector: 'Cython' -##-- If False: a feature with multiple values in its ID attribute is treated as an error --## -##-- If True: multiple ID values are allowed, but only the first value is used --## -counter_allow_multi_id: True - ##-- If True: produce diagnostic logs to indicate what was eliminated and why --## counter_diags: False From d0de3311e5676869e8827ed8798378eb77b8a5f4 Mon Sep 17 00:00:00 2001 From: Alex Tate <0xalextate@gmail.com> Date: Fri, 7 Oct 2022 15:14:57 -0700 Subject: [PATCH 02/23] If a feature lacks an ID/gene_id attribute, but has a Parent attribute, then the value of Parent is used as the ID. It is no longer treated as an error. --- tiny/rna/counter/hts_parsing.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tiny/rna/counter/hts_parsing.py b/tiny/rna/counter/hts_parsing.py index e5a106e6..4a04db80 100644 --- a/tiny/rna/counter/hts_parsing.py +++ b/tiny/rna/counter/hts_parsing.py @@ -688,12 +688,15 @@ def chrom_vector_setdefault(self, chrom): if chrom not in self.feats.chrom_vectors: self.feats.add_chrom(chrom) - def get_feature_id(self, row): - id_collection = row.attr.get('ID', default= - row.attr.get('gene_id', default=None)) + @staticmethod + def get_feature_id(row): + id_collection = \ + row.attr.get('ID', default= + row.attr.get('gene_id', default= + row.attr.get('Parent', default=None))) if id_collection is None: - raise ValueError(f"Feature {row.name} does not contain an ID attribute.") + raise ValueError(f"Feature {row.name} does not have an ID attribute.") if len(id_collection) == 0: raise ValueError("A feature's ID attribute cannot be empty. This value is required.") if len(id_collection) > 1: From 9f47f7c1caf10f0a49dacf666037ee55542745f1 Mon Sep 17 00:00:00 2001 From: Alex Tate <0xalextate@gmail.com> Date: Fri, 7 Oct 2022 15:17:59 -0700 Subject: [PATCH 03/23] The GFF parsing loop (and error handling) has been moved from ReferenceTables to its own standalone function. This allows parsing machinery to be shared with the new GFFValidation class. --- tiny/rna/counter/hts_parsing.py | 68 ++++++---- tiny/rna/counter/validation.py | 221 ++++++++++++++++++++++++++++++++ 2 files changed, 263 insertions(+), 26 deletions(-) create mode 100644 tiny/rna/counter/validation.py diff --git a/tiny/rna/counter/hts_parsing.py b/tiny/rna/counter/hts_parsing.py index 4a04db80..79ab89b8 100644 --- a/tiny/rna/counter/hts_parsing.py +++ b/tiny/rna/counter/hts_parsing.py @@ -1,10 +1,11 @@ +import functools import os.path import HTSeq import sys import re from collections import Counter, defaultdict -from typing import Tuple, List, Dict, Iterator, Optional, DefaultDict, Set, Union, IO +from typing import Tuple, List, Dict, Iterator, Optional, DefaultDict, Set, Union, IO, Callable from inspect import stack from tiny.rna.counter.matching import Wildcard @@ -254,6 +255,25 @@ def infer_strandedness(sam_file: str, intervals: dict) -> str: else: return "non-reverse" +def parse_gff(file, row_fn: Callable, alias_keys=None): + if alias_keys is not None: + row_fn = functools.partial(row_fn, alias_keys=alias_keys) + + gff = HTSeq.GFF_Reader(file) + try: + for row in gff: + row_fn(row) + except Exception as e: + # Append to error message while preserving exception provenance and traceback + extended_msg = f"Error occurred on line {gff.line_no} of {file}" + if type(e) is KeyError: + e.args += (extended_msg,) + else: + primary_msg = "%s\n%s" % (str(e.args[0]), extended_msg) + e.args = (primary_msg,) + e.args[1:] + raise e.with_traceback(sys.exc_info()[2]) from e + + def parse_GFF_attribute_string(attrStr, extra_return_first_value=False, gff_version=2): """Parses a GFF attribute string and returns it as a dictionary. @@ -462,34 +482,30 @@ def get(self) -> Tuple[StepVector, AliasTable, ClassTable, dict]: """Initiates GFF parsing and returns the resulting reference tables""" for file, alias_keys in self.gff_files.items(): - gff = HTSeq.GFF_Reader(file) - try: - for row in gff: - if row.iv.strand == ".": - raise ValueError(f"Feature {row.name} in {file} has no strand information.") - if not self.filter_match(row): - self.exclude_row(row) - continue - - # Grab the primary key for this feature - feature_id = self.get_feature_id(row) - # Get feature's classes and identity match tuples - matches, classes = self.get_matches_and_classes(row.attr) - # Only add features with identity matches if all_features is False - if not self.all_features and not len(matches): - self.exclude_row(row) - continue - # Add feature data to root ancestor in the reference tables - root_id = self.add_feature(feature_id, row, matches, classes) - # Add alias to root ancestor if it is unique - self.add_alias(root_id, alias_keys, row.attr) - except Exception as e: - # Append to error message while preserving exception provenance and traceback - e.args = (str(e.args[0]) + "\nError occurred on line %d of %s" % (gff.line_no, file),) - raise e.with_traceback(sys.exc_info()[2]) from e + parse_gff(file, self.parse_row, alias_keys=alias_keys) return self.finalize_tables() + def parse_row(self, row, alias_keys=None): + if row.type.lower() == "chromosome" or not self.filter_match(row): + self.exclude_row(row) + return + if row.iv.strand == ".": + raise ValueError(f"Feature {row.name} has no strand information.") + + # Grab the primary key for this feature + feature_id = self.get_feature_id(row) + # Get feature's classes and identity match tuples + matches, classes = self.get_matches_and_classes(row.attr) + # Only add features with identity matches if all_features is False + if not self.all_features and not len(matches): + self.exclude_row(row) + return + # Add feature data to root ancestor in the reference tables + root_id = self.add_feature(feature_id, row, matches, classes) + # Add alias to root ancestor if it is unique + self.add_alias(root_id, alias_keys, row.attr) + def get_root_feature(self, lineage: list) -> str: """Returns the highest feature ID in the ancestral tree which passed stage 1 selection. The original feature ID is returned if there are no valid ancestors.""" diff --git a/tiny/rna/counter/validation.py b/tiny/rna/counter/validation.py new file mode 100644 index 00000000..479b7b68 --- /dev/null +++ b/tiny/rna/counter/validation.py @@ -0,0 +1,221 @@ +import functools +import subprocess +import sys + +from collections import Counter, defaultdict + +from rna.counter.hts_parsing import parse_gff, ReferenceTables + + +class ReportFormatter: + error_header = "========[ ERROR ]==============================================" + warning_header = "========[ WARNING ]============================================" + + def __init__(self, key_mapper): + self.key_mapper = key_mapper + self.warnings = [] + self.errors = [] + + def print_report(self): + for error_section in self.errors: + print(self.error_header) + print(error_section) + print() + + for warning_section in self.warnings: + print(self.warning_header) + print(warning_section) + print() + + def add_error_section(self, header, body=None): + self.add_section(header, body, self.errors) + + def add_warning_section(self, header, body=None): + self.add_section(header, body, self.warnings) + + def add_section(self, header, body, dest): + if isinstance(body, dict): + body = self.nested_dict(body) + elif isinstance(body, list): + body = '\n'.join(body) + if body: + dest.append('\n'.join([header, body])) + else: + dest.append(header) + + def nested_dict(self, summary, indent='\t'): + report_lines = self.recursive_indent(summary, indent) + return '\n'.join(report_lines) + + def recursive_indent(self, mapping, indent): + lines = [] + for key, val in mapping.items(): + if not val: return lines + key_header = f"{indent}{self.key_mapper.get(key, key)}: " + if isinstance(val, dict): + lines.append(key_header) + lines.extend(self.recursive_indent(val, indent + '\t')) + elif isinstance(val, list): + lines.append(key_header) + lines.extend([indent + '\t' + line for line in val]) + else: + lines.append(key_header + str(val)) + return lines + + def indent(self, lines, level=1, sep='\n'): + ind_token = '\t' * level + out = ind_token + out += (sep + ind_token).join(lines) + return out + + +class GFFValidator: + """Validates GFF files based on their contents and the contents of sequencing files to which + the GFF files are expected to be applied.""" + + targets = { + "ID attribute": "Features missing a valid identifier attribute", + "seq chromosomes": "Chromosomes present in sequence files", + "gff chromosomes": "Chromosomes present in GFF files", + "strand": "Features missing strand information", + } + + def __init__(self, gff_files, prefs, ebwt=None, genomes=None, alignments=None): + self.ReferenceTables = ReferenceTables(gff_files, None, **prefs) + self.report = ReportFormatter(self.targets) + self.chrom_set = set() + + self.seq_files = [ebwt, genomes, alignments] + self.gff_files = gff_files + self.prefs = prefs + + def validate(self): + self.parse_and_validate_gffs(self.gff_files) + self.validate_chroms(*self.seq_files) + self.report.print_report() + if self.report.errors: + sys.exit(1) + + def parse_and_validate_gffs(self, file_set): + gff_infractions = defaultdict(Counter) + for file, *_ in file_set.items(): + row_fn = functools.partial(self.validate_gff_row, report=gff_infractions[file]) + parse_gff(file, row_fn=row_fn) + + if len(gff_infractions.values()): + self.generate_gff_report(gff_infractions) + + def validate_gff_row(self, row, report): + # Check for reasons to normally skip row + if row.type.lower() == "chromosome": return # Skip definitions of whole chromosomes regardless + if not self.ReferenceTables.filter_match(row): return # Obey source/type filters before validation + + if row.iv.strand not in ('+', '-'): + report["strand"] += 1 + + try: + self.ReferenceTables.get_feature_id(row) + except: + report['ID attribute'] += 1 + + self.chrom_set.add(row.iv.chrom) + + def generate_gff_report(self, infractions): + header = "The following issues were found in the GFF files provided:" + self.report.add_error_section(header, infractions) + + def validate_chroms(self, ebwt=None, genomes=None, alignments=None): + # First search bowtie indexes if they are available + if ebwt is not None: + try: + chroms, shared = self.chroms_shared_with_ebwt(ebwt) + self.generate_chrom_report(chroms, shared) + return + except Exception: + pass # Fallback to other input options + + # Next search the genome fasta(s) if available + if genomes is not None: + chroms, shared = self.chroms_shared_with_genomes(genomes) + self.generate_chrom_report(chroms, shared) + return + + # Preferred inputs aren't available; continue testing with heuristic options + if alignments is not None: + self.validate_chroms_heuristic(alignments) + else: + self.report.add_warning_section("Shared chromosome identifiers could not be validated.") + + def validate_chroms_heuristic(self, alignments): + suspect_files = self.alignment_chroms_mismatch_heuristic(alignments) + self.generate_chrom_heuristics_report(suspect_files) + + def chroms_shared_with_ebwt(self, index_prefix): + """Returns the set intersection between parsed GFF chromosomes and those in the bowtie index""" + + summary = subprocess.check_output(['bowtie-inspect', index_prefix, '-s']).decode('latin1').splitlines() + ebwt_chroms = set() + + for line in summary: + if line.startswith("Sequence-"): + try: + ebwt_chroms.add(line.split('\t')[1]) + except IndexError: + pass + + shared = ebwt_chroms & self.chrom_set + return shared, ebwt_chroms + + def chroms_shared_with_genomes(self, genome_fastas): + """Returns the set intersection between parsed GFF chromosomes and those in the bowtie index""" + + genome_chroms = set() + for fasta in genome_fastas: + with open(fasta, 'rb') as f: + for line in f: + if line[0] == ord('>'): + genome_chroms.add(line[1:].strip().decode()) + + shared = genome_chroms & self.chrom_set + return shared, genome_chroms + + def alignment_chroms_mismatch_heuristic(self, sam_files): + """Since alignment files can be very large, we only check that there's at least one shared + chromosome identifier and only the first subset_size lines are read from each file.""" + + subset_size = 5000 + files_wo_overlap = [] + + for file in sam_files: + file_chroms = set() + with open(file, 'rb') as f: + for line, i in zip(f, range(subset_size)): + if line[0] == b"@": continue + file_chroms.add(line.split(b'\t')[2]) + if i % 1000 == 0 and len(file_chroms & self.chrom_set): break + + if not len(file_chroms & self.chrom_set): + files_wo_overlap.append(file) + + return files_wo_overlap + + def generate_chrom_report(self, shared, chroms): + if shared: return + header = "GFF files and sequence files don't share any chromosome identifiers." + summary = { + "gff chromosomes": sorted(self.chrom_set), + "seq chromosomes": sorted(chroms) + } + + self.report.add_error_section(header, summary) + + def generate_chrom_heuristics_report(self, suspect_files): + if not suspect_files: return + header = "GFF files and sequence files might not contain the same chromosome identifiers.\n" \ + "This is determined from a subset of each sequence file, so false positives may be reported." + summary = { + "The following sequence files might be incompatible": sorted(suspect_files), + "The following chromosomes are present in GFF files": sorted(self.chrom_set) + } + + self.report.add_warning_section(header, summary) \ No newline at end of file From f83f2f173a7e3c4cd4c72a4586cebf32b2961b8b Mon Sep 17 00:00:00 2001 From: Alex Tate <0xalextate@gmail.com> Date: Fri, 7 Oct 2022 15:19:06 -0700 Subject: [PATCH 04/23] Unit tests for the new GFFValidation class. Still needs tests for alignment_chroms_mismatch_heuristic() --- .../counter/validation/ebwt/ram1.1.ebwt | Bin 0 -> 4197414 bytes .../counter/validation/ebwt/ram1.2.ebwt | Bin 0 -> 1256 bytes .../counter/validation/ebwt/ram1.3.ebwt | Bin 0 -> 17 bytes .../counter/validation/ebwt/ram1.4.ebwt | Bin 0 -> 2500 bytes .../counter/validation/ebwt/ram1.rev.1.ebwt | Bin 0 -> 4197414 bytes .../counter/validation/ebwt/ram1.rev.2.ebwt | Bin 0 -> 1256 bytes .../counter/validation/genome/genome.fasta | 6 + tests/unit_tests_validation.py | 172 ++++++++++++++++++ 8 files changed, 178 insertions(+) create mode 100644 tests/testdata/counter/validation/ebwt/ram1.1.ebwt create mode 100644 tests/testdata/counter/validation/ebwt/ram1.2.ebwt create mode 100644 tests/testdata/counter/validation/ebwt/ram1.3.ebwt create mode 100644 tests/testdata/counter/validation/ebwt/ram1.4.ebwt create mode 100644 tests/testdata/counter/validation/ebwt/ram1.rev.1.ebwt create mode 100644 tests/testdata/counter/validation/ebwt/ram1.rev.2.ebwt create mode 100644 tests/testdata/counter/validation/genome/genome.fasta create mode 100644 tests/unit_tests_validation.py diff --git a/tests/testdata/counter/validation/ebwt/ram1.1.ebwt b/tests/testdata/counter/validation/ebwt/ram1.1.ebwt new file mode 100644 index 0000000000000000000000000000000000000000..67de2b90c0d8a1cc9690f6187d10f4b1817e329e GIT binary patch literal 4197414 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 p)h2J5nooVRW{{ct}IP?Gj literal 0 HcmV?d00001 diff --git a/tests/testdata/counter/validation/ebwt/ram1.2.ebwt b/tests/testdata/counter/validation/ebwt/ram1.2.ebwt new file mode 100644 index 0000000000000000000000000000000000000000..a6a7d1e4b5d9ad981c9927da35adcbf6f4785402 GIT binary patch literal 1256 zcmWmCUuf2490l;RobSrqGM%<%%{kvCqWwWCZxV_rt&r#~Xh>lQv2frLrChUmp>!oL zuDqzo6toTT&X{2dS+k@m|2$hx2)yP4n>T`FW1sX540cJ>T~Y$w~MB-k40ZB#)~9 zvjBHpQc@qiDOsWaG|mBQ2i}oulcTi%Y4#O8NA>i$`+~kk^EO|Vtfq4j?iwDxH+!ji zhr4Yw9ie;GqU2UwztB4g*H6P`p6{T|Rff#^P`yh|TWLK|pUm(*skjz@$($1I1-QPF z&He7{&0k1!o!(cRFV2twOsC#MzMI{*^V4WnHSP)?qq9ukM{37>H}JIz_iZraw6rZu z9>6t69zACLF)ew+du*_tRX+o}Mc-2Ibl!ObpKt0pE|(wRe=@(Meu4M1a(V{OJup4` zQ%I)aevXb->mZI(a1H8*or`>L(Q^{VF5h!u*R>`yz4dAN-ELiCpkTe^{o2fb7{_is z4RBq2oonIE%r3LWaBOn7%;>jeG9l0P^6G#&##cAq*ZqspNj=&$rai4uTjMluz+bEwsW)7QunTI{zaV4H7?EB~|(Xqt-9scF!^wHT( z&w0F`;O(PvlX)lH7xgUhuRjD^r9KR|((0D~pj;|&@6%PLYY3*Do_1b(tv2TqygWkN z-8idZ?xpQl`4#EfiMImR1oJn{9+(lBkpd%A|1+I6=5NF~;yeiRfwdUE7so|fd-)iL zuba|C*M5Fx(^RXz*Z=b>&A-tx=KXfUjum0){zg8NvRaStUHNswm#1=-*>v-U^_=4W zL7G0JV;;$4_RTe Ar2qf` literal 0 HcmV?d00001 diff --git a/tests/testdata/counter/validation/ebwt/ram1.3.ebwt b/tests/testdata/counter/validation/ebwt/ram1.3.ebwt new file mode 100644 index 0000000000000000000000000000000000000000..72d40b2d310f65783fc39832dc9d378e7c935952 GIT binary patch literal 17 ScmZQ%U|?VbVh|8e2eJVH8~{52 literal 0 HcmV?d00001 diff --git a/tests/testdata/counter/validation/ebwt/ram1.4.ebwt b/tests/testdata/counter/validation/ebwt/ram1.4.ebwt new file mode 100644 index 0000000000000000000000000000000000000000..ae543bb9a92ed326a16fa2e7d6f19b5f61281831 GIT binary patch literal 2500 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 +ATGC +>chr2 +CGTA +>chr3 +ATATAT \ No newline at end of file diff --git a/tests/unit_tests_validation.py b/tests/unit_tests_validation.py new file mode 100644 index 00000000..d4ca829d --- /dev/null +++ b/tests/unit_tests_validation.py @@ -0,0 +1,172 @@ +import unittest +from unittest.mock import patch, mock_open + +from rna.counter.validation import GFFValidator, ReportFormatter + + +class ValidationTests(unittest.TestCase): + + """======== Helper functions =========================== """ + + def make_gff_row(self, **cols): + template = { + 'seqid': "I", + 'source': "Wormbase", + 'type': "gene", + 'start': 3747, + 'end': 3909, + 'score': ".", + 'strand': "+", + 'phase': ".", + 'attrs': { + 'ID': 'featid', + 'gene_id': 'featid', + 'Parent': 'parentid' + } + } + + new_gff = dict(template, **cols) + new_gff['attrs'] = ';'.join([f"{k}={v}" for k, v in new_gff['attrs'].items()]) + return '\t'.join(map(str, new_gff.values())) + + def make_gff_validator(self) -> GFFValidator: + return GFFValidator({}, {}) + + def mock_gff_open(self, contents): + mo = mock_open(read_data=contents) + return patch('tiny.rna.counter.hts_parsing.HTSeq.utils.open', mo) + + """Does GFFValidator correctly validate strand information?""" + + def test_gff_strand_validation(self): + validator = self.make_gff_validator() + mock_filename = '/dev/null' + mock_gff = '\n'.join([ + self.make_gff_row(strand="+"), # Valid + self.make_gff_row(strand="-"), # Valid + self.make_gff_row(strand=".", type="chromosome"), # Valid + self.make_gff_row(strand=".") # Invalid + ]) + + expected = '\n'.join([ + "The following issues were found in the GFF files provided:", + "\t" + f"{mock_filename}: ", + "\t\t" + f"{validator.targets['strand']}: 1" + ]) + + with self.mock_gff_open(mock_gff) as p: + validator.parse_and_validate_gffs({mock_filename: []}) + + self.assertListEqual(validator.report.errors, [expected]) + self.assertListEqual(validator.report.warnings, []) + + """Does GFFValidator correctly validate ID attributes?""" + + def test_gff_id_validation(self): + validator = self.make_gff_validator() + mock_filename = '/dev/null' + mock_gff = '\n'.join([ + self.make_gff_row(attrs={'ID': 'feat1'}), # Valid + self.make_gff_row(attrs={'gene_id': 'feat3'}), # Valid + self.make_gff_row(attrs={'Parent': 'feat5'}), # Valid + self.make_gff_row(attrs={'Gene id': 'feat6'}), # Invalid + self.make_gff_row(attrs={'other': 'feat7'}), # Invalid + self.make_gff_row(attrs={}), # Invalid + ]) + + expected = '\n'.join([ + "The following issues were found in the GFF files provided:", + "\t" + f"{mock_filename}: ", + "\t\t" + f"{validator.targets['ID attribute']}: 3" + ]) + + with self.mock_gff_open(mock_gff) as p: + validator.parse_and_validate_gffs({mock_filename: []}) + + self.assertListEqual(validator.report.errors, [expected]) + self.assertListEqual(validator.report.warnings, []) + + """Does GFFValidator.chroms_shared_with_ebwt() correctly identify ebwt chromosomes?""" + + def test_ebwt_chroms(self): + validator = self.make_gff_validator() + ebwt_prefix = "./testdata/counter/validation/ebwt/ram1" + + # Chroms are shared + validator.chrom_set = {'ram1'} + shared, ebwt_chroms = validator.chroms_shared_with_ebwt(ebwt_prefix) + self.assertSetEqual(shared, validator.chrom_set) + self.assertSetEqual(shared, ebwt_chroms) + + # Chroms aren't shared + validator.chrom_set = {'chr1', 'chr2', 'chr3'} + shared, ebwt_chroms = validator.chroms_shared_with_ebwt(ebwt_prefix) + self.assertSetEqual(shared, set()) + self.assertSetEqual(ebwt_chroms, {'ram1'}) + + """Does GFFValidator.chroms_shared_with_genomes() correctly identify genome chromosomes?""" + + def test_genome_chroms(self): + validator = self.make_gff_validator() + fasta_file = "./testdata/counter/validation/genome/genome.fasta" + + # Chroms are shared + validator.chrom_set = {'chr1', 'chr2', 'chr3'} + shared, genome_chroms = validator.chroms_shared_with_genomes([fasta_file]) + self.assertSetEqual(shared, validator.chrom_set) + self.assertSetEqual(shared, genome_chroms) + + # Chroms aren't shared + validator.chrom_set = {'ram1'} + shared, genome_chroms = validator.chroms_shared_with_genomes([fasta_file]) + self.assertSetEqual(shared, set()) + self.assertSetEqual(genome_chroms, {'chr1', 'chr2', 'chr3'}) + + """Does GFFValidator.generate_gff_report() correctly process an infractions report?""" + + def test_gff_report_output(self): + validator = self.make_gff_validator() + infractions = { + "gff1": {'ID attribute': 10, 'strand': 5}, + "gff2": {'ID attribute': 1}, + "gff3": {'strand': 1}, + "gff4": {} + } + + expected = '\n'.join([ + "The following issues were found in the GFF files provided:", + "\tgff1: ", + "\t\t" + f"{validator.targets['ID attribute']}: 10", + "\t\t" + f"{validator.targets['strand']}: 5", + "\tgff2: ", + "\t\t" + f"{validator.targets['ID attribute']}: 1", + "\tgff3: ", + "\t\t" + f"{validator.targets['strand']}: 1" + ]) + + validator.generate_gff_report(infractions) + self.assertListEqual(validator.report.errors, [expected]) + + def test_chrom_report_output(self): + validator = self.make_gff_validator() + validator.chrom_set = {'chr1', 'chr2'} + seq_chroms = {'chr3', 'chr4'} + shared_chroms = validator.chrom_set & seq_chroms + + exp_errors = '\n'.join([ + "GFF files and sequence files don't share any chromosome identifiers.", + "\tChromosomes are present in GFF files: ", + "\t\tchr1", + "\t\tchr2", + "\tThe following chromosomes are present in sequence files: ", + "\t\tchr3", + "\t\tchr4" + ]) + + validator.generate_chrom_report(shared_chroms, seq_chroms) + # self.assertEqual(validator.report.errors[0], exp_errors) + validator.report.errors *= 3 + validator.report.print_report() + +if __name__ == '__main__': + unittest.main() From f0a2713275528fe05155cc8ecc5936f29ade2bdb Mon Sep 17 00:00:00 2001 From: Alex Tate <0xalextate@gmail.com> Date: Fri, 7 Oct 2022 15:24:34 -0700 Subject: [PATCH 05/23] Drafted integration of the new GFFValidation class into pipeline startup (configuration.py) and tiny-count startup (counter.py) GFF validation is treated as an optional step that must be specifically requested in configuration.py. This is because we will assume that resume runs are using inputs that have already been validated. GFF validation is skipped in tiny-count during pipeline runs. This is because we will assume that both end-to-end runs and resume runs are using inputs that have already been validated. --- tiny/entry.py | 2 +- tiny/rna/configuration.py | 20 +++++++++++++++++++- tiny/rna/counter/counter.py | 8 ++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/tiny/entry.py b/tiny/entry.py index 9db63c74..feccf61d 100644 --- a/tiny/entry.py +++ b/tiny/entry.py @@ -87,7 +87,7 @@ def run(tinyrna_cwl_path: str, config_file: str) -> None: print("Running the end-to-end analysis...") # First get the configuration file set up for this run - config_object = Configuration(config_file) + config_object = Configuration(config_file, validate_inputs=True) run_directory = config_object.create_run_directory() config_object.save_run_profile() diff --git a/tiny/rna/configuration.py b/tiny/rna/configuration.py index ebaf0c10..caacb9dd 100644 --- a/tiny/rna/configuration.py +++ b/tiny/rna/configuration.py @@ -12,6 +12,8 @@ from datetime import datetime from typing import Union, Any +from rna.counter.validation import GFFValidator + timestamp_format = re.compile(r"\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}") @@ -160,7 +162,7 @@ class Configuration(ConfigBase): appropriately if 'run_bowtie_index' is set to 'true' """ - def __init__(self, config_file: str): + def __init__(self, config_file: str, validate_inputs=False): # Parse YAML configuration file super().__init__(config_file) @@ -172,6 +174,7 @@ def __init__(self, config_file: str): self.setup_ebwt_idx() self.process_sample_sheet() self.process_feature_sheet() + if validate_inputs: self.validate_inputs() def load_paths_config(self): """Constructs a sub-configuration object containing workflow file preferences""" @@ -309,6 +312,21 @@ def setup_ebwt_idx(self): # preserve the prefix path. What the workflow "sees" is the ebwt files at working dir root self["ebwt"] = os.path.basename(self["ebwt"]) + def validate_inputs(self): + """For now, only GFF files are validated here""" + + gff_files = {gff['path']: [] for gff in self['gff_files']} + prefs = {x: self[f'{x}_filter'] for x in ['source', 'type']} + ebwt = self['ebwt'] if not self['run_bowtie_build'] else None + + GFFValidator( + gff_files, + prefs=prefs, + ebwt=ebwt, + genomes=self.paths['reference_genome_files'], + alignments=None # Used in tiny-count standalone runs + ).validate() + def save_run_profile(self, config_file_name=None) -> str: """Saves Samples Sheet and processed run config to the Run Directory for record keeping""" diff --git a/tiny/rna/counter/counter.py b/tiny/rna/counter/counter.py index c5381119..002137bd 100644 --- a/tiny/rna/counter/counter.py +++ b/tiny/rna/counter/counter.py @@ -13,6 +13,7 @@ from collections import defaultdict from typing import Tuple, List, Dict +from tiny.rna.counter.validation import GFFValidator from tiny.rna.counter.features import Features, FeatureCounter from tiny.rna.counter.statistics import MergedStatsManager from tiny.rna.util import report_execution_time, from_here, ReadOnlyDict @@ -157,6 +158,12 @@ def load_config(features_csv: str, is_pipeline: bool) -> Tuple[List[dict], Dict[ return rules, gff_files +def validate_inputs(gffs, libraries, prefs): + if prefs.get('is_pipeline'): return + libraries = [lib['File'] for lib in libraries] + GFFValidator(gffs, prefs, alignments=libraries).validate() + + @report_execution_time("Counting and merging") def map_and_reduce(libraries, prefs): """Assigns one worker process per library and merges the statistics they report""" @@ -190,6 +197,7 @@ def main(): # Load selection rules and feature sources from the Features Sheet selection_rules, gff_file_set = load_config(args['features_csv'], args['is_pipeline']) + validate_inputs(gff_file_set, libraries, args) # global for multiprocessing global counter From 71ac07ac9ae9255a95a933dbbc832f128ad395ea Mon Sep 17 00:00:00 2001 From: Alex Tate <0xalextate@gmail.com> Date: Sun, 9 Oct 2022 15:16:17 -0700 Subject: [PATCH 06/23] Small corrections for configuration.py's usage of GFFValidator --- tiny/rna/configuration.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tiny/rna/configuration.py b/tiny/rna/configuration.py index caacb9dd..dde86823 100644 --- a/tiny/rna/configuration.py +++ b/tiny/rna/configuration.py @@ -12,7 +12,7 @@ from datetime import datetime from typing import Union, Any -from rna.counter.validation import GFFValidator +from tiny.rna.counter.validation import GFFValidator timestamp_format = re.compile(r"\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}") @@ -317,7 +317,7 @@ def validate_inputs(self): gff_files = {gff['path']: [] for gff in self['gff_files']} prefs = {x: self[f'{x}_filter'] for x in ['source', 'type']} - ebwt = self['ebwt'] if not self['run_bowtie_build'] else None + ebwt = self.paths['ebwt'] if not self['run_bowtie_build'] else None GFFValidator( gff_files, From 8743abb0d37b746b094d3576a88a8f9cbf85531b Mon Sep 17 00:00:00 2001 From: Alex Tate <0xalextate@gmail.com> Date: Sun, 9 Oct 2022 15:18:56 -0700 Subject: [PATCH 07/23] Script termination via sys.exit() no longer results in a traceback being printed. Adding this exception so that we can call sys.exit() on validation failure and let the validation report speak for itself, rather than following the report with an unnecessary stacktrace --- tiny/rna/counter/counter.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tiny/rna/counter/counter.py b/tiny/rna/counter/counter.py index 002137bd..c7f48f0b 100644 --- a/tiny/rna/counter/counter.py +++ b/tiny/rna/counter/counter.py @@ -208,7 +208,8 @@ def main(): # Write final outputs merged_counts.write_report_files() - except: + except Exception as e: + if type(e) is SystemExit: return traceback.print_exception(*sys.exc_info()) if args['is_pipeline']: print("\n\ntiny-count encountered an error. Don't worry! You don't have to start over.\n" From 95022ab02e479cb2688fad3a7cf79639c39d0e1d Mon Sep 17 00:00:00 2001 From: Alex Tate <0xalextate@gmail.com> Date: Sun, 9 Oct 2022 15:23:42 -0700 Subject: [PATCH 08/23] Final corrections and comments for validation.py. Chromosome heuristics will now read up to 50,000 lines of each SAM file (while checking every 10,000 lines for chromosome matches) because it is quite a bit faster than I assumed. For 9 library files this represents only ~0.4s of runtime --- tiny/rna/counter/validation.py | 63 ++++++++++++++++++---------------- 1 file changed, 34 insertions(+), 29 deletions(-) diff --git a/tiny/rna/counter/validation.py b/tiny/rna/counter/validation.py index 479b7b68..7a8b97a5 100644 --- a/tiny/rna/counter/validation.py +++ b/tiny/rna/counter/validation.py @@ -4,7 +4,7 @@ from collections import Counter, defaultdict -from rna.counter.hts_parsing import parse_gff, ReferenceTables +from tiny.rna.counter.hts_parsing import parse_gff, ReferenceTables class ReportFormatter: @@ -47,27 +47,28 @@ def nested_dict(self, summary, indent='\t'): report_lines = self.recursive_indent(summary, indent) return '\n'.join(report_lines) - def recursive_indent(self, mapping, indent): + def recursive_indent(self, mapping: dict, indent: str): + """Converts a nested dictionary into a properly indented list of lines + + Args: + mapping: the nested dictionary to be formatted + indent: the indent string to prepend to lines on the current level + """ + lines = [] for key, val in mapping.items(): - if not val: return lines + if not val: continue key_header = f"{indent}{self.key_mapper.get(key, key)}: " if isinstance(val, dict): lines.append(key_header) lines.extend(self.recursive_indent(val, indent + '\t')) - elif isinstance(val, list): + elif isinstance(val, (list, set)): lines.append(key_header) - lines.extend([indent + '\t' + line for line in val]) + lines.extend([indent + '\t' + line for line in sorted(map(str, val))]) else: lines.append(key_header + str(val)) return lines - def indent(self, lines, level=1, sep='\n'): - ind_token = '\t' * level - out = ind_token - out += (sep + ind_token).join(lines) - return out - class GFFValidator: """Validates GFF files based on their contents and the contents of sequencing files to which @@ -75,6 +76,7 @@ class GFFValidator: targets = { "ID attribute": "Features missing a valid identifier attribute", + "sam files": "Potentially incompatible SAM alignment files", "seq chromosomes": "Chromosomes present in sequence files", "gff chromosomes": "Chromosomes present in GFF files", "strand": "Features missing strand information", @@ -90,6 +92,7 @@ def __init__(self, gff_files, prefs, ebwt=None, genomes=None, alignments=None): self.prefs = prefs def validate(self): + print("Validating annotation files...") self.parse_and_validate_gffs(self.gff_files) self.validate_chroms(*self.seq_files) self.report.print_report() @@ -99,24 +102,23 @@ def validate(self): def parse_and_validate_gffs(self, file_set): gff_infractions = defaultdict(Counter) for file, *_ in file_set.items(): - row_fn = functools.partial(self.validate_gff_row, report=gff_infractions[file]) + row_fn = functools.partial(self.validate_gff_row, issues=gff_infractions, file=file) parse_gff(file, row_fn=row_fn) - if len(gff_infractions.values()): + if gff_infractions: self.generate_gff_report(gff_infractions) - def validate_gff_row(self, row, report): - # Check for reasons to normally skip row + def validate_gff_row(self, row, issues, file): if row.type.lower() == "chromosome": return # Skip definitions of whole chromosomes regardless if not self.ReferenceTables.filter_match(row): return # Obey source/type filters before validation if row.iv.strand not in ('+', '-'): - report["strand"] += 1 + issues[file]["strand"] += 1 try: self.ReferenceTables.get_feature_id(row) except: - report['ID attribute'] += 1 + issues[file]['ID attribute'] += 1 self.chrom_set.add(row.iv.chrom) @@ -167,7 +169,7 @@ def chroms_shared_with_ebwt(self, index_prefix): return shared, ebwt_chroms def chroms_shared_with_genomes(self, genome_fastas): - """Returns the set intersection between parsed GFF chromosomes and those in the bowtie index""" + """Returns the set intersection between parsed GFF chromosomes and those in the reference genome""" genome_chroms = set() for fasta in genome_fastas: @@ -179,23 +181,22 @@ def chroms_shared_with_genomes(self, genome_fastas): shared = genome_chroms & self.chrom_set return shared, genome_chroms - def alignment_chroms_mismatch_heuristic(self, sam_files): + def alignment_chroms_mismatch_heuristic(self, sam_files, subset_size=50000): """Since alignment files can be very large, we only check that there's at least one shared chromosome identifier and only the first subset_size lines are read from each file.""" - subset_size = 5000 - files_wo_overlap = [] + files_wo_overlap = {} for file in sam_files: file_chroms = set() with open(file, 'rb') as f: for line, i in zip(f, range(subset_size)): - if line[0] == b"@": continue - file_chroms.add(line.split(b'\t')[2]) - if i % 1000 == 0 and len(file_chroms & self.chrom_set): break + if line[0] == ord('@'): continue + file_chroms.add(line.split(b'\t')[2].strip().decode()) + if i % 10000 == 0 and len(file_chroms & self.chrom_set): break if not len(file_chroms & self.chrom_set): - files_wo_overlap.append(file) + files_wo_overlap[file] = file_chroms return files_wo_overlap @@ -203,19 +204,23 @@ def generate_chrom_report(self, shared, chroms): if shared: return header = "GFF files and sequence files don't share any chromosome identifiers." summary = { - "gff chromosomes": sorted(self.chrom_set), - "seq chromosomes": sorted(chroms) + "gff chromosomes": self.chrom_set, + "seq chromosomes": chroms } self.report.add_error_section(header, summary) def generate_chrom_heuristics_report(self, suspect_files): if not suspect_files: return + header = "GFF files and sequence files might not contain the same chromosome identifiers.\n" \ "This is determined from a subset of each sequence file, so false positives may be reported." + chroms = {file: [f"Chromosomes sampled: {', '.join(sorted(chroms))}"] + for file, chroms in suspect_files.items()} + summary = { - "The following sequence files might be incompatible": sorted(suspect_files), - "The following chromosomes are present in GFF files": sorted(self.chrom_set) + "sam files": chroms, + "gff chromosomes": self.chrom_set } self.report.add_warning_section(header, summary) \ No newline at end of file From 3bd3ca3e9da0c27d48561f537d82058c51b5b315 Mon Sep 17 00:00:00 2001 From: Alex Tate <0xalextate@gmail.com> Date: Sun, 9 Oct 2022 15:24:25 -0700 Subject: [PATCH 09/23] Final corrections for unit tests. Missing tests have been added and existing tests have been updated. --- tests/unit_tests_validation.py | 144 +++++++++++++++++++++++++++++---- 1 file changed, 130 insertions(+), 14 deletions(-) diff --git a/tests/unit_tests_validation.py b/tests/unit_tests_validation.py index d4ca829d..e4a6a8ea 100644 --- a/tests/unit_tests_validation.py +++ b/tests/unit_tests_validation.py @@ -1,4 +1,9 @@ +import contextlib import unittest +import time +import io + +from glob import glob from unittest.mock import patch, mock_open from rna.counter.validation import GFFValidator, ReportFormatter @@ -80,7 +85,7 @@ def test_gff_id_validation(self): "\t\t" + f"{validator.targets['ID attribute']}: 3" ]) - with self.mock_gff_open(mock_gff) as p: + with patch('tiny.rna.counter.hts_parsing.HTSeq.utils.open', mock_open(read_data=mock_gff)) as p: validator.parse_and_validate_gffs({mock_filename: []}) self.assertListEqual(validator.report.errors, [expected]) @@ -122,6 +127,39 @@ def test_genome_chroms(self): self.assertSetEqual(shared, set()) self.assertSetEqual(genome_chroms, {'chr1', 'chr2', 'chr3'}) + """Does GFFValidator's alignments heuristic identify potentially incompatible SAM files?""" + + def test_alignments_heuristic(self): + validator = self.make_gff_validator() + sam_files = ['./testdata/counter/identity_choice_test.sam', + './testdata/counter/single.sam'] + + sam_chroms = { + sam_files[0]: {'I', 'V', 'MtDNA'}, + sam_files[1]: {'I'} + } + + # Some chroms are shared + validator.chrom_set = {'I', 'not_shared', 'also_not_shared'} + bad_sams = validator.alignment_chroms_mismatch_heuristic(sam_files) + self.assertDictEqual(bad_sams, {}) + + # Some chroms aren't shared + validator.chrom_set = {'V', 'not_shared', 'also_not_shared'} + bad_sams = validator.alignment_chroms_mismatch_heuristic(sam_files) + self.assertDictEqual(bad_sams, {sam_files[1]: sam_chroms[sam_files[1]]}) + + # Chroms aren't shared + validator.chrom_set = {'not_shared', 'also_not_shared'} + bad_sams = validator.alignment_chroms_mismatch_heuristic(sam_files) + self.assertDictEqual(bad_sams, sam_chroms) + + # Chroms aren't shared, single SAM file input + validator.chrom_set = {'V'} + sam_file = sam_files[1] + bad_sams = validator.alignment_chroms_mismatch_heuristic([sam_file]) + self.assertDictEqual(bad_sams, {sam_file: sam_chroms[sam_file]}) + """Does GFFValidator.generate_gff_report() correctly process an infractions report?""" def test_gff_report_output(self): @@ -135,17 +173,19 @@ def test_gff_report_output(self): expected = '\n'.join([ "The following issues were found in the GFF files provided:", - "\tgff1: ", + "\t" + "gff1: ", "\t\t" + f"{validator.targets['ID attribute']}: 10", "\t\t" + f"{validator.targets['strand']}: 5", - "\tgff2: ", + "\t" + "gff2: ", "\t\t" + f"{validator.targets['ID attribute']}: 1", - "\tgff3: ", + "\t" + "gff3: ", "\t\t" + f"{validator.targets['strand']}: 1" ]) validator.generate_gff_report(infractions) - self.assertListEqual(validator.report.errors, [expected]) + self.assertEqual(validator.report.errors[0], expected) + + """Does GFFValidator.generate_chrom_report() correctly process an infractions report?""" def test_chrom_report_output(self): validator = self.make_gff_validator() @@ -155,18 +195,94 @@ def test_chrom_report_output(self): exp_errors = '\n'.join([ "GFF files and sequence files don't share any chromosome identifiers.", - "\tChromosomes are present in GFF files: ", - "\t\tchr1", - "\t\tchr2", - "\tThe following chromosomes are present in sequence files: ", - "\t\tchr3", - "\t\tchr4" + "\t" + f"{validator.targets['gff chromosomes']}: ", + "\t\t" + "chr1", + "\t\t" + "chr2", + "\t" + f"{validator.targets['seq chromosomes']}: ", + "\t\t" + "chr3", + "\t\t" + "chr4" ]) validator.generate_chrom_report(shared_chroms, seq_chroms) - # self.assertEqual(validator.report.errors[0], exp_errors) - validator.report.errors *= 3 - validator.report.print_report() + self.assertEqual(validator.report.errors[0], exp_errors) + + """Does GFFValidator.generate_chrom_heuristics_report() correctly process an infractions report?""" + + def test_chrom_heuristics_report_output(self): + validator = self.make_gff_validator() + validator.chrom_set = {'chr1', 'chr2'} + suspect_files = { + 'sam1': {'chr3', 'chr4'}, + 'sam2': {'chr5', 'chr6'} + } + + exp_warnings = '\n'.join([ + "GFF files and sequence files might not contain the same chromosome identifiers.", + "This is determined from a subset of each sequence file, so false positives may be reported.", + "\t" + f"{validator.targets['sam files']}: ", + "\t\t" + "sam1: ", + "\t\t\t" + f"Chromosomes sampled: {', '.join(sorted(suspect_files['sam1']))}", + "\t\t" + "sam2: ", + "\t\t\t" + f"Chromosomes sampled: {', '.join(sorted(suspect_files['sam2']))}", + "\t" + f"{validator.targets['gff chromosomes']}: ", + "\t\t" + "chr1", + "\t\t" + "chr2", + ]) + + validator.generate_chrom_heuristics_report(suspect_files) + self.assertEqual(validator.report.warnings[0], exp_warnings) + + """Does ReportFormatter add and print all sections with correct formatting?""" + + def test_report_multi_section(self): + key_mapper = {'short': "long description"} + formatter = ReportFormatter(key_mapper) + + formatter.add_warning_section("Header only") + formatter.add_warning_section("Header 2", {'short': 5, 'short2': [1,2,3]}) + formatter.add_error_section("Header 3", {'short': {'level2a': {4,5,6}, 'level2b': 'msg'}}) + + expected_report = '\n'.join([ + ReportFormatter.error_header, + "Header 3", + "\t" + "long description: ", + "\t\t" + "level2a: ", + *["\t\t\t" + str(i) for i in [4,5,6]], + "\t\t" + "level2b: msg", + "", + ReportFormatter.warning_header, + "Header only", + "", + ReportFormatter.warning_header, + "Header 2", + "\t" + "long description: 5", + "\t" + "short2: ", + *["\t\t" + str(i) for i in [1,2,3]], + "", + "" + ]) + + stdout = io.StringIO() + with contextlib.redirect_stdout(stdout): + formatter.print_report() + + self.assertEqual(stdout.getvalue(), expected_report) + + """Do chromosome heuristics run in 2 seconds or less for a full-size test dataset?""" + + def test_chrom_heuristics_runtime(self): + validator = self.make_gff_validator() + validator.chrom_set = {'none match'} + files = glob("./testdata/local_only/sam/full/*.sam") + + start = time.time() + _ = validator.alignment_chroms_mismatch_heuristic(files) + end = time.time() + + print(f"Chromosome heuristics runtime: {end-start:.2f}s") + self.assertLessEqual(end-start, 2) + + if __name__ == '__main__': unittest.main() From 15f15243f7ac128f88d8534009ebde830b341ba8 Mon Sep 17 00:00:00 2001 From: Alex Tate <0xalextate@gmail.com> Date: Tue, 11 Oct 2022 19:38:05 -0700 Subject: [PATCH 10/23] Minor efficiency fix in ReferenceTables.get_feature_id(). Previously all 3 keys were queried with every function call, in reverse order from lowest to highest priority, even if the preferred key was present. Now the chain will check the highest priority keys first, and continue as soon as a match is found --- tiny/rna/counter/hts_parsing.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tiny/rna/counter/hts_parsing.py b/tiny/rna/counter/hts_parsing.py index 79ab89b8..777548b6 100644 --- a/tiny/rna/counter/hts_parsing.py +++ b/tiny/rna/counter/hts_parsing.py @@ -706,10 +706,9 @@ def chrom_vector_setdefault(self, chrom): @staticmethod def get_feature_id(row): - id_collection = \ - row.attr.get('ID', default= - row.attr.get('gene_id', default= - row.attr.get('Parent', default=None))) + id_collection = row.attr.get('ID') \ + or row.attr.get('gene_id') \ + or row.attr.get('Parent') if id_collection is None: raise ValueError(f"Feature {row.name} does not have an ID attribute.") From bb6e306baae7d26e9981858392487039c4f9e752 Mon Sep 17 00:00:00 2001 From: Alex Tate <0xalextate@gmail.com> Date: Tue, 11 Oct 2022 20:24:20 -0700 Subject: [PATCH 11/23] Major change to feature strandedness requirements. Currently strand is mapped from True/False to +/-. Now, if a feature's strand is anything but +/-, it is mapped to None. The GFFValidator produces a warning about this but no longer treats it as a hard error. Per Tai, a strand type of None matches strand selectors for "sense", "antisense", and "both." 5' and 3' anchored selectors can also evaluate these features, but evaluation does not distinguish between 5' and 3' ends. --- tests/unit_tests_hts_parsing.py | 2 +- tests/unit_tests_validation.py | 58 ++++++++++++++++++++------------- tiny/rna/counter/features.py | 11 ++++--- tiny/rna/counter/hts_parsing.py | 52 ++++++++++++++++------------- tiny/rna/counter/matching.py | 39 ++++++++++++++++++++-- tiny/rna/counter/validation.py | 41 ++++++++++++++++++----- tiny/rna/util.py | 14 +++++++- 7 files changed, 154 insertions(+), 63 deletions(-) diff --git a/tests/unit_tests_hts_parsing.py b/tests/unit_tests_hts_parsing.py index 1360631d..daa2b358 100644 --- a/tests/unit_tests_hts_parsing.py +++ b/tests/unit_tests_hts_parsing.py @@ -204,7 +204,7 @@ def test_ref_tables_missing_id_attribute(self): gff_row_without_id = helpers.read(self.short_gff_file).replace('ID=Gene:WBGene00023193;', '') mock_reader = mock_open(read_data=gff_row_without_id) - expected_err = f"Feature WBGene00023193 does not contain an ID attribute.\n" + expected_err = f"Feature WBGene00023193 does not have an ID attribute.\n" expected_err += f"Error occurred on line 1 of {self.short_gff_file}" with patch('tiny.rna.counter.hts_parsing.HTSeq.utils.open', new=mock_reader): diff --git a/tests/unit_tests_validation.py b/tests/unit_tests_validation.py index e4a6a8ea..a692c216 100644 --- a/tests/unit_tests_validation.py +++ b/tests/unit_tests_validation.py @@ -13,6 +13,14 @@ class ValidationTests(unittest.TestCase): """======== Helper functions =========================== """ + @classmethod + def setUpClass(self): + self.strand_ext_header = \ + 'Unstranded features are allowed, but they can lead to potentially unexpected results.\n' \ + 'These features will match "sense", "antisense", and "both" strand selectors. 5\'/3\' anchored\n' \ + "overlap selectors for these features will evaluate for termini shared with the alignment,\n" \ + "but will not distinguish between the alignment's 5' and 3' ends." + def make_gff_row(self, **cols): template = { 'seqid': "I", @@ -54,16 +62,17 @@ def test_gff_strand_validation(self): ]) expected = '\n'.join([ - "The following issues were found in the GFF files provided:", - "\t" + f"{mock_filename}: ", - "\t\t" + f"{validator.targets['strand']}: 1" + "The following issues were found in the GFF files provided. ", + self.strand_ext_header, + "\t" + f"{validator.targets['strand']}: ", + "\t\t" + f"1 missing in {mock_filename}" ]) with self.mock_gff_open(mock_gff) as p: validator.parse_and_validate_gffs({mock_filename: []}) - self.assertListEqual(validator.report.errors, [expected]) - self.assertListEqual(validator.report.warnings, []) + self.assertListEqual(validator.report.warnings, [expected]) + self.assertListEqual(validator.report.errors, []) """Does GFFValidator correctly validate ID attributes?""" @@ -80,9 +89,9 @@ def test_gff_id_validation(self): ]) expected = '\n'.join([ - "The following issues were found in the GFF files provided:", - "\t" + f"{mock_filename}: ", - "\t\t" + f"{validator.targets['ID attribute']}: 3" + "The following issues were found in the GFF files provided. ", + "\t" + f"{validator.targets['ID attribute']}: ", + "\t\t" + f"3 missing in {mock_filename}" ]) with patch('tiny.rna.counter.hts_parsing.HTSeq.utils.open', mock_open(read_data=mock_gff)) as p: @@ -165,25 +174,28 @@ def test_alignments_heuristic(self): def test_gff_report_output(self): validator = self.make_gff_validator() infractions = { - "gff1": {'ID attribute': 10, 'strand': 5}, - "gff2": {'ID attribute': 1}, - "gff3": {'strand': 1}, - "gff4": {} + 'ID attribute': {'gff1': 10, 'gff2': 1}, + 'strand': {'gff1': 5, 'gff3': 1} } - expected = '\n'.join([ - "The following issues were found in the GFF files provided:", - "\t" + "gff1: ", - "\t\t" + f"{validator.targets['ID attribute']}: 10", - "\t\t" + f"{validator.targets['strand']}: 5", - "\t" + "gff2: ", - "\t\t" + f"{validator.targets['ID attribute']}: 1", - "\t" + "gff3: ", - "\t\t" + f"{validator.targets['strand']}: 1" + gff_sections_header = "The following issues were found in the GFF files provided. " + expected_errors = '\n'.join([ + gff_sections_header, + "\t" + f"{validator.targets['ID attribute']}: ", + "\t\t" + "10 missing in gff1", + "\t\t" + "1 missing in gff2" + ]) + + expected_warnings = '\n'.join([ + '\n'.join([gff_sections_header, self.strand_ext_header]), + "\t" + f"{validator.targets['strand']}: ", + "\t\t" + "5 missing in gff1", + "\t\t" + "1 missing in gff3" ]) validator.generate_gff_report(infractions) - self.assertEqual(validator.report.errors[0], expected) + self.assertEqual(validator.report.errors[0], expected_errors) + self.assertEqual(validator.report.warnings[0], expected_warnings) """Does GFFValidator.generate_chrom_report() correctly process an infractions report?""" @@ -226,7 +238,7 @@ def test_chrom_heuristics_report_output(self): "\t\t\t" + f"Chromosomes sampled: {', '.join(sorted(suspect_files['sam2']))}", "\t" + f"{validator.targets['gff chromosomes']}: ", "\t\t" + "chr1", - "\t\t" + "chr2", + "\t\t" + "chr2" ]) validator.generate_chrom_heuristics_report(suspect_files) diff --git a/tiny/rna/counter/features.py b/tiny/rna/counter/features.py index 22bb7038..1fbef7bf 100644 --- a/tiny/rna/counter/features.py +++ b/tiny/rna/counter/features.py @@ -9,7 +9,8 @@ from .matching import * # Type aliases for human readability -match_tuple = Tuple[int, int, IntervalSelector] # (rank, rule, interval selector) +match_tuple = Tuple[int, int, IntervalSelector] # (rank, rule, IntervalSelector) +unbuilt_match_tuple = Tuple[int, int, str] # (rank, rule, interval selector keyword) feature_record_tuple = Tuple[str, str, Tuple[match_tuple]] # (feature ID, strand, match tuple) @@ -146,7 +147,7 @@ def choose(cls, candidates: Set[feature_record_tuple], alignment: dict) -> Mappi rule_def = FeatureSelector.rules_table[rule] if alignment['nt5end'] not in rule_def["nt5end"]: continue if alignment['Length'] not in rule_def["Length"]: continue - if alignment['Strand'] ^ strand not in rule_def["Strand"]: continue + if (alignment['Strand'], strand) not in rule_def["Strand"]: continue selections[feat].add(rule) min_rank = rank @@ -188,7 +189,7 @@ def build_selectors(rules_table) -> List[dict]: return rules_table @staticmethod - def build_interval_selectors(iv: 'HTSeq.GenomicInterval', match_tuples: List[Tuple]): + def build_interval_selectors(iv: 'HTSeq.GenomicInterval', match_tuples: List[unbuilt_match_tuple]): """Builds partial/full/exact/3' anchored/5' anchored interval selectors Unlike build_selectors() and build_inverted_identities(), this function @@ -208,8 +209,8 @@ def build_interval_selectors(iv: 'HTSeq.GenomicInterval', match_tuples: List[Tup 'full': lambda: IntervalFullMatch(iv), 'exact': lambda: IntervalExactMatch(iv), 'partial': lambda: IntervalPartialMatch(iv), - "5' anchored": lambda: Interval5pMatch(iv), - "3' anchored": lambda: Interval3pMatch(iv), + "5' anchored": lambda: Interval5pMatch(iv) if iv.strand in ('+', '-') else IntervalAnchorMatch(iv), + "3' anchored": lambda: Interval3pMatch(iv) if iv.strand in ('+', '-') else IntervalAnchorMatch(iv), } for i in range(len(match_tuples)): diff --git a/tiny/rna/counter/hts_parsing.py b/tiny/rna/counter/hts_parsing.py index 777548b6..aaa59c78 100644 --- a/tiny/rna/counter/hts_parsing.py +++ b/tiny/rna/counter/hts_parsing.py @@ -255,25 +255,6 @@ def infer_strandedness(sam_file: str, intervals: dict) -> str: else: return "non-reverse" -def parse_gff(file, row_fn: Callable, alias_keys=None): - if alias_keys is not None: - row_fn = functools.partial(row_fn, alias_keys=alias_keys) - - gff = HTSeq.GFF_Reader(file) - try: - for row in gff: - row_fn(row) - except Exception as e: - # Append to error message while preserving exception provenance and traceback - extended_msg = f"Error occurred on line {gff.line_no} of {file}" - if type(e) is KeyError: - e.args += (extended_msg,) - else: - primary_msg = "%s\n%s" % (str(e.args[0]), extended_msg) - e.args = (primary_msg,) + e.args[1:] - raise e.with_traceback(sys.exc_info()[2]) from e - - def parse_GFF_attribute_string(attrStr, extra_return_first_value=False, gff_version=2): """Parses a GFF attribute string and returns it as a dictionary. @@ -426,6 +407,25 @@ def not_implemented(self): raise NotImplementedError(f"CaseInsensitiveAttrs does not support {stack()[1].function}") +def parse_gff(file, row_fn: Callable, alias_keys=None): + if alias_keys is not None: + row_fn = functools.partial(row_fn, alias_keys=alias_keys) + + gff = HTSeq.GFF_Reader(file) + try: + for row in gff: + row_fn(row) + except Exception as e: + # Append to error message while preserving exception provenance and traceback + extended_msg = f"Error occurred on line {gff.line_no} of {file}" + if type(e) is KeyError: + e.args += (extended_msg,) + else: + primary_msg = "%s\n%s" % (str(e.args[0]), extended_msg) + e.args = (primary_msg,) + e.args[1:] + raise + + # Type aliases for human readability ClassTable = AliasTable = DefaultDict[str, Tuple[str]] StepVector = HTSeq.GenomicArrayOfSets @@ -490,8 +490,6 @@ def parse_row(self, row, alias_keys=None): if row.type.lower() == "chromosome" or not self.filter_match(row): self.exclude_row(row) return - if row.iv.strand == ".": - raise ValueError(f"Feature {row.name} has no strand information.") # Grab the primary key for this feature feature_id = self.get_feature_id(row) @@ -651,7 +649,8 @@ def _finalize_features(self): for sub_iv in merged_sub_ivs: finalized_match_tuples = self.selector.build_interval_selectors(sub_iv, sorted_matches.copy()) - self.feats[sub_iv] += (tagged_id, sub_iv.strand == '+', tuple(finalized_match_tuples)) + strand = self.map_strand(sub_iv.strand) + self.feats[sub_iv] += (tagged_id, strand, tuple(finalized_match_tuples)) @staticmethod def _merge_adjacent_subintervals(unmerged_sub_ivs: List[HTSeq.GenomicInterval]) -> list: @@ -690,6 +689,15 @@ def get_feats_table_size(self) -> int: return total_feats + def map_strand(self, gff_value: str): + """Maps HTSeq's strand representation (+/-/.) to + tinyRNA's strand representation (True/False/None)""" + + return { + '+': True, + '-': False, + }.get(gff_value, None) + def was_matched(self, untagged_id): """Checks if the feature ID previously matched on identity, regardless of whether the matching rule was tagged or untagged.""" diff --git a/tiny/rna/counter/matching.py b/tiny/rna/counter/matching.py index 5382b9a6..a7e7ecc9 100644 --- a/tiny/rna/counter/matching.py +++ b/tiny/rna/counter/matching.py @@ -1,7 +1,9 @@ """Selector classes which determine a match via __contains__()""" -import re import HTSeq +import re + +from typing import Optional, Tuple from tiny.rna.util import Singleton @@ -27,8 +29,11 @@ def __init__(self, strand): self.strand = strand self.select = (self.strand == 'sense') - def __contains__(self, x: bool): - return self.select ^ x + def __contains__(self, strand_relationship: Tuple[bool, Optional[bool]]): + """If feature strand is None, a match is not possible for 'sense' or 'antisense'""" + + aln_strand, feat_strand = strand_relationship + return feat_strand is None or aln_strand ^ feat_strand ^ self.select def __repr__(self): return str(self.strand) @@ -156,6 +161,34 @@ def __repr__(self): return f"" +class IntervalAnchorMatch(IntervalSelector): + """Evaluates whether an alignment's start matches the feature's start, and vice versa for end.""" + + def __init__(self, iv: HTSeq.GenomicInterval): + super().__init__(iv) + assert iv.strand not in ('+', '-') + + def __contains__(self, alignment: dict): + """The following diagram demonstrates unstranded anchored matching semantics. + + Match | <------->| + Match |<-----------|--> + Match |<---------->| + No match | <---------|-> + <-----------|<==feat_A==>|-----------> + + Args: + alignment: An alignment dictionary containing strand, start, and end + + Returns: True if the alignment's 5' end is anchored to the strand-appropriate + terminus of this feature's interval. + """ + + return alignment['Start'] == self.start or alignment['End'] == self.end + + def __repr__(self): + return f"" + class Interval5pMatch(IntervalSelector): """Evaluates whether an alignment's 5' end is anchored to the corresponding terminus of the feature""" diff --git a/tiny/rna/counter/validation.py b/tiny/rna/counter/validation.py index 7a8b97a5..40485e25 100644 --- a/tiny/rna/counter/validation.py +++ b/tiny/rna/counter/validation.py @@ -4,6 +4,7 @@ from collections import Counter, defaultdict +from tiny.rna.util import sorted_natural from tiny.rna.counter.hts_parsing import parse_gff, ReferenceTables @@ -64,7 +65,7 @@ def recursive_indent(self, mapping: dict, indent: str): lines.extend(self.recursive_indent(val, indent + '\t')) elif isinstance(val, (list, set)): lines.append(key_header) - lines.extend([indent + '\t' + line for line in sorted(map(str, val))]) + lines.extend([indent + '\t' + line for line in map(str, val)]) else: lines.append(key_header + str(val)) return lines @@ -113,18 +114,42 @@ def validate_gff_row(self, row, issues, file): if not self.ReferenceTables.filter_match(row): return # Obey source/type filters before validation if row.iv.strand not in ('+', '-'): - issues[file]["strand"] += 1 + issues['strand'][file] += 1 try: self.ReferenceTables.get_feature_id(row) except: - issues[file]['ID attribute'] += 1 + issues['ID attribute'][file] += 1 self.chrom_set.add(row.iv.chrom) def generate_gff_report(self, infractions): - header = "The following issues were found in the GFF files provided:" - self.report.add_error_section(header, infractions) + header = "The following issues were found in the GFF files provided. " + + if "strand" in infractions: + strand_issues = {"strand": + sorted_natural([ + f"{count} missing in {file}" + for file, count in infractions['strand'].items() + ], reverse=True) + } + + ext_header = 'Unstranded features are allowed, but they can lead to potentially unexpected results.\n' \ + 'These features will match "sense", "antisense", and "both" strand selectors. 5\'/3\' anchored\n' \ + "overlap selectors for these features will evaluate for termini shared with the alignment,\n" \ + "but will not distinguish between the alignment's 5' and 3' ends." + + self.report.add_warning_section('\n'.join([header, ext_header]), strand_issues) + + if "ID attribute" in infractions: + idattr_issues = {"ID attribute": + sorted_natural([ + f"{count} missing in {file}" + for file, count in infractions['ID attribute'].items() + ], reverse=True) + } + + self.report.add_error_section(header, idattr_issues) def validate_chroms(self, ebwt=None, genomes=None, alignments=None): # First search bowtie indexes if they are available @@ -204,8 +229,8 @@ def generate_chrom_report(self, shared, chroms): if shared: return header = "GFF files and sequence files don't share any chromosome identifiers." summary = { - "gff chromosomes": self.chrom_set, - "seq chromosomes": chroms + "gff chromosomes": sorted(self.chrom_set), + "seq chromosomes": sorted(chroms) } self.report.add_error_section(header, summary) @@ -220,7 +245,7 @@ def generate_chrom_heuristics_report(self, suspect_files): summary = { "sam files": chroms, - "gff chromosomes": self.chrom_set + "gff chromosomes": sorted(self.chrom_set) } self.report.add_warning_section(header, summary) \ No newline at end of file diff --git a/tiny/rna/util.py b/tiny/rna/util.py index fe04e07f..ac94fd0c 100644 --- a/tiny/rna/util.py +++ b/tiny/rna/util.py @@ -81,4 +81,16 @@ def __init__(self, rw_dict): super().__init__(rw_dict) def __setitem__(self, *_): - raise RuntimeError("Attempted to modify read-only dictionary after construction.") \ No newline at end of file + raise RuntimeError("Attempted to modify read-only dictionary after construction.") + +def sorted_natural(lines, reverse=False): + """Sorts alphanumeric strings with entire numbers considered in the sorting order, + rather than the default behavior which is to sort by the individual ASCII values + of the given number. Returns a sorted copy of the list, just like sorted(). + + Not sure who to credit... it seems this snippet has been floating around for quite + some time. Strange that there isn't something in the standard library for this.""" + + convert = lambda text: int(text) if text.isdigit() else text.lower() + alphanum_key = lambda key: [convert(c) for c in re.split(r'(\d+)', key)] + return sorted(lines, key=alphanum_key, reverse=reverse) \ No newline at end of file From f4e619d3d96f8ecac511cc91c6b6fa6c945d5764 Mon Sep 17 00:00:00 2001 From: Alex Tate <0xalextate@gmail.com> Date: Wed, 12 Oct 2022 15:41:24 -0700 Subject: [PATCH 12/23] Decided to add IntervalAnchorMatch to the list of available selectors for the Overlap column. I think this makes it easier to explain how the 5'/3' anchored selectors behave with unstranded features --- tiny/rna/counter/features.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tiny/rna/counter/features.py b/tiny/rna/counter/features.py index 1fbef7bf..675d5c45 100644 --- a/tiny/rna/counter/features.py +++ b/tiny/rna/counter/features.py @@ -209,6 +209,7 @@ def build_interval_selectors(iv: 'HTSeq.GenomicInterval', match_tuples: List[unb 'full': lambda: IntervalFullMatch(iv), 'exact': lambda: IntervalExactMatch(iv), 'partial': lambda: IntervalPartialMatch(iv), + 'anchored': lambda: IntervalAnchorMatch(iv), "5' anchored": lambda: Interval5pMatch(iv) if iv.strand in ('+', '-') else IntervalAnchorMatch(iv), "3' anchored": lambda: Interval3pMatch(iv) if iv.strand in ('+', '-') else IntervalAnchorMatch(iv), } From 6a67b897306dd5b2d78aa8b64146ccb59f87a2c5 Mon Sep 17 00:00:00 2001 From: Alex Tate <0xalextate@gmail.com> Date: Wed, 12 Oct 2022 15:46:13 -0700 Subject: [PATCH 13/23] Updates for the input file requirements table. Removing stranded features and adding that Parent is now used as a fallback ID attribute --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 91b7d4df..a418c74c 100644 --- a/README.md +++ b/README.md @@ -90,12 +90,12 @@ tiny get-template ### Requirements for User-Provided Input Files -| Input Type | File Extension | Requirements | -|----------------------------------------------------------------------------|-------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| Reference annotations
[(example)](START_HERE/reference_data/ram1.gff3) | GFF3 / GFF2 / GTF | Column 9 attributes (defined as "tag=value" or "tag "):

  • Each feature must have an `ID` or `gene_id` tag.
  • Feature classes can be defined with the `Class` tag. If undefined, the default value \__UNKNOWN_\_ will be used.
  • Discontinuous features must be defined with the `Parent` tag whose value is the logical parent's `ID`, or by sharing the same `ID`.
  • Attribute values containing commas must represent lists.
  • All features must be stranded.
  • See the example link (left) for col. 9 formatting.
| -| Sequencing data
[(example)](START_HERE/fastq_files) | FASTQ(.gz) | Files must be demultiplexed. | -| Reference genome
[(example)](START_HERE/reference_data/ram1.fa) | FASTA | Chromosome identifiers (e.g. Chr1):
  • Must match your reference annotation file chromosome identifiers
  • Are case sensitive
| -| Bowtie indexes (optional) 1 | ebwt | Must be small indexes (.ebwtl indexes are not supported) | +| Input Type | File Extension | Requirements | +|----------------------------------------------------------------------------|-------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Reference annotations
[(example)](START_HERE/reference_data/ram1.gff3) | GFF3 / GFF2 / GTF | Column 9 attributes (defined as "tag=value" or "tag "):
  • Each feature must have an `ID` or `gene_id` or `Parent` tag (referred to as `ID` henceforth).
  • Feature classes can be defined with the `Class` tag. If undefined, the default value \__UNKNOWN_\_ will be used.
  • Discontinuous features must be defined with the `Parent` tag whose value is the logical parent's `ID`, or by sharing the same `ID`.
  • Attribute values containing commas must represent lists.
  • See the example link (left) for col. 9 formatting.
| +| Sequencing data
[(example)](START_HERE/fastq_files) | FASTQ(.gz) | Files must be demultiplexed. | +| Reference genome
[(example)](START_HERE/reference_data/ram1.fa) | FASTA | Chromosome identifiers (e.g. Chr1):
  • Must match your reference annotation file chromosome identifiers
  • Are case sensitive
| +| Bowtie indexes (optional) 1 | ebwt | Must be small indexes (.ebwtl indexes are not supported) |
1 Bowtie indexes can be created for you. See the [configuration file documentation](doc/Configuration.md#building-bowtie-indexes). From 08221c0afe96b3ce976141dfbd07f7b596b68fde Mon Sep 17 00:00:00 2001 From: Alex Tate <0xalextate@gmail.com> Date: Wed, 12 Oct 2022 15:48:19 -0700 Subject: [PATCH 14/23] Adding the "anchored" overlap selector and updates for the behavior of unstranded features. Also refined/simplified the Overlap explanation in Stage 2 --- doc/tiny-count.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/doc/tiny-count.md b/doc/tiny-count.md index 1439c635..ad5e1314 100644 --- a/doc/tiny-count.md +++ b/doc/tiny-count.md @@ -46,16 +46,24 @@ You can optionally specify a tag for each rule. Feature assignments resulting fr | _features.csv columns:_ | Hierarchy | Overlap | |-------------------------|-----------|---------| -This stage of selection is concerned with the interval overlap between alignments and features. **Overlap is determined in a strandless fashion.** See the [Strand](#strand) section in Stage 3 for refinement of selections by strand. +Features overlapping a read alignment are selected based on their overlap characteristics. These matches are then sorted by hierarchy value before proceeding to Stage 3. ### Overlap -This column allows you to specify which read alignments should be assigned based on how their start and end points overlap with candidate features. Candidates for each matched rule can be selected using the following options: +This column allows you to specify the extent of overlap required for candidate feature selection: - `partial`: alignment overlaps feature by at least one base - `full`: alignment does not extend beyond either terminus of the feature - `exact`: alignment termini are equal to the feature's +- `anchored`: alignment's start and/or end is equal to the feature's - `5' anchored`: alignment's 5' end is equal to the corresponding terminus of the feature - `3' anchored`: alignment's 3' end is equal to the corresponding terminus of the feature +In order to be a candidate, a feature must match a rule in Stage 1, reside on the same chromosome as the alignment, and must overlap the alignment by at least 1 nucleotide. + +#### Strandedness and the Overlap Selector +A feature does not have to be on the same strand as the alignment in order to be a candidate. See the [Strand](#strand) section in Stage 3 for selection by strand. Unstranded features will have `5' anchored` and `3' anchored` overlap selectors downgraded to `anchored` selectors. Alignments overlapping these features are evaluated for shared start and/or end coordinates, but 5'/3' ends are not distinguished. + +#### Selector Demonstration + The following diagrams demonstrate the strand semantics of these interval selectors. The first two options show separate illustrations for features on each strand for emphasis. All matches shown in the remaining three options apply to features on either strand. ![3'_anchored_5'_anchored](../images/3'_anchored_5'_anchored_interval.png) ![Full_Exact_Partial](../images/full_exact_partial_interval.png) @@ -79,10 +87,14 @@ You can use larger hierarchy values to exclude features that are not of interest The final stage of selection is concerned with the small RNA attributes of each alignment locus. Candidates are evaluated in order of hierarchy value where smaller values take precedence. Once a match has been found, reads are excluded from remaining candidates with larger hierarchy values. ### Strand +This selector defines requirements for the alignment's strand relative to the feature's strand. Here, sense and antisense don't refer to the feature's or alignment's strand alone, but rather whether the alignment is sense/antisense to the feature. - `sense`: the alignment strand must match the feature's strand for a match - `antisense`: the alignment strand must not match the feature's strand for a match - `both`: strand is not evaluated +#### Unstranded Features +These features will match all strand selectors regardless of the alignment's strand. + ### 5' End Nucleotide and Length | Parameter | Single | List | Range | Wildcard | From ba6659ee496c66fdff7bd7178cd0700b0f5e6aa9 Mon Sep 17 00:00:00 2001 From: Alex Tate <0xalextate@gmail.com> Date: Wed, 12 Oct 2022 15:49:00 -0700 Subject: [PATCH 15/23] Small correction/refinement of Stage 2 explanation --- doc/Configuration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/Configuration.md b/doc/Configuration.md index 464fadc5..4dbf5869 100644 --- a/doc/Configuration.md +++ b/doc/Configuration.md @@ -132,7 +132,7 @@ The Features Sheet allows you to define selection rules that determine how featu Rules apply to features parsed from **all** Feature Sources, with the exception of "Alias by..." which only applies to the Feature Source on the same row. Selection first takes place against feature attributes (GFF column 9), and is directed by defining the attribute you want to be considered (Select for...) and the acceptable values for that attribute (with value...). -Rules that match features in the first stage of selection will be used in a second stage which performs elimination by hierarchy and interval overlap characteristics. Remaining candidates pass to the third and final stage of selection which examines characteristics of the alignment itself: strand relative to the feature of interest, 5' end nucleotide, and length. +Rules that match features in the first stage of selection will be used in a second stage which evaluates alignment vs. feature interval overlap. These matches are sorted by hierarchy value and passed to the third and final stage of selection which examines characteristics of the alignment itself: strand relative to the feature of interest, 5' end nucleotide, and length. See [tiny-count's documentation](tiny-count.md#feature-selection) for an explanation of each column. From 6d21b2301007487ef77fb7851913fcf5f5be9491 Mon Sep 17 00:00:00 2001 From: Alex Tate <0xalextate@gmail.com> Date: Wed, 12 Oct 2022 15:49:55 -0700 Subject: [PATCH 16/23] Update to support the new None strand type --- tests/unit_test_helpers.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/unit_test_helpers.py b/tests/unit_test_helpers.py index 317dbbad..0084cfc1 100644 --- a/tests/unit_test_helpers.py +++ b/tests/unit_test_helpers.py @@ -135,7 +135,8 @@ def reassemble_gz_w(mock_calls): # Converts strand character to a boolean value def strand_to_bool(strand): - assert strand in ['+', '-'] + assert strand in ('+', '-', None) + if strand is None: return None return strand == '+' From b384d1b98ebd5d719361965f20f8e464b47a4292 Mon Sep 17 00:00:00 2001 From: Alex Tate <0xalextate@gmail.com> Date: Fri, 14 Oct 2022 13:35:25 -0700 Subject: [PATCH 17/23] Bugfix to avoid circular references in ReferenceTables.parents when a feature has a Parent= but no ID/gene_id=. This was causing an infinite loop when ReferenceTables later tried to find the root ancestor of these features. --- tiny/rna/counter/hts_parsing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tiny/rna/counter/hts_parsing.py b/tiny/rna/counter/hts_parsing.py index aaa59c78..57d0cc55 100644 --- a/tiny/rna/counter/hts_parsing.py +++ b/tiny/rna/counter/hts_parsing.py @@ -589,7 +589,7 @@ def get_row_parent(self, feature_id: str, row_attrs: CaseInsensitiveAttrs) -> st if len(parent_attr) > 1: raise ValueError(f"{feature_id} defines multiple parents which is unsupported at this time.") - if len(parent_attr) == 0 or parent is None: + if parent in (None, feature_id): return feature_id if (parent not in self.tags # If parent is not a root feature and parent not in self.parents # If parent doesn't have a parent itself From 083e3dc79d03d7f89865e8005a56a040767fd70c Mon Sep 17 00:00:00 2001 From: Alex Tate <0xalextate@gmail.com> Date: Fri, 14 Oct 2022 13:49:19 -0700 Subject: [PATCH 18/23] Per GFF specification, our GFF attribute parser now URL decodes keys and values after they have been parsed. Note that this happens after comma separated values have been split. This means that value list items can contain URL encoded commas which are then preserved as part of the value (rather than being split on the encoded comma) --- tiny/rna/counter/hts_parsing.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tiny/rna/counter/hts_parsing.py b/tiny/rna/counter/hts_parsing.py index 57d0cc55..4e577285 100644 --- a/tiny/rna/counter/hts_parsing.py +++ b/tiny/rna/counter/hts_parsing.py @@ -6,6 +6,7 @@ from collections import Counter, defaultdict from typing import Tuple, List, Dict, Iterator, Optional, DefaultDict, Set, Union, IO, Callable +from urllib.parse import unquote from inspect import stack from tiny.rna.counter.matching import Wildcard @@ -258,10 +259,10 @@ def infer_strandedness(sam_file: str, intervals: dict) -> str: def parse_GFF_attribute_string(attrStr, extra_return_first_value=False, gff_version=2): """Parses a GFF attribute string and returns it as a dictionary. - This is a slight modification of the same method found in HTSeq.features. - It has been adapted to parse comma separated attribute values as separate values. - Values are stored in a set for ease of handling in ReferenceTables and because - duplicate values don't make sense in this context. + This slight modification of the same method found in HTSeq.features includes + the following for improved compliance with the GFF format: + - Attribute values containing commas are tokenized. + - Attribute keys are URL decoded. Values are URL decoded after tokenization. Per the original HTSeq docstring: "If 'extra_return_first_value' is set, a pair is returned: the dictionary @@ -294,9 +295,9 @@ def parse_GFF_attribute_string(attrStr, extra_return_first_value=False, gff_vers if (gff_version == 2) and val.startswith('"') and val.endswith('"'): val = val[1:-1] # Modification: allow for comma separated attribute values - attribute_dict[key] = (val,) \ + attribute_dict[unquote(key)] = (unquote(val),) \ if ',' not in val \ - else tuple(c.strip() for c in val.split(',')) + else tuple(unquote(c.strip()) for c in val.split(',')) if extra_return_first_value and i == 0: first_val = val if extra_return_first_value: From 12b3872da23b2a624ed29749f6b9d86ffd9360f8 Mon Sep 17 00:00:00 2001 From: Alex Tate <0xalextate@gmail.com> Date: Fri, 14 Oct 2022 14:02:53 -0700 Subject: [PATCH 19/23] Updating GFF file requirements to notify users that features listing multiple parents aren't supported. --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index a418c74c..f3903cc6 100644 --- a/README.md +++ b/README.md @@ -90,12 +90,12 @@ tiny get-template ### Requirements for User-Provided Input Files -| Input Type | File Extension | Requirements | -|----------------------------------------------------------------------------|-------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| Reference annotations
[(example)](START_HERE/reference_data/ram1.gff3) | GFF3 / GFF2 / GTF | Column 9 attributes (defined as "tag=value" or "tag "):
  • Each feature must have an `ID` or `gene_id` or `Parent` tag (referred to as `ID` henceforth).
  • Feature classes can be defined with the `Class` tag. If undefined, the default value \__UNKNOWN_\_ will be used.
  • Discontinuous features must be defined with the `Parent` tag whose value is the logical parent's `ID`, or by sharing the same `ID`.
  • Attribute values containing commas must represent lists.
  • See the example link (left) for col. 9 formatting.
| -| Sequencing data
[(example)](START_HERE/fastq_files) | FASTQ(.gz) | Files must be demultiplexed. | -| Reference genome
[(example)](START_HERE/reference_data/ram1.fa) | FASTA | Chromosome identifiers (e.g. Chr1):
  • Must match your reference annotation file chromosome identifiers
  • Are case sensitive
| -| Bowtie indexes (optional) 1 | ebwt | Must be small indexes (.ebwtl indexes are not supported) | +| Input Type | File Extension | Requirements | +|----------------------------------------------------------------------------|-------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Reference annotations
[(example)](START_HERE/reference_data/ram1.gff3) | GFF3 / GFF2 / GTF | Column 9 attributes (defined as "tag=value" or "tag "):
  • Each feature must have an `ID` or `gene_id` or `Parent` tag (referred to as `ID` henceforth).
  • Feature classes can be defined with the `Class` tag. If undefined, the default value \__UNKNOWN_\_ will be used.
  • Discontinuous features must be defined with the `Parent` tag whose value is the logical parent's `ID`, or by sharing the same `ID`.
  • Attribute values containing commas must represent lists.
  • `Parent` tags with multiple values are not yet supported.
  • See the example link (left) for col. 9 formatting.
| +| Sequencing data
[(example)](START_HERE/fastq_files) | FASTQ(.gz) | Files must be demultiplexed. | +| Reference genome
[(example)](START_HERE/reference_data/ram1.fa) | FASTA | Chromosome identifiers (e.g. Chr1):
  • Must match your reference annotation file chromosome identifiers
  • Are case sensitive
| +| Bowtie indexes (optional) 1 | ebwt | Must be small indexes (.ebwtl indexes are not supported) |
1 Bowtie indexes can be created for you. See the [configuration file documentation](doc/Configuration.md#building-bowtie-indexes). From 2bf00aacc75e3eca9c6ddc8cc1616f21909e2e53 Mon Sep 17 00:00:00 2001 From: Alex Tate <0xalextate@gmail.com> Date: Fri, 14 Oct 2022 14:14:43 -0700 Subject: [PATCH 20/23] Unrelated minor changes: correcting user facing error messages that refer to tiny-collapse as Collapser --- tests/unit_tests_collapser.py | 6 +++--- tiny/rna/collapser.py | 4 ++-- tiny/rna/counter/statistics.py | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/unit_tests_collapser.py b/tests/unit_tests_collapser.py index 3c8045ce..41d032e2 100644 --- a/tests/unit_tests_collapser.py +++ b/tests/unit_tests_collapser.py @@ -35,9 +35,9 @@ def setUpClass(self): self.output = {"file": { "out": {"exists": "mockPrefixExists_collapsed.fa", "dne": "mockPrefixDNE_collapsed.fa"}, "low": {"exists": "mockPrefixExists_collapsed_lowcounts.fa", "dne": "mockPrefixDNE_collapsed_lowcounts.fa"}}} - self.output["msg"] = {k: "Collapser critical error: "+v['exists']+" already exists.\n" + self.output["msg"] = {k: "tiny-collapse critical error: "+v['exists']+" already exists.\n" for k,v in self.output['file'].items()} - self.prefix_required_msg = "Collapser critical error: an output file must be specified.\n" + self.prefix_required_msg = "tiny-collapse critical error: an output file must be specified.\n" # Min-length fastq/fasta (single record) self.min_seq = "GTTTTGTTGGGCTTTCGCGAAGATCGGAAGAGCACACGTCTGAACTCCAGTCACATCACGATCTCGTATGCCGTCT" @@ -351,7 +351,7 @@ def test_collapser_command(self): with ShellCapture(f'tiny-collapse -i /dev/null -o {prefix}') as test: test() self.assertEqual(test.get_stdout(), '') - self.assertIn(f"Collapser critical error: {expected_out_file} already exists.\n", test.get_stderr()) + self.assertIn(f"tiny-collapse critical error: {expected_out_file} already exists.\n", test.get_stderr()) # (Very) roughly tests that the output file of the last test (same prefix) was not modified by this call self.assertEqual(test_collapsed_fa_size, os.path.getsize(expected_out_file)) finally: diff --git a/tiny/rna/collapser.py b/tiny/rna/collapser.py index aff1eb32..4e3504ff 100644 --- a/tiny/rna/collapser.py +++ b/tiny/rna/collapser.py @@ -159,7 +159,7 @@ def seq2fasta(seqs: dict, out_prefix: str, thresh: int = 0, gz: bool = False) -> Returns: None """ - assert out_prefix is not None, "Collapser critical error: an output file prefix must be specified." + assert out_prefix is not None, "tiny-collapse critical error: an output file prefix must be specified." assert thresh >= 0, "An invalid threshold was specified." writer, encoder, mode = fasta_interface(gz) @@ -189,7 +189,7 @@ def look_before_you_leap(out_prefix: str, gz: bool) -> (str, str): candidates = tuple(f"{out_prefix}{file}{ext}" for file in ["_collapsed", "_collapsed_lowcounts"]) for file in candidates: if os.path.isfile(file): - raise FileExistsError(f"Collapser critical error: {file} already exists.") + raise FileExistsError(f"tiny-collapse critical error: {file} already exists.") return candidates diff --git a/tiny/rna/counter/statistics.py b/tiny/rna/counter/statistics.py index 70c9a7e8..8d625564 100644 --- a/tiny/rna/counter/statistics.py +++ b/tiny/rna/counter/statistics.py @@ -375,7 +375,7 @@ def write_output_logfile(self): if len(self.missing_collapser_outputs): missing = '\n\t'.join(self.missing_collapser_outputs) self.add_warning("The Unique Sequences stat could not be determined for the following libraries because " - "their Collapser outputs were not found in the working directory:\n\t" + missing) + "their tiny-collapse outputs were not found in the working directory:\n\t" + missing) # Only display conditional categories if they were collected for at least one library empty_rows = self.pipeline_stats_df.loc[self.conditional_categories].isna().all(axis='columns') From 73ed931a880a332eba7c63078b2530a45ad230b9 Mon Sep 17 00:00:00 2001 From: Alex Tate <0xalextate@gmail.com> Date: Fri, 14 Oct 2022 18:52:24 -0700 Subject: [PATCH 21/23] Added a test that downloads complete GFF/GTF genomes from Ensemble for C. elegans and Arabidopsis, then runs all four files through ReferenceTables.get(). Genomes need only be downloaded once. Nevertheless, this is a long-running test so I've set it for manual activation only --- tests/unit_tests_hts_parsing.py | 54 +++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/tests/unit_tests_hts_parsing.py b/tests/unit_tests_hts_parsing.py index daa2b358..fe741193 100644 --- a/tests/unit_tests_hts_parsing.py +++ b/tests/unit_tests_hts_parsing.py @@ -732,5 +732,59 @@ def test_CaseInsensitiveAttrs_contains_ident_wildcard(self): self.assertFalse(cia.contains_ident(("attrkey4", Wildcard()))) self.assertFalse(cia.contains_ident((Wildcard(), "attrval7"))) + +@unittest.skip("Long-running test, execute manually") +class GenomeParsingTests(unittest.TestCase): + """Runs full-scale, unmodified GFF3/GTF genomes for select species through the ReferenceTables class""" + + @classmethod + def setUpClass(self): + import requests + release = "54" + baseurl = "http://ftp.ensemblgenomes.org/pub/release-" + self.data_dir = "./testdata/local_only/gff/" + if not os.path.exists(self.data_dir): os.makedirs(self.data_dir) + + self.urls = { + 'Arabidopsis': + baseurl + '%s/plants/{}/arabidopsis_thaliana/Arabidopsis_thaliana.TAIR10.%s.{}.gz' % (release, release), + 'C. elegans': + baseurl + '%s/metazoa/{}/caenorhabditis_elegans/Caenorhabditis_elegans.WBcel235.%s.{}.gz' % (release, release) + } + + self.genomes = { + species: os.path.join(self.data_dir, os.path.basename(url)) + for species, url in self.urls.items() + } + + # Download genome files if they aren't already in the data dir + for ftype in ('gff3', 'gtf'): + for species, file_template in self.genomes.items(): + file = file_template.format(ftype) + if os.path.isfile(file): continue + + print(f"Downloading {ftype} genome for {species} from Ensembl...") + url = self.urls[species].format(ftype, ftype) + with requests.get(url, stream=True) as r: + r.raise_for_status() + with open(file, 'wb') as f: + for chunk in r.iter_content(chunk_size=16384): + f.write(chunk) + + """Does ReferenceTables.get() process all genomes without throwing any errors?""" + def test_gff_megazord(self): + print("Running GFF Megazord test. This will take a long time...") + + # Single rule with all wildcard selectors, but only Identity is actually relevant within ReferenceTables + rules = [{'Identity': ('', ''), 'Tag': '', 'Hierarchy': 0, 'Overlap': 'partial', 'Strand': '', 'nt5end': '', 'Length': ''}] + files = {gff.format(ftype): [] for gff in self.genomes.values() for ftype in ('gff3', 'gtf')} + + fs = FeatureSelector(rules, LibraryStats()) + rt = ReferenceTables(files, fs) + + # The test is passed if this command + # completes without throwing errors. + rt.get() + if __name__ == '__main__': unittest.main() From 7a927dec5dc02162817dc5bd646833681da14c4a Mon Sep 17 00:00:00 2001 From: Alex Tate <0xalextate@gmail.com> Date: Sat, 15 Oct 2022 11:05:16 -0700 Subject: [PATCH 22/23] Outputs of tiny-plot are now organized into subdirectories. Directories are created only for the plots requested, and only once a plot is complete and ready to be saved --- tiny/rna/plotter.py | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/tiny/rna/plotter.py b/tiny/rna/plotter.py index 7ab2ca14..e31e8310 100644 --- a/tiny/rna/plotter.py +++ b/tiny/rna/plotter.py @@ -120,7 +120,7 @@ def len_dist_plots(matrices: dict, out_prefix:str, vmin: int = None, vmax: int = # Save plot pdf_name = make_filename([out_prefix, condition_and_rep, "len_dist"], ext='.pdf') - plot.figure.savefig(pdf_name) + save_plot(plot, "len_dist", pdf_name) def get_len_dist_dict(files_list: list) -> DefaultDict[str, Dict[str, pd.DataFrame]]: @@ -175,7 +175,7 @@ def class_charts(raw_class_counts: pd.DataFrame, mapped_reads: pd.Series, out_pr # Save the plot pdf_name = make_filename([out_prefix, library, 'class_chart'], ext='.pdf') - chart.figure.savefig(pdf_name) + save_plot(chart, "class_chart", pdf_name) def rule_charts(rule_counts: pd.DataFrame, out_prefix: str, scale=2, **kwargs): @@ -203,7 +203,7 @@ def rule_charts(rule_counts: pd.DataFrame, out_prefix: str, scale=2, **kwargs): # Save the plot pdf_name = make_filename([out_prefix, library, 'rule_chart'], ext='.pdf') - chart.figure.savefig(pdf_name) + save_plot(chart, "rule_chart", pdf_name) def get_proportions_df(counts_df: pd.DataFrame, mapped_totals: pd.Series, un: str, scale=2): @@ -299,7 +299,7 @@ def scatter_replicates(count_df: pd.DataFrame, samples: dict, output_prefix: str rscat.set_xlabel("Log$_{2}$ normalized reads in replicate " + rep1) rscat.set_ylabel("Log$_{2}$ normalized reads in replicate " + rep2) pdf_name = make_filename([output_prefix, samp, 'replicates', rep1, rep2, 'scatter'], ext='.pdf') - rscat.figure.savefig(pdf_name) + save_plot(rscat, "replicate_scatter", pdf_name) def load_dge_tables(comparisons: list) -> pd.DataFrame: @@ -376,7 +376,7 @@ def scatter_dges(count_df, dges, output_prefix, view_lims, classes=None, show_un sscat.set_ylabel("Log$_{2}$ normalized reads in " + p2) sscat.get_legend().set_bbox_to_anchor((1, 1)) pdf_name = make_filename([output_prefix, pair, 'scatter_by_dge_class'], ext='.pdf') - sscat.figure.savefig(pdf_name) + save_plot(sscat, "scatter_by_dge_class", pdf_name) else: for pair in dges: @@ -392,7 +392,7 @@ def scatter_dges(count_df, dges, output_prefix, view_lims, classes=None, show_un sscat.set_xlabel("Log$_{2}$ normalized reads in " + p1) sscat.set_ylabel("Log$_{2}$ normalized reads in " + p2) pdf_name = make_filename([output_prefix, pair, 'scatter_by_dge'], ext='.pdf') - sscat.figure.savefig(pdf_name) + save_plot(sscat, 'scatter_by_dge', pdf_name) def load_raw_counts(raw_counts_file: str) -> pd.DataFrame: @@ -527,6 +527,21 @@ def get_class_counts(raw_counts_df: pd.DataFrame) -> pd.DataFrame: return class_counts +def save_plot(plot, dir_name, filename): + """Complete plots are sent here for output patterns that apply to all plots + Args: + - plot: the finished axes object + - dir_name: all plots are placed in subdirectories by this name + - filename: the file basename for the plot, including extension + """ + + if not os.path.isdir(dir_name): + os.mkdir(dir_name) + + final_save_path = os.path.join(dir_name, filename) + plot.figure.savefig(final_save_path) + + def validate_inputs(args: argparse.Namespace) -> None: """Determines if the necessary input files have been provided for the requested plots This is necessary because we allow users to run the tool with only the files necessary From 05fdfdcd3f49653167f63d62bd9e12ec703bf0d5 Mon Sep 17 00:00:00 2001 From: Alex Tate <0xalextate@gmail.com> Date: Sat, 15 Oct 2022 11:10:25 -0700 Subject: [PATCH 23/23] CWL update for tiny-plot subdirectory outputs. PCA plots are placed in the root level of the tiny-plot output directory rather than their own subdirectory. This is because there will always be just one PCA plot, whereas all other plot types will have multiple outputs with a sufficient number of groups and replicates. These changes also open up opportunities for downstream steps to further process tiny-plot outputs --- tiny/cwl/tools/tiny-plot.cwl | 32 ++++++++++++++++++++++++++++--- tiny/cwl/workflows/tinyrna_wf.cwl | 13 +++++++++++-- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/tiny/cwl/tools/tiny-plot.cwl b/tiny/cwl/tools/tiny-plot.cwl index b4490b77..732f7579 100644 --- a/tiny/cwl/tools/tiny-plot.cwl +++ b/tiny/cwl/tools/tiny-plot.cwl @@ -87,10 +87,36 @@ inputs: doc: "A list of desired plot types to produce" outputs: - plots: - type: File[]? + + len_dist: + type: Directory? + outputBinding: + glob: len_dist + + rule_chart: + type: Directory? + outputBinding: + glob: rule_chart + + class_chart: + type: Directory? + outputBinding: + glob: class_chart + + replicate_scatter: + type: Directory? + outputBinding: + glob: replicate_scatter + + sample_avg_scatter_by_dge: + type: Directory? + outputBinding: + glob: scatter_by_dge + + sample_avg_scatter_by_dge_class: + type: Directory? outputBinding: - glob: "*.pdf" + glob: scatter_by_dge_class console_output: type: stdout \ No newline at end of file diff --git a/tiny/cwl/workflows/tinyrna_wf.cwl b/tiny/cwl/workflows/tinyrna_wf.cwl index c1f67ada..06fb79b1 100644 --- a/tiny/cwl/workflows/tinyrna_wf.cwl +++ b/tiny/cwl/workflows/tinyrna_wf.cwl @@ -261,7 +261,14 @@ steps: out_prefix: run_name plot_requests: plot_requests vector_scatter: plot_vector_points - out: [plots, console_output] + out: + - console_output + - len_dist + - rule_chart + - class_chart + - replicate_scatter + - sample_avg_scatter_by_dge + - sample_avg_scatter_by_dge_class organize_bt_indexes: run: ../tools/make-subdir.cwl @@ -325,7 +332,9 @@ steps: run: ../tools/make-subdir.cwl in: dir_files: - source: [ plotter/plots, plotter/console_output, dge/pca_plot ] + source: [plotter/len_dist, plotter/rule_chart, plotter/class_chart, plotter/replicate_scatter, + plotter/sample_avg_scatter_by_dge, plotter/sample_avg_scatter_by_dge_class, + dge/pca_plot, plotter/console_output] pickValue: all_non_null dir_name: dir_name_plotter out: [ subdir ]