diff --git a/README.md b/README.md index 57d6ad57..3b555bcb 100644 --- a/README.md +++ b/README.md @@ -33,9 +33,9 @@ Once you have `micromamba` installed and have already cloned this repo, you can ### Linux ```bash -micromamba create -n qligfep_new python=3.11 +micromamba create -n qligfep_new python=3.14 micromamba activate qligfep_new -micromamba install gfortran=11.3.0 openff-toolkit=0.17.1 "openff-utilities>=0.1.12" openff-forcefields=2026.01.0 openmm=8.1.1 openff-nagl=0.5.4 openff-nagl-models=2025.9.0 lomap2 kartograf=1.0.1 michellab::fkcombu konnektor -c conda-forge --yes +micromamba install gfortran=11.3.0 openff-toolkit=0.18.1 "openff-utilities>=0.1.12" openff-forcefields=2026.01.0 openmm=8.4.0 openff-nagl=0.5.5 openff-nagl-models=2025.9.0 lomap2 kartograf=1.0.1 michellab::fkcombu konnektor -c conda-forge --yes ``` Now that you have the environment ready and activated, [clone the repository](https://docs.github.com/en/repositories/creating-and-managing-repositories/cloning-a-repository), enter the `Q` directory with `cd Q/`, and install qligfep: @@ -49,7 +49,7 @@ The `qprep` Fortran binary will be automatically compiled during installation. To install everything in one line... ```bash -micromamba create -n qligfep_new python=3.11 gfortran=11.3.0 openff-toolkit=0.17.1 "openff-utilities>=0.1.12" openff-forcefields=2026.01.0 openmm=8.1.1 openff-nagl=0.5.4 openff-nagl-models=2025.9.0 lomap2 kartograf=1.0.1 michellab::fkcombu konnektor -c conda-forge --yes && micromamba activate qligfep_new && python -m pip install -e . +micromamba create -n qligfep_new python=3.14 gfortran=11.3.0 openff-toolkit=0.18.1 "openff-utilities>=0.1.12" openff-forcefields=2026.01.0 openmm=8.4.0 openff-nagl=0.5.5 openff-nagl-models=2025.9.0 lomap2 kartograf=1.0.1 michellab::fkcombu konnektor -c conda-forge --yes && micromamba activate qligfep_new && python -m pip install -e . ``` @@ -58,7 +58,7 @@ micromamba create -n qligfep_new python=3.11 gfortran=11.3.0 openff-toolkit=0.17 Similar to Linux, [clone the repository](https://docs.github.com/en/repositories/creating-and-managing-repositories/cloning-a-repository), enter the `Q` directory with `cd Q/`, create the environment and install: ``` bash -micromamba create -n qligfep_new python=3.11 gfortran=11.3.0 openff-toolkit=0.17.1 "openff-utilities>=0.1.12" openff-forcefields=2026.01.0 openmm=8.1.1 openff-nagl=0.5.4 openff-nagl-models=2025.9.0 lomap2 kartograf=1.0.1 davidararipe::kcombu_bss konnektor -c conda-forge --yes +micromamba create -n qligfep_new python=3.14 gfortran=11.3.0 openff-toolkit=0.18.1 "openff-utilities>=0.1.12" openff-forcefields=2026.01.0 openmm=8.4.0 openff-nagl=0.5.5 openff-nagl-models=2025.9.0 lomap2 kartograf=1.0.1 davidararipe::kcombu_bss konnektor -c conda-forge --yes micromamba activate qligfep_new python -m pip install joblib scipy tqdm python -m pip install -e . @@ -70,7 +70,7 @@ The `qprep` Fortran binary will be automatically compiled during installation. To install everything in one line... ```bash -micromamba create -n qligfep_new python=3.11 gfortran=11.3.0 openff-toolkit=0.17.1 "openff-utilities>=0.1.12" openff-forcefields=2026.01.0 openmm=8.1.1 openff-nagl=0.5.4 openff-nagl-models=2025.9.0 lomap2 kartograf=1.0.1 davidararipe::kcombu_bss konnektor -c conda-forge --yes && micromamba activate qligfep_new && python -m pip install joblib scipy tqdm && python -m pip install -e . +micromamba create -n qligfep_new python=3.14 gfortran=11.3.0 openff-toolkit=0.18.1 "openff-utilities>=0.1.12" openff-forcefields=2026.01.0 openmm=8.4.0 openff-nagl=0.5.5 openff-nagl-models=2025.9.0 lomap2 kartograf=1.0.1 davidararipe::kcombu_bss konnektor -c conda-forge --yes && micromamba activate qligfep_new && python -m pip install joblib scipy tqdm && python -m pip install -e . ``` diff --git a/pyproject.toml b/pyproject.toml index 9f6df315..126abc9d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "QligFEP" dynamic = ["version"] description = "Python CLI designed to facilitate a robust and fast setup of free energy perturbation (FEP)." readme = { file = "README.md", content-type = "text/markdown" } -requires-python = ">=3.10" +requires-python = ">=3.14" license = { file = "LICENSE" } # keywords = [""] authors = [{ name = "Willem Jespers", email = "w.jespers@rug.nl" }, @@ -24,7 +24,7 @@ classifiers = [ "Intended Audience :: Science/Research", "Topic :: Scientific/Engineering :: Chemistry", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10" # todo: might have other versions + "Programming Language :: Python :: 3.14" # todo: might have other versions ] dependencies = [ "joblib", @@ -69,15 +69,16 @@ repository = "https://github.com/qusers/qligfep" [tool.black] line-length = 110 -target-version = ['py310'] +target-version = ['py314'] [tool.isort] profile = "black" +line_length = 110 [tool.ruff] line-length = 110 indent-width = 4 -target-version = 'py39' +target-version = 'py314' [tool.ruff.lint] select = [ diff --git a/src/QligFEP/CLI/cog_cli.py b/src/QligFEP/CLI/cog_cli.py index 89d61d9b..7eb7a734 100644 --- a/src/QligFEP/CLI/cog_cli.py +++ b/src/QligFEP/CLI/cog_cli.py @@ -65,7 +65,7 @@ def _cog_sdf(self): for i, center in enumerate(centers): logger.debug(f"Ligand {i+1} center: {center}") - overall_center = [sum(x) / len(centers) for x in zip(*centers)] + overall_center = [sum(x) / len(centers) for x in zip(*centers, strict=False)] return f"[{round(overall_center[0], 3):.3f} {round(overall_center[1], 3):.3f} {round(overall_center[2], 3):.3f}]" def _calculate_center(self, coordinates): diff --git a/src/QligFEP/CLI/konnektor_cli.py b/src/QligFEP/CLI/konnektor_cli.py index 7468ee68..92f8ed97 100644 --- a/src/QligFEP/CLI/konnektor_cli.py +++ b/src/QligFEP/CLI/konnektor_cli.py @@ -3,7 +3,6 @@ import argparse import json from pathlib import Path -from typing import Optional import numpy as np from kartograf import KartografAtomMapper, SmallMoleculeComponent @@ -34,18 +33,18 @@ class KonnektorWrap: def __init__( self, inp: str, - out: Optional[str] = None, + out: str | None = None, network: str = "mst", scorer: str = "combined", restraint_method: str = "heavyatom_p", processes: int = 1, log_level: str = "info", - central_ligand: Optional[str] = None, + central_ligand: str | None = None, n_redundancy: int = 2, connectivity: int = 3, separate_charges: bool = False, charge_changes_score: float = 0.0, - exp_key: Optional[str] = None, + exp_key: str | None = None, self_solve: bool = False, ): self.inp = inp @@ -66,7 +65,7 @@ def __init__( self.out = self._parse_output(out) self._sdf_dir = self._prepare_input() - def _parse_output(self, output: Optional[str]) -> str: + def _parse_output(self, output: str | None) -> str: inpath = Path(self.inp) if output is None: if inpath.is_dir(): @@ -310,7 +309,7 @@ def run(self) -> dict: return result - def _resolve_sdf_path(self) -> Optional[Path]: + def _resolve_sdf_path(self) -> Path | None: """Find the SDF file used as input.""" inp = Path(self.inp) if inp.is_file() and inp.suffix == ".sdf": @@ -354,7 +353,7 @@ def _mcs_rmsd(mol_a, mol_b, timeout=5): conf_a = ha.GetConformer() conf_b = hb.GetConformer() sq_dists = [] - for ia, ib in zip(match_a, match_b): + for ia, ib in zip(match_a, match_b, strict=False): pa = conf_a.GetAtomPosition(ia) pb = conf_b.GetAtomPosition(ib) sq_dists.append((pa.x - pb.x) ** 2 + (pa.y - pb.y) ** 2 + (pa.z - pb.z) ** 2) @@ -423,7 +422,7 @@ def _realign_to_neighbors(outlier_mol, neighbor_mols): continue conf_n = hn.GetConformer() o_to_core = {oa: i for i, oa in enumerate(match_o)} - for oa, na in zip(match_o_nb, match_n_nb): + for oa, na in zip(match_o_nb, match_n_nb, strict=False): if oa in o_to_core: p = conf_n.GetAtomPosition(na) core_positions.setdefault(o_to_core[oa], []).append( diff --git a/src/QligFEP/CLI/lomap_wrap_cli.py b/src/QligFEP/CLI/lomap_wrap_cli.py index 5fac38ff..a61a6e19 100644 --- a/src/QligFEP/CLI/lomap_wrap_cli.py +++ b/src/QligFEP/CLI/lomap_wrap_cli.py @@ -5,7 +5,6 @@ import re from multiprocessing import cpu_count from pathlib import Path -from typing import Optional import lomap import numpy as np @@ -22,10 +21,10 @@ class LomapWrap: def __init__( self, inp: str, - out: Optional[str] = None, + out: str | None = None, time=30, verbose="info", - exp_key: Optional[str] = None, + exp_key: str | None = None, **kwargs, ): self.nodes = {} diff --git a/src/QligFEP/CLI/qligfep_cli.py b/src/QligFEP/CLI/qligfep_cli.py index 9d67edd8..03408dc3 100644 --- a/src/QligFEP/CLI/qligfep_cli.py +++ b/src/QligFEP/CLI/qligfep_cli.py @@ -4,7 +4,6 @@ import datetime import json from pathlib import Path -from typing import Optional from QligFEP import __version__ @@ -15,7 +14,7 @@ from .parser_base import parse_arguments -def main(args: Optional[argparse.Namespace] = None, **kwargs) -> None: +def main(args: argparse.Namespace | None = None, **kwargs) -> None: """Main function for qligfep_cli.py. Takes arguments from argparse and passes them to QligFEP class. If no arguments are given, the function will use the keyword arguments that are passed to it. diff --git a/src/QligFEP/CLI/setupFEP.py b/src/QligFEP/CLI/setupFEP.py index f634b76b..3f422326 100644 --- a/src/QligFEP/CLI/setupFEP.py +++ b/src/QligFEP/CLI/setupFEP.py @@ -7,7 +7,6 @@ import shutil import subprocess from pathlib import Path -from typing import Optional from ..IO import parse_qprep_total_charge from ..logger import logger, setup_logger @@ -76,7 +75,7 @@ def submit_command(command: str) -> None: raise -def main(args: Optional[argparse.Namespace] = None, **kwargs) -> None: +def main(args: argparse.Namespace | None = None, **kwargs) -> None: # setup the logger with the desired log level setup_logger(level=args.log) @@ -113,7 +112,7 @@ def main(args: Optional[argparse.Namespace] = None, **kwargs) -> None: lig_pairs = ligpairs_from_json(args.json_map) protein_dir = cwd / "2.protein" - for system, sys_dir in zip(systems, sys_directories): + for system, sys_dir in zip(systems, sys_directories, strict=False): for lig1, lig2, same_charge in lig_pairs: # For cross-charge water edges, look up the protein leg's total charge diff --git a/src/QligFEP/IO.py b/src/QligFEP/IO.py index 6f409e4e..1e11a3fd 100644 --- a/src/QligFEP/IO.py +++ b/src/QligFEP/IO.py @@ -4,7 +4,7 @@ import stat import subprocess from pathlib import Path -from typing import NamedTuple, Optional +from typing import NamedTuple import numpy as np import pandas as pd @@ -34,8 +34,8 @@ class SlurmRunInfo(NamedTuple): """Per-replicate run metadata parsed from one ``slurm*.out`` footer.""" runtime: str - seed: Optional[str] - replicate: Optional[str] + seed: str | None + replicate: str | None status: str diff --git a/src/QligFEP/README.md b/src/QligFEP/README.md deleted file mode 100644 index 620419c3..00000000 --- a/src/QligFEP/README.md +++ /dev/null @@ -1,66 +0,0 @@ -# QligFEP v2.0 and QresFEP v1.0 - -This collection of python command line functions is designed with the -aim to facilitate a robust and fast setup of FEP calculations for the -software package **Q**. These modules use python 3, python 2 is no -longer supported, and an old version of the code using python 2 -is now only available in the python2 branch. - -This package includes at the moment two main modules: -- QligFEP.py: module to generate ligand FEP calculations using a -dual topology approach, -see Jespers et al. (https://doi.org/10.1186/s13321-019-0348-5). - -- QresFEP.py: module to generate protein FEP calculations using a -single topology approach, -see Jespers et al. (https://doi.org/10.1021/acs.jctc.9b00538). - -Future versions will include QLIE, dual topology QresFEP and several -translation tools for new forcefields (at the moment we support opls, -charmm,amber and openFF). - -A few toplevel scripts are included in the scripts folder to facilitate -high throughput setup. Additionally, a tutorials folder is included -with a detailed description of the setup procedure as published in -Jespers et al. (QresFEP/QligFEP). This tutorial includes the generation -of ligand parameters using OPLS, how to prepare a protein system, and -how to run ligand and protein FEP calculations. These examples are -based on ligand binding of CDk2 inhibitors. - -# Installing QligFEP and QresFEP - -- Install a working version of Q, e.g.: - - - - -- Clone this repository: - - git clone https://github.com/qusers/qligfep.git - -- Create the shipped conda environment - - conda env create -f environment.yml - -In settings.py: - -- Change SCHROD_DIR to the Schrodinger location, if you want to be -able to generate OPLS ligand parameters using ffld_server. - -- Change Q_DIR to the location of the q executables. This can be -particularly useful if you use setupFEP from a local machine on -a mounted directory. (In which case, the executables of the preparation -part and running part of Q are at several places). - -- You can add slurm specific parameters in the CLUSTER INPUTS section, -according to the given example. - -## Requirements -- ffld_server -- cgenff -- Protein Preparation Wizard -- Python3.10 -- Q - -contact: [Willem Jespers (PhD)](mailto:w.jespers@lacdr.leidenuniv.nl?subject=[QLigFEP]%20[QResFEP]) - diff --git a/src/QligFEP/analysis_plotting.py b/src/QligFEP/analysis_plotting.py index 6a651972..c3f39965 100644 --- a/src/QligFEP/analysis_plotting.py +++ b/src/QligFEP/analysis_plotting.py @@ -238,7 +238,7 @@ def result_to_latex(res, latexify_each=False): # TODO: move this out of this me (1.04, hori_height - spacing), (1.04, hori_height - spacing * 2), ) - for txt_position, body in zip(txt_positions, text_body): + for txt_position, body in zip(txt_positions, text_body, strict=False): plt.text( *txt_position, body, diff --git a/src/QligFEP/analyze_FEP.py b/src/QligFEP/analyze_FEP.py index 1deda388..fbca5175 100644 --- a/src/QligFEP/analyze_FEP.py +++ b/src/QligFEP/analyze_FEP.py @@ -4,7 +4,6 @@ import json import os from pathlib import Path -from typing import Optional import numpy as np import pandas as pd @@ -31,7 +30,7 @@ def __init__( system: str, target_name: str, mapping_json: str, - n_lambdas: Optional[int] = None, + n_lambdas: int | None = None, allow_missing_edges: bool = False, ) -> None: """Initialize the FEP reader class. This class will store the FEP information inside diff --git a/src/QligFEP/analyze_neq.py b/src/QligFEP/analyze_neq.py index f95575aa..8e584103 100644 --- a/src/QligFEP/analyze_neq.py +++ b/src/QligFEP/analyze_neq.py @@ -26,7 +26,6 @@ import os from collections import defaultdict from pathlib import Path -from typing import Optional import numpy as np import pandas as pd @@ -75,7 +74,7 @@ def dF_to_kcal(dF: float, work_units: str, temperature: float) -> float: WORK_TAIL_BYTES = 256 * 1024 -def read_final_work(log_path: str) -> Optional[float]: +def read_final_work(log_path: str) -> float | None: """Read the final accumulated switching work from a qdyn NEQ-mode log. Each switch prints ``At step N, work accumulated was ...`` every output interval; @@ -101,7 +100,7 @@ def read_final_work(log_path: str) -> Optional[float]: if "work accumulated" in line: try: return float(line.split()[6]) - except (IndexError, ValueError): + except IndexError, ValueError: logger.warning(f"Could not parse the work value in {log_path}") return None return None @@ -264,7 +263,7 @@ def _dF_by_rep_from_works(by_rep: dict, beta: float, work_units: str, temperatur continue try: result[rep] = dF_to_kcal(bar_delta_f(forward, reverse, beta), work_units, temperature) - except (ValueError, RuntimeError): + except ValueError, RuntimeError: result[rep] = None return result @@ -573,7 +572,7 @@ def populate_mapping_json(df: pd.DataFrame, mapping_json: str, output_file: str) logger.info(f"Injected NEQ ddG into {matched} edge(s); wrote {output_file}") -def _nan_to_none(value) -> Optional[float]: +def _nan_to_none(value) -> float | None: """Return ``None`` for a missing/NaN value (so it serializes to JSON null), else a float.""" return None if pd.isna(value) else float(value) diff --git a/src/QligFEP/chemIO.py b/src/QligFEP/chemIO.py index 8b0c4e7d..81ae388b 100644 --- a/src/QligFEP/chemIO.py +++ b/src/QligFEP/chemIO.py @@ -1,7 +1,7 @@ from io import StringIO from itertools import product from pathlib import Path -from typing import Generator, Optional, Union # noqa: UP035 +from typing import Generator # noqa: UP035 import pandas as pd import py3Dmol @@ -55,7 +55,7 @@ def __init__(self, lig, pattern: str = "*.sdf", reindex_hydrogens: bool = True): self.setup_mols_and_names(self.lig, pattern) self.parse_sdf_contents() # add the sdf content to the dictionary - def __getitem__(self, name: str) -> Optional[Molecule]: + def __getitem__(self, name: str) -> Molecule | None: """Retrieve a molecule by its name. Args: @@ -71,12 +71,12 @@ def __getitem__(self, name: str) -> Optional[Molecule]: logger.warning(f"Molecule with name {name} not found.") return None - def __iter__(self) -> Generator[tuple[str, Molecule], None, None]: + def __iter__(self) -> Generator[tuple[str, Molecule]]: """Iterate over the names and the Molecule objects""" - yield from zip(self.lig_names, self.molecules) + yield from zip(self.lig_names, self.molecules, strict=False) def display_overlay( - self, *ligands: Union[str, Chem.Mol, Molecule], size=(800, 600), render=False + self, *ligands: str | Chem.Mol | Molecule, size=(800, 600), render=False ) -> py3Dmol.view: """Display the overlay of the ligands using py3Dmol. @@ -138,7 +138,7 @@ def _rdkit_to_openff(self, rdkit_mol: Chem.Mol, hydrogens_are_explicit: bool) -> rdkit_mol, hydrogens_are_explicit=hydrogens_are_explicit, allow_undefined_stereo=True ) - def _parse_mol(self, ligpath: Union[Path, str]) -> tuple[list[Molecule], list[str]]: + def _parse_mol(self, ligpath: Path | str) -> tuple[list[Molecule], list[str]]: """Parse a .sdf file into a list of Molecule objects and their names. Loads via RDKit first to inspect the original hydrogen state, then converts @@ -193,7 +193,7 @@ def _parse_mol(self, ligpath: Union[Path, str]) -> tuple[list[Molecule], list[st if self._reindex_hydrogens: mols = [self._force_H_reindexing(mol) for mol in mols] - for mol, name in zip(mols, lig_names): + for mol, name in zip(mols, lig_names, strict=False): mol.name = name return mols, lig_names @@ -246,7 +246,7 @@ def parse_sdf_contents(self): mol.to_file(string_buffer, file_format="sdf") self.sdf_contents.update({name: string_buffer.getvalue().splitlines()}) - def write_sdf_separate(self, output_dir, molecules: Optional[list[Molecule]] = None) -> None: + def write_sdf_separate(self, output_dir, molecules: list[Molecule] | None = None) -> None: """Function to write the separate multiple molecules within a sdf file into their own .sdf, placed under `output_dir`. @@ -273,7 +273,7 @@ def write_sdf_separate(self, output_dir, molecules: Optional[list[Molecule]] = N for mol in molecules: mol.to_file(file_path=f"{mol.name}.sdf", file_format="sdf") - def write_to_single_sdf(self, output_name: str, molecules: Optional[list[Molecule]] = None) -> None: + def write_to_single_sdf(self, output_name: str, molecules: list[Molecule] | None = None) -> None: """Writes all `self.molecules` to a single `.sdf` file. Args: @@ -304,7 +304,7 @@ def write_to_single_pdb(self, output_name: str, init_offset: int = 0) -> pd.Data lig_resn = ["LI", "LG", "LH"] last_lig_resn = [d for d in "ABCDEFGHIJKLMNOPQRSTUVWXYZ"] lig_resnames = ["".join(i) for i in product(lig_resn, last_lig_resn)] - for mol, resn in zip(self.molecules, lig_resnames): + for mol, resn in zip(self.molecules, lig_resnames, strict=False): # write the molecule pdb lines in memory and convert them to pd.DataFrame output = StringIO() mol.to_file(output, file_format="pdb") diff --git a/src/QligFEP/conformer_generator.py b/src/QligFEP/conformer_generator.py index 7bc50eda..7c8781ae 100644 --- a/src/QligFEP/conformer_generator.py +++ b/src/QligFEP/conformer_generator.py @@ -8,7 +8,6 @@ """ from pathlib import Path -from typing import Union from rdkit import Chem from rdkit.Chem import AllChem, rdFMCS @@ -38,7 +37,7 @@ class BiasedConformerGenerator(MoleculeIO): def __init__( self, - references: Union[str, Path, MoleculeIO], + references: str | Path | MoleculeIO, reindex_hydrogens: bool = True, mcs_atom_compare: str = "any", mcs_bond_compare: str = "any", diff --git a/src/QligFEP/lig_aligner.py b/src/QligFEP/lig_aligner.py index b2e1821b..0c66a9a9 100644 --- a/src/QligFEP/lig_aligner.py +++ b/src/QligFEP/lig_aligner.py @@ -5,7 +5,7 @@ from concurrent.futures import ThreadPoolExecutor, as_completed from functools import partial from pathlib import Path -from typing import Any, Optional, Union +from typing import Any from openff.toolkit import Molecule from rdkit import Chem @@ -45,12 +45,12 @@ def __init__( pattern: str = f"*{SDF_EXTENSION}", reindex_hydrogens: bool = True, n_threads: int = 1, - protein: Optional[str] = None, + protein: str | None = None, energy: str = "a", search: str = "f", steep_descend: bool = True, connectivity: str = "t", - top_constraint_tol: Optional[int] = None, + top_constraint_tol: int | None = None, atom_type: str = "X", bond_type: str = "X", scaffold_lock: bool = False, @@ -114,10 +114,10 @@ def __init__( super().__init__(lig, pattern=pattern, reindex_hydrogens=reindex_hydrogens) self.kcombu_exe = self._set_fkcombu_exe() self.n_threads = n_threads - self.reference_mol: Optional[Molecule] = None + self.reference_mol: Molecule | None = None self.aligned_molecules: dict[str, Molecule] = {} self.alignment_scores: dict[str, dict[str, float]] = {} - self.temp_dir: Optional[tempfile.TemporaryDirectory] = None + self.temp_dir: tempfile.TemporaryDirectory | None = None self.fkparams = self._process_fkparams( { "P": protein, @@ -373,10 +373,10 @@ def _transfer_charges_metadata(molA, molB): matchB = molB.GetSubstructMatch(mcs_mol) logger.trace("Mapping of atoms:") - for a, b in zip(matchA, matchB): # trace the mapping; used for debugging + for a, b in zip(matchA, matchB, strict=False): # trace the mapping; used for debugging logger.trace(f"MolA atom {a} maps to MolB atom {b}") - for a, b in zip(matchA, matchB): # iterate atoms and transfer charges + for a, b in zip(matchA, matchB, strict=False): # iterate atoms and transfer charges atomA = molA.GetAtomWithIdx(a) atomB = molB.GetAtomWithIdx(b) formal_charge = atomA.GetFormalCharge() @@ -403,7 +403,7 @@ def _transfer_sdf_metadata(self, original_file: Path, aligned_file: Path): aligned_supplier = Chem.SDMolSupplier(str(aligned_file), removeHs=True, sanitize=False) aligned_mols = [] - for original_mol, aligned_mol in zip(original_supplier, aligned_supplier): + for original_mol, aligned_mol in zip(original_supplier, aligned_supplier, strict=False): if original_mol is not None and aligned_mol is not None: Chem.SanitizeMol(aligned_mol, sanitizeOps=Chem.SanitizeFlags.SANITIZE_SETAROMATICITY) @@ -423,7 +423,7 @@ def _transfer_sdf_metadata(self, original_file: Path, aligned_file: Path): aligned_writer.close() def align_single_molecule( - self, molecule: Union[str, Molecule], reference: Union[str, Molecule] + self, molecule: str | Molecule, reference: str | Molecule ) -> tuple[Molecule, dict[str, float]]: """ Align a single molecule to a reference molecule. @@ -464,7 +464,7 @@ def align_single_molecule( return aligned_molecule, scores def kcombu_align( - self, reference: Union[str, Molecule], molecules_to_align: Optional[list[Union[str, Molecule]]] = None + self, reference: str | Molecule, molecules_to_align: list[str | Molecule] | None = None ) -> list[Molecule]: """ Aligns the specified molecules to a reference molecule using kcombu. The aligned molecules returned @@ -531,9 +531,7 @@ def kcombu_align( self.cleanup() return aligned_ligands - def output_aligned_ligands( - self, output_name: str, ref_names: Optional[Union[str, list[str]]] = None - ) -> None: + def output_aligned_ligands(self, output_name: str, ref_names: str | list[str] | None = None) -> None: """ Write the aligned molecules to a single .sdf file, optionally including the original reference ligand(s). @@ -575,7 +573,7 @@ def output_aligned_ligands( self.temp_dir.cleanup() logger.info("Temporary directory cleaned up") - def get_molecule(self, name: str, aligned: bool = True) -> Optional[Molecule]: + def get_molecule(self, name: str, aligned: bool = True) -> Molecule | None: """ Retrieve a molecule by name, either aligned or original. @@ -642,11 +640,11 @@ def __init__( self.opt_param = opt_param self.max_preiters = max_preiters self.max_postiters = max_postiters - self.reference_mol: Optional[Molecule] = None + self.reference_mol: Molecule | None = None self.aligned_molecules: dict[str, Molecule] = {} self.alignment_scores: dict[str, tuple[float, float]] = {} - def _resolve_molecule(self, mol_or_name: Union[str, Molecule]) -> Molecule: + def _resolve_molecule(self, mol_or_name: str | Molecule) -> Molecule: """Resolve a molecule from a name string or return the Molecule directly. Args: @@ -687,7 +685,7 @@ def _align_rdkit_mol(self, ref_rdkit: Chem.Mol, probe_rdkit: Chem.Mol) -> tuple[ return shape_tani, color_tani def align_single_molecule( - self, molecule: Union[str, Molecule], reference: Union[str, Molecule] + self, molecule: str | Molecule, reference: str | Molecule ) -> tuple[Molecule, float, float]: """Align a single molecule to a reference molecule. @@ -712,7 +710,7 @@ def align_single_molecule( return aligned_mol, shape_tani, color_tani def align( - self, reference: Union[str, Molecule], molecules_to_align: Optional[list[Union[str, Molecule]]] = None + self, reference: str | Molecule, molecules_to_align: list[str | Molecule] | None = None ) -> dict[str, Molecule]: """Align molecules to a reference using shape+color overlap. @@ -766,9 +764,7 @@ def align( return self.aligned_molecules - def output_aligned_ligands( - self, output_name: str, ref_names: Optional[Union[str, list[str]]] = None - ) -> None: + def output_aligned_ligands(self, output_name: str, ref_names: str | list[str] | None = None) -> None: """Write aligned molecules to a single SDF file, including alignment scores as SD properties. Args: @@ -803,7 +799,7 @@ def output_aligned_ligands( writer.close() logger.info(f"Aligned molecules written to {output_name}") - def get_molecule(self, name: str, aligned: bool = True) -> Optional[Molecule]: + def get_molecule(self, name: str, aligned: bool = True) -> Molecule | None: """Retrieve a molecule by name, either aligned or original. Args: diff --git a/src/QligFEP/openff2Q.py b/src/QligFEP/openff2Q.py index b7d8f453..8779b7d5 100644 --- a/src/QligFEP/openff2Q.py +++ b/src/QligFEP/openff2Q.py @@ -2,7 +2,7 @@ from io import StringIO from pathlib import Path -from typing import Optional, TextIO +from typing import TextIO import numpy as np from joblib import Parallel, delayed, parallel_config @@ -104,7 +104,7 @@ def __init__( self.total_charges = {} # store the total charges self._set_nagl(nagl=nagl, nagl_model=nagl_model) - def _set_forcefield(self, ffstring: Optional[str]) -> ForceField: + def _set_forcefield(self, ffstring: str | None) -> ForceField: if ffstring is None: # why not the constrained: https://docs.openforcefield.org/projects/toolkit/en/stable/faq.html ffstring = "openff-2.3.0.offxml" @@ -148,7 +148,7 @@ def _assign_charge(self, molecule: Molecule) -> np.ndarray: def set_topologies_and_parameters(self): topologies = {} parameters = {} - for lname, mol in zip(self.lig_names, self.molecules): + for lname, mol in zip(self.lig_names, self.molecules, strict=False): topology = Topology.from_molecules(mol) topologies.update({lname: topology}) parameters.update({lname: self.forcefield.label_molecules(topology)[0]}) @@ -163,7 +163,7 @@ def process_ligands(self) -> None: charges_magnitudes = Parallel()(delayed(self._assign_charge)(molecule) for molecule in molecules) logger.info("Done! Writing .lib, .prm and .pdb files for each ligand") logger.debug(f"Output path: {self.out_dir}") - for lname, charges in zip(self.lig_names, charges_magnitudes): + for lname, charges in zip(self.lig_names, charges_magnitudes, strict=False): charges = round_charges_preserving_sum(charges) self.charges_list_magnitude.update({lname: charges}) formatted_sum = f"{charges.sum():.3f}" @@ -243,8 +243,8 @@ def create_atom_prm_mapping(self, lname): def write_lib_Q( self, lname: str, - outfile: Optional[TextIO] = None, - prefix: Optional[str] = None, + outfile: TextIO | None = None, + prefix: str | None = None, residue_name: str = "LIG", ): """Writes Q's .lib file for a given ligand. @@ -302,7 +302,7 @@ def write_lib_Q( if should_close: outfile.close() - def write_prm_Q(self, lname: str, outfile: Optional[TextIO] = None, prefix: Optional[str] = None): + def write_prm_Q(self, lname: str, outfile: TextIO | None = None, prefix: str | None = None): """Writes Q's .prm file for a given ligand. Args: @@ -316,7 +316,7 @@ def write_prm_Q(self, lname: str, outfile: Optional[TextIO] = None, prefix: Opti Defaults to "LIG". """ - def insert_prefix(at_name, prefix: Optional[str]): + def insert_prefix(at_name, prefix: str | None): if prefix is not None: return prefix + at_name return at_name @@ -442,7 +442,7 @@ def insert_prefix(at_name, prefix: Optional[str]): if should_close: outfile.close() - def write_PDB(self, lname: str, outfile: Optional[TextIO] = None, residue_name: str = "LIG"): + def write_PDB(self, lname: str, outfile: TextIO | None = None, residue_name: str = "LIG"): """Writes pdb file for a given ligand. Args: @@ -514,7 +514,7 @@ def write_cofactor_plus_ff_files(self, ff: str): ) lig_prm_contents = {} - for name, prefix, res in zip(self.lig_names, prefixes, residues): + for name, prefix, res in zip(self.lig_names, prefixes, residues, strict=False): lib_out = StringIO() self.write_lib_Q(name, outfile=lib_out, prefix=prefix, residue_name=res) lib_lines = lib_out.getvalue().split("\n") diff --git a/src/QligFEP/pdb_utils.py b/src/QligFEP/pdb_utils.py index 7cb227fe..3c50a981 100644 --- a/src/QligFEP/pdb_utils.py +++ b/src/QligFEP/pdb_utils.py @@ -5,7 +5,6 @@ import warnings from pathlib import Path from string import ascii_uppercase -from typing import Optional, Union import MDAnalysis as mda import numpy as np @@ -21,10 +20,10 @@ def rm_HOH_clash_NN( pdb_df_query: pd.DataFrame, pdb_df_target: pd.DataFrame, th: float = 2.5, - output_file: Union[str, Path] = None, + output_file: str | Path = None, heavy_only: bool = True, ligand_only: bool = False, - header: Optional[str] = None, + header: str | None = None, save_removed: bool = False, ): """Use a NearestNeighbors approach to find water molecules within a distance threshold @@ -189,11 +188,11 @@ def next_chain_id(existing_ids): def append_pdb_to_another( - main_pdb: Union[pd.DataFrame, str, list[str]], - to_append_pdb: Union[pd.DataFrame, str, list[str]], - save_pdb: Optional[str] = None, + main_pdb: pd.DataFrame | str | list[str], + to_append_pdb: pd.DataFrame | str | list[str], + save_pdb: str | None = None, assign_new_chain: bool = False, - new_ligname: Optional[str] = None, + new_ligname: str | None = None, ignore_waters: bool = False, ) -> pd.DataFrame: """Reads the two pdbs as DataFrames, appends the second to the end of the protein @@ -370,7 +369,9 @@ def disulfide_search(npdb, min_dist=1.8, max_dist_cyx=4.0, max_dist_cys=2.5): # Find disulfide pairs with appropriate distance cutoffs for ii, res_i in enumerate(cys_residues): for res_j in cys_residues[ii + 1 :]: - distance = math.sqrt(sum((a - b) ** 2 for a, b in zip(res_i["coords"], res_j["coords"]))) + distance = math.sqrt( + sum((a - b) ** 2 for a, b in zip(res_i["coords"], res_j["coords"], strict=False)) + ) # Use wide range only when both residues are CYX/CYD (confirmed disulfide # partners). Mixed CYX+CYS pairs use the strict cutoff to avoid false @@ -407,7 +408,7 @@ def get_coords(atomname, residue): def calculate_distance(atom_coords, center_coords) -> float: - return math.sqrt(sum((a - b) ** 2 for a, b in zip(atom_coords, center_coords))) + return math.sqrt(sum((a - b) ** 2 for a, b in zip(atom_coords, center_coords, strict=False))) def _convert_to(value, dtype): @@ -477,7 +478,7 @@ def read_pdb_to_dataframe(pdb_file): return df -def residue_atom_serial_range(pdb_df, residue_names: Union[str, list[str]]) -> Optional[tuple[int, int]]: +def residue_atom_serial_range(pdb_df, residue_names: str | list[str]) -> tuple[int, int] | None: """Return the (first, last) atom serial numbers for the given residue name(s). Returns None when no atoms match, so callers can skip building restraints @@ -497,7 +498,7 @@ def residue_atom_serial_range(pdb_df, residue_names: Union[str, list[str]]) -> O def write_dataframe_to_pdb( - df, output_file, header: Optional[str] = None, ter_after_indices: Optional[set[int]] = None + df, output_file, header: str | None = None, ter_after_indices: set[int] | None = None ): """Save a DataFrame object created from read_pdb_to_dataframe function to a PDB file. diff --git a/src/QligFEP/qligfep.py b/src/QligFEP/qligfep.py index 2d6c678a..fa63f080 100644 --- a/src/QligFEP/qligfep.py +++ b/src/QligFEP/qligfep.py @@ -5,7 +5,7 @@ import shutil import stat from pathlib import Path -from typing import Literal, Optional, Union +from typing import Literal import numpy as np import pandas as pd @@ -64,7 +64,7 @@ COUNTER_WATER_RESNAME = "CWT" -def lrf_required_for_edge(same_charge: "bool | None") -> bool: +def lrf_required_for_edge(same_charge: bool | None) -> bool: """Whether this edge must run with LRF on. A charge-changing edge (``same_charge`` is False) changes the in-sphere net charge, whose @@ -96,10 +96,10 @@ def __init__( replicates: str = "10", sampling: Literal["sigmoidal", "linear", "exponential", "reverse_exponential"] = "sigmoidal", timestep: Literal["1fs", "2fs"] = "2fs", - to_clean: Optional[list[str]] = None, - water_thresh: Union[float, int] = 1.4, + to_clean: list[str] | None = None, + water_thresh: float | int = 1.4, dr_force: float = 0.5, - random_state: Optional[int] = 42, + random_state: int | None = 42, wath_ligand_only: bool = False, neq: bool = False, neq_reps: int = 5, @@ -108,7 +108,7 @@ def __init__( neq_relax_steps: int = 5000, neq_L: float = 8.0, neq_schedule: Literal["sigmoidal", "linear"] = "sigmoidal", - protein_charge: Optional[int] = None, + protein_charge: int | None = None, charge_method: str = "ion_match", ): self.timestep = timestep @@ -159,7 +159,7 @@ def __init__( raise ValueError(f"charge_method={charge_method!r} not in {valid_charge_methods}") self.charge_method = charge_method # Populated by read_files() once formal charges are known. - self.same_charge: Optional[bool] = None + self.same_charge: bool | None = None # Co-alchemical water state, populated by place_counter_water() when # charge_method == "coalchemical_water". Each entry is a dict with # keys "topology_indices" (3 ints) and "qatoms" (3 Q-atom descriptors). @@ -175,7 +175,7 @@ def __init__( try: resnr = int(line[22:26]) atnr = int(line[6:11]) - except (IndexError, ValueError): + except IndexError, ValueError: continue break self.residueoffset = resnr @@ -594,7 +594,7 @@ def place_counter_ions(self, writedir: str) -> int: try: last_atnr = max(last_atnr, int(line[6:11])) last_resnr = max(last_resnr, int(line[22:26])) - except (ValueError, IndexError): + except ValueError, IndexError: continue # Append ion ATOM lines to the combined PDB @@ -1605,7 +1605,7 @@ def write_runfile(self, writedir, file_list): elif self.start == "0.5": outfile.write(f"{mpirun} md_0500_0500.inp > md_0500_0500.log\n\n") - for md1, md2 in zip(md_1, md_2): + for md1, md2 in zip(md_1, md_2, strict=False): outfile.write(f"{mpirun} {md1[:-4]}.inp > {md1[:-4]}.log\n") outfile.write(f"{mpirun} {md2[:-4]}.inp > {md2[:-4]}.log\n") outfile.write("\n") @@ -1629,7 +1629,7 @@ def write_qfep(self, windows, lambdas): with open(qfep_out, "w") as outfile: outfile.write(content) - def avoid_water_protein_clashes(self, writedir, header: Optional[str] = None, save_removed: bool = False): + def avoid_water_protein_clashes(self, writedir, header: str | None = None, save_removed: bool = False): """Function to remove water molecules too close to protein & ligands | ligands (water leg). Thresholds are the distances in Ångström from the protein & ligands | ligands atoms to the nearest heavy atom in the water molecule (HOH). diff --git a/src/QligFEP/qmapfep.py b/src/QligFEP/qmapfep.py index 0b84699a..3b14765e 100644 --- a/src/QligFEP/qmapfep.py +++ b/src/QligFEP/qmapfep.py @@ -8,7 +8,7 @@ from collections import namedtuple from functools import cached_property, lru_cache from pathlib import Path -from typing import Literal, Optional +from typing import Literal import networkx as nx from networkx.readwrite import json_graph @@ -345,7 +345,9 @@ def png(self): self._remove_hs() # Does this three belongs here ?? self._reorient_molecule() # # Loop over R groups. - for color, (label, group) in zip(self.palette, self.pool.groups[self.pool_idx].items()): + for color, (label, group) in zip( + self.palette, self.pool.groups[self.pool_idx].items(), strict=False + ): if label == "Core": continue self._highlight_residue(group, color, highlights) @@ -1020,11 +1022,11 @@ def makeplot_dG(self): class Init: def __init__( self, - input_sdf: Optional[str] = None, - input_json: Optional[str] = None, + input_sdf: str | None = None, + input_json: str | None = None, metric: Literal["MFP", "Tanimoto", "MCS", "SMILES"] = "Tanimoto", - output: Optional[str] = None, - wrkdir: Optional[str] = None, + output: str | None = None, + wrkdir: str | None = None, ): metric = metric o = output diff --git a/src/QligFEP/restraints/restraint_setter.py b/src/QligFEP/restraints/restraint_setter.py index 2178ddbd..9226b029 100644 --- a/src/QligFEP/restraints/restraint_setter.py +++ b/src/QligFEP/restraints/restraint_setter.py @@ -3,7 +3,6 @@ from copy import deepcopy from itertools import zip_longest from pathlib import Path -from typing import Union import numpy as np from kartograf import KartografAtomMapper, SmallMoleculeComponent @@ -53,7 +52,7 @@ def __init__(self, molA: str, molB: str, kartograf_max_atom_distance: float | in @staticmethod def input_to_small_molecule_component( - input_molecule: Union[Molecule, Chem.Mol, str, Path], + input_molecule: Molecule | Chem.Mol | str | Path, ) -> SmallMoleculeComponent: if isinstance(input_molecule, SmallMoleculeComponent): mol = input_molecule @@ -257,7 +256,7 @@ def is_ring_equivalent( return False # Further check the atomic number and connectivity - for a, b in zip(ring_atoms_a_indices, ring_atoms_b_indices): + for a, b in zip(ring_atoms_a_indices, ring_atoms_b_indices, strict=False): logger.trace(f"Comparison method: {compare_method}") atom_a = mol_a.GetAtomWithIdx(a) atom_b = mol_b.GetAtomWithIdx(b) diff --git a/src/QligFEP/visualization.py b/src/QligFEP/visualization.py index 3341590e..d32b9590 100644 --- a/src/QligFEP/visualization.py +++ b/src/QligFEP/visualization.py @@ -1,7 +1,6 @@ """Module to hold utility visualization functions for QligFEP.""" from pathlib import Path -from typing import Optional, Union import matplotlib.pyplot as plt import py3Dmol @@ -15,15 +14,15 @@ from .restraints.restraint_setter import RestraintSetter -def mol_to_molblock(mol: Union[Molecule, Chem.Mol]) -> str: +def mol_to_molblock(mol: Molecule | Chem.Mol) -> str: if isinstance(mol, Molecule): mol = mol.to_rdkit() return Chem.MolToMolBlock(mol) def render_system( - molecules: list[Union[Molecule, Chem.Mol]], - protein_path: Optional[Union[Path, str]] = None, + molecules: list[Molecule | Chem.Mol], + protein_path: Path | str | None = None, protein_style: str = "stick", size: tuple[int, int] = (600, 500), ) -> None: @@ -83,8 +82,8 @@ def render_system( # Credit to: https://github.com/OpenFreeEnergy/openfe/blob/main/openfe/utils/visualization_3D.py # And to: https://github.com/OpenFreeEnergy/kartograf/blob/main/src/kartograf/utils/mapping_visualization_widget.py def render_ligand_restraints( - ligand1: Union[Chem.Mol, Molecule, SmallMoleculeComponent], - ligand2: Union[Chem.Mol, Molecule, SmallMoleculeComponent], + ligand1: Chem.Mol | Molecule | SmallMoleculeComponent, + ligand2: Chem.Mol | Molecule | SmallMoleculeComponent, restraint_mapping: dict[int, int], show_atom_idxs: bool = True, size: tuple[int, int] = (900, 500), @@ -177,8 +176,8 @@ def add_spheres(view, mol, mapping, is_ligand1=True, viewer=(0, 0)): def apply_and_render_restraint( - ligand1: Union[Molecule, Chem.Mol, str, Path], - ligand2: Union[Molecule, Chem.Mol, str, Path], + ligand1: Molecule | Chem.Mol | str | Path, + ligand2: Molecule | Chem.Mol | str | Path, restraint_method: str = "hybridization_p", show_atom_idxs: bool = True, size: tuple[int, int] = (900, 500), diff --git a/test/conftest.py b/test/conftest.py index 73f3d4c1..f4b76d0e 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -300,7 +300,7 @@ def tyk2_tutorial_path(tutorials_path: Path) -> Path: @pytest.fixture -def temp_work_dir() -> Generator[Path, None, None]: +def temp_work_dir() -> Generator[Path]: """Create a temporary working directory that is cleaned up after the test.""" temp_dir = Path(tempfile.mkdtemp()) try: @@ -611,7 +611,7 @@ def compare_inp_files(self, actual: dict, expected: dict, ignore_keys: list[str] f"[{section}] length mismatch: expected {len(expected_section)}, got {len(actual_section)}" ) else: - for i, (exp_line, act_line) in enumerate(zip(expected_section, actual_section)): + for i, (exp_line, act_line) in enumerate(zip(expected_section, actual_section, strict=False)): if exp_line != act_line: differences.append(f"[{section}][{i}]: expected '{exp_line}', got '{act_line}'") diff --git a/test/neq/test_analysis_plotting.py b/test/neq/test_analysis_plotting.py index 4029e0fa..b42c4d24 100644 --- a/test/neq/test_analysis_plotting.py +++ b/test/neq/test_analysis_plotting.py @@ -66,7 +66,7 @@ def test_create_ddG_plot_axis_bounds_include_all_points(): fig, ax = create_ddG_plot(df, target_name="test") xlo, xhi = ax.get_xlim() ylo, yhi = ax.get_ylim() - for exp, calc in zip(df["ddg_value"], df["Q_ddG_avg"]): + for exp, calc in zip(df["ddg_value"], df["Q_ddG_avg"], strict=False): assert xlo <= exp <= xhi, f"exp {exp} outside x-range [{xlo}, {xhi}]" assert ylo <= calc <= yhi, f"calc {calc} outside y-range [{ylo}, {yhi}]" diff --git a/test/qligfep/test_fep_input_files.py b/test/qligfep/test_fep_input_files.py index 4e216875..180f23c9 100644 --- a/test/qligfep/test_fep_input_files.py +++ b/test/qligfep/test_fep_input_files.py @@ -155,7 +155,9 @@ def test_distance_restraints_match_golden(self, generated_fep_dir: Path): ), f"Distance restraints count mismatch: expected {len(golden_restraints)}, got {len(actual_restraints)}" # Compare each restraint line - for i, (golden_line, actual_line) in enumerate(zip(golden_restraints, actual_restraints)): + for i, (golden_line, actual_line) in enumerate( + zip(golden_restraints, actual_restraints, strict=False) + ): assert ( actual_line == golden_line ), f"Distance restraint {i} mismatch:\n expected: {golden_line}\n actual: {actual_line}" diff --git a/test/qligfep/test_qprep_cli.py b/test/qligfep/test_qprep_cli.py index 8858672a..5fffa770 100644 --- a/test/qligfep/test_qprep_cli.py +++ b/test/qligfep/test_qprep_cli.py @@ -350,6 +350,7 @@ def test_dna_with_c1_inside_sphere_unchanged(self): for (aname, elem), (px, py, pz) in zip( [("P", "P"), ("OP1", "O"), ("OP2", "O"), ("O5'", "O"), ("C1'", "C")], positions, + strict=False, ): rows.append(_make_atom("ATOM", serial, aname, "DA", "E", 1, px, py, pz, elem)) serial += 1