11"""Data structures for multiple sequence alignments."""
22
3- from typing import Iterable , List , Mapping , Tuple , Optional , Union
3+ from collections . abc import Iterable , Mapping
44from copy import deepcopy
5- from dataclasses import asdict , dataclass , field , fields
5+ from dataclasses import asdict , dataclass , field , fields , replace
66from io import TextIOWrapper
77from itertools import dropwhile , product
88import sys
@@ -34,29 +34,40 @@ class MSAName:
3434 >>> MSAName('>sp|P07807|DYR_YEAST').entry_name
3535 'DYR_YEAST'
3636 >>> MSAName('>UniRef90_A0A1B2').unique_id
37- '__NO_ENTRY_ID__'
37+ 'A0A1B2'
38+ >>> MSAName('>UniRef90_A0A1B2').database
39+ 'UniRef90'
40+ >>> MSAName('>MGYP000745883360').unique_id
41+ 'MGYP000745883360'
3842
3943 """
4044 name : str
41- input_name : str = field (init = False )
4245 database : str = field (init = False )
4346 unique_id : str = field (init = False )
4447 entry_name : str = field (init = False )
4548
4649 def __post_init__ (self ):
4750 if not isinstance (self .name , str ):
4851 try :
49- self .input_name = "" .join (self .name )
52+ self ._input_name = "" .join (self .name )
5053 except TypeError :
5154 raise TypeError (f"MSA name `{ self .name } ` is type { type (self .name )} ." )
5255 else :
53- self .input_name = self .name
54- self .name = "" .join (dropwhile (lambda s : s == ">" , self .name )).rstrip () # Strip out leading ">"
55- try :
56- self .database , self .unique_id , self .entry_name = self .name .split ("|" )
57- except ValueError :
58- # print_err(self.name)
59- self .database , self .unique_id , self .entry_name = "__NO_NAME__" , "__NO_ENTRY_ID__" , "__NO_ENTRY_NAME__"
56+ self ._input_name = self .name
57+ self .name = self ._input_name .removeprefix (">" ).rstrip () # Strip out leading ">"
58+ db , _id , _name = None , None , None
59+ if "|" in self .name :
60+ parts = self .name .split ("|" , maxsplit = 2 )
61+ if len (parts ) == 3 :
62+ db , _id , _name = parts
63+ elif self .name .startswith ("UniRef" ):
64+ parts = self .name .split ("_" , maxsplit = 1 )
65+ db = parts [0 ]
66+ _id = parts [1 ] if len (parts ) == 2 else None
67+
68+ self .database = db or "__NO_DB_NAME__"
69+ self .unique_id = _id or self .name or "__NO_ENTRY_ID__"
70+ self .entry_name = _name or _id or self .name or "__NO_ENTRY_NAME__"
6071
6172 def __str__ (self ) -> str :
6273 return self .name
@@ -87,7 +98,7 @@ class MSADescription:
8798 description : str
8899 species_id : str = field (init = False )
89100 prefix : str = field (init = False )
90- info : Mapping [str , Union [ int , str ] ] = field (init = False )
101+ info : Mapping [str , int | str ] = field (init = False )
91102 _verbose : bool = False
92103
93104 def __post_init__ (self ):
@@ -107,25 +118,32 @@ def __post_init__(self):
107118 val = int (val )
108119 info [key ] = val
109120 self .info = info
110- if "OX" in self .info : # NCBI identifier. Doesn't exist for everything
111- species_id = f"NCBI:{ self .info ['OX' ]} "
112- elif "TaxID" in self .info :
113- species_id = f"NCBI:{ self .info ['TaxID' ]} "
121+ taxon_id = self .info .get ("OX" , self .info .get ("TaxID" )) or - 1
122+ if taxon_id > - 1 : # NCBI identifier. Doesn't exist for everything
123+ if isinstance (taxon_id , str ) and taxon_id .isdigit ():
124+ self .taxon_id = int (taxon_id )
125+ species_id = f"NCBI:{ taxon_id } "
114126 elif "OS" in self .info : # UniProt species name fallback
115127 species_id = f"Name:{ self .info ['OS' ]} "
116128 else :
117129 species_id = - 1
118130 if self .description != '__BLOCK_GAPS__' and self ._verbose :
119131 print_err (f"[WARN] MSA has no species info. Description string: { self .description .rstrip ()} " )
132+ self .taxon_id = taxon_id
120133 self .species_id = species_id
134+
121135 if species_id == - 1 :
122- self .generic_species_name = None
136+ self .generic_species_name = None
123137 else :
124- normed_name = _name_normalizer ([self .info .get ('OS' , '' )])
125- try :
126- self .generic_species_name = normed_name [0 ]
127- except IndexError :
128- self .generic_species_name = self .info ['OS' ]
138+ os_val = self .info .get ('OS' , '' )
139+ if os_val :
140+ normed_name = _name_normalizer ([os_val ])
141+ try :
142+ self .generic_species_name = normed_name [0 ]
143+ except IndexError :
144+ self .generic_species_name = os_val
145+ else :
146+ self .generic_species_name = None
129147
130148 def __str__ (self ) -> str :
131149 return self .description
@@ -160,7 +178,8 @@ class MSALine:
160178 gap_fraction : float = field (init = False )
161179
162180 def __post_init__ (self ):
163- self .sequence = '' .join (letter for letter in self .sequence if not letter .islower ()) # remove insertions(?)
181+ self ._input_sequence = self .sequence
182+ self .sequence = '' .join (letter for letter in self ._input_sequence if not letter .islower ()) # remove insertions(?)
164183 self .name = MSAName (self .name )
165184 self .unique_id = self .name .unique_id
166185 self .entry_name = self .name .entry_name
@@ -174,12 +193,13 @@ def __repr__(self) -> str:
174193 return f"MSALine(name='{ self .name } ', length={ len (self )} )"
175194
176195 def __str__ (self ) -> str :
177- return f">{ str (self .name )} { str (self .description )} \n { self .sequence } "
196+ return f">{ str (self .name )} { str (self .description )} \n { self ._input_sequence } "
178197
179198
180199class PairedMSALine (MSALine ):
181200
182201 def __post_init__ (self ):
202+ self ._input_sequence = self .sequence
183203 if not _PAIRED_SPACER in self .name :
184204 raise ValueError (f"Paired MSA must contain '{ _PAIRED_SPACER } ' separator in name: { self .name } " )
185205 self .name = tuple (MSAName (name ) for name in self .name .split (_PAIRED_SPACER ))
@@ -192,7 +212,7 @@ def __repr__(self) -> str:
192212 return "Paired " + super ().__repr__ ()
193213
194214 def __str__ (self ) -> str :
195- return f">{ _PAIRED_SPACER .join (map (str , self .name ))} { _PAIRED_SPACER .join (map (str , self .description ))} \n { self .sequence } "
215+ return f">{ _PAIRED_SPACER .join (map (str , self .name ))} { _PAIRED_SPACER .join (map (str , self .description ))} \n { self ._input_sequence } "
196216
197217
198218@dataclass
@@ -224,14 +244,24 @@ def __post_init__(self):
224244 for line in self .lines
225245 ]
226246
227- def sequences (self ) -> List [str ]:
247+ def sequences (self ) -> list [str ]:
228248 return [line .sequence for line in self .lines ]
229249
230- def gap_fraction (self ) -> List [float ]:
250+ def gap_fraction (self ) -> list [float ]:
231251 return [line .gap_fraction for line in self .lines ]
232252
253+ def truncate (self , n : int ) -> 'MSA' :
254+ return replace (self , lines = [
255+ MSALine (
256+ sequence = line .sequence [:n ],
257+ description = str (line .description ),
258+ name = str (line .name ),
259+ )
260+ for line in self .lines
261+ ])
262+
233263 @classmethod
234- def from_file (cls , file : Union [ str , TextIOWrapper ] ) -> 'MSA' :
264+ def from_file (cls , file : str | TextIOWrapper ) -> 'MSA' :
235265 from bioino import FastaCollection
236266 collection = list (FastaCollection .from_file (file ).sequences )
237267 # print(collection[0])
@@ -311,23 +341,43 @@ def write(self, file=sys.stdout) -> None:
311341 return None
312342
313343
344+ @dataclass
314345class PairedMSA (MSA ):
315-
316346 """Paired MSA object which can be used for co-evolutionary analyses.
317347 """
348+ chain_a_length : int
349+ chain_b_length : int = field (init = False )
318350
319- def __init__ (self ,
320- chain_a_length : int ,
321- * args , ** kwargs ):
322- super ().__init__ (* args , ** kwargs )
323- self .chain_a_length = chain_a_length
351+ def __post_init__ (self ):
352+ super ().__post_init__ ()
324353 self .chain_b_length = self .seq_length - self .chain_a_length
325354
355+ def split (
356+ self
357+ ):
358+ msa1 = MSA ([
359+ MSALine (
360+ sequence = line .sequence [:self .chain_a_length ],
361+ description = str (line .description [0 ]),
362+ name = str (line .name [0 ]),
363+ )
364+ for line in self .lines
365+ ])
366+ msa2 = MSA ([
367+ MSALine (
368+ sequence = line .sequence [self .chain_a_length :],
369+ description = str (line .description [1 ]),
370+ name = str (line .name [1 ]),
371+ )
372+ for line in self .lines
373+ ])
374+ return msa1 , msa2
375+
326376 @staticmethod
327377 def _check_ref_match (
328378 msa1 : MSA ,
329379 msa2 : MSA ,
330- interaction_map : Optional [ Mapping [str , Iterable [str ]]] = None ,
380+ interaction_map : Mapping [str , Iterable [str ]] | None = None ,
331381 name_attr : str = "species_id"
332382 ) -> None :
333383 """Validate that the reference sequences (first lines) of two MSAs
@@ -381,13 +431,13 @@ def _check_ref_match(
381431 @staticmethod
382432 def join_msa (
383433 msa1 : MSA ,
384- msa2 : Optional [ MSA ] = None ,
434+ msa2 : MSA | None = None ,
385435 blocked : bool = False ,
386- interaction_map : Optional [ Union [ str , Mapping [str , Iterable [str ]]]] = None ,
436+ interaction_map : str | Mapping [str , Iterable [str ]] | None = None ,
387437 strict_species_match : bool = False ,
388438 enforce_ref_match : bool = False ,
389439 name_attr : str = "species_id"
390- ) -> Tuple [ List [PairedMSALine ], int ]:
440+ ) -> tuple [ list [PairedMSALine ], int ]:
391441 if strict_species_match or interaction_map is None :
392442 fallback_name_attr = name_attr
393443 else :
@@ -530,7 +580,10 @@ def join_msa(
530580 )
531581 ] for lines in (msa1 .lines , msa2 .lines )
532582 )
533- msa1 , msa2 = (msa ._filter_by_index (idx ) for idx , msa in zip ((idx1 , idx2 ), (msa1 , msa2 )))
583+ msa1 , msa2 = (
584+ msa ._filter_by_index (idx )
585+ for idx , msa in zip ((idx1 , idx2 ), (msa1 , msa2 ))
586+ )
534587 msa_lines += PairedMSA .__make_blocked (msa1 , msa2 )
535588
536589 return msa_lines , msa1 .seq_length
@@ -540,7 +593,7 @@ def __make_blocked(
540593 msa1 : MSA ,
541594 msa2 : MSA ,
542595 gap_char : str = '-'
543- ) -> List [PairedMSALine ]:
596+ ) -> list [PairedMSALine ]:
544597 gaps1 , gaps2 = (gap_char * msa .seq_length for msa in (msa1 , msa2 ))
545598 # The msas must be str representations of the blocked+paired MSAs here
546599 block1 = [
@@ -563,9 +616,9 @@ def __make_blocked(
563616 def from_msa (
564617 cls ,
565618 msa1 : MSA ,
566- msa2 : Optional [ MSA ] = None ,
619+ msa2 : MSA | None = None ,
567620 blocked : bool = False ,
568- interaction_map : Optional [ Union [ str , Mapping [str , Iterable [str ]]]] = None ,
621+ interaction_map : str | Mapping [str , Iterable [str ]] | None = None ,
569622 strict_species_match : bool = False ,
570623 enforce_ref_match : bool = False ,
571624 ** kwargs
@@ -587,8 +640,8 @@ def from_msa(
587640 @classmethod
588641 def from_file (
589642 cls ,
590- file1 : Union [ str , TextIOWrapper ] ,
591- file2 : Optional [ Union [ str , TextIOWrapper ]] = None ,
643+ file1 : str | TextIOWrapper ,
644+ file2 : str | TextIOWrapper | None = None ,
592645 blocked : bool = False ,
593646 ** kwargs
594647 ) -> 'PairedMSA' :
0 commit comments