Skip to content

Commit

Permalink
Merge branch 'chore_update-pydantic-v2' of github.com:Opentrons/opent…
Browse files Browse the repository at this point in the history
…rons into chore_update-pydantic-v2
  • Loading branch information
SyntaxColoring committed May 23, 2024
2 parents fef7108 + c47cb10 commit a7a69d7
Show file tree
Hide file tree
Showing 7 changed files with 66 additions and 61 deletions.
70 changes: 40 additions & 30 deletions robot-server/robot_server/app_setup.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
"""Main FastAPI application."""
import asyncio
import logging
from typing import Optional
from typing import Optional, AsyncGenerator
from pathlib import Path

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager

from opentrons import __version__

Expand Down Expand Up @@ -43,36 +44,16 @@
log = logging.getLogger(__name__)


app = FastAPI(
title="Opentrons OT-2 HTTP API Spec",
description=(
"This OpenAPI spec describes the HTTP API of the Opentrons "
"OT-2. It may be retrieved from a robot on port 31950 at "
"/openapi. Some schemas used in requests and responses use "
"the `x-patternProperties` key to mean the JSON Schema "
"`patternProperties` behavior."
),
version=__version__,
exception_handlers=exception_handlers,
# Disable documentation hosting via Swagger UI, normally at /docs.
# We instead focus on the docs hosted by ReDoc, at /redoc.
docs_url=None,
)

# cors
app.add_middleware(
CORSMiddleware,
allow_origins=("*"),
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)

# main router
app.include_router(router=router)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""Lifespan event handler for FastAPI."""
try:
await on_startup()
yield
finally:
await on_shutdown()


@app.on_event("startup")
async def on_startup() -> None:
"""Handle app startup."""
settings = get_settings()
Expand Down Expand Up @@ -111,7 +92,6 @@ async def on_startup() -> None:
)


@app.on_event("shutdown")
async def on_shutdown() -> None:
"""Handle app shutdown."""
# FIXME(mm, 2024-01-31): Cleaning up everything concurrently like this is prone to
Expand All @@ -131,3 +111,33 @@ async def on_shutdown() -> None:

for e in shutdown_errors:
log.warning("Error during shutdown", exc_info=e)


app = FastAPI(
title="Opentrons HTTP API Spec",
description=(
"This OpenAPI spec describes the HTTP API of the Opentrons "
"robots. It may be retrieved from a robot on port 31950 at "
"/openapi. Some schemas used in requests and responses use "
"the `x-patternProperties` key to mean the JSON Schema "
"`patternProperties` behavior."
),
version=__version__,
exception_handlers=exception_handlers,
# Disable documentation hosting via Swagger UI, normally at /docs.
# We instead focus on the docs hosted by ReDoc, at /redoc.
docs_url=None,
lifespan=lifespan,
)

# cors
app.add_middleware(
CORSMiddleware,
allow_origins=("*"),
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)

# main router
app.include_router(router=router)
20 changes: 10 additions & 10 deletions robot-server/robot_server/errors/error_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,25 +27,25 @@ def as_error(self, status_code: int) -> ApiError:
"""Serialize the response as an API error to raise in a handler."""
return ApiError(
status_code=status_code,
content=self.dict(),
content=self.model_dump(),
)


class ErrorSource(BaseModel):
class ErrorSource(BaseResponseBody):
"""An object containing references to the source of the error."""

pointer: Optional[str] = Field(
None,
default=None,
description=(
"A JSON Pointer [RFC6901] to the associated entity in the request document."
),
)
parameter: Optional[str] = Field(
None,
default=None,
description="a string indicating which URI query parameter caused the error.",
)
header: Optional[str] = Field(
None,
default=None,
description="A string indicating which header caused the error.",
)

Expand Down Expand Up @@ -95,18 +95,18 @@ def get_some_model():
),
)
source: Optional[ErrorSource] = Field(
None,
default=None,
description="An object containing references to the source of the error.",
)
meta: Optional[Dict[str, Any]] = Field(
None,
default=None,
description=(
"An object containing non-standard information about this "
"occurrence of the error"
),
)
errorCode: str = Field(
ErrorCodes.GENERAL_ERROR.value.code,
default=ErrorCodes.GENERAL_ERROR.value.code,
description=("The Opentrons error code associated with the error"),
)

Expand Down Expand Up @@ -179,7 +179,7 @@ class ErrorBody(BaseErrorBody, BaseModel, Generic[ErrorDetailsT]):

errors: Sequence[ErrorDetailsT] = Field(..., description="Error details.")
links: Optional[ResourceLinks] = Field(
None,
default=None,
description=(
"Links that leads to further details about "
"this particular occurrence of the problem."
Expand All @@ -192,7 +192,7 @@ class MultiErrorResponse(BaseErrorBody, BaseModel, Generic[ErrorDetailsT]):

errors: Sequence[ErrorDetailsT] = Field(..., description="Error details.")
links: Optional[ResourceLinks] = Field(
None,
default=None,
description=(
"Links that leads to further details about "
"this particular occurrence of the problem."
Expand Down
6 changes: 3 additions & 3 deletions robot-server/robot_server/health/models.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
"""HTTP request and response models for /health endpoints."""
import typing
from pydantic import BaseModel, Field
from pydantic import Field
from opentrons_shared_data.deck.dev_types import RobotModel
from robot_server.service.json_api import BaseResponseBody


class HealthLinks(BaseModel):
class HealthLinks(BaseResponseBody):
"""Useful server links."""

apiLog: str = Field(
Expand All @@ -24,7 +24,7 @@ class HealthLinks(BaseModel):
examples=["/logs/server.log"],
)
oddLog: typing.Optional[str] = Field(
None,
default=None,
description=(
"The path to the on-device display app logs endpoint"
" (only present on the Opentrons Flex)"
Expand Down
10 changes: 5 additions & 5 deletions robot-server/robot_server/instruments/instrument_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,14 @@ class _GenericInstrument(BaseModel, Generic[InstrumentModelT, InstrumentDataT]):
instrumentModel: InstrumentModelT = Field(..., description="Instrument model.")
serialNumber: str = Field(..., description="Instrument hardware serial number.")
subsystem: Optional[SubSystem] = Field(
None,
default=None,
description="The subsystem corresponding to this instrument.",
)
ok: Literal[True] = Field(
..., description="Whether this instrument is OK and ready to go"
)
firmwareVersion: Optional[str] = Field(
None, description="The firmware version of this instrument (if applicable)"
default=None, description="The firmware version of this instrument (if applicable)"
)
data: InstrumentDataT

Expand All @@ -78,7 +78,7 @@ class GripperData(BaseModel):
# TODO (spp, 2023-01-03): update calibration field as decided after
# spike https://opentrons.atlassian.net/browse/RSS-167
calibratedOffset: Optional[InstrumentCalibrationData] = Field(
None, description="Calibrated gripper offset."
default=None, description="Calibrated gripper offset."
)


Expand All @@ -89,7 +89,7 @@ class PipetteData(BaseModel):
min_volume: float = Field(..., description="Minimum pipette volume.")
max_volume: float = Field(..., description="Maximum pipette volume.")
calibratedOffset: Optional[InstrumentCalibrationData] = Field(
None, description="Calibrated pipette offset."
default=None, description="Calibrated pipette offset."
)

# TODO (spp, 2022-12-20): update/ add fields according to client needs.
Expand All @@ -113,7 +113,7 @@ class Pipette(_GenericInstrument[PipetteModel, PipetteData]):
instrumentName: PipetteName
instrumentModel: PipetteModel
data: PipetteData
state: Optional[PipetteState]
state: Optional[PipetteState] = None


class Gripper(_GenericInstrument[GripperModelStr, GripperData]):
Expand Down
2 changes: 1 addition & 1 deletion robot-server/robot_server/instruments/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ async def _get_gripper_instrument_data(
attached_gripper: Optional[GripperDict],
) -> Optional[AttachedItem]:
subsys = HWSubSystem.of_mount(OT3Mount.GRIPPER)
status = hardware.attached_subsystems.get(subsys)
status = hardware.attached_subsystems.get(key=subsys)
if status and (status.fw_update_needed or not status.ok):
return _bad_gripper_response()
if attached_gripper:
Expand Down
13 changes: 4 additions & 9 deletions robot-server/robot_server/service/json_api/response.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
Callable,
)
from typing_extensions import get_args
from pydantic import Field, BaseModel, RootModel
from pydantic import Field, BaseModel, RootModel, model_serializer
from fastapi.responses import JSONResponse
from fastapi.dependencies.utils import get_typed_return_annotation
from .resource_links import ResourceLinks as DeprecatedResourceLinks
Expand Down Expand Up @@ -40,20 +40,15 @@ class BaseResponseBody(BaseModel):
JSON responses adhere to the server's generated OpenAPI Spec.
"""

def dict(self, *args: Any, **kwargs: Any) -> Dict[str, Any]:
@model_serializer
def serializer(self) -> Dict[str, Any]:
"""Always exclude `None` when serializing to an object.
The OpenAPI spec marks `Optional` BaseModel fields as omittable, but
not nullable. This `dict` method override ensures that `null` is never
returned in a response, which would violate the spec.
"""
kwargs["exclude_none"] = True
return super().model_dump(*args, **kwargs)

def json(self, *args: Any, **kwargs: Any) -> str:
"""See notes in `.dict()`."""
kwargs["exclude_none"] = True
return super().json(*args, **kwargs)
return {k: v for k, v in self.__dict__.items() if v is not None}


class SimpleBody(BaseResponseBody, BaseModel, Generic[ResponseDataT]):
Expand Down
6 changes: 3 additions & 3 deletions robot-server/tests/errors/test_exception_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,15 +161,15 @@ def create_item(item: Item) -> Item:
"id": "InvalidRequest",
"title": "Invalid Request",
"detail": "Input should be a valid integer, unable to parse "
"string as an integer",
"string as an integer",
"source": {"pointer": "/int_field"},
},
{
"errorCode": "4000",
"id": "InvalidRequest",
"title": "Invalid Request",
"detail": "Input should be a valid boolean, unable to interpret "
"input",
"input",
"source": {"pointer": "/array_field/0"},
},
]
Expand All @@ -193,7 +193,7 @@ def get_item(count: int) -> Item:
"id": "InvalidRequest",
"title": "Invalid Request",
"detail": "Input should be a valid integer, unable to parse "
"string as an integer",
"string as an integer",
"source": {"parameter": "count"},
},
]
Expand Down

0 comments on commit a7a69d7

Please sign in to comment.