From 397b71144ee2ec80c61f336981fd7ec52b0dd798 Mon Sep 17 00:00:00 2001 From: David-Araripe Date: Tue, 8 Sep 2026 11:13:10 +0200 Subject: [PATCH 1/4] remove stale readme inside src/ --- src/QligFEP/README.md | 66 ------------------------------------------- 1 file changed, 66 deletions(-) delete mode 100644 src/QligFEP/README.md 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]) - From e82aff81edee1f9b84fd0d983df48d2c080c00a4 Mon Sep 17 00:00:00 2001 From: David-Araripe Date: Tue, 8 Sep 2026 23:33:25 +0200 Subject: [PATCH 2/4] refactor(python): apply Python 3.14 syntax upgrades Modernize type annotations, explicit zip behavior, and exception syntax for the Python 3.14 formatter and Ruff targets. --- src/QligFEP/CLI/cog_cli.py | 2 +- src/QligFEP/CLI/konnektor_cli.py | 15 ++++---- src/QligFEP/CLI/lomap_wrap_cli.py | 5 ++- src/QligFEP/CLI/qligfep_cli.py | 3 +- src/QligFEP/CLI/setupFEP.py | 5 ++- src/QligFEP/IO.py | 6 ++-- src/QligFEP/analysis_plotting.py | 2 +- src/QligFEP/analyze_FEP.py | 3 +- src/QligFEP/analyze_neq.py | 9 +++-- src/QligFEP/chemIO.py | 20 +++++------ src/QligFEP/conformer_generator.py | 3 +- src/QligFEP/lig_aligner.py | 40 ++++++++++------------ src/QligFEP/openff2Q.py | 20 +++++------ src/QligFEP/pdb_utils.py | 23 +++++++------ src/QligFEP/qligfep.py | 22 ++++++------ src/QligFEP/qmapfep.py | 14 ++++---- src/QligFEP/restraints/restraint_setter.py | 5 ++- src/QligFEP/visualization.py | 15 ++++---- test/conftest.py | 4 +-- test/neq/test_analysis_plotting.py | 2 +- test/qligfep/test_fep_input_files.py | 4 ++- test/qligfep/test_qprep_cli.py | 1 + 22 files changed, 108 insertions(+), 115 deletions(-) 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/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 From fde77b833b07c912d13e4f1621f3b3b5cbd8604d Mon Sep 17 00:00:00 2001 From: David-Araripe Date: Tue, 8 Sep 2026 23:33:50 +0200 Subject: [PATCH 3/4] chore(python): raise baseline to Python 3.14 Align packaging metadata, Black, Ruff, and isort with the Python 3.14 minimum and 110-character formatting policy. --- pyproject.toml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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 = [ From a26936bb60c0c46f1b662ab1baf7bb626a76f278 Mon Sep 17 00:00:00 2001 From: David-Araripe Date: Tue, 8 Sep 2026 23:34:07 +0200 Subject: [PATCH 4/4] docs(install): require Python 3.14 and compatible deps Update Linux and macOS instructions to use OpenMM 8.4, OpenFF Toolkit 0.18.1, and OpenFF NAGL 0.5.5, the earliest compatible direct pins verified for Python 3.14. --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 . ```