Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions docs/dd/ref/prepared_protein_stamp.md
Original file line number Diff line number Diff line change
@@ -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
5 changes: 4 additions & 1 deletion docs/dd/tools/proteinprep.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
68 changes: 68 additions & 0 deletions src/drug_discovery/structures/prepared_protein_stamp.py
Original file line number Diff line number Diff line change
@@ -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)

Check failure on line 68 in src/drug_discovery/structures/prepared_protein_stamp.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Change this code to not construct the path from user-controlled data.

See more on https://sonarcloud.io/project/issues?id=formiclabs_deeporigin-client&issues=AaBJkiCstxS4wo3Odb7Y&open=AaBJkiCstxS4wo3Odb7Y&pullRequest=617
49 changes: 38 additions & 11 deletions src/drug_discovery/structures/protein.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."

Expand Down Expand Up @@ -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.

Expand All @@ -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")

Expand All @@ -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(
Expand Down Expand Up @@ -1500,22 +1517,26 @@ 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().
remote_path: Custom remote path to upload to. Overrides the
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,
Expand Down Expand Up @@ -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.
Expand All @@ -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)
Expand All @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions src/utils/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)."""

Expand Down
148 changes: 148 additions & 0 deletions tests/test_prepared_protein_stamp.py
Original file line number Diff line number Diff line change
@@ -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"
1 change: 1 addition & 0 deletions zensical.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" = [
Expand Down
Loading