# Copyright 2015-2015 by Eric Rasche. All rights reserved. # # This file is part of the Biopython distribution and governed by your # choice of the "Biopython License Agreement" or the "BSD 3-Clause License". # Please see the LICENSE file that should have been included as part of this # package. """Bio.Align support for "xmfa" output from Mauve/ProgressiveMauve. You are expected to use this module via the Bio.Align functions. """ from Bio.Align import Alignment from Bio.Align import interfaces from Bio.Seq import Seq, reverse_complement from Bio.SeqRecord import SeqRecord class AlignmentWriter(interfaces.AlignmentWriter): """Mauve xmfa alignment writer.""" fmt = "Mauve" def __init__(self, target, metadata=None, identifiers=None): """Create an AlignmentWriter object. Arguments: - target - output stream or file name - metadata - metadata to be included in the output. If metadata is None, then the alignments object to be written must have an attribute `metadata`. - identifiers - list of the IDs of the sequences included in the alignment. Sequences will be numbered according to their index in this list. If identifiers is None, then the alignments object to be written must have an attribute `identifiers`. """ super().__init__(target) self._metadata = metadata self._identifiers = identifiers def write_header(self, alignments): """Write the file header to the output file.""" stream = self.stream metadata = self._metadata format_version = metadata.get("FormatVersion", "Mauve1") line = f"#FormatVersion {format_version}\n" stream.write(line) identifiers = self._identifiers filename = metadata.get("File") if filename is None: # sequences came from separate files for index, filename in enumerate(identifiers): number = index + 1 line = f"#Sequence{number}File\t{filename}\n" stream.write(line) line = f"#Sequence{number}Format\tFastA\n" stream.write(line) else: # sequences came from one combined file for number, identifier in enumerate(identifiers): assert number == int(identifier) number += 1 line = f"#Sequence{number}File\t{filename}\n" stream.write(line) line = f"#Sequence{number}Entry\t{number}\n" stream.write(line) line = f"#Sequence{number}Format\tFastA\n" stream.write(line) backbone_file = metadata.get("BackboneFile") if backbone_file is not None: line = f"#BackboneFile\t{backbone_file}\n" stream.write(line) def write_file(self, alignments): """Write a file with the alignments, and return the number of alignments. alignments - A Bio.Align.mauve.AlignmentIterator object. """ metadata = self._metadata if metadata is None: try: metadata = alignments.metadata except AttributeError: raise ValueError("alignments do not have an attribute `metadata`") else: self._metadata = metadata identifiers = self._identifiers if identifiers is None: try: identifiers = alignments.identifiers except AttributeError: raise ValueError("alignments do not have an attribute `identifiers`") else: self._identifiers = identifiers count = interfaces.AlignmentWriter.write_file(self, alignments) return count def format_alignment(self, alignment): """Return a string with a single alignment in the Mauve format.""" metadata = self._metadata n, m = alignment.shape if n == 0: raise ValueError("Must have at least one sequence") if m == 0: raise ValueError("Non-empty sequences are required") filename = metadata.get("File") lines = [] for i in range(n): identifier = alignment.sequences[i].id start = alignment.coordinates[i, 0] end = alignment.coordinates[i, -1] if start <= end: strand = "+" else: strand = "-" start, end = end, start if start == end: assert start == 0 else: start += 1 # switch to 1-based counting sequence = alignment[i] if filename is None: number = ( self._identifiers.index(identifier) + 1 ) # Switch to 1-based counting line = f"> {number}:{start}-{end} {strand} {identifier}\n" else: number = int(identifier) + 1 # Switch to 1-based counting line = f"> {number}:{start}-{end} {strand} {filename}\n" lines.append(line) line = f"{sequence}\n" lines.append(line) lines.append("=\n") return "".join(lines) class AlignmentIterator(interfaces.AlignmentIterator): """Mauve xmfa alignment iterator.""" fmt = "Mauve" def _read_header(self, stream): metadata = {} prefix = "Sequence" suffixes = ("File", "Entry", "Format") id_info = {} for suffix in suffixes: id_info[suffix] = [] for line in stream: if not line.startswith("#"): self._line = line.strip() break key, value = line[1:].split() if key.startswith(prefix): for suffix in suffixes: if key.endswith(suffix): break else: raise ValueError("Unexpected keyword '%s'" % key) if suffix == "Entry": value = int(value) - 1 # Switch to 0-based counting seq_num = int(key[len(prefix) : -len(suffix)]) id_info[suffix].append(value) assert seq_num == len(id_info[suffix]) # Mauve uses 1-based counting else: metadata[key] = value.strip() else: if not metadata: raise ValueError("Empty file.") from None if len(set(id_info["File"])) == 1: # A single file containing all sequences was provided as input; # store the file name once, and use the entry number as ID metadata["File"] = id_info["File"][0] self.identifiers = [str(entry) for entry in id_info["Entry"]] else: assert len(set(id_info["File"])) == len(id_info["File"]) # Separate files for each of the sequences were provided as input; # use the sequence file as ID self.identifiers = id_info["File"] self.metadata = metadata def _parse_description(self, line): assert line.startswith(">") locus, strand, comments = line[1:].split(None, 2) seq_num, start_end = locus.split(":") seq_num = int(seq_num) - 1 # python counting identifier = self.identifiers[seq_num] assert strand in "+-" start, end = start_end.split("-") start = int(start) end = int(end) if start == 0: assert end == 0 # unaligned sequence else: start -= 1 # python counting return (identifier, start, end, strand, comments) def _read_next_alignment(self, stream): descriptions = [] seqs = [] try: line = self._line except AttributeError: pass else: del self._line description = self._parse_description(line) identifier, start, end, strand, comments = description descriptions.append(description) seqs.append("") for line in stream: line = line.strip() if line.startswith("="): # There may be more data, but we've reached the end of this # alignment coordinates = Alignment.infer_coordinates(seqs) records = [] for index, (description, seq) in enumerate(zip(descriptions, seqs)): identifier, start, end, strand, comments = description seq = seq.replace("-", "") assert len(seq) == end - start if strand == "+": pass elif strand == "-": seq = reverse_complement(seq, inplace=False) coordinates[index, :] = len(seq) - coordinates[index, :] else: raise ValueError("Unexpected strand '%s'" % strand) coordinates[index] += start if start == 0: seq = Seq(seq) else: seq = Seq({start: seq}, length=end) record = SeqRecord(seq, id=identifier, description=comments) records.append(record) return Alignment(records, coordinates) elif line.startswith(">"): description = self._parse_description(line) identifier, start, end, strand, comments = description descriptions.append(description) seqs.append("") else: seqs[-1] += line