Skip to content

Commit 5405714

Browse files
feat(robot-server): Implement the /labwareOffsets endpoints with an in-memory store (#17059)
1 parent 68947bb commit 5405714

File tree

8 files changed

+455
-17
lines changed

8 files changed

+455
-17
lines changed
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
"""FastAPI dependencies for the `/labwareOffsets` endpoints."""
2+
3+
4+
from typing import Annotated
5+
6+
from fastapi import Depends
7+
8+
from server_utils.fastapi_utils.app_state import (
9+
AppState,
10+
AppStateAccessor,
11+
get_app_state,
12+
)
13+
from .store import LabwareOffsetStore
14+
15+
16+
_labware_offset_store_accessor = AppStateAccessor[LabwareOffsetStore](
17+
"labware_offset_store"
18+
)
19+
20+
21+
async def get_labware_offset_store(
22+
app_state: Annotated[AppState, Depends(get_app_state)],
23+
) -> LabwareOffsetStore:
24+
"""Get the server's singleton LabwareOffsetStore."""
25+
labware_offset_store = _labware_offset_store_accessor.get_from(app_state)
26+
if labware_offset_store is None:
27+
labware_offset_store = LabwareOffsetStore()
28+
_labware_offset_store_accessor.set_on(app_state, labware_offset_store)
29+
return labware_offset_store
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
"""Request/response models for the `/labwareOffsets` endpoints."""
2+
3+
4+
from typing import Literal, Type
5+
from typing_extensions import Self
6+
7+
from robot_server.errors.error_responses import ErrorDetails
8+
9+
10+
class LabwareOffsetNotFound(ErrorDetails):
11+
"""An error returned when a requested labware offset does not exist."""
12+
13+
id: Literal["LabwareOffsetNotFound"] = "LabwareOffsetNotFound"
14+
title: str = "Labware Offset Not Found"
15+
16+
@classmethod
17+
def build(cls: Type[Self], bad_offset_id: str) -> Self:
18+
"""Return an error with a standard message."""
19+
return cls.construct(detail=f'No offset found with ID "{bad_offset_id}".')

robot-server/robot_server/labware_offsets/router.py

Lines changed: 74 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,28 @@
11
"""FastAPI endpoint functions for the `/labwareOffsets` endpoints."""
22

33

4+
from datetime import datetime
45
import textwrap
56
from typing import Annotated, Literal
67

78
import fastapi
8-
from opentrons.protocol_engine import LabwareOffset, LabwareOffsetCreate
9+
from opentrons.protocol_engine import LabwareOffset, LabwareOffsetCreate, ModuleModel
10+
from opentrons.types import DeckSlotName
911

12+
from robot_server.labware_offsets.models import LabwareOffsetNotFound
13+
from robot_server.service.dependencies import get_current_time, get_unique_id
1014
from robot_server.service.json_api.request import RequestModel
1115
from robot_server.service.json_api.response import (
16+
MultiBodyMeta,
1217
PydanticResponse,
1318
SimpleBody,
1419
SimpleEmptyBody,
1520
SimpleMultiBody,
1621
)
1722

23+
from .store import LabwareOffsetNotFoundError, LabwareOffsetStore
24+
from .fastapi_dependencies import get_labware_offset_store
25+
1826

1927
router = fastapi.APIRouter(prefix="/labwareOffsets")
2028

@@ -31,11 +39,26 @@
3139
To do that, you must add the offset to a run, through the `/runs` endpoints.
3240
"""
3341
),
42+
status_code=201,
3443
)
35-
def post_labware_offset( # noqa: D103
36-
new_offset: Annotated[RequestModel[LabwareOffsetCreate], fastapi.Body()]
37-
) -> PydanticResponse[SimpleEmptyBody]:
38-
raise NotImplementedError()
44+
async def post_labware_offset( # noqa: D103
45+
store: Annotated[LabwareOffsetStore, fastapi.Depends(get_labware_offset_store)],
46+
new_offset_id: Annotated[str, fastapi.Depends(get_unique_id)],
47+
new_offset_created_at: Annotated[datetime, fastapi.Depends(get_current_time)],
48+
request_body: Annotated[RequestModel[LabwareOffsetCreate], fastapi.Body()],
49+
) -> PydanticResponse[SimpleBody[LabwareOffset]]:
50+
new_offset = LabwareOffset.construct(
51+
id=new_offset_id,
52+
createdAt=new_offset_created_at,
53+
definitionUri=request_body.data.definitionUri,
54+
location=request_body.data.location,
55+
vector=request_body.data.vector,
56+
)
57+
store.add(new_offset)
58+
return await PydanticResponse.create(
59+
content=SimpleBody.construct(data=new_offset),
60+
status_code=201,
61+
)
3962

4063

4164
@PydanticResponse.wrap_route(
@@ -48,7 +71,8 @@ def post_labware_offset( # noqa: D103
4871
" Results are returned in order from oldest to newest."
4972
),
5073
)
51-
def get_labware_offsets( # noqa: D103
74+
async def get_labware_offsets( # noqa: D103
75+
store: Annotated[LabwareOffsetStore, fastapi.Depends(get_labware_offset_store)],
5276
id: Annotated[
5377
str | None,
5478
fastapi.Query(description="Filter for exact matches on the `id` field."),
@@ -64,23 +88,23 @@ def get_labware_offsets( # noqa: D103
6488
),
6589
] = None,
6690
location_slot_name: Annotated[
67-
str | None,
91+
DeckSlotName | None,
6892
fastapi.Query(
69-
alias="location.slotName",
93+
alias="locationSlotName",
7094
description="Filter for exact matches on the `location.slotName` field.",
7195
),
7296
] = None,
7397
location_module_model: Annotated[
74-
str | None,
98+
ModuleModel | None,
7599
fastapi.Query(
76-
alias="location.moduleModel",
100+
alias="locationModuleModel",
77101
description="Filter for exact matches on the `location.moduleModel` field.",
78102
),
79103
] = None,
80104
location_definition_uri: Annotated[
81105
str | None,
82106
fastapi.Query(
83-
alias="location.definitionUri",
107+
alias="locationDefinitionUri",
84108
description=(
85109
"Filter for exact matches on the `location.definitionUri` field."
86110
" (Not to be confused with just `definitionUri`.)"
@@ -104,7 +128,32 @@ def get_labware_offsets( # noqa: D103
104128
),
105129
] = "unlimited",
106130
) -> PydanticResponse[SimpleMultiBody[LabwareOffset]]:
107-
raise NotImplementedError()
131+
if cursor not in (0, None) or page_length != "unlimited":
132+
# todo(mm, 2024-12-06): Support this when LabwareOffsetStore supports it.
133+
raise NotImplementedError(
134+
"Pagination not currently supported on this endpoint."
135+
)
136+
137+
result_data = store.search(
138+
id_filter=id,
139+
definition_uri_filter=definition_uri,
140+
location_slot_name_filter=location_slot_name,
141+
location_definition_uri_filter=location_definition_uri,
142+
location_module_model_filter=location_module_model,
143+
)
144+
145+
meta = MultiBodyMeta.construct(
146+
# todo(mm, 2024-12-06): Update this when pagination is supported.
147+
cursor=0,
148+
totalLength=len(result_data),
149+
)
150+
151+
return await PydanticResponse.create(
152+
SimpleMultiBody[LabwareOffset].construct(
153+
data=result_data,
154+
meta=meta,
155+
)
156+
)
108157

109158

110159
@PydanticResponse.wrap_route(
@@ -113,19 +162,28 @@ def get_labware_offsets( # noqa: D103
113162
summary="Delete a single labware offset",
114163
description="Delete a single labware offset. The deleted offset is returned.",
115164
)
116-
def delete_labware_offset( # noqa: D103
165+
async def delete_labware_offset( # noqa: D103
166+
store: Annotated[LabwareOffsetStore, fastapi.Depends(get_labware_offset_store)],
117167
id: Annotated[
118168
str,
119169
fastapi.Path(description="The `id` field of the offset to delete."),
120170
],
121171
) -> PydanticResponse[SimpleBody[LabwareOffset]]:
122-
raise NotImplementedError()
172+
try:
173+
deleted_offset = store.delete(offset_id=id)
174+
except LabwareOffsetNotFoundError as e:
175+
raise LabwareOffsetNotFound.build(bad_offset_id=e.bad_offset_id).as_error(404)
176+
else:
177+
return await PydanticResponse.create(SimpleBody.construct(data=deleted_offset))
123178

124179

125180
@PydanticResponse.wrap_route(
126181
router.delete,
127182
path="",
128183
summary="Delete all labware offsets",
129184
)
130-
def delete_all_labware_offsets() -> PydanticResponse[SimpleEmptyBody]: # noqa: D103
131-
raise NotImplementedError()
185+
async def delete_all_labware_offsets( # noqa: D103
186+
store: Annotated[LabwareOffsetStore, fastapi.Depends(get_labware_offset_store)]
187+
) -> PydanticResponse[SimpleEmptyBody]:
188+
store.delete_all()
189+
return await PydanticResponse.create(SimpleEmptyBody.construct())
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# noqa: D100
2+
3+
from opentrons.protocol_engine import LabwareOffset, ModuleModel
4+
from opentrons.types import DeckSlotName
5+
6+
7+
# todo(mm, 2024-12-06): Convert to be SQL-based and persistent instead of in-memory.
8+
# https://opentrons.atlassian.net/browse/EXEC-1015
9+
class LabwareOffsetStore:
10+
"""A persistent store for labware offsets, to support the `/labwareOffsets` endpoints."""
11+
12+
def __init__(self) -> None:
13+
self._offsets_by_id: dict[str, LabwareOffset] = {}
14+
15+
def add(self, offset: LabwareOffset) -> None:
16+
"""Store a new labware offset."""
17+
assert offset.id not in self._offsets_by_id
18+
self._offsets_by_id[offset.id] = offset
19+
20+
def search(
21+
self,
22+
id_filter: str | None,
23+
definition_uri_filter: str | None,
24+
location_slot_name_filter: DeckSlotName | None,
25+
location_module_model_filter: ModuleModel | None,
26+
location_definition_uri_filter: str | None,
27+
# todo(mm, 2024-12-06): Support pagination (cursor & pageLength query params).
28+
# The logic for that is currently duplicated across several places in
29+
# robot-server and api. We should try to clean that up, or at least avoid
30+
# making it worse.
31+
) -> list[LabwareOffset]:
32+
"""Return all matching labware offsets in order from oldest-added to newest."""
33+
34+
def is_match(candidate: LabwareOffset) -> bool:
35+
return (
36+
id_filter in (None, candidate.id)
37+
and definition_uri_filter in (None, candidate.definitionUri)
38+
and location_slot_name_filter in (None, candidate.location.slotName)
39+
and location_module_model_filter
40+
in (None, candidate.location.moduleModel)
41+
and location_definition_uri_filter
42+
in (None, candidate.location.definitionUri)
43+
)
44+
45+
return [
46+
candidate
47+
for candidate in self._offsets_by_id.values()
48+
if is_match(candidate)
49+
]
50+
51+
def delete(self, offset_id: str) -> LabwareOffset:
52+
"""Delete a labware offset by its ID. Return what was just deleted."""
53+
try:
54+
return self._offsets_by_id.pop(offset_id)
55+
except KeyError:
56+
raise LabwareOffsetNotFoundError(bad_offset_id=offset_id) from None
57+
58+
def delete_all(self) -> None:
59+
"""Delete all labware offsets."""
60+
self._offsets_by_id.clear()
61+
62+
63+
class LabwareOffsetNotFoundError(KeyError):
64+
"""Raised when trying to access a labware offset that doesn't exist."""
65+
66+
def __init__(self, bad_offset_id: str) -> None:
67+
super().__init__(bad_offset_id)
68+
self.bad_offset_id = bad_offset_id

robot-server/tests/integration/conftest.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,8 +132,8 @@ async def _clean_server_state_async() -> None:
132132

133133
await _reset_deck_configuration(robot_client)
134134
await _reset_error_recovery_settings(robot_client)
135-
136135
await _delete_client_data(robot_client)
136+
await _delete_labware_offsets(robot_client)
137137

138138
asyncio.run(_clean_server_state_async())
139139

@@ -179,3 +179,7 @@ async def _reset_deck_configuration(robot_client: RobotClient) -> None:
179179

180180
async def _reset_error_recovery_settings(robot_client: RobotClient) -> None:
181181
await robot_client.delete_error_recovery_settings()
182+
183+
184+
async def _delete_labware_offsets(robot_client: RobotClient) -> None:
185+
await robot_client.delete_all_labware_offsets()

0 commit comments

Comments
 (0)