diff --git a/docs/labelbox/index.rst b/docs/labelbox/index.rst index 3e27674d0..fb7c6e1b5 100644 --- a/docs/labelbox/index.rst +++ b/docs/labelbox/index.rst @@ -29,6 +29,7 @@ Labelbox Python SDK Documentation issue-position label label-score + label-version labeling-frontend labeling-frontend-options labeling-parameter-override diff --git a/docs/labelbox/label-version.rst b/docs/labelbox/label-version.rst new file mode 100644 index 000000000..ab6a64568 --- /dev/null +++ b/docs/labelbox/label-version.rst @@ -0,0 +1,6 @@ +Label Version +=============================================================================================== + +.. automodule:: labelbox.schema.label_version + :members: LabelVersion, LabelVersionStage, LabelVersionAction + :show-inheritance: diff --git a/libs/labelbox/src/labelbox/__init__.py b/libs/labelbox/src/labelbox/__init__.py index c4f4e2c27..fbd815b35 100644 --- a/libs/labelbox/src/labelbox/__init__.py +++ b/libs/labelbox/src/labelbox/__init__.py @@ -102,6 +102,11 @@ from labelbox.schema.organization import Organization from labelbox.schema.project import Project from labelbox.schema.project_model_config import ProjectModelConfig +from labelbox.schema.label_version import ( + LabelVersion, + LabelVersionAction, + LabelVersionStage, +) from labelbox.schema.performance import ( PerformanceCursorRows, PerformanceDeletedLabels, diff --git a/libs/labelbox/src/labelbox/client.py b/libs/labelbox/src/labelbox/client.py index f5f97cfc8..2b43de574 100644 --- a/libs/labelbox/src/labelbox/client.py +++ b/libs/labelbox/src/labelbox/client.py @@ -42,6 +42,7 @@ from labelbox.schema.foundry.foundry_client import FoundryClient from labelbox.schema.iam_integration import IAMIntegration from labelbox.schema.identifiables import DataRowIds, GlobalKeys +from labelbox.schema import label_version as _label_version from labelbox.schema.label_score import LabelScore from labelbox.schema.labeling_frontend import LabelingFrontend from labelbox.schema import performance as _performance @@ -2786,6 +2787,32 @@ def get_sign_in_events( page_size=page_size, ) + def get_label_version( + self, version_id: str, include_annotations: bool = True + ) -> Optional[_label_version.LabelVersion]: + """Returns one recorded version of a label. + + List a project's versions with :meth:`Project.label_versions` or one + label's with :meth:`Label.versions`. + + Args: + version_id (str): The version's ID (``LabelVersion.id``). + include_annotations (bool): Also read the annotations at that + moment (``label`` and ``label_json``). Defaults to True. + + Returns: + Optional[LabelVersion]: ``None`` if it does not exist or you may + not read its label. + + Example: + >>> version = client.get_label_version("cm2v...") + >>> print(version.action, version.created_by_id, version.created_at) + >>> annotations = version.label_json + """ + return _label_version.get_label_version( + self, version_id, include_annotations=include_annotations + ) + def get_task_by_id(self, task_id: str) -> Union[Task, DataUpsertTask]: """ Fetches a task by ID. diff --git a/libs/labelbox/src/labelbox/data/serialization/ndjson/objects.py b/libs/labelbox/src/labelbox/data/serialization/ndjson/objects.py index 55d6b5e62..319f9acbb 100644 --- a/libs/labelbox/src/labelbox/data/serialization/ndjson/objects.py +++ b/libs/labelbox/src/labelbox/data/serialization/ndjson/objects.py @@ -509,7 +509,11 @@ def from_common( else: binary = np.all(mask.mask.value == mask.color, axis=-1) im_bytes = BytesIO() - Image.fromarray(binary, "L").save(im_bytes, format="PNG") + # Pillow 13 refuses a bool array with mode "L"; uint8 0/1 gives + # the same L-mode PNG that older Pillow produced from it. + Image.fromarray(binary.astype(np.uint8)).save( + im_bytes, format="PNG" + ) lbv1_mask = _PNGMask( png=base64.b64encode(im_bytes.getvalue()).decode("utf-8") ) diff --git a/libs/labelbox/src/labelbox/schema/label.py b/libs/labelbox/src/labelbox/schema/label.py index 371193a13..a88454cf7 100644 --- a/libs/labelbox/src/labelbox/schema/label.py +++ b/libs/labelbox/src/labelbox/schema/label.py @@ -1,6 +1,8 @@ from typing import TYPE_CHECKING from labelbox.orm import query +from labelbox.pagination import PaginatedCollection +from labelbox.schema import label_version as _label_version from labelbox.orm.db_object import DbObject, Updateable, BulkDeletable from labelbox.orm.model import Entity, Field, Relationship @@ -44,6 +46,31 @@ def __init__(self, *args, **kwargs): reviews = Relationship.ToMany("Review", False) created_by = Relationship.ToOne("User", False, "created_by") + def versions(self, include_annotations: bool = True) -> PaginatedCollection: + """Returns this label's recorded versions, oldest first. + + See :meth:`labelbox.schema.project.Project.label_versions` for what a + version is and who may read it. + + Args: + include_annotations (bool): Also read each version's annotations + (``label`` and ``label_json``). Defaults to True. + + Returns: + PaginatedCollection of LabelVersion. + + Example: + >>> for version in label.versions(): + >>> print(version.created_at, version.action, + >>> version.created_by_id, version.label_json) + """ + return _label_version.get_label_versions( + self.client, + self.project().uid, + label=self.uid, + include_annotations=include_annotations, + ) + @staticmethod def bulk_delete(labels) -> None: """Deletes all the given Labels. diff --git a/libs/labelbox/src/labelbox/schema/label_version.py b/libs/labelbox/src/labelbox/schema/label_version.py new file mode 100644 index 000000000..6bca8cf4a --- /dev/null +++ b/libs/labelbox/src/labelbox/schema/label_version.py @@ -0,0 +1,221 @@ +"""Label version history. + +Labelbox records a version of a label at each hand-off: when the label is +submitted or saved from the editor, approved or rejected in a review step, +submitted from a rework step, or moved into a rework step. Each version holds +who made the hand-off, when, in which workflow step, and the label's +annotations at that moment. Autosaves and drafts record no versions, and +versions are never changed or deleted. + +The methods on :class:`labelbox.Client`, :class:`labelbox.schema.project.Project` +and :class:`labelbox.schema.label.Label` that return the classes below are thin +wrappers over the functions at the bottom of this module. +""" + +import json +from datetime import datetime, timezone +from enum import Enum +from typing import TYPE_CHECKING, Any, Dict, Optional, Union + +from labelbox.pagination import PaginatedCollection +from labelbox.utils import _CamelCaseMixin + +if TYPE_CHECKING: + from labelbox import Client + +# The API returns at most 100 versions per page. +_MAX_PAGE_SIZE = 100 + +DateTimeLike = Union[datetime, str] +"""A moment: a ``datetime`` (naive ones are read as UTC) or an ISO 8601 string.""" + + +class LabelVersionAction(str, Enum): + """The hand-off a label version records.""" + + #: The label was created from the editor: a labeler's submit. + SUBMITTED = "SUBMITTED" + #: An existing label was saved from the editor: a reviewer's edit in a + #: review step, a labeler's edit in a rework step, or a later save. + SAVED = "SAVED" + #: A reviewer approved the label in a review step. + APPROVED = "APPROVED" + #: A reviewer rejected the label in a review step. + REJECTED = "REJECTED" + #: The data row was submitted from a rework step. + REWORK_SUBMITTED = "REWORK_SUBMITTED" + #: The data row was moved into a rework step by hand. + SENT_TO_REWORK = "SENT_TO_REWORK" + + +class LabelVersionStage(_CamelCaseMixin): + """The workflow step a label version was recorded in, as it was then. + + Attributes: + task_queue_id (str): The step's task queue ID. + name (Optional[str]): The step's name when the version was recorded. + type (Optional[str]): The step's type, for example + ``"MANUAL_REVIEW_QUEUE"`` or ``"MANUAL_REWORK_QUEUE"``. + """ + + task_queue_id: str + name: Optional[str] = None + type: Optional[str] = None + + +class LabelVersion(_CamelCaseMixin): + """One recorded version of a label. + + Attributes: + id (str): The version's ID. It never changes. + label_id (str) + data_row_id (str) + project_id (str) + action (str): The hand-off (see :class:`LabelVersionAction`). + Unknown values are kept as strings. + created_at (datetime): When the version was recorded. + created_by_id (str): The user who made the hand-off: the labeler for + a submit, the reviewer for a review save or decision. + stage (Optional[LabelVersionStage]): The workflow step the data row + was in. ``None`` when it was in none (Done). + annotation_count (int): Number of annotations (features) in the + version. + label (Optional[str]): The annotations at that moment, as stringified + JSON in the same shape as :attr:`labelbox.schema.label.Label.label`. + Feature schema names are the ones in effect then. ``None`` when + read with ``include_annotations=False``. + label_json (Optional[dict]): ``label`` parsed. + """ + + id: str + label_id: str + data_row_id: str + project_id: str + action: Union[LabelVersionAction, str] + created_at: datetime + created_by_id: str + stage: Optional[LabelVersionStage] = None + annotation_count: int + label: Optional[str] = None + label_json: Optional[Dict[str, Any]] = None + + +_FIELDS = """ + id labelId dataRowId projectId action createdAt createdById + stage { taskQueueId name type } + annotationCount +""" +_ANNOTATION_FIELDS = "label labelJson" + + +def _fields(include_annotations: bool) -> str: + return _FIELDS + (_ANNOTATION_FIELDS if include_annotations else "") + + +def _to_id(name: str, value: Any) -> Optional[str]: + if value is None: + return None + uid = getattr(value, "uid", value) + if not isinstance(uid, str) or not uid: + raise ValueError(f"{name} must be an ID or an SDK object with one") + return uid + + +def _to_datetime( + name: str, value: Optional[DateTimeLike] +) -> Optional[datetime]: + if value is None: + return None + if isinstance(value, str): + try: + value = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as error: + raise ValueError( + f"{name} must be a datetime or an ISO 8601 string" + ) from error + if not isinstance(value, datetime): + raise ValueError(f"{name} must be a datetime or an ISO 8601 string") + if value.tzinfo is None: + value = value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + + +def _iso(value: Optional[datetime]) -> Optional[str]: + return ( + value.isoformat(timespec="milliseconds").replace("+00:00", "Z") + if value + else None + ) + + +def _to_version(result: Dict[str, Any]) -> LabelVersion: + label_json = result.get("labelJson") + if isinstance(label_json, str): + label_json = json.loads(label_json) + action = result.get("action") + if action in LabelVersionAction._value2member_map_: + action = LabelVersionAction(action) + return LabelVersion.model_validate( + {**result, "labelJson": label_json, "action": action} + ) + + +def get_label_versions( + client: "Client", + project_id: str, + label: Any = None, + data_row: Any = None, + created_by: Any = None, + created_after: Optional[DateTimeLike] = None, + created_before: Optional[DateTimeLike] = None, + include_annotations: bool = True, + page_size: int = _MAX_PAGE_SIZE, +) -> PaginatedCollection: + if not isinstance(page_size, int) or not 1 <= page_size <= _MAX_PAGE_SIZE: + raise ValueError(f"page_size must be between 1 and {_MAX_PAGE_SIZE}") + after = _to_datetime("created_after", created_after) + before = _to_datetime("created_before", created_before) + if after and before and after >= before: + raise ValueError("created_after must be before created_before") + where = { + key: value + for key, value in { + "projectId": _to_id("project", project_id), + "labelId": _to_id("label", label), + "dataRowId": _to_id("data_row", data_row), + "createdById": _to_id("created_by", created_by), + "createdAfter": _iso(after), + "createdBefore": _iso(before), + }.items() + if value is not None + } + query = """query GetLabelVersionsPyApi( + $where: LabelVersionsWhereInput!, $first: Int, $from: String + ) { + labelVersions(where: $where, first: $first, after: $from) { + nodes { %s } + nextCursor + } + }""" % _fields(include_annotations) + # The input object is a nested dict: PaginatedCollection only types + # scalars. + params: Dict[str, Any] = {"where": where, "first": page_size} + return PaginatedCollection( + client=client, + query=query, + params=params, + dereferencing=["labelVersions", "nodes"], + obj_class=lambda _, result: _to_version(result), + cursor_path=["labelVersions", "nextCursor"], + ) + + +def get_label_version( + client: "Client", version_id: str, include_annotations: bool = True +) -> Optional[LabelVersion]: + query = """query GetLabelVersionPyApi($id: ID!) { + labelVersion(where: { id: $id }) { %s } + }""" % _fields(include_annotations) + result = client.execute(query, {"id": _to_id("version_id", version_id)}) + version = (result or {}).get("labelVersion") + return _to_version(version) if version else None diff --git a/libs/labelbox/src/labelbox/schema/project.py b/libs/labelbox/src/labelbox/schema/project.py index c39433cf4..04b365c7a 100644 --- a/libs/labelbox/src/labelbox/schema/project.py +++ b/libs/labelbox/src/labelbox/schema/project.py @@ -53,6 +53,7 @@ LabelingService, LabelingServiceStatus, ) +from labelbox.schema import label_version as _label_version from labelbox.schema import performance as _performance from labelbox.schema.labeling_service_dashboard import LabelingServiceDashboard from labelbox.schema.media_type import MediaType @@ -339,6 +340,77 @@ def get_resource_tags(self) -> List[ResourceTag]: return [ResourceTag(self.client, tag) for tag in results] + def label_versions( + self, + label=None, + data_row=None, + created_by=None, + created_after: Optional[_label_version.DateTimeLike] = None, + created_before: Optional[_label_version.DateTimeLike] = None, + include_annotations: bool = True, + page_size: int = 100, + ) -> PaginatedCollection: + """Returns the recorded versions of this project's labels, oldest first. + + A version is recorded at each hand-off: when a label is submitted or + saved from the editor, approved or rejected in a review step, + submitted from a rework step, or moved into a rework step. Review and + rework edit the same label, so one label's versions show what each + reviewer and rework changed. Autosaves and drafts record none, there + are none from before recording was enabled for the project, and + versions are never changed or deleted. + + You see the versions of the labels you may read: only your own + without permission to see other users' labels, and only labels your + organization made in another organization's project without + permission to see all labels. + + Args: + label (str or Label): Optional. Only this label's versions. + data_row (str or DataRow): Optional. Only versions of labels on + this data row. + created_by (str or User): Optional. Only versions whose hand-off + this user made (the labeler or reviewer). + created_after (datetime or str): Optional. Only versions recorded + at or after this time. Naive datetimes are read as UTC. + created_before (datetime or str): Optional. Only versions recorded + before this time. + include_annotations (bool): Also read each version's annotations + (``label`` and ``label_json``). Defaults to True. + page_size (int): Versions fetched per request, at most 100. + + Returns: + PaginatedCollection of LabelVersion, fetched a page at a time as + you iterate. + + Raises: + lbox.exceptions.AuthorizationError: You may not read this + project's labels, or the API key does not allow it. + ValueError: A bad page size or time range. + + Example: + >>> for version in project.label_versions(label=label_id): + >>> print(version.action, version.created_by_id, + >>> version.stage and version.stage.name) + >>> + >>> # Everything reviewers did in October, without annotations. + >>> october = project.label_versions( + >>> created_after="2026-10-01T00:00:00Z", + >>> created_before="2026-11-01T00:00:00Z", + >>> include_annotations=False) + """ + return _label_version.get_label_versions( + self.client, + self.uid, + label=label, + data_row=data_row, + created_by=created_by, + created_after=created_after, + created_before=created_before, + include_annotations=include_annotations, + page_size=page_size, + ) + def labels( self, datasets=None, order_by=None, created_by=None ) -> PaginatedCollection: diff --git a/libs/labelbox/tests/unit/schema/test_label_version.py b/libs/labelbox/tests/unit/schema/test_label_version.py new file mode 100644 index 000000000..fc49c2918 --- /dev/null +++ b/libs/labelbox/tests/unit/schema/test_label_version.py @@ -0,0 +1,280 @@ +from datetime import datetime, timezone +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from labelbox import LabelVersion, LabelVersionAction, LabelVersionStage +from labelbox.schema import label_version +from labelbox.schema.label import Label +from labelbox.schema.project import Project + +PROJECT_ID = "cproject000000000000000001" + + +def node(index, **overrides): + return { + "id": f"cversion00000000000000000{index}", + "labelId": "clabel0000000000000000001", + "dataRowId": "cdatarow00000000000000001", + "projectId": PROJECT_ID, + "action": "SUBMITTED", + "createdAt": f"2026-10-19T10:00:0{index}.000Z", + "createdById": "clabeler000000000000000001", + "stage": { + "taskQueueId": "b1a2c3d4-0000-0000-0000-000000000001", + "name": "Initial labeling task", + "type": "INITIAL_LABELING_QUEUE", + }, + "annotationCount": 1, + "label": '{"objects":[],"classifications":[{"title":"Caption"}]}', + "labelJson": { + "objects": [], + "classifications": [{"title": "Caption"}], + }, + **overrides, + } + + +def client_returning(*pages): + """A client whose execute answers with ``pages`` in turn and keeps a copy + of each call's params in ``client.sent`` (the paginator reuses one dict).""" + client = MagicMock() + client.sent = [] + answers = list(pages) + + def execute(query, params, **_): + client.sent.append((query, dict(params))) + return answers.pop(0) + + client.execute.side_effect = execute + return client + + +def page(nodes, next_cursor=None): + return {"labelVersions": {"nodes": nodes, "nextCursor": next_cursor}} + + +class TestGetLabelVersions: + def test_pages_by_cursor_and_parses_versions(self): + client = client_returning( + page([node(1), node(2)], "cursor-1"), + page([node(3, action="APPROVED", stage=None)]), + ) + + versions = list( + label_version.get_label_versions(client, PROJECT_ID, page_size=2) + ) + + assert [v.id for v in versions] == [ + "cversion000000000000000001", + "cversion000000000000000002", + "cversion000000000000000003", + ] + first = versions[0] + assert isinstance(first, LabelVersion) + assert first.action is LabelVersionAction.SUBMITTED + assert first.created_at == datetime( + 2026, 10, 19, 10, 0, 1, tzinfo=timezone.utc + ) + assert first.created_by_id == "clabeler000000000000000001" + assert first.stage == LabelVersionStage( + task_queue_id="b1a2c3d4-0000-0000-0000-000000000001", + name="Initial labeling task", + type="INITIAL_LABELING_QUEUE", + ) + assert first.label_json == { + "objects": [], + "classifications": [{"title": "Caption"}], + } + assert versions[2].stage is None + assert versions[2].action is LabelVersionAction.APPROVED + + (query1, params1), (_, params2) = client.sent + assert "labelVersions(where: $where, first: $first, after: $from)" in ( + query1 + ) + assert "label labelJson" in query1 + assert params1 == { + "where": {"projectId": PROJECT_ID}, + "first": 2, + "from": None, + } + assert params2["from"] == "cursor-1" + + def test_sends_only_the_filters_given(self): + client = client_returning(page([])) + label = SimpleNamespace(uid="clabel0000000000000000001") + user = SimpleNamespace(uid="creviewer0000000000000001") + + list( + label_version.get_label_versions( + client, + PROJECT_ID, + label=label, + data_row="cdatarow00000000000000001", + created_by=user, + created_after=datetime(2026, 10, 1), + created_before="2026-11-01T02:00:00+02:00", + ) + ) + + [(_, params)] = client.sent + assert params["where"] == { + "projectId": PROJECT_ID, + "labelId": "clabel0000000000000000001", + "dataRowId": "cdatarow00000000000000001", + "createdById": "creviewer0000000000000001", + "createdAfter": "2026-10-01T00:00:00.000Z", + "createdBefore": "2026-11-01T00:00:00.000Z", + } + assert params["first"] == 100 + + def test_can_leave_annotations_out(self): + client = client_returning( + page( + [ + { + k: v + for k, v in node(1).items() + if k not in ("label", "labelJson") + } + ] + ) + ) + + [version] = list( + label_version.get_label_versions( + client, PROJECT_ID, include_annotations=False + ) + ) + + [(query, _)] = client.sent + assert "labelJson" not in query + assert version.label is None + assert version.label_json is None + + def test_keeps_an_unknown_action_as_a_string(self): + client = client_returning(page([node(1, action="NEW_ACTION")])) + + [version] = list(label_version.get_label_versions(client, PROJECT_ID)) + + assert version.action == "NEW_ACTION" + + def test_parses_label_json_sent_as_text(self): + client = client_returning(page([node(1, labelJson='{"objects": []}')])) + + [version] = list(label_version.get_label_versions(client, PROJECT_ID)) + + assert version.label_json == {"objects": []} + + @pytest.mark.parametrize("page_size", [0, 101, "10"]) + def test_refuses_a_bad_page_size(self, page_size): + with pytest.raises(ValueError, match="page_size"): + label_version.get_label_versions( + MagicMock(), PROJECT_ID, page_size=page_size + ) + + def test_refuses_an_empty_time_range(self): + with pytest.raises(ValueError, match="created_after must be before"): + label_version.get_label_versions( + MagicMock(), + PROJECT_ID, + created_after="2026-11-01T00:00:00Z", + created_before="2026-10-01T00:00:00Z", + ) + + def test_refuses_an_unreadable_time(self): + with pytest.raises(ValueError, match="created_after"): + label_version.get_label_versions( + MagicMock(), PROJECT_ID, created_after="last tuesday" + ) + + def test_refuses_an_empty_id(self): + with pytest.raises(ValueError, match="label"): + label_version.get_label_versions(MagicMock(), PROJECT_ID, label="") + + +class TestGetLabelVersion: + def test_reads_one_version(self): + client = MagicMock() + client.execute.return_value = {"labelVersion": node(1, action="SAVED")} + + version = label_version.get_label_version( + client, "cversion000000000000000001" + ) + + assert version is not None + assert version.action is LabelVersionAction.SAVED + (query, params), _ = client.execute.call_args + assert "labelVersion(where: { id: $id })" in query + assert params == {"id": "cversion000000000000000001"} + + def test_returns_none_when_unreadable(self): + client = MagicMock() + client.execute.return_value = {"labelVersion": None} + + assert label_version.get_label_version(client, "cmissing") is None + + +class TestEntryPoints: + def test_project_label_versions(self): + client = client_returning(page([node(1)])) + project = Project(client, {"id": PROJECT_ID, **_project_fields()}) + + versions = list( + project.label_versions(label="clabel0000000000000000001") + ) + + assert len(versions) == 1 + [(_, params)] = client.sent + assert params["where"] == { + "projectId": PROJECT_ID, + "labelId": "clabel0000000000000000001", + } + + def test_label_versions(self): + client = client_returning(page([node(1)])) + label = Label( + client, {"id": "clabel0000000000000000001", **_label_fields()} + ) + # The relationship is per instance; the label's project is one read. + label.project = lambda: SimpleNamespace(uid=PROJECT_ID) + + versions = list(label.versions(include_annotations=False)) + + assert len(versions) == 1 + [(query, params)] = client.sent + assert params["where"] == { + "projectId": PROJECT_ID, + "labelId": "clabel0000000000000000001", + } + assert "labelJson" not in query + + def test_client_get_label_version(self): + from labelbox import Client + + client = MagicMock(spec=Client) + client.execute.return_value = {"labelVersion": node(1)} + + version = Client.get_label_version(client, "cversion000000000000000001") + + assert version is not None and version.label_id == ( + "clabel0000000000000000001" + ) + + +def _project_fields(): + return { + field.graphql_name: None + for field in Project.fields() + if field.graphql_name != "id" + } + + +def _label_fields(): + return { + field.graphql_name: None + for field in Label.fields() + if field.graphql_name != "id" + }