Skip to content

Commit adaa82a

Browse files
committed
Add v2 entity_lists workspace sub-resource
Wraps the two endpoints exposed under a workspace by the v2 API: POST /api/workspaces/:workspace_id/entity_lists → create GET /api/workspaces/:workspace_id/entity_lists/:id → show No list/index endpoint exists yet on the server, so the client deliberately ships create + get only. Reached via client.workspaces.entity_lists. EntityList and EntityListItem models normalise the persisted 'dataset_external_id' field back to 'dataset_id' so callers see one name across create + show. Create accepts items either as EntityListItem instances or as plain dicts, validates entity_type against the server's enum (protein/peptide/gene) and the non-empty items constraint before round-tripping.
1 parent 2571cbc commit adaa82a

6 files changed

Lines changed: 391 additions & 0 deletions

File tree

src/md_python/models/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
NormalisationImputationDataset,
1111
PairwiseComparisonDataset,
1212
)
13+
from .entity_list import EntityList, EntityListItem
1314
from .experiment import Experiment
1415
from .metadata import ExperimentDesign, SampleMetadata
1516
from .registered_module import RegisteredModule
@@ -31,4 +32,6 @@
3132
"Tab",
3233
"TabModule",
3334
"RegisteredModule",
35+
"EntityList",
36+
"EntityListItem",
3437
]
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
"""
2+
EntityList and EntityListItem models for the v2 workspaces entity_lists API.
3+
4+
Mirror the ``ToHash::EntityList.to_hash_with_owner`` helper plus the
5+
appended ``items`` field set by ``present_entity_list`` in
6+
``app/api/api/v2/workspaces/api.rb``.
7+
8+
An entity list groups together a fixed selection of proteins, peptides,
9+
or genes drawn from one or more datasets — used by visualisation modules
10+
that take a list-id (``proteinListId`` / ``entityListId``) instead of a
11+
full dataset.
12+
"""
13+
14+
from dataclasses import dataclass, field
15+
from datetime import datetime
16+
from typing import Any, Dict, List, Optional
17+
from uuid import UUID
18+
19+
from pydantic.dataclasses import dataclass as pydantic_dataclass
20+
21+
22+
def _parse_iso_datetime(value: Optional[str]) -> Optional[datetime]:
23+
if value is not None and isinstance(value, str):
24+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
25+
return None
26+
27+
28+
@pydantic_dataclass
29+
@dataclass
30+
class EntityListItem:
31+
"""A single membership row in an entity list.
32+
33+
``dataset_id`` and ``group_id`` must both be set or both be null —
34+
the server-side validator on ``EntityListItem`` enforces this. The
35+
pair identifies a specific row in a specific dataset; ``entity_id``
36+
is the human-readable identifier (e.g. protein group accession).
37+
"""
38+
39+
entity_id: str
40+
group_id: Optional[int] = None
41+
dataset_id: Optional[str] = None
42+
id: Optional[UUID] = None
43+
44+
@classmethod
45+
def from_json(cls, data: Dict[str, Any]) -> "EntityListItem":
46+
# Server returns ``dataset_external_id`` on the persisted record
47+
# but accepts ``dataset_id`` on create — normalise both back to
48+
# ``dataset_id`` on the Python side so callers see one name.
49+
raw_id = data.get("id")
50+
return cls(
51+
entity_id=str(data["entity_id"]),
52+
group_id=data.get("group_id"),
53+
dataset_id=data.get("dataset_id") or data.get("dataset_external_id"),
54+
id=UUID(str(raw_id)) if raw_id is not None else None,
55+
)
56+
57+
def to_create_payload(self) -> Dict[str, Any]:
58+
"""Render as the JSON shape the Create endpoint accepts."""
59+
payload: Dict[str, Any] = {"entity_id": self.entity_id}
60+
if self.group_id is not None:
61+
payload["group_id"] = self.group_id
62+
if self.dataset_id is not None:
63+
payload["dataset_id"] = self.dataset_id
64+
return payload
65+
66+
67+
@pydantic_dataclass
68+
@dataclass
69+
class EntityList:
70+
"""A named list of proteins / peptides / genes drawn from datasets."""
71+
72+
id: UUID
73+
name: str
74+
type: str # 'protein' | 'peptide' | 'gene'
75+
experiment_id: Optional[UUID] = None
76+
items_count: int = 0
77+
owner: bool = False
78+
items: List[EntityListItem] = field(default_factory=list)
79+
created_at: Optional[datetime] = None
80+
updated_at: Optional[datetime] = None
81+
82+
@classmethod
83+
def from_json(cls, data: Dict[str, Any]) -> "EntityList":
84+
experiment_id_raw = data.get("experiment_id")
85+
return cls(
86+
id=UUID(str(data["id"])),
87+
name=str(data["name"]),
88+
type=str(data["type"]),
89+
experiment_id=(
90+
UUID(str(experiment_id_raw)) if experiment_id_raw is not None else None
91+
),
92+
items_count=int(data.get("items_count") or 0),
93+
owner=bool(data.get("owner", False)),
94+
items=[EntityListItem.from_json(item) for item in data.get("items") or []],
95+
created_at=_parse_iso_datetime(data.get("created_at")),
96+
updated_at=_parse_iso_datetime(data.get("updated_at")),
97+
)

src/md_python/resources/v2/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from .datasets import Datasets
66
from .entities import Entities
7+
from .entity_lists import EntityLists
78
from .jobs import Jobs
89
from .module_registry import ModuleRegistry
910
from .uploads import Uploads
@@ -13,6 +14,7 @@
1314
"Uploads",
1415
"Datasets",
1516
"Entities",
17+
"EntityLists",
1618
"Jobs",
1719
"Workspaces",
1820
"Tabs",
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"""
2+
EntityLists resource for the MD Python v2 client.
3+
4+
Maps the two endpoints exposed under a workspace:
5+
6+
POST /api/workspaces/:workspace_id/entity_lists → create
7+
GET /api/workspaces/:workspace_id/entity_lists/:id → show
8+
9+
There is no list/index endpoint yet (the server-side controller exposes
10+
only Create and Show), so this resource intentionally does not implement
11+
``list``. Looking up an entity list requires its id.
12+
"""
13+
14+
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence, Union
15+
16+
from ...models.entity_list import EntityList, EntityListItem
17+
18+
if TYPE_CHECKING:
19+
from ...base_client import BaseMDClient
20+
21+
22+
_JSON_HEADERS = {"Content-Type": "application/json"}
23+
24+
25+
def _check(response: Any, expected: int, action: str) -> None:
26+
if response.status_code != expected:
27+
raise Exception(f"Failed to {action}: {response.status_code} - {response.text}")
28+
29+
30+
# A caller can pass items as a list of EntityListItem objects or as a
31+
# list of plain dicts ({entity_id, group_id, dataset_id}). Both shapes
32+
# are normalised before sending.
33+
EntityListItemInput = Union[EntityListItem, Dict[str, Any]]
34+
35+
36+
class EntityLists:
37+
"""Workspace-scoped entity lists (proteins / peptides / genes).
38+
39+
Reached via ``client.workspaces.entity_lists`` and always
40+
parameterised by ``workspace_id`` since lists live under a workspace.
41+
"""
42+
43+
def __init__(self, client: "BaseMDClient"):
44+
self._client = client
45+
46+
def _base(self, workspace_id: str) -> str:
47+
return f"/workspaces/{workspace_id}/entity_lists"
48+
49+
def create(
50+
self,
51+
workspace_id: str,
52+
name: str,
53+
entity_type: str,
54+
items: Sequence[EntityListItemInput],
55+
) -> EntityList:
56+
"""Create a named entity list inside a workspace.
57+
58+
Args:
59+
workspace_id: Parent workspace UUID.
60+
name: Display name for the list.
61+
entity_type: One of ``protein``, ``peptide``, or ``gene``.
62+
items: At least one item. Each item is either an
63+
:class:`EntityListItem` or a dict with ``entity_id``,
64+
``group_id`` and ``dataset_id``.
65+
66+
Returns:
67+
The created :class:`EntityList` (with ``items`` populated).
68+
"""
69+
if entity_type not in {"protein", "peptide", "gene"}:
70+
raise ValueError(
71+
"entity_type must be one of: protein, peptide, gene "
72+
f"(got {entity_type!r})"
73+
)
74+
if not items:
75+
raise ValueError("items must include at least one entry")
76+
77+
payload_items: List[Dict[str, Any]] = []
78+
for item in items:
79+
if isinstance(item, EntityListItem):
80+
payload_items.append(item.to_create_payload())
81+
elif isinstance(item, dict):
82+
if "entity_id" not in item:
83+
raise ValueError("every item must have an 'entity_id' field")
84+
payload_items.append(dict(item))
85+
else:
86+
raise TypeError(
87+
"items entries must be EntityListItem or dict, "
88+
f"got {type(item).__name__}"
89+
)
90+
91+
response = self._client._make_request(
92+
method="POST",
93+
endpoint=self._base(workspace_id),
94+
json={
95+
"name": name,
96+
"entity_type": entity_type,
97+
"items": payload_items,
98+
},
99+
headers=_JSON_HEADERS,
100+
)
101+
_check(response, 201, "create entity list")
102+
return EntityList.from_json(response.json())
103+
104+
def get(self, workspace_id: str, list_id: str) -> Optional[EntityList]:
105+
"""Get a single entity list, or ``None`` if not found."""
106+
response = self._client._make_request(
107+
method="GET",
108+
endpoint=f"{self._base(workspace_id)}/{list_id}",
109+
)
110+
if response.status_code == 404:
111+
return None
112+
_check(response, 200, "get entity list")
113+
return EntityList.from_json(response.json())

src/md_python/resources/v2/workspaces.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from typing import TYPE_CHECKING, Any, Dict, List, Optional
1414

1515
from ...models import RegisteredModule, Tab, TabModule, Workspace
16+
from .entity_lists import EntityLists
1617

1718
if TYPE_CHECKING:
1819
from ...base_client import BaseMDClient
@@ -411,6 +412,7 @@ def __init__(
411412
self._client = client
412413
self.tabs = Tabs(client)
413414
self.modules = TabModules(client, registry=registry)
415+
self.entity_lists = EntityLists(client)
414416

415417
def create(self, name: str, description: Optional[str] = None) -> Workspace:
416418
payload: Dict[str, Any] = {"name": name}

0 commit comments

Comments
 (0)