From 59fafb946eb525f82727d0ad5bc8f6d9fa58c7c9 Mon Sep 17 00:00:00 2001 From: roshkjr Date: Mon, 29 Jun 2026 13:08:31 +0100 Subject: [PATCH 1/7] improve reading of ccds from components file --- pdbeccdutils/core/ccd_reader.py | 39 ++++++++++++++++++++++----------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/pdbeccdutils/core/ccd_reader.py b/pdbeccdutils/core/ccd_reader.py index 3365e57..d756c01 100644 --- a/pdbeccdutils/core/ccd_reader.py +++ b/pdbeccdutils/core/ccd_reader.py @@ -28,7 +28,7 @@ import logging import os from datetime import date -from typing import Dict, List, NamedTuple +from typing import Collection, Dict, List, NamedTuple, Optional import rdkit from pdbeccdutils.core.component import Component @@ -101,7 +101,7 @@ def read_pdb_cif_file(path_to_cif: str, sanitize: bool = True) -> CCDReaderResul def read_pdb_components_file( - path_to_cif: str, sanitize: bool = True, include: list[str] = [] + path_to_cif: str, sanitize: bool = True, include: Optional[Collection[str]] = None ) -> Dict[str, CCDReaderResult]: """ Process multiple compounds stored in the wwPDB CCD @@ -112,8 +112,10 @@ def read_pdb_components_file( multiple ligands in it. sanitize (bool): Whether or not the components should be sanitized Defaults to True. - include (list[str]): List of CCDs to be parsed. By default it is empty and parse - all the CCDs. If a list of CCDs provided, will only parse them + include (Optional[Collection[str]]): List of CCDs to be parsed. By default it + is None and parses all the CCDs. If a collection of CCDs is provided, only + those CCDs are parsed. Empty collections parse all CCDs, matching the + previous behavior. Raises: ValueError: if the file does not exist. @@ -126,16 +128,27 @@ def read_pdb_components_file( raise ValueError("File '{}' does not exists".format(path_to_cif)) result_bag = {} - for block in cif.read(path_to_cif): - if (block.name in include) or (len(include) == 0): - try: - result_bag[block.name] = _parse_pdb_mmcif(block, sanitize) - except CCDUtilsError as e: - logging.error( - f"ERROR: Data block {block.name} not processed. Reason: ({str(e)})." - ) - return result_bag + doc = cif.read(path_to_cif) + if not include: + blocks = doc + else: + blocks = [] + for block_name in dict.fromkeys(include): + block = doc.find_block(block_name) + if block is None: + logging.warning(f"Data block {block_name} not found in {path_to_cif}.") + continue + blocks.append(block) + + for block in blocks: + try: + result_bag[block.name] = _parse_pdb_mmcif(block, sanitize) + except CCDUtilsError as e: + logging.error( + f"ERROR: Data block {block.name} not processed. Reason: ({str(e)})." + ) + return result_bag # region parse mmcif From 3047bc32bab737a5a0b870e50f42cafd84ccb31a Mon Sep 17 00:00:00 2001 From: roshkjr Date: Mon, 29 Jun 2026 13:17:55 +0100 Subject: [PATCH 2/7] improve reading prds from PRD dictionary --- pdbeccdutils/core/prd_reader.py | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/pdbeccdutils/core/prd_reader.py b/pdbeccdutils/core/prd_reader.py index d408889..6ff451e 100644 --- a/pdbeccdutils/core/prd_reader.py +++ b/pdbeccdutils/core/prd_reader.py @@ -18,7 +18,7 @@ import logging import os -from typing import Dict +from typing import Collection, Dict, Optional import rdkit from pdbeccdutils.core.component import Component from pdbeccdutils.core import ccd_reader @@ -69,31 +69,47 @@ def read_pdb_cif_file( def read_pdb_components_file( - path_to_cif: str, sanitize: bool = True + path_to_cif: str, sanitize: bool = True, include: Optional[Collection[str]] = None ) -> Dict[str, ccd_reader.CCDReaderResult]: """ - Process multiple compounds stored in the wwPDB CCD - `components.cif` file. + Process multiple compounds stored in the wwPDB PRD + `prdcc-all.cif` file. Args: path_to_cif (str): Path to the `prdcc-all.cif` file with multiple ligands in it. sanitize (bool): Whether or not the components should be sanitized Defaults to True. + include (Optional[Collection[str]]): List of PRDs to be parsed. By default it + is None and parses all the PRDs. If a collection of PRDs is provided, only + those PRDs are parsed. Empty collections parse all PRDs, matching the + previous behavior. Raises: ValueError: if the file does not exist. Returns: dict[str, CCDReaderResult]: Internal representation of all - the components in the `components.cif` file. + the components in the `prdcc-all.cif` file. """ if not os.path.isfile(path_to_cif): raise ValueError("File '{}' does not exists".format(path_to_cif)) result_bag = {} + doc = cif.read(path_to_cif) - for block in cif.read(path_to_cif): + if not include: + blocks = doc + else: + blocks = [] + for block_name in dict.fromkeys(include): + block = doc.find_block(block_name) + if block is None: + logging.warning(f"Data block {block_name} not found in {path_to_cif}.") + continue + blocks.append(block) + + for block in blocks: try: result_bag[block.name] = _parse_pdb_mmcif(block, sanitize) except CCDUtilsError as e: From 2f190bac1983e17585376808014e6bb9fac36c39 Mon Sep 17 00:00:00 2001 From: roshkjr Date: Mon, 29 Jun 2026 13:30:48 +0100 Subject: [PATCH 3/7] [#37]: Get both atom_ids from cif before accessing their indices Fix #37 --- pdbeccdutils/core/ccd_reader.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/pdbeccdutils/core/ccd_reader.py b/pdbeccdutils/core/ccd_reader.py index d756c01..a333349 100644 --- a/pdbeccdutils/core/ccd_reader.py +++ b/pdbeccdutils/core/ccd_reader.py @@ -329,17 +329,23 @@ def _parse_pdb_bonds(mol, cif_block, errors): bonds = cif_block.find( "_chem_comp_bond.", ["atom_id_1", "atom_id_2", "value_order"] ) - atoms_ids = list(atoms.find_column("_chem_comp_atom.atom_id")) + atom_indices = { + atom_id: atom_index + for atom_index, atom_id in enumerate( + atoms.find_column("_chem_comp_atom.atom_id") + ) + } for row in bonds: + atom_1 = row["_chem_comp_bond.atom_id_1"] + atom_2 = row["_chem_comp_bond.atom_id_2"] + try: - atom_1 = row["_chem_comp_bond.atom_id_1"] - atom_1_id = atoms_ids.index(atom_1) - atom_2 = row["_chem_comp_bond.atom_id_2"] - atom_2_id = atoms_ids.index(atom_2) + atom_1_id = atom_indices[atom_1] + atom_2_id = atom_indices[atom_2] bond_order = helper.bond_pdb_order(row["_chem_comp_bond.value_order"]) mol.AddBond(atom_1_id, atom_2_id, bond_order) - except ValueError: + except (KeyError, ValueError): errors.append( f"Error perceiving {atom_1} - {atom_2} bond in _chem_comp_bond" ) From d004accfe1346e90f65dc0e1860c65c385176b7c Mon Sep 17 00:00:00 2001 From: roshkjr Date: Mon, 29 Jun 2026 14:35:00 +0100 Subject: [PATCH 4/7] Add tests for improved dictionary reader --- pdbeccdutils/tests/test_ccd_reader.py | 28 +++++++++++++++++++++++++++ pdbeccdutils/tests/test_prd_reader.py | 15 ++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 pdbeccdutils/tests/test_ccd_reader.py create mode 100644 pdbeccdutils/tests/test_prd_reader.py diff --git a/pdbeccdutils/tests/test_ccd_reader.py b/pdbeccdutils/tests/test_ccd_reader.py new file mode 100644 index 0000000..31fcdb1 --- /dev/null +++ b/pdbeccdutils/tests/test_ccd_reader.py @@ -0,0 +1,28 @@ +import logging +from pathlib import Path + +from gemmi import cif +from rdkit import Chem + +from pdbeccdutils.core import ccd_reader +from pdbeccdutils.tests.tst_utilities import cif_filename + + +def _components_cif(tmp_path): + path = tmp_path / "components.cif" + ccds = [Path(cif_filename("00O")), Path(cif_filename("007"))] + path.write_text("\n".join(ccd.read_text() for ccd in ccds)) + return path + + +def test_read_pdb_components_file_parses_all_for_empty_include(tmp_path): + path = _components_cif(tmp_path) + + for include in (None, []): + result = ccd_reader.read_pdb_components_file( + str(path), sanitize=False, include=include + ) + + assert list(result) == ["00O", "007"] + + diff --git a/pdbeccdutils/tests/test_prd_reader.py b/pdbeccdutils/tests/test_prd_reader.py new file mode 100644 index 0000000..6a340f6 --- /dev/null +++ b/pdbeccdutils/tests/test_prd_reader.py @@ -0,0 +1,15 @@ +import logging + +from pdbeccdutils.core import prd_reader +from pdbeccdutils.tests.tst_utilities import prd_cif_filename + + +def test_read_prd_components_file_parses_all_for_empty_include(): + path = prd_cif_filename("PRDCC_000103") + + for include in (None, []): + result = prd_reader.read_pdb_components_file( + path, sanitize=False, include=include + ) + + assert list(result) == ["PRD_000103"] From 99cc7f67df35a57d3bbe9da549bf3ca8ac78613e Mon Sep 17 00:00:00 2001 From: roshkjr Date: Mon, 29 Jun 2026 15:34:08 +0100 Subject: [PATCH 5/7] use unquoted atom ids --- pdbeccdutils/core/ccd_reader.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/pdbeccdutils/core/ccd_reader.py b/pdbeccdutils/core/ccd_reader.py index a333349..e136f15 100644 --- a/pdbeccdutils/core/ccd_reader.py +++ b/pdbeccdutils/core/ccd_reader.py @@ -331,23 +331,29 @@ def _parse_pdb_bonds(mol, cif_block, errors): ) atom_indices = { atom_id: atom_index - for atom_index, atom_id in enumerate( + for atom_index, _atom_id in enumerate( atoms.find_column("_chem_comp_atom.atom_id") - ) + ) if (atom_id := cif.as_string(_atom_id)) } - for row in bonds: - atom_1 = row["_chem_comp_bond.atom_id_1"] - atom_2 = row["_chem_comp_bond.atom_id_2"] + for index, row in enumerate(bonds): + atom_1 = cif.as_string(row["_chem_comp_bond.atom_id_1"]) + atom_2 = cif.as_string(row["_chem_comp_bond.atom_id_2"]) try: atom_1_id = atom_indices[atom_1] atom_2_id = atom_indices[atom_2] - bond_order = helper.bond_pdb_order(row["_chem_comp_bond.value_order"]) + bond_order = helper.bond_pdb_order(cif.as_string(row["_chem_comp_bond.value_order"])) + if bond_order is None: + errors.append( + f"""Unknown bond order {cif.as_string(row['_chem_comp_bond.value_order'])} in + {index} entry in _chem_comp_bond""" + ) + continue mol.AddBond(atom_1_id, atom_2_id, bond_order) - except (KeyError, ValueError): + except KeyError: errors.append( - f"Error perceiving {atom_1} - {atom_2} bond in _chem_comp_bond" + f"Missing atom in {index} entry in _chem_comp_bond" ) except RuntimeError: errors.append(f"Duplicit bond {atom_1} - {atom_2}") From c36fd8899a43d455d64ff773f66cf83e4e0db2b7 Mon Sep 17 00:00:00 2001 From: roshkjr Date: Mon, 29 Jun 2026 15:35:26 +0100 Subject: [PATCH 6/7] add tests for ccd and prd readers --- pdbeccdutils/tests/test_ccd_reader.py | 65 +++++++++++++++++++++++++++ pdbeccdutils/tests/test_prd_reader.py | 13 ++++++ 2 files changed, 78 insertions(+) diff --git a/pdbeccdutils/tests/test_ccd_reader.py b/pdbeccdutils/tests/test_ccd_reader.py index 31fcdb1..d443920 100644 --- a/pdbeccdutils/tests/test_ccd_reader.py +++ b/pdbeccdutils/tests/test_ccd_reader.py @@ -26,3 +26,68 @@ def test_read_pdb_components_file_parses_all_for_empty_include(tmp_path): assert list(result) == ["00O", "007"] +def test_read_pdb_components_file_uses_include_order_and_skips_missing( + tmp_path, caplog +): + path = _components_cif(tmp_path) + + with caplog.at_level(logging.WARNING): + result = ccd_reader.read_pdb_components_file( + str(path), sanitize=False, include=["007", "MISSING", "00O", "007"] + ) + + assert list(result) == ["007", "00O"] + assert "Data block MISSING not found" in caplog.text + + +def test_parse_pdb_bonds_uses_atom_id_lookup(): + block = cif.read_string( + """ + data_TST + loop_ + _chem_comp_atom.atom_id + A + B + loop_ + _chem_comp_bond.atom_id_1 + _chem_comp_bond.atom_id_2 + _chem_comp_bond.value_order + A B SING + """ + ).sole_block() + mol = Chem.RWMol() + mol.AddAtom(Chem.Atom("C")) + mol.AddAtom(Chem.Atom("O")) + errors = [] + + ccd_reader._parse_pdb_bonds(mol, block, errors) + + assert errors == [] + assert mol.GetNumBonds() == 1 + assert mol.GetBondBetweenAtoms(0, 1) is not None + + +def test_parse_pdb_bonds_missing_atom_1_does_not_mask_error(): + block = cif.read_string( + """ + data_TST + loop_ + _chem_comp_atom.atom_id + A + loop_ + _chem_comp_bond.atom_id_1 + _chem_comp_bond.atom_id_2 + _chem_comp_bond.value_order + ? A SING + """ + ).sole_block() + mol = Chem.RWMol() + mol.AddAtom(Chem.Atom("C")) + errors = [] + + ccd_reader._parse_pdb_bonds(mol, block, errors) + + assert errors == [ + f"Missing atom in 0 entry in _chem_comp_bond" + ] + assert mol.GetNumBonds() == 0 \ No newline at end of file diff --git a/pdbeccdutils/tests/test_prd_reader.py b/pdbeccdutils/tests/test_prd_reader.py index 6a340f6..f2c0d92 100644 --- a/pdbeccdutils/tests/test_prd_reader.py +++ b/pdbeccdutils/tests/test_prd_reader.py @@ -13,3 +13,16 @@ def test_read_prd_components_file_parses_all_for_empty_include(): ) assert list(result) == ["PRD_000103"] + +def test_read_prd_components_file_uses_include_order_and_skips_missing(caplog): + path = prd_cif_filename("PRDCC_000103") + + with caplog.at_level(logging.WARNING): + result = prd_reader.read_pdb_components_file( + path, + sanitize=False, + include=["PRD_000103", "MISSING", "PRD_000103"], + ) + + assert list(result) == ["PRD_000103"] + assert "Data block MISSING not found" in caplog.text \ No newline at end of file From 3aa6a2ea2e7a84f83e0b93a18dc7a3a9d6483245 Mon Sep 17 00:00:00 2001 From: roshkjr Date: Mon, 29 Jun 2026 16:10:52 +0100 Subject: [PATCH 7/7] bump up version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 6f50a59..2db2e75 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "pdbeccdutils" -version = "1.0.3" +version = "1.0.4" description = "Toolkit to parse and process small molecules in wwPDB" authors = ["Protein Data Bank in Europe "] license = "Apache License 2.0."