Source code for mkt.schema.kinase_schema

"""Core Pydantic models representing a kinase at the kinase-domain level.

Defines :class:`KinaseInfo` and its component models (:class:`UniProt`,
:class:`KinHub`, :class:`KLIFS`, :class:`Pfam`, :class:`KinCore`) along with the
:class:`Group`/:class:`Family` enumerations. :class:`KinaseInfo` exposes adjudication
helpers for kinase-domain sequence and group assignment.
"""

import logging
from enum import Enum

from mkt.schema.constants import LIST_FULL_KLIFS_REGION, LIST_KLIFS_REGION, LIST_PFAM_KD
from mkt.schema.utils import rgetattr
from pydantic import BaseModel, ConfigDict, Field, constr, field_validator
from strenum import StrEnum

logger = logging.getLogger(__name__)


[docs] class Group(StrEnum): """Enum class for kinase groups.""" AGC = "AGC" # Protein Kinase A, G, and C families Atypical = "Atypical" # Atypical protein kinases CAMK = "CAMK" # Calcium/calmodulin-dependent protein kinase family CK1 = "CK1" # Casein kinase 1 family CMGC = "CMGC" # Cyclin-dependent kinase, Mitogen-activated protein kinase, Glycogen synthase kinase, and CDK-like kinase families NEK = "NEK" # NIMA (Never in Mitosis Gene A)-related kinase family - KinCore treats as group RGC = "RGC" # Receptor guanylate cyclase family STE = "STE" # Homologs of yeast Sterile 7, Sterile 11, Sterile 20 kinases TK = "TK" # Tyrosine kinase family TKL = "TKL" # Tyrosine kinase-like family Other = "Other" # Other protein kinases
[docs] class Family(Enum): """Enum class for kinase families (>=5 in KinHub).""" STE20 = "STE20" CAMKL = "CAMKL" CDK = "CDK" Eph = "Eph" PIK = "PIK" MAPK = "MAPK" STKR = "STKR" NEK = "NEK" Src = "Src" DYRK = "DYRK" PKC = "PKC" STE11 = "STE11" RSK = "RSK" MLK = "MLK" GRK = "GRK" CK1 = "CK1" DMPK = "DMPK" STE7 = "STE7" PIKK = "PIKK" RSKb = "RSKb" Alpha = "Alpha" Tec = "Tec" CAMK1 = "CAMK1" PDGFR = "PDGFR" ULK = "ULK" DAPK = "DAPK" RAF = "RAF" RIPK = "RIPK" MLCK = "MLCK" PKA = "PKA" MAPKAPK = "MAPKAPK" RGC = "RGC" CDKL = "CDKL" MAST = "MAST" TSSK = "TSSK" ABC1 = "ABC1" PDHK = "PDHK" JakA = ("Jak", "JakA") JakB = ("Jakb", "JakB") PIPK = "PIPK" PLK = "PLK" Other = "Other" Null = None
KinaseDomainName = StrEnum( "KinaseDomainName", {"KD" + str(idx + 1): kd for idx, kd in enumerate(LIST_PFAM_KD)} ) SeqUniProt = constr(pattern=r"^[ACDEFGHIKLMNPQRSTVWXY]+$") """Pydantic model for UniProt sequence constraints.""" SeqKLIFS = constr(pattern=r"^[ACDEFGHIKLMNPQRSTVWY\-]{85}$") """Pydantic model for KLIFS pocket sequence constraints.""" SwissProtPattern = r"^[A-Z][0-9][A-Z0-9]{3}[0-9](_[12])?$" """Regex pattern for SwissProt ID.""" SwissProtID = constr(pattern=SwissProtPattern) """Pydantic model for SwissProt ID constraints.""" TrEMBLPattern = r"^[A-Z][0-9][A-Z][A-Z0-9]{2}[0-9][A-Z][A-Z0-9]{2}[0-9](_[12])?$" """Regex pattern for TrEBML ID.""" TrEMBLID = constr(pattern=TrEMBLPattern) """Pydantic model for TrEMBL ID constraints.""" SwissProtPatternSuffix = SwissProtPattern.replace("$", "") + r"(_[12])?$" """Regex pattern for SwissProt ID with optional '_1' or '_2' for multi-KD.""" SwissProtIDSuffix = constr(pattern=SwissProtPatternSuffix) """Pydantic model for SwissProt ID constraints with suffix.""" TrEMBLPatternSuffix = TrEMBLPattern.replace("$", "") + r"(_[12])?$" """Regex pattern for TrEBML ID with optional '_1' or '_2' for multi-KD.""" TrEMBLIDSuffix = constr(pattern=TrEMBLPatternSuffix) """Pydantic model for TrEMBL ID constraints with suffix."""
[docs] class TemplateSource(StrEnum): """Enum class for template source.""" PDB70 = "PDB70" activeAF2 = "activeAF2" activePDB = "activePDB" none = "notemp"
[docs] class MSASource(StrEnum): """Enum class for MSA source.""" family = "family" ortholog = "ortholog" uniref90 = "uniref90"
[docs] class KinHub(BaseModel): """Pydantic model for KinHub information.""" model_config = ConfigDict(use_enum_values=True) hgnc_name: str | None = None kinase_name: str | None = None manning_name: str xname: str group: Group family: Family
[docs] class UniProt(BaseModel): """Pydantic model for UniProt information.""" header: str canonical_seq: SeqUniProt phospho_sites: list[int] | None = None phospho_evidence: list[set[str]] | None = None phospho_description: list[str] | None = None
[docs] class KLIFS(BaseModel): """Pydantic model for KLIFS information.""" model_config = ConfigDict(use_enum_values=True) gene_name: str name: str full_name: str group: Group family: Family iuphar: int kinase_id: int pocket_seq: SeqKLIFS | None = None
[docs] class Pfam(BaseModel): """Pydantic model for Pfam information.""" model_config = ConfigDict(use_enum_values=True) domain_name: KinaseDomainName start: int end: int protein_length: int pfam_accession: str in_alphafold: bool
[docs] class KinCoreFASTA(BaseModel): """Pydantic model for KinCore FASTA information.""" model_config = ConfigDict(use_enum_values=True) seq: SeqUniProt group: Group hgnc: set[str] swissprot: str uniprot: SwissProtID | TrEMBLID start_md: int # Modi-Dunbrack, 2019 end_md: int # Modi-Dunbrack, 2019 length_md: int | None = None # Modi-Dunbrack, 2019 start_af2: int | None = None # AF2 active state end_af2: int | None = None # AF2 active state length_af2: int | None = None # AF2 active state length_uniprot: int | None = None # AF2 active state source_file: str start: int | None = None # fasta2uniprot end: int | None = None # fasta2uniprot mismatch: list[int] | None = None # fasta2uniprot
[docs] class KinCoreCIF(BaseModel): """Pydantic model for KinCore CIF information.""" model_config = ConfigDict(use_enum_values=True) cif: dict[str, str | list[str]] group: Group hgnc: str min_aloop_pLDDT: float template_source: TemplateSource msa_size: int msa_source: MSASource model_no: int = Field(..., ge=1, lt=6) start: int | None = None # cif2uniprot end: int | None = None # cif2uniprot mismatch: list[int] | None = None # cif2uniprot
[docs] class KinCore(BaseModel): """Pydantic model for KinCore information.""" fasta: KinCoreFASTA cif: KinCoreCIF | None = None start: int | None = None # fasta2cif end: int | None = None # fasta2cif mismatch: list[int] | None = None # fasta2cif
[docs] class KinaseInfoUniProt(BaseModel): """Pydantic model for kinase information at the level of the UniProt ID.""" hgnc_name: str uniprot_id: SwissProtID | TrEMBLID uniprot: UniProt pfam: Pfam | None = None
[docs] class KinaseInfoKinaseDomain(BaseModel): """Pydantic model for kinase information at the level of the kinase domain.""" uniprot_id: SwissProtIDSuffix | TrEMBLIDSuffix kinhub: KinHub | None = None klifs: KLIFS | None = None kincore: KinCore | None = None
[docs] class KinaseInfo(BaseModel): """Pydantic model for kinase information at the level of the kinase domain.""" hgnc_name: str uniprot_id: SwissProtIDSuffix | TrEMBLIDSuffix uniprot: UniProt kinhub: KinHub | None = None klifs: KLIFS | None = None pfam: Pfam | None = None kincore: KinCore | None = None KLIFS2UniProtIdx: dict[str, int | None] | None = None KLIFS2UniProtSeq: dict[str, str | None] | None = None
[docs] @field_validator("KLIFS2UniProtIdx", mode="before") @classmethod def validate_klifs2uniprotidx( cls, value: dict[str, int | None] | None, ) -> dict[str, int | None] | None: """Validate KLIFS2UniProtIdx dictionary to include all regions since TOML doesn't save None. Parameters ---------- value : dict[str, int | None] Dictionary mapping KLIFS residue to UniProt indices. Returns ------- dict[str, int | None] Dictionary mapping KLIFS residue to UniProt indices. """ dict_temp = dict.fromkeys(LIST_KLIFS_REGION, None) if value is not None: for key, val in value.items(): dict_temp[key] = val return dict_temp else: return None
[docs] @field_validator("KLIFS2UniProtSeq", mode="before") @classmethod def validate_klifs2uniprotseq( cls, value: dict[str, str | None] | None, ) -> dict[str, str | None] | None: """Validate KLIFS2UniProtSeq dictionary to include all regions since TOML doesn't save None. Parameters ---------- value : dict[str, str | None] Dictionary mapping KLIFS residue to UniProt residue. Returns ------- dict[str, str | None] Dictionary mapping KLIFS residue to UniProt residue. """ dict_temp = dict.fromkeys(LIST_FULL_KLIFS_REGION, None) if value is not None: for key, val in value.items(): dict_temp[key] = val return dict_temp else: return None
[docs] def extract_sequence_from_cif(self, bool_verbose: bool = False) -> str | None: """Extract sequence from CIF if available. Parameters ---------- bool_verbose : bool, optional Whether to log verbose messages, by default False. Returns ------- str | None The sequence from the CIF if available, otherwise None. """ key_seq = "_entity_poly.pdbx_seq_one_letter_code" try: if self.kincore is not None and self.kincore.cif is not None: return self.kincore.cif.cif[key_seq][0].replace("\n", "") else: if bool_verbose: logger.info(f"No CIF sequence for {self.hgnc_name}") return None except Exception as e: if bool_verbose: logger.info(f"No Kincore entry for {self.hgnc_name}: {e}") return None
[docs] def adjudicate_kd_sequence(self, bool_verbose: bool = False) -> str | None: """Adjudicate kinase domain sequence based on available data. Parameters ---------- bool_verbose : bool, optional Whether to log verbose messages, by default False. Returns ------- str | None The kinase domain sequence if available, otherwise None. """ if self.kincore is not None: seq = self.extract_sequence_from_cif(bool_verbose=bool_verbose) if seq is not None: return seq else: # all non-None entries will have fastas return self.kincore.fasta.seq elif self.pfam is not None: return self.uniprot.canonical_seq[self.pfam.start - 1 : self.pfam.end] else: if bool_verbose: logger.info(f"No kinase domain sequence found for {self.hgnc_name}") return None
[docs] def _klifs_uniprot_idx_bounds(self) -> tuple[int, int] | None: """Return the min and max non-None UniProt indices in KLIFS2UniProtIdx. Returns ------- tuple[int, int] | None The (min, max) UniProt indices spanned by the KLIFS pocket if any are available, otherwise None. """ if self.KLIFS2UniProtIdx is None: return None list_idx = [idx for idx in self.KLIFS2UniProtIdx.values() if idx is not None] if len(list_idx) == 0: return None return min(list_idx), max(list_idx)
[docs] def _reconcile_kd_bound_with_klifs( self, bound: int, klifs_bound: int, is_start: bool, int_max_gap: int, bool_verbose: bool, ) -> int | None: """Reconcile an adjudicated kinase domain bound with the KLIFS pocket. The KLIFS pocket should fall within the kinase domain, i.e. the minimum KLIFS index should be >= the kinase domain start and the maximum KLIFS index should be <= the kinase domain end. When this is violated, the gap is compared against ``int_max_gap``: small gaps expand the bound to the KLIFS index, larger gaps return None since the mapping is unreliable. Parameters ---------- bound : int The adjudicated kinase domain bound (start or end). klifs_bound : int The corresponding KLIFS pocket bound (min for start, max for end). is_start : bool Whether ``bound`` is the kinase domain start (True) or end (False). int_max_gap : int Maximum allowed gap between the kinase domain bound and the KLIFS bound before the bound is treated as unreliable and None is returned. bool_verbose : bool Whether to log verbose messages. Returns ------- int | None The reconciled bound, expanded to the KLIFS index when the gap is within ``int_max_gap``, otherwise None. """ str_bound = "start" if is_start else "end" # violation is min < start (start) or max > end (end) gap = bound - klifs_bound if is_start else klifs_bound - bound if gap <= 0: return bound if gap <= int_max_gap: if bool_verbose: logger.info( f"KLIFS pocket {str_bound} ({klifs_bound}) extends past kinase " f"domain {str_bound} ({bound}) for {self.hgnc_name} by {gap}; " f"expanding {str_bound} to {klifs_bound}." ) return klifs_bound logger.warning( f"Kinase domain {str_bound} found for {self.hgnc_name} but KLIFS pocket " f"{str_bound} ({klifs_bound}) gap is {gap} (larger than cut-off " f"{int_max_gap}); returning None." ) return None
[docs] def adjudicate_kd_start( self, int_max_gap: int = 15, bool_verbose: bool = False ) -> int | None: """Adjudicate kinase domain start based on available data. Parameters ---------- int_max_gap : int, optional Maximum allowed gap between the kinase domain start and the minimum KLIFS pocket index before the start is treated as unreliable and None is returned, by default 15. bool_verbose : bool, optional Whether to log verbose messages, by default False. Returns ------- int | None The start of the kinase domain if available, otherwise None. """ if self.kincore is not None: start = rgetattr(self, "kincore.cif.start") or rgetattr( self, "kincore.fasta.start" ) elif self.pfam is not None: start = self.pfam.start else: if bool_verbose: logger.info( f"No kinase domain sequence start found for {self.hgnc_name}" ) return None bounds = self._klifs_uniprot_idx_bounds() if start is not None and bounds is not None: start = self._reconcile_kd_bound_with_klifs( bound=start, klifs_bound=bounds[0], is_start=True, int_max_gap=int_max_gap, bool_verbose=bool_verbose, ) return start
[docs] def adjudicate_kd_end( self, int_max_gap: int = 15, bool_verbose: bool = False ) -> int | None: """Adjudicate kinase domain end based on available data. Parameters ---------- int_max_gap : int, optional Maximum allowed gap between the kinase domain end and the maximum KLIFS pocket index before the end is treated as unreliable and None is returned, by default 15. bool_verbose : bool, optional Whether to log verbose messages, by default False. Returns ------- int | None The end of the kinase domain if available, otherwise None. """ if self.kincore is not None: end = rgetattr(self, "kincore.cif.end") or rgetattr( self, "kincore.fasta.end" ) elif self.pfam is not None: end = self.pfam.end else: if bool_verbose: logger.info(f"No kinase domain sequence end found for {self.hgnc_name}") return None bounds = self._klifs_uniprot_idx_bounds() if end is not None and bounds is not None: end = self._reconcile_kd_bound_with_klifs( bound=end, klifs_bound=bounds[1], is_start=False, int_max_gap=int_max_gap, bool_verbose=bool_verbose, ) return end
[docs] def adjudicate_group(self, bool_verbose: bool = False) -> str | None: """Adjudicate group based on available data. Parameters ---------- bool_verbose : bool, optional Whether to log verbose messages, by default False. Returns ------- str | None The group of the kinase if available, otherwise None. """ list_attr = ["kincore.fasta.group", "kinhub.group", "klifs.group"] for attr in list_attr: group = rgetattr(self, attr) if group is not None: return group if bool_verbose: logger.info(f"No group found for {self.hgnc_name}") return None
[docs] def is_lipid_kinase(self) -> bool: """Return boolean if a lipid kinase. Returns ------- bool Whether or not is a lipid kinase """ str_hgnc = self.hgnc_name.split("_")[0] bool1 = str_hgnc.startswith("PI") bool2 = not (str_hgnc.startswith("PIM") or str_hgnc.startswith("PIN")) # protein kinase # previously included "PI4KAP1" and "PI4KAP2" since pseudogenes but they're lipid bool3 = not (str_hgnc in ["PIK3R4"]) if bool1 and bool2 and bool3: return True else: return False
[docs] def is_pseudogene(self) -> bool: """Return boolean if a pseudogene. Returns ------- bool Whether or not is a pseudogene """ from mkt.schema.utils import rgetattr for attr, str_attr in [ ("uniprot.header", "putative"), ("klifs.full_name", "pseudogene"), ]: val = rgetattr(self, attr) if val is not None and str_attr in val.lower(): return True return False
[docs] def is_pseudokinase(self) -> bool: """Return boolean if a (predicted) pseudokinase. Predicts catalytic deficiency from the KLIFS pocket by testing the three canonical catalytic residues -- the VAIK beta3 lysine (III:17), the HRD catalytic aspartate (c.l:70) and the DFG aspartate (xDFG:81); a kinase missing any one is called a pseudokinase. The catalytic lysine may instead sit in beta2 (II:13) in the WNK family, which is accepted as present. Two hand-curated overrides correct the known failure modes of the heuristic (see ``LIST_PSEUDOKINASE_TRIAD_INTACT`` and ``LIST_PSEUDOKINASE_HEURISTIC_FALSE_POSITIVE`` in ``mkt.schema.constants`` for membership and citations). Returns False when no KLIFS pocket is available. Returns ------- bool Whether or not is a predicted pseudokinase """ from mkt.schema.constants import ( LIST_KLIFS_REGION, LIST_PSEUDOKINASE_HEURISTIC_FALSE_POSITIVE, LIST_PSEUDOKINASE_TRIAD_INTACT, STR_KLIFS_BETA2_LYSINE, STR_KLIFS_BETA3_LYSINE, STR_KLIFS_CATALYTIC_ASP, STR_KLIFS_DFG_ASP, ) # curated literature overrides take precedence over the sequence heuristic if self.hgnc_name in LIST_PSEUDOKINASE_TRIAD_INTACT: return True if self.hgnc_name in LIST_PSEUDOKINASE_HEURISTIC_FALSE_POSITIVE: return False # cannot assess catalytic residues without a pocket if self.klifs is None or self.klifs.pocket_seq is None: return False pocket = self.klifs.pocket_seq def _residue(label): return pocket[LIST_KLIFS_REGION.index(label)] # the catalytic lysine is normally in beta3 (VAIK); the WNK family relocates it # to beta2 ("With No K [in beta3]"), so accept a lysine at either position has_lysine = ( _residue(STR_KLIFS_BETA3_LYSINE) == "K" or _residue(STR_KLIFS_BETA2_LYSINE) == "K" ) has_catalytic_asp = _residue(STR_KLIFS_CATALYTIC_ASP) == "D" has_dfg_asp = _residue(STR_KLIFS_DFG_ASP) == "D" # a pseudokinase is missing at least one of the three catalytic residues return not (has_lysine and has_catalytic_asp and has_dfg_asp)
[docs] def return_molecular_brake_residues(self) -> dict[str, str | None] | None: """Return this kinase's residues at the molecular brake KLIFS positions. The molecular brake is a network of conserved residues in the KLIFS pocket (see ``DICT_MOLECULAR_BRAKE`` in ``mkt.schema.constants`` for the region:idx labels and their canonical identities). This reads the residue at each of those positions from the KLIFS-to-UniProt index mapping, i.e. ``canonical_seq[KLIFS2UniProtIdx[label] - 1 + offset]``, where the per-position offset comes from ``DICT_MOLECULAR_BRAKE_OFFSET`` (the brake lysine sits one residue N-terminal to its VIII:79 KLIFS-aligned position). Returns ------- dict[str, str | None] | None Dictionary mapping each molecular brake KLIFS region:idx label to the residue found at that position in this kinase, or None where the position is unmapped. Returns None entirely when no KLIFS pocket mapping is available (``KLIFS2UniProtIdx`` is None). """ from mkt.schema.constants import ( DICT_MOLECULAR_BRAKE, DICT_MOLECULAR_BRAKE_OFFSET, ) if self.KLIFS2UniProtIdx is None: return None seq = self.uniprot.canonical_seq dict_residues: dict[str, str | None] = {} for label in DICT_MOLECULAR_BRAKE: idx = self.KLIFS2UniProtIdx.get(label) if idx is None: dict_residues[label] = None continue offset = DICT_MOLECULAR_BRAKE_OFFSET.get(label, 0) dict_residues[label] = seq[idx - 1 + offset] return dict_residues
[docs] def check_molecular_brake_against_canonical( self, ) -> tuple[bool, bool, bool] | None: """Check this kinase's molecular brake residues against the canonical identities. Compares the residue at each molecular brake position (see ``return_molecular_brake_residues``) against the conserved canonical residue in ``DICT_MOLECULAR_BRAKE``. An unmapped position (None) is treated as not matching. Returns ------- tuple[bool, bool, bool] | None One boolean per molecular brake position -- in ``DICT_MOLECULAR_BRAKE`` order -- indicating whether this kinase's residue matches the canonical identity. Returns None when no KLIFS pocket mapping is available. """ from mkt.schema.constants import DICT_MOLECULAR_BRAKE dict_residues = self.return_molecular_brake_residues() if dict_residues is None: return None return tuple( dict_residues[label] == canonical for label, canonical in DICT_MOLECULAR_BRAKE.items() )