From 57d7c6721b113a5533c58e5dc77ed001d8bc82a1 Mon Sep 17 00:00:00 2001 From: Thomas Holder Date: Thu, 21 Sep 2023 20:32:23 +0200 Subject: [PATCH] Add type annotations and mypy CI job --- .github/workflows/python-package.yml | 10 +++++ .gitignore | 2 + propka/atom.py | 47 ++++++++----------- propka/bonds.py | 6 ++- propka/calculations.py | 15 +++++-- propka/conformation_container.py | 46 ++++++++++++------- propka/energy.py | 21 ++++++--- propka/group.py | 67 ++++++++++++++++------------ propka/hydrogens.py | 47 ++++++++++--------- propka/input.py | 21 +++++---- propka/molecular_container.py | 31 ++++++++----- propka/vector_algebra.py | 49 +++++++++++++------- setup.cfg | 12 +++++ tests/test_basic_regression.py | 8 ++-- 14 files changed, 233 insertions(+), 149 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 25eb1f9..571cd14 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -56,3 +56,13 @@ jobs: with: name: coverage-html path: htmlcov/* + + static_type_check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + with: + python-version: "3.12" + - run: python -m pip install mypy types-setuptools + - run: mypy diff --git a/.gitignore b/.gitignore index 25ac1eb..c3ad56e 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,5 @@ docs/build docs/source/api/*.rst build/ +dist/ +.coverage diff --git a/propka/atom.py b/propka/atom.py index 231abb3..5cb6a4e 100644 --- a/propka/atom.py +++ b/propka/atom.py @@ -7,9 +7,15 @@ """ import string +from typing import cast, List, NoReturn, Optional, TYPE_CHECKING + from propka.lib import make_tidy_atom_label from . import hybrid36 +if TYPE_CHECKING: + from propka.group import Group + from propka.molecular_container import MolecularContainer + from propka.conformation_container import ConformationContainer # Format strings that get used in multiple places (or are very complex) PDB_LINE_FMT1 = ( @@ -37,37 +43,24 @@ class Atom: removed as reading/writing PROPKA input is no longer supported. """ - def __init__(self, line=None): + def __init__(self, line: Optional[str] = None): """Initialize Atom object. Args: line: Line from a PDB file to set properties of atom. """ - self.occ = None - self.numb = None - self.res_name = None - self.type = None - self.chain_id = None - self.beta = None - self.icode = None - self.res_num = None - self.name = None - self.element = None - self.x = None - self.y = None - self.z = None - self.group = None - self.group_type = None - self.number_of_bonded_elements = {} - self.cysteine_bridge = False - self.bonded_atoms = [] + self.number_of_bonded_elements: NoReturn = cast(NoReturn, {}) # FIXME unused? + self.group: Optional[Group] = None + self.group_type: Optional[str] = None + self.cysteine_bridge: bool = False + self.bonded_atoms: List[Atom] = [] self.residue = None - self.conformation_container = None - self.molecular_container = None + self.conformation_container: Optional[ConformationContainer] = None + self.molecular_container: Optional[MolecularContainer] = None self.is_protonated = False self.steric_num_lone_pairs_set = False - self.terminal = None - self.charge = 0 + self.terminal: Optional[str] = None + self.charge = 0.0 self.charge_set = False self.steric_number = 0 self.number_of_lone_pairs = 0 @@ -84,7 +77,7 @@ def __init__(self, line=None): self.sybyl_assigned = False self.marvin_pka = False - def set_properties(self, line): + def set_properties(self, line: Optional[str]): """Line from PDB file to set properties of atom. Args: @@ -112,10 +105,8 @@ def set_properties(self, line): self.z = float(line[46:54].strip()) self.res_num = int(line[22:26].strip()) self.res_name = "{0:<3s}".format(line[17:20].strip()) - self.chain_id = line[21] # Set chain id to "_" if it is just white space. - if not self.chain_id.strip(): - self.chain_id = '_' + self.chain_id = line[21].strip() or '_' self.type = line[:6].strip().lower() # TODO - define nucleic acid residue names elsewhere @@ -134,7 +125,7 @@ def set_properties(self, line): self.element = '{0:1s}{1:1s}'.format( self.element[0], self.element[1].lower()) - def set_group_type(self, type_): + def set_group_type(self, type_: str): """Set group type of atom. Args: diff --git a/propka/bonds.py b/propka/bonds.py index 1bb0311..10190db 100644 --- a/propka/bonds.py +++ b/propka/bonds.py @@ -10,6 +10,10 @@ import json import pkg_resources import propka.calculations +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from propka.molecular_container import MolecularContainer _LOGGER = logging.getLogger(__name__) @@ -329,7 +333,7 @@ def check_distance(self, atom1, atom2): return True return False - def find_bonds_for_molecules_using_boxes(self, molecules): + def find_bonds_for_molecules_using_boxes(self, molecules: "MolecularContainer"): """ Finds all bonds for a molecular container. Args: diff --git a/propka/calculations.py b/propka/calculations.py index e93e86e..15109a0 100644 --- a/propka/calculations.py +++ b/propka/calculations.py @@ -6,13 +6,17 @@ """ import math +from typing import Iterable, Optional, Tuple, TypeVar +from .vector_algebra import _XYZ +_BoundXYZ_1 = TypeVar("_BoundXYZ_1", bound=_XYZ) +_BoundXYZ_2 = TypeVar("_BoundXYZ_2", bound=_XYZ) #: Maximum distance used to bound calculations of smallest distance MAX_DISTANCE = 1e6 -def squared_distance(atom1, atom2): +def squared_distance(atom1: _XYZ, atom2: _XYZ) -> float: """Calculate the squared distance between two atoms. Args: @@ -28,7 +32,7 @@ def squared_distance(atom1, atom2): return res -def distance(atom1, atom2): +def distance(atom1: _XYZ, atom2: _XYZ) -> float: """Calculate the distance between two atoms. Args: @@ -40,7 +44,10 @@ def distance(atom1, atom2): return math.sqrt(squared_distance(atom1, atom2)) -def get_smallest_distance(atoms1, atoms2): +def get_smallest_distance( + atoms1: Iterable[_BoundXYZ_1], + atoms2: Iterable[_BoundXYZ_2], +) -> Tuple[Optional[_BoundXYZ_1], float, Optional[_BoundXYZ_2]]: """Calculate the smallest distance between two groups of atoms. Args: @@ -59,4 +66,4 @@ def get_smallest_distance(atoms1, atoms2): res_dist = dist res_atom1 = atom1 res_atom2 = atom2 - return [res_atom1, math.sqrt(res_dist), res_atom2] + return (res_atom1, math.sqrt(res_dist), res_atom2) diff --git a/propka/conformation_container.py b/propka/conformation_container.py index 9defd81..0196b99 100644 --- a/propka/conformation_container.py +++ b/propka/conformation_container.py @@ -6,6 +6,12 @@ """ import logging import functools +from typing import Iterable, List, NoReturn, Optional, TYPE_CHECKING, Set + +if TYPE_CHECKING: + from propka.atom import Atom + from propka.molecular_container import MolecularContainer + import propka.ligand from propka.output import make_interaction_map from propka.determinant import Determinant @@ -13,7 +19,6 @@ from propka.determinants import set_backbone_determinants, set_ion_determinants from propka.determinants import set_determinants from propka.group import Group, is_group -from typing import Iterable _LOGGER = logging.getLogger(__name__) @@ -38,7 +43,10 @@ class ConformationContainer: PROPKA inputs is no longer supported. """ - def __init__(self, name='', parameters=None, molecular_container=None): + def __init__(self, + name: str = '', + parameters=None, + molecular_container: Optional["MolecularContainer"] = None): """Initialize conformation container. Args: @@ -49,9 +57,9 @@ def __init__(self, name='', parameters=None, molecular_container=None): self.molecular_container = molecular_container self.name = name self.parameters = parameters - self.atoms = [] - self.groups = [] - self.chains = [] + self.atoms: List["Atom"] = [] + self.groups: List[Group] = [] + self.chains: List[str] = [] self.current_iter_item = 0 self.marvin_pkas_calculated = False self.non_covalently_coupled_groups = False @@ -126,7 +134,8 @@ def find_non_covalently_coupled_groups(self, verbose=False): self.get_titratable_groups()))) > 0: self.non_covalently_coupled_groups = True - def find_bonded_titratable_groups(self, atom, num_bonds, original_atom): + def find_bonded_titratable_groups(self, atom: "Atom", num_bonds: int, + original_atom: "Atom"): """Find bonded titrable groups. Args: @@ -136,7 +145,7 @@ def find_bonded_titratable_groups(self, atom, num_bonds, original_atom): Returns: a set of bonded atom groups """ - res = set() + res: Set[Group] = set() for bond_atom in atom.bonded_atoms: # skip the original atom if bond_atom == original_atom: @@ -152,7 +161,7 @@ def find_bonded_titratable_groups(self, atom, num_bonds, original_atom): bond_atom, num_bonds+1, original_atom) return res - def setup_and_add_group(self, group): + def setup_and_add_group(self, group: Optional[Group]): """Check if we want to include this group in the calculations. Args: @@ -166,7 +175,7 @@ def setup_and_add_group(self, group): self.init_group(group) self.groups.append(group) - def init_group(self, group): + def init_group(self, group: Group): """Initialize the given Group object. Args: @@ -178,10 +187,11 @@ def init_group(self, group): # If --titrate_only option is set, make non-specified residues # un-titratable: + assert self.molecular_container is not None titrate_only = self.molecular_container.options.titrate_only if titrate_only is not None: atom = group.atom - if not (atom.chain_id, atom.res_num, atom.icode) in titrate_only: + if (atom.chain_id, atom.res_num, atom.icode) not in titrate_only: group.titratable = False if group.residue_type == 'CYS': group.exclude_cys_from_results = True @@ -475,7 +485,7 @@ def get_ions(self): group for group in self.groups if group.residue_type in self.parameters.ions.keys()] - def get_group_names(self, group_list): + def get_group_names(self, group_list: NoReturn) -> NoReturn: # FIXME unused? """Get names of groups in list. Args: @@ -483,9 +493,11 @@ def get_group_names(self, group_list): Returns: list of groups """ + if TYPE_CHECKING: + assert False return [group for group in self.groups if group.type in group_list] - def get_ligand_atoms(self): + def get_ligand_atoms(self) -> List["Atom"]: """Get atoms associated with ligands. Returns: @@ -493,7 +505,7 @@ def get_ligand_atoms(self): """ return [atom for atom in self.atoms if atom.type == 'hetatm'] - def get_heavy_ligand_atoms(self): + def get_heavy_ligand_atoms(self) -> List["Atom"]: """Get heavy atoms associated with ligands. Returns: @@ -503,7 +515,7 @@ def get_heavy_ligand_atoms(self): atom for atom in self.atoms if atom.type == 'hetatm' and atom.element != 'H'] - def get_chain(self, chain): + def get_chain(self, chain: str) -> List["Atom"]: """Get atoms associated with a specific chain. Args: @@ -513,7 +525,7 @@ def get_chain(self, chain): """ return [atom for atom in self.atoms if atom.chain_id != chain] - def add_atom(self, atom): + def add_atom(self, atom: "Atom"): """Add atom to container. Args: @@ -556,7 +568,7 @@ def top_up(self, other): """ self.top_up_from_atoms(other.atoms) - def top_up_from_atoms(self, other_atoms: Iterable["propka.atom.Atom"]): + def top_up_from_atoms(self, other_atoms: Iterable["Atom"]): """Adds atoms which are missing from this container. Args: @@ -613,7 +625,7 @@ def sort_atoms(self): self.atoms[i].numb = i+1 @staticmethod - def sort_atoms_key(atom): + def sort_atoms_key(atom: "Atom") -> float: """Generate key for atom sorting. Args: diff --git a/propka/energy.py b/propka/energy.py index 8b54163..ae959ab 100644 --- a/propka/energy.py +++ b/propka/energy.py @@ -7,6 +7,12 @@ """ import math import logging +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from propka.conformation_container import ConformationContainer + from propka.group import Group + from propka.calculations import squared_distance, get_smallest_distance @@ -27,13 +33,14 @@ SEPARATE_NUM_BURIED_MAX = 400 -def radial_volume_desolvation(parameters, group): +def radial_volume_desolvation(parameters, group: "Group") -> None: """Calculate desolvation terms for group. Args: parameters: parameters for desolvation calculation group: group of atoms for calculation """ + assert group.atom.conformation_container is not None all_atoms = group.atom.conformation_container.get_non_hydrogen_atoms() volume = 0.0 group.num_volume = 0 @@ -66,7 +73,7 @@ def radial_volume_desolvation(parameters, group): * volume_after_allowance * scale_factor) -def calculate_scale_factor(parameters, weight): +def calculate_scale_factor(parameters, weight: float) -> float: """Calculate desolvation scaling factor. Args: @@ -82,7 +89,7 @@ def calculate_scale_factor(parameters, weight): return scale_factor -def calculate_weight(parameters, num_volume): +def calculate_weight(parameters, num_volume: int) -> float: """Calculate the atom-based desolvation weight. TODO - figure out why a similar function exists in version.py @@ -102,7 +109,7 @@ def calculate_weight(parameters, num_volume): return weight -def calculate_pair_weight(parameters, num_volume1, num_volume2): +def calculate_pair_weight(parameters, num_volume1: int, num_volume2: int) -> float: """Calculate the atom-pair based desolvation weight. Args: @@ -120,7 +127,7 @@ def calculate_pair_weight(parameters, num_volume1, num_volume2): return weight -def hydrogen_bond_energy(dist, dpka_max, cutoffs, f_angle=1.0): +def hydrogen_bond_energy(dist, dpka_max: float, cutoffs, f_angle=1.0) -> float: """Calculate hydrogen-bond interaction pKa shift. Args: @@ -319,7 +326,7 @@ def check_coulomb_pair(parameters, group1, group2, dist): return do_coulomb -def coulomb_energy(dist, weight, parameters): +def coulomb_energy(dist: float, weight: float, parameters) -> float: """Calculates the Coulomb interaction pKa shift based on Coulomb's law. Args: @@ -340,7 +347,7 @@ def coulomb_energy(dist, weight, parameters): return abs(dpka) -def backbone_reorganization(_, conformation): +def backbone_reorganization(_, conformation: "ConformationContainer") -> None: """Perform calculations related to backbone reorganizations. NOTE - this was described in the code as "adding test stuff" diff --git a/propka/group.py b/propka/group.py index 3a284ce..65e09f4 100644 --- a/propka/group.py +++ b/propka/group.py @@ -11,8 +11,11 @@ """ import logging import math +from typing import cast, Dict, Iterable, List, NoReturn, Optional + import propka.ligand import propka.protonate +from propka.atom import Atom from propka.ligand_pka_values import LigandPkaValues from propka.determinant import Determinant @@ -57,7 +60,7 @@ class Group: longer supported. """ - def __init__(self, atom): + def __init__(self, atom: Atom): """Initialize with an atom. Args: @@ -67,7 +70,11 @@ def __init__(self, atom): self.type = '' atom.group = self # set up data structures - self.determinants = {'sidechain': [], 'backbone': [], 'coulomb': []} + self.determinants: Dict[str, List[Determinant]] = { + 'sidechain': [], + 'backbone': [], + 'coulomb': [], + } self.pka_value = 0.0 self.model_pka = 0.0 # Energy associated with volume interactions @@ -84,16 +91,16 @@ def __init__(self, atom): self.z = 0.0 self.charge = 0 self.parameters = None - self.exclude_cys_from_results = None - self.interaction_atoms_for_acids = [] - self.interaction_atoms_for_bases = [] + self.exclude_cys_from_results = False + self.interaction_atoms_for_acids: List[Atom] = [] + self.interaction_atoms_for_bases: List[Atom] = [] self.model_pka_set = False self.intrinsic_pka = None - self.titratable = None + self.titratable = False # information on covalent and non-covalent coupling - self.non_covalently_coupled_groups = [] - self.covalently_coupled_groups = [] - self.coupled_titrating_group = None + self.non_covalently_coupled_groups: List["Group"] = [] + self.covalently_coupled_groups: List["Group"] = [] + self.coupled_titrating_group: Optional["Group"] = None self.common_charge_centre = False self.residue_type = self.atom.res_name if self.atom.terminal: @@ -112,9 +119,9 @@ def __init__(self, atom): self.label = fmt.format( type=self.residue_type, name=atom.name, chain=atom.chain_id) # container for squared distances - self.squared_distances = {} + self.squared_distances: NoReturn = cast(NoReturn, {}) # FIXME unused? - def couple_covalently(self, other): + def couple_covalently(self, other: "Group") -> None: """Couple this group with another group. Args: @@ -126,7 +133,7 @@ def couple_covalently(self, other): if self not in other.covalently_coupled_groups: other.covalently_coupled_groups.append(self) - def couple_non_covalently(self, other): + def couple_non_covalently(self, other: "Group") -> None: """Non-covalenthly couple this group with another group. Args: @@ -154,7 +161,7 @@ def get_non_covalently_coupled_groups(self): """ return self.non_covalently_coupled_groups - def share_determinants(self, others): + def share_determinants(self, others: Iterable["Group"]) -> None: """Share determinants between this group and others. Args: @@ -172,7 +179,7 @@ def share_determinants(self, others): self.calculate_total_pka() the_other.calculate_total_pka() - def share_determinant(self, new_determinant, type_): + def share_determinant(self, new_determinant: Determinant, type_: str) -> None: """Add determinant to this group's list of determinants. Args: @@ -230,7 +237,7 @@ def __iadd__(self, other): self.add_determinant(determinant, type_) return self - def add_determinant(self, new_determinant, type_): + def add_determinant(self, new_determinant: Determinant, type_: str) -> None: """Add to current and creates non-present determinants. Args: @@ -247,7 +254,7 @@ def add_determinant(self, new_determinant, type_): self.determinants[type_].append(Determinant(new_determinant.group, new_determinant.value)) - def set_determinant(self, new_determinant, type_): + def set_determinant(self, new_determinant: Determinant, type_: str) -> None: """Overwrite current and create non-present determinants. Args: @@ -345,8 +352,8 @@ def setup_atoms(self): # set the main atom as interaction atom self.set_interaction_atoms([self.atom], [self.atom]) - def set_interaction_atoms(self, interaction_atoms_for_acids, - interaction_atoms_for_bases): + def set_interaction_atoms(self, interaction_atoms_for_acids: List[Atom], + interaction_atoms_for_bases: List[Atom]): """Set interacting atoms and group types. Args: @@ -359,10 +366,10 @@ def set_interaction_atoms(self, interaction_atoms_for_acids, self.interaction_atoms_for_bases = interaction_atoms_for_bases # check if all atoms have been identified ok = True - for [expect, found, _] in [[EXPECTED_ATOMS_ACID_INTERACTIONS, - self.interaction_atoms_for_acids, 'acid'], - [EXPECTED_ATOMS_BASE_INTERACTIONS, - self.interaction_atoms_for_bases, 'base']]: + for (expect, found) in [ + (EXPECTED_ATOMS_ACID_INTERACTIONS, self.interaction_atoms_for_acids), + (EXPECTED_ATOMS_BASE_INTERACTIONS, self.interaction_atoms_for_bases), + ]: if self.type in expect.keys(): for elem in expect[self.type].keys(): if (len([a for a in found if a.element == elem]) @@ -395,7 +402,7 @@ def set_interaction_atoms(self, interaction_atoms_for_acids, ' {0:s}'.format( str(self.interaction_atoms_for_bases[i]))) - def get_interaction_atoms(self, interacting_group): + def get_interaction_atoms(self, interacting_group) -> List[Atom]: """Get atoms involved in interaction with other group. Args: @@ -403,6 +410,7 @@ def get_interaction_atoms(self, interacting_group): Returns: list of atoms """ + assert self.parameters is not None if interacting_group.residue_type in self.parameters.base_list: return self.interaction_atoms_for_bases else: @@ -518,7 +526,7 @@ def calculate_intrinsic_pka(self): self.model_pka + self.energy_volume + self.energy_local + back_bone + side_chain) - def get_summary_string(self, remove_penalised_group=False): + def get_summary_string(self, remove_penalised_group: bool = False) -> str: """Create summary string for this group. Args: @@ -1210,7 +1218,7 @@ def __init__(self, atom): self.model_pka_set = True -def is_group(parameters, atom): +def is_group(parameters, atom: Atom) -> Optional[Group]: """Identify whether the atom belongs to a group. Args: @@ -1244,7 +1252,7 @@ def is_group(parameters, atom): return None -def is_protein_group(parameters, atom): +def is_protein_group(parameters, atom: Atom) -> Optional[Group]: """Identify whether the atom belongs to a protein group. Args: @@ -1278,7 +1286,7 @@ def is_protein_group(parameters, atom): return None -def is_ligand_group_by_groups(_, atom): +def is_ligand_group_by_groups(_, atom: Atom) -> Optional[Group]: """Identify whether the atom belongs to a ligand group by checking groups. Args: @@ -1360,7 +1368,7 @@ def is_ligand_group_by_groups(_, atom): return None -def is_ligand_group_by_marvin_pkas(parameters, atom): +def is_ligand_group_by_marvin_pkas(parameters, atom: Atom) -> Optional[Group]: """Identify whether the atom belongs to a ligand group by calculating 'Marvin pKas'. @@ -1375,6 +1383,7 @@ def is_ligand_group_by_marvin_pkas(parameters, atom): # calculate Marvin ligand pkas for this conformation container # if not already done # TODO - double-check testing coverage of these functions. + assert atom.conformation_container is not None if not atom.conformation_container.marvin_pkas_calculated: lpka = LigandPkaValues(parameters) lpka.get_marvin_pkas_for_molecular_container( @@ -1396,7 +1405,7 @@ def is_ligand_group_by_marvin_pkas(parameters, atom): return None -def is_ion_group(parameters, atom): +def is_ion_group(parameters, atom: Atom) -> Optional[Group]: """Identify whether the atom belongs to an ion group. Args: diff --git a/propka/hydrogens.py b/propka/hydrogens.py index c1f0bdf..b7034ef 100644 --- a/propka/hydrogens.py +++ b/propka/hydrogens.py @@ -7,15 +7,19 @@ """ import math import logging +from typing import List, Optional, Tuple, TYPE_CHECKING + from propka.protonate import Protonate from propka.bonds import BondMaker from propka.atom import Atom +if TYPE_CHECKING: + from propka.molecular_container import MolecularContainer _LOGGER = logging.getLogger(__name__) -def setup_bonding_and_protonation(molecular_container): +def setup_bonding_and_protonation(molecular_container: "MolecularContainer") -> None: """Set up bonding and protonation for a molecule. Args: @@ -34,7 +38,7 @@ def setup_bonding_and_protonation(molecular_container): protonator.protonate(molecular_container) -def setup_bonding(molecular_container): +def setup_bonding(molecular_container: "MolecularContainer") -> BondMaker: """Set up bonding for a molecular container. Args: @@ -47,7 +51,7 @@ def setup_bonding(molecular_container): return my_bond_maker -def setup_bonding_and_protonation_30_style(molecular_container): +def setup_bonding_and_protonation_30_style(molecular_container: "MolecularContainer") -> BondMaker: """Set up bonding for a molecular container. Args: @@ -63,7 +67,7 @@ def setup_bonding_and_protonation_30_style(molecular_container): return bond_maker -def protonate_30_style(molecular_container): +def protonate_30_style(molecular_container: "MolecularContainer") -> None: """Protonate the molecule. Args: @@ -73,9 +77,9 @@ def protonate_30_style(molecular_container): _LOGGER.info('Now protonating %s', name) # split atom into residues curres = -1000000 - residue = [] - o_atom = None - c_atom = None + residue: List[Atom] = [] + o_atom: Optional[Atom] = None + c_atom: Optional[Atom] = None for atom in molecular_container.conformations[name].atoms: if atom.res_num != curres: curres = atom.res_num @@ -100,7 +104,7 @@ def protonate_30_style(molecular_container): residue.append(atom) -def set_ligand_atom_names(molecular_container): +def set_ligand_atom_names(molecular_container: "MolecularContainer") -> None: """Set names for ligands in molecular container. Args: @@ -110,7 +114,7 @@ def set_ligand_atom_names(molecular_container): molecular_container.conformations[name].set_ligand_atom_names() -def add_arg_hydrogen(residue): +def add_arg_hydrogen(residue: List[Atom]) -> List[Atom]: """Adds Arg hydrogen atoms to residues according to the 'old way'. Args: @@ -142,7 +146,7 @@ def add_arg_hydrogen(residue): return [h1_atom, h2_atom, h3_atom, h4_atom, h5_atom] -def add_his_hydrogen(residue): +def add_his_hydrogen(residue: List[Atom]) -> None: """Adds His hydrogen atoms to residues according to the 'old way'. Args: @@ -165,7 +169,7 @@ def add_his_hydrogen(residue): he_atom.name = "HNE" -def add_trp_hydrogen(residue): +def add_trp_hydrogen(residue: List[Atom]) -> None: """Adds Trp hydrogen atoms to residues according to the 'old way'. Args: @@ -188,7 +192,7 @@ def add_trp_hydrogen(residue): he_atom.name = "HNE" -def add_amd_hydrogen(residue): +def add_amd_hydrogen(residue: List[Atom]) -> None: """Adds Gln & Asn hydrogen atoms to residues according to the 'old way'. Args: @@ -217,7 +221,9 @@ def add_amd_hydrogen(residue): h2_atom.name = "HN2" -def add_backbone_hydrogen(residue, o_atom, c_atom): +def add_backbone_hydrogen(residue: List[Atom], + o_atom: Optional[Atom], + c_atom: Optional[Atom]) -> Tuple[Optional[Atom], Optional[Atom]]: """Adds hydrogen backbone atoms to residues according to the old way. dR is wrong for the N-terminus (i.e. first residue) but it doesn't affect @@ -240,18 +246,18 @@ def add_backbone_hydrogen(residue, o_atom, c_atom): new_c_atom = atom if atom.name == "O": new_o_atom = atom - if None in [c_atom, o_atom, n_atom]: - return [new_o_atom, new_c_atom] + if c_atom is None or o_atom is None or n_atom is None: + return (new_o_atom, new_c_atom) if n_atom.res_name == "PRO": # PRO doesn't have an H-atom; do nothing pass else: h_atom = protonate_direction(n_atom, o_atom, c_atom) h_atom.name = "H" - return [new_o_atom, new_c_atom] + return (new_o_atom, new_c_atom) -def protonate_direction(x1_atom, x2_atom, x3_atom): +def protonate_direction(x1_atom: Atom, x2_atom: Atom, x3_atom: Atom) -> Atom: """Protonates an atom, x1_atom, given a direction. New direction for x1_atom proton is (x2_atom -> x3_atom). @@ -275,7 +281,7 @@ def protonate_direction(x1_atom, x2_atom, x3_atom): return h_atom -def protonate_average_direction(x1_atom, x2_atom, x3_atom): +def protonate_average_direction(x1_atom: Atom, x2_atom: Atom, x3_atom: Atom) -> Atom: """Protonates an atom, x1_atom, given a direction. New direction for x1_atom is (x1_atom/x2_atom -> x3_atom). @@ -301,7 +307,7 @@ def protonate_average_direction(x1_atom, x2_atom, x3_atom): return h_atom -def protonate_sp2(x1_atom, x2_atom, x3_atom): +def protonate_sp2(x1_atom: Atom, x2_atom: Atom, x3_atom: Atom) -> Atom: """Protonates a SP2 atom, given a list of atoms Args: @@ -323,7 +329,7 @@ def protonate_sp2(x1_atom, x2_atom, x3_atom): return h_atom -def make_new_h(atom, x, y, z): +def make_new_h(atom: Atom, x: float, y: float, z: float) -> Atom: """Add a new hydrogen to an atom at the specified position. Args: @@ -347,5 +353,6 @@ def make_new_h(atom, x, y, z): new_h.number_of_protons_to_add = 0 new_h.num_pi_elec_2_3_bonds = 0 atom.bonded_atoms.append(new_h) + assert atom.conformation_container is not None atom.conformation_container.add_atom(new_h) return new_h diff --git a/propka/input.py b/propka/input.py index 720eecc..c62ff84 100644 --- a/propka/input.py +++ b/propka/input.py @@ -10,12 +10,15 @@ :func:`get_atom_lines_from_input`) have been removed. """ import typing +from typing import Iterator, Tuple import contextlib from pathlib import Path from pkg_resources import resource_filename from propka.lib import protein_precheck from propka.atom import Atom from propka.conformation_container import ConformationContainer +from propka.molecular_container import MolecularContainer +from propka.parameters import Parameters def open_file_for_reading( @@ -27,18 +30,14 @@ def open_file_for_reading( input_file: path to file or file-like object. If file-like object, then will attempt seek(0). """ - try: + if not isinstance(input_file, (str, Path)): input_file.seek(0) - except AttributeError: - pass - else: - # TODO use contextlib.nullcontext when dropping Python 3.6 support - return contextlib.contextmanager(lambda: (yield input_file))() + return contextlib.nullcontext(input_file) return contextlib.closing(open(input_file, 'rt')) -def read_molecule_file(filename: str, mol_container, stream=None): +def read_molecule_file(filename: str, mol_container: MolecularContainer, stream=None) -> MolecularContainer: """Read input file or stream (PDB or PROPKA) for a molecular container Args: @@ -123,7 +122,7 @@ def read_molecule_file(filename: str, mol_container, stream=None): return mol_container -def read_parameter_file(input_file, parameters): +def read_parameter_file(input_file, parameters: Parameters) -> Parameters: """Read a parameter file. Args: @@ -144,7 +143,7 @@ def read_parameter_file(input_file, parameters): return parameters -def conformation_sorter(conf): +def conformation_sorter(conf: str) -> int: """TODO - figure out what this function does.""" model = int(conf[:-1]) altloc = conf[-1:] @@ -152,7 +151,7 @@ def conformation_sorter(conf): def get_atom_lines_from_pdb(pdb_file, ignore_residues=[], keep_protons=False, - tags=['ATOM ', 'HETATM'], chains=None): + tags=['ATOM ', 'HETATM'], chains=None) -> Iterator[Tuple[str, Atom]]: """Get atom lines from PDB file. Args: @@ -237,7 +236,7 @@ def read_pdb(pdb_file, parameters, molecule): keep_protons=molecule.options.keep_protons, chains=molecule.options.chains) for (name, atom) in lines: - if not name in conformations.keys(): + if name not in conformations.keys(): conformations[name] = ConformationContainer( name=name, parameters=parameters, molecular_container=molecule) conformations[name].add_atom(atom) diff --git a/propka/molecular_container.py b/propka/molecular_container.py index 37aba70..2f7f892 100644 --- a/propka/molecular_container.py +++ b/propka/molecular_container.py @@ -6,6 +6,8 @@ """ import logging import os +from typing import Dict, List, Optional, Tuple + import propka.version from propka.output import write_pka, print_header, print_result from propka.conformation_container import ConformationContainer @@ -28,7 +30,12 @@ class MolecularContainer: PROPKA input files is no longer supported. """ - def __init__(self, parameters, options=None): + conformation_names: List[str] + conformations: Dict[str, ConformationContainer] + name: Optional[str] + version: propka.version.Version + + def __init__(self, parameters, options=None) -> None: """Initialize molecular container. Args: @@ -50,7 +57,7 @@ def __init__(self, parameters, options=None): parameters.version) raise Exception(errstr) - def top_up_conformations(self): + def top_up_conformations(self) -> None: """Makes sure that all atoms are present in all conformations.""" ref_atoms = { atom.residue_label: atom @@ -60,24 +67,24 @@ def top_up_conformations(self): for conf in self.conformations.values(): conf.top_up_from_atoms(ref_atoms.values()) - def find_covalently_coupled_groups(self): + def find_covalently_coupled_groups(self) -> None: """Find covalently coupled groups.""" for name in self.conformation_names: self.conformations[name].find_covalently_coupled_groups() - def find_non_covalently_coupled_groups(self): + def find_non_covalently_coupled_groups(self) -> None: """Find non-covalently coupled groups.""" verbose = self.options.display_coupled_residues for name in self.conformation_names: self.conformations[name].find_non_covalently_coupled_groups( verbose=verbose) - def extract_groups(self): + def extract_groups(self) -> None: """Identify the groups needed for pKa calculation.""" for name in self.conformation_names: self.conformations[name].extract_groups() - def calculate_pka(self): + def calculate_pka(self) -> None: """Calculate pKa values.""" # calculate for each conformation for name in self.conformation_names: @@ -90,7 +97,7 @@ def calculate_pka(self): # print out the conformation-average results print_result(self, 'AVR', self.version.parameters) - def average_of_conformations(self): + def average_of_conformations(self) -> None: """Generate an average of conformations.""" parameters = self.conformations[self.conformation_names[0]].parameters # make a new configuration to hold the average values @@ -124,7 +131,7 @@ def average_of_conformations(self): self.conformations['AVR'] = avr_conformation def write_pka(self, filename=None, reference="neutral", - direction="folding", options=None): + direction="folding", options=None) -> None: """Write pKa information to a file. Args: @@ -187,7 +194,7 @@ def get_folding_profile(self, conformation='AVR', reference="neutral", stability_range = [min(stable_values), max(stable_values)] return profile, opt, range_80pct, stability_range - def get_charge_profile(self, conformation='AVR', grid=[0., 14., .1]): + def get_charge_profile(self, conformation: str = 'AVR', grid=[0., 14., .1]): """Get charge profile for conformation as function of pH. Args: @@ -196,7 +203,7 @@ def get_charge_profile(self, conformation='AVR', grid=[0., 14., .1]): Returns: list of charge state values """ - charge_profile = [] + charge_profile: List[List[float]] = [] for ph in make_grid(*grid): conf = self.conformations[conformation] q_unfolded, q_folded = conf.calculate_charge( @@ -204,8 +211,8 @@ def get_charge_profile(self, conformation='AVR', grid=[0., 14., .1]): charge_profile.append([ph, q_unfolded, q_folded]) return charge_profile - def get_pi(self, conformation='AVR', grid=[0., 14., 1], *, - precision: float = 1e-4): + def get_pi(self, conformation: str = 'AVR', grid=[0., 14., 1], *, + precision: float = 1e-4) -> Tuple[float, float]: """Get the isoelectric points for folded and unfolded states. Args: diff --git a/propka/vector_algebra.py b/propka/vector_algebra.py index 0ae9601..5abc723 100644 --- a/propka/vector_algebra.py +++ b/propka/vector_algebra.py @@ -6,16 +6,35 @@ """ import logging import math +from typing import Optional, Protocol, Union from propka.lib import get_sorted_configurations _LOGGER = logging.getLogger(__name__) +class _XYZ(Protocol): + """ + Protocol for types which have x/y/z attributes, like Vector or Atom. + """ + x: float + y: float + z: float + + class Vector: """Vector""" - def __init__(self, xi=0.0, yi=0.0, zi=0.0, atom1=None, atom2=None): + x: float + y: float + z: float + + def __init__(self, + xi: float = 0.0, + yi: float = 0.0, + zi: float = 0.0, + atom1: Optional[_XYZ] = None, + atom2: Optional[_XYZ] = None): """Initialize vector. Args: @@ -41,17 +60,17 @@ def __init__(self, xi=0.0, yi=0.0, zi=0.0, atom1=None, atom2=None): self.y = atom2.y - self.y self.z = atom2.z - self.z - def __add__(self, other): + def __add__(self, other: _XYZ): return Vector(self.x + other.x, self.y + other.y, self.z + other.z) - def __sub__(self, other): + def __sub__(self, other: _XYZ): return Vector(self.x - other.x, self.y - other.y, self.z - other.z) - def __mul__(self, other): + def __mul__(self, other: Union["Vector", "Matrix4x4", float]): """Dot product, scalar and matrix multiplication.""" if isinstance(other, Vector): return self.x * other.x + self.y * other.y + self.z * other.z @@ -66,14 +85,12 @@ def __mul__(self, other): ) elif type(other) in [int, float]: return Vector(self.x * other, self.y * other, self.z * other) - else: - _LOGGER.info('{0:s} not supported'.format(type(other))) - raise TypeError + raise TypeError(f'{type(other)} not supported') def __rmul__(self, other): return self.__mul__(other) - def __pow__(self, other): + def __pow__(self, other: _XYZ): """Cross product.""" return Vector(self.y * other.z - self.z * other.y, self.z * other.x - self.x * other.z, @@ -89,7 +106,7 @@ def sq_length(self): """Return vector squared-length""" return self.x * self.x + self.y * self.y + self.z * self.z - def length(self): + def length(self) -> float: """Return vector length.""" return math.sqrt(self.sq_length()) @@ -107,7 +124,7 @@ def orthogonal(self): res = Vector(self.z, 0, -self.x) return res - def rescale(self, new_length): + def rescale(self, new_length: float): """ Rescale vector to new length while preserving direction """ frac = new_length/(self.length()) res = Vector(xi=self.x*frac, yi=self.y*frac, zi=self.z*frac) @@ -145,7 +162,7 @@ def __init__(self, self.a44 = a44i -def angle(avec, bvec): +def angle(avec: Vector, bvec: Vector) -> float: """Get the angle between two vectors. Args: @@ -158,7 +175,7 @@ def angle(avec, bvec): return math.acos(dot / (avec.length() * bvec.length())) -def angle_degrees(avec, bvec): +def angle_degrees(avec: Vector, bvec: Vector) -> float: """Get the angle between two vectors in degrees. Args: @@ -170,7 +187,7 @@ def angle_degrees(avec, bvec): return math.degrees(angle(avec, bvec)) -def signed_angle_around_axis(avec, bvec, axis): +def signed_angle_around_axis(avec: Vector, bvec: Vector, axis: Vector) -> float: """Get signed angle of two vectors around axis in radians. Args: @@ -189,7 +206,7 @@ def signed_angle_around_axis(avec, bvec, axis): return ang -def rotate_vector_around_an_axis(theta, axis, vec): +def rotate_vector_around_an_axis(theta: float, axis: Vector, vec: Vector) -> Vector: """Rotate vector around an axis. Args: @@ -225,7 +242,7 @@ def rotate_vector_around_an_axis(theta, axis, vec): return vec -def rotate_atoms_around_z_axis(theta): +def rotate_atoms_around_z_axis(theta: float) -> Matrix4x4: """Get rotation matrix for z-axis. Args: @@ -253,7 +270,7 @@ def rotate_atoms_around_z_axis(theta): ) -def rotate_atoms_around_y_axis(theta): +def rotate_atoms_around_y_axis(theta: float) -> Matrix4x4: """Get rotation matrix for y-axis. Args: diff --git a/setup.cfg b/setup.cfg index e974c2e..7e33fe2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -19,3 +19,15 @@ omit = exclude_lines = pragma: no cover +[yapf] +column_limit = 88 +based_on_style = pep8 +allow_split_before_dict_value = False + +[mypy] +files = propka,tests +exclude = (?x)( + /_version\.py$ + ) +explicit_package_bases = True +ignore_missing_imports = True diff --git a/tests/test_basic_regression.py b/tests/test_basic_regression.py index d94fd72..8f27263 100644 --- a/tests/test_basic_regression.py +++ b/tests/test_basic_regression.py @@ -10,6 +10,7 @@ from propka.molecular_container import MolecularContainer from propka.input import read_parameter_file, read_molecule_file from propka.lib import loadOptions +from typing import List _LOGGER = logging.getLogger(__name__) @@ -32,8 +33,6 @@ if not RESULTS_DIR.is_dir(): _LOGGER.warning("Switching to sub-directory") RESULTS_DIR = Path("results") -# Arguments to add to all tests -DEFAULT_ARGS = [] def get_test_dirs(): @@ -87,8 +86,8 @@ def run_propka(options, pdb_path, tmp_path): def parse_pka(pka_path: Path) -> dict: """Parse testable data from a .pka file into a dictionary. """ - pka_list = [] - data = {"pKa": pka_list} + pka_list: List[float] = [] + data: dict = {"pKa": pka_list} with open(pka_path, "rt") as pka_file: at_pka = False @@ -98,6 +97,7 @@ def parse_pka(pka_path: Path) -> dict: at_pka = False else: m = re.search(r'\d+\.\d+', line[13:]) + assert m is not None pka_list.append(float(m.group())) elif "model-pKa" in line: at_pka = True