"""Sequence-alignment logic backing the Streamlit app.
Provides :class:`SequenceAlignment`, which computes and formats kinase sequence
alignments for display in the Streamlit app.
"""
import logging
from typing import Any
from mkt.databases.klifs import DICT_POCKET_KLIFS_REGIONS
from mkt.databases.utils import load_kinase_object
from mkt.schema.kinase_schema import KinaseInfo
from mkt.schema.utils import rgetattr
from pydantic.dataclasses import dataclass
logger = logging.getLogger(__name__)
DICT_ALIGNMENT = {
"UniProt": {
"seq": "uniprot.canonical_seq",
"start": 1,
"end": lambda x: len(x),
},
"Pfam": {
"seq": None,
"start": "pfam.start",
"end": "pfam.end",
},
"KinCore, FASTA": {
"seq": "kincore.fasta.seq",
"start": "kincore.fasta.start",
"end": "kincore.fasta.end",
},
"KinCore, CIF": {
"seq": "kincore.cif.cif", # need to get from dict "_entity_poly.pdbx_seq_one_letter_code"
"start": "kincore.cif.start",
"end": "kincore.cif.end",
},
"Phosphosites": {
"seq": "uniprot.phospho_sites",
"start": None,
"end": None,
},
"KLIFS": {
"seq": "KLIFS2UniProtIdx",
"start": lambda x: min([x for x in x.values() if x is not None]),
"end": lambda x: max([x for x in x.values() if x is not None]),
},
}
[docs]
@dataclass
class SequenceAlignment:
"""Class to generate sequence alignments for kinase sequences."""
str_kinase: str
"""Gene name of the kinase for which to generate the sequence alignment."""
dict_color: dict[str, str]
"""Color dictionary for sequence viewer."""
bool_mismatch: bool = True
"""If True, show mismatches with UniProt seq in crimson, by default True."""
bool_klifs: bool = True
"""If True, shade KLIFS pocket residues using KLIFS pocket colors, by default True."""
bool_reverse: bool = True
"""Whether or not to reverse order of inputs"""
obj_kinase: KinaseInfo | None = None
"""KinaseInfo object from which to extract sequences (loaded from str_kinase if not provided)."""
def __post_init__(self):
if self.obj_kinase is None:
self.obj_kinase = load_kinase_object(self.str_kinase)
# generate and save alignments
self.dict_align = self.generate_alignments()
self.list_sequences = [v["str_seq"] for v in self.dict_align.values()]
self.list_ids = list(self.dict_align.keys())
self.list_colors = [v["list_colors"] for v in self.dict_align.values()]
# reverse alignment entries, if desired
if self.bool_reverse:
self.list_sequences = self.list_sequences[::-1]
self.list_ids = self.list_ids[::-1]
self.list_colors = self.list_colors[::-1]
[docs]
@staticmethod
def _map_single_alignment(
idx_start: int,
idx_end: int,
str_uniprot: str,
seq_obj: str | None = None,
):
"""Map the indices of the alignment to the original sequence.
Parameters
----------
idx_start : int
Start index of the alignment.
idx_end : int
End index of the alignment.
str_uniprot : str
Full canonical UniProt sequence.
seq_obj : str | None
Seq obj provided by user or database. If None, use UniProt sequence.
This is the case for Pfam, which only provides start and end indices.
Returns
-------
str
Output string with the alignment mapped to the original sequence.
"""
# if either idx is None, return string of dashes length of UniProt
if any([i is None for i in [idx_start, idx_end]]):
str_out = "-" * len(str_uniprot)
# UniProt phosphosites
if isinstance(seq_obj, list):
str_out = "".join(
[
str_uniprot[idx] if idx + 1 in seq_obj else i
for idx, i in enumerate(str_out)
]
)
return str_out
n_before, n_after = idx_start - 1, len(str_uniprot) - idx_end
# KLIFS
if isinstance(seq_obj, dict):
list_seq = [
str_uniprot[i - 1] if i in seq_obj.values() else "-"
for i in range(idx_start, idx_end + 1)
]
str_out = "-" * n_before + "".join(list_seq) + "-" * n_after
# Pfam just provides start and end indices - extract from UniProt
elif seq_obj is None:
str_out = "".join(
[
str_uniprot[i - 1] if i in range(idx_start, idx_end + 1) else "-"
for i in range(1, len(str_uniprot) + 1)
]
)
# all others provide sequences as a contiguous string
else:
str_out = "-" * n_before + seq_obj + "-" * n_after
return str_out
[docs]
def _parse_start_end_values(self, start_or_end: Any, str_seq: str) -> int | None:
"""Parse the start and end keys for the alignment.
Parameters
----------
start_or_end : Any
The start or end key to parse.
str_seq : str
The sequence to parse - only used if start_or_end callable.
Returns
-------
int | None
The parsed start or end index, or None if not found.
"""
try:
if isinstance(start_or_end, str):
output = rgetattr(self.obj_kinase, start_or_end)
elif isinstance(start_or_end, int):
output = start_or_end
elif callable(start_or_end):
output = start_or_end(str_seq)
else:
logger.info(
f"Start or end value {start_or_end} "
"is not a string, int, or callable "
"and cannot be parsed. Returning None..."
)
output = None
except Exception as e:
logger.error(f"Error parsing start/end values: {e}. Returning None...")
output = None
return output
[docs]
def generate_alignments(
self,
) -> dict[str, str | list[str]]:
"""Iterate through the KinaseInfo object and generate a list of sequences and a list of colors.
Returns
-------
dict[str, str | list[str]]
A dictionary with keys DICT_ALIGNMENT containing the
sequences and a list of colors per residue.
"""
uniprot_seq = self.obj_kinase.uniprot.canonical_seq
dict_out = {
k: dict.fromkeys(["str_seq", "list_colors"]) for k in DICT_ALIGNMENT.keys()
}
for key, value in DICT_ALIGNMENT.items():
seq = rgetattr(self.obj_kinase, value["seq"])
# KinCore CIF sequence needs to be extracted from dict and have linebreaks removed
if key == "KinCore, CIF" and seq is not None:
seq = seq["_entity_poly.pdbx_seq_one_letter_code"][0].replace("\n", "")
# CDKL1 KinCore FASTA and CIF have an extra M at the start - remove and add back
if self.obj_kinase.hgnc_name == "CDKL1" and key.startswith("KinCore"):
seq = seq[1:]
start = self._parse_start_end_values(value["start"], seq)
end = self._parse_start_end_values(value["end"], seq)
seq_out = self._map_single_alignment(start, end, uniprot_seq, seq)
# CDKL1 KinCore FASTA and CIF have an extra M at the start
# add back and add "-" for all other sequences
if self.obj_kinase.hgnc_name == "CDKL1":
if key.startswith("KinCore"):
seq_out = "M" + seq_out
else:
seq_out = "-" + seq_out
dict_out[key]["str_seq"] = seq_out
if key == "KLIFS" and seq is not None and self.bool_klifs:
seq_rev = {v: k for k, v in seq.items()}
list_cols = [
(
DICT_POCKET_KLIFS_REGIONS[seq_rev[i].split(":")[0]]["color"]
if i in seq_rev
else self.dict_color["-"]
)
for i in range(start, end + 1)
]
n_before, n_after = start - 1, len(uniprot_seq) - end
list_cols = (
[self.dict_color["-"]]
* (n_before + 1 * (self.obj_kinase.hgnc_name == "CDKL1"))
+ list_cols
+ [self.dict_color["-"]] * n_after
)
else:
list_cols = [self.dict_color[i] for i in seq_out]
dict_out[key]["list_colors"] = list_cols
if self.obj_kinase.hgnc_name == "CDKL1":
uniprot_seq = "-" + uniprot_seq
if self.bool_mismatch:
for key, value in dict_out.items():
for idx, (ref_seq, map_seq) in enumerate(
zip(uniprot_seq, value["str_seq"])
):
if ref_seq != map_seq and map_seq != "-":
# Claude proposed crimson
value["list_colors"][idx] = "#DC143C"
return dict_out