From f1ae026960b5ad8ee09160a210f4bd99ad8848e8 Mon Sep 17 00:00:00 2001 From: Srinivas Gorur-Shandilya Date: Mon, 5 Oct 2026 20:09:17 -0400 Subject: [PATCH 1/5] DDOS-7931: create kwargs for protein/ligand metadata Extend Entities.create_protein with state, preparation, structure_hash, and a mapped origin value; create_ligand accepts origin only. Return the new columns on create responses and cover payload translation with tests. --- src/platform/entities.py | 66 ++++++++++++++++++- tests/test_entities.py | 135 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 200 insertions(+), 1 deletion(-) diff --git a/src/platform/entities.py b/src/platform/entities.py index 9c0edc2b..3887f2eb 100644 --- a/src/platform/entities.py +++ b/src/platform/entities.py @@ -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 ( @@ -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. @@ -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 = [ @@ -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", ] @@ -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. @@ -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. @@ -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), @@ -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, ) -> dict: """Create a new protein. @@ -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. @@ -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: @@ -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, diff --git a/tests/test_entities.py b/tests/test_entities.py index 9998e272..e0c410fa 100644 --- a/tests/test_entities.py +++ b/tests/test_entities.py @@ -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" @@ -552,3 +554,136 @@ 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).""" + 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 From 6f5fe954ca94b712e6c4cae302de7ed50d1483cb Mon Sep 17 00:00:00 2001 From: Srinivas Gorur-Shandilya Date: Mon, 5 Oct 2026 20:14:59 -0400 Subject: [PATCH 2/5] docs: show create_protein/ligand metadata in entity-updates notebook Demonstrate state, preparation, structure_hash, and mapped origin on create so the DDOS-7931 public kwargs have a runnable Entities-layer example. --- docs/notebooks/clean/entity-updates.ipynb | 61 ++++++++++++++++++++++- 1 file changed, 59 insertions(+), 2 deletions(-) diff --git a/docs/notebooks/clean/entity-updates.ipynb b/docs/notebooks/clean/entity-updates.ipynb index 78cb910d..c8fef520 100644 --- a/docs/notebooks/clean/entity-updates.ipynb +++ b/docs/notebooks/clean/entity-updates.ipynb @@ -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." ] }, { @@ -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" @@ -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, From 521e5c4e2ec0c8d48fa1f24a3d7901c762a18ef2 Mon Sep 17 00:00:00 2001 From: Srinivas Gorur-Shandilya Date: Mon, 5 Oct 2026 20:15:37 -0400 Subject: [PATCH 3/5] test: note entity-updates notebook coverage for create metadata Retriggers required Test Python Code after the docs-only notebook push (path filter is **/*.py only). --- tests/test_entities.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_entities.py b/tests/test_entities.py index e0c410fa..de04f813 100644 --- a/tests/test_entities.py +++ b/tests/test_entities.py @@ -631,7 +631,10 @@ def test_create_ligand_forwards_origin_payload() -> None: def test_create_protein_metadata_lv1(client: DeepOriginClient) -> None: - """create_protein persists state, preparation, structure_hash, and origin (DDOS-7931).""" + """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) From b7ca03e280cad02d3e47a9d5faadcc9624f17f41 Mon Sep 17 00:00:00 2001 From: Srinivas Gorur-Shandilya Date: Mon, 5 Oct 2026 20:23:52 -0400 Subject: [PATCH 4/5] docs(agents): record merge-ready lessons from PR #655 --- docs/agents/merge-ready-lessons.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/agents/merge-ready-lessons.md b/docs/agents/merge-ready-lessons.md index 72338375..bacb04e3 100644 --- a/docs/agents/merge-ready-lessons.md +++ b/docs/agents/merge-ready-lessons.md @@ -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. From b7706a9acdce0975bf6018924db5320e567ac17b Mon Sep 17 00:00:00 2001 From: Srinivas Gorur-Shandilya Date: Tue, 6 Oct 2026 08:19:00 -0400 Subject: [PATCH 5/5] test: use real protein id for origin lv1 entity tests MOCK_CANONICAL_PROTEIN_ID is mock-only; dev API rejects it as origin_entity_id. --- tests/test_entities.py | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/tests/test_entities.py b/tests/test_entities.py index de04f813..10b2f54c 100644 --- a/tests/test_entities.py +++ b/tests/test_entities.py @@ -59,6 +59,18 @@ def _unique_test_smiles(*, suffix: str = "O") -> str: return backbone + suffix +def _create_origin_source_protein(client: DeepOriginClient) -> str: + """Create a protein row to use as ``origin.entity_id`` on live platform APIs. + + The mock server's ``MOCK_CANONICAL_PROTEIN_ID`` (``"brd"``) is not accepted + as ``origin_entity_id`` on dev/prod — the API requires a real entity id. + """ + remote = f"testing/origin-source-{uuid.uuid4().hex[:12]}.pdb" + client.files.upload(_BRD_PDB_LOCAL, remote) + create = client.entities.create_protein(file_path=remote) + return create["data"]["id"] + + def _wait_for_ligand(client: DeepOriginClient, lig_id: str) -> dict: """Poll until a ligand row is readable via GET or search-by-id. @@ -635,7 +647,7 @@ def test_create_protein_metadata_lv1(client: DeepOriginClient) -> None: Also exercised via entity-updates notebook create-with-metadata cells. """ - client.files.upload(_BRD_PDB_LOCAL, _BRD_PDB_REMOTE) + source_protein_id = _create_origin_source_protein(client) remote = f"testing/prepared-meta-{uuid.uuid4().hex[:12]}.pdb" client.files.upload(_BRD_PDB_LOCAL, remote) preparation = {"chains": [{"id": "chain:A", "decision": "keep"}]} @@ -649,7 +661,7 @@ def test_create_protein_metadata_lv1(client: DeepOriginClient) -> None: origin={ "kind": "prepared", "entity_type": "protein", - "entity_id": MOCK_CANONICAL_PROTEIN_ID, + "entity_id": source_protein_id, }, ) @@ -659,12 +671,12 @@ def test_create_protein_metadata_lv1(client: DeepOriginClient) -> None: 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 + assert data["origin_entity_id"] == source_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) + source_protein_id = _create_origin_source_protein(client) smiles = _unique_test_smiles(suffix="S") tag = f"origin-{uuid.uuid4().hex[:10]}" create = client.entities.create_ligand( @@ -673,7 +685,7 @@ def test_create_ligand_origin_lv1(client: DeepOriginClient) -> None: origin={ "kind": "crystal_extraction", "entity_type": "protein", - "entity_id": MOCK_CANONICAL_PROTEIN_ID, + "entity_id": source_protein_id, }, ) lig_id = create["data"]["id"] @@ -681,7 +693,7 @@ def test_create_ligand_origin_lv1(client: DeepOriginClient) -> None: data = create["data"] assert data["origin_kind"] == "crystal_extraction" assert data["origin_entity_type"] == "protein" - assert data["origin_entity_id"] == MOCK_CANONICAL_PROTEIN_ID + assert data["origin_entity_id"] == source_protein_id fetched = _wait_for_ligand(client, lig_id) assert fetched.get("origin_kind") == "crystal_extraction" finally: