Skip to content

Commit

Permalink
feat(robot-server): Implement the /labwareOffsets endpoints with an…
Browse files Browse the repository at this point in the history
… in-memory store (#17059)
  • Loading branch information
SyntaxColoring authored Dec 6, 2024
1 parent 68947bb commit 5405714
Show file tree
Hide file tree
Showing 8 changed files with 455 additions and 17 deletions.
29 changes: 29 additions & 0 deletions robot-server/robot_server/labware_offsets/fastapi_dependencies.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""FastAPI dependencies for the `/labwareOffsets` endpoints."""


from typing import Annotated

from fastapi import Depends

from server_utils.fastapi_utils.app_state import (
AppState,
AppStateAccessor,
get_app_state,
)
from .store import LabwareOffsetStore


_labware_offset_store_accessor = AppStateAccessor[LabwareOffsetStore](
"labware_offset_store"
)


async def get_labware_offset_store(
app_state: Annotated[AppState, Depends(get_app_state)],
) -> LabwareOffsetStore:
"""Get the server's singleton LabwareOffsetStore."""
labware_offset_store = _labware_offset_store_accessor.get_from(app_state)
if labware_offset_store is None:
labware_offset_store = LabwareOffsetStore()
_labware_offset_store_accessor.set_on(app_state, labware_offset_store)
return labware_offset_store
19 changes: 19 additions & 0 deletions robot-server/robot_server/labware_offsets/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"""Request/response models for the `/labwareOffsets` endpoints."""


from typing import Literal, Type
from typing_extensions import Self

from robot_server.errors.error_responses import ErrorDetails


class LabwareOffsetNotFound(ErrorDetails):
"""An error returned when a requested labware offset does not exist."""

id: Literal["LabwareOffsetNotFound"] = "LabwareOffsetNotFound"
title: str = "Labware Offset Not Found"

@classmethod
def build(cls: Type[Self], bad_offset_id: str) -> Self:
"""Return an error with a standard message."""
return cls.construct(detail=f'No offset found with ID "{bad_offset_id}".')
90 changes: 74 additions & 16 deletions robot-server/robot_server/labware_offsets/router.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,28 @@
"""FastAPI endpoint functions for the `/labwareOffsets` endpoints."""


from datetime import datetime
import textwrap
from typing import Annotated, Literal

import fastapi
from opentrons.protocol_engine import LabwareOffset, LabwareOffsetCreate
from opentrons.protocol_engine import LabwareOffset, LabwareOffsetCreate, ModuleModel
from opentrons.types import DeckSlotName

from robot_server.labware_offsets.models import LabwareOffsetNotFound
from robot_server.service.dependencies import get_current_time, get_unique_id
from robot_server.service.json_api.request import RequestModel
from robot_server.service.json_api.response import (
MultiBodyMeta,
PydanticResponse,
SimpleBody,
SimpleEmptyBody,
SimpleMultiBody,
)

from .store import LabwareOffsetNotFoundError, LabwareOffsetStore
from .fastapi_dependencies import get_labware_offset_store


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

Expand All @@ -31,11 +39,26 @@
To do that, you must add the offset to a run, through the `/runs` endpoints.
"""
),
status_code=201,
)
def post_labware_offset( # noqa: D103
new_offset: Annotated[RequestModel[LabwareOffsetCreate], fastapi.Body()]
) -> PydanticResponse[SimpleEmptyBody]:
raise NotImplementedError()
async def post_labware_offset( # noqa: D103
store: Annotated[LabwareOffsetStore, fastapi.Depends(get_labware_offset_store)],
new_offset_id: Annotated[str, fastapi.Depends(get_unique_id)],
new_offset_created_at: Annotated[datetime, fastapi.Depends(get_current_time)],
request_body: Annotated[RequestModel[LabwareOffsetCreate], fastapi.Body()],
) -> PydanticResponse[SimpleBody[LabwareOffset]]:
new_offset = LabwareOffset.construct(
id=new_offset_id,
createdAt=new_offset_created_at,
definitionUri=request_body.data.definitionUri,
location=request_body.data.location,
vector=request_body.data.vector,
)
store.add(new_offset)
return await PydanticResponse.create(
content=SimpleBody.construct(data=new_offset),
status_code=201,
)


@PydanticResponse.wrap_route(
Expand All @@ -48,7 +71,8 @@ def post_labware_offset( # noqa: D103
" Results are returned in order from oldest to newest."
),
)
def get_labware_offsets( # noqa: D103
async def get_labware_offsets( # noqa: D103
store: Annotated[LabwareOffsetStore, fastapi.Depends(get_labware_offset_store)],
id: Annotated[
str | None,
fastapi.Query(description="Filter for exact matches on the `id` field."),
Expand All @@ -64,23 +88,23 @@ def get_labware_offsets( # noqa: D103
),
] = None,
location_slot_name: Annotated[
str | None,
DeckSlotName | None,
fastapi.Query(
alias="location.slotName",
alias="locationSlotName",
description="Filter for exact matches on the `location.slotName` field.",
),
] = None,
location_module_model: Annotated[
str | None,
ModuleModel | None,
fastapi.Query(
alias="location.moduleModel",
alias="locationModuleModel",
description="Filter for exact matches on the `location.moduleModel` field.",
),
] = None,
location_definition_uri: Annotated[
str | None,
fastapi.Query(
alias="location.definitionUri",
alias="locationDefinitionUri",
description=(
"Filter for exact matches on the `location.definitionUri` field."
" (Not to be confused with just `definitionUri`.)"
Expand All @@ -104,7 +128,32 @@ def get_labware_offsets( # noqa: D103
),
] = "unlimited",
) -> PydanticResponse[SimpleMultiBody[LabwareOffset]]:
raise NotImplementedError()
if cursor not in (0, None) or page_length != "unlimited":
# todo(mm, 2024-12-06): Support this when LabwareOffsetStore supports it.
raise NotImplementedError(
"Pagination not currently supported on this endpoint."
)

result_data = store.search(
id_filter=id,
definition_uri_filter=definition_uri,
location_slot_name_filter=location_slot_name,
location_definition_uri_filter=location_definition_uri,
location_module_model_filter=location_module_model,
)

meta = MultiBodyMeta.construct(
# todo(mm, 2024-12-06): Update this when pagination is supported.
cursor=0,
totalLength=len(result_data),
)

return await PydanticResponse.create(
SimpleMultiBody[LabwareOffset].construct(
data=result_data,
meta=meta,
)
)


@PydanticResponse.wrap_route(
Expand All @@ -113,19 +162,28 @@ def get_labware_offsets( # noqa: D103
summary="Delete a single labware offset",
description="Delete a single labware offset. The deleted offset is returned.",
)
def delete_labware_offset( # noqa: D103
async def delete_labware_offset( # noqa: D103
store: Annotated[LabwareOffsetStore, fastapi.Depends(get_labware_offset_store)],
id: Annotated[
str,
fastapi.Path(description="The `id` field of the offset to delete."),
],
) -> PydanticResponse[SimpleBody[LabwareOffset]]:
raise NotImplementedError()
try:
deleted_offset = store.delete(offset_id=id)
except LabwareOffsetNotFoundError as e:
raise LabwareOffsetNotFound.build(bad_offset_id=e.bad_offset_id).as_error(404)
else:
return await PydanticResponse.create(SimpleBody.construct(data=deleted_offset))


@PydanticResponse.wrap_route(
router.delete,
path="",
summary="Delete all labware offsets",
)
def delete_all_labware_offsets() -> PydanticResponse[SimpleEmptyBody]: # noqa: D103
raise NotImplementedError()
async def delete_all_labware_offsets( # noqa: D103
store: Annotated[LabwareOffsetStore, fastapi.Depends(get_labware_offset_store)]
) -> PydanticResponse[SimpleEmptyBody]:
store.delete_all()
return await PydanticResponse.create(SimpleEmptyBody.construct())
68 changes: 68 additions & 0 deletions robot-server/robot_server/labware_offsets/store.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# noqa: D100

from opentrons.protocol_engine import LabwareOffset, ModuleModel
from opentrons.types import DeckSlotName


# todo(mm, 2024-12-06): Convert to be SQL-based and persistent instead of in-memory.
# https://opentrons.atlassian.net/browse/EXEC-1015
class LabwareOffsetStore:
"""A persistent store for labware offsets, to support the `/labwareOffsets` endpoints."""

def __init__(self) -> None:
self._offsets_by_id: dict[str, LabwareOffset] = {}

def add(self, offset: LabwareOffset) -> None:
"""Store a new labware offset."""
assert offset.id not in self._offsets_by_id
self._offsets_by_id[offset.id] = offset

def search(
self,
id_filter: str | None,
definition_uri_filter: str | None,
location_slot_name_filter: DeckSlotName | None,
location_module_model_filter: ModuleModel | None,
location_definition_uri_filter: str | None,
# todo(mm, 2024-12-06): Support pagination (cursor & pageLength query params).
# The logic for that is currently duplicated across several places in
# robot-server and api. We should try to clean that up, or at least avoid
# making it worse.
) -> list[LabwareOffset]:
"""Return all matching labware offsets in order from oldest-added to newest."""

def is_match(candidate: LabwareOffset) -> bool:
return (
id_filter in (None, candidate.id)
and definition_uri_filter in (None, candidate.definitionUri)
and location_slot_name_filter in (None, candidate.location.slotName)
and location_module_model_filter
in (None, candidate.location.moduleModel)
and location_definition_uri_filter
in (None, candidate.location.definitionUri)
)

return [
candidate
for candidate in self._offsets_by_id.values()
if is_match(candidate)
]

def delete(self, offset_id: str) -> LabwareOffset:
"""Delete a labware offset by its ID. Return what was just deleted."""
try:
return self._offsets_by_id.pop(offset_id)
except KeyError:
raise LabwareOffsetNotFoundError(bad_offset_id=offset_id) from None

def delete_all(self) -> None:
"""Delete all labware offsets."""
self._offsets_by_id.clear()


class LabwareOffsetNotFoundError(KeyError):
"""Raised when trying to access a labware offset that doesn't exist."""

def __init__(self, bad_offset_id: str) -> None:
super().__init__(bad_offset_id)
self.bad_offset_id = bad_offset_id
6 changes: 5 additions & 1 deletion robot-server/tests/integration/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,8 @@ async def _clean_server_state_async() -> None:

await _reset_deck_configuration(robot_client)
await _reset_error_recovery_settings(robot_client)

await _delete_client_data(robot_client)
await _delete_labware_offsets(robot_client)

asyncio.run(_clean_server_state_async())

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

async def _reset_error_recovery_settings(robot_client: RobotClient) -> None:
await robot_client.delete_error_recovery_settings()


async def _delete_labware_offsets(robot_client: RobotClient) -> None:
await robot_client.delete_all_labware_offsets()
Loading

0 comments on commit 5405714

Please sign in to comment.