diff --git a/docs/dd/ref/prepared_protein_stamp.md b/docs/dd/ref/prepared_protein_stamp.md new file mode 100644 index 00000000..abd2a131 --- /dev/null +++ b/docs/dd/ref/prepared_protein_stamp.md @@ -0,0 +1,24 @@ +# Prepared Protein stamp + +Helpers that detect and preserve the file-borne Prepared Protein stamp +(`REMARK 99 DO_PREPARED`) on PDB files. + +Protein Prep writes this stamp onto the prepared PDB before upload. Downstream +tools (Pocket Finder, Docking, System Prep) skip automatic protein cleanup when +the stamp is present. The CLI must not drop it when rewriting or syncing a +protein that already points at a stamped remote file. + +::: src.drug_discovery.structures.prepared_protein_stamp + options: + docstring_style: google + show_root_heading: false + show_category_heading: true + show_object_full_path: false + show_root_toc_entry: false + members_order: alphabetical + filters: + - "!^_" + show_signature: true + show_signature_annotations: true + show_if_no_docstring: true + group_by_category: true diff --git a/docs/dd/tools/proteinprep.md b/docs/dd/tools/proteinprep.md index 76b56caf..ae830060 100644 --- a/docs/dd/tools/proteinprep.md +++ b/docs/dd/tools/proteinprep.md @@ -61,7 +61,10 @@ prepared = prep.run() `run()` returns an in-memory [`Protein`](../ref/protein.md) whose `remote_path` points to the prepared Protein Data Bank (PDB) file. It has no platform protein ID until you call `sync()` or `update()`. The original input -protein is unchanged. +protein is unchanged. The prepared PDB carries a +[`REMARK 99 DO_PREPARED`](../ref/prepared_protein_stamp.md) stamp; pass that +`Protein` into Pocket Finder or other tools without re-serializing the file so +the stamp stays intact. Loops-off preparation may also run asynchronously: diff --git a/src/drug_discovery/structures/prepared_protein_stamp.py b/src/drug_discovery/structures/prepared_protein_stamp.py new file mode 100644 index 00000000..d8d20396 --- /dev/null +++ b/src/drug_discovery/structures/prepared_protein_stamp.py @@ -0,0 +1,68 @@ +"""Prepared Protein stamp: file-borne REMARK 99 DO_PREPARED token. + +Mirrors the platform-toolbox stamp helper so the CLI can detect and preserve +the stamp without depending on ``toolbox_core``. Downstream Pocket Finder, +Docking, and System Prep skip AUTO protein cleanup when the token is present. +""" + +from __future__ import annotations + +from pathlib import Path + +from deeporigin.utils.constants import PREPARED_PROTEIN_STAMP_LINE + +_STAMP_BYTES = (PREPARED_PROTEIN_STAMP_LINE + "\n").encode("ascii") + + +def text_has_prepared_protein_stamp(text: str) -> bool: + """Return True if a REMARK 99 DO_PREPARED token appears before ATOM/HETATM. + + Whitespace around the remark number is lenient so a one-space rewrite still + matches. The writer always emits the canonical two-space line. + + Args: + text: PDB file contents as text. + """ + for line in text.splitlines(): + if line.startswith(("ATOM", "HETATM")): + return False + parts = line.split() + if ( + len(parts) >= 3 + and parts[0] == "REMARK" + and parts[1] == "99" + and parts[2] == "DO_PREPARED" + ): + return True + return False + + +def has_prepared_protein_stamp(path: str | Path) -> bool: + """Return True if a REMARK 99 DO_PREPARED token appears before ATOM/HETATM. + + Args: + path: Local PDB path to inspect. + """ + text = Path(path).read_bytes().decode("latin-1") + return text_has_prepared_protein_stamp(text) + + +def stamp_prepared_protein_pdb(path: str | Path) -> None: + """Prepend the canonical Prepared Protein stamp if it is not already present. + + Uses a text prepend, not a structure rewrite, so ATOM/HETATM bytes are + unchanged. Idempotent when :func:`has_prepared_protein_stamp` is already + true. + + Args: + path: Local PDB path to stamp. + + Raises: + OSError: If the file cannot be read or written. + FileNotFoundError: If *path* does not exist. + """ + pdb_path = Path(path) + if has_prepared_protein_stamp(pdb_path): + return + body = pdb_path.read_bytes() + pdb_path.write_bytes(_STAMP_BYTES + body) diff --git a/src/drug_discovery/structures/protein.py b/src/drug_discovery/structures/protein.py index 8da8b7e1..e49ccc58 100644 --- a/src/drug_discovery/structures/protein.py +++ b/src/drug_discovery/structures/protein.py @@ -35,6 +35,11 @@ from .ligand import Ligand, LigandSet from .pocket import Pocket from .pose import Pose, PoseSet +from .prepared_protein_stamp import ( + has_prepared_protein_stamp, + stamp_prepared_protein_pdb, + text_has_prepared_protein_stamp, +) _PROTEIN_STRUCTURE_NOT_LOADED_MSG = "Protein structure is not loaded." @@ -1149,6 +1154,10 @@ def to_pdb(self, file_path: Optional[str | Path] = None) -> str: protein has :attr:`remote_path` but no local file yet, raise; rehydrate with :meth:`download` first. + When the source PDB (:attr:`local_path` or :attr:`block_content`) carries a + Prepared Protein stamp (``REMARK 99 DO_PREPARED``), the stamp is + re-prepended after the biotite rewrite so the token is never dropped. + Args: file_path (str): Path where the PDB file will be written. @@ -1171,6 +1180,12 @@ def to_pdb(self, file_path: Optional[str | Path] = None) -> str: ), ) + source_has_stamp = False + if self.local_path is not None and Path(self.local_path).is_file(): + source_has_stamp = has_prepared_protein_stamp(self.local_path) + elif isinstance(self.block_content, str) and self.block_content: + source_has_stamp = text_has_prepared_protein_stamp(self.block_content) + if file_path is None: file_path = PROTEINS_DIR / (self.to_hash() + ".pdb") @@ -1180,6 +1195,8 @@ def to_pdb(self, file_path: Optional[str | Path] = None) -> str: pdb_file = PDBFile() pdb_file.set_structure(self.structure) pdb_file.write(str(file_path)) + if source_has_stamp: + stamp_prepared_protein_pdb(file_path) return str(file_path) except Exception as e: raise RuntimeError( @@ -1500,8 +1517,11 @@ def register( ) -> None: """Register the protein as a new record in the data platform. - Uploads the protein file to remote storage and creates a new protein + Uploads the protein file when needed, then creates a new protein record, regardless of whether one already exists for this file path. + When :attr:`remote_path` is already set and ``remote_path`` is not + passed, skips upload so an existing UFA object (e.g. a Prepared + Protein with ``REMARK 99 DO_PREPARED``) is not overwritten. Args: client: DeepOriginClient instance. If None, uses DeepOriginClient(). @@ -1509,13 +1529,14 @@ def register( default hash-based path. Returns: - None. As a side effect, uploads the protein and sets ``self.id`` - to the newly created record's ID. + None. As a side effect, uploads the protein when needed and sets + ``self.id`` to the newly created record's ID. """ if client is None: client = DeepOriginClient() - self.upload(client=client, remote_path=remote_path) + if remote_path is not None or self.remote_path is None: + self.upload(client=client, remote_path=remote_path) kwargs: dict[str, Any] = { "file_path": self.remote_path, @@ -1548,13 +1569,18 @@ def sync( ) -> None: """Sync the protein to the data platform. - Uploads the protein file and links to an existing record if one with - the same file path already exists, otherwise creates a new record via - :meth:`register`. + Uploads the protein file when needed and links to an existing record if + one with the same file path already exists, otherwise creates a new + record via :meth:`register`. + + When :attr:`remote_path` is already set and ``remote_path`` is not + passed, skips upload so an existing UFA object (e.g. a Prepared Protein + stamped with ``REMARK 99 DO_PREPARED``) is not overwritten by a + biotite rewrite. Args: - lazy: If True, skip syncing when the protein already has an ID. - Defaults to False. + lazy: If True, skip syncing when the protein already has an ID or a + ``remote_path``. Defaults to False. client: DeepOriginClient instance. If None, uses DeepOriginClient(). remote_path: Custom remote path to upload to. Overrides the default hash-based path. @@ -1565,7 +1591,7 @@ def sync( and sets :attr:`project_id` when a project scope applies or the platform row includes ``project_id``. """ - if lazy and self.id is not None: + if lazy and (self.id is not None or self.remote_path is not None): if client is None: client = DeepOriginClient() proj_id = self.resolved_project_id(client=client) @@ -1576,7 +1602,8 @@ def sync( if client is None: client = DeepOriginClient() - self.upload(client=client, remote_path=remote_path) + if remote_path is not None or self.remote_path is None: + self.upload(client=client, remote_path=remote_path) proj_id = self.resolved_project_id(client=client) if proj_id is not None: diff --git a/src/utils/constants.py b/src/utils/constants.py index 047b8876..3ef802aa 100644 --- a/src/utils/constants.py +++ b/src/utils/constants.py @@ -104,6 +104,12 @@ ) """Used by ``SystemPrep.run`` / ``get_results`` when output paths are missing.""" +PREPARED_PROTEIN_STAMP_LINE = "REMARK 99 DO_PREPARED" +"""Canonical PDB REMARK line marking a Prepared Protein (file-borne stamp). + +Written by Protein Prep before UFA upload. Downstream tools skip AUTO protein +cleanup when this token is present. CLI writers must preserve it.""" + PROTEIN_PREP_PDB_ID_PATTERN = r"^[A-Za-z0-9]{4}$" """JSON Schema pattern for Protein Prep ``pdb_id`` (loop-modelling templates).""" diff --git a/tests/test_prepared_protein_stamp.py b/tests/test_prepared_protein_stamp.py new file mode 100644 index 00000000..409b6552 --- /dev/null +++ b/tests/test_prepared_protein_stamp.py @@ -0,0 +1,148 @@ +"""Tests for Prepared Protein stamp helpers and stamp-preserving Protein I/O.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock, patch + +from deeporigin.drug_discovery.structures.prepared_protein_stamp import ( + has_prepared_protein_stamp, + stamp_prepared_protein_pdb, + text_has_prepared_protein_stamp, +) +from deeporigin.drug_discovery.structures.protein import Protein +from deeporigin.utils.constants import PREPARED_PROTEIN_STAMP_LINE + +_MINIMAL_ATOM = ( + "ATOM 1 N ALA A 1 11.104 13.207 9.068 1.00 0.00 N\n" + "END\n" +) + + +def test_text_has_prepared_protein_stamp_detects_canonical_line() -> None: + """Canonical REMARK 99 DO_PREPARED before ATOM is detected.""" + text = f"{PREPARED_PROTEIN_STAMP_LINE}\n{_MINIMAL_ATOM}" + assert text_has_prepared_protein_stamp(text) is True + + +def test_text_has_prepared_protein_stamp_lenient_whitespace() -> None: + """One-space REMARK 99 still matches (reader is lenient).""" + text = f"REMARK 99 DO_PREPARED\n{_MINIMAL_ATOM}" + assert text_has_prepared_protein_stamp(text) is True + + +def test_text_has_prepared_protein_stamp_false_after_atom() -> None: + """Stamp after the first ATOM/HETATM does not count.""" + text = f"{_MINIMAL_ATOM}{PREPARED_PROTEIN_STAMP_LINE}\n" + assert text_has_prepared_protein_stamp(text) is False + + +def test_text_has_prepared_protein_stamp_absent() -> None: + """Unstamped PDB text returns False.""" + assert text_has_prepared_protein_stamp(_MINIMAL_ATOM) is False + + +def test_stamp_prepared_protein_pdb_is_idempotent(tmp_path: Path) -> None: + """stamp_prepared_protein_pdb prepends once and is a no-op thereafter.""" + pdb_path = tmp_path / "protein.pdb" + pdb_path.write_text(_MINIMAL_ATOM) + assert has_prepared_protein_stamp(pdb_path) is False + + stamp_prepared_protein_pdb(pdb_path) + assert has_prepared_protein_stamp(pdb_path) is True + first = pdb_path.read_bytes() + + stamp_prepared_protein_pdb(pdb_path) + assert pdb_path.read_bytes() == first + assert pdb_path.read_text().startswith(PREPARED_PROTEIN_STAMP_LINE + "\n") + + +def test_protein_to_pdb_preserves_do_prepared_stamp(tmp_path: Path) -> None: + """biotite rewrite via to_pdb re-prepends DO_PREPARED when the source had it.""" + stamped = tmp_path / "stamped.pdb" + stamped.write_text(f"{PREPARED_PROTEIN_STAMP_LINE}\n{_MINIMAL_ATOM}") + protein = Protein.from_file(stamped) + assert has_prepared_protein_stamp(stamped) is True + + out = tmp_path / "rewritten.pdb" + protein.to_pdb(out) + assert has_prepared_protein_stamp(out) is True + assert out.read_text().startswith(PREPARED_PROTEIN_STAMP_LINE + "\n") + + +def test_protein_to_pdb_does_not_add_stamp_when_source_unstamped( + tmp_path: Path, +) -> None: + """to_pdb does not invent a stamp for unstamped sources.""" + plain = tmp_path / "plain.pdb" + plain.write_text(_MINIMAL_ATOM) + protein = Protein.from_file(plain) + + out = tmp_path / "rewritten.pdb" + protein.to_pdb(out) + assert has_prepared_protein_stamp(out) is False + + +def test_protein_sync_lazy_skips_upload_when_remote_path_set() -> None: + """sync(lazy=True) is a no-op when remote_path is already populated.""" + protein = Protein( + name="prepared", + structure=None, + remote_path="entities/proteins/prepared.pdb", + ) + with patch.object(protein, "upload") as upload: + protein.sync(lazy=True) + upload.assert_not_called() + assert protein.id is None + assert protein.remote_path == "entities/proteins/prepared.pdb" + + +def test_protein_sync_skips_upload_but_registers_when_remote_path_only() -> None: + """Non-lazy sync with remote_path set skips upload and still registers.""" + from deeporigin.platform.client import DeepOriginClient + + protein = Protein( + name="prepared", + structure=None, + remote_path="entities/proteins/prepared.pdb", + ) + client = MagicMock(spec=DeepOriginClient) + client.project_id = None + client.entities = MagicMock() + client.entities.search_proteins.return_value = {"data": []} + client.entities.create_protein.return_value = {"data": {"id": "prot-new"}} + + with patch.object(protein, "upload") as upload: + protein.sync(lazy=False, client=client) + + upload.assert_not_called() + client.entities.search_proteins.assert_called_once_with( + file_path="entities/proteins/prepared.pdb", + ) + client.entities.create_protein.assert_called_once() + assert protein.id == "prot-new" + assert protein.remote_path == "entities/proteins/prepared.pdb" + + +def test_protein_sync_links_existing_without_upload_when_remote_path_set() -> None: + """Non-lazy sync finds an existing row by file_path without uploading.""" + from deeporigin.platform.client import DeepOriginClient + + protein = Protein( + name="prepared", + structure=None, + remote_path="entities/proteins/prepared.pdb", + ) + client = MagicMock(spec=DeepOriginClient) + client.project_id = None + client.entities = MagicMock() + client.entities.search_proteins.return_value = { + "data": [{"id": "prot-existing", "project_id": None}], + } + + with patch.object(protein, "upload") as upload: + protein.sync(lazy=False, client=client) + + upload.assert_not_called() + client.entities.create_protein.assert_not_called() + assert protein.id == "prot-existing" diff --git a/zensical.toml b/zensical.toml index 739bd105..502e059e 100644 --- a/zensical.toml +++ b/zensical.toml @@ -59,6 +59,7 @@ nav = [ {"LigandSet" = "dd/ref/ligandset.md"}, {"Pose" = "dd/ref/pose.md"}, {"Protein" = "dd/ref/protein.md"}, + {"Prepared Protein stamp" = "dd/ref/prepared_protein_stamp.md"}, {"Pocket" = "dd/ref/pocket.md"}, ]}, {"Tools" = [