Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions fiftyone/server/routes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from .geo import GeoPoints
from .get_similar_labels_frames import GetSimilarLabelsFrameCollection
from .media import Media
from .sample import Sample
from .sample import SampleRoutes
from .plugins import Plugins
from .screenshot import Screenshot
from .sort import Sort
Expand All @@ -29,6 +29,7 @@
routes = (
EmbeddingsRoutes
+ OperatorRoutes
+ SampleRoutes
+ [
("/aggregate", Aggregate),
("/event", Event),
Expand All @@ -38,7 +39,6 @@
("/geo", GeoPoints),
("/media", Media),
("/plugins", Plugins),
("/dataset/{dataset_id}/sample/{sample_id}", Sample),
("/sort", Sort),
("/screenshot/{img:str}", Screenshot),
("/tag", Tag),
Expand Down
189 changes: 142 additions & 47 deletions fiftyone/server/routes/sample.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,42 +5,93 @@
| `voxel51.com <https://voxel51.com/>`_
|
"""

import logging

from starlette.endpoints import HTTPEndpoint
from starlette.exceptions import HTTPException
from starlette.requests import Request

import fiftyone.core.labels as fol
import fiftyone as fo
import fiftyone.core.odm.utils as fou
from typing import List
from fiftyone.server.utils.jsonpatch import parse
from fiftyone.server.utils import transform_json
from fiftyone.server.decorators import route
from typing import Any

logger = logging.getLogger(__name__)

LABEL_CLASS_MAP = {
"Classification": fol.Classification,
"Classifications": fol.Classifications,
"Detection": fol.Detection,
"Detections": fol.Detections,
"Polyline": fol.Polyline,
"Polylines": fol.Polylines,
}

def get_sample(dataset_id: str, sample_id: str) -> fo.Sample:
"""Retrieves a sample from a dataset.

Args:
dataset_id: the dataset ID
sample_id: the sample ID

Returns:
the sample

Raises:
HTTPException: if the dataset or sample is not found
"""
try:
dataset = fou.load_dataset(id=dataset_id)
except ValueError:
raise HTTPException(
status_code=404,
detail=f"Dataset '{dataset_id}' not found",
)

try:
sample = dataset[sample_id]
except KeyError:
raise HTTPException(
status_code=404,
detail=f"Sample '{sample_id}' not found in dataset '{dataset_id}'",
)

return sample


def handle_json_patch(target: Any, patch_list: List[dict]) -> Any:
"""Applies a list of JSON patch operations to a target object."""
try:
patches = parse(patch_list, transform_fn=transform_json)
except Exception as e:
raise HTTPException(
status_code=400,
detail=f"Failed to parse patches due to: {e}",
)

errors = {}
for i, p in enumerate(patches):
try:
p.apply(target)
except Exception as e:
logger.error("Error applying patch %s: %s", p, e)
errors[str(patch_list[i])] = str(e)

if errors:
raise HTTPException(
status_code=400,
detail=errors,
)
return target


class Sample(HTTPEndpoint):
@route
async def patch(self, request: Request, data: dict) -> dict:
"""Applies a list of field updates to a sample.

See: https://datatracker.ietf.org/doc/html/rfc6902

Args:
request: Starlette request with dataset_id and sample_id in path params
data: A dict mapping field names to values.

Field value handling:
- None: deletes the field
- dict with "_cls" key: deserializes as a FiftyOne label using from_dict
- other: assigns the value directly to the field

Returns:
the final state of the sample as a dict
"""
Expand All @@ -53,49 +104,31 @@ async def patch(self, request: Request, data: dict) -> dict:
dataset_id,
)

if not isinstance(data, dict):
raise HTTPException(
status_code=400,
detail="Request body must be a JSON object mapping field names to values",
)

try:
dataset = fou.load_dataset(id=dataset_id)
except ValueError:
raise HTTPException(
status_code=404,
detail=f"Dataset '{dataset_id}' not found",
)
sample = get_sample(dataset_id, sample_id)

try:
sample = dataset[sample_id]
except KeyError:
content_type = request.headers.get("Content-Type", "")
ctype = content_type.split(";", 1)[0].strip().lower()
if ctype == "application/json":
result = self._handle_patch(sample, data)
elif ctype == "application/json-patch+json":
result = handle_json_patch(sample, data)
else:
raise HTTPException(
status_code=404,
detail=f"Sample '{sample_id}' not found in dataset '{dataset_id}'",
status_code=415,
detail=f"Unsupported Content-Type '{ctype}'",
)
sample.save()
return result.to_dict(include_private=True)

def _handle_patch(self, sample: fo.Sample, data: dict) -> dict:
errors = {}
for field_name, value in data.items():
try:
if value is None:
sample.clear_field(field_name)
continue

if isinstance(value, dict) and "_cls" in value:
cls_name = value.get("_cls")
if cls_name in LABEL_CLASS_MAP:
label_cls = LABEL_CLASS_MAP[cls_name]
try:
sample[field_name] = label_cls.from_dict(value)
except Exception as e:
errors[field_name] = str(e)
else:
errors[
field_name
] = f"Unsupported label class '{cls_name}'"
else:
sample[field_name] = value
sample[field_name] = transform_json(value)
except Exception as e:
errors[field_name] = str(e)

Expand All @@ -104,6 +137,68 @@ async def patch(self, request: Request, data: dict) -> dict:
status_code=400,
detail=errors,
)
return sample


class SampleField(HTTPEndpoint):
@route
async def patch(self, request: Request, data: dict) -> dict:
"""Applies a list of field updates to a sample field in a list by id.

See: https://datatracker.ietf.org/doc/html/rfc6902

Args:
request: Starlette request with dataset_id and sample_id in path params
data: patch of type op, path, value.

Returns:
the final state of the sample as a dict
"""
dataset_id = request.path_params["dataset_id"]
sample_id = request.path_params["sample_id"]
path = request.path_params["field_path"]
field_id = request.path_params["field_id"]

logger.info(
"Received patch request for field %s with ID %s on sample %s in dataset %s",
path,
field_id,
sample_id,
dataset_id,
)

sample = get_sample(dataset_id, sample_id)

try:
field_list = sample.get_field(path)
except Exception as e:
raise HTTPException(
status_code=404,
detail=f"Field '{path}' not found in sample '{sample_id}'",
)

if not isinstance(field_list, list):
raise HTTPException(
status_code=400,
detail=f"Field '{path}' is not a list",
)

field = next((f for f in field_list if f.id == field_id), None)
if field is None:
raise HTTPException(
status_code=404,
detail=f"Field with id '{field_id}' not found in field '{path}'",
)

result = handle_json_patch(field, data)
sample.save()
return result.to_dict()


return sample.to_dict(include_private=True)
SampleRoutes = [
("/dataset/{dataset_id}/sample/{sample_id}", Sample),
(
"/dataset/{dataset_id}/sample/{sample_id}/{field_path}/{field_id}",
SampleField,
),
]
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@

import fiftyone.core.dataset as fod
import fiftyone.core.fields as fof
from fiftyone.server.utils.json_transform import (
transform as transform_json,
) # auto-register resource types


_cache = cachetools.TTLCache(maxsize=10, ttl=900) # ttl in seconds
Expand Down
10 changes: 10 additions & 0 deletions fiftyone/server/utils/json_transform/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"""
FiftyOne Server utils json transform.

| Copyright 2017-2025, Voxel51, Inc.
| `voxel51.com <https://voxel51.com/>`_
|
"""

import fiftyone.server.utils.json_transform.types # auto-register resource types
from fiftyone.server.utils.json_transform.transform import transform
66 changes: 66 additions & 0 deletions fiftyone/server/utils/json_transform/transform.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""Transform a json value.

| Copyright 2017-2025, Voxel51, Inc.
| `voxel51.com <https://voxel51.com/>`_
|
"""
from typing import Any, Callable, Type, TypeVar

T = TypeVar("T")

REGISTRY: dict[Type[T], Callable[[dict], T]] = {}


def register(
cls: Type[T], # pylint: disable=redefined-builtin
) -> Callable[[Callable[[dict], T]], Callable[[dict], T]]:
"""Register a validator function for a resource type.

Args:
cls Type[T]: The resource type

Returns:
Callable[[Callable[[dict], T]], Callable[[dict], T]]: A decorator
that registers the decorated function as a validator for the given
resource type.
"""

def inner(fn: Callable[[dict], T]) -> Callable[[dict], T]:
if not callable(fn):
raise TypeError("fn must be callable")

if cls in REGISTRY:
raise ValueError(
f"Resource type '{cls.__name__}' validator already registered"
)

REGISTRY[cls] = fn

return fn

return inner


def transform(
value: Any,
) -> Any:
"""Transforms a patch value if there is a registered transform method.
Args:
value (Any): The patch value optionally containing "_cls" key.

Returns:
Any: The transformed value or the original value if no transform is found.
"""
if not isinstance(value, dict):
return value

func = None
cls_name = value.get("_cls")
if cls_name:
func = next(
(fn for cls, fn in REGISTRY.items() if cls.__name__ == cls_name),
None,
)
if not func:
raise ValueError(f"No transform registered for class '{cls_name}'")
return func(value) if func else value
38 changes: 38 additions & 0 deletions fiftyone/server/utils/json_transform/types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""Json types registery.

| Copyright 2017-2025, Voxel51, Inc.
| `voxel51.com <https://voxel51.com/>`_
|
"""
from fiftyone.server.utils.json_transform.transform import register
import fiftyone.core.labels as fol


@register(fol.Classification)
def transform_classification(value: dict) -> fol.Classification:
return fol.Classification.from_dict(value)


@register(fol.Classifications)
def transform_classifications(value: dict) -> fol.Classifications:
return fol.Classifications.from_dict(value)


@register(fol.Detection)
def transform_detection(value: dict) -> fol.Detection:
return fol.Detection.from_dict(value)


@register(fol.Detections)
def transform_detections(value: dict) -> fol.Detections:
return fol.Detections.from_dict(value)


@register(fol.Polyline)
def transform_polyline(value: dict) -> fol.Polyline:
return fol.Polyline.from_dict(value)


@register(fol.Polylines)
def transform_polylines(value: dict) -> fol.Polylines:
return fol.Polylines.from_dict(value)
Loading
Loading