#!/usr/bin/env python """ MIT License Copyright (c) 2021 Michael Alonge Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ import os import abc class AGPError(Exception): """ Exception raised for AGP related errors. """ def __init__(self, fname, line_number, message="Error in AGP file."): self.fname = fname self.line_number = line_number self.message = message self.report = "" self.report += "\n\nFILE : {}\n".format(self.fname) self.report += "LINE : {}\n".format(self.line_number) self.report += "ERROR: {}\n".format(self.message) super().__init__(self.report) def __repr__(self): return "AGPError" class AGPFile: """ A class storing the contents of an AGP v2.1 file. https://www.ncbi.nlm.nih.gov/assembly/agp/AGP_Specification/ The class is able to read new AGP lines in order to sequentially build the complete file. The class should be capable of checking the validity of the file, as well as writing the AGP contents to a file stream. Common abbreviations: "comp": AGP component "obj": AGP object "pid": AGP part number """ def __init__(self, in_file, mode="r"): if mode != "r" and mode != "w": raise ValueError("AGPFile mode must be either read ('r') or write ('w').") self._agp_version = "2.1" self._fname = os.path.abspath(in_file) # Store comment and AGP lines separately. self._comment_lines = [] self._objects = [] # Store info enabling us to keep track of the state of the AGP file self._current_obj = None self._seen_objs = set() # Read the contents of the AGP file if mode == "r": self._read_file() def _read_file(self): """ Read the agp file associated with this instance of the class. If that file doesn't exist yet, proceed without reading anything. When reading, check the validity of individual AGP lines. """ if not os.path.isfile(self.fname): return # The AGP file exists. Initialize everything. self._comment_lines = [] self._objects = [] self._current_obj = None self._seen_objs = set() line_number = 0 in_body = False with open(self.fname, "r") as f: for line in f: line_number += 1 line = line.rstrip("\n") if line.startswith("#"): if not in_body: self._comment_lines.append(line) else: raise AGPError(self.fname, line_number, "illegal comment in AGP body") continue # In a valid AGP file, we should no longer see comment lines in_body = True fields = line.split("\t") # There should be exactly 9 tab delimited fields if not len(fields) == 9: raise AGPError(self.fname, line_number, "detected more than 9 tab delimited fields") # All fields should have a value if not all(fields): raise AGPError(self.fname, line_number, "detected an empty field") # Instantiate all the AGPLine objects. These will do line-specific validations. if fields[4] == "N" or fields[4] == "U": agp_line = AGPGapLine(self.fname, line_number, *fields) else: agp_line = AGPSeqLine(self.fname, line_number, *fields) self._add_line(agp_line) def _add_line(self, agp_line): # Perform validity checks if this is a new object if agp_line.obj != self._current_obj: # Check if we have already seen this object before if agp_line.obj in self._seen_objs: raise AGPError(self.fname, agp_line.line_number, "object identifier out of order") # Add the new object to our master list agp_obj = AGPObject(self.fname, agp_line) self._objects.append(agp_obj) # Initialize all the info for this new object self._current_obj = agp_obj.obj self._seen_objs.add(agp_obj.obj) else: self._objects[-1].add_line(agp_line) @property def agp_version(self): return self._agp_version @property def fname(self): return self._fname @property def num_lines(self): """ Calculate the number of lines in the current state of the AGP file. """ return sum([len(self._comment_lines)] + [obj.num_lines for obj in self._objects]) @property def num_objs(self): return len(self._objects) def add_pragma(self): pragma = "## agp-version {}".format(self.agp_version) if self._comment_lines: new_comment_lines = [pragma] for i in self._comment_lines: if i != pragma: new_comment_lines.append(i) self._comment_lines = new_comment_lines else: self._comment_lines.append(pragma) def iterate_objs(self): """ Iterate over the objects of the AGP file. """ for obj in self._objects: yield obj def iterate_lines(self): """ Iterate over the non-comment lines of AGP file. """ for obj in self.iterate_objs(): for j in obj.iterate_lines(): yield j def add_comment(self, c): if not isinstance(c, str): raise TypeError("Comment must be a string") if not c.startswith("#"): raise ValueError("Comment must start with a '#' character") if c not in self._comment_lines: self._comment_lines.append(c) def add_seq_line(self, obj, obj_beg, obj_end, pid, comp_type, comp, comp_beg, comp_end, orientation): """ # TODO fill this out :param obj: :param obj_beg: :param obj_end: :param pid: :param comp_type: :param comp: :param comp_beg: :param comp_end: :param orientation: """ line_number = self.num_lines + 1 agp_line = AGPSeqLine(self.fname, line_number, obj, obj_beg, obj_end, pid, comp_type, comp, comp_beg, comp_end, orientation) self._add_line(agp_line) def add_gap_line(self, obj, obj_beg, obj_end, pid, comp_type, gap_len, gap_type, linkage, linkage_evidence): """ # TODO fill this out :param obj: :param obj_beg: :param obj_end: :param pid: :param comp_type: :param gap_len: :param gap_type: :param linkage: :param linkage_evidence: """ line_number = self.num_lines + 1 agp_line = AGPGapLine(self.fname, line_number, obj, obj_beg, obj_end, pid, comp_type, gap_len, gap_type, linkage, linkage_evidence) self._add_line(agp_line) def pop_agp_line(self): """ Remove the last AGP line and update state info accordingly. """ if not self._objects: return if self._objects[-1].num_lines == 1: self._seen_objs.remove(self._objects[-1].obj) self._objects = self._objects[:-1] else: self._objects[-1].pop_line() def write(self): """ Write the agp contents to a file. """ with open(self.fname, "w") as f: if self._comment_lines: f.write("\n".join(self._comment_lines) + "\n") if self._objects: f.write("\n".join([str(obj) for obj in self._objects]) + "\n") class AGPObject: """ This (python) object represents an AGP object. Objects will consist of AGP lines, and have to adhere to certain rules. By organizing AGP lines into the objects that they comprise, we can easily calculate stats about the assembly (the collection of objects). """ def __init__(self, agp_fname, in_agp_line): if not isinstance(in_agp_line, AGPLine): raise TypeError("in_agp_line must be an instance of 'AGPLine'") # The object is defined by the object identifier and a list of AGP lines self.fname = agp_fname self._obj = in_agp_line.obj self._agp_lines = [] # Store info enabling us to keep track of the state of the object self.previous_pid = 0 self.obj_intervals = [] # Stores intervals as 0-indexed # Perform checks to ensure the object is properly initialized if not in_agp_line.obj_beg == 1: raise AGPError(self.fname, in_agp_line.line_number, "the first object coordinates should start with '1'") if not in_agp_line.pid == 1: raise AGPError(self.fname, in_agp_line.line_number, "all objects should start with a 'part_number' of '1'") # If we have passed the initialization tests, add this line like any other self.add_line(in_agp_line) def __str__(self): return "\n".join([str(i) for i in self._agp_lines]) def __repr__(self): return "AGP Object: {}".format(self.obj) @property def obj(self): return self._obj @property def obj_len(self): return int(self.obj_intervals[-1][1]) @property def num_lines(self): return len(self._agp_lines) def add_line(self, agp_line): # Perform validity checks if this is a new object if agp_line.obj != self.obj: raise AGPError(self.fname, agp_line, "cannot add line from object {} to object {}".format(agp_line.obj, self.obj)) # Check that our PID is sequential if agp_line.pid - self.previous_pid != 1: raise AGPError(self.fname, agp_line.line_number, "non-sequential part_numbers") # Check that the object intervals are sequential if self.obj_intervals: if self.obj_intervals[-1][1] != agp_line.obj_beg - 1: raise AGPError(self.fname, agp_line.line_number, "some positions in %s are not accounted for or overlapping" % agp_line.obj) self.previous_pid = agp_line.pid self.obj_intervals.append((agp_line.obj_beg - 1, agp_line.obj_end)) self._agp_lines.append(agp_line) def iterate_lines(self): for i in self._agp_lines: yield i def pop_line(self): """ Pop a line from this object. """ if self.num_lines <= 1: raise RuntimeError("Can't pop any more lines from this AGP object.") self._agp_lines.pop() self.previous_pid = self._agp_lines[-1].pid self.obj_intervals = self.obj_intervals[:-1] class AGPLine: """ An abstract base class representing a single AGP file line. Inheriting subclasses should override or implement new methods to check the validity of a single AFP line. Validity checks that involve multiple lines should not be considered. """ __metaclass__ = abc.ABCMeta allowed_comp_types = set() def __init__(self, fname, line_number, obj, obj_beg, obj_end, pid, comp_type): self.is_gap = None # File info self.fname = fname self.line_number = line_number # Object info self.obj = obj self.obj_beg = obj_beg self.obj_end = obj_end self.pid = pid self.comp_type = comp_type self._validate_numerics() self._validate_strings() self._validate_obj_coords() self._validate_component_type() self._validate_line() @abc.abstractmethod def __str__(self): """ Return the tab delimited AGP line""" pass @abc.abstractmethod def _validate_numerics(self): """ Ensure all numeric fields and positive integers. """ pass @abc.abstractmethod def _validate_strings(self): """ Ensure all text fields are strings. """ pass def _validate_obj_coords(self): if self.obj_beg > self.obj_end: raise AGPError(self.fname, self.line_number, "object_beg (%d) must be <= object_end (%d)" % (self.obj_beg, self.obj_end)) def _validate_component_type(self): if self.comp_type not in self.allowed_comp_types: raise AGPError(self.fname, self.line_number, "invalid component type: %s" % self.comp_type) @abc.abstractmethod def _validate_line(self): """ Final remaining validations specific to the gap or sequence AGP lines. """ pass class AGPSeqLine(AGPLine): """ A subclass of AGPLine specifically for AGP lines that represent sequences. """ allowed_comp_types = {"A", "D", "F", "G", "O", "P", "W"} allowed_orientations = {"+", "-", "?", "0", "na"} def __init__(self, fname, line_number, obj, obj_beg, obj_end, pid, comp_type, comp, comp_beg, comp_end, orientation): self.comp = comp self.comp_beg = comp_beg self.comp_end = comp_end self.orientation = orientation # Set the object attributes and perform superclass-defined validations super(AGPSeqLine, self).__init__(fname, line_number, obj, obj_beg, obj_end, pid, comp_type) self.is_gap = False def __str__(self): return "\t".join([ self.obj, str(self.obj_beg), str(self.obj_end), str(self.pid), self.comp_type, self.comp, str(self.comp_beg), str(self.comp_end), self.orientation ]) def _validate_numerics(self): # Convert all numeric types to integers try: self.line_number = int(self.line_number) self.obj_beg = int(self.obj_beg) self.obj_end = int(self.obj_end) self.pid = int(self.pid) self.comp_beg = int(self.comp_beg) self.comp_end = int(self.comp_end) except TypeError: raise AGPError(self.fname, self.line_number, "encountered an invalid non-integer numeric AGP field") # Ensure that all numeric values are positive if not all([ self.obj_beg > 0, self.obj_end > 0, self.pid > 0, self.comp_beg > 0, self.comp_end > 0 ]): raise AGPError(self.fname, self.line_number, "encountered an invalid zero or negative numeric AGP field.") # Check the coordinates if self.comp_beg > self.comp_end: raise AGPError(self.fname, self.line_number, "component_beg (%d) must be <= component_end (%d)" % (self.comp_beg, self.comp_end)) if self.obj_end - (self.obj_beg-1) != self.comp_end - (self.comp_beg-1): raise AGPError(self.fname, self.line_number, "object coordinates (%d, %d) and component coordinates (%d, %d) do not have the same length" % (self.obj_beg, self.obj_end, self.comp_beg, self.comp_end)) def _validate_strings(self): try: self.obj = str(self.obj) self.comp_type = str(self.comp_type) self.comp = str(self.comp) self.orientation = str(self.orientation) except TypeError: raise AGPError(self.fname, self.line_number, "encountered an invalid type for an AGP text field") def _validate_line(self): if self.orientation not in AGPSeqLine.allowed_orientations: raise AGPError(self.fname, self.line_number, "invalid orientation: %s" % self.orientation) class AGPGapLine(AGPLine): """ A subclass of AGPLine specifically for AGP lines that represent sequence gaps. """ allowed_comp_types = {"N", "U"} allowed_linkage_types = {"yes", "no"} allowed_gap_types = { "scaffold", "contig", "centromere", "short_arm", "heterochromatin", "telomere", "repeat", "contamination" } allowed_evidence_types = { "na", "paired-ends", "align_genus", "align_xgenus", "align_trnscpt", "within_clone", "clone_contig", "map", "pcr", "proximity_ligation", "strobe", "unspecified" } def __init__(self, fname, line_number, obj, obj_beg, obj_end, pid, comp_type, gap_len, gap_type, linkage, linkage_evidence): self.gap_len = gap_len self.gap_type = gap_type self.linkage = linkage self.linkage_evidence = linkage_evidence # Set the object attributes and perform superclass-defined validations super(AGPGapLine, self).__init__(fname, line_number, obj, obj_beg, obj_end, pid, comp_type) self.is_gap = True def __str__(self): return "\t".join([ self.obj, str(self.obj_beg), str(self.obj_end), str(self.pid), self.comp_type, str(self.gap_len), self.gap_type, self.linkage, self.linkage_evidence ]) def _validate_numerics(self): # Convert all numeric types to integers try: self.line_number = int(self.line_number) self.obj_beg = int(self.obj_beg) self.obj_end = int(self.obj_end) self.pid = int(self.pid) self.gap_len = int(self.gap_len) except TypeError: raise AGPError(self.fname, self.line_number, "encountered an invalid non-integer numeric AGP field") # Ensure that all numeric values are positive if not all([ self.obj_beg > 0, self.obj_end > 0, self.pid > 0, self.gap_len > 0 ]): raise AGPError(self.fname, self.line_number, "encountered an invalid negative numeric AGP field") # Make sure the coordinates match if self.obj_end - (self.obj_beg-1) != self.gap_len: raise AGPError(self.fname, self.line_number, "object coordinates (%d, %d) and gap length (%d) are not the same length" % (self.obj_beg, self.obj_end, self.gap_len)) def _validate_strings(self): try: self.obj = str(self.obj) self.comp_type = str(self.comp_type) self.gap_type = str(self.gap_type) self.linkage = str(self.linkage) self.linkage_evidence = str(self.linkage_evidence) except TypeError: raise AGPError(self.fname, self.line_number, "encountered an invalid type for an AGP text field") def _validate_line(self): """ Validation specific to AGP gap lines. """ if self.comp_type == "U" and self.gap_len != 100: raise AGPError(self.fname, self.line_number, "invalid gap length for component type 'U': %d (should be 100)" % self.gap_len) if self.gap_type not in AGPGapLine.allowed_gap_types: raise AGPError(self.fname, self.line_number, "invalid gap type: %s" % self.gap_type) if self.linkage not in AGPGapLine.allowed_linkage_types: raise AGPError(self.fname, self.line_number, "invalid linkage field: %s" % self.linkage) all_evidence = self.linkage_evidence.split(";") for e in all_evidence: if e not in AGPGapLine.allowed_evidence_types: raise AGPError(self.fname, self.line_number, "invalid linkage evidence: %s" % e) if self.linkage == "no": if self.gap_type == "scaffold": raise AGPError(self.fname, self.line_number, "invalid 'scaffold' gap without linkage evidence") if self.linkage_evidence != "na": raise AGPError(self.fname, self.line_number, "linkage evidence must be 'na' when not asserting linkage. Got {}".format(self.linkage_evidence)) else: if "na" in all_evidence: raise AGPError(self.fname, self.line_number, "'na' is invalid linkage evidence when asserting linkage")