Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
4 changes: 4 additions & 0 deletions docs/agents/merge-ready-lessons.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

Notes from past merge-ready cycles. Read before starting; append after success.

## 2026-10-05 — PR #655 — DDOS-7931 create metadata kwargs

Copilot correctly required a runnable Entities-layer demo for new public create kwargs — extend the existing `entity-updates` notebook rather than inventing a new one. Reinforced: notebook-only pushes skip `Test Python Code` (`**/*.py` path filter), so touch a `.py` file before waiting on required CI.

## 2026-10-05 — PR #654 — DDOS-7952 Pose origin normalize

`Test Python Code` path-filters on `**/*.py`, so a docs-only push leaves required formatting/functionality checks stuck or cancelled — touch a `.py` file to retrigger. `gh pr checks --watch --fail-fast` also exits early on non-required SonarCloud failures; watch required Ubuntu checks only, and treat matrix `cancel` as not-green (fail-fast can cancel queued Ubuntu jobs while Windows already passed). Copilot’s docs nit was real: pose origin docs must say known values + unknown strings preserved, not an exhaustive four-value list.
Expand Down
61 changes: 59 additions & 2 deletions docs/notebooks/clean/entity-updates.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
"source": [
"## Setup\n",
"\n",
"Credentials come from **`~/.deeporigin/`** (written when [authenticating](../../how-to/auth.md)). We use `DeepOriginClient.from_disk()` so a repo `.env` or `DO_*` variables in the kernel do not override disk config."
"Credentials come from **`~/.deeporigin/`** (run `deeporigin login` once). We use `DeepOriginClient.from_disk()` so a repo `.env` or `DO_*` variables in the kernel do not override disk config."
]
},
{
Expand All @@ -48,7 +48,7 @@
"from deeporigin.drug_discovery import BRD_DATA_DIR, Ligand, Protein\n",
"from deeporigin.platform import DeepOriginClient\n",
"\n",
"# Disk config only (~/.deeporigin/ after authenticating).\n",
"# Disk config only (~/.deeporigin/ after `deeporigin login`).\n",
"# Do not use DeepOriginClient() here — it prefers DO_* env vars over disk.\n",
"client = DeepOriginClient.from_disk(\"dev\")\n",
"client"
Expand Down Expand Up @@ -133,6 +133,63 @@
"print(f\"file_path={protein_row['file_path']!r}\")"
]
},
{
"cell_type": "markdown",
"id": "3d619a5b",
"metadata": {},
"source": [
"## Entities layer — create with metadata and origin\n",
"\n",
"`create_protein` accepts write-once prep metadata (`state`, `preparation`, `structure_hash`) and\n",
"an `origin` mapping (`kind`, `entity_type`, `entity_id`). `create_ligand` accepts `origin` only.\n",
"The client maps `origin` to platform `origin_*` columns; `origin_entity_display_id` is server-managed.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "89acc135",
"metadata": {},
"outputs": [],
"source": [
"meta_tag = f\"meta-{uuid.uuid4().hex[:10]}\"\n",
"meta_pdb = f\"entities/proteins/meta-{meta_tag}.pdb\"\n",
"client.files.upload(BRD_DATA_DIR / \"brd.pdb\", meta_pdb)\n",
"\n",
"prepared = client.entities.create_protein(\n",
" file_path=meta_pdb,\n",
" state=\"prepared\",\n",
" preparation={\"chains\": [{\"id\": \"chain:A\", \"decision\": \"keep\"}]},\n",
" structure_hash=f\"hash-{meta_tag}\",\n",
" origin={\n",
" \"kind\": \"prepared\",\n",
" \"entity_type\": \"protein\",\n",
" \"entity_id\": protein_id, # prior cell's create_protein id\n",
" },\n",
")\n",
"prep_row = prepared[\"data\"]\n",
"print(\n",
" f\"Prepared protein {prep_row['id']}: state={prep_row.get('state')!r}, \"\n",
" f\"hash={prep_row.get('structure_hash')!r}, origin_kind={prep_row.get('origin_kind')!r}\"\n",
")\n",
"\n",
"extracted = client.entities.create_ligand(\n",
" smiles=\"CCO\",\n",
" name=f\"extracted-{meta_tag}\",\n",
" variant_name_tag=meta_tag,\n",
" origin={\n",
" \"kind\": \"crystal_extraction\",\n",
" \"entity_type\": \"protein\",\n",
" \"entity_id\": prep_row[\"id\"],\n",
" },\n",
")\n",
"lig_row = extracted[\"data\"]\n",
"print(\n",
" f\"Ligand {lig_row['id']}: origin_kind={lig_row.get('origin_kind')!r}, \"\n",
" f\"origin_entity_id={lig_row.get('origin_entity_id')!r}\"\n",
")\n"
]
},
{
"cell_type": "code",
"execution_count": null,
Expand Down
66 changes: 65 additions & 1 deletion src/platform/entities.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from __future__ import annotations

from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, Literal, TypedDict

from deeporigin.platform.tags import merge_entity_tags, stamp_batch_row_tags
from deeporigin.utils.constants import (
Expand All @@ -14,6 +14,38 @@
if TYPE_CHECKING:
from deeporigin.platform.client import DeepOriginClient

ProteinState = Literal["unprocessed", "prepared"]


class EntityOrigin(TypedDict, total=False):
"""Write-once provenance supplied on entity create.

Maps to platform columns ``origin_kind``, ``origin_entity_type``, and
``origin_entity_id``. ``origin_entity_display_id`` is server-stamped.
"""

kind: str
entity_type: str
entity_id: str


def _apply_entity_origin(
set_dict: dict[str, Any],
origin: EntityOrigin | None,
) -> None:
"""Translate public ``origin`` mapping into platform ``origin_*`` set fields."""
if origin is None:
return
kind = origin.get("kind")
if kind is not None:
set_dict["origin_kind"] = kind
entity_type = origin.get("entity_type")
if entity_type is not None:
set_dict["origin_entity_type"] = entity_type
entity_id = origin.get("entity_id")
if entity_id is not None:
set_dict["origin_entity_id"] = entity_id


def _writable_ligand_set_fields(set_dict: dict[str, Any]) -> dict[str, Any]:
"""Return a ligand create/update ``set`` payload without molprops fields.
Expand Down Expand Up @@ -54,6 +86,10 @@ def _writable_ligand_set_fields(set_dict: dict[str, Any]) -> dict[str, Any]:
"molecular_weight",
"structure_key",
"tags",
"origin_kind",
"origin_entity_type",
"origin_entity_id",
"origin_entity_display_id",
]

PROTEIN_RETURNING_FIELDS = [
Expand Down Expand Up @@ -85,6 +121,13 @@ def _writable_ligand_set_fields(set_dict: dict[str, Any]) -> dict[str, Any]:
"protein_family",
"ligandability_score",
"protein_length",
"state",
"preparation",
"structure_hash",
"origin_kind",
"origin_entity_type",
"origin_entity_id",
"origin_entity_display_id",
]


Expand Down Expand Up @@ -525,6 +568,7 @@ def create_ligand(
hbond_acceptor_count: int | None = None,
rotatable_bond_count: int | None = None,
tpsa: float | None = None,
origin: EntityOrigin | None = None,
) -> dict:
"""Create a new ligand.

Expand All @@ -536,6 +580,8 @@ def create_ligand(
variant_name_tag: Variant name tag. Defaults to empty string.
tags: Data-platform metadata tags (jsonb object). Provenance
``app`` / ``session`` are merged from the client automatically.
origin: Write-once provenance (``kind``, ``entity_type``, ``entity_id``).
Create-only; not accepted on :meth:`update_ligand`.
molecular_weight: Deprecated. Server-computed from SMILES; ignored.
formal_charge: Deprecated. Server-computed from SMILES; ignored.
hbond_donor_count: Deprecated. Server-computed from SMILES; ignored.
Expand All @@ -559,6 +605,7 @@ def create_ligand(
if mol_file is not None:
set_dict["mol_file"] = mol_file
set_dict["tags"] = merge_entity_tags(self._c, tags, always=True)
_apply_entity_origin(set_dict, origin)

body: dict[str, Any] = {
"set": _writable_ligand_set_fields(set_dict),
Expand Down Expand Up @@ -777,6 +824,10 @@ def create_protein(
protein_length: int | None = None,
project_id: str | None = None,
tags: dict[str, Any] | None = None,
state: ProteinState | None = None,
preparation: dict[str, Any] | None = None,
origin: EntityOrigin | None = None,
structure_hash: str | None = None,
Comment thread
sg-s marked this conversation as resolved.
) -> dict:
"""Create a new protein.

Expand All @@ -791,6 +842,11 @@ def create_protein(
project_id: Project ID for the protein.
tags: Data-platform metadata tags (jsonb object). When provided,
provenance ``app`` / ``session`` are merged from the client.
state: ``unprocessed`` or ``prepared`` (Protein Prep signal).
preparation: JSON summary of what preparation did.
origin: Write-once provenance (``kind``, ``entity_type``, ``entity_id``).
Create-only; not accepted on :meth:`update_protein`.
structure_hash: Tool-supplied structure fingerprint for dedupe.

Returns:
Dictionary containing the created protein data.
Expand All @@ -799,6 +855,13 @@ def create_protein(
"file_path": file_path,
}

if state is not None:
set_dict["state"] = state
if preparation is not None:
set_dict["preparation"] = preparation
if structure_hash is not None:
set_dict["structure_hash"] = structure_hash

if project_id is not None:
set_dict["project_id"] = project_id
if gene_symbol is not None:
Expand All @@ -814,6 +877,7 @@ def create_protein(
if protein_length is not None:
set_dict["protein_length"] = protein_length
set_dict["tags"] = merge_entity_tags(self._c, tags, always=True)
_apply_entity_origin(set_dict, origin)

body: dict[str, Any] = {
"set": set_dict,
Expand Down
138 changes: 138 additions & 0 deletions tests/test_entities.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
from deeporigin.drug_discovery.structures.ligand import Ligand
from deeporigin.exceptions import DeepOriginException
from deeporigin.platform import DeepOriginClient
from deeporigin.platform.entities import Entities
from tests.mock_server.routers.data_platform import MOCK_CANONICAL_PROTEIN_ID

_BRD_PDB_LOCAL = BRD_DATA_DIR / "brd.pdb"
_BRD_PDB_REMOTE = "testing/brd.pdb"
Expand Down Expand Up @@ -552,3 +554,139 @@ def test_update_ligand_with_tags_lv1(client: DeepOriginClient):
except DeepOriginException as _e:
if "404" not in str(_e):
raise


class _CapturePostJsonClient:
"""Minimal client stub that records entity create POST bodies."""

org_key = "test-org"
_app = "do-dd-client-tests"
_session = "entities-unit"

def __init__(self) -> None:
self.last_body: dict | None = None

def post_json(self, _path: str, body: dict) -> dict:
self.last_body = body
row = dict(body.get("set", {}))
row["id"] = "stub-id"
return {"data": row}


def test_create_protein_forwards_metadata_payload() -> None:
"""create_protein translates origin and passes protein metadata in the set payload."""
stub = _CapturePostJsonClient()
entities = Entities(stub) # type: ignore[arg-type]
preparation = {"chains": [{"id": "chain:A", "decision": "keep"}]}

entities.create_protein(
file_path="testing/prepared.pdb",
state="prepared",
preparation=preparation,
structure_hash="struct-hash-abc",
origin={
"kind": "prepared",
"entity_type": "protein",
"entity_id": MOCK_CANONICAL_PROTEIN_ID,
},
)

assert stub.last_body is not None
set_payload = stub.last_body["set"]
assert set_payload["state"] == "prepared"
assert set_payload["preparation"] == preparation
assert set_payload["structure_hash"] == "struct-hash-abc"
assert set_payload["origin_kind"] == "prepared"
assert set_payload["origin_entity_type"] == "protein"
assert set_payload["origin_entity_id"] == MOCK_CANONICAL_PROTEIN_ID
assert "origin_entity_display_id" not in set_payload
returning = stub.last_body["returning"]
assert "state" in returning
assert "preparation" in returning
assert "structure_hash" in returning
assert "origin_kind" in returning


def test_create_ligand_forwards_origin_payload() -> None:
"""create_ligand translates origin into origin_* set fields."""
stub = _CapturePostJsonClient()
entities = Entities(stub) # type: ignore[arg-type]

entities.create_ligand(
smiles="CCO",
origin={
"kind": "crystal_extraction",
"entity_type": "protein",
"entity_id": MOCK_CANONICAL_PROTEIN_ID,
},
)

assert stub.last_body is not None
set_payload = stub.last_body["set"]
assert set_payload["origin_kind"] == "crystal_extraction"
assert set_payload["origin_entity_type"] == "protein"
assert set_payload["origin_entity_id"] == MOCK_CANONICAL_PROTEIN_ID
assert "state" not in set_payload
assert "origin_kind" in stub.last_body["returning"]


def test_create_protein_metadata_lv1(client: DeepOriginClient) -> None:
"""create_protein persists state, preparation, structure_hash, and origin (DDOS-7931).

Also exercised via entity-updates notebook create-with-metadata cells.
"""
client.files.upload(_BRD_PDB_LOCAL, _BRD_PDB_REMOTE)
remote = f"testing/prepared-meta-{uuid.uuid4().hex[:12]}.pdb"
client.files.upload(_BRD_PDB_LOCAL, remote)
preparation = {"chains": [{"id": "chain:A", "decision": "keep"}]}
structure_hash = f"hash-{uuid.uuid4().hex[:16]}"

response = client.entities.create_protein(
file_path=remote,
state="prepared",
preparation=preparation,
structure_hash=structure_hash,
origin={
"kind": "prepared",
"entity_type": "protein",
"entity_id": MOCK_CANONICAL_PROTEIN_ID,
},
)

data = response["data"]
assert data["state"] == "prepared"
assert data["preparation"] == preparation
assert data["structure_hash"] == structure_hash
assert data["origin_kind"] == "prepared"
assert data["origin_entity_type"] == "protein"
assert data["origin_entity_id"] == MOCK_CANONICAL_PROTEIN_ID


def test_create_ligand_origin_lv1(client: DeepOriginClient) -> None:
"""create_ligand persists translated origin on create (DDOS-7931)."""
client.files.upload(_BRD_PDB_LOCAL, _BRD_PDB_REMOTE)
smiles = _unique_test_smiles(suffix="S")
tag = f"origin-{uuid.uuid4().hex[:10]}"
create = client.entities.create_ligand(
smiles=smiles,
name=f"ligand-{tag}",
origin={
"kind": "crystal_extraction",
"entity_type": "protein",
"entity_id": MOCK_CANONICAL_PROTEIN_ID,
},
)
lig_id = create["data"]["id"]
try:
data = create["data"]
assert data["origin_kind"] == "crystal_extraction"
assert data["origin_entity_type"] == "protein"
assert data["origin_entity_id"] == MOCK_CANONICAL_PROTEIN_ID
fetched = _wait_for_ligand(client, lig_id)
assert fetched.get("origin_kind") == "crystal_extraction"
finally:
try:
client.entities.delete(entity="ligands", entity_id=lig_id)
except DeepOriginException as exc:
if "404" not in str(exc):
raise
Loading